diff --git a/src/SharpSync/Database/SqliteSyncDatabase.cs b/src/SharpSync/Database/SqliteSyncDatabase.cs
index bbbbfa5..44bf36a 100644
--- a/src/SharpSync/Database/SqliteSyncDatabase.cs
+++ b/src/SharpSync/Database/SqliteSyncDatabase.cs
@@ -4,33 +4,61 @@
namespace Oire.SharpSync.Database;
///
-/// SQLite implementation of the sync database
+/// SQLite implementation of the sync database for tracking file synchronization state.
///
+///
+/// This class provides persistent storage for sync state using SQLite. It tracks file metadata,
+/// sync status, and conflict information. The database is automatically created if it doesn't exist.
+///
public class SqliteSyncDatabase: ISyncDatabase {
private readonly string _databasePath;
private SQLiteAsyncConnection? _connection;
private bool _disposed;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The full path to the SQLite database file.
+ /// Thrown when databasePath is null.
public SqliteSyncDatabase(string databasePath) {
- _databasePath = databasePath ?? throw new ArgumentNullException(nameof(databasePath));
+ ArgumentNullException.ThrowIfNull(databasePath);
+ _databasePath = databasePath;
}
+ ///
+ /// Initializes the database connection and creates necessary tables and indexes.
+ ///
+ /// Cancellation token to cancel the operation.
+ ///
+ /// This method must be called before using any other database operations.
+ /// It creates the directory if it doesn't exist and sets up the database schema.
+ ///
public async Task InitializeAsync(CancellationToken cancellationToken = default) {
var directory = Path.GetDirectoryName(_databasePath);
- if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) {
Directory.CreateDirectory(directory);
+ }
_connection = new SQLiteAsyncConnection(_databasePath);
await _connection.CreateTableAsync();
- await _connection.ExecuteAsync(@"
+ await _connection.ExecuteAsync("""
CREATE INDEX IF NOT EXISTS idx_syncstates_status
- ON SyncStates(Status)");
- await _connection.ExecuteAsync(@"
+ ON SyncStates(Status)
+ """);
+ await _connection.ExecuteAsync("""
CREATE INDEX IF NOT EXISTS idx_syncstates_lastsync
- ON SyncStates(LastSyncTime)");
+ ON SyncStates(LastSyncTime)
+ """);
}
+ ///
+ /// Gets the synchronization state for a specific file path.
+ ///
+ /// The file path to look up.
+ /// Cancellation token to cancel the operation.
+ /// The sync state if found; otherwise, null.
+ /// Thrown when the database is not initialized.
public async Task GetSyncStateAsync(string path, CancellationToken cancellationToken = default) {
EnsureInitialized();
return await _connection!.Table()
@@ -38,6 +66,16 @@ CREATE INDEX IF NOT EXISTS idx_syncstates_lastsync
.FirstOrDefaultAsync();
}
+ ///
+ /// Updates or inserts a synchronization state record.
+ ///
+ /// The sync state to update or insert.
+ /// Cancellation token to cancel the operation.
+ ///
+ /// If the state has Id = 0, it will be inserted as a new record.
+ /// Otherwise, the existing record will be updated.
+ ///
+ /// Thrown when the database is not initialized.
public async Task UpdateSyncStateAsync(SyncState state, CancellationToken cancellationToken = default) {
EnsureInitialized();
@@ -67,6 +105,12 @@ public async Task> GetPendingSyncStatesAsync(Cancellation
.ToListAsync();
}
+ ///
+ /// Begins a database transaction for atomic operations.
+ ///
+ /// Cancellation token to cancel the operation.
+ /// A transaction object that can be used to commit or rollback changes.
+ /// Thrown when the database is not initialized.
public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) {
EnsureInitialized();
await Task.CompletedTask; // This method returns immediately but needs to be async for interface consistency
@@ -115,8 +159,9 @@ public async Task GetStatsAsync(CancellationToken cancellationTok
}
private void EnsureInitialized() {
- if (_connection == null)
+ if (_connection is null) {
throw new InvalidOperationException("Database not initialized. Call InitializeAsync first.");
+ }
}
public void Dispose() {
diff --git a/src/SharpSync/SharpSync.csproj b/src/SharpSync/SharpSync.csproj
index 6cefd52..1eb4829 100644
--- a/src/SharpSync/SharpSync.csproj
+++ b/src/SharpSync/SharpSync.csproj
@@ -25,6 +25,8 @@
file-sync;synchronization;webdav;sftp;nextcloud;owncloud;backup;sync
1.0.0
false
+
+ $(NoWarn);NETSDK1206
diff --git a/src/SharpSync/Storage/LocalFileStorage.cs b/src/SharpSync/Storage/LocalFileStorage.cs
index 6412363..fc7f8d5 100644
--- a/src/SharpSync/Storage/LocalFileStorage.cs
+++ b/src/SharpSync/Storage/LocalFileStorage.cs
@@ -13,21 +13,24 @@ public class LocalFileStorage: ISyncStorage {
public string RootPath => _rootPath;
public LocalFileStorage(string rootPath) {
- if (string.IsNullOrWhiteSpace(rootPath))
+ if (string.IsNullOrWhiteSpace(rootPath)) {
throw new ArgumentException("Root path cannot be empty", nameof(rootPath));
+ }
_rootPath = Path.GetFullPath(rootPath);
- if (!Directory.Exists(_rootPath))
+ if (!Directory.Exists(_rootPath)) {
throw new DirectoryNotFoundException($"Root path does not exist: {_rootPath}");
+ }
}
public async Task> ListItemsAsync(string path, CancellationToken cancellationToken = default) {
var fullPath = GetFullPath(path);
var items = new List();
- if (!Directory.Exists(fullPath))
+ if (!Directory.Exists(fullPath)) {
return items;
+ }
// Get directories
foreach (var dir in Directory.EnumerateDirectories(fullPath)) {
diff --git a/src/SharpSync/Storage/WebDavStorage.cs b/src/SharpSync/Storage/WebDavStorage.cs
index 5e18ead..e97e1e5 100644
--- a/src/SharpSync/Storage/WebDavStorage.cs
+++ b/src/SharpSync/Storage/WebDavStorage.cs
@@ -55,8 +55,9 @@ public WebDavStorage(
int chunkSizeBytes = 10 * 1024 * 1024, // 10MB
int maxRetries = 3,
int timeoutSeconds = 300) {
- if (string.IsNullOrWhiteSpace(baseUrl))
+ if (string.IsNullOrWhiteSpace(baseUrl)) {
throw new ArgumentException("Base URL cannot be empty", nameof(baseUrl));
+ }
_baseUrl = baseUrl.TrimEnd('/');
_rootPath = rootPath.Trim('/');
diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs
index b7428cd..d4d6dda 100644
--- a/src/SharpSync/Sync/SyncEngine.cs
+++ b/src/SharpSync/Sync/SyncEngine.cs
@@ -5,7 +5,7 @@
namespace Oire.SharpSync.Sync;
///
-/// Production-ready sync engine with incremental sync, change detection, and parallel processing
+/// Sync engine with incremental sync, change detection, and parallel processing
/// Optimized for large file sets and efficient synchronization
///
public class SyncEngine: ISyncEngine {
@@ -59,12 +59,19 @@ public SyncEngine(
IConflictResolver conflictResolver,
int maxParallelism = 4,
bool useChecksums = false,
- TimeSpan? changeDetectionWindow = null) {
- _localStorage = localStorage ?? throw new ArgumentNullException(nameof(localStorage));
- _remoteStorage = remoteStorage ?? throw new ArgumentNullException(nameof(remoteStorage));
- _database = database ?? throw new ArgumentNullException(nameof(database));
- _filter = filter ?? throw new ArgumentNullException(nameof(filter));
- _conflictResolver = conflictResolver ?? throw new ArgumentNullException(nameof(conflictResolver));
+ TimeSpan? changeDetectionWindow = null
+ ) {
+ ArgumentNullException.ThrowIfNull(localStorage);
+ ArgumentNullException.ThrowIfNull(remoteStorage);
+ ArgumentNullException.ThrowIfNull(database);
+ ArgumentNullException.ThrowIfNull(filter);
+ ArgumentNullException.ThrowIfNull(conflictResolver);
+
+ _localStorage = localStorage;
+ _remoteStorage = remoteStorage;
+ _database = database;
+ _filter = filter;
+ _conflictResolver = conflictResolver;
_maxParallelism = Math.Max(1, maxParallelism);
_useChecksums = useChecksums;
@@ -86,60 +93,68 @@ public SyncEngine(
}
///
- /// Performs incremental synchronization
+ /// Performs incremental synchronization between local and remote storage.
///
+ /// Optional synchronization options including dry run mode and conflict resolution settings.
+ /// Cancellation token to cancel the operation.
+ /// A containing synchronization statistics and any errors that occurred.
+ /// Thrown when the sync engine has been disposed.
+ /// Thrown when synchronization is already in progress.
+ /// Thrown when the operation is cancelled.
public async Task SynchronizeAsync(SyncOptions? options = null, CancellationToken cancellationToken = default) {
- if (_disposed)
+ if (_disposed) {
throw new ObjectDisposedException(nameof(SyncEngine));
+ }
- if (!await _syncSemaphore.WaitAsync(0, cancellationToken))
+ if (!await _syncSemaphore.WaitAsync(0, cancellationToken)) {
throw new InvalidOperationException("Synchronization is already in progress");
+ }
try {
- using (_currentSyncCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) {
- var syncToken = _currentSyncCts.Token;
- var result = new SyncResult();
- var sw = Stopwatch.StartNew();
-
- try {
- // Phase 1: Fast change detection
- RaiseProgress(new SyncProgress { CurrentItem = "Detecting changes..." }, SyncOperation.Scanning);
- var changes = await DetectChangesAsync(options, syncToken);
-
- if (changes.TotalChanges == 0) {
- result.Success = true;
- result.Details = "No changes detected";
- return result;
- }
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ _currentSyncCts = linkedCts;
+ var syncToken = linkedCts.Token;
+ var result = new SyncResult();
+ var sw = Stopwatch.StartNew();
- // Phase 2: Process changes (respecting dry run mode)
- RaiseProgress(new SyncProgress { TotalItems = changes.TotalChanges }, SyncOperation.Unknown);
+ try {
+ // Phase 1: Fast change detection
+ RaiseProgress(new SyncProgress { CurrentItem = "Detecting changes..." }, SyncOperation.Scanning);
+ var changes = await DetectChangesAsync(options, syncToken);
- if (options?.DryRun != true) {
- await ProcessChangesAsync(changes, options, result, syncToken);
+ if (changes.TotalChanges == 0) {
+ result.Success = true;
+ result.Details = "No changes detected";
+ return result;
+ }
- // Phase 3: Update database state
- await UpdateDatabaseStateAsync(changes, syncToken);
- } else {
- // Dry run - just count what would be done
- result.FilesSynchronized = changes.Additions.Count + changes.Modifications.Count;
- result.FilesDeleted = changes.Deletions.Count;
- result.Details = $"Dry run: Would sync {result.FilesSynchronized} files, delete {result.FilesDeleted}";
- }
+ // Phase 2: Process changes (respecting dry run mode)
+ RaiseProgress(new SyncProgress { TotalItems = changes.TotalChanges }, SyncOperation.Unknown);
- result.Success = true;
- } catch (OperationCanceledException) {
- result.Error = new InvalidOperationException("Synchronization was cancelled");
- throw;
- } catch (Exception ex) {
- result.Error = ex;
- result.Details = ex.Message;
- } finally {
- result.ElapsedTime = sw.Elapsed;
+ if (options?.DryRun != true) {
+ await ProcessChangesAsync(changes, options, result, syncToken);
+
+ // Phase 3: Update database state
+ await UpdateDatabaseStateAsync(changes, syncToken);
+ } else {
+ // Dry run - just count what would be done
+ result.FilesSynchronized = changes.Additions.Count + changes.Modifications.Count;
+ result.FilesDeleted = changes.Deletions.Count;
+ result.Details = $"Dry run: Would sync {result.FilesSynchronized} files, delete {result.FilesDeleted}";
}
- return result;
+ result.Success = true;
+ } catch (OperationCanceledException) {
+ result.Error = new InvalidOperationException("Synchronization was cancelled");
+ throw;
+ } catch (Exception ex) {
+ result.Error = ex;
+ result.Details = ex.Message;
+ } finally {
+ result.ElapsedTime = sw.Elapsed;
}
+
+ return result;
} finally {
_currentSyncCts = null;
_syncSemaphore.Release();
@@ -147,8 +162,15 @@ public async Task SynchronizeAsync(SyncOptions? options = null, Canc
}
///
- /// Preview what would be synchronized without making changes
+ /// Previews what would be synchronized without making any actual changes (dry run mode).
///
+ /// Optional synchronization options. DryRun will be forced to true.
+ /// Cancellation token to cancel the operation.
+ /// A containing information about what changes would be made.
+ ///
+ /// This method is useful for showing users what changes will be made before actually synchronizing.
+ /// It performs full change detection but does not modify any files or the sync state database.
+ ///
public async Task PreviewSyncAsync(SyncOptions? options = null, CancellationToken cancellationToken = default) {
var previewOptions = options?.Clone() ?? new SyncOptions();
previewOptions.DryRun = true;
@@ -210,8 +232,9 @@ private async Task ScanDirectoryRecursiveAsync(
var tasks = new List();
foreach (var item in items) {
- if (!_filter.ShouldSync(item.Path))
+ if (!_filter.ShouldSync(item.Path)) {
continue;
+ }
changeSet.ProcessedPaths.Add(item.Path);
@@ -256,20 +279,24 @@ private async Task ScanDirectoryRecursiveAsync(
private async Task HasChangedAsync(ISyncStorage storage, SyncItem item, SyncState tracked, bool isLocal, CancellationToken cancellationToken) {
if (isLocal) {
// Check local changes
- if (tracked.LocalModified == null)
+ if (tracked.LocalModified is null) {
return true; // Was deleted, now exists
+ }
// Use ETag if available (fast)
- if (!string.IsNullOrEmpty(item.ETag) && item.ETag == tracked.LocalHash)
+ if (!string.IsNullOrEmpty(item.ETag) && item.ETag == tracked.LocalHash) {
return false;
+ }
// Check modification time (considering detection window)
- if (Math.Abs((item.LastModified - tracked.LocalModified.Value).TotalMilliseconds) > _changeDetectionWindow.TotalMilliseconds)
+ if (Math.Abs((item.LastModified - tracked.LocalModified.Value).TotalMilliseconds) > _changeDetectionWindow.TotalMilliseconds) {
return true;
+ }
// Check size
- if (item.Size != tracked.LocalSize)
+ if (item.Size != tracked.LocalSize) {
return true;
+ }
// If using checksums, compute and compare
if (_useChecksums && !item.IsDirectory) {
@@ -280,20 +307,24 @@ private async Task HasChangedAsync(ISyncStorage storage, SyncItem item, Sy
return false;
} else {
// Check remote changes
- if (tracked.RemoteModified == null)
+ if (tracked.RemoteModified is null) {
return true; // Was deleted, now exists
+ }
// Use ETag if available (fast)
- if (!string.IsNullOrEmpty(item.ETag) && item.ETag == tracked.RemoteHash)
+ if (!string.IsNullOrEmpty(item.ETag) && item.ETag == tracked.RemoteHash) {
return false;
+ }
// Check modification time
- if (Math.Abs((item.LastModified - tracked.RemoteModified.Value).TotalMilliseconds) > _changeDetectionWindow.TotalMilliseconds)
+ if (Math.Abs((item.LastModified - tracked.RemoteModified.Value).TotalMilliseconds) > _changeDetectionWindow.TotalMilliseconds) {
return true;
+ }
// Check size
- if (item.Size != tracked.RemoteSize)
+ if (item.Size != tracked.RemoteSize) {
return true;
+ }
// If using checksums, compute and compare
if (_useChecksums && !item.IsDirectory) {
@@ -384,7 +415,7 @@ private static ActionGroups AnalyzeAndPrioritizeChanges(ChangeSet changes) {
}
var action = CreateDeletionAction(deletion);
- if (action != null) {
+ if (action is not null) {
groups.Deletes.Add(action);
}
}
@@ -412,8 +443,9 @@ private static int CalculatePriority(SyncItem item) {
int priority = 0;
// Directories first
- if (item.IsDirectory)
+ if (item.IsDirectory) {
priority += 1000;
+ }
// Smaller files first (within same category)
priority += (int)(1000000 - Math.Min(item.Size / 1024, 999999));
@@ -473,8 +505,9 @@ private async Task ProcessPhase1_DirectoriesAndSmallFilesAsync(
CancellationToken cancellationToken) {
var allSmallActions = actionGroups.Directories.Concat(actionGroups.SmallFiles).ToList();
- if (allSmallActions.Count == 0)
+ if (allSmallActions.Count == 0) {
return;
+ }
// Use high parallelism for small operations
var parallelOptions = new ParallelOptions {
@@ -513,11 +546,12 @@ private async Task ProcessPhase2_LargeFilesAsync(
ProgressCounter progressCounter,
int totalChanges,
CancellationToken cancellationToken) {
- if (actionGroups.LargeFiles.Count == 0)
+ if (actionGroups.LargeFiles.Count == 0) {
return;
+ }
// Use reduced parallelism for large files to avoid bandwidth saturation
- var semaphore = new SemaphoreSlim(Math.Max(1, _maxParallelism / 2), Math.Max(1, _maxParallelism / 2));
+ using var semaphore = new SemaphoreSlim(Math.Max(1, _maxParallelism / 2), Math.Max(1, _maxParallelism / 2));
var tasks = new List();
foreach (var action in actionGroups.LargeFiles) {
@@ -525,7 +559,6 @@ private async Task ProcessPhase2_LargeFilesAsync(
}
await Task.WhenAll(tasks);
- semaphore.Dispose();
}
private async Task ProcessLargeFileAsync(
@@ -691,13 +724,15 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult
// Apply resolution
switch (resolution) {
case ConflictResolution.UseLocal:
- if (action.LocalItem != null)
+ if (action.LocalItem is not null) {
await UploadFileAsync(action, result, cancellationToken);
+ }
break;
case ConflictResolution.UseRemote:
- if (action.RemoteItem != null)
+ if (action.RemoteItem is not null) {
await DownloadFileAsync(action, result, cancellationToken);
+ }
break;
case ConflictResolution.Skip:
@@ -705,12 +740,12 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult
break;
case ConflictResolution.RenameLocal:
- // TODO: Implement rename logic
+ // TODO: Implement rename logic - create new file with modified name
result.IncrementFilesConflicted();
break;
case ConflictResolution.RenameRemote:
- // TODO: Implement rename logic
+ // TODO: Implement rename logic - create new file with modified name
result.IncrementFilesConflicted();
break;
@@ -791,24 +826,35 @@ private async Task UpdateDatabaseStateAsync(ChangeSet changes, CancellationToken
await Task.WhenAll(updates);
}
- private static SyncOperation GetOperationType(SyncActionType actionType) {
- return actionType switch {
- SyncActionType.Download => SyncOperation.Downloading,
- SyncActionType.Upload => SyncOperation.Uploading,
- SyncActionType.DeleteLocal or SyncActionType.DeleteRemote => SyncOperation.Deleting,
- SyncActionType.Conflict => SyncOperation.ResolvingConflict,
- _ => SyncOperation.Unknown
- };
- }
+ private static SyncOperation GetOperationType(SyncActionType actionType) => actionType switch {
+ SyncActionType.Download => SyncOperation.Downloading,
+ SyncActionType.Upload => SyncOperation.Uploading,
+ SyncActionType.DeleteLocal or SyncActionType.DeleteRemote => SyncOperation.Deleting,
+ SyncActionType.Conflict => SyncOperation.ResolvingConflict,
+ _ => SyncOperation.Unknown
+ };
private void RaiseProgress(SyncProgress progress, SyncOperation operation) {
ProgressChanged?.Invoke(this, new SyncProgressEventArgs(progress, progress.CurrentItem, operation));
}
+ ///
+ /// Gets synchronization database statistics including file counts and sizes.
+ ///
+ /// Cancellation token to cancel the operation.
+ /// Database statistics including total files, directories, and sizes.
public async Task GetStatsAsync(CancellationToken cancellationToken = default) {
return await _database.GetStatsAsync(cancellationToken);
}
+ ///
+ /// Resets the synchronization state by clearing all tracked file information from the database.
+ ///
+ /// Cancellation token to cancel the operation.
+ ///
+ /// Use this method with caution as it will force a full resynchronization on the next sync operation.
+ /// This is useful when the sync state becomes corrupted or when starting fresh.
+ ///
public async Task ResetSyncStateAsync(CancellationToken cancellationToken = default) {
await _database.ClearAsync(cancellationToken);
}
@@ -825,9 +871,9 @@ public void Dispose() {
#region Change Tracking Types
internal sealed class ChangeSet {
- public List Additions { get; } = new();
- public List Modifications { get; } = new();
- public List Deletions { get; } = new();
+ public List Additions { get; } = [];
+ public List Modifications { get; } = [];
+ public List Deletions { get; } = [];
public HashSet ProcessedPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
public int TotalChanges => Additions.Count + Modifications.Count + Deletions.Count;
@@ -878,11 +924,11 @@ internal sealed class SyncAction {
/// Organizes sync actions into optimized processing groups
///
internal sealed class ActionGroups {
- public List Directories { get; } = new();
- public List SmallFiles { get; } = new();
- public List LargeFiles { get; } = new();
- public List Deletes { get; } = new();
- public List Conflicts { get; } = new();
+ public List Directories { get; } = [];
+ public List SmallFiles { get; } = [];
+ public List LargeFiles { get; } = [];
+ public List Deletes { get; } = [];
+ public List Conflicts { get; } = [];
public void SortByPriority() {
Directories.Sort((a, b) => b.Priority.CompareTo(a.Priority));