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