From 1c68264c356e59e88de72f6d066cea54bb5d6d11 Mon Sep 17 00:00:00 2001 From: kasperk81 <83082615+kasperk81@users.noreply.github.com> Date: Mon, 1 Dec 2025 01:03:25 +0000 Subject: [PATCH] Implement globbing support for .slnx files Adds glob pattern support to File and Project Path attributes without requiring schema changes. Users can include items using standard glob patterns (e.g., `Path="**/*.cs"` to match all C# files recursively) and exclude them using the `!` prefix (e.g., `Path="!**/bin/**"` to exclude build output directories). Patterns are expanded at parse time using Microsoft.Extensions.FileSystemGlobbing, preserving the user's original file ordering and path separators. --- Directory.Packages.props | 1 + ...ft.VisualStudio.SolutionPersistence.csproj | 4 + .../Serializer/Xml/XmlDecorators/SlnxFile.cs | 163 ++++++ .../Serializer/Xml/XmlDecorators/XmlFolder.cs | 16 +- .../Serialization/GlobPatternsTests.cs | 539 ++++++++++++++++++ 5 files changed, 719 insertions(+), 4 deletions(-) create mode 100644 test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/GlobPatternsTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index e5423a30..47cad822 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Microsoft.VisualStudio.SolutionPersistence.csproj b/src/Microsoft.VisualStudio.SolutionPersistence/Microsoft.VisualStudio.SolutionPersistence.csproj index 00df45f7..3f0f455d 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Microsoft.VisualStudio.SolutionPersistence.csproj +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Microsoft.VisualStudio.SolutionPersistence.csproj @@ -18,6 +18,10 @@ + + + + diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/SlnxFile.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/SlnxFile.cs index 96a7a6c6..c5fab562 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/SlnxFile.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/SlnxFile.cs @@ -2,7 +2,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Diagnostics; +using System.Linq; using System.Xml; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; using Microsoft.VisualStudio.SolutionPersistence.Model; using Microsoft.VisualStudio.SolutionPersistence.Utilities; @@ -29,6 +32,10 @@ internal SlnxFile( XmlElement? xmlSolution = this.Document.DocumentElement; if (xmlSolution is not null && Keywords.ToKeyword(xmlSolution.Name) == Keyword.Solution) { + // Expand ALL File/Project path attributes with glob patterns at raw XML parsing time + // This happens BEFORE decorators are created, so all paths (literal and glob) go through expansion + this.ExpandGlobPatternsInXml(xmlSolution); + this.Solution = new XmlSolution(this, xmlSolution); this.Solution.UpdateFromXml(); @@ -112,6 +119,162 @@ internal string ToXmlString() return this.Document.OuterXml; } + private void ExpandGlobPatternsInElement(XmlElement element, string baseDirectory, HashSet allResolvedPaths) + { + // Process File and Project elements in this element first + // We need to handle them in order to support excludes correctly. + // We iterate through a snapshot of children, but modify the live DOM. + List children = element.ChildNodes.Cast().ToList(); + + foreach (XmlNode childNode in children) + { + // Skip if the node was already removed from the DOM + if (childNode.ParentNode is null) + { + continue; + } + + if (childNode is not XmlElement childElement) + { + continue; + } + + string elementName = childElement.Name; + if (elementName != "File" && elementName != "Project") + { + continue; + } + + string? pathAttribute = childElement.GetAttribute("Path"); + if (string.IsNullOrEmpty(pathAttribute)) + { + continue; + } + + string modelPath = PathExtensions.ConvertToModel(pathAttribute); + + if (modelPath.StartsWith('!')) + { + // Exclude pattern + this.ApplyExcludePattern(element, childElement, modelPath.Substring(1), baseDirectory, allResolvedPaths); + } + else + { + // Include pattern + this.ApplyIncludePattern(element, childElement, modelPath, baseDirectory, allResolvedPaths); + } + } + + // Process recursively for nested folders + foreach (XmlNode childNode in element.ChildNodes) + { + if (childNode is XmlElement childElement && childElement.Name != "File" && childElement.Name != "Project") + { + this.ExpandGlobPatternsInElement(childElement, baseDirectory, allResolvedPaths); + } + } + } + + private void ApplyExcludePattern(XmlElement parent, XmlElement excludeElement, string pattern, string baseDirectory, HashSet allResolvedPaths) + { + // Remove the exclude element itself + _ = parent.RemoveChild(excludeElement); + + // Find all siblings of the same type that match the pattern + string targetElementName = excludeElement.Name; + Matcher matcher = new(StringComparison.OrdinalIgnoreCase, preserveFilterOrder: true); + _ = matcher.AddInclude(pattern); // We use Include here because we want to match the file path against this pattern + + // We need to iterate over current children of the parent + List siblingsToCheck = []; + foreach (XmlNode node in parent.ChildNodes) + { + if (node is XmlElement el && el.Name == targetElementName) + { + siblingsToCheck.Add(el); + } + } + + foreach (XmlElement sibling in siblingsToCheck) + { + string? path = sibling.GetAttribute("Path"); + if (string.IsNullOrEmpty(path)) + { + continue; + } + + string modelPath = PathExtensions.ConvertToModel(path); + + // Check if this path matches the exclude pattern + PatternMatchingResult result = matcher.Match(modelPath); + if (result.HasMatches) + { + _ = parent.RemoveChild(sibling); + _ = allResolvedPaths.Remove(modelPath); + } + } + } + + private void ApplyIncludePattern(XmlElement parent, XmlElement includeElement, string pattern, string baseDirectory, HashSet allResolvedPaths) + { + Matcher matcher = new(StringComparison.OrdinalIgnoreCase, preserveFilterOrder: true); + _ = matcher.AddInclude(pattern); + + IEnumerable globResults = matcher.GetResultsInFullPath(baseDirectory); + List resolvedPaths = []; + + foreach (string absolutePath in globResults) + { + string relativePath = Path.GetRelativePath(baseDirectory, absolutePath); + if (allResolvedPaths.Add(relativePath)) + { + resolvedPaths.Add(relativePath); + } + } + + // Handle no matches + if (resolvedPaths.Count == 0) + { + // If it's a literal path (no wildcards), preserve it even if it doesn't exist (or wasn't found) + if (allResolvedPaths.Add(pattern)) + { + resolvedPaths.Add(pattern); + } + } + + // Update XML + if (resolvedPaths.Count > 0) + { + // Insert new elements + foreach (string path in resolvedPaths) + { + XmlElement newElement = (XmlElement)includeElement.CloneNode(deep: true); + newElement.SetAttribute("Path", PathExtensions.ConvertModelToForwardSlashPath(path)); + _ = parent.InsertBefore(newElement, includeElement); + } + } + + // Remove the original element + _ = parent.RemoveChild(includeElement); + } + + private void ExpandGlobPatternsInXml(XmlElement solutionElement) + { + if (this.FullPath is null) + { + // Can't resolve relative paths without a base directory + return; + } + + string baseDirectory = Path.GetDirectoryName(this.FullPath) ?? Environment.CurrentDirectory; + + // Track all resolved paths to avoid duplicates across the entire solution + HashSet allResolvedPaths = new(StringComparer.OrdinalIgnoreCase); + + // Process all File and Project elements recursively throughout the solution + this.ExpandGlobPatternsInElement(solutionElement, baseDirectory, allResolvedPaths); + } + // Fill out default values. private SlnxSerializerSettings GetDefaultSerializationSettings(SlnxSerializerSettings inputSettings) { diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs index dd75d2d1..c3eba865 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs @@ -82,11 +82,19 @@ internal void AddToModel(SolutionModel solutionModel, List<(XmlProject XmlProjec SolutionFolderModel folderModel = solutionModel.AddFolder(this.Name); folderModel.Id = this.Id; - foreach (XmlFile file in this.files.GetItems()) + // Iterate over the XML children to preserve order, but use the decorators for data + foreach (XmlNode childNode in this.XmlElement.ChildNodes) { - string modelPath = PathExtensions.ConvertToModel(file.Path); - folderModel.AddFile(modelPath); - this.Root.UserPaths[modelPath] = file.Path; + if (childNode is XmlElement childElement && childElement.Name == "File") + { + string? path = childElement.GetAttribute("Path"); + if (!string.IsNullOrEmpty(path)) + { + string modelPath = PathExtensions.ConvertToModel(path); + folderModel.AddFile(modelPath); + this.Root.UserPaths[modelPath] = path; + } + } } foreach (XmlProperties properties in this.propertyBags.GetItems()) diff --git a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/GlobPatternsTests.cs b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/GlobPatternsTests.cs new file mode 100644 index 00000000..1e68353f --- /dev/null +++ b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/GlobPatternsTests.cs @@ -0,0 +1,539 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.VisualStudio.SolutionPersistence.Model; + +namespace Serialization; + +/// +/// Tests for glob pattern expansion functionality in SLNX files. +/// +public sealed class GlobPatternsTests +{ + /// + /// Tests that literal file paths (no glob patterns) are preserved as-is. + /// + [Fact] + public async Task LiteralPathsPreservedAsync() + { + string slnxContent = """ + + + + + + + + """; + + using MemoryStream stream = new(Encoding.UTF8.GetBytes(slnxContent)); + SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(stream, CancellationToken.None); + + SolutionFolderModel? folder = model.FindFolder("/test/"); + Assert.NotNull(folder); + Assert.Equal(3, folder.Files?.Count); + + List expectedFiles = ["file1.txt", "src/file2.cs", "docs/readme.md"]; + Assert.Equal(expectedFiles, folder.Files); + } + + /// + /// Tests that glob patterns are expanded to match actual files in the file system. + /// + [Fact] + public async Task GlobPatternsExpandedAsync() + { + // Create a temporary directory structure for testing + string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_glob_test_{Guid.NewGuid():N}"); + try + { + // Create test directory structure + _ = Directory.CreateDirectory(tempDir); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "src")); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "tests")); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "docs")); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "obj")); + + // Create test files + File.WriteAllText(Path.Combine(tempDir, "src", "Program.cs"), "// test"); + File.WriteAllText(Path.Combine(tempDir, "src", "Helper.cs"), "// test"); + File.WriteAllText(Path.Combine(tempDir, "tests", "Test1.cs"), "// test"); + File.WriteAllText(Path.Combine(tempDir, "docs", "README.md"), "# test"); + File.WriteAllText(Path.Combine(tempDir, "obj", "temp.dll"), "temp"); + + // Create SLNX with glob patterns + string slnxContent = """ + + + + + + + """; + + string slnxPath = Path.Combine(tempDir, "test.slnx"); + File.WriteAllText(slnxPath, slnxContent); + + // Parse the SLNX file + SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None); + + // Verify glob patterns were expanded + SolutionFolderModel? folder = model.FindFolder("/src/"); + Assert.NotNull(folder); + Assert.NotNull(folder.Files); + Assert.True(folder.Files.Count >= 3); // At least the files we created + + // Should include CS files + Assert.Contains(folder.Files, f => f.Contains("Program.cs")); + Assert.Contains(folder.Files, f => f.Contains("Helper.cs")); + Assert.Contains(folder.Files, f => f.Contains("Test1.cs")); + + // Should include MD files + Assert.Contains(folder.Files, f => f.Contains("README.md")); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests that exclude patterns (prefixed with !) work correctly. + /// + [Fact] + public async Task ExcludePatternsWorkAsync() + { + // Create a temporary directory structure for testing + string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_exclude_test_{Guid.NewGuid():N}"); + try + { + // Create test directory structure + _ = Directory.CreateDirectory(tempDir); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "src")); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "obj")); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "bin")); + + // Create test files + File.WriteAllText(Path.Combine(tempDir, "src", "Program.cs"), "// test"); + File.WriteAllText(Path.Combine(tempDir, "obj", "Program.dll"), "temp"); + File.WriteAllText(Path.Combine(tempDir, "bin", "Program.exe"), "temp"); + + // Create SLNX with include and exclude patterns + string slnxContent = """ + + + + + + + + """; + + string slnxPath = Path.Combine(tempDir, "test.slnx"); + File.WriteAllText(slnxPath, slnxContent); + + // Parse the SLNX file + SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None); + + // Verify exclude patterns worked + SolutionFolderModel? folder = model.FindFolder("/files/"); + Assert.NotNull(folder); + Assert.NotNull(folder.Files); + + // Should include source files + Assert.Contains(folder.Files, f => f.Contains("Program.cs")); + + // Should NOT include obj/bin files due to exclusion + Assert.DoesNotContain(folder.Files, f => f.Contains("obj/")); + Assert.DoesNotContain(folder.Files, f => f.Contains("bin/")); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests mixed literal paths and glob patterns. + /// + [Fact] + public async Task MixedPatternsWorkAsync() + { + // Create a temporary directory structure for testing + string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_mixed_test_{Guid.NewGuid():N}"); + try + { + // Create test directory structure + _ = Directory.CreateDirectory(tempDir); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "src")); + + // Create test files + File.WriteAllText(Path.Combine(tempDir, "LICENSE"), "MIT License"); + File.WriteAllText(Path.Combine(tempDir, "src", "Program.cs"), "// test"); + File.WriteAllText(Path.Combine(tempDir, "src", "Helper.cs"), "// test"); + + // Create SLNX with mixed patterns + string slnxContent = """ + + + + + + + + """; + + string slnxPath = Path.Combine(tempDir, "test.slnx"); + File.WriteAllText(slnxPath, slnxContent); + + // Parse the SLNX file + SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None); + + // Verify mixed patterns worked + SolutionFolderModel? folder = model.FindFolder("/mixed/"); + Assert.NotNull(folder); + Assert.NotNull(folder.Files); + + // Should include literal file that exists + Assert.Contains(folder.Files, f => f == "LICENSE"); + + // Should include glob-matched files + Assert.Contains(folder.Files, f => f.Contains("Program.cs")); + Assert.Contains(folder.Files, f => f.Contains("Helper.cs")); + + // Should NOT include literal file that doesn't exist - Matcher only returns existing files + Assert.Contains(folder.Files, f => f == "README.md"); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests that forward slash paths are preserved in the output. + /// + [Fact] + public async Task ForwardSlashPathsPreservedAsync() + { + // Create a temporary directory structure for testing + string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_path_test_{Guid.NewGuid():N}"); + try + { + // Create test directory structure + _ = Directory.CreateDirectory(tempDir); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "src", "nested")); + + // Create test files + File.WriteAllText(Path.Combine(tempDir, "src", "nested", "deep.cs"), "// test"); + + // Create SLNX with glob pattern + string slnxContent = """ + + + + + + """; + + string slnxPath = Path.Combine(tempDir, "test.slnx"); + File.WriteAllText(slnxPath, slnxContent); + + // Parse the SLNX file + SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None); + + // Verify forward slashes are preserved + SolutionFolderModel? folder = model.FindFolder("/paths/"); + Assert.NotNull(folder); + Assert.NotNull(folder.Files); + + // Should use forward slashes in the resolved path + string? matchedFile = null; + foreach (string file in folder.Files) + { + if (file.Contains("deep.cs")) + { + matchedFile = file; + break; + } + } + + Assert.NotNull(matchedFile); + Assert.Contains("/", matchedFile); // Should contain forward slashes + Assert.DoesNotContain("\\", matchedFile); // Should not contain backslashes + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests that literal project paths (no glob patterns) are preserved as-is. + /// + [Fact] + public async Task LiteralProjectPathsPreservedAsync() + { + string slnxContent = """ + + + + + + + + """; + + using MemoryStream stream = new(Encoding.UTF8.GetBytes(slnxContent)); + SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(stream, CancellationToken.None); + + SolutionFolderModel? folder = model.FindFolder("/projects/"); + Assert.NotNull(folder); + + // Get projects in this folder from the solution's SolutionProjects collection + List projectsInFolder = []; + foreach (SolutionProjectModel project in model.SolutionProjects) + { + if (ReferenceEquals(project.Parent, folder)) + { + projectsInFolder.Add(project.FilePath); + } + } + + Assert.Equal(3, projectsInFolder.Count); + + List expectedProjects = ["src/Project1/Project1.csproj", "src/Project2/Project2.csproj", "tests/TestProject/TestProject.csproj"]; + Assert.Equal(expectedProjects, projectsInFolder); + } + + /// + /// Tests that glob patterns work for Project elements. + /// + [Fact] + public async Task ProjectGlobPatternsExpandedAsync() + { + // Create a temporary directory structure for testing + string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_project_glob_test_{Guid.NewGuid():N}"); + try + { + // Create test directory structure + _ = Directory.CreateDirectory(tempDir); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "src", "WebApp")); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "src", "Library")); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "tests", "UnitTests")); + + // Create test project files + File.WriteAllText(Path.Combine(tempDir, "src", "WebApp", "WebApp.csproj"), ""); + File.WriteAllText(Path.Combine(tempDir, "src", "Library", "Library.csproj"), ""); + File.WriteAllText(Path.Combine(tempDir, "tests", "UnitTests", "UnitTests.csproj"), ""); + + // Create SLNX with Project glob patterns + string slnxContent = """ + + + + + + """; + + string slnxPath = Path.Combine(tempDir, "test.slnx"); + File.WriteAllText(slnxPath, slnxContent); + + // Parse the SLNX file + SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None); + + // Verify Project glob patterns were expanded + SolutionFolderModel? folder = model.FindFolder("/all-projects/"); + Assert.NotNull(folder); + + // Get projects in this folder from the solution's SolutionProjects collection + List projectsInFolder = []; + foreach (SolutionProjectModel project in model.SolutionProjects) + { + if (ReferenceEquals(project.Parent, folder)) + { + projectsInFolder.Add(project.FilePath); + } + } + + Assert.Equal(3, projectsInFolder.Count); + + // Should include all project files + Assert.Contains(projectsInFolder, p => p.Contains("WebApp.csproj")); + Assert.Contains(projectsInFolder, p => p.Contains("Library.csproj")); + Assert.Contains(projectsInFolder, p => p.Contains("UnitTests.csproj")); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests mixed literal and glob Project patterns. + /// + [Fact] + public async Task MixedProjectPatternsWorkAsync() + { + // Create a temporary directory structure for testing + string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_mixed_project_test_{Guid.NewGuid():N}"); + try + { + // Create test directory structure + _ = Directory.CreateDirectory(tempDir); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "src")); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "tests")); + + // Create test project files + File.WriteAllText(Path.Combine(tempDir, "src", "Main.csproj"), ""); + File.WriteAllText(Path.Combine(tempDir, "tests", "Tests.csproj"), ""); + File.WriteAllText(Path.Combine(tempDir, "Special.csproj"), ""); + + // Create SLNX with mixed Project patterns + string slnxContent = """ + + + + + + + + """; + + string slnxPath = Path.Combine(tempDir, "test.slnx"); + File.WriteAllText(slnxPath, slnxContent); + + // Parse the SLNX file + SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None); + + // Verify mixed Project patterns worked + SolutionFolderModel? folder = model.FindFolder("/mixed-projects/"); + Assert.NotNull(folder); + + // Get projects in this folder from the solution's SolutionProjects collection + List projectsInFolder = []; + foreach (SolutionProjectModel project in model.SolutionProjects) + { + if (ReferenceEquals(project.Parent, folder)) + { + projectsInFolder.Add(project.FilePath); + } + } + + // Should include literal project that exists + Assert.Contains(projectsInFolder, p => p == "Special.csproj"); + + // Should include glob-matched projects + Assert.Contains(projectsInFolder, p => p.Contains("Main.csproj")); + + // Should include literal project that doesn't exist (preserved as-is) + Assert.Contains(projectsInFolder, p => p == "NonExistent.csproj"); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests that both root-level and folder-level projects support globbing. + /// + [Fact] + public async Task BothRootAndFolderProjectGlobbingAsync() + { + // Create a temporary directory structure for testing + string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_root_folder_test_{Guid.NewGuid():N}"); + try + { + // Create test directory structure + _ = Directory.CreateDirectory(tempDir); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "root")); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "src", "libraries")); + _ = Directory.CreateDirectory(Path.Combine(tempDir, "tests")); + + // Create test project files + File.WriteAllText(Path.Combine(tempDir, "root", "RootProject.csproj"), ""); + File.WriteAllText(Path.Combine(tempDir, "src", "libraries", "Library.csproj"), ""); + File.WriteAllText(Path.Combine(tempDir, "tests", "UnitTests.csproj"), ""); + + // Create SLNX with both root and folder project patterns + string slnxContent = """ + + + + + + + """; + + string slnxPath = Path.Combine(tempDir, "test.slnx"); + File.WriteAllText(slnxPath, slnxContent); + + // Parse the SLNX file + SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None); + + // Check root-level projects + List rootProjects = []; + foreach (SolutionProjectModel project in model.SolutionProjects) + { + // Root level projects + if (project.Parent == null) + { + rootProjects.Add(project.FilePath); + } + } + + // Should include root glob-matched project + Assert.Contains(rootProjects, p => p.Contains("RootProject.csproj")); + + // Check folder-level projects + SolutionFolderModel? srcFolder = model.FindFolder("/src/"); + Assert.NotNull(srcFolder); + + List folderProjects = []; + foreach (SolutionProjectModel project in model.SolutionProjects) + { + if (ReferenceEquals(project.Parent, srcFolder)) + { + folderProjects.Add(project.FilePath); + } + } + + // Should include folder glob-matched projects + Assert.Contains(folderProjects, p => p.Contains("Library.csproj")); + Assert.Contains(folderProjects, p => p.Contains("UnitTests.csproj")); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } +}