diff --git a/CLAUDE.md b/CLAUDE.md index a6cf077..ee561e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,9 +122,13 @@ SharpSync is a **pure .NET file synchronization library** with no native depende - **Large File Support**: Chunked/multipart uploads with platform-specific optimizations - **Network Resilience**: Retry logic and error handling with automatic reconnection - **Parallel Processing**: Configurable parallelism with intelligent prioritization +- **Bandwidth Throttling**: Configurable transfer rate limits via `SyncOptions.MaxBytesPerSecond` +- **Virtual File Support**: Callback hook for Windows Cloud Files API placeholder integration +- **Structured Logging**: High-performance logging via `Microsoft.Extensions.Logging` ### Dependencies +- `Microsoft.Extensions.Logging.Abstractions` (9.0.1) - Logging abstraction - `sqlite-net-pcl` (1.9.172) - SQLite database - `SQLitePCLRaw.bundle_e_sqlite3` (3.0.2) - SQLite native binaries - `WebDav.Client` (2.9.0) - WebDAV protocol @@ -164,6 +168,7 @@ SharpSync is a **pure .NET file synchronization library** with no native depende │ ├── Auth/ # OAuth2 authentication │ ├── Core/ # Interfaces and models │ ├── Database/ # State persistence +│ ├── Logging/ # High-performance logging │ ├── Storage/ # Storage backends │ └── Sync/ # Sync engine ├── tests/ @@ -290,11 +295,16 @@ _watcher.EnableRaisingEvents = true; | No selective folder sync API | Can't sync single folders on demand | Planned: `SyncFolderAsync()`, `SyncFilesAsync()` | | No pause/resume | Long syncs can't be paused | Planned: `PauseAsync()`, `ResumeAsync()` | | No incremental change notification | FileSystemWatcher triggers full scan | Planned: `NotifyLocalChangeAsync()` | -| No virtual file awareness | Can't track placeholder vs downloaded | Planned: `VirtualFileCallback` | | Single-threaded engine | One sync at a time per instance | By design - create separate instances if needed | -| ~~No bandwidth throttling~~ | ~~Can saturate network~~ | ✅ `SyncOptions.MaxBytesPerSecond` IMPLEMENTED | | OCIS TUS not implemented | Falls back to generic upload | Planned for v1.0 | +### ✅ Resolved API Gaps + +| Feature | Implementation | +|---------|----------------| +| Bandwidth throttling | `SyncOptions.MaxBytesPerSecond` - limits transfer rate | +| Virtual file awareness | `SyncOptions.VirtualFileCallback` - hook for Windows Cloud Files API integration | + ### Required SharpSync API Additions (v1.0) These APIs are required for v1.0 release to support Nimbus desktop client: @@ -308,16 +318,19 @@ These APIs are required for v1.0 release to support Nimbus desktop client: 4. OCIS TUS protocol implementation (`WebDavStorage.cs:547` currently falls back) **Sync Control:** -5. ~~`SyncOptions.MaxBytesPerSecond`~~ ✅ Built-in bandwidth throttling (IMPLEMENTED) -6. `PauseAsync()` / `ResumeAsync()` - Pause and resume long-running syncs -7. `GetPendingOperationsAsync()` - Inspect sync queue for UI display +5. `PauseAsync()` / `ResumeAsync()` - Pause and resume long-running syncs +6. `GetPendingOperationsAsync()` - Inspect sync queue for UI display **Progress & History:** -8. Per-file progress events (currently only per-sync-operation) -9. `GetRecentOperationsAsync()` - Operation history for activity feed +7. Per-file progress events (currently only per-sync-operation) +8. `GetRecentOperationsAsync()` - Operation history for activity feed -**Virtual Files:** -10. `SyncOptions.VirtualFileCallback` - Hook for virtual file systems (Windows Cloud Files API) +**✅ Completed:** +- `SyncOptions.MaxBytesPerSecond` - Built-in bandwidth throttling +- `SyncOptions.VirtualFileCallback` - Hook for virtual file systems (Windows Cloud Files API) +- `SyncOptions.CreateVirtualFilePlaceholders` - Enable/disable virtual file placeholder creation +- `VirtualFileState` enum - Track placeholder state (None, Placeholder, Hydrated, Partial) +- `SyncPlanAction.WillCreateVirtualPlaceholder` - Preview which downloads will create placeholders ### API Readiness Score for Nimbus @@ -332,9 +345,9 @@ These APIs are required for v1.0 release to support Nimbus desktop client: | Conflict resolution | 9/10 | Rich analysis, extensible callbacks | | Selective sync | 4/10 | Filter-only, no folder/file API | | Pause/Resume | 2/10 | Not implemented | -| Desktop integration hooks | 6/10 | Good abstraction, missing some hooks | +| Desktop integration hooks | 8/10 | Virtual file callback, bandwidth throttling implemented | -**Current Overall: 7/10** - Solid foundation, gaps identified +**Current Overall: 7.25/10** - Solid foundation, key desktop hooks now available **Target for v1.0: 9.5/10** - All gaps resolved, ready for Nimbus development @@ -485,6 +498,9 @@ The core library is production-ready, but several critical items must be address - ✅ FTP/FTPS storage implementation - ✅ S3 storage implementation with AWS S3 and S3-compatible services - ✅ Integration test infrastructure with Docker for SFTP, FTP, and S3/LocalStack +- ✅ Bandwidth throttling (`SyncOptions.MaxBytesPerSecond`) +- ✅ Virtual file placeholder support (`SyncOptions.VirtualFileCallback`) for Windows Cloud Files API +- ✅ High-performance logging with `Microsoft.Extensions.Logging.Abstractions` **🚧 Required for v1.0 Release** @@ -504,7 +520,7 @@ Desktop Client APIs (for Nimbus): - [ ] `PauseAsync()` / `ResumeAsync()` - Pause and resume long-running syncs - [ ] `GetPendingOperationsAsync()` - Inspect sync queue for UI display - [ ] Per-file progress events (currently only per-sync-operation) -- [ ] `SyncOptions.VirtualFileCallback` - Hook for virtual file systems (Windows Cloud Files API) +- [x] `SyncOptions.VirtualFileCallback` - Hook for virtual file systems (Windows Cloud Files API) ✅ - [ ] `GetRecentOperationsAsync()` - Operation history for activity feed Performance & Polish: diff --git a/src/SharpSync/Core/SyncItem.cs b/src/SharpSync/Core/SyncItem.cs index fa2a7ac..7fdfd62 100644 --- a/src/SharpSync/Core/SyncItem.cs +++ b/src/SharpSync/Core/SyncItem.cs @@ -48,4 +48,23 @@ public class SyncItem { /// Gets or sets the MIME type /// public string? MimeType { get; set; } + + /// + /// Gets or sets the virtual file state for cloud file systems + /// + /// + /// Used by desktop clients integrating with Windows Cloud Files API or similar + /// virtual file systems. When , the file + /// exists only as metadata locally and content must be fetched on demand. + /// + public VirtualFileState VirtualState { get; set; } = VirtualFileState.None; + + /// + /// Gets or sets the cloud-specific item identifier for virtual file tracking + /// + /// + /// Platform-specific identifier used by cloud file systems to track the file. + /// For Windows Cloud Files API, this could be the CF_PLACEHOLDER_BASIC_INFO identifier. + /// + public string? CloudFileId { get; set; } } diff --git a/src/SharpSync/Core/SyncOptions.cs b/src/SharpSync/Core/SyncOptions.cs index 05534f3..7f1d67e 100644 --- a/src/SharpSync/Core/SyncOptions.cs +++ b/src/SharpSync/Core/SyncOptions.cs @@ -78,6 +78,35 @@ public class SyncOptions { /// public long? MaxBytesPerSecond { get; set; } + /// + /// Gets or sets whether to create virtual file placeholders after downloading files. + /// + /// + /// When enabled, the will be invoked after each + /// successful file download. This allows desktop clients to integrate with + /// platform-specific virtual file systems like Windows Cloud Files API. + /// + public bool CreateVirtualFilePlaceholders { get; set; } + + /// + /// Gets or sets the callback invoked after a file is downloaded to create a virtual file placeholder. + /// + /// + /// + /// This callback is only invoked when is true + /// and a file (not directory) is successfully downloaded to local storage. + /// + /// + /// The callback receives the relative path, full local path, and file metadata, + /// allowing the application to convert the file to a cloud files placeholder. + /// + /// + /// If the callback throws an exception, the error is logged but sync continues. + /// The file will remain fully hydrated (not converted to placeholder). + /// + /// + public VirtualFileCallbackDelegate? VirtualFileCallback { get; set; } + /// /// Creates a copy of the sync options /// @@ -96,7 +125,9 @@ public SyncOptions Clone() { ConflictResolution = ConflictResolution, TimeoutSeconds = TimeoutSeconds, ExcludePatterns = new List(ExcludePatterns), - MaxBytesPerSecond = MaxBytesPerSecond + MaxBytesPerSecond = MaxBytesPerSecond, + CreateVirtualFilePlaceholders = CreateVirtualFilePlaceholders, + VirtualFileCallback = VirtualFileCallback }; } } diff --git a/src/SharpSync/Core/SyncPlanAction.cs b/src/SharpSync/Core/SyncPlanAction.cs index 9a8743e..c7f35c9 100644 --- a/src/SharpSync/Core/SyncPlanAction.cs +++ b/src/SharpSync/Core/SyncPlanAction.cs @@ -48,9 +48,10 @@ public string Description { get { var sizeStr = IsDirectory ? "folder" : FormatSize(Size); var pathDisplay = IsDirectory ? $"{Path}/" : Path; + var placeholderSuffix = WillCreateVirtualPlaceholder ? " [placeholder]" : ""; return ActionType switch { - SyncActionType.Download => $"Download {pathDisplay}" + (IsDirectory ? "" : $" ({sizeStr})"), + SyncActionType.Download => $"Download {pathDisplay}" + (IsDirectory ? "" : $" ({sizeStr})") + placeholderSuffix, SyncActionType.Upload => $"Upload {pathDisplay}" + (IsDirectory ? "" : $" ({sizeStr})"), SyncActionType.DeleteLocal => $"Delete {pathDisplay} from local storage", SyncActionType.DeleteRemote => $"Delete {pathDisplay} from remote storage", @@ -69,6 +70,26 @@ public string Description { /// public int Priority { get; init; } + /// + /// Gets whether this download action will create a virtual file placeholder. + /// + /// + /// This is true when is enabled + /// and this action is a download operation for a file (not directory). + /// Desktop clients can use this to display placeholder indicators in their UI. + /// + public bool WillCreateVirtualPlaceholder { get; init; } + + /// + /// Gets the current virtual file state of the item (if applicable). + /// + /// + /// For existing local files, this indicates whether the file is currently + /// a placeholder, hydrated, or a regular file. Useful for displaying + /// file status in desktop client UIs. + /// + public VirtualFileState CurrentVirtualState { get; init; } + private static string FormatSize(long bytes) { if (bytes < 1024) { return $"{bytes} B"; diff --git a/src/SharpSync/Core/VirtualFileCallback.cs b/src/SharpSync/Core/VirtualFileCallback.cs new file mode 100644 index 0000000..bb2ad56 --- /dev/null +++ b/src/SharpSync/Core/VirtualFileCallback.cs @@ -0,0 +1,36 @@ +namespace Oire.SharpSync.Core; + +/// +/// Delegate for handling virtual file placeholder creation after a file is downloaded. +/// +/// The relative path of the downloaded file. +/// The full local filesystem path where the file was written. +/// Metadata about the downloaded file including size, modified time, etc. +/// Cancellation token to cancel the operation. +/// A task that completes when the virtual file placeholder is created. +/// +/// +/// This callback is invoked by the sync engine after a file is successfully downloaded +/// to the local storage. Desktop clients can use this hook to integrate with platform-specific +/// virtual file systems like Windows Cloud Files API. +/// +/// +/// Example usage with Windows Cloud Files API: +/// +/// options.VirtualFileCallback = async (path, fullPath, metadata, ct) => { +/// // Convert the downloaded file to a cloud files placeholder +/// await CloudFilesAPI.ConvertToPlaceholderAsync(fullPath, metadata.Size, ct); +/// }; +/// +/// +/// +/// If the callback throws an exception, the sync engine will log the error but continue +/// processing. The file will remain fully hydrated (not a placeholder) in that case. +/// +/// +public delegate Task VirtualFileCallbackDelegate( + string relativePath, + string localFullPath, + SyncItem fileMetadata, + CancellationToken cancellationToken +); diff --git a/src/SharpSync/Core/VirtualFileState.cs b/src/SharpSync/Core/VirtualFileState.cs new file mode 100644 index 0000000..ef2fe99 --- /dev/null +++ b/src/SharpSync/Core/VirtualFileState.cs @@ -0,0 +1,34 @@ +namespace Oire.SharpSync.Core; + +/// +/// Represents the virtual file state for cloud file systems (e.g., Windows Cloud Files API) +/// +public enum VirtualFileState { + /// + /// Not a virtual file - the file is fully present on disk + /// + None, + + /// + /// Placeholder file - only metadata is stored locally, content is on remote storage + /// + /// + /// The file appears in the file system but its content is not downloaded. + /// Accessing the file will trigger on-demand download (hydration). + /// + Placeholder, + + /// + /// Hydrated file - content has been downloaded and is available locally + /// + /// + /// The file was previously a placeholder but has been fully downloaded. + /// It may still be tracked by the cloud file system for dehydration. + /// + Hydrated, + + /// + /// Partially hydrated - some content is available locally (e.g., during streaming) + /// + Partial +} diff --git a/src/SharpSync/Logging/LogMessages.cs b/src/SharpSync/Logging/LogMessages.cs new file mode 100644 index 0000000..5ed5e1b --- /dev/null +++ b/src/SharpSync/Logging/LogMessages.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Logging; + +namespace Oire.SharpSync.Logging; + +/// +/// High-performance log messages using source generators. +/// +internal static partial class LogMessages { + [LoggerMessage( + EventId = 1, + Level = LogLevel.Warning, + Message = "Error scanning directory {DirectoryPath}")] + public static partial void DirectoryScanError(this ILogger logger, Exception ex, string directoryPath); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Warning, + Message = "Error processing {FilePath}")] + public static partial void ProcessingError(this ILogger logger, Exception ex, string filePath); + + [LoggerMessage( + EventId = 3, + Level = LogLevel.Warning, + Message = "Error processing large file {FilePath}")] + public static partial void LargeFileProcessingError(this ILogger logger, Exception ex, string filePath); + + [LoggerMessage( + EventId = 4, + Level = LogLevel.Warning, + Message = "Error resolving conflict for {FilePath}")] + public static partial void ConflictResolutionError(this ILogger logger, Exception ex, string filePath); + + [LoggerMessage( + EventId = 5, + Level = LogLevel.Warning, + Message = "Error deleting {FilePath}")] + public static partial void DeletionError(this ILogger logger, Exception ex, string filePath); + + [LoggerMessage( + EventId = 6, + Level = LogLevel.Warning, + Message = "Virtual file callback failed for {FilePath}")] + public static partial void VirtualFileCallbackError(this ILogger logger, Exception ex, string filePath); +} diff --git a/src/SharpSync/SharpSync.csproj b/src/SharpSync/SharpSync.csproj index 89f3e5f..ee402cc 100644 --- a/src/SharpSync/SharpSync.csproj +++ b/src/SharpSync/SharpSync.csproj @@ -34,6 +34,7 @@ + diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index 7aec035..fbdc217 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -1,6 +1,9 @@ using System.Collections.Concurrent; using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Oire.SharpSync.Core; +using Oire.SharpSync.Logging; using Oire.SharpSync.Storage; namespace Oire.SharpSync.Sync; @@ -15,6 +18,7 @@ public class SyncEngine: ISyncEngine { private readonly ISyncDatabase _database; private readonly ISyncFilter _filter; private readonly IConflictResolver _conflictResolver; + private readonly ILogger _logger; // Configuration private readonly int _maxParallelism; @@ -26,6 +30,7 @@ public class SyncEngine: ISyncEngine { private readonly SemaphoreSlim _syncSemaphore; private CancellationTokenSource? _currentSyncCts; private long? _currentMaxBytesPerSecond; + private SyncOptions? _currentOptions; /// /// Gets whether the engine is currently synchronizing @@ -50,6 +55,7 @@ public class SyncEngine: ISyncEngine { /// Sync state database /// File filter for selective sync /// Conflict resolution strategy + /// Optional logger for diagnostic output /// Maximum parallel operations (default: 4) /// Whether to use checksums for change detection (default: false) /// Time window for modification detection (default: 2 seconds) @@ -59,6 +65,7 @@ public SyncEngine( ISyncDatabase database, ISyncFilter filter, IConflictResolver conflictResolver, + ILogger? logger = null, int maxParallelism = 4, bool useChecksums = false, TimeSpan? changeDetectionWindow = null @@ -74,6 +81,7 @@ public SyncEngine( _database = database; _filter = filter; _conflictResolver = conflictResolver; + _logger = logger ?? NullLogger.Instance; _maxParallelism = Math.Max(1, maxParallelism); _useChecksums = useChecksums; @@ -82,17 +90,6 @@ public SyncEngine( _syncSemaphore = new SemaphoreSlim(1, 1); } - /// - /// Simplified constructor with default settings - /// - public SyncEngine( - ISyncStorage localStorage, - ISyncStorage remoteStorage, - ISyncDatabase database, - ISyncFilter filter, - IConflictResolver conflictResolver) - : this(localStorage, remoteStorage, database, filter, conflictResolver, 4, false, null) { - } /// /// Performs incremental synchronization between local and remote storage. @@ -116,6 +113,7 @@ public async Task SynchronizeAsync(SyncOptions? options = null, Canc using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _currentSyncCts = linkedCts; _currentMaxBytesPerSecond = options?.MaxBytesPerSecond; + _currentOptions = options; var syncToken = linkedCts.Token; var result = new SyncResult(); var sw = Stopwatch.StartNew(); @@ -161,6 +159,7 @@ public async Task SynchronizeAsync(SyncOptions? options = null, Canc } finally { _currentSyncCts = null; _currentMaxBytesPerSecond = null; + _currentOptions = null; _syncSemaphore.Release(); } } @@ -228,10 +227,14 @@ public async Task GetSyncPlanAsync(SyncOptions? options = null, Cancel .Concat(actionGroups.Conflicts) .OrderByDescending(a => a.Priority); + var willCreatePlaceholders = options?.CreateVirtualFilePlaceholders is true; + foreach (var action in allActions) { cancellationToken.ThrowIfCancellationRequested(); var item = action.LocalItem ?? action.RemoteItem; + var isDownload = action.Type == SyncActionType.Download; + var isFile = item?.IsDirectory is false; planActions.Add(new SyncPlanAction { ActionType = action.Type, @@ -240,7 +243,9 @@ public async Task GetSyncPlanAsync(SyncOptions? options = null, Cancel Size = item?.Size ?? 0, LastModified = item?.LastModified, ConflictType = action.Type == SyncActionType.Conflict ? action.ConflictType : null, - Priority = action.Priority + Priority = action.Priority, + WillCreateVirtualPlaceholder = willCreatePlaceholders && isDownload && isFile, + CurrentVirtualState = item?.VirtualState ?? VirtualFileState.None }); } @@ -409,7 +414,7 @@ CancellationToken cancellationToken } } catch (Exception ex) when (ex is not OperationCanceledException) { // Log error but continue scanning other directories - Debug.WriteLine($"Error scanning directory {dirPath}: {ex.Message}"); + _logger.DirectoryScanError(ex, dirPath); } } @@ -688,7 +693,7 @@ await Parallel.ForEachAsync(allSmallActions, parallelOptions, async (action, ct) } } catch (Exception ex) when (ex is not OperationCanceledException) { result.IncrementFilesSkipped(); - Debug.WriteLine($"Error processing {action.Path}: {ex.Message}"); + _logger.ProcessingError(ex, action.Path); } }); } @@ -746,7 +751,7 @@ private async Task ProcessLargeFileAsync( }, GetOperationType(action.Type)); } catch (Exception ex) when (ex is not OperationCanceledException) { result.IncrementFilesSkipped(); - Debug.WriteLine($"Error processing large file {action.Path}: {ex.Message}"); + _logger.LargeFileProcessingError(ex, action.Path); } finally { semaphore.Release(); } @@ -777,7 +782,7 @@ private async Task ProcessPhase3_DeletesAndConflictsAsync( }, SyncOperation.ResolvingConflict); } catch (Exception ex) when (ex is not OperationCanceledException) { result.IncrementFilesSkipped(); - Debug.WriteLine($"Error resolving conflict for {action.Path}: {ex.Message}"); + _logger.ConflictResolutionError(ex, action.Path); } } @@ -800,7 +805,7 @@ private async Task ProcessPhase3_DeletesAndConflictsAsync( }, SyncOperation.Deleting); } catch (Exception ex) when (ex is not OperationCanceledException) { result.IncrementFilesSkipped(); - Debug.WriteLine($"Error deleting {action.Path}: {ex.Message}"); + _logger.DeletionError(ex, action.Path); } } } @@ -836,11 +841,36 @@ private async Task DownloadFileAsync(SyncAction action, ThreadSafeSyncResult res using var remoteStream = await _remoteStorage.ReadFileAsync(action.Path, cancellationToken); var streamToRead = WrapWithThrottling(remoteStream); await _localStorage.WriteFileAsync(action.Path, streamToRead, cancellationToken); + + // Invoke virtual file callback if enabled + await TryInvokeVirtualFileCallbackAsync(action.Path, action.RemoteItem, cancellationToken); } result.IncrementFilesSynchronized(); } + /// + /// Invokes the virtual file callback if placeholders are enabled and callback is configured. + /// + private async Task TryInvokeVirtualFileCallbackAsync(string relativePath, SyncItem fileMetadata, CancellationToken cancellationToken) { + if (_currentOptions?.CreateVirtualFilePlaceholders is not true || _currentOptions.VirtualFileCallback is null) { + return; + } + + try { + // Construct the full local path from the storage root and relative path + var localFullPath = Path.Combine(_localStorage.RootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + + await _currentOptions.VirtualFileCallback(relativePath, localFullPath, fileMetadata, cancellationToken); + + // Update the item's virtual state to indicate it's now a placeholder + fileMetadata.VirtualState = VirtualFileState.Placeholder; + } catch (Exception ex) { + // Log but don't fail the sync - the file remains fully hydrated + _logger.VirtualFileCallbackError(ex, relativePath); + } + } + private async Task UploadFileAsync(SyncAction action, ThreadSafeSyncResult result, CancellationToken cancellationToken) { if (action.LocalItem!.IsDirectory) { await _remoteStorage.CreateDirectoryAsync(action.Path, cancellationToken); diff --git a/tests/SharpSync.Tests/Sync/VirtualFileCallbackTests.cs b/tests/SharpSync.Tests/Sync/VirtualFileCallbackTests.cs new file mode 100644 index 0000000..176d84b --- /dev/null +++ b/tests/SharpSync.Tests/Sync/VirtualFileCallbackTests.cs @@ -0,0 +1,341 @@ +using Oire.SharpSync.Core; +using Oire.SharpSync.Database; +using Oire.SharpSync.Storage; +using Oire.SharpSync.Sync; + +namespace Oire.SharpSync.Tests.Sync; + +public class VirtualFileCallbackTests: IDisposable { + private readonly string _localRootPath; + private readonly string _remoteRootPath; + private readonly string _dbPath; + private readonly LocalFileStorage _localStorage; + private readonly LocalFileStorage _remoteStorage; + private readonly SqliteSyncDatabase _database; + + public VirtualFileCallbackTests() { + _localRootPath = Path.Combine(Path.GetTempPath(), "SharpSyncTests", "VirtualFile", "Local", Guid.NewGuid().ToString()); + _remoteRootPath = Path.Combine(Path.GetTempPath(), "SharpSyncTests", "VirtualFile", "Remote", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_localRootPath); + Directory.CreateDirectory(_remoteRootPath); + + _dbPath = Path.Combine(Path.GetTempPath(), "SharpSyncTests", $"sync_vf_{Guid.NewGuid()}.db"); + _localStorage = new LocalFileStorage(_localRootPath); + _remoteStorage = new LocalFileStorage(_remoteRootPath); + _database = new SqliteSyncDatabase(_dbPath); + _database.InitializeAsync().GetAwaiter().GetResult(); + } + + public void Dispose() { + _database?.Dispose(); + + if (Directory.Exists(_localRootPath)) { + Directory.Delete(_localRootPath, recursive: true); + } + + if (Directory.Exists(_remoteRootPath)) { + Directory.Delete(_remoteRootPath, recursive: true); + } + + if (File.Exists(_dbPath)) { + File.Delete(_dbPath); + } + + GC.SuppressFinalize(this); + } + + [Fact] + public async Task SynchronizeAsync_WithVirtualFileCallback_InvokesCallbackAfterDownload() { + // Arrange + var callbackInvocations = new List<(string RelativePath, string LocalFullPath, SyncItem Metadata)>(); + + var options = new SyncOptions { + CreateVirtualFilePlaceholders = true, + VirtualFileCallback = (relativePath, localFullPath, metadata, ct) => { + callbackInvocations.Add((relativePath, localFullPath, metadata)); + return Task.CompletedTask; + } + }; + + // Create a file on the remote side to be downloaded + var remoteFilePath = Path.Combine(_remoteRootPath, "document.txt"); + await File.WriteAllTextAsync(remoteFilePath, "Hello, Virtual World!"); + + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var syncEngine = new SyncEngine(_localStorage, _remoteStorage, _database, filter, conflictResolver); + + // Act + var result = await syncEngine.SynchronizeAsync(options); + + // Assert + Assert.True(result.Success); + Assert.Single(callbackInvocations); + Assert.Equal("document.txt", callbackInvocations[0].RelativePath); + Assert.Equal(Path.Combine(_localRootPath, "document.txt"), callbackInvocations[0].LocalFullPath); + Assert.Equal(21, callbackInvocations[0].Metadata.Size); // "Hello, Virtual World!".Length + } + + [Fact] + public async Task SynchronizeAsync_WithVirtualFileCallbackDisabled_DoesNotInvokeCallback() { + // Arrange + var callbackInvoked = false; + + var options = new SyncOptions { + CreateVirtualFilePlaceholders = false, + VirtualFileCallback = (_, _, _, _) => { + callbackInvoked = true; + return Task.CompletedTask; + } + }; + + // Create a file on the remote side + var remoteFilePath = Path.Combine(_remoteRootPath, "document.txt"); + await File.WriteAllTextAsync(remoteFilePath, "Test content"); + + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var syncEngine = new SyncEngine(_localStorage, _remoteStorage, _database, filter, conflictResolver); + + // Act + await syncEngine.SynchronizeAsync(options); + + // Assert + Assert.False(callbackInvoked); + } + + [Fact] + public async Task SynchronizeAsync_WithNullCallback_DoesNotThrow() { + // Arrange + var options = new SyncOptions { + CreateVirtualFilePlaceholders = true, + VirtualFileCallback = null + }; + + var remoteFilePath = Path.Combine(_remoteRootPath, "document.txt"); + await File.WriteAllTextAsync(remoteFilePath, "Test content"); + + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var syncEngine = new SyncEngine(_localStorage, _remoteStorage, _database, filter, conflictResolver); + + // Act + var result = await syncEngine.SynchronizeAsync(options); + + // Assert + Assert.True(result.Success); + Assert.True(File.Exists(Path.Combine(_localRootPath, "document.txt"))); + } + + [Fact] + public async Task SynchronizeAsync_CallbackThrowsException_ContinuesSyncWithoutFailing() { + // Arrange + var callbackInvocationCount = 0; + + var options = new SyncOptions { + CreateVirtualFilePlaceholders = true, + VirtualFileCallback = (_, _, _, _) => { + callbackInvocationCount++; + throw new InvalidOperationException("Simulated callback failure"); + } + }; + + // Create files on the remote side + await File.WriteAllTextAsync(Path.Combine(_remoteRootPath, "file1.txt"), "Content 1"); + await File.WriteAllTextAsync(Path.Combine(_remoteRootPath, "file2.txt"), "Content 2"); + + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var syncEngine = new SyncEngine(_localStorage, _remoteStorage, _database, filter, conflictResolver); + + // Act + var result = await syncEngine.SynchronizeAsync(options); + + // Assert + Assert.True(result.Success); + Assert.Equal(2, callbackInvocationCount); + Assert.True(File.Exists(Path.Combine(_localRootPath, "file1.txt"))); + Assert.True(File.Exists(Path.Combine(_localRootPath, "file2.txt"))); + } + + [Fact] + public async Task SynchronizeAsync_DirectoryDownload_DoesNotInvokeCallback() { + // Arrange + var callbackInvoked = false; + + var options = new SyncOptions { + CreateVirtualFilePlaceholders = true, + VirtualFileCallback = (_, _, _, _) => { + callbackInvoked = true; + return Task.CompletedTask; + } + }; + + // Create only a directory on the remote side (no files) + Directory.CreateDirectory(Path.Combine(_remoteRootPath, "subdir")); + + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var syncEngine = new SyncEngine(_localStorage, _remoteStorage, _database, filter, conflictResolver); + + // Act + await syncEngine.SynchronizeAsync(options); + + // Assert - callback should not be invoked for directories + Assert.False(callbackInvoked); + Assert.True(Directory.Exists(Path.Combine(_localRootPath, "subdir"))); + } + + [Fact] + public async Task SynchronizeAsync_UploadOperation_DoesNotInvokeCallback() { + // Arrange + var callbackInvoked = false; + + var options = new SyncOptions { + CreateVirtualFilePlaceholders = true, + VirtualFileCallback = (_, _, _, _) => { + callbackInvoked = true; + return Task.CompletedTask; + } + }; + + // Create a file on the local side (will be uploaded, not downloaded) + await File.WriteAllTextAsync(Path.Combine(_localRootPath, "local-file.txt"), "Local content"); + + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var syncEngine = new SyncEngine(_localStorage, _remoteStorage, _database, filter, conflictResolver); + + // Act + await syncEngine.SynchronizeAsync(options); + + // Assert - callback should not be invoked for uploads + Assert.False(callbackInvoked); + } + + [Fact] + public async Task GetSyncPlanAsync_WithVirtualFilePlaceholders_SetsWillCreateVirtualPlaceholder() { + // Arrange + var options = new SyncOptions { + CreateVirtualFilePlaceholders = true + }; + + // Create a file on the remote side (will be planned for download) + await File.WriteAllTextAsync(Path.Combine(_remoteRootPath, "remote-file.txt"), "Remote content"); + + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var syncEngine = new SyncEngine(_localStorage, _remoteStorage, _database, filter, conflictResolver); + + // Act + var plan = await syncEngine.GetSyncPlanAsync(options); + + // Assert + Assert.Single(plan.Actions); + var action = plan.Actions[0]; + Assert.Equal(SyncActionType.Download, action.ActionType); + Assert.True(action.WillCreateVirtualPlaceholder); + } + + [Fact] + public async Task GetSyncPlanAsync_WithoutVirtualFilePlaceholders_DoesNotSetWillCreateVirtualPlaceholder() { + // Arrange + var options = new SyncOptions { + CreateVirtualFilePlaceholders = false + }; + + // Create a file on the remote side + await File.WriteAllTextAsync(Path.Combine(_remoteRootPath, "remote-file.txt"), "Remote content"); + + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var syncEngine = new SyncEngine(_localStorage, _remoteStorage, _database, filter, conflictResolver); + + // Act + var plan = await syncEngine.GetSyncPlanAsync(options); + + // Assert + Assert.Single(plan.Actions); + var action = plan.Actions[0]; + Assert.Equal(SyncActionType.Download, action.ActionType); + Assert.False(action.WillCreateVirtualPlaceholder); + } + + [Fact] + public async Task GetSyncPlanAsync_DirectoryDownload_DoesNotSetWillCreateVirtualPlaceholder() { + // Arrange + var options = new SyncOptions { + CreateVirtualFilePlaceholders = true + }; + + // Create only a directory on the remote side + Directory.CreateDirectory(Path.Combine(_remoteRootPath, "subdir")); + + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var syncEngine = new SyncEngine(_localStorage, _remoteStorage, _database, filter, conflictResolver); + + // Act + var plan = await syncEngine.GetSyncPlanAsync(options); + + // Assert + Assert.Single(plan.Actions); + var action = plan.Actions[0]; + Assert.True(action.IsDirectory); + Assert.False(action.WillCreateVirtualPlaceholder); + } + + [Fact] + public void SyncPlanAction_Description_IncludesPlaceholderIndicator() { + // Arrange + var action = new SyncPlanAction { + ActionType = SyncActionType.Download, + Path = "document.pdf", + IsDirectory = false, + Size = 1024 * 1024, // 1 MB + WillCreateVirtualPlaceholder = true + }; + + // Act + var description = action.Description; + + // Assert + Assert.Contains("[placeholder]", description); + Assert.Contains("Download document.pdf", description); + } + + [Fact] + public void SyncPlanAction_Description_ExcludesPlaceholderIndicatorWhenNotSet() { + // Arrange + var action = new SyncPlanAction { + ActionType = SyncActionType.Download, + Path = "document.pdf", + IsDirectory = false, + Size = 1024 * 1024, + WillCreateVirtualPlaceholder = false + }; + + // Act + var description = action.Description; + + // Assert + Assert.DoesNotContain("[placeholder]", description); + } + + [Fact] + public void SyncOptions_Clone_PreservesVirtualFileSettings() { + // Arrange + VirtualFileCallbackDelegate callback = (_, _, _, _) => Task.CompletedTask; + var original = new SyncOptions { + CreateVirtualFilePlaceholders = true, + VirtualFileCallback = callback + }; + + // Act + var cloned = original.Clone(); + + // Assert + Assert.True(cloned.CreateVirtualFilePlaceholders); + Assert.Same(callback, cloned.VirtualFileCallback); + } +}