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);
+ }
+}