diff --git a/Directory.Packages.props b/Directory.Packages.props index 89d9507..49fcf26 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,8 +5,8 @@ - - + + @@ -15,9 +15,9 @@ - - - - + + + + \ No newline at end of file diff --git a/ENUMERATOR_IMPLEMENTATION.md b/ENUMERATOR_IMPLEMENTATION.md new file mode 100644 index 0000000..f8dd1c8 --- /dev/null +++ b/ENUMERATOR_IMPLEMENTATION.md @@ -0,0 +1,114 @@ +# Enumerator Implementation + +## Overview + +This document describes the new `Enumerator` class that enhances the existing `SlnScanner` functionality by providing a unified interface for enumerating both solution files and project files based on the specified enumeration type. + +## Key Features + +### EnumerationType Enum +- **Sln**: Enumerates solution files (.sln, .slnx, .slnf) and extracts all project paths from them +- **Project**: Directly enumerates project files (.csproj, .vbproj, .sqlproj, .fsproj, .vcxproj) + +### Enhanced Functionality +1. **Unified Interface**: Single method `EnumerateProjectPaths()` handles both solution and project enumeration +2. **Parallel Processing**: Solutions are processed in parallel for better performance +3. **Recursive Scanning**: Supports recursive directory traversal with configurable depth +4. **File Format Support**: + - Solution formats: .sln, .slnx, .slnf + - Project formats: .csproj, .vbproj, .sqlproj, .fsproj, .vcxproj +5. **Error Handling**: Robust error handling with detailed error reporting via ErrorSink + +## Implementation Details + +### Core Classes + +#### Enumerator +- Location: `bld/Infrastructure/Enumerator.cs` +- Constructor: `Enumerator(CleaningOptions Options, ErrorSink ErrorSink)` +- Main Method: `EnumerateProjectPaths(string path, EnumerationType enumerationType)` + +#### EnumerationType Enum +- Location: `bld/Models/DirectoryModels.cs` +- Values: `Sln`, `Project` + +### Key Methods + +1. **EnumerateProjectPaths**: Main entry point for enumeration +2. **EnumerateProjectsFromSolutions**: Processes solution files and extracts projects +3. **EnumerateProjectsFromSolution**: Parses individual solution files +4. **EnumerateProjectFiles**: Direct project file enumeration +5. **GetSolutionFilesAsync**: Asynchronously finds solution files +6. **GetProjectFilesAsync**: Asynchronously finds project files + +### Performance Optimizations + +- **Parallel Processing**: Solution files are processed concurrently using `Task.WhenAll` +- **Async/Await Pattern**: Full async enumeration for non-blocking operations +- **Efficient File Filtering**: Uses `Directory.EnumerateFiles` with proper enumeration options + +## Usage Examples + +### Basic Usage +```csharp +var options = new CleaningOptions(); +var errorSink = new ErrorSink(console); +var enumerator = new Enumerator(options, errorSink); + +// Enumerate projects from solution files +await foreach (var projectPath in enumerator.EnumerateProjectPaths(rootPath, EnumerationType.Sln)) { + Console.WriteLine($"Project from solution: {projectPath}"); +} + +// Enumerate project files directly +await foreach (var projectPath in enumerator.EnumerateProjectPaths(rootPath, EnumerationType.Project)) { + Console.WriteLine($"Direct project file: {projectPath}"); +} +``` + +### Integration with Existing Services +The `EnumeratorDemoService` demonstrates how to integrate the new functionality: +```csharp +var demoService = new EnumeratorDemoService(console, options); +await demoService.CompareEnumerationApproachesAsync(rootPath); +``` + +## Comparison with SlnScanner + +| Feature | SlnScanner | Enumerator | +|---------|------------|------------| +| Solution Files | ✅ .sln, .slnf, .slnx | ✅ .sln, .slnf, .slnx | +| Project Files | ❌ Not directly | ✅ All MSBuild formats | +| Project Enumeration | ❌ Manual parsing needed | ✅ Automatic extraction | +| Parallel Processing | ❌ Sequential | ✅ Parallel solutions | +| Unified Interface | ❌ Separate methods | ✅ Single method with enum | +| Error Handling | ✅ Basic | ✅ Enhanced | + +## Testing + +The implementation includes comprehensive tests in `bld.Tests/EnumeratorTests.cs` covering: +- Solution file parsing and project extraction +- Direct project file enumeration +- Error handling for invalid paths +- Edge cases (empty paths, non-existent directories) + +## Integration Points + +The new `Enumerator` can be used as a drop-in replacement or complement to `SlnScanner` in: +- `CleaningApplication` +- `NugetAnalysisApplication` +- `TfmService` +- `OutdatedService` +- `CpmService` +- `ContainerizeService` + +## Future Enhancements + +1. **Caching**: Add project metadata caching for repeated enumerations +2. **Filtering**: Enhanced filtering based on project types or conditions +3. **Progress Reporting**: Integration with progress reporting for large solutions +4. **Configuration**: More granular configuration options for enumeration behavior + +## Conclusion + +The `Enumerator` class provides a modern, efficient, and unified approach to project enumeration that extends beyond the current `SlnScanner` capabilities while maintaining backward compatibility with existing patterns. \ No newline at end of file diff --git a/bld.Tests/EnumeratorTests.cs b/bld.Tests/EnumeratorTests.cs new file mode 100644 index 0000000..29abc22 --- /dev/null +++ b/bld.Tests/EnumeratorTests.cs @@ -0,0 +1,578 @@ +using bld.Infrastructure; +using bld.Models; +using bld.Services; +using System.Text; +using Xunit.Abstractions; +using Spectre.Console; + +namespace bld.Tests; + +public class EnumeratorTests(ITestOutputHelper output) { + private readonly ITestOutputHelper _output = output; + + // Initialize MSBuild once for all tests + static EnumeratorTests() { + var console = new TestConsoleOutput(null!); // Null output for static initialization + var options = new CleaningOptions(); + MSBuildInitializer.Initialize(console, options); + } + + [Fact] + public async Task EnumerateProjectPaths_WithValidSolutionFile_ReturnsProjectPaths() { + // Arrange + var tempDir = CreateTempDirectory(); + var slnPath = Path.Combine(tempDir, "test.sln"); + var projPath1 = Path.Combine(tempDir, "Project1", "Project1.csproj"); + var projPath2 = Path.Combine(tempDir, "Project2", "Project2.vbproj"); + + try { + // Create directory structure + Directory.CreateDirectory(Path.GetDirectoryName(projPath1)!); + Directory.CreateDirectory(Path.GetDirectoryName(projPath2)!); + + // Create simple project files + await File.WriteAllTextAsync(projPath1, CreateSimpleCsprojContent()); + await File.WriteAllTextAsync(projPath2, CreateSimpleVbprojContent()); + + // Create solution file + await File.WriteAllTextAsync(slnPath, CreateSimpleSolutionContent(projPath1, projPath2)); + + var options = new CleaningOptions(); + var errorSink = new ErrorSink(new TestConsoleOutput(_output)); + var enumerator = new Enumerator(options, errorSink); + + // Act + var projectPaths = new List(); + await foreach (var path in enumerator.EnumerateProjectPaths(slnPath, EnumerationType.Sln)) { + projectPaths.Add(path); + } + + // Assert + Assert.Equal(2, projectPaths.Count); + Assert.Contains(projPath1, projectPaths); + Assert.Contains(projPath2, projectPaths); + } + finally { + CleanupTempDirectory(tempDir); + } + } + + [Fact] + public async Task EnumerateProjectPaths_WithValidProjectFile_ReturnsSinglePath() { + // Arrange + var tempDir = CreateTempDirectory(); + var projPath = Path.Combine(tempDir, "Test.csproj"); + + try { + await File.WriteAllTextAsync(projPath, CreateSimpleCsprojContent()); + + var options = new CleaningOptions(); + var errorSink = new ErrorSink(new TestConsoleOutput(_output)); + var enumerator = new Enumerator(options, errorSink); + + // Act + var projectPaths = new List(); + await foreach (var path in enumerator.EnumerateProjectPaths(projPath, EnumerationType.Project)) { + projectPaths.Add(path); + } + + // Assert + Assert.Single(projectPaths); + Assert.Equal(projPath, projectPaths[0]); + } + finally { + CleanupTempDirectory(tempDir); + } + } + + [Fact] + public async Task EnumerateProjectPaths_WithDirectoryContainingProjects_ReturnsAllProjectPaths() { + // Arrange + var tempDir = CreateTempDirectory(); + var projPath1 = Path.Combine(tempDir, "Proj1", "Test1.csproj"); + var projPath2 = Path.Combine(tempDir, "Proj2", "Test2.sqlproj"); + var projPath3 = Path.Combine(tempDir, "Proj3", "Test3.fsproj"); + + try { + // Create directory structure and project files + Directory.CreateDirectory(Path.GetDirectoryName(projPath1)!); + Directory.CreateDirectory(Path.GetDirectoryName(projPath2)!); + Directory.CreateDirectory(Path.GetDirectoryName(projPath3)!); + + await File.WriteAllTextAsync(projPath1, CreateSimpleCsprojContent()); + await File.WriteAllTextAsync(projPath2, CreateSimpleSqlprojContent()); + await File.WriteAllTextAsync(projPath3, CreateSimpleFsprojContent()); + + var options = new CleaningOptions(); + var errorSink = new ErrorSink(new TestConsoleOutput(_output)); + var enumerator = new Enumerator(options, errorSink); + + // Act + var projectPaths = new List(); + await foreach (var path in enumerator.EnumerateProjectPaths(tempDir, EnumerationType.Project)) { + projectPaths.Add(path); + } + + // Assert + Assert.Equal(3, projectPaths.Count); + Assert.Contains(projPath1, projectPaths); + Assert.Contains(projPath2, projectPaths); + Assert.Contains(projPath3, projectPaths); + } + finally { + CleanupTempDirectory(tempDir); + } + } + + [Fact] + public async Task EnumerateProjectPaths_WithNonExistentPath_ReturnsEmpty() { + // Arrange + var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var options = new CleaningOptions(); + var errorSink = new ErrorSink(new TestConsoleOutput(_output)); + var enumerator = new Enumerator(options, errorSink); + + // Act + var projectPaths = new List(); + await foreach (var path in enumerator.EnumerateProjectPaths(nonExistentPath, EnumerationType.Project)) { + projectPaths.Add(path); + } + + // Assert + Assert.Empty(projectPaths); + } + + [Fact] + public async Task EnumerateProjectPaths_WithEmptyPath_ReturnsEmpty() { + // Arrange + var options = new CleaningOptions(); + var errorSink = new ErrorSink(new TestConsoleOutput(_output)); + var enumerator = new Enumerator(options, errorSink); + + // Act + var projectPaths = new List(); + await foreach (var path in enumerator.EnumerateProjectPaths("", EnumerationType.Project)) { + projectPaths.Add(path); + } + + // Assert + Assert.Empty(projectPaths); + } + + [Fact] + public async Task EnumerateProjCfg_WithValidSolutionFile_ReturnsProjCfgWithConfigurations() { + // Arrange + var tempDir = CreateTempDirectory(); + var slnPath = Path.Combine(tempDir, "test.sln"); + var projPath1 = Path.Combine(tempDir, "Project1", "Project1.csproj"); + var projPath2 = Path.Combine(tempDir, "Project2", "Project2.vbproj"); + + try { + // Create directory structure + Directory.CreateDirectory(Path.GetDirectoryName(projPath1)!); + Directory.CreateDirectory(Path.GetDirectoryName(projPath2)!); + + // Create simple project files + await File.WriteAllTextAsync(projPath1, CreateSimpleCsprojContent()); + await File.WriteAllTextAsync(projPath2, CreateSimpleVbprojContent()); + + // Create solution file with configurations + await File.WriteAllTextAsync(slnPath, CreateSolutionWithConfigurations(projPath1, projPath2)); + + var options = new CleaningOptions(); + var errorSink = new ErrorSink(new TestConsoleOutput(_output)); + var enumerator = new Enumerator(options, errorSink); + + // Act + var projCfgs = new List(); + await foreach (var projCfg in enumerator.Enumerate(slnPath, EnumerationType.Sln)) { + projCfgs.Add(projCfg); + } + + // Assert + Assert.Equal(4, projCfgs.Count); // 2 projects * 2 configurations each + + // Check that we have both Debug and Release configurations for each project + var proj1Cfgs = projCfgs.Where(p => p.Path == projPath1).ToList(); + var proj2Cfgs = projCfgs.Where(p => p.Path == projPath2).ToList(); + + Assert.Equal(2, proj1Cfgs.Count); + Assert.Equal(2, proj2Cfgs.Count); + + Assert.Contains(proj1Cfgs, p => p.Configuration == "Debug"); + Assert.Contains(proj1Cfgs, p => p.Configuration == "Release"); + Assert.Contains(proj2Cfgs, p => p.Configuration == "Debug"); + Assert.Contains(proj2Cfgs, p => p.Configuration == "Release"); + + // Check that solution reference is properly set + Assert.All(projCfgs, projCfg => Assert.NotNull(projCfg.Proj.Parent)); + Assert.All(projCfgs, projCfg => Assert.Equal(slnPath, projCfg.Proj.Parent!.Path)); + } + finally { + CleanupTempDirectory(tempDir); + } + } + + [Fact] + public async Task EnumerateProjCfg_WithValidProjectFile_ReturnsDefaultConfigurations() { + // Arrange + var tempDir = CreateTempDirectory(); + var projPath = Path.Combine(tempDir, "Test.csproj"); + + try { + await File.WriteAllTextAsync(projPath, CreateSimpleCsprojContent()); + + var options = new CleaningOptions(); + var errorSink = new ErrorSink(new TestConsoleOutput(_output)); + var enumerator = new Enumerator(options, errorSink); + + // Act + var projCfgs = new List(); + await foreach (var projCfg in enumerator.Enumerate(projPath, EnumerationType.Project)) { + projCfgs.Add(projCfg); + } + + // Assert + Assert.Equal(2, projCfgs.Count); // Debug and Release by default + Assert.Contains(projCfgs, p => p.Configuration == "Debug"); + Assert.Contains(projCfgs, p => p.Configuration == "Release"); + Assert.All(projCfgs, projCfg => Assert.Equal(projPath, projCfg.Path)); + Assert.All(projCfgs, projCfg => Assert.Null(projCfg.Proj.Parent)); // No solution parent + } + finally { + CleanupTempDirectory(tempDir); + } + } + + [Fact] + public async Task EnumerateProjCfg_WithValidProjectFileNoDebug_ReturnsOnlyRelease() { + // Arrange + var tempDir = CreateTempDirectory(); + var projPath = Path.Combine(tempDir, "Test.csproj"); + + try { + await File.WriteAllTextAsync(projPath, CreateSimpleCsprojContent()); + + var options = new CleaningOptions(); + var errorSink = new ErrorSink(new TestConsoleOutput(_output)); + var enumerator = new Enumerator(options, errorSink); + + // Act - Disable default Debug configuration + var projCfgs = new List(); + await foreach (var projCfg in enumerator.Enumerate(projPath, EnumerationType.Project, createDefaultDebugConfiguration: false)) { + projCfgs.Add(projCfg); + } + + // Assert + Assert.Single(projCfgs); // Only Release + Assert.Equal("Release", projCfgs[0].Configuration); + Assert.Equal(projPath, projCfgs[0].Path); + } + finally { + CleanupTempDirectory(tempDir); + } + } + + [Fact] + public async Task EnumerateProjCfg_WithDirectoryContainingProjects_ReturnsAllProjectConfigurations() { + // Arrange + var tempDir = CreateTempDirectory(); + var projPath1 = Path.Combine(tempDir, "Proj1", "Test1.csproj"); + var projPath2 = Path.Combine(tempDir, "Proj2", "Test2.fsproj"); + + try { + // Create directory structure and project files + Directory.CreateDirectory(Path.GetDirectoryName(projPath1)!); + Directory.CreateDirectory(Path.GetDirectoryName(projPath2)!); + + await File.WriteAllTextAsync(projPath1, CreateSimpleCsprojContent()); + await File.WriteAllTextAsync(projPath2, CreateSimpleFsprojContent()); + + var options = new CleaningOptions(); + var errorSink = new ErrorSink(new TestConsoleOutput(_output)); + var enumerator = new Enumerator(options, errorSink); + + // Act + var projCfgs = new List(); + await foreach (var projCfg in enumerator.Enumerate(tempDir, EnumerationType.Project)) { + projCfgs.Add(projCfg); + } + + // Assert + Assert.Equal(4, projCfgs.Count); // 2 projects * 2 configurations each + + // Check that we have both projects + var proj1Cfgs = projCfgs.Where(p => p.Path == projPath1).ToList(); + var proj2Cfgs = projCfgs.Where(p => p.Path == projPath2).ToList(); + + Assert.Equal(2, proj1Cfgs.Count); + Assert.Equal(2, proj2Cfgs.Count); + + // All should have Debug and Release configurations + Assert.Contains(proj1Cfgs, p => p.Configuration == "Debug"); + Assert.Contains(proj1Cfgs, p => p.Configuration == "Release"); + Assert.Contains(proj2Cfgs, p => p.Configuration == "Debug"); + Assert.Contains(proj2Cfgs, p => p.Configuration == "Release"); + } + finally { + CleanupTempDirectory(tempDir); + } + } + + [Fact] + public async Task EnumerateProjCfg_WithVcxprojInSolution_ExtractsPlatformConfigurations() { + // Arrange + var tempDir = CreateTempDirectory(); + var slnPath = Path.Combine(tempDir, "test.sln"); + var vcxprojPath = Path.Combine(tempDir, "NativeProject", "NativeProject.vcxproj"); + + try { + // Create directory structure + Directory.CreateDirectory(Path.GetDirectoryName(vcxprojPath)!); + + // Create vcxproj file + await File.WriteAllTextAsync(vcxprojPath, CreateSimpleVcxprojContent()); + + // Create solution file with platform configurations for vcxproj + await File.WriteAllTextAsync(slnPath, CreateSolutionWithPlatformConfigurations(vcxprojPath)); + + var options = new CleaningOptions(); + var errorSink = new ErrorSink(new TestConsoleOutput(_output)); + var enumerator = new Enumerator(options, errorSink); + + // Act + var projCfgs = new List(); + await foreach (var projCfg in enumerator.Enumerate(slnPath, EnumerationType.Sln)) { + projCfgs.Add(projCfg); + } + + // Assert + Assert.Equal(4, projCfgs.Count); // Debug|x64, Debug|Win32, Release|x64, Release|Win32 + + // Check that platform is set for vcxproj + Assert.All(projCfgs, projCfg => Assert.NotNull(projCfg.Platform)); + Assert.Contains(projCfgs, p => p.Configuration == "Debug" && p.Platform == "x64"); + Assert.Contains(projCfgs, p => p.Configuration == "Debug" && p.Platform == "Win32"); + Assert.Contains(projCfgs, p => p.Configuration == "Release" && p.Platform == "x64"); + Assert.Contains(projCfgs, p => p.Configuration == "Release" && p.Platform == "Win32"); + } + finally { + CleanupTempDirectory(tempDir); + } + } + + [Fact] + public async Task EnumerateProjCfg_WithNonExistentPath_ReturnsEmpty() { + // Arrange + var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var options = new CleaningOptions(); + var errorSink = new ErrorSink(new TestConsoleOutput(_output)); + var enumerator = new Enumerator(options, errorSink); + + // Act + var projCfgs = new List(); + await foreach (var projCfg in enumerator.Enumerate(nonExistentPath, EnumerationType.Project)) { + projCfgs.Add(projCfg); + } + + // Assert + Assert.Empty(projCfgs); + } + + private static string CreateSolutionWithConfigurations(string projPath1, string projPath2) { + var proj1Guid = "{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}"; + var proj2Guid = "{F184B08F-C81C-45F6-A57F-5ABD9991F28F}"; + var proj1Id = Guid.NewGuid().ToString().ToUpper(); + var proj2Id = Guid.NewGuid().ToString().ToUpper(); + + return $@"Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project(""{proj1Guid}"") = ""Project1"", ""{projPath1.Replace(Path.DirectorySeparatorChar, '\\')}"", ""{{{proj1Id}}}"" +EndProject +Project(""{proj2Guid}"") = ""Project2"", ""{projPath2.Replace(Path.DirectorySeparatorChar, '\\')}"", ""{{{proj2Id}}}"" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {{{proj1Id}}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {{{proj1Id}}}.Debug|Any CPU.Build.0 = Debug|Any CPU + {{{proj1Id}}}.Release|Any CPU.ActiveCfg = Release|Any CPU + {{{proj1Id}}}.Release|Any CPU.Build.0 = Release|Any CPU + {{{proj2Id}}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {{{proj2Id}}}.Debug|Any CPU.Build.0 = Debug|Any CPU + {{{proj2Id}}}.Release|Any CPU.ActiveCfg = Release|Any CPU + {{{proj2Id}}}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal"; + } + + private static string CreateSolutionWithPlatformConfigurations(string vcxprojPath) { + var vcxprojGuid = "{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}"; + var projId = Guid.NewGuid().ToString().ToUpper(); + + return $@"Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project(""{vcxprojGuid}"") = ""NativeProject"", ""{vcxprojPath.Replace(Path.DirectorySeparatorChar, '\\')}"", ""{{{projId}}}"" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {{{projId}}}.Debug|x64.ActiveCfg = Debug|x64 + {{{projId}}}.Debug|x64.Build.0 = Debug|x64 + {{{projId}}}.Debug|x86.ActiveCfg = Debug|Win32 + {{{projId}}}.Debug|x86.Build.0 = Debug|Win32 + {{{projId}}}.Release|x64.ActiveCfg = Release|x64 + {{{projId}}}.Release|x64.Build.0 = Release|x64 + {{{projId}}}.Release|x86.ActiveCfg = Release|Win32 + {{{projId}}}.Release|x86.Build.0 = Release|Win32 + EndGlobalSection +EndGlobal"; + } + + private static string CreateSimpleVcxprojContent() { + return """ + + + + + Debug + Win32 + + + Release + Win32 + + + Debug + x64 + + + Release + x64 + + + + 16.0 + Win32Proj + 10.0 + + + + + + """; + } + + private static string CreateTempDirectory() { + var tempPath = Path.Combine(Path.GetTempPath(), "EnumeratorTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempPath); + return tempPath; + } + + private static void CleanupTempDirectory(string path) { + if (Directory.Exists(path)) { + Directory.Delete(path, true); + } + } + + private static string CreateSimpleCsprojContent() { + return """ + + + net8.0 + + + """; + } + + private static string CreateSimpleVbprojContent() { + return """ + + + net8.0 + + + """; + } + + private static string CreateSimpleSqlprojContent() { + return """ + + + net8.0 + + + """; + } + + private static string CreateSimpleFsprojContent() { + return """ + + + net8.0 + + + """; + } + + private static string CreateSimpleSolutionContent(string projPath1, string projPath2) { + var proj1Guid = "{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}"; // C# project GUID + var proj2Guid = "{F184B08F-C81C-45F6-A57F-5ABD9991F28F}"; // VB project GUID + var proj1Id = Guid.NewGuid().ToString().ToUpper(); + var proj2Id = Guid.NewGuid().ToString().ToUpper(); + + return $@"Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project(""{proj1Guid}"") = ""Project1"", ""{projPath1.Replace(Path.DirectorySeparatorChar, '\\')}"", ""{{{proj1Id}}}"" +EndProject +Project(""{proj2Guid}"") = ""Project2"", ""{projPath2.Replace(Path.DirectorySeparatorChar, '\\')}"", ""{{{proj2Id}}}"" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {{{proj1Id}}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {{{proj1Id}}}.Debug|Any CPU.Build.0 = Debug|Any CPU + {{{proj1Id}}}.Release|Any CPU.ActiveCfg = Release|Any CPU + {{{proj1Id}}}.Release|Any CPU.Build.0 = Release|Any CPU + {{{proj2Id}}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {{{proj2Id}}}.Debug|Any CPU.Build.0 = Debug|Any CPU + {{{proj2Id}}}.Release|Any CPU.ActiveCfg = Release|Any CPU + {{{proj2Id}}}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal"; + } +} + +internal class TestConsoleOutput(ITestOutputHelper? output) : IConsoleOutput { + public void WriteError(string message, Exception? exception = default) => output?.WriteLine($"ERROR: {message}"); + public void WriteWarning(string message) => output?.WriteLine($"WARNING: {message}"); + public void WriteInfo(string message) => output?.WriteLine($"INFO: {message}"); + public void WriteVerbose(string message) => output?.WriteLine($"VERBOSE: {message}"); + public void WriteDebug(string message) => output?.WriteLine($"DEBUG: {message}"); + public void WriteRule(string title) => output?.WriteLine($"=== {title} ==="); + public void WriteTable(Table table) => output?.WriteLine("TABLE: " + table.ToString()); + public bool Confirm(string message, bool defaultValue = false) => defaultValue; + public T Prompt(SelectionPrompt prompt) where T : notnull => throw new NotImplementedException(); + public void StartProgress(string description, Action action) => action(null!); + public Task StartProgressAsync(string description, Func action) => action(null!); + public Task StartStatusAsync(string description, Func action) => action(null!); + public void WriteException(Exception exception) => output?.WriteLine($"EXCEPTION: {exception.Message}"); + public void WriteOutput(string caption, string content) => output?.WriteLine($"{caption}: {content}"); +} \ No newline at end of file diff --git a/bld.Tests/bld.Tests.csproj b/bld.Tests/bld.Tests.csproj index 8778298..533767f 100644 --- a/bld.Tests/bld.Tests.csproj +++ b/bld.Tests/bld.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable false diff --git a/bld/Infrastructure/Enumerator.cs b/bld/Infrastructure/Enumerator.cs new file mode 100644 index 0000000..ef0a631 --- /dev/null +++ b/bld/Infrastructure/Enumerator.cs @@ -0,0 +1,349 @@ +using bld.Models; +using bld.Services; +using Microsoft.Build.Construction; + +namespace bld.Infrastructure; + +/// +/// Enhanced enumerator that can enumerate solution files and project files +/// based on the specified enumeration type, with parallel processing support +/// +internal class Enumerator(CleaningOptions Options, ErrorSink ErrorSink) { + + /// + /// Enumerates files based on the specified enumeration type + /// + /// Base directory path to scan + /// Type of files to enumerate (Sln or Project) + /// Async enumerable of project file paths + public async IAsyncEnumerable EnumerateProjectPaths(string path, EnumerationType enumerationType) { + if (string.IsNullOrWhiteSpace(path)) { + yield break; + } + + // Handle single file case + if (File.Exists(path)) { + if (enumerationType == EnumerationType.Sln && IsSolutionFile(path)) { + await foreach (var projectPath in EnumerateProjectsFromSolution(path)) { + yield return projectPath; + } + } + else if (enumerationType == EnumerationType.Project && IsProjectFile(path)) { + yield return path; + } + yield break; + } + + // Handle directory case + var pathRooted = DirExt.EnsureRooted(path, Environment.CurrentDirectory); + if (!Directory.Exists(pathRooted)) { + ErrorSink.AddError($"Input path {path} (translated to {pathRooted}) not found."); + yield break; + } + + if (enumerationType == EnumerationType.Sln) { + await foreach (var projectPath in EnumerateProjectsFromSolutions(pathRooted)) { + yield return projectPath; + } + } + else { + await foreach (var projectPath in EnumerateProjectFiles(pathRooted)) { + yield return projectPath; + } + } + } + + /// + /// Enumerates solution files in the specified directory and extracts all project paths + /// + private async IAsyncEnumerable EnumerateProjectsFromSolutions(string directoryPath) { + var solutionFiles = await GetSolutionFilesAsync(directoryPath); + + // Process solutions in parallel for better performance + var tasks = solutionFiles.Select(async slnPath => { + var projectPaths = new List(); + await foreach (var projectPath in EnumerateProjectsFromSolution(slnPath)) { + projectPaths.Add(projectPath); + } + return projectPaths; + }); + + var results = await Task.WhenAll(tasks); + + foreach (var projectPaths in results) { + foreach (var projectPath in projectPaths) { + yield return projectPath; + } + } + } + + /// + /// Gets all solution files from the directory + /// + private async Task> GetSolutionFilesAsync(string directoryPath) { + return await Task.Run(() => { + var fileSearcher = Directory.EnumerateFiles(directoryPath, "*.sln?", new EnumerationOptions { + IgnoreInaccessible = true, + MatchCasing = MatchCasing.CaseInsensitive, + MatchType = MatchType.Win32, + MaxRecursionDepth = Options.Depth, + RecurseSubdirectories = true, + ReturnSpecialDirectories = false + }); + + return fileSearcher.Where(IsSolutionFile).ToList(); + }); + } + + /// + /// Enumerates all project paths from a single solution file + /// + private async IAsyncEnumerable EnumerateProjectsFromSolution(string slnPath) { + SolutionFile? solution = null; + var sln = new Sln(slnPath); + + try { + solution = await Task.Run(() => SolutionFile.Parse(slnPath)); + } + catch (Exception xcptn) { + ErrorSink.AddError($"Failed to parse solution file.", exception: xcptn, sln: sln); + yield break; + } + + foreach (var project in solution.ProjectsInOrder + .Where(p => File.Exists(p.AbsolutePath) && + p.ProjectType == SolutionProjectType.KnownToBeMSBuildFormat && + IsProjectFile(p.AbsolutePath))) { + yield return project.AbsolutePath; + } + } + + /// + /// Enumerates project files directly from the directory + /// + private async IAsyncEnumerable EnumerateProjectFiles(string directoryPath) { + var projectFiles = await GetProjectFilesAsync(directoryPath); + + foreach (var projectFile in projectFiles) { + yield return projectFile; + } + } + + /// + /// Gets all project files from the directory + /// + private async Task> GetProjectFilesAsync(string directoryPath) { + return await Task.Run(() => { + var patterns = new[] { "*.csproj", "*.vbproj", "*.sqlproj", "*.fsproj", "*.vcxproj" }; + var projectFiles = new List(); + + foreach (var pattern in patterns) { + var fileSearcher = Directory.EnumerateFiles(directoryPath, pattern, new EnumerationOptions { + IgnoreInaccessible = true, + MatchCasing = MatchCasing.CaseInsensitive, + MatchType = MatchType.Win32, + MaxRecursionDepth = Options.Depth, + RecurseSubdirectories = true, + ReturnSpecialDirectories = false + }); + + projectFiles.AddRange(fileSearcher); + } + + return projectFiles.Distinct().ToList(); + }); + } + + /// + /// Checks if a file is a supported solution file format + /// + private static bool IsSolutionFile(string filePath) { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return extension == ".sln" || extension == ".slnf" || extension == ".slnx"; + } + + /// + /// Checks if a file is a supported MSBuild project file format + /// + private static bool IsProjectFile(string filePath) { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return extension switch { + ".csproj" => true, + ".vbproj" => true, + ".sqlproj" => true, + ".fsproj" => true, + ".vcxproj" => true, + _ => false + }; + } + + /// + /// Enumerates project configurations based on the specified enumeration type + /// This method performs the same logic as SlnParser.ParseSolution, extracting + /// project configurations from solution files or creating default configurations for projects + /// + /// Base directory path to scan + /// Type of files to enumerate (Sln or Project) + /// Whether to create a default Debug configuration when no configurations are found + /// Async enumerable of project configurations + public async IAsyncEnumerable Enumerate(string path, EnumerationType enumerationType, bool createDefaultDebugConfiguration = true) { + if (string.IsNullOrWhiteSpace(path)) { + yield break; + } + + path = DirExt.EnsureRooted(path, Environment.CurrentDirectory); + // Handle single file case + if (File.Exists(path)) { + if (enumerationType.HasFlag(EnumerationType.Sln) && IsSolutionFile(path)) { + await foreach (var projCfg in EnumerateProjCfgFromSolution(path, createDefaultDebugConfiguration)) { + yield return projCfg; + } + } + else if (enumerationType.HasFlag(EnumerationType.Project) && IsProjectFile(path)) { + await foreach (var projCfg in CreateDefaultProjCfgForProject(path, createDefaultDebugConfiguration)) { + yield return projCfg; + } + } + yield break; + } + + // Handle directory case + var pathRooted = DirExt.EnsureRooted(path, Environment.CurrentDirectory); + if (!Directory.Exists(pathRooted)) { + ErrorSink.AddError($"Input path {path} (translated to {pathRooted}) not found."); + yield break; + } + + if (enumerationType.HasFlag(EnumerationType.Sln)) { + await foreach (var projCfg in EnumerateProjCfgFromSolutions(pathRooted, createDefaultDebugConfiguration)) { + yield return projCfg; + } + } + + if (enumerationType.HasFlag(EnumerationType.Project)) { + await foreach (var projCfg in EnumerateProjCfgFromProjects(pathRooted, createDefaultDebugConfiguration)) { + yield return projCfg; + } + } + } + + /// + /// Enumerates solution files in the specified directory and extracts all project configurations + /// + private async IAsyncEnumerable EnumerateProjCfgFromSolutions(string directoryPath, bool createDefaultDebugConfiguration) { + var solutionFiles = await GetSolutionFilesAsync(directoryPath); + + // Process solutions sequentially to maintain order (like SlnParser.ParseSolution does) + foreach (var slnPath in solutionFiles) { + await foreach (var projCfg in EnumerateProjCfgFromSolution(slnPath, createDefaultDebugConfiguration)) { + yield return projCfg; + } + } + } + + /// + /// Enumerates all project configurations from a single solution file + /// This method replicates the logic from SlnParser.ParseSolution + /// + private async IAsyncEnumerable EnumerateProjCfgFromSolution(string slnPath, bool createDefaultDebugConfiguration) { + SolutionFile? solution = null; + var sln = new Sln(slnPath); + + try { + solution = await Task.Run(() => SolutionFile.Parse(slnPath)); + } + catch (Exception xcptn) { + ErrorSink.AddError($"Failed to parse solution file.", exception: xcptn, sln: sln); + yield break; + } + + // Process projects in order, similar to SlnParser.ParseSolution + foreach (var project in solution.ProjectsInOrder + .Where(p => File.Exists(p.AbsolutePath) && + p.ProjectType == SolutionProjectType.KnownToBeMSBuildFormat && + IsProjectFile(p.AbsolutePath))) { + + var fullyQualifiedPath = project.AbsolutePath; + var queryPlatform = false; + + // Determine if we need to query platform based on project type (same logic as SlnParser) + switch (Path.GetExtension(fullyQualifiedPath)!.ToLowerInvariant()) { + case ".csproj": + case ".fsproj": + case ".sqlproj": + case ".vbproj": // old proj do not have the + queryPlatform = false; + break; + case ".vcxproj": + queryPlatform = true; + break; + default: + continue; + } + + var proj = new Proj(fullyQualifiedPath, sln); + + if (project.ProjectConfigurations is { }) { + // Extract configurations from solution, similar to SlnParser.ParseSolution + foreach (var cfg in project.ProjectConfigurations + .Select(x => (x.Value?.ConfigurationName, queryPlatform ? x.Value?.PlatformName : null)) + .Where(x => x.ConfigurationName is not null) + .Distinct()) { + var projCfg = new ProjCfg(proj, cfg.ConfigurationName!, cfg.Item2); + yield return projCfg; + } + } + else { + // Create default configurations when none are found, same as SlnParser + if (createDefaultDebugConfiguration) { + var projCfg = new ProjCfg(proj, "Debug", null); + yield return projCfg; + } + var projCfgRelease = new ProjCfg(proj, "Release", null); + yield return projCfgRelease; + } + } + } + + /// + /// Enumerates project files directly from the directory and creates default configurations + /// + private async IAsyncEnumerable EnumerateProjCfgFromProjects(string directoryPath, bool createDefaultDebugConfiguration) { + var projectFiles = await GetProjectFilesAsync(directoryPath); + + foreach (var projectFile in projectFiles) { + await foreach (var projCfg in CreateDefaultProjCfgForProject(projectFile, createDefaultDebugConfiguration)) { + yield return projCfg; + } + } + } + + /// + /// Creates default project configurations for a single project file + /// + private async IAsyncEnumerable CreateDefaultProjCfgForProject(string projectPath, bool createDefaultDebugConfiguration) { + // Since we're not parsing from a solution, we create a standalone project + var proj = new Proj(projectPath, null); + + // Create default configurations + if (createDefaultDebugConfiguration) { + yield return new ProjCfg(proj, "Debug", null); + } + yield return new ProjCfg(proj, "Release", null); + + await Task.CompletedTask; // Make this async for consistency + } +} + +/// +/// Extension methods for async enumerable operations +/// +internal static class AsyncEnumerableExtensions { + public static async Task> ToListAsync(this IAsyncEnumerable source) { + var list = new List(); + await foreach (var item in source) { + list.Add(item); + } + return list; + } +} \ No newline at end of file diff --git a/bld/Models/DirectoryModels.cs b/bld/Models/DirectoryModels.cs index 4b26c5e..fca11cd 100644 --- a/bld/Models/DirectoryModels.cs +++ b/bld/Models/DirectoryModels.cs @@ -23,6 +23,24 @@ internal enum ProjectType { Vcxproj, } +[Flags] +/// +/// Enumeration types for file scanning +/// +internal enum EnumerationType { + /// + /// Scan for solution files (.sln, .slnx, .slnf) + /// + Sln = 0x1, + + /// + /// Scan for project files (.csproj, .vbproj, .sqlproj, etc.) + /// + Project = 0x2, + + Both = Sln | Project +} + /// /// Represents a directory with associated project information for cleaning /// diff --git a/bld/Services/CleaningApplication.cs b/bld/Services/CleaningApplication.cs index 3fd75a2..7aa8d87 100644 --- a/bld/Services/CleaningApplication.cs +++ b/bld/Services/CleaningApplication.cs @@ -46,7 +46,13 @@ public async Task RunAsync(string[] rootPaths, CleaningOptions options) { try { foreach (var rootPath in rootPaths) { - // todo check for csproj + // Using the new Enumerator class would look like this: + // var enumerator = new Enumerator(options, errorSink); + // await foreach (var projectPath in enumerator.EnumerateProjectPaths(rootPath, EnumerationType.Sln)) { + // // Process each project directly without needing to parse solutions first + // } + + // Current implementation using SlnScanner await foreach (var sln in scanner.Enumerate(rootPath)) { await _console.StartStatusAsync($"Processing solution {sln}", async ctx => { var curProj = default(string); diff --git a/bld/Services/EnumeratorDemoService.cs b/bld/Services/EnumeratorDemoService.cs new file mode 100644 index 0000000..7af42a5 --- /dev/null +++ b/bld/Services/EnumeratorDemoService.cs @@ -0,0 +1,62 @@ +using bld.Infrastructure; +using bld.Models; + +namespace bld.Services; + +/// +/// Demonstration service showing the new Enumerator functionality +/// +internal class EnumeratorDemoService(IConsoleOutput console, CleaningOptions options) { + + /// + /// Demonstrates enumeration of solution files and their projects + /// + public async Task EnumerateSolutionProjectsAsync(string rootPath) { + var errorSink = new ErrorSink(console); + var enumerator = new Enumerator(options, errorSink); + + console.WriteInfo($"Enumerating projects from solutions in: {rootPath}"); + + var projectCount = 0; + await foreach (var projectPath in enumerator.EnumerateProjectPaths(rootPath, EnumerationType.Sln)) { + projectCount++; + console.WriteInfo($" {projectCount}. {projectPath}"); + } + + console.WriteInfo($"Found {projectCount} projects from solution files"); + } + + /// + /// Demonstrates enumeration of project files directly + /// + public async Task EnumerateProjectFilesAsync(string rootPath) { + var errorSink = new ErrorSink(console); + var enumerator = new Enumerator(options, errorSink); + + console.WriteInfo($"Enumerating project files directly in: {rootPath}"); + + var projectCount = 0; + await foreach (var projectPath in enumerator.EnumerateProjectPaths(rootPath, EnumerationType.Project)) { + projectCount++; + console.WriteInfo($" {projectCount}. {projectPath}"); + } + + console.WriteInfo($"Found {projectCount} project files"); + } + + /// + /// Compares the two enumeration approaches + /// + public async Task CompareEnumerationApproachesAsync(string rootPath) { + console.WriteRule("Enumerator Comparison Demo"); + + console.WriteInfo("1. Projects from Solutions (.sln, .slnx, .slnf):"); + await EnumerateSolutionProjectsAsync(rootPath); + + console.WriteInfo(""); + console.WriteInfo("2. Projects from Direct File Search (.csproj, .vbproj, .sqlproj, etc.):"); + await EnumerateProjectFilesAsync(rootPath); + + console.WriteRule("Demo Complete"); + } +} \ No newline at end of file diff --git a/bld/Services/OutdatedService.cs b/bld/Services/OutdatedService.cs index d31923f..5549027 100644 --- a/bld/Services/OutdatedService.cs +++ b/bld/Services/OutdatedService.cs @@ -28,7 +28,8 @@ public async Task CheckOutdatedPackagesAsync(string rootPath, bool updatePa _console.WriteInfo("Checking for outdated packages..."); var errorSink = new ErrorSink(_console); - var slnScanner = new SlnScanner(_options, errorSink); + //var slnScanner = new SlnScanner(_options, errorSink); + var slnScanner = new Enumerator(_options, errorSink); var slnParser = new SlnParser(_console, errorSink); var fileSystem = new FileSystem(_console, errorSink); var cache = new ProjCfgCache(_console); @@ -43,9 +44,11 @@ public async Task CheckOutdatedPackagesAsync(string rootPath, bool updatePa try { var projParser = new ProjParser(_console, errorSink, _options); - await foreach (var slnPath in slnScanner.Enumerate(rootPath)) { - await _console.StartStatusAsync($"Processing solution {slnPath}", async ctx => { - await foreach (var projCfg in slnParser.ParseSolution(slnPath, fileSystem)) { + await foreach (var projCfg in slnScanner.Enumerate(rootPath, EnumerationType.Both)) { + //await foreach (var slnPath in slnScanner.Enumerate(rootPath)) { + //await _console.StartStatusAsync($"Processing solution {slnPath}", async ctx => { + //await foreach (var projCfg in slnParser.ParseSolution(slnPath, fileSystem)) + { var packageRefs = new PackageInfoContainer(); // new List(); // Only process "Release" configuration as per spec // todo 20250830 aggregate @@ -82,7 +85,7 @@ await _console.StartStatusAsync($"Processing solution {slnPath}", async ctx => { list.Add(pkg); } } - }); + //}); } } catch (Exception ex) { diff --git a/bld/bld.csproj b/bld/bld.csproj index 5c9323f..6fd58b0 100644 --- a/bld/bld.csproj +++ b/bld/bld.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 Major latest enable