diff --git a/SharpTools.Tools.Tests/CancellationTokenUtils.cs b/SharpTools.Tools.Tests/CancellationTokenUtils.cs new file mode 100644 index 0000000..51cab62 --- /dev/null +++ b/SharpTools.Tools.Tests/CancellationTokenUtils.cs @@ -0,0 +1,30 @@ +using System.Collections.Immutable; + +namespace SharpTools.Tools.Tests; +public static class CancellationTokenUtils +{ + public static TimeoutWatcher ApplyTimeout(int seconds, ref CancellationToken cancellationToken) + { + var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(seconds)); + + var ct = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token); + + cancellationToken = ct.Token; + + return new TimeoutWatcher(cts.Token, [cts, ct]); + } + + public record TimeoutWatcher( + CancellationToken CancellationToken, + ImmutableArray Disposables + ) : IDisposable + { + public bool TimedOut => CancellationToken.IsCancellationRequested; + + public void Dispose() { + foreach(var disposable in Disposables) + disposable.Dispose(); + } + } +} diff --git a/SharpTools.Tools.Tests/Retry.cs b/SharpTools.Tools.Tests/Retry.cs new file mode 100644 index 0000000..9b412eb --- /dev/null +++ b/SharpTools.Tools.Tests/Retry.cs @@ -0,0 +1,80 @@ +using System.Diagnostics; + +namespace SharpTools.Tools.Tests; + +[DebuggerStepThrough] +public static class Retry +{ + public static async Task Until(int timeLimit, Func> assertion) + { + var cancellationToken = TestContext.Current.CancellationToken; + + using var timeoutWatcher = CancellationTokenUtils.ApplyTimeout( + timeLimit, + ref cancellationToken + ); + + try + { + while (!cancellationToken.IsCancellationRequested) + { + var result = await assertion(); + + if (result) + return; + + await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken); + } + } + catch (TaskCanceledException) + { + if (!timeoutWatcher.TimedOut) + throw; + } + + await assertion(); + } + + public static async Task UntilPasses(Action condition) + { + await UntilPasses(5, condition); + } + + public static async Task UntilPasses(Func condition) + { + await UntilPasses(5, condition); + } + + public static async Task UntilPasses(int timeLimit, Func condition) + { + await Until( + timeLimit, + async () => + { + try + { + await condition(); + return true; + } + catch + { + return false; + } + } + ); + + await condition(); + } + + public static async Task UntilPasses(int timeLimit, Action condition) + { + await UntilPasses( + timeLimit, + () => + { + condition(); + return Task.CompletedTask; + } + ); + } +} diff --git a/SharpTools.Tools.Tests/Services/FileChangeListenerTests.cs b/SharpTools.Tools.Tests/Services/FileChangeListenerTests.cs new file mode 100644 index 0000000..a8c48ae --- /dev/null +++ b/SharpTools.Tools.Tests/Services/FileChangeListenerTests.cs @@ -0,0 +1,20 @@ +using SharpTools.Tools.Services; + +namespace SharpTools.Tools.Tests.Services; + +public class FileChangeListenerTests { + [Theory] + [InlineData("c:\\root\\obj", true)] + [InlineData("c:\\root\\.git", true)] + [InlineData("c:\\root\\bin", true)] + [InlineData("c:\\root\\BIN", true)] + [InlineData("c:\\root\\foo", false)] + [InlineData("c:\\root\\nested\\obj", true)] + [InlineData("c:\\root\\nested\\.git", true)] + [InlineData("c:\\root\\nested\\bin", true)] + [InlineData("c:\\root\\nested\\BIN", true)] + [InlineData("c:\\root\\nested\\foo", false)] + public void IsPathIgnored_ignores_git_bin_obj(string path, bool isIgnored) { + Assert.Equal(isIgnored, FileChangeListener.IsPathIgnored("c:\\root", path, ['\\'])); + } +} diff --git a/SharpTools.Tools.Tests/Services/FileMonitoringServicesTests.cs b/SharpTools.Tools.Tests/Services/FileMonitoringServicesTests.cs new file mode 100644 index 0000000..24ac856 --- /dev/null +++ b/SharpTools.Tools.Tests/Services/FileMonitoringServicesTests.cs @@ -0,0 +1,314 @@ +using Microsoft.Extensions.Logging; +using SharpTools.Tools.Interfaces; +using SharpTools.Tools.Services; +using Xunit; + +namespace SharpTools.Tools.Tests.Services; + +public class FileMonitoringServicesTests : IDisposable +{ + private readonly ILogger _logger; + private readonly string _testDirectory; + private readonly FileMonitoringService _service; + + public FileMonitoringServicesTests() + { + _logger = new LoggerFactory().CreateLogger(); + _testDirectory = Path.Combine(Path.GetTempPath(), "FileMonitoringTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDirectory); + _service = new FileMonitoringService(_logger); + } + + public void Dispose() + { + _service?.Dispose(); + if (Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, true); + } + } + + [Fact] + public async Task AssessIfReloadNecessary_NoChanges_ReturnsFalse() + { + // Arrange + _service.StartMonitoring(_testDirectory); + _service.SetKnownFilePaths(new HashSet()); + + // Act + var result = await _service.AssessIfReloadNecessary(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task AssessIfReloadNecessary_FileChangedBeforeSetKnownFilePaths_ReturnsTrue() + { + // Arrange + var filePath = Path.Combine(_testDirectory, "test.txt"); + File.WriteAllText(filePath, "initial content"); + + _service.StartMonitoring(_testDirectory); + + + File.WriteAllText(filePath, "changed content"); + var knownFilePaths = new HashSet { filePath }; + + // Act + _service.SetKnownFilePaths(knownFilePaths); + + await Retry.UntilPasses(() => Assert.True( _service.ChangeCount >= 1)); + + var result = await _service.AssessIfReloadNecessary(); + + // Assert + Assert.True(result); + } + + + [Fact] + public async Task AssessIfReloadNecessary_FileDeletedBeforeSetKnownFilePaths_ReturnsTrue() + { + // Arrange + var filePath = Path.Combine(_testDirectory, "test.txt"); + File.WriteAllText(filePath, "initial content"); + + _service.StartMonitoring(_testDirectory); + + File.Delete(filePath); + + var knownFilePaths = new HashSet { filePath }; + + // Act + _service.SetKnownFilePaths(knownFilePaths); + + await Retry.UntilPasses(() => Assert.True( _service.ChangeCount >= 1)); + + var result = await _service.AssessIfReloadNecessary(); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task AssessIfReloadNecessary_KnownFileChangedAfterSetKnownFilePaths_ReturnsTrue() + { + // Arrange + var filePath = Path.Combine(_testDirectory, "test.txt"); + + File.WriteAllText(filePath, "initial content"); + + var knownFilePaths = new HashSet { filePath }; + + _service.StartMonitoring(_testDirectory); + _service.SetKnownFilePaths(knownFilePaths); + + Assert.Equal(0, _service.ChangeCount); + + // Act + File.WriteAllText(filePath, "changed content"); + + await Retry.UntilPasses(() => Assert.True(_service.ChangeCount >= 1)); + + var result = await _service.AssessIfReloadNecessary(); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task AssessIfReloadNecessary_KnownFileDeletedAfterSetKnownFilePaths_ReturnsTrue() + { + // Arrange + var filePath = Path.Combine(_testDirectory, "test.txt"); + File.WriteAllText(filePath, "initial content"); + var knownFilePaths = new HashSet { filePath }; + + _service.StartMonitoring(_testDirectory); + _service.SetKnownFilePaths(knownFilePaths); + + // Act + File.Delete(filePath); + + await Retry.UntilPasses(() => Assert.True(_service.ChangeCount >= 1)); + + var result = await _service.AssessIfReloadNecessary(); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task AssessIfReloadNecessary_KnownFileDirectoryDeletedAfterSetKnownFilePaths_ReturnsTrue() + { + // Arrange + var subdirectory = Path.Combine(_testDirectory, "bar"); + Directory.CreateDirectory(subdirectory); + var filePath = Path.Combine(subdirectory, "test.txt"); + File.WriteAllText(filePath, "initial content"); + + var knownFilePaths = new HashSet { filePath }; + _service.StartMonitoring(_testDirectory); + _service.SetKnownFilePaths(knownFilePaths); + + // Act + Directory.Delete(subdirectory, true); + + Assert.False(File.Exists(filePath)); + + // Wait for the file watcher to pick up the change + await Retry.UntilPasses(() => Assert.True(_service.ChangeCount >= 1)); + + var result = await _service.AssessIfReloadNecessary(); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task AssessIfReloadNecessary_KnownFileDirectoryChangedAfterSetKnownFilePaths_ReturnsTrue() + { + // Renaming the directory is effectively the same as deleting the file from the monitor's perspective. + // Rather than look for directory rename events, the AssessIfReloadNecessary method will simply check + // if any relevant files are missing. + + // Arrange + var subdirectory = Path.Combine(_testDirectory, "bar"); + Directory.CreateDirectory(subdirectory); + var filePath = Path.Combine(subdirectory, "test.txt"); + File.WriteAllText(filePath, "initial content"); + + var knownFilePaths = new HashSet { filePath }; + _service.StartMonitoring(_testDirectory); + _service.SetKnownFilePaths(knownFilePaths); + + // Act + var newSubDirectory = Path.Combine(_testDirectory, "baz"); + Directory.Move(subdirectory, newSubDirectory); + + Assert.True(File.Exists(Path.Combine(newSubDirectory, "test.txt"))); + + // Wait for the file watcher to pick up the change + await Retry.UntilPasses(() => Assert.True(_service.ChangeCount >= 1)); + + var result = await _service.AssessIfReloadNecessary(); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task AssessIfReloadNecessary_ExpectedChange_ReturnsFalse() + { + // Arrange + var filePath = Path.Combine(_testDirectory, "test.txt"); + File.WriteAllText(filePath, "initial content"); + var knownFilePaths = new HashSet { filePath }; + _service.StartMonitoring(_testDirectory); + _service.SetKnownFilePaths(knownFilePaths); + var newContent = "changed content"; + _service.RegisterExpectedChange(filePath, newContent); + + // Act + File.WriteAllText(filePath, newContent); + + // Wait for the file watcher to pick up the change + await Retry.UntilPasses(() => Assert.True(_service.ChangeCount >= 1)); + + var result = await _service.AssessIfReloadNecessary(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task AssessIfReloadNecessary_ExpectedChangeContentMismatch_ReturnsTrue() + { + // Arrange + var filePath = Path.Combine(_testDirectory, "test.txt"); + File.WriteAllText(filePath, "initial content"); + var knownFilePaths = new HashSet { filePath }; + _service.StartMonitoring(_testDirectory); + _service.SetKnownFilePaths(knownFilePaths); + _service.RegisterExpectedChange(filePath, "expected content"); + + // Act + File.WriteAllText(filePath, "unexpected content"); + + // Wait for the file watcher to pick up the change + await Retry.UntilPasses(() => Assert.True(_service.ChangeCount >= 1)); + + var result = await _service.AssessIfReloadNecessary(); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task AssessIfReloadNecessary_UnknownFileChanged_ReturnsFalse() + { + // Arrange + var filePath = Path.Combine(_testDirectory, "test.txt"); + File.WriteAllText(filePath, "initial content"); + _service.StartMonitoring(_testDirectory); + _service.SetKnownFilePaths(new HashSet()); + + // Act + File.WriteAllText(filePath, "changed content"); + + // Wait for the file watcher to pick up the change + await Retry.UntilPasses(() => Assert.True(_service.ChangeCount >= 1)); + + var result = await _service.AssessIfReloadNecessary(); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("bin")] + [InlineData("obj")] + [InlineData(".git")] + public async Task AssessIfReloadNecessary_IgnoresChangesInIgnoredDirectories_ReturnsFalse(string ignoredDir) + { + // Arrange + var dir = Path.Combine(_testDirectory, ignoredDir); + Directory.CreateDirectory(dir); + var filePath = Path.Combine(dir, "test.txt"); + File.WriteAllText(filePath, "initial content"); + + var knownFilePaths = new HashSet { filePath }; + _service.StartMonitoring(_testDirectory); + _service.SetKnownFilePaths(knownFilePaths); + + // Act + File.WriteAllText(filePath, "changed content"); + + // Wait for the file watcher to pick up the change + await Retry.UntilPasses(() => Assert.True(_service.ChangeCount >= 1)); + + var result = await _service.AssessIfReloadNecessary(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task Reload_is_considered_necessary_if_monitoring_is_not_running() + { + // Reload is necessary if monitoring hasn't even been started. + Assert.True(await _service.AssessIfReloadNecessary()); + + + _service.StartMonitoring(_testDirectory); + + // Verify starting monitoring changes the value we're testing + Assert.False(await _service.AssessIfReloadNecessary()); + + _service.StopMonitoring(); + + // Reload is necessary if monitoring has been stopped. + Assert.True(await _service.AssessIfReloadNecessary()); + } +} \ No newline at end of file diff --git a/SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj b/SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj new file mode 100644 index 0000000..adb0f0e --- /dev/null +++ b/SharpTools.Tools.Tests/SharpTools.Tools.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/SharpTools.Tools/Extensions/ServiceCollectionExtensions.cs b/SharpTools.Tools/Extensions/ServiceCollectionExtensions.cs index 4a7a509..c3fb510 100644 --- a/SharpTools.Tools/Extensions/ServiceCollectionExtensions.cs +++ b/SharpTools.Tools/Extensions/ServiceCollectionExtensions.cs @@ -17,10 +17,11 @@ public static class ServiceCollectionExtensions { /// The service collection for chaining. public static IServiceCollection WithSharpToolsServices(this IServiceCollection services, bool enableGit = true, string? buildConfiguration = null) { services.AddSingleton(); - services.AddSingleton(sp => + services.AddSingleton(sp => new SolutionManager( - sp.GetRequiredService>(), + sp.GetRequiredService>(), sp.GetRequiredService(), + sp.GetRequiredService(), buildConfiguration ) ); @@ -36,6 +37,7 @@ public static IServiceCollection WithSharpToolsServices(this IServiceCollection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/SharpTools.Tools/Interfaces/IFileMonitoringService.cs b/SharpTools.Tools/Interfaces/IFileMonitoringService.cs new file mode 100644 index 0000000..400a6ed --- /dev/null +++ b/SharpTools.Tools/Interfaces/IFileMonitoringService.cs @@ -0,0 +1,36 @@ +namespace SharpTools.Tools.Interfaces; + +/// +/// Service for monitoring file changes and coordinating with MCP operations to detect external modifications. +/// +public interface IFileMonitoringService : IDisposable +{ + /// + /// Start monitoring a directory for file changes. + /// + /// Directory to monitor recursively + void StartMonitoring(string directory); + + /// + /// Stop monitoring. + /// + void StopMonitoring(); + + /// + /// Provide the set of files to watch after solution load. + /// This will reconcile any changes that happened during startup. + /// + /// Set of file paths that are part of the solution + void SetKnownFilePaths(ISet filePathsToWatch); + + /// + /// Whether a solution reload is needed due to external file changes. + /// + Task AssessIfReloadNecessary(); + + /// + /// Register an expected file change for backward compatibility. + /// + /// File path that will be modified + void RegisterExpectedChange(string filePath, string fileContents); +} \ No newline at end of file diff --git a/SharpTools.Tools/Interfaces/ISolutionManager.cs b/SharpTools.Tools/Interfaces/ISolutionManager.cs index 0ac3e50..9b5102e 100644 --- a/SharpTools.Tools/Interfaces/ISolutionManager.cs +++ b/SharpTools.Tools/Interfaces/ISolutionManager.cs @@ -20,4 +20,5 @@ public interface ISolutionManager : IDisposable { Task GetCompilationAsync(ProjectId projectId, CancellationToken cancellationToken); Task ReloadSolutionFromDiskAsync(CancellationToken cancellationToken); void RefreshCurrentSolution(); + Task ReloadSolutionIfChangedExternallyAsync(CancellationToken cancellationToken); } \ No newline at end of file diff --git a/SharpTools.Tools/Mcp/ToolHelpers.cs b/SharpTools.Tools/Mcp/ToolHelpers.cs index b90707f..324baf7 100644 --- a/SharpTools.Tools/Mcp/ToolHelpers.cs +++ b/SharpTools.Tools/Mcp/ToolHelpers.cs @@ -1,6 +1,7 @@ using System.Text.Encodings.Web; using System.Text.Json.Serialization; using ModelContextProtocol; +using SharpTools.Tools.Interfaces; using SharpTools.Tools.Services; namespace SharpTools.Tools.Mcp; @@ -8,20 +9,24 @@ namespace SharpTools.Tools.Mcp; internal static class ToolHelpers { public const string SharpToolPrefix = "SharpTool_"; - public static void EnsureSolutionLoaded(ISolutionManager solutionManager) { + public static async Task EnsureSolutionLoaded(ISolutionManager solutionManager, CancellationToken cancellationToken) { if (!solutionManager.IsSolutionLoaded) { throw new McpException($"No solution is currently loaded. Please use '{SharpToolPrefix}{nameof(Tools.SolutionTools.LoadSolution)}' first."); } + + await solutionManager.ReloadSolutionIfChangedExternallyAsync(cancellationToken); } /// /// Safely ensures that a solution is loaded, with detailed error information. /// - public static void EnsureSolutionLoadedWithDetails(ISolutionManager solutionManager, ILogger logger, string operationName) { + public static async Task EnsureSolutionLoadedWithDetails(ISolutionManager solutionManager, ILogger logger, string operationName, CancellationToken cancellationToken) { if (!solutionManager.IsSolutionLoaded) { logger.LogError("Attempted to execute {Operation} without a loaded solution", operationName); throw new McpException($"No solution is currently loaded. Please use '{SharpToolPrefix}{nameof(Tools.SolutionTools.LoadSolution)}' before calling '{operationName}'."); } + + await solutionManager.ReloadSolutionIfChangedExternallyAsync(cancellationToken); } private const string FqnHelpMessage = $" Try `{ToolHelpers.SharpToolPrefix}{nameof(Tools.AnalysisTools.SearchDefinitions)}`, `{ToolHelpers.SharpToolPrefix}{nameof(Tools.AnalysisTools.GetMembers)}`, or `{ToolHelpers.SharpToolPrefix}{nameof(Tools.DocumentTools.ReadTypesFromRoslynDocument)}` to find what you need."; public static async Task GetRoslynSymbolOrThrowAsync( diff --git a/SharpTools.Tools/Mcp/Tools/AnalysisTools.cs b/SharpTools.Tools/Mcp/Tools/AnalysisTools.cs index b73a245..e9fb2eb 100644 --- a/SharpTools.Tools/Mcp/Tools/AnalysisTools.cs +++ b/SharpTools.Tools/Mcp/Tools/AnalysisTools.cs @@ -22,7 +22,7 @@ public static async Task GetAllSubtypes( CancellationToken cancellationToken) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedParentTypeName, "fullyQualifiedParentTypeName", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(GetAllSubtypes)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(GetAllSubtypes), cancellationToken); logger.LogInformation("Executing {GetAllSubtypes} for: {TypeName}", nameof(GetAllSubtypes), fullyQualifiedParentTypeName); @@ -161,7 +161,7 @@ public static async Task GetMembers( CancellationToken cancellationToken = default) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedTypeName, nameof(fullyQualifiedTypeName), logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(GetMembers)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(GetMembers), cancellationToken); logger.LogInformation("Executing '{GetMembers}' for: {TypeName} (IncludePrivate: {IncludePrivate})", nameof(GetMembers), fullyQualifiedTypeName, includePrivateMembers); @@ -357,7 +357,7 @@ public static async Task ViewDefinition( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ViewDefinition)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ViewDefinition), cancellationToken); logger.LogInformation("Executing '{ViewDefinition}' for: {SymbolName}", nameof(ViewDefinition), fullyQualifiedSymbolName); @@ -423,7 +423,7 @@ public static async Task ListImplementations( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ListImplementations)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ListImplementations), cancellationToken); logger.LogInformation("Executing '{ViewImplementations}' for: {SymbolName}", nameof(ListImplementations), fullyQualifiedSymbolName); @@ -698,7 +698,7 @@ public static async Task FindReferences( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedSymbolName, "fullyQualifiedSymbolName", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(FindReferences)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(FindReferences), cancellationToken); logger.LogInformation("Executing '{FindReferences}' for: {SymbolName}", nameof(FindReferences), fullyQualifiedSymbolName); @@ -830,7 +830,7 @@ public static async Task ViewInheritanceChain( CancellationToken cancellationToken) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedTypeName, "fullyQualifiedTypeName", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ViewInheritanceChain)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ViewInheritanceChain), cancellationToken); logger.LogInformation("Executing '{ViewInheritanceChain}' for: {TypeName}", nameof(ViewInheritanceChain), fullyQualifiedTypeName); @@ -1005,7 +1005,7 @@ public static async Task ViewCallGraph( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedMethodName, "fullyQualifiedMethodName", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ViewCallGraph)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ViewCallGraph), cancellationToken); logger.LogInformation("Executing '{ViewCallGraph}' for: {MethodName}", nameof(ViewCallGraph), fullyQualifiedMethodName); @@ -1121,7 +1121,7 @@ static bool IsGeneratedCode(string signature) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(regexPattern, "regexPattern", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(SearchDefinitions)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(SearchDefinitions), cancellationToken); logger.LogInformation("Executing '{SearchDefinitions}' with pattern: {RegexPattern}", nameof(SearchDefinitions), regexPattern); @@ -1518,7 +1518,7 @@ public static async Task ManageUsings( } // Ensure solution is loaded - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ManageUsings)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ManageUsings), cancellationToken); var solution = solutionManager.CurrentSolution ?? throw new McpException("Current solution is null."); var document = solution.Projects @@ -1632,7 +1632,7 @@ public static async Task ManageAttributes( } // Ensure solution is loaded and get target symbol - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ManageAttributes)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ManageAttributes), cancellationToken); var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, targetDeclaration, cancellationToken); if (!symbol.DeclaringSyntaxReferences.Any()) { @@ -1727,7 +1727,7 @@ public static async Task AnalyzeComplexity( } // Ensure solution is loaded - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(AnalyzeComplexity)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(AnalyzeComplexity), cancellationToken); // Track metrics for the final report var metrics = new Dictionary(); @@ -1779,7 +1779,7 @@ public static async Task FindPotentialDuplicates( [Description("The minimum similarity score (0.0 to 1.0) for methods to be considered similar. (start with 0.75)")] double similarityThreshold, CancellationToken cancellationToken) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(FindPotentialDuplicates)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(FindPotentialDuplicates), cancellationToken); logger.LogInformation("Executing '{ToolName}' with threshold {Threshold}", nameof(FindPotentialDuplicates), similarityThreshold); if (similarityThreshold < 0.0 || similarityThreshold > 1.0) { diff --git a/SharpTools.Tools/Mcp/Tools/DocumentTools.cs b/SharpTools.Tools/Mcp/Tools/DocumentTools.cs index b8a359e..aa6a756 100644 --- a/SharpTools.Tools/Mcp/Tools/DocumentTools.cs +++ b/SharpTools.Tools/Mcp/Tools/DocumentTools.cs @@ -31,7 +31,7 @@ public static async Task ReadRawFromRoslynDocument( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(filePath, "filePath", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReadRawFromRoslynDocument)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReadRawFromRoslynDocument), cancellationToken); logger.LogInformation("Reading document at {FilePath}", filePath); @@ -86,7 +86,7 @@ public static async Task CreateRoslynDocument( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(filePath, "filePath", logger); ErrorHandlingHelpers.ValidateStringParameter(content, "content", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(CreateRoslynDocument)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(CreateRoslynDocument), cancellationToken); content = content.TrimBackslash(); logger.LogInformation("Creating new document at {FilePath}", filePath); @@ -168,7 +168,7 @@ public static async Task OverwriteRoslynDocument( return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateStringParameter(filePath, "filePath", logger); ErrorHandlingHelpers.ValidateStringParameter(content, "content", logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(OverwriteRoslynDocument)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(OverwriteRoslynDocument), cancellationToken); content = content.TrimBackslash(); logger.LogInformation("Overwriting document at {FilePath}", filePath); @@ -298,7 +298,7 @@ public static async Task ReadTypesFromRoslynDocument( CancellationToken cancellationToken) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { ErrorHandlingHelpers.ValidateFilePath(filePath, logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReadTypesFromRoslynDocument)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReadTypesFromRoslynDocument), cancellationToken); var pathInfo = documentOperations.GetPathInfo(filePath); if (!pathInfo.Exists) { diff --git a/SharpTools.Tools/Mcp/Tools/ModificationTools.cs b/SharpTools.Tools/Mcp/Tools/ModificationTools.cs index c3d27f9..3ac3350 100644 --- a/SharpTools.Tools/Mcp/Tools/ModificationTools.cs +++ b/SharpTools.Tools/Mcp/Tools/ModificationTools.cs @@ -49,7 +49,7 @@ public static async Task AddMember( codeSnippet = codeSnippet.TrimBackslash(); // Ensure solution is loaded - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(AddMember)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(AddMember), cancellationToken); logger.LogInformation("Executing '{AddMember}' for target: {TargetName}", nameof(AddMember), fullyQualifiedTargetName); // Get the target symbol @@ -237,7 +237,7 @@ public static async Task OverwriteMember( ErrorHandlingHelpers.ValidateStringParameter(newMemberCode, nameof(newMemberCode), logger); newMemberCode = newMemberCode.TrimBackslash(); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(OverwriteMember)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(OverwriteMember), cancellationToken); logger.LogInformation("Executing '{OverwriteMember}' for: {SymbolName}", nameof(OverwriteMember), fullyQualifiedMemberName); var symbol = await ToolHelpers.GetRoslynSymbolOrThrowAsync(solutionManager, fullyQualifiedMemberName, cancellationToken); @@ -370,7 +370,7 @@ public static async Task RenameSymbol( } // Ensure solution is loaded - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(RenameSymbol)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(RenameSymbol), cancellationToken); logger.LogInformation("Executing '{RenameSymbol}' for {SymbolName} to {NewName}", nameof(RenameSymbol), fullyQualifiedSymbolName, newName); // Get the symbol to rename @@ -466,7 +466,7 @@ public static async Task ReplaceAllReferences( // Note: filenameFilter can be empty or null, as this indicates "replace in all files" // Ensure solution is loaded - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReplaceAllReferences)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(ReplaceAllReferences), cancellationToken); logger.LogInformation("Executing '{ReplaceAllReferences}' for {SymbolName} with text '{ReplacementCode}', filter: {Filter}", nameof(ReplaceAllReferences), fullyQualifiedSymbolName, replacementCode, filenameFilter ?? "none"); @@ -602,7 +602,7 @@ public static async Task Undo( CancellationToken cancellationToken) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(Undo)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(Undo), cancellationToken); logger.LogInformation("Executing '{UndoLastChange}'", nameof(Undo)); var (success, message) = await modificationService.UndoLastChangeAsync(cancellationToken); @@ -642,7 +642,7 @@ public static async Task FindAndReplace( .Replace(@"\r", @"\n"); // Ensure solution is loaded - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(FindAndReplace)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(FindAndReplace), cancellationToken); logger.LogInformation("Executing '{FindAndReplace}' with pattern: '{Pattern}', replacement: {Replacement}, target: {Target}", nameof(FindAndReplace), regexPattern, replacementText, target); @@ -848,7 +848,7 @@ public static async Task MoveMember( ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedMemberName, nameof(fullyQualifiedMemberName), logger); ErrorHandlingHelpers.ValidateStringParameter(fullyQualifiedDestinationTypeOrNamespaceName, nameof(fullyQualifiedDestinationTypeOrNamespaceName), logger); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(MoveMember)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(MoveMember), cancellationToken); logger.LogInformation("Executing '{MoveMember}' moving {MemberName} to {DestinationName}", nameof(MoveMember), fullyQualifiedMemberName, fullyQualifiedDestinationTypeOrNamespaceName); diff --git a/SharpTools.Tools/Mcp/Tools/SolutionTools.cs b/SharpTools.Tools/Mcp/Tools/SolutionTools.cs index a295c40..57d6106 100644 --- a/SharpTools.Tools/Mcp/Tools/SolutionTools.cs +++ b/SharpTools.Tools/Mcp/Tools/SolutionTools.cs @@ -103,7 +103,7 @@ private static async Task GetProjectStructure( CancellationToken cancellationToken) { return await ErrorHandlingHelpers.ExecuteWithErrorHandlingAsync(async () => { - ToolHelpers.EnsureSolutionLoaded(solutionManager); + await ToolHelpers.EnsureSolutionLoaded(solutionManager, cancellationToken); var projectsData = new List(); @@ -370,7 +370,7 @@ public static async Task LoadProject( ErrorHandlingHelpers.ValidateStringParameter(projectName, "projectName", logger); logger.LogInformation("Executing '{LoadProjectToolName}' tool for project: {ProjectName}", nameof(LoadProject), projectName); - ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(LoadProject)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(solutionManager, logger, nameof(LoadProject), cancellationToken); int indexOfParen = projectName.IndexOf('('); string projectNameNormalized = indexOfParen == -1 ? projectName.Trim() diff --git a/SharpTools.Tools/Services/DocumentOperationsService.cs b/SharpTools.Tools/Services/DocumentOperationsService.cs index c05a448..f870864 100644 --- a/SharpTools.Tools/Services/DocumentOperationsService.cs +++ b/SharpTools.Tools/Services/DocumentOperationsService.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using System.Xml; using Microsoft.CodeAnalysis.Text; +using SharpTools.Tools.Interfaces; namespace SharpTools.Tools.Services; @@ -13,6 +14,7 @@ public class DocumentOperationsService : IDocumentOperationsService { private readonly ISolutionManager _solutionManager; private readonly ICodeModificationService _modificationService; private readonly IGitService _gitService; + private readonly IFileMonitoringService _fileMonitoring; private readonly ILogger _logger; // Extensions for common code file types that can be formatted @@ -29,10 +31,12 @@ public DocumentOperationsService( ISolutionManager solutionManager, ICodeModificationService modificationService, IGitService gitService, + IFileMonitoringService fileMonitoring, ILogger logger) { _solutionManager = solutionManager; _modificationService = modificationService; _gitService = gitService; + _fileMonitoring = fileMonitoring; _logger = logger; } @@ -79,8 +83,11 @@ public async Task WriteFileAsync(string filePath, string content, bool ove Directory.CreateDirectory(directory); } + _fileMonitoring.RegisterExpectedChange(filePath, content); + // Write the content to the file await File.WriteAllTextAsync(filePath, content, cancellationToken); + _logger.LogInformation("File {Operation} at {FilePath}", File.Exists(filePath) ? "overwritten" : "created", filePath); @@ -123,8 +130,8 @@ public async Task WriteFileAsync(string filePath, string content, bool ove } else { _logger.LogWarning("Failed to format file: {FilePath}", filePath); } - return true; - } + return true; + } private async Task TryAddFileToLegacyProjectAsync(string filePath, Project project, CancellationToken cancellationToken) { if (!_solutionManager.IsSolutionLoaded || !File.Exists(filePath)) { diff --git a/SharpTools.Tools/Services/FileChangeListener.cs b/SharpTools.Tools/Services/FileChangeListener.cs new file mode 100644 index 0000000..5a51f35 --- /dev/null +++ b/SharpTools.Tools/Services/FileChangeListener.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace SharpTools.Tools.Services +{ + public class FileChangeListener : IDisposable { + private static readonly char[] pathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; + private readonly ILogger _logger; + private readonly object _lock = new(); + private readonly FileSystemWatcher _watcher; + private bool _enabled = true; + + private readonly List _knownFilePaths = new (); + private readonly List _changedFiles = new(); + private readonly Dictionary _expectedChanges = new(StringComparer.OrdinalIgnoreCase); + private bool _reloadIsNecessary; + + public int ChangeCount { get; private set; } // For testing purposes + + public FileChangeListener(ILogger logger, string directory) + { + _logger = logger; + _watcher = new FileSystemWatcher(directory) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName, + IncludeSubdirectories = true, + EnableRaisingEvents = true + }; + + _watcher.Changed += OnFileChanged; + _watcher.Created += OnFileChanged; + _watcher.Deleted += OnFileChanged; + _watcher.Renamed += OnFileRenamed; + _watcher.Error += OnError; + } + + public void SetKnownFilePaths(ISet filePathsToWatch) + { + lock (_lock) { + if (_knownFilePaths.Any()) + throw new InvalidOperationException($"{nameof(SetKnownFilePaths)} should only be called once."); + + _logger.LogInformation("Setting known file paths: {FileCount} files", filePathsToWatch.Count); + _knownFilePaths.AddRange(filePathsToWatch); + + if (_changedFiles.Any(changedFile => _knownFilePaths.Contains(changedFile))) + { + _reloadIsNecessary = true; + _logger.LogWarning("File changed during solution load."); + } + + _changedFiles.Clear(); + } + } + + public async Task AssessIfReloadNecessary() { + + Dictionary? expectedChanges = null; + HashSet? knownFilePaths = null; + List? changedFiles = null; + + bool needReload; + + lock (_lock) { + + needReload = _reloadIsNecessary; + + if (!needReload) { + expectedChanges = _expectedChanges.ToDictionary(); + knownFilePaths = new HashSet(_knownFilePaths); + changedFiles = _changedFiles.ToList(); + } + } + + if (!needReload) { + needReload = await AssessIfReloadNecessaryInternal(_logger, expectedChanges!, knownFilePaths!, changedFiles!); + } + + if (needReload) { + // Disable this listener as it no longer needs to monitor files. + Disable(); + } + + return needReload; + } + + private static async Task AssessIfReloadNecessaryInternal( + ILogger logger, + IReadOnlyDictionary expectedChanges, + IReadOnlyCollection knownFilePaths, + IReadOnlyCollection changedFiles) + { + foreach (var changedFile in changedFiles.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var isKnown = knownFilePaths.Contains(changedFile);; + + if (isKnown) + { + if (!File.Exists(changedFile)) + { + logger.LogWarning("Known file {File} was deleted.", changedFile); + return true; + } + + var hasExpectedChange = expectedChanges.TryGetValue(changedFile, out var expectedContent); + + if (hasExpectedChange) + { + try + { + var actualContent = await File.ReadAllTextAsync(changedFile); + if (actualContent != expectedContent) + { + logger.LogWarning("File content mismatch for {File}", changedFile); + return true; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error reading file {File} for comparison", changedFile); + return true; + } + } + else + { + logger.LogWarning("Unexpected file change for {File}", changedFile); + return true; + } + } + } + + return false; + } + + public void RegisterExpectedChange(string filePath, string fileContents) + { + lock (_lock) + { + _logger.LogTrace("Registering expected change to {FilePath}", filePath); + _expectedChanges[filePath] = fileContents; + } + } + + public static bool IsPathIgnored(string basePath, string path, char[] pathSeparators) + { + var relativePath = Path.GetRelativePath(basePath, path); + var pathParts = relativePath.Split(pathSeparators); + if (pathParts.Length > 0) + { + + if (pathParts.Any(part => part.Equals(".git", StringComparison.OrdinalIgnoreCase) || + part.Equals("bin", StringComparison.OrdinalIgnoreCase) || + part.Equals("obj", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + return false; + } + + private void OnFileChanged(object sender, FileSystemEventArgs e) + { + lock (_lock) { + ChangeCount++; + + if (!_enabled) return; + if (IsPathIgnored(_watcher.Path, e.FullPath, pathSeparators)) + { + _logger.LogTrace("Ignoring change in {File} because it is in an ignored directory.", e.FullPath); + return; + } + _logger.LogTrace("File change event: {ChangeType} for {File}", e.ChangeType, e.FullPath); + _changedFiles.Add(e.FullPath); + } + } + + private void OnFileRenamed(object sender, RenamedEventArgs e) + { + lock (_lock) { + ChangeCount++; + + if (!_enabled) return; + _logger.LogTrace("File rename event: {OldFile} to {NewFile}", e.OldFullPath, e.FullPath); + + var oldPath = e.OldFullPath; + + if (_knownFilePaths.Any(p => p.StartsWith(oldPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogInformation("Directory with known files renamed from {OldPath}. Forcing reload.", oldPath); + _reloadIsNecessary = true; + return; + } + + if (!IsPathIgnored(_watcher.Path, e.OldFullPath, pathSeparators)) + { + _changedFiles.Add(e.OldFullPath); + } + if (!IsPathIgnored(_watcher.Path, e.FullPath, pathSeparators)) + { + _changedFiles.Add(e.FullPath); + } + } + } + + private void OnError(object sender, ErrorEventArgs e) + { + lock (_lock) + { + if (!_enabled) return; + _logger.LogError(e.GetException(), "File system watcher error."); + _reloadIsNecessary = true; + } + } + + public void Disable() + { + _watcher.EnableRaisingEvents = false; + _enabled = false; + _reloadIsNecessary = true; + lock (_lock) + { + // Wait for any in-progress events to complete + } + } + + public void Dispose() + { + _watcher.Dispose(); + } + } +} \ No newline at end of file diff --git a/SharpTools.Tools/Services/FileMonitoringService.cs b/SharpTools.Tools/Services/FileMonitoringService.cs new file mode 100644 index 0000000..bf2402b --- /dev/null +++ b/SharpTools.Tools/Services/FileMonitoringService.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SharpTools.Tools.Interfaces; + +namespace SharpTools.Tools.Services +{ + public sealed class FileMonitoringService : IFileMonitoringService + { + private readonly ILogger _logger; + private readonly object _lock = new(); + + // _listener has its own lock. To prevent deadlock we never pick up _lock while _listener + // might be locked. We may hold _lock while calling into _listener, which is necessary for + // threadsafe cleanup. + + private FileChangeListener? _listener; + private bool _disposed; + + /* ChangeCount is exported so tests can wait for events to come in */ + public int ChangeCount { + get { + var listener = ThreadSafeGetCurrentListener(); + + return listener?.ChangeCount ?? -1; + } + } + + public FileMonitoringService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void StartMonitoring(string directory) + { + lock (_lock) + { + StopMonitoring(); + + _logger.LogInformation("Starting file monitoring for directory: {Directory}", directory); + _listener = new FileChangeListener(_logger, directory); + } + } + + public void StopMonitoring() + { + lock (_lock) + { + var listenerToDisable = _listener; + _listener = null; + + // StopMonitoring is called from StartMonitoring within the lock so there is no point in doing + // this cleanup outside the lock. + if (listenerToDisable != null) + { + listenerToDisable.Disable(); + listenerToDisable.Dispose(); + } + } + } + + private FileChangeListener? ThreadSafeGetCurrentListener() + { + FileChangeListener? listener; + lock (_lock) { + listener = _listener; + } + return listener; + } + + public void SetKnownFilePaths(ISet filePathsToWatch) { + FileChangeListener? listener = ThreadSafeGetCurrentListener(); + + listener?.SetKnownFilePaths(filePathsToWatch); + } + + public async Task AssessIfReloadNecessary() + { + FileChangeListener? listener = ThreadSafeGetCurrentListener(); + + if (listener is null) + return true; + + return await listener.AssessIfReloadNecessary(); + } + + public void RegisterExpectedChange(string filePath, string fileContents) + { + FileChangeListener? listener = ThreadSafeGetCurrentListener(); + + listener?.RegisterExpectedChange(filePath, fileContents); + } + + public void Dispose() + { + if (_disposed) return; + + StopMonitoring(); + _disposed = true; + } + } +} \ No newline at end of file diff --git a/SharpTools.Tools/Services/SemanticSimilarityService.cs b/SharpTools.Tools/Services/SemanticSimilarityService.cs index 1b842cd..f623a08 100644 --- a/SharpTools.Tools/Services/SemanticSimilarityService.cs +++ b/SharpTools.Tools/Services/SemanticSimilarityService.cs @@ -16,7 +16,7 @@ namespace SharpTools.Tools.Services { public class SemanticSimilarityService : ISemanticSimilarityService { private static class Tuning { - public static readonly int MaxDegreesOfParallelism = Math.Max(1, Environment.ProcessorCount / 2); + public static readonly int MaxDegreesOfParallelism = Math.Max(1, Environment.ProcessorCount / 2); public const int MethodLineCountFilter = 10; public const double DefaultSimilarityThreshold = 0.7; @@ -186,7 +186,7 @@ public SemanticSimilarityService( public async Task> FindSimilarMethodsAsync( double similarityThreshold, CancellationToken cancellationToken) { - ToolHelpers.EnsureSolutionLoadedWithDetails(_solutionManager, _logger, nameof(FindSimilarMethodsAsync)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(_solutionManager, _logger, nameof(FindSimilarMethodsAsync), cancellationToken); _logger.LogInformation("Starting semantic similarity analysis with threshold {Threshold}, MaxDOP: {MaxDop}", similarityThreshold, Tuning.MaxDegreesOfParallelism); var allMethodFeatures = new System.Collections.Concurrent.ConcurrentBag(); @@ -504,7 +504,7 @@ private double CalculateCosineSimilarity(Dictionary vec1, Dictionar public async Task> FindSimilarClassesAsync( double similarityThreshold, CancellationToken cancellationToken) { - ToolHelpers.EnsureSolutionLoadedWithDetails(_solutionManager, _logger, nameof(FindSimilarClassesAsync)); + await ToolHelpers.EnsureSolutionLoadedWithDetails(_solutionManager, _logger, nameof(FindSimilarClassesAsync), cancellationToken); _logger.LogInformation("Starting class semantic similarity analysis with threshold {Threshold}, MaxDOP: {MaxDop}", similarityThreshold, Tuning.MaxDegreesOfParallelism); var allClassFeatures = new System.Collections.Concurrent.ConcurrentBag(); @@ -762,7 +762,7 @@ await Parallel.ForEachAsync(documents, parallelOptions, async (document, docCt) totalLinesOfCode, classMethodFeatures ); - } + } private List CompareClassFeatures( List allClassFeatures, @@ -933,6 +933,6 @@ private void AddTypeAndNamespaceIfExternal( if (typeSymbol is IPointerTypeSymbol pointerTypeSymbol) { AddTypeAndNamespaceIfExternal(pointerTypeSymbol.PointedAtType, containingClassSymbol, externalTypeFqns, usedNamespaceFqns); } - } + } } } diff --git a/SharpTools.Tools/Services/SolutionManager.cs b/SharpTools.Tools/Services/SolutionManager.cs index e8651dd..3fc3c48 100644 --- a/SharpTools.Tools/Services/SolutionManager.cs +++ b/SharpTools.Tools/Services/SolutionManager.cs @@ -1,12 +1,14 @@ using System.Runtime.InteropServices; using System.Xml.Linq; using ModelContextProtocol; +using SharpTools.Tools.Interfaces; using SharpTools.Tools.Mcp.Tools; namespace SharpTools.Tools.Services; public sealed class SolutionManager : ISolutionManager { private readonly ILogger _logger; private readonly IFuzzyFqnLookupService _fuzzyFqnLookupService; + private readonly IFileMonitoringService _fileMonitoring; private MSBuildWorkspace? _workspace; private Solution? _currentSolution; private MetadataLoadContext? _metadataLoadContext; @@ -21,9 +23,10 @@ public sealed class SolutionManager : ISolutionManager { public Solution? CurrentSolution => _currentSolution; private readonly string? _buildConfiguration; - public SolutionManager(ILogger logger, IFuzzyFqnLookupService fuzzyFqnLookupService, string? buildConfiguration = null) { + public SolutionManager(ILogger logger, IFuzzyFqnLookupService fuzzyFqnLookupService, IFileMonitoringService fileMonitoring, string? buildConfiguration = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _fuzzyFqnLookupService = fuzzyFqnLookupService ?? throw new ArgumentNullException(nameof(fuzzyFqnLookupService)); + _fileMonitoring = fileMonitoring ?? throw new ArgumentNullException(nameof(fileMonitoring)); _buildConfiguration = buildConfiguration; } public async Task LoadSolutionAsync(string solutionPath, CancellationToken cancellationToken) { @@ -32,6 +35,13 @@ public async Task LoadSolutionAsync(string solutionPath, CancellationToken cance throw new FileNotFoundException("Solution file not found.", solutionPath); } UnloadSolution(); // Clears previous state including _allLoadedReflectionTypesCache + + // Start file monitoring before loading solution + var solutionDirectory = Path.GetDirectoryName(solutionPath); + if (!string.IsNullOrEmpty(solutionDirectory)) { + _fileMonitoring.StartMonitoring(solutionDirectory); + } + try { _logger.LogInformation("Creating MSBuildWorkspace..."); var properties = new Dictionary { @@ -48,6 +58,7 @@ public async Task LoadSolutionAsync(string solutionPath, CancellationToken cance _currentSolution = await _workspace.OpenSolutionAsync(solutionPath, new ProgressReporter(_logger), cancellationToken); _logger.LogInformation("Solution loaded successfully with {ProjectCount} projects.", _currentSolution.Projects.Count()); InitializeMetadataContextAndReflectionCache(_currentSolution, cancellationToken); + FocusFileMonitoring(_currentSolution); } catch (Exception ex) { _logger.LogError(ex, "Failed to load solution: {SolutionPath}", solutionPath); UnloadSolution(); @@ -194,6 +205,7 @@ private void LoadTypesFromAssembly(string assemblyPath, ref int typesCachedCount } } public void UnloadSolution() { + _fileMonitoring.StopMonitoring(); _logger.LogInformation("Unloading current solution and workspace."); _compilationCache.Clear(); _semanticModelCache.Clear(); @@ -235,6 +247,12 @@ public async Task ReloadSolutionFromDiskAsync(CancellationToken cancellationToke await LoadSolutionAsync(_workspace.CurrentSolution.FilePath!, cancellationToken); _logger.LogDebug("Current solution state has been refreshed from workspace."); } + public async Task ReloadSolutionIfChangedExternallyAsync(CancellationToken cancellationToken) { + if (await _fileMonitoring.AssessIfReloadNecessary()) { + await ReloadSolutionFromDiskAsync(cancellationToken); + } + } + private void OnWorkspaceFailed(object? sender, WorkspaceDiagnosticEventArgs e) { var diagnostic = e.Diagnostic; var level = diagnostic.Kind == WorkspaceDiagnosticKind.Failure ? LogLevel.Error : LogLevel.Warning; @@ -648,4 +666,48 @@ private static string[] GetCompatibleFrameworks(string targetFramework) { _ => new[] { "net8.0", "net7.0", "net6.0", "net5.0", "netcoreapp3.1", "netcoreapp3.0", "netcoreapp2.1", "netstandard2.1", "netstandard2.0", "netstandard1.6" } }; } + + private void FocusFileMonitoring(Solution solution) + { + // Gather all file paths from the Roslyn solution + var solutionFilePaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Add solution file + if (!string.IsNullOrEmpty(solution.FilePath)) + { + solutionFilePaths.Add(solution.FilePath); + } + + foreach (var project in solution.Projects) + { + // Add project file + if (!string.IsNullOrEmpty(project.FilePath)) + solutionFilePaths.Add(project.FilePath); + + // Add all documents + foreach (var document in project.Documents) + { + if (!string.IsNullOrEmpty(document.FilePath)) + solutionFilePaths.Add(document.FilePath); + } + + // Add additional documents (like config files) + foreach (var additionalDoc in project.AdditionalDocuments) + { + if (!string.IsNullOrEmpty(additionalDoc.FilePath)) + solutionFilePaths.Add(additionalDoc.FilePath); + } + + // Add analyzer config documents (like .editorconfig) + foreach (var configDoc in project.AnalyzerConfigDocuments) + { + if (!string.IsNullOrEmpty(configDoc.FilePath)) + solutionFilePaths.Add(configDoc.FilePath); + } + } + + // Provide the definitive set of files to the monitoring service + _fileMonitoring.SetKnownFilePaths(solutionFilePaths); + _logger.LogInformation("File monitoring is now active for {FileCount} files in the solution.", solutionFilePaths.Count); + } } \ No newline at end of file diff --git a/SharpTools.sln b/SharpTools.sln index 3adb5cf..f08b507 100644 --- a/SharpTools.sln +++ b/SharpTools.sln @@ -9,26 +9,68 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpTools.SseServer", "Sha EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpTools.StdioServer", "SharpTools.StdioServer\SharpTools.StdioServer.csproj", "{6DF2B244-8781-4F09-87E7-F5130D03821D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpTools.Tools.Tests", "SharpTools.Tools.Tests\SharpTools.Tools.Tests.csproj", "{FB125F6D-C10E-41F8-A5DE-07279F7500D3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {43569443-A282-4E77-AD84-9E5813A98E8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {43569443-A282-4E77-AD84-9E5813A98E8D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Debug|x64.ActiveCfg = Debug|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Debug|x64.Build.0 = Debug|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Debug|x86.ActiveCfg = Debug|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Debug|x86.Build.0 = Debug|Any CPU {43569443-A282-4E77-AD84-9E5813A98E8D}.Release|Any CPU.ActiveCfg = Release|Any CPU {43569443-A282-4E77-AD84-9E5813A98E8D}.Release|Any CPU.Build.0 = Release|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Release|x64.ActiveCfg = Release|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Release|x64.Build.0 = Release|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Release|x86.ActiveCfg = Release|Any CPU + {43569443-A282-4E77-AD84-9E5813A98E8D}.Release|x86.Build.0 = Release|Any CPU {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Debug|x64.Build.0 = Debug|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Debug|x86.Build.0 = Debug|Any CPU {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Release|Any CPU.Build.0 = Release|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Release|x64.ActiveCfg = Release|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Release|x64.Build.0 = Release|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Release|x86.ActiveCfg = Release|Any CPU + {C3B267BF-EA86-4A06-BAE6-1FEDC5A30213}.Release|x86.Build.0 = Release|Any CPU {6DF2B244-8781-4F09-87E7-F5130D03821D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6DF2B244-8781-4F09-87E7-F5130D03821D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Debug|x64.ActiveCfg = Debug|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Debug|x64.Build.0 = Debug|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Debug|x86.ActiveCfg = Debug|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Debug|x86.Build.0 = Debug|Any CPU {6DF2B244-8781-4F09-87E7-F5130D03821D}.Release|Any CPU.ActiveCfg = Release|Any CPU {6DF2B244-8781-4F09-87E7-F5130D03821D}.Release|Any CPU.Build.0 = Release|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Release|x64.ActiveCfg = Release|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Release|x64.Build.0 = Release|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Release|x86.ActiveCfg = Release|Any CPU + {6DF2B244-8781-4F09-87E7-F5130D03821D}.Release|x86.Build.0 = Release|Any CPU + {FB125F6D-C10E-41F8-A5DE-07279F7500D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB125F6D-C10E-41F8-A5DE-07279F7500D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB125F6D-C10E-41F8-A5DE-07279F7500D3}.Debug|x64.ActiveCfg = Debug|Any CPU + {FB125F6D-C10E-41F8-A5DE-07279F7500D3}.Debug|x64.Build.0 = Debug|Any CPU + {FB125F6D-C10E-41F8-A5DE-07279F7500D3}.Debug|x86.ActiveCfg = Debug|Any CPU + {FB125F6D-C10E-41F8-A5DE-07279F7500D3}.Debug|x86.Build.0 = Debug|Any CPU + {FB125F6D-C10E-41F8-A5DE-07279F7500D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB125F6D-C10E-41F8-A5DE-07279F7500D3}.Release|Any CPU.Build.0 = Release|Any CPU + {FB125F6D-C10E-41F8-A5DE-07279F7500D3}.Release|x64.ActiveCfg = Release|Any CPU + {FB125F6D-C10E-41F8-A5DE-07279F7500D3}.Release|x64.Build.0 = Release|Any CPU + {FB125F6D-C10E-41F8-A5DE-07279F7500D3}.Release|x86.ActiveCfg = Release|Any CPU + {FB125F6D-C10E-41F8-A5DE-07279F7500D3}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/docs/filemon.design.md b/docs/filemon.design.md new file mode 100644 index 0000000..6a00cd5 --- /dev/null +++ b/docs/filemon.design.md @@ -0,0 +1,55 @@ +# File Monitoring Design for SharpTools MCP Server + +## Overview + +The file monitoring system provides intelligent detection of external file changes to keep the MCP server's Roslyn workspace synchronized with the file system. It monitors all solution files for unexpected changes so the solution can be reloaded when commands are run after external changes to solution files. + +## Core Concepts + +### Open Concerns + +- Is watching DocumentOperationService.WriteFileAsync sufficient for discovering all expected file changes? +- Comparison of filenames given case insensitivity on Windows? (Note WSL on Windows is case sensitive) + +### Comprehensive file monitoring + +The file monitoring service is starts watching the solution folder recursively before the solution is loaded. Once the solution is loaded information from Roslyn is used to control what files can actually trigger a reload. All MCP operations will check if a reload is needed before continuing their operation. + +Monitoring always ignores certain directories altogether: ".git", "bin" and "obj". + +Monitoring is halted when SolutionManager unloads the solution or when the singleton is disposed by application close. + +### Tracking Expected Changes To Avoid Reloading + +Operations register files they expect to modify as they discover them during execution with the expected file contents. +This allows file monitoring to ignore expected changes and prevent unnecessary reloads. + +### Integration + +A singleton implementation of IFileMonitoringService tracks changes. SolutionManager makes calls indicating when monitoring should start and what files are known to be relevant. DocumentOperationService notifies when any expected changes are written. ToolsHelper's EnsureSolutionLoaded* methods, which are called at the start of every MCP operation, notify SolutionManager to reload the solution if appropriate. + +## File Discovery and Monitoring Strategy + +To minimze monitoring calls made to the operating system, the system watches the entire solution's root directory recursively. This automatically handles file additions and deletions without complex tracking. + +The monitoring is made efficient and relevant by filtering events against the actual files that make up the solution: + +1. **Directory Watching**: A single `FileSystemWatcher` is configured to monitor the solution's root directory and all its subdirectories for any file changes. +2. **Known File Set**: On solution load, the `SolutionManager` inspects the Roslyn workspace to get a complete list of all file paths that are part of the solution. This includes project files, source documents, additional files, and analyzer configuration files (like `.editorconfig`). This list is stored in a fast-lookup data structure like a `HashSet`. +3. **Event Filtering**: When the `FileSystemWatcher` raises a file change event, the `FileMonitoringService` checks if the full path of the changed file exists in the set of known solution files. Changes to files not in this set are ignored. + +This approach ensures that the system only reacts to changes in files it genuinely cares about, providing a balance of simplicity (one watcher) and precision (filtering by exact file path). + +## Change detection while loading the solution + +Before the solution is loaded we do not know exactly what files might necessitate a reload. Once the solution is loaded we do know what files are relevant, and will have expected changes registered before we expect to assess if a reload is need. To support this, monitoring has phases: + +1. **Early Monitoring**: The `FileMonitoringService` starts watching the solution directory for all changes *immediately* on application startup, even before it knows which files are part of the solution. It records the paths of any changed files into a temporary backlog. + +2. **Transitioning to the next monitoring phase **: After the `SolutionManager` has finished loading the solution, it provides the `FileMonitoringService` with the definitive set of "known" solution file paths. Upon receiving the set of known files, the service reconciles its backlog of recorded changes. If any of the newly provided "known" files appear in the backlog, it means they were changed during the startup process. In this case, an internal flag hadChangesWhileLoadingSolution is set to true which will cause AssessIfReloadNecessary to return true until monitoring is restarted. + +3. ** Regular Monitoring** Once the set of known solution files known then a record is kept of which of those files receive change notifications. At the same time any expected changes that are registered are kept, storing the last expected content for each registered file. When AssessIfReloadNecessary is called the list of changed relevant files are considered. If any of them don't have an expected change registered then a reload is considered necessary. If the changed file has a registered expected change then a reload is considered necessary if the file's actual content doesn't match the last registered expected content. + +## Error Handling Strategy + +- **FileSystemWatcher failures** → Stop monitoring and set IsReloadNeeded = true \ No newline at end of file