From 1564a23444b9d55d562c58c9bc7b080e5d3e0ec0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:42:53 +0000 Subject: [PATCH 1/6] Initial plan From 4d22767c0fd03a7615721cba1760022d4427e29c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:49:51 +0000 Subject: [PATCH 2/6] Implement --required-files CLI argument with glob matching, tests, and README docs Co-authored-by: 304NotModified <5808377+304NotModified@users.noreply.github.com> Agent-Logs-Url: https://github.com/304NotModified/SLNX-validator/sessions/a91fd106-0ab8-4a61-966c-d18d183152f2 --- README.md | 49 ++++++ src/SLNX-validator/Program.cs | 16 +- src/SLNX-validator/RequiredFilesChecker.cs | 33 ++++ src/SLNX-validator/SLNX-validator.csproj | 1 + .../ProgramIntegrationTests.cs | 77 +++++++++ .../RequiredFilesCheckerTests.cs | 155 ++++++++++++++++++ 6 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 src/SLNX-validator/RequiredFilesChecker.cs create mode 100644 tests/SLNX-validator.Tests/RequiredFilesCheckerTests.cs diff --git a/README.md b/README.md index 4fc01c1..fccc09f 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,54 @@ slnx-validator MySolution.slnx --sonarqube-report-file sonar-issues.json --conti Always exits with code `0`, even when validation errors are found. Useful in CI pipelines where SonarQube handles the failure decision. Default: `false`. +### `--required-files` + +Verify that a set of files or directories matching glob patterns exist before the tool runs. If any pattern produces no match, or if a matched path does not exist on disk, the tool exits with code `2`. + +**Syntax** + +``` +--required-files ";;..." +``` + +Patterns are separated by `;`. Patterns starting with `!` are exclusions. Pattern order matters: a later pattern can override an earlier one. + +**Supported glob syntax** + +| Pattern | Meaning | Example | +|---|---|---| +| `*` | Any file in the current directory (no path separator) | `doc/*.md` | +| `**` | Any depth of subdirectories | `src/**/*.cs` | +| `!pattern` | Exclude matching paths | `!**/bin/**` | +| `dir/` | Match a directory and its contents | `docs/` | + +> **Note:** `{a,b}` alternation and `[abc]` character classes are not supported by this library. Use multiple patterns separated by `;` instead. +> For example, instead of `*.{cs,fs}`, use `**/*.cs;**/*.fs`. + +**Examples** + +Require all `.md` files under `doc/`: +``` +slnx-validator MySolution.slnx --required-files "doc/*.md" +``` + +Require all `.cs` files under `src/`, excluding the `bin` and `obj` folders: +``` +slnx-validator MySolution.slnx --required-files "src/**/*.cs;!**/bin/**;!**/obj/**" +``` + +Require a specific config file and the entire `docs/` directory: +``` +slnx-validator MySolution.slnx --required-files "appsettings.json;docs/" +``` + +**Exit codes** + +| Code | Description | +|------|-------------| +| `0` | All patterns matched and all matched paths exist on disk. | +| `2` | One or more patterns produced no matches, or a matched path does not exist on disk. | + ## SonarQube integration example ```powershell @@ -185,6 +233,7 @@ The following are **intentionally out of scope** because the toolchain already h | `SLNX011` | `ReferencedFileNotFound` | A file referenced in `` does not exist on disk. | | `SLNX012` | `InvalidWildcardUsage` | A `` contains a wildcard pattern (see [`examples/invalid-wildcard.slnx`](examples/invalid-wildcard.slnx)). | | `SLNX013` | `XsdViolation` | The XML structure violates the schema, e.g. `` inside `` (see [`examples/invalid-xsd.slnx`](examples/invalid-xsd.slnx)). | +| `SLNX020` | `RequiredFilesNotFound` | A `--required-files` pattern produced no matches, or a matched path does not exist on disk (exits with code `2`). | ## XSD Schema diff --git a/src/SLNX-validator/Program.cs b/src/SLNX-validator/Program.cs index c904754..6586e2e 100644 --- a/src/SLNX-validator/Program.cs +++ b/src/SLNX-validator/Program.cs @@ -23,11 +23,17 @@ public static async Task Main(string[] args) Description = "Continue and exit with code 0 even when validation errors are found." }; + var requiredFilesOption = new Option("--required-files") + { + Description = "Semicolon-separated glob patterns for required files and directories. The tool exits with code 2 if any pattern produces no match or a matched path does not exist." + }; + var rootCommand = new RootCommand("Validates .slnx solution files.") { inputArgument, sonarqubeReportOption, - continueOnErrorOption + continueOnErrorOption, + requiredFilesOption }; var services = new ServiceCollection() @@ -38,6 +44,14 @@ public static async Task Main(string[] args) rootCommand.SetAction(async (parseResult, cancellationToken) => { + var requiredFiles = parseResult.GetValue(requiredFilesOption); + if (requiredFiles is not null) + { + var checkResult = await RequiredFilesChecker.CheckAsync(requiredFiles, Environment.CurrentDirectory); + if (checkResult != 0) + return checkResult; + } + var input = parseResult.GetValue(inputArgument); var sonarqubeReport = parseResult.GetValue(sonarqubeReportOption); var continueOnError = parseResult.GetValue(continueOnErrorOption); diff --git a/src/SLNX-validator/RequiredFilesChecker.cs b/src/SLNX-validator/RequiredFilesChecker.cs new file mode 100644 index 0000000..ffa899b --- /dev/null +++ b/src/SLNX-validator/RequiredFilesChecker.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; + +namespace JulianVerdurmen.SlnxValidator; + +internal static class RequiredFilesChecker +{ + public static async Task CheckAsync(string patternsRaw, string rootDirectory) + { + var patterns = patternsRaw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var matcher = new Matcher(StringComparison.OrdinalIgnoreCase, preserveFilterOrder: true); + + foreach (var pattern in patterns) + { + if (pattern.StartsWith('!')) + matcher.AddExclude(pattern[1..]); + else + matcher.AddInclude(pattern); + } + + var directoryInfo = new DirectoryInfoWrapper(new DirectoryInfo(rootDirectory)); + var result = matcher.Execute(directoryInfo); + + if (!result.HasMatches) + { + await Console.Error.WriteLineAsync($"[SLNX020] Required files check failed: no files matched the patterns: {patternsRaw}"); + return 2; + } + + return 0; + } +} diff --git a/src/SLNX-validator/SLNX-validator.csproj b/src/SLNX-validator/SLNX-validator.csproj index c3a81de..5b24c8b 100644 --- a/src/SLNX-validator/SLNX-validator.csproj +++ b/src/SLNX-validator/SLNX-validator.csproj @@ -27,6 +27,7 @@ + diff --git a/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs b/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs index d95b2f8..fc508d8 100644 --- a/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs +++ b/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs @@ -139,4 +139,81 @@ public async Task Invoke_WithBinaryFile_ReturnsNonZeroExitCode() File.Delete(path); } } + + [Test] + [NotInParallel("CurrentDirectory")] + public async Task Invoke_WithRequiredFiles_AllMatch_ReturnsZeroExitCode() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + var csprojPath = Path.Combine(tempDir, "App.csproj"); + var slnxPath = Path.Combine(tempDir, "test.slnx"); + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + + await File.WriteAllTextAsync(csprojPath, ""); + await File.WriteAllTextAsync(slnxPath, """ + + + + """); + await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); + + try + { + var previousDir = Environment.CurrentDirectory; + Environment.CurrentDirectory = tempDir; + try + { + var exitCode = await Program.Main([slnxPath, "--required-files", "doc/*.md"]); + exitCode.Should().Be(0); + } + finally + { + Environment.CurrentDirectory = previousDir; + } + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + [NotInParallel("CurrentDirectory")] + public async Task Invoke_WithRequiredFiles_NoMatch_ReturnsTwoExitCode() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + var csprojPath = Path.Combine(tempDir, "App.csproj"); + var slnxPath = Path.Combine(tempDir, "test.slnx"); + + await File.WriteAllTextAsync(csprojPath, ""); + await File.WriteAllTextAsync(slnxPath, """ + + + + """); + + try + { + var previousDir = Environment.CurrentDirectory; + Environment.CurrentDirectory = tempDir; + try + { + var exitCode = await Program.Main([slnxPath, "--required-files", "nonexistent/**/*.md"]); + exitCode.Should().Be(2); + } + finally + { + Environment.CurrentDirectory = previousDir; + } + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } } diff --git a/tests/SLNX-validator.Tests/RequiredFilesCheckerTests.cs b/tests/SLNX-validator.Tests/RequiredFilesCheckerTests.cs new file mode 100644 index 0000000..4e63baf --- /dev/null +++ b/tests/SLNX-validator.Tests/RequiredFilesCheckerTests.cs @@ -0,0 +1,155 @@ +using AwesomeAssertions; + +namespace JulianVerdurmen.SlnxValidator.Tests; + +public class RequiredFilesCheckerTests +{ + private static string CreateTempDir() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + return tempDir; + } + + [Test] + public async Task CheckAsync_SingleIncludePattern_MatchesFiles_ReturnsZero() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); + await File.WriteAllTextAsync(Path.Combine(docDir, "contributing.md"), "# Contributing"); + + var exitCode = await RequiredFilesChecker.CheckAsync("doc/*.md", tempDir); + + exitCode.Should().Be(0); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task CheckAsync_IncludeFollowedByExclude_ExcludesFiles_ReturnsZero() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); + await File.WriteAllTextAsync(Path.Combine(docDir, "contributing.md"), "# Contributing"); + + // Include all .md in doc/, then exclude contributing.md — should still match (readme.md) + var exitCode = await RequiredFilesChecker.CheckAsync("doc/*.md;!doc/contributing.md", tempDir); + + exitCode.Should().Be(0); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task CheckAsync_IncludeFollowedByExcludeAll_NoMatches_ReturnsNonZero() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); + + // Include then exclude everything → no matches + var exitCode = await RequiredFilesChecker.CheckAsync("doc/*.md;!doc/*.md", tempDir); + + exitCode.Should().NotBe(0); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task CheckAsync_ExcludeFollowedByReInclude_RestoresFile_ReturnsZero() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); + await File.WriteAllTextAsync(Path.Combine(docDir, "contributing.md"), "# Contributing"); + + // Exclude all md, then re-include readme.md → readme.md should match + var exitCode = await RequiredFilesChecker.CheckAsync("doc/*.md;!doc/*.md;doc/readme.md", tempDir); + + exitCode.Should().Be(0); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task CheckAsync_PatternWithNoMatches_ReturnsNonZero() + { + var tempDir = CreateTempDir(); + try + { + var exitCode = await RequiredFilesChecker.CheckAsync("nonexistent/**/*.cs", tempDir); + + exitCode.Should().NotBe(0); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task CheckAsync_WhitespaceAroundPatterns_IsTrimmed_ReturnsZero() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); + + var exitCode = await RequiredFilesChecker.CheckAsync(" doc/*.md ", tempDir); + + exitCode.Should().Be(0); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task CheckAsync_EmptyPatternEntries_AreDiscarded_ReturnsZero() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); + + var exitCode = await RequiredFilesChecker.CheckAsync(";;doc/*.md;;", tempDir); + + exitCode.Should().Be(0); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } +} From 6e1405ede3a0e577166fd7860b0bf6dff6e86edb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:27:42 +0000 Subject: [PATCH 3/6] Add last check: required files must be referenced as in the .slnx solution Co-authored-by: 304NotModified <5808377+304NotModified@users.noreply.github.com> Agent-Logs-Url: https://github.com/304NotModified/SLNX-validator/sessions/2cd8b089-f7a8-46ee-a2c4-ad527da73512 --- README.md | 13 +- src/SLNX-validator/Program.cs | 26 +++- src/SLNX-validator/RequiredFilesChecker.cs | 67 ++++++++++- .../ProgramIntegrationTests.cs | 49 +++++++- .../RequiredFilesCheckerTests.cs | 113 ++++++++++++++++++ 5 files changed, 256 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fccc09f..7a1e5fe 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,12 @@ Always exits with code `0`, even when validation errors are found. Useful in CI ### `--required-files` -Verify that a set of files or directories matching glob patterns exist before the tool runs. If any pattern produces no match, or if a matched path does not exist on disk, the tool exits with code `2`. +Verify that a set of files or directories matching glob patterns exist before the tool runs **and** are referenced as `` entries in the solution file(s) being validated. The check runs in two stages: + +1. **Pre-check** — the glob patterns must match at least one file on disk (runs before validation). +2. **Last check** — every matched file must appear as a `` element inside the `.slnx` file(s) (runs after validation). Relative paths in the solution file are resolved relative to the solution file's location. + +If either check fails, the tool exits with code `2`. **Syntax** @@ -110,8 +115,8 @@ slnx-validator MySolution.slnx --required-files "appsettings.json;docs/" | Code | Description | |------|-------------| -| `0` | All patterns matched and all matched paths exist on disk. | -| `2` | One or more patterns produced no matches, or a matched path does not exist on disk. | +| `0` | All patterns matched, all matched files exist on disk, and all are referenced in the solution. | +| `2` | A pattern produced no disk matches, or a matched file is not referenced as `` in the solution. | ## SonarQube integration example @@ -233,7 +238,7 @@ The following are **intentionally out of scope** because the toolchain already h | `SLNX011` | `ReferencedFileNotFound` | A file referenced in `` does not exist on disk. | | `SLNX012` | `InvalidWildcardUsage` | A `` contains a wildcard pattern (see [`examples/invalid-wildcard.slnx`](examples/invalid-wildcard.slnx)). | | `SLNX013` | `XsdViolation` | The XML structure violates the schema, e.g. `` inside `` (see [`examples/invalid-xsd.slnx`](examples/invalid-xsd.slnx)). | -| `SLNX020` | `RequiredFilesNotFound` | A `--required-files` pattern produced no matches, or a matched path does not exist on disk (exits with code `2`). | +| `SLNX020` | `RequiredFilesNotFound` | A `--required-files` pattern produced no disk matches, or a matched file is not referenced as `` in the solution (exits with code `2`). | ## XSD Schema diff --git a/src/SLNX-validator/Program.cs b/src/SLNX-validator/Program.cs index 6586e2e..f962845 100644 --- a/src/SLNX-validator/Program.cs +++ b/src/SLNX-validator/Program.cs @@ -1,5 +1,6 @@ using System.CommandLine; using JulianVerdurmen.SlnxValidator.Core; +using JulianVerdurmen.SlnxValidator.Core.FileSystem; using Microsoft.Extensions.DependencyInjection; namespace JulianVerdurmen.SlnxValidator; @@ -45,17 +46,34 @@ public static async Task Main(string[] args) rootCommand.SetAction(async (parseResult, cancellationToken) => { var requiredFiles = parseResult.GetValue(requiredFilesOption); + IReadOnlyList? matchedRequiredPaths = null; + if (requiredFiles is not null) { - var checkResult = await RequiredFilesChecker.CheckAsync(requiredFiles, Environment.CurrentDirectory); - if (checkResult != 0) - return checkResult; + // Pre-check: glob patterns must match at least one file on disk. + matchedRequiredPaths = RequiredFilesChecker.ResolveMatchedPaths(requiredFiles, Environment.CurrentDirectory); + if (matchedRequiredPaths.Count == 0) + { + await Console.Error.WriteLineAsync($"[SLNX020] Required files check failed: no files matched the patterns: {requiredFiles}"); + return 2; + } } var input = parseResult.GetValue(inputArgument); var sonarqubeReport = parseResult.GetValue(sonarqubeReportOption); var continueOnError = parseResult.GetValue(continueOnErrorOption); - return await services.GetRequiredService().RunAsync(input!, sonarqubeReport, continueOnError, cancellationToken); + var runResult = await services.GetRequiredService().RunAsync(input!, sonarqubeReport, continueOnError, cancellationToken); + + if (matchedRequiredPaths is not null) + { + // Last check: every required file must be referenced as a in the .slnx. + var slnxFiles = services.GetRequiredService().Resolve(input!); + var slnxCheckResult = await RequiredFilesChecker.CheckInSlnxAsync(matchedRequiredPaths, slnxFiles); + if (slnxCheckResult != 0) + return slnxCheckResult; + } + + return runResult; }); return await rootCommand.Parse(args).InvokeAsync(); diff --git a/src/SLNX-validator/RequiredFilesChecker.cs b/src/SLNX-validator/RequiredFilesChecker.cs index ffa899b..471f47b 100644 --- a/src/SLNX-validator/RequiredFilesChecker.cs +++ b/src/SLNX-validator/RequiredFilesChecker.cs @@ -1,3 +1,4 @@ +using System.Xml.Linq; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.FileSystemGlobbing.Abstractions; @@ -5,7 +6,11 @@ namespace JulianVerdurmen.SlnxValidator; internal static class RequiredFilesChecker { - public static async Task CheckAsync(string patternsRaw, string rootDirectory) + /// + /// Resolves glob patterns against and returns + /// the matched paths as absolute paths. Returns an empty list when nothing matches. + /// + public static IReadOnlyList ResolveMatchedPaths(string patternsRaw, string rootDirectory) { var patterns = patternsRaw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); @@ -22,7 +27,20 @@ public static async Task CheckAsync(string patternsRaw, string rootDirector var directoryInfo = new DirectoryInfoWrapper(new DirectoryInfo(rootDirectory)); var result = matcher.Execute(directoryInfo); - if (!result.HasMatches) + return result.HasMatches + ? result.Files.Select(f => Path.GetFullPath(Path.Combine(rootDirectory, f.Path))).ToList() + : []; + } + + /// + /// Pre-check: verifies that at least one file on disk matches the glob patterns. + /// Returns exit code 2 when no files match; 0 otherwise. + /// + public static async Task CheckAsync(string patternsRaw, string rootDirectory) + { + var matched = ResolveMatchedPaths(patternsRaw, rootDirectory); + + if (matched.Count == 0) { await Console.Error.WriteLineAsync($"[SLNX020] Required files check failed: no files matched the patterns: {patternsRaw}"); return 2; @@ -30,4 +48,49 @@ public static async Task CheckAsync(string patternsRaw, string rootDirector return 0; } + + /// + /// Last check: verifies that every path in is + /// referenced as a <File Path="..."> element in at least one of the + /// solution files. Paths in the .slnx are resolved + /// relative to each solution file's directory before comparison. + /// Returns exit code 2 when any required file is missing; 0 otherwise. + /// + public static async Task CheckInSlnxAsync(IReadOnlyList requiredAbsolutePaths, IReadOnlyList slnxFilePaths) + { + // Collect all paths declared in the .slnx files, normalised to absolute paths. + var slnxFileRefs = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var slnxFile in slnxFilePaths) + { + if (!File.Exists(slnxFile)) + continue; + + var slnxDir = Path.GetDirectoryName(slnxFile)!; + try + { + var content = await File.ReadAllTextAsync(slnxFile); + var doc = XDocument.Parse(content); + foreach (var fileElement in doc.Descendants("File")) + { + var path = fileElement.Attribute("Path")?.Value; + if (path is null) + continue; + var fullPath = Path.IsPathRooted(path) + ? Path.GetFullPath(path) + : Path.GetFullPath(Path.Combine(slnxDir, path)); + slnxFileRefs.Add(fullPath); + } + } + catch (Exception) + { + // Malformed .slnx files are already reported by the normal validation step. + } + } + + var missing = requiredAbsolutePaths.Where(p => !slnxFileRefs.Contains(p)).ToList(); + foreach (var m in missing) + await Console.Error.WriteLineAsync($"[SLNX020] Required file not referenced in solution: {m}"); + + return missing.Count > 0 ? 2 : 0; + } } diff --git a/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs b/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs index fc508d8..ba388ad 100644 --- a/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs +++ b/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs @@ -142,7 +142,7 @@ public async Task Invoke_WithBinaryFile_ReturnsNonZeroExitCode() [Test] [NotInParallel("CurrentDirectory")] - public async Task Invoke_WithRequiredFiles_AllMatch_ReturnsZeroExitCode() + public async Task Invoke_WithRequiredFiles_AllMatchAndReferencedInSlnx_ReturnsZeroExitCode() { var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); Directory.CreateDirectory(tempDir); @@ -153,12 +153,16 @@ public async Task Invoke_WithRequiredFiles_AllMatch_ReturnsZeroExitCode() Directory.CreateDirectory(docDir); await File.WriteAllTextAsync(csprojPath, ""); + await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); + // The .slnx references doc/readme.md as a so the last check passes. await File.WriteAllTextAsync(slnxPath, """ + + + """); - await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); try { @@ -180,6 +184,47 @@ await File.WriteAllTextAsync(slnxPath, """ } } + [Test] + [NotInParallel("CurrentDirectory")] + public async Task Invoke_WithRequiredFiles_MatchesButNotInSlnx_ReturnsTwoExitCode() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + var csprojPath = Path.Combine(tempDir, "App.csproj"); + var slnxPath = Path.Combine(tempDir, "test.slnx"); + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + + await File.WriteAllTextAsync(csprojPath, ""); + await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); + // The .slnx does NOT reference doc/readme.md — so the last check fails. + await File.WriteAllTextAsync(slnxPath, """ + + + + """); + + try + { + var previousDir = Environment.CurrentDirectory; + Environment.CurrentDirectory = tempDir; + try + { + var exitCode = await Program.Main([slnxPath, "--required-files", "doc/*.md"]); + exitCode.Should().Be(2); + } + finally + { + Environment.CurrentDirectory = previousDir; + } + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + [Test] [NotInParallel("CurrentDirectory")] public async Task Invoke_WithRequiredFiles_NoMatch_ReturnsTwoExitCode() diff --git a/tests/SLNX-validator.Tests/RequiredFilesCheckerTests.cs b/tests/SLNX-validator.Tests/RequiredFilesCheckerTests.cs index 4e63baf..f4e28a2 100644 --- a/tests/SLNX-validator.Tests/RequiredFilesCheckerTests.cs +++ b/tests/SLNX-validator.Tests/RequiredFilesCheckerTests.cs @@ -152,4 +152,117 @@ public async Task CheckAsync_EmptyPatternEntries_AreDiscarded_ReturnsZero() Directory.Delete(tempDir, recursive: true); } } + + [Test] + public async Task CheckInSlnxAsync_RequiredFilePresentInSlnx_ReturnsZero() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + var readmePath = Path.Combine(docDir, "readme.md"); + await File.WriteAllTextAsync(readmePath, "# Readme"); + + var slnxPath = Path.Combine(tempDir, "solution.slnx"); + await File.WriteAllTextAsync(slnxPath, """ + + + + + + """); + + var requiredAbsolutePaths = new[] { Path.GetFullPath(readmePath) }; + var exitCode = await RequiredFilesChecker.CheckInSlnxAsync(requiredAbsolutePaths, [slnxPath]); + + exitCode.Should().Be(0); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task CheckInSlnxAsync_RequiredFileMissingFromSlnx_ReturnsTwo() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + var readmePath = Path.Combine(docDir, "readme.md"); + await File.WriteAllTextAsync(readmePath, "# Readme"); + + var slnxPath = Path.Combine(tempDir, "solution.slnx"); + await File.WriteAllTextAsync(slnxPath, """ + + + + + + """); + + var requiredAbsolutePaths = new[] { Path.GetFullPath(readmePath) }; + var exitCode = await RequiredFilesChecker.CheckInSlnxAsync(requiredAbsolutePaths, [slnxPath]); + + exitCode.Should().Be(2); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task CheckInSlnxAsync_RelativePathsNormalisedCorrectly_ReturnsZero() + { + var tempDir = CreateTempDir(); + try + { + var subDir = Path.Combine(tempDir, "sub"); + Directory.CreateDirectory(subDir); + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + var readmePath = Path.Combine(docDir, "readme.md"); + await File.WriteAllTextAsync(readmePath, "# Readme"); + + // .slnx is in a subdirectory — path uses ".." to reach doc/readme.md + var slnxPath = Path.Combine(subDir, "solution.slnx"); + await File.WriteAllTextAsync(slnxPath, """ + + + + + + """); + + var requiredAbsolutePaths = new[] { Path.GetFullPath(readmePath) }; + var exitCode = await RequiredFilesChecker.CheckInSlnxAsync(requiredAbsolutePaths, [slnxPath]); + + exitCode.Should().Be(0); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task CheckInSlnxAsync_NoSlnxFiles_ReturnsTwoForEachRequired() + { + var tempDir = CreateTempDir(); + try + { + var readmePath = Path.GetFullPath(Path.Combine(tempDir, "readme.md")); + var exitCode = await RequiredFilesChecker.CheckInSlnxAsync([readmePath], []); + + exitCode.Should().Be(2); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } } From a9600b4ba925684f441c492c2d8e9e52a7c39f5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:17:30 +0000 Subject: [PATCH 4/6] Refactor --required-files: move to Core with DI, integrate with validator pipeline, fix error codes Co-authored-by: 304NotModified <5808377+304NotModified@users.noreply.github.com> Agent-Logs-Url: https://github.com/304NotModified/SLNX-validator/sessions/b69c15c2-a945-4420-bc1c-b4542a60f632 --- README.md | 15 +- .../SLNX-validator.Core.csproj | 1 + .../ServiceCollectionExtensions.cs | 1 + .../SonarQubeReporting/SonarReporter.cs | 10 + .../Validation/IRequiredFilesChecker.cs | 23 ++ .../Validation/RequiredFilesChecker.cs | 77 +++++ .../ValidationResults/ValidationErrorCode.cs | 4 + src/SLNX-validator/Program.cs | 32 +-- src/SLNX-validator/RequiredFilesChecker.cs | 96 ------- src/SLNX-validator/SLNX-validator.csproj | 1 - src/SLNX-validator/ValidationCollector.cs | 29 +- src/SLNX-validator/ValidatorRunner.cs | 13 +- .../RequiredFilesCheckerTests.cs | 235 +++++++++++++++ ...llErrorCodes_MatchesSnapshot.verified.json | 52 +++- .../ProgramIntegrationTests.cs | 45 +-- .../RequiredFilesCheckerTests.cs | 268 ------------------ .../ValidationCollectorTests.cs | 150 ++++++++++ .../ValidatorRunnerTests.cs | 20 +- 18 files changed, 614 insertions(+), 458 deletions(-) create mode 100644 src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs create mode 100644 src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs delete mode 100644 src/SLNX-validator/RequiredFilesChecker.cs create mode 100644 tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs delete mode 100644 tests/SLNX-validator.Tests/RequiredFilesCheckerTests.cs create mode 100644 tests/SLNX-validator.Tests/ValidationCollectorTests.cs diff --git a/README.md b/README.md index 7a1e5fe..c20ef63 100644 --- a/README.md +++ b/README.md @@ -67,12 +67,12 @@ Always exits with code `0`, even when validation errors are found. Useful in CI ### `--required-files` -Verify that a set of files or directories matching glob patterns exist before the tool runs **and** are referenced as `` entries in the solution file(s) being validated. The check runs in two stages: +Verify that a set of files or directories matching glob patterns exist on disk **and** are referenced as `` entries in the solution file(s) being validated. Any failure is reported as a normal validation error (exit code `1`) that also appears in SonarQube reports. -1. **Pre-check** — the glob patterns must match at least one file on disk (runs before validation). -2. **Last check** — every matched file must appear as a `` element inside the `.slnx` file(s) (runs after validation). Relative paths in the solution file are resolved relative to the solution file's location. +- **Disk check** — if no files match the glob patterns, a `SLNX020` (`RequiredFileDoesntExistOnSystem`) error is added to the solution result. +- **Reference check** — for each matched file that is not referenced as `` in the `.slnx`, a `SLNX021` (`RequiredFileNotReferencedInSolution`) error is added. The error message shows the exact `` element that should be added. -If either check fails, the tool exits with code `2`. +Relative paths in the `.slnx` are resolved relative to the solution file's location. **Syntax** @@ -115,8 +115,8 @@ slnx-validator MySolution.slnx --required-files "appsettings.json;docs/" | Code | Description | |------|-------------| -| `0` | All patterns matched, all matched files exist on disk, and all are referenced in the solution. | -| `2` | A pattern produced no disk matches, or a matched file is not referenced as `` in the solution. | +| `0` | All patterns matched and all matched files are referenced in the solution. | +| `1` | One or more required files don't exist on disk or are not referenced in the solution (same as any other validation error). | ## SonarQube integration example @@ -238,7 +238,8 @@ The following are **intentionally out of scope** because the toolchain already h | `SLNX011` | `ReferencedFileNotFound` | A file referenced in `` does not exist on disk. | | `SLNX012` | `InvalidWildcardUsage` | A `` contains a wildcard pattern (see [`examples/invalid-wildcard.slnx`](examples/invalid-wildcard.slnx)). | | `SLNX013` | `XsdViolation` | The XML structure violates the schema, e.g. `` inside `` (see [`examples/invalid-xsd.slnx`](examples/invalid-xsd.slnx)). | -| `SLNX020` | `RequiredFilesNotFound` | A `--required-files` pattern produced no disk matches, or a matched file is not referenced as `` in the solution (exits with code `2`). | +| `SLNX020` | `RequiredFileDoesntExistOnSystem` | A `--required-files` pattern matched no files on the file system. | +| `SLNX021` | `RequiredFileNotReferencedInSolution` | A `--required-files` matched file exists on disk but is not referenced as a `` element in the solution. | ## XSD Schema diff --git a/src/SLNX-validator.Core/SLNX-validator.Core.csproj b/src/SLNX-validator.Core/SLNX-validator.Core.csproj index 29a61cb..4cdd8d0 100644 --- a/src/SLNX-validator.Core/SLNX-validator.Core.csproj +++ b/src/SLNX-validator.Core/SLNX-validator.Core.csproj @@ -14,6 +14,7 @@ + diff --git a/src/SLNX-validator.Core/ServiceCollectionExtensions.cs b/src/SLNX-validator.Core/ServiceCollectionExtensions.cs index 2af27ec..42c1ba1 100644 --- a/src/SLNX-validator.Core/ServiceCollectionExtensions.cs +++ b/src/SLNX-validator.Core/ServiceCollectionExtensions.cs @@ -15,6 +15,7 @@ public static IServiceCollection AddSlnxValidator(this IServiceCollection servic services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/src/SLNX-validator.Core/SonarQubeReporting/SonarReporter.cs b/src/SLNX-validator.Core/SonarQubeReporting/SonarReporter.cs index 8ce7f5c..0d9dc4d 100644 --- a/src/SLNX-validator.Core/SonarQubeReporting/SonarReporter.cs +++ b/src/SLNX-validator.Core/SonarQubeReporting/SonarReporter.cs @@ -95,6 +95,16 @@ public async Task WriteReportAsync(IReadOnlyList results, "The XML structure violates the .slnx schema.", SonarRuleType.BUG, SonarRuleSeverity.MAJOR, SonarCleanCodeAttribute.COMPLETE, SonarImpactSeverity.MEDIUM), + ValidationErrorCode.RequiredFileDoesntExistOnSystem => CreateRule(code, + "Required file does not exist on the system", + "A file required by '--required-files' does not exist on the file system.", + SonarRuleType.BUG, SonarRuleSeverity.MAJOR, SonarCleanCodeAttribute.COMPLETE, SonarImpactSeverity.HIGH), + + ValidationErrorCode.RequiredFileNotReferencedInSolution => CreateRule(code, + "Required file not referenced in solution", + "A file required by '--required-files' exists on the file system but is not referenced as a element in the solution.", + SonarRuleType.BUG, SonarRuleSeverity.MAJOR, SonarCleanCodeAttribute.COMPLETE, SonarImpactSeverity.HIGH), + _ => throw new ArgumentOutOfRangeException(nameof(code), code, null) }; diff --git a/src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs b/src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs new file mode 100644 index 0000000..6d45d96 --- /dev/null +++ b/src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs @@ -0,0 +1,23 @@ +using JulianVerdurmen.SlnxValidator.Core.ValidationResults; + +namespace JulianVerdurmen.SlnxValidator.Core.Validation; + +public interface IRequiredFilesChecker +{ + /// + /// Resolves semicolon-separated glob patterns against + /// and returns the matched absolute paths. Returns an empty list when no files match. + /// + IReadOnlyList ResolveMatchedPaths(string patternsRaw, string rootDirectory); + + /// + /// Checks which of the are NOT referenced as a + /// <File Path="..."> element in the given .slnx content. + /// Paths in the .slnx are resolved relative to . + /// Returns a for each missing file. + /// + IReadOnlyList CheckInSlnx( + IReadOnlyList requiredAbsolutePaths, + string slnxContent, + string slnxDirectory); +} diff --git a/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs b/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs new file mode 100644 index 0000000..802dbd4 --- /dev/null +++ b/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs @@ -0,0 +1,77 @@ +using System.Xml.Linq; +using JulianVerdurmen.SlnxValidator.Core.ValidationResults; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; + +namespace JulianVerdurmen.SlnxValidator.Core.Validation; + +internal sealed class RequiredFilesChecker : IRequiredFilesChecker +{ + /// + public IReadOnlyList ResolveMatchedPaths(string patternsRaw, string rootDirectory) + { + var patterns = patternsRaw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var matcher = new Matcher(StringComparison.OrdinalIgnoreCase, preserveFilterOrder: true); + + foreach (var pattern in patterns) + { + if (pattern.StartsWith('!')) + matcher.AddExclude(pattern[1..]); + else + matcher.AddInclude(pattern); + } + + var directoryInfo = new DirectoryInfoWrapper(new DirectoryInfo(rootDirectory)); + var result = matcher.Execute(directoryInfo); + + return result.HasMatches + ? result.Files.Select(f => Path.GetFullPath(Path.Combine(rootDirectory, f.Path))).ToList() + : []; + } + + /// + public IReadOnlyList CheckInSlnx( + IReadOnlyList requiredAbsolutePaths, + string slnxContent, + string slnxDirectory) + { + var slnxFileRefs = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + var doc = XDocument.Parse(slnxContent); + foreach (var fileElement in doc.Descendants("File")) + { + var path = fileElement.Attribute("Path")?.Value; + if (path is null) + continue; + + var fullPath = Path.IsPathRooted(path) + ? Path.GetFullPath(path) + : Path.GetFullPath(Path.Combine(slnxDirectory, path)); + + slnxFileRefs.Add(fullPath); + } + } + catch (Exception) + { + // Malformed XML is already reported by the XML validator. + } + + var errors = new List(); + foreach (var requiredPath in requiredAbsolutePaths) + { + if (!slnxFileRefs.Contains(requiredPath)) + { + var relativePath = Path.GetRelativePath(slnxDirectory, requiredPath).Replace('\\', '/'); + errors.Add(new ValidationError( + ValidationErrorCode.RequiredFileNotReferencedInSolution, + $"Required file is not referenced in the solution: {requiredPath}" + + $" — add: ")); + } + } + + return errors; + } +} diff --git a/src/SLNX-validator.Core/ValidationResults/ValidationErrorCode.cs b/src/SLNX-validator.Core/ValidationResults/ValidationErrorCode.cs index b8da9ff..8e14f9e 100644 --- a/src/SLNX-validator.Core/ValidationResults/ValidationErrorCode.cs +++ b/src/SLNX-validator.Core/ValidationResults/ValidationErrorCode.cs @@ -12,4 +12,8 @@ public enum ValidationErrorCode ReferencedFileNotFound = 11, InvalidWildcardUsage = 12, XsdViolation = 13, + + // Required-files errors + RequiredFileDoesntExistOnSystem = 20, + RequiredFileNotReferencedInSolution = 21, } diff --git a/src/SLNX-validator/Program.cs b/src/SLNX-validator/Program.cs index f962845..3c01d56 100644 --- a/src/SLNX-validator/Program.cs +++ b/src/SLNX-validator/Program.cs @@ -1,6 +1,5 @@ using System.CommandLine; using JulianVerdurmen.SlnxValidator.Core; -using JulianVerdurmen.SlnxValidator.Core.FileSystem; using Microsoft.Extensions.DependencyInjection; namespace JulianVerdurmen.SlnxValidator; @@ -26,7 +25,7 @@ public static async Task Main(string[] args) var requiredFilesOption = new Option("--required-files") { - Description = "Semicolon-separated glob patterns for required files and directories. The tool exits with code 2 if any pattern produces no match or a matched path does not exist." + Description = "Semicolon-separated glob patterns for files that must exist on disk and be referenced as elements in the solution." }; var rootCommand = new RootCommand("Validates .slnx solution files.") @@ -45,35 +44,12 @@ public static async Task Main(string[] args) rootCommand.SetAction(async (parseResult, cancellationToken) => { - var requiredFiles = parseResult.GetValue(requiredFilesOption); - IReadOnlyList? matchedRequiredPaths = null; - - if (requiredFiles is not null) - { - // Pre-check: glob patterns must match at least one file on disk. - matchedRequiredPaths = RequiredFilesChecker.ResolveMatchedPaths(requiredFiles, Environment.CurrentDirectory); - if (matchedRequiredPaths.Count == 0) - { - await Console.Error.WriteLineAsync($"[SLNX020] Required files check failed: no files matched the patterns: {requiredFiles}"); - return 2; - } - } - var input = parseResult.GetValue(inputArgument); var sonarqubeReport = parseResult.GetValue(sonarqubeReportOption); var continueOnError = parseResult.GetValue(continueOnErrorOption); - var runResult = await services.GetRequiredService().RunAsync(input!, sonarqubeReport, continueOnError, cancellationToken); - - if (matchedRequiredPaths is not null) - { - // Last check: every required file must be referenced as a in the .slnx. - var slnxFiles = services.GetRequiredService().Resolve(input!); - var slnxCheckResult = await RequiredFilesChecker.CheckInSlnxAsync(matchedRequiredPaths, slnxFiles); - if (slnxCheckResult != 0) - return slnxCheckResult; - } - - return runResult; + var requiredFiles = parseResult.GetValue(requiredFilesOption); + return await services.GetRequiredService() + .RunAsync(input!, sonarqubeReport, continueOnError, requiredFiles, Environment.CurrentDirectory, cancellationToken); }); return await rootCommand.Parse(args).InvokeAsync(); diff --git a/src/SLNX-validator/RequiredFilesChecker.cs b/src/SLNX-validator/RequiredFilesChecker.cs deleted file mode 100644 index 471f47b..0000000 --- a/src/SLNX-validator/RequiredFilesChecker.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.Xml.Linq; -using Microsoft.Extensions.FileSystemGlobbing; -using Microsoft.Extensions.FileSystemGlobbing.Abstractions; - -namespace JulianVerdurmen.SlnxValidator; - -internal static class RequiredFilesChecker -{ - /// - /// Resolves glob patterns against and returns - /// the matched paths as absolute paths. Returns an empty list when nothing matches. - /// - public static IReadOnlyList ResolveMatchedPaths(string patternsRaw, string rootDirectory) - { - var patterns = patternsRaw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - var matcher = new Matcher(StringComparison.OrdinalIgnoreCase, preserveFilterOrder: true); - - foreach (var pattern in patterns) - { - if (pattern.StartsWith('!')) - matcher.AddExclude(pattern[1..]); - else - matcher.AddInclude(pattern); - } - - var directoryInfo = new DirectoryInfoWrapper(new DirectoryInfo(rootDirectory)); - var result = matcher.Execute(directoryInfo); - - return result.HasMatches - ? result.Files.Select(f => Path.GetFullPath(Path.Combine(rootDirectory, f.Path))).ToList() - : []; - } - - /// - /// Pre-check: verifies that at least one file on disk matches the glob patterns. - /// Returns exit code 2 when no files match; 0 otherwise. - /// - public static async Task CheckAsync(string patternsRaw, string rootDirectory) - { - var matched = ResolveMatchedPaths(patternsRaw, rootDirectory); - - if (matched.Count == 0) - { - await Console.Error.WriteLineAsync($"[SLNX020] Required files check failed: no files matched the patterns: {patternsRaw}"); - return 2; - } - - return 0; - } - - /// - /// Last check: verifies that every path in is - /// referenced as a <File Path="..."> element in at least one of the - /// solution files. Paths in the .slnx are resolved - /// relative to each solution file's directory before comparison. - /// Returns exit code 2 when any required file is missing; 0 otherwise. - /// - public static async Task CheckInSlnxAsync(IReadOnlyList requiredAbsolutePaths, IReadOnlyList slnxFilePaths) - { - // Collect all paths declared in the .slnx files, normalised to absolute paths. - var slnxFileRefs = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var slnxFile in slnxFilePaths) - { - if (!File.Exists(slnxFile)) - continue; - - var slnxDir = Path.GetDirectoryName(slnxFile)!; - try - { - var content = await File.ReadAllTextAsync(slnxFile); - var doc = XDocument.Parse(content); - foreach (var fileElement in doc.Descendants("File")) - { - var path = fileElement.Attribute("Path")?.Value; - if (path is null) - continue; - var fullPath = Path.IsPathRooted(path) - ? Path.GetFullPath(path) - : Path.GetFullPath(Path.Combine(slnxDir, path)); - slnxFileRefs.Add(fullPath); - } - } - catch (Exception) - { - // Malformed .slnx files are already reported by the normal validation step. - } - } - - var missing = requiredAbsolutePaths.Where(p => !slnxFileRefs.Contains(p)).ToList(); - foreach (var m in missing) - await Console.Error.WriteLineAsync($"[SLNX020] Required file not referenced in solution: {m}"); - - return missing.Count > 0 ? 2 : 0; - } -} diff --git a/src/SLNX-validator/SLNX-validator.csproj b/src/SLNX-validator/SLNX-validator.csproj index 5b24c8b..c3a81de 100644 --- a/src/SLNX-validator/SLNX-validator.csproj +++ b/src/SLNX-validator/SLNX-validator.csproj @@ -27,7 +27,6 @@ - diff --git a/src/SLNX-validator/ValidationCollector.cs b/src/SLNX-validator/ValidationCollector.cs index 5d18d30..ad3b97e 100644 --- a/src/SLNX-validator/ValidationCollector.cs +++ b/src/SLNX-validator/ValidationCollector.cs @@ -4,9 +4,13 @@ namespace JulianVerdurmen.SlnxValidator; -internal sealed class ValidationCollector(IFileSystem fileSystem, ISlnxValidator validator) +internal sealed class ValidationCollector(IFileSystem fileSystem, ISlnxValidator validator, IRequiredFilesChecker requiredFilesChecker) { - public async Task> CollectAsync(IReadOnlyList files, CancellationToken cancellationToken) + public async Task> CollectAsync( + IReadOnlyList files, + IReadOnlyList? matchedRequiredPaths, + string? requiredFilesPattern, + CancellationToken cancellationToken) { var results = new List(files.Count); @@ -36,11 +40,27 @@ public async Task> CollectAsync(IReadOnlyLis var directory = Path.GetDirectoryName(file)!; var result = await validator.ValidateAsync(content, directory, cancellationToken); + var allErrors = result.Errors.ToList(); + + if (matchedRequiredPaths is not null) + { + if (matchedRequiredPaths.Count == 0) + { + allErrors.Add(new ValidationError( + ValidationErrorCode.RequiredFileDoesntExistOnSystem, + $"Required file does not exist on the system. No files matched: {requiredFilesPattern}")); + } + else + { + allErrors.AddRange(requiredFilesChecker.CheckInSlnx(matchedRequiredPaths, content, directory)); + } + } + results.Add(new FileValidationResult { File = file, - HasErrors = !result.IsValid, - Errors = result.Errors, + HasErrors = allErrors.Count > 0, + Errors = allErrors, }); } @@ -63,3 +83,4 @@ private static bool IsBinaryFile(string path) return buffer[..bytesRead].Contains((byte)0); } } + diff --git a/src/SLNX-validator/ValidatorRunner.cs b/src/SLNX-validator/ValidatorRunner.cs index 46af0d1..090242d 100644 --- a/src/SLNX-validator/ValidatorRunner.cs +++ b/src/SLNX-validator/ValidatorRunner.cs @@ -1,12 +1,14 @@ using JulianVerdurmen.SlnxValidator.Core; using JulianVerdurmen.SlnxValidator.Core.FileSystem; using JulianVerdurmen.SlnxValidator.Core.SonarQubeReporting; +using JulianVerdurmen.SlnxValidator.Core.Validation; namespace JulianVerdurmen.SlnxValidator; -internal sealed class ValidatorRunner(ISlnxFileResolver resolver, ValidationCollector collector, ISonarReporter sonarReporter) +internal sealed class ValidatorRunner(ISlnxFileResolver resolver, ValidationCollector collector, ISonarReporter sonarReporter, IRequiredFilesChecker requiredFilesChecker) { - public async Task RunAsync(string input, string? sonarqubeReportPath, bool continueOnError, CancellationToken cancellationToken) + public async Task RunAsync(string input, string? sonarqubeReportPath, bool continueOnError, + string? requiredFilesPattern, string workingDirectory, CancellationToken cancellationToken) { var files = resolver.Resolve(input); @@ -16,7 +18,12 @@ public async Task RunAsync(string input, string? sonarqubeReportPath, bool return continueOnError ? 0 : 1; } - var results = await collector.CollectAsync(files, cancellationToken); + // Resolve required file glob patterns to absolute disk paths (once for all .slnx files). + IReadOnlyList? matchedRequiredPaths = null; + if (requiredFilesPattern is not null) + matchedRequiredPaths = requiredFilesChecker.ResolveMatchedPaths(requiredFilesPattern, workingDirectory); + + var results = await collector.CollectAsync(files, matchedRequiredPaths, requiredFilesPattern, cancellationToken); await ValidationReporter.Report(results); diff --git a/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs b/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs new file mode 100644 index 0000000..d1460e4 --- /dev/null +++ b/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs @@ -0,0 +1,235 @@ +using AwesomeAssertions; +using JulianVerdurmen.SlnxValidator.Core.Validation; +using JulianVerdurmen.SlnxValidator.Core.ValidationResults; + +namespace JulianVerdurmen.SlnxValidator.Core.Tests; + +public class RequiredFilesCheckerTests +{ + private static string CreateTempDir() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + return tempDir; + } + + private static RequiredFilesChecker CreateChecker() => new(); + + // ── ResolveMatchedPaths ────────────────────────────────────────────────── + + [Test] + public void ResolveMatchedPaths_SingleInclude_MatchesFiles_ReturnsNonEmpty() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + File.WriteAllText(Path.Combine(docDir, "readme.md"), "# Readme"); + File.WriteAllText(Path.Combine(docDir, "contributing.md"), "# Contributing"); + + var matched = CreateChecker().ResolveMatchedPaths("doc/*.md", tempDir); + + matched.Should().HaveCount(2); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public void ResolveMatchedPaths_IncludeFollowedByExclude_ExcludesFile() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + File.WriteAllText(Path.Combine(docDir, "readme.md"), "# Readme"); + File.WriteAllText(Path.Combine(docDir, "contributing.md"), "# Contributing"); + + var matched = CreateChecker().ResolveMatchedPaths("doc/*.md;!doc/contributing.md", tempDir); + + matched.Should().HaveCount(1); + matched[0].Should().EndWith("readme.md"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public void ResolveMatchedPaths_AllExcluded_ReturnsEmpty() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + File.WriteAllText(Path.Combine(docDir, "readme.md"), "# Readme"); + + var matched = CreateChecker().ResolveMatchedPaths("doc/*.md;!doc/*.md", tempDir); + + matched.Should().BeEmpty(); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public void ResolveMatchedPaths_PatternMatchesNothing_ReturnsEmpty() + { + var tempDir = CreateTempDir(); + try + { + var matched = CreateChecker().ResolveMatchedPaths("nonexistent/**/*.cs", tempDir); + + matched.Should().BeEmpty(); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public void ResolveMatchedPaths_WhitespaceAroundPatterns_IsTrimmed() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + File.WriteAllText(Path.Combine(docDir, "readme.md"), "# Readme"); + + var matched = CreateChecker().ResolveMatchedPaths(" doc/*.md ", tempDir); + + matched.Should().HaveCount(1); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public void ResolveMatchedPaths_EmptyPatternEntries_AreDiscarded() + { + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + File.WriteAllText(Path.Combine(docDir, "readme.md"), "# Readme"); + + var matched = CreateChecker().ResolveMatchedPaths(";;doc/*.md;;", tempDir); + + matched.Should().HaveCount(1); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + // ── CheckInSlnx ───────────────────────────────────────────────────────── + + private static readonly string SlnxDir = OperatingSystem.IsWindows() ? @"C:\repo" : "/repo"; + + [Test] + public void CheckInSlnx_RequiredFilePresentInSlnx_ReturnsNoErrors() + { + var requiredPath = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "readme.md")); + var slnxContent = """ + + + + + + """; + + var errors = CreateChecker().CheckInSlnx([requiredPath], slnxContent, SlnxDir); + + errors.Should().BeEmpty(); + } + + [Test] + public void CheckInSlnx_RequiredFileMissingFromSlnx_ReturnsError() + { + var requiredPath = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "readme.md")); + var slnxContent = """ + + + + + + """; + + var errors = CreateChecker().CheckInSlnx([requiredPath], slnxContent, SlnxDir); + + errors.Should().HaveCount(1); + errors[0].Code.Should().Be(ValidationErrorCode.RequiredFileNotReferencedInSolution); + } + + [Test] + public void CheckInSlnx_ErrorMessageContainsFileElement() + { + var requiredPath = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "readme.md")); + var slnxContent = ""; + + var errors = CreateChecker().CheckInSlnx([requiredPath], slnxContent, SlnxDir); + + errors.Should().HaveCount(1); + errors[0].Message.Should().Contain(" + + + + + """; + + var errors = CreateChecker().CheckInSlnx([requiredPath], slnxContent, slnxDir); + + errors.Should().BeEmpty(); + } + + [Test] + public void CheckInSlnx_MultipleRequiredFiles_ReportsAllMissing() + { + var path1 = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "readme.md")); + var path2 = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "contributing.md")); + var slnxContent = ""; + + var errors = CreateChecker().CheckInSlnx([path1, path2], slnxContent, SlnxDir); + + errors.Should().HaveCount(2); + errors.Should().AllSatisfy(e => e.Code.Should().Be(ValidationErrorCode.RequiredFileNotReferencedInSolution)); + } + + [Test] + public void CheckInSlnx_EmptyRequiredPaths_ReturnsNoErrors() + { + var slnxContent = ""; + + var errors = CreateChecker().CheckInSlnx([], slnxContent, SlnxDir); + + errors.Should().BeEmpty(); + } +} diff --git a/tests/SLNX-validator.Core.Tests/SonarReporterTests.WriteReportAsync_AllErrorCodes_MatchesSnapshot.verified.json b/tests/SLNX-validator.Core.Tests/SonarReporterTests.WriteReportAsync_AllErrorCodes_MatchesSnapshot.verified.json index f65af26..3c05ff4 100644 --- a/tests/SLNX-validator.Core.Tests/SonarReporterTests.WriteReportAsync_AllErrorCodes_MatchesSnapshot.verified.json +++ b/tests/SLNX-validator.Core.Tests/SonarReporterTests.WriteReportAsync_AllErrorCodes_MatchesSnapshot.verified.json @@ -1,4 +1,4 @@ -{ +{ "rules": [ { "id": "SLNX001", @@ -104,6 +104,36 @@ "severity": "MEDIUM" } ] + }, + { + "id": "SLNX020", + "name": "Required file does not exist on the system", + "description": "A file required by '--required-files' does not exist on the file system.", + "engineId": "slnx-validator", + "cleanCodeAttribute": "COMPLETE", + "type": "BUG", + "severity": "MAJOR", + "impacts": [ + { + "softwareQuality": "MAINTAINABILITY", + "severity": "HIGH" + } + ] + }, + { + "id": "SLNX021", + "name": "Required file not referenced in solution", + "description": "A file required by '--required-files' exists on the file system but is not referenced as a element in the solution.", + "engineId": "slnx-validator", + "cleanCodeAttribute": "COMPLETE", + "type": "BUG", + "severity": "MAJOR", + "impacts": [ + { + "softwareQuality": "MAINTAINABILITY", + "severity": "HIGH" + } + ] } ], "issues": [ @@ -176,6 +206,26 @@ "startLine": 7 } } + }, + { + "ruleId": "SLNX020", + "primaryLocation": { + "message": "Sample message for RequiredFileDoesntExistOnSystem", + "filePath": "Solution.slnx", + "textRange": { + "startLine": 8 + } + } + }, + { + "ruleId": "SLNX021", + "primaryLocation": { + "message": "Sample message for RequiredFileNotReferencedInSolution", + "filePath": "Solution.slnx", + "textRange": { + "startLine": 9 + } + } } ] } \ No newline at end of file diff --git a/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs b/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs index ba388ad..0195b6e 100644 --- a/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs +++ b/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs @@ -186,48 +186,7 @@ await File.WriteAllTextAsync(slnxPath, """ [Test] [NotInParallel("CurrentDirectory")] - public async Task Invoke_WithRequiredFiles_MatchesButNotInSlnx_ReturnsTwoExitCode() - { - var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(tempDir); - - var csprojPath = Path.Combine(tempDir, "App.csproj"); - var slnxPath = Path.Combine(tempDir, "test.slnx"); - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - - await File.WriteAllTextAsync(csprojPath, ""); - await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); - // The .slnx does NOT reference doc/readme.md — so the last check fails. - await File.WriteAllTextAsync(slnxPath, """ - - - - """); - - try - { - var previousDir = Environment.CurrentDirectory; - Environment.CurrentDirectory = tempDir; - try - { - var exitCode = await Program.Main([slnxPath, "--required-files", "doc/*.md"]); - exitCode.Should().Be(2); - } - finally - { - Environment.CurrentDirectory = previousDir; - } - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - [NotInParallel("CurrentDirectory")] - public async Task Invoke_WithRequiredFiles_NoMatch_ReturnsTwoExitCode() + public async Task Invoke_WithRequiredFiles_NoMatch_ReturnsNonZeroExitCode() { var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); Directory.CreateDirectory(tempDir); @@ -249,7 +208,7 @@ await File.WriteAllTextAsync(slnxPath, """ try { var exitCode = await Program.Main([slnxPath, "--required-files", "nonexistent/**/*.md"]); - exitCode.Should().Be(2); + exitCode.Should().NotBe(0); } finally { diff --git a/tests/SLNX-validator.Tests/RequiredFilesCheckerTests.cs b/tests/SLNX-validator.Tests/RequiredFilesCheckerTests.cs deleted file mode 100644 index f4e28a2..0000000 --- a/tests/SLNX-validator.Tests/RequiredFilesCheckerTests.cs +++ /dev/null @@ -1,268 +0,0 @@ -using AwesomeAssertions; - -namespace JulianVerdurmen.SlnxValidator.Tests; - -public class RequiredFilesCheckerTests -{ - private static string CreateTempDir() - { - var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(tempDir); - return tempDir; - } - - [Test] - public async Task CheckAsync_SingleIncludePattern_MatchesFiles_ReturnsZero() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); - await File.WriteAllTextAsync(Path.Combine(docDir, "contributing.md"), "# Contributing"); - - var exitCode = await RequiredFilesChecker.CheckAsync("doc/*.md", tempDir); - - exitCode.Should().Be(0); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public async Task CheckAsync_IncludeFollowedByExclude_ExcludesFiles_ReturnsZero() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); - await File.WriteAllTextAsync(Path.Combine(docDir, "contributing.md"), "# Contributing"); - - // Include all .md in doc/, then exclude contributing.md — should still match (readme.md) - var exitCode = await RequiredFilesChecker.CheckAsync("doc/*.md;!doc/contributing.md", tempDir); - - exitCode.Should().Be(0); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public async Task CheckAsync_IncludeFollowedByExcludeAll_NoMatches_ReturnsNonZero() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); - - // Include then exclude everything → no matches - var exitCode = await RequiredFilesChecker.CheckAsync("doc/*.md;!doc/*.md", tempDir); - - exitCode.Should().NotBe(0); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public async Task CheckAsync_ExcludeFollowedByReInclude_RestoresFile_ReturnsZero() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); - await File.WriteAllTextAsync(Path.Combine(docDir, "contributing.md"), "# Contributing"); - - // Exclude all md, then re-include readme.md → readme.md should match - var exitCode = await RequiredFilesChecker.CheckAsync("doc/*.md;!doc/*.md;doc/readme.md", tempDir); - - exitCode.Should().Be(0); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public async Task CheckAsync_PatternWithNoMatches_ReturnsNonZero() - { - var tempDir = CreateTempDir(); - try - { - var exitCode = await RequiredFilesChecker.CheckAsync("nonexistent/**/*.cs", tempDir); - - exitCode.Should().NotBe(0); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public async Task CheckAsync_WhitespaceAroundPatterns_IsTrimmed_ReturnsZero() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); - - var exitCode = await RequiredFilesChecker.CheckAsync(" doc/*.md ", tempDir); - - exitCode.Should().Be(0); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public async Task CheckAsync_EmptyPatternEntries_AreDiscarded_ReturnsZero() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); - - var exitCode = await RequiredFilesChecker.CheckAsync(";;doc/*.md;;", tempDir); - - exitCode.Should().Be(0); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public async Task CheckInSlnxAsync_RequiredFilePresentInSlnx_ReturnsZero() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - var readmePath = Path.Combine(docDir, "readme.md"); - await File.WriteAllTextAsync(readmePath, "# Readme"); - - var slnxPath = Path.Combine(tempDir, "solution.slnx"); - await File.WriteAllTextAsync(slnxPath, """ - - - - - - """); - - var requiredAbsolutePaths = new[] { Path.GetFullPath(readmePath) }; - var exitCode = await RequiredFilesChecker.CheckInSlnxAsync(requiredAbsolutePaths, [slnxPath]); - - exitCode.Should().Be(0); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public async Task CheckInSlnxAsync_RequiredFileMissingFromSlnx_ReturnsTwo() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - var readmePath = Path.Combine(docDir, "readme.md"); - await File.WriteAllTextAsync(readmePath, "# Readme"); - - var slnxPath = Path.Combine(tempDir, "solution.slnx"); - await File.WriteAllTextAsync(slnxPath, """ - - - - - - """); - - var requiredAbsolutePaths = new[] { Path.GetFullPath(readmePath) }; - var exitCode = await RequiredFilesChecker.CheckInSlnxAsync(requiredAbsolutePaths, [slnxPath]); - - exitCode.Should().Be(2); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public async Task CheckInSlnxAsync_RelativePathsNormalisedCorrectly_ReturnsZero() - { - var tempDir = CreateTempDir(); - try - { - var subDir = Path.Combine(tempDir, "sub"); - Directory.CreateDirectory(subDir); - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - var readmePath = Path.Combine(docDir, "readme.md"); - await File.WriteAllTextAsync(readmePath, "# Readme"); - - // .slnx is in a subdirectory — path uses ".." to reach doc/readme.md - var slnxPath = Path.Combine(subDir, "solution.slnx"); - await File.WriteAllTextAsync(slnxPath, """ - - - - - - """); - - var requiredAbsolutePaths = new[] { Path.GetFullPath(readmePath) }; - var exitCode = await RequiredFilesChecker.CheckInSlnxAsync(requiredAbsolutePaths, [slnxPath]); - - exitCode.Should().Be(0); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public async Task CheckInSlnxAsync_NoSlnxFiles_ReturnsTwoForEachRequired() - { - var tempDir = CreateTempDir(); - try - { - var readmePath = Path.GetFullPath(Path.Combine(tempDir, "readme.md")); - var exitCode = await RequiredFilesChecker.CheckInSlnxAsync([readmePath], []); - - exitCode.Should().Be(2); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } -} diff --git a/tests/SLNX-validator.Tests/ValidationCollectorTests.cs b/tests/SLNX-validator.Tests/ValidationCollectorTests.cs new file mode 100644 index 0000000..7164bdc --- /dev/null +++ b/tests/SLNX-validator.Tests/ValidationCollectorTests.cs @@ -0,0 +1,150 @@ +using AwesomeAssertions; +using JulianVerdurmen.SlnxValidator.Core.Validation; +using JulianVerdurmen.SlnxValidator.Core.ValidationResults; +using NSubstitute; + +namespace JulianVerdurmen.SlnxValidator.Tests; + +public class ValidationCollectorTests +{ + private static string CreateTempDir() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + return tempDir; + } + + private static ValidationCollector CreateCollector( + IRequiredFilesChecker? checker = null, + ISlnxValidator? validator = null) + { + checker ??= Substitute.For(); + validator ??= Substitute.For(); + validator + .ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ValidationResult()); + return new ValidationCollector(new MockFileSystem(), validator, checker); + } + + [Test] + public async Task CollectAsync_RequiredFilesPatternNoMatch_AddsRequiredFileDoesntExistOnSystemError() + { + var tempDir = CreateTempDir(); + try + { + var slnxPath = Path.Combine(tempDir, "test.slnx"); + await File.WriteAllTextAsync(slnxPath, ""); + + var fileSystem = new MockFileSystem(slnxPath); + var validator = Substitute.For(); + validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ValidationResult()); + var checker = Substitute.For(); + var collector = new ValidationCollector(fileSystem, validator, checker); + + // Empty matched paths = pattern matched nothing on disk + var results = await collector.CollectAsync([slnxPath], matchedRequiredPaths: [], "*.md", CancellationToken.None); + + results.Should().HaveCount(1); + results[0].HasErrors.Should().BeTrue(); + results[0].Errors.Should().ContainSingle(e => e.Code == ValidationErrorCode.RequiredFileDoesntExistOnSystem); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task CollectAsync_RequiredFileMissingFromSlnx_AddsRequiredFileNotReferencedInSolutionError() + { + var tempDir = CreateTempDir(); + try + { + var slnxPath = Path.Combine(tempDir, "test.slnx"); + await File.WriteAllTextAsync(slnxPath, ""); + var requiredFile = Path.Combine(tempDir, "readme.md"); + + var fileSystem = new MockFileSystem(slnxPath); + var validator = Substitute.For(); + validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ValidationResult()); + var checker = Substitute.For(); + checker.CheckInSlnx(Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns([new ValidationError(ValidationErrorCode.RequiredFileNotReferencedInSolution, + $"Required file is not referenced in the solution: {requiredFile} — add: ")]); + + var collector = new ValidationCollector(fileSystem, validator, checker); + + var results = await collector.CollectAsync([slnxPath], [requiredFile], "*.md", CancellationToken.None); + + results.Should().HaveCount(1); + results[0].HasErrors.Should().BeTrue(); + results[0].Errors.Should().ContainSingle(e => e.Code == ValidationErrorCode.RequiredFileNotReferencedInSolution); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task CollectAsync_RequiredFileMatchedAndReferenced_NoExtraErrors() + { + var tempDir = CreateTempDir(); + try + { + var slnxPath = Path.Combine(tempDir, "test.slnx"); + await File.WriteAllTextAsync(slnxPath, ""); + var requiredFile = Path.Combine(tempDir, "readme.md"); + + var fileSystem = new MockFileSystem(slnxPath); + var validator = Substitute.For(); + validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ValidationResult()); + var checker = Substitute.For(); + checker.CheckInSlnx(Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + + var collector = new ValidationCollector(fileSystem, validator, checker); + + var results = await collector.CollectAsync([slnxPath], [requiredFile], "*.md", CancellationToken.None); + + results.Should().HaveCount(1); + results[0].HasErrors.Should().BeFalse(); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task CollectAsync_NullMatchedRequiredPaths_SkipsRequiredFilesCheck() + { + var tempDir = CreateTempDir(); + try + { + var slnxPath = Path.Combine(tempDir, "test.slnx"); + await File.WriteAllTextAsync(slnxPath, ""); + + var fileSystem = new MockFileSystem(slnxPath); + var validator = Substitute.For(); + validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ValidationResult()); + var checker = Substitute.For(); + var collector = new ValidationCollector(fileSystem, validator, checker); + + // null matchedRequiredPaths = no --required-files option was provided + var results = await collector.CollectAsync([slnxPath], matchedRequiredPaths: null, requiredFilesPattern: null, CancellationToken.None); + + results.Should().HaveCount(1); + results[0].HasErrors.Should().BeFalse(); + checker.DidNotReceive().CheckInSlnx(Arg.Any>(), Arg.Any(), Arg.Any()); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } +} diff --git a/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs b/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs index a9f5a97..d85c7cc 100644 --- a/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs +++ b/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs @@ -7,11 +7,12 @@ namespace JulianVerdurmen.SlnxValidator.Tests; public class ValidatorRunnerTests { - private static ValidatorRunner CreateRunner(IFileSystem fileSystem) + private static ValidatorRunner CreateRunner(IFileSystem fileSystem, IRequiredFilesChecker? checker = null) { - var collector = new ValidationCollector(fileSystem, Substitute.For()); + checker ??= Substitute.For(); + var collector = new ValidationCollector(fileSystem, Substitute.For(), checker); var sonarReporter = new SonarReporter(fileSystem); - return new ValidatorRunner(Substitute.For(), collector, sonarReporter); + return new ValidatorRunner(Substitute.For(), collector, sonarReporter, checker); } [Test] @@ -19,7 +20,8 @@ public async Task RunAsync_FileNotFound_ContinueOnErrorFalse_ReturnsOne() { var runner = CreateRunner(new MockFileSystem()); - var exitCode = await runner.RunAsync("nonexistent.slnx", sonarqubeReportPath: null, continueOnError: false, CancellationToken.None); + var exitCode = await runner.RunAsync("nonexistent.slnx", sonarqubeReportPath: null, continueOnError: false, + requiredFilesPattern: null, workingDirectory: ".", CancellationToken.None); exitCode.Should().Be(1); } @@ -29,7 +31,8 @@ public async Task RunAsync_FileNotFound_ContinueOnErrorTrue_ReturnsZero() { var runner = CreateRunner(new MockFileSystem()); - var exitCode = await runner.RunAsync("nonexistent.slnx", sonarqubeReportPath: null, continueOnError: true, CancellationToken.None); + var exitCode = await runner.RunAsync("nonexistent.slnx", sonarqubeReportPath: null, continueOnError: true, + requiredFilesPattern: null, workingDirectory: ".", CancellationToken.None); exitCode.Should().Be(0); } @@ -39,7 +42,8 @@ public async Task RunAsync_NoFilesFound_ContinueOnErrorFalse_ReturnsOne() { var runner = CreateRunner(new MockFileSystem()); - var exitCode = await runner.RunAsync("src/*.slnx", sonarqubeReportPath: null, continueOnError: false, CancellationToken.None); + var exitCode = await runner.RunAsync("src/*.slnx", sonarqubeReportPath: null, continueOnError: false, + requiredFilesPattern: null, workingDirectory: ".", CancellationToken.None); exitCode.Should().Be(1); } @@ -49,8 +53,10 @@ public async Task RunAsync_NoFilesFound_ContinueOnErrorTrue_ReturnsZero() { var runner = CreateRunner(new MockFileSystem()); - var exitCode = await runner.RunAsync("src/*.slnx", sonarqubeReportPath: null, continueOnError: true, CancellationToken.None); + var exitCode = await runner.RunAsync("src/*.slnx", sonarqubeReportPath: null, continueOnError: true, + requiredFilesPattern: null, workingDirectory: ".", CancellationToken.None); exitCode.Should().Be(0); } } + From ec1f478635a00ce0fc0dbf501b9fe6b775f2b67f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 12:37:23 +0000 Subject: [PATCH 5/6] Add SlnxFileRefs DTO, options records, extend IFileSystem; convert tests to unit tests; update README Co-authored-by: 304NotModified <5808377+304NotModified@users.noreply.github.com> Agent-Logs-Url: https://github.com/304NotModified/SLNX-validator/sessions/b2fb8573-e2fd-4582-aa07-b67537345be2 --- README.md | 6 +- .../FileSystem/IFileSystem.cs | 2 + .../FileSystem/RealFileSystem.cs | 3 + .../Validation/IRequiredFilesChecker.cs | 9 +- .../Validation/RequiredFilesChecker.cs | 32 +--- .../Validation/SlnxFileRefs.cs | 55 ++++++ src/SLNX-validator/Program.cs | 14 +- src/SLNX-validator/RequiredFilesOptions.cs | 15 ++ src/SLNX-validator/ValidationCollector.cs | 19 +- src/SLNX-validator/ValidatorRunner.cs | 26 +-- src/SLNX-validator/ValidatorRunnerOptions.cs | 9 + .../MockFileSystem.cs | 4 + .../RequiredFilesCheckerTests.cs | 98 +--------- tests/SLNX-validator.Tests/MockFileSystem.cs | 24 ++- .../ProgramIntegrationTests.cs | 81 --------- .../ValidationCollectorTests.cs | 171 ++++++------------ .../ValidatorRunnerTests.cs | 75 +++++++- 17 files changed, 284 insertions(+), 359 deletions(-) create mode 100644 src/SLNX-validator.Core/Validation/SlnxFileRefs.cs create mode 100644 src/SLNX-validator/RequiredFilesOptions.cs create mode 100644 src/SLNX-validator/ValidatorRunnerOptions.cs diff --git a/README.md b/README.md index c20ef63..2b1f402 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,9 @@ Require all `.md` files under `doc/`: slnx-validator MySolution.slnx --required-files "doc/*.md" ``` -Require all `.cs` files under `src/`, excluding the `bin` and `obj` folders: +Require all `.yaml` files except those in the `src/` folder: ``` -slnx-validator MySolution.slnx --required-files "src/**/*.cs;!**/bin/**;!**/obj/**" +slnx-validator MySolution.slnx --required-files "**/*.yaml;!src/**" ``` Require a specific config file and the entire `docs/` directory: @@ -116,7 +116,7 @@ slnx-validator MySolution.slnx --required-files "appsettings.json;docs/" | Code | Description | |------|-------------| | `0` | All patterns matched and all matched files are referenced in the solution. | -| `1` | One or more required files don't exist on disk or are not referenced in the solution (same as any other validation error). | +| `1` | Any validation error — including required files not existing or not referenced. | ## SonarQube integration example diff --git a/src/SLNX-validator.Core/FileSystem/IFileSystem.cs b/src/SLNX-validator.Core/FileSystem/IFileSystem.cs index d648da8..9b51625 100644 --- a/src/SLNX-validator.Core/FileSystem/IFileSystem.cs +++ b/src/SLNX-validator.Core/FileSystem/IFileSystem.cs @@ -7,4 +7,6 @@ public interface IFileSystem IEnumerable GetFiles(string directory, string searchPattern); void CreateDirectory(string path); Stream CreateFile(string path); + Stream OpenRead(string path); + Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default); } diff --git a/src/SLNX-validator.Core/FileSystem/RealFileSystem.cs b/src/SLNX-validator.Core/FileSystem/RealFileSystem.cs index 5496a50..5992e92 100644 --- a/src/SLNX-validator.Core/FileSystem/RealFileSystem.cs +++ b/src/SLNX-validator.Core/FileSystem/RealFileSystem.cs @@ -8,4 +8,7 @@ public IEnumerable GetFiles(string directory, string searchPattern) => Directory.GetFiles(directory, searchPattern); public void CreateDirectory(string path) => Directory.CreateDirectory(path); public Stream CreateFile(string path) => File.Create(path); + public Stream OpenRead(string path) => File.OpenRead(path); + public Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default) => + File.ReadAllTextAsync(path, cancellationToken); } diff --git a/src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs b/src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs index 6d45d96..0513da9 100644 --- a/src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs +++ b/src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs @@ -11,13 +11,12 @@ public interface IRequiredFilesChecker IReadOnlyList ResolveMatchedPaths(string patternsRaw, string rootDirectory); /// - /// Checks which of the are NOT referenced as a - /// <File Path="..."> element in the given .slnx content. - /// Paths in the .slnx are resolved relative to . + /// Checks which of the are NOT present in + /// . /// Returns a for each missing file. /// IReadOnlyList CheckInSlnx( IReadOnlyList requiredAbsolutePaths, - string slnxContent, - string slnxDirectory); + SlnxFileRefs slnxFileRefs); } + diff --git a/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs b/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs index 802dbd4..0a94cfc 100644 --- a/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs +++ b/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs @@ -1,4 +1,3 @@ -using System.Xml.Linq; using JulianVerdurmen.SlnxValidator.Core.ValidationResults; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.FileSystemGlobbing.Abstractions; @@ -33,38 +32,14 @@ public IReadOnlyList ResolveMatchedPaths(string patternsRaw, string root /// public IReadOnlyList CheckInSlnx( IReadOnlyList requiredAbsolutePaths, - string slnxContent, - string slnxDirectory) + SlnxFileRefs slnxFileRefs) { - var slnxFileRefs = new HashSet(StringComparer.OrdinalIgnoreCase); - - try - { - var doc = XDocument.Parse(slnxContent); - foreach (var fileElement in doc.Descendants("File")) - { - var path = fileElement.Attribute("Path")?.Value; - if (path is null) - continue; - - var fullPath = Path.IsPathRooted(path) - ? Path.GetFullPath(path) - : Path.GetFullPath(Path.Combine(slnxDirectory, path)); - - slnxFileRefs.Add(fullPath); - } - } - catch (Exception) - { - // Malformed XML is already reported by the XML validator. - } - var errors = new List(); foreach (var requiredPath in requiredAbsolutePaths) { - if (!slnxFileRefs.Contains(requiredPath)) + if (!slnxFileRefs.AbsoluteFilePaths.Contains(requiredPath, StringComparer.OrdinalIgnoreCase)) { - var relativePath = Path.GetRelativePath(slnxDirectory, requiredPath).Replace('\\', '/'); + var relativePath = Path.GetRelativePath(slnxFileRefs.SlnxDirectory, requiredPath).Replace('\\', '/'); errors.Add(new ValidationError( ValidationErrorCode.RequiredFileNotReferencedInSolution, $"Required file is not referenced in the solution: {requiredPath}" + @@ -75,3 +50,4 @@ public IReadOnlyList CheckInSlnx( return errors; } } + diff --git a/src/SLNX-validator.Core/Validation/SlnxFileRefs.cs b/src/SLNX-validator.Core/Validation/SlnxFileRefs.cs new file mode 100644 index 0000000..3ca3da1 --- /dev/null +++ b/src/SLNX-validator.Core/Validation/SlnxFileRefs.cs @@ -0,0 +1,55 @@ +using System.Xml.Linq; + +namespace JulianVerdurmen.SlnxValidator.Core.Validation; + +/// +/// Represents the set of absolute file paths that are referenced as +/// <File Path="..."> elements inside a .slnx solution file. +/// +public sealed class SlnxFileRefs +{ + /// The directory that contains the .slnx file. + public string SlnxDirectory { get; } + + /// Absolute, normalised paths for every <File> entry in the solution. + public IReadOnlyList AbsoluteFilePaths { get; } + + private SlnxFileRefs(string slnxDirectory, IReadOnlyList absoluteFilePaths) + { + SlnxDirectory = slnxDirectory; + AbsoluteFilePaths = absoluteFilePaths; + } + + /// + /// Parses and returns the resolved absolute paths of all + /// <File Path="..."> elements. Relative paths are resolved against + /// . Returns an empty set when the XML is malformed. + /// + public static SlnxFileRefs Parse(string slnxContent, string slnxDirectory) + { + var refs = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + var doc = XDocument.Parse(slnxContent); + foreach (var fileElement in doc.Descendants("File")) + { + var path = fileElement.Attribute("Path")?.Value; + if (path is null) + continue; + + var fullPath = Path.IsPathRooted(path) + ? Path.GetFullPath(path) + : Path.GetFullPath(Path.Combine(slnxDirectory, path)); + + refs.Add(fullPath); + } + } + catch (Exception) + { + // Malformed XML is already reported by the XML validator. + } + + return new SlnxFileRefs(slnxDirectory, [.. refs]); + } +} diff --git a/src/SLNX-validator/Program.cs b/src/SLNX-validator/Program.cs index 3c01d56..c0c820e 100644 --- a/src/SLNX-validator/Program.cs +++ b/src/SLNX-validator/Program.cs @@ -44,12 +44,14 @@ public static async Task Main(string[] args) rootCommand.SetAction(async (parseResult, cancellationToken) => { - var input = parseResult.GetValue(inputArgument); - var sonarqubeReport = parseResult.GetValue(sonarqubeReportOption); - var continueOnError = parseResult.GetValue(continueOnErrorOption); - var requiredFiles = parseResult.GetValue(requiredFilesOption); - return await services.GetRequiredService() - .RunAsync(input!, sonarqubeReport, continueOnError, requiredFiles, Environment.CurrentDirectory, cancellationToken); + var options = new ValidatorRunnerOptions( + Input: parseResult.GetValue(inputArgument)!, + SonarqubeReportPath: parseResult.GetValue(sonarqubeReportOption), + ContinueOnError: parseResult.GetValue(continueOnErrorOption), + RequiredFilesPattern: parseResult.GetValue(requiredFilesOption), + WorkingDirectory: Environment.CurrentDirectory); + + return await services.GetRequiredService().RunAsync(options, cancellationToken); }); return await rootCommand.Parse(args).InvokeAsync(); diff --git a/src/SLNX-validator/RequiredFilesOptions.cs b/src/SLNX-validator/RequiredFilesOptions.cs new file mode 100644 index 0000000..8998f93 --- /dev/null +++ b/src/SLNX-validator/RequiredFilesOptions.cs @@ -0,0 +1,15 @@ +namespace JulianVerdurmen.SlnxValidator; + +/// +/// Bundled options for the --required-files feature passed to +/// . +/// +/// +/// Absolute disk paths that were matched by . +/// An empty list means the pattern matched no files. +/// means the --required-files option was not used. +/// +/// The raw semicolon-separated pattern string supplied by the user. +internal sealed record RequiredFilesOptions( + IReadOnlyList? MatchedPaths, + string? Pattern); diff --git a/src/SLNX-validator/ValidationCollector.cs b/src/SLNX-validator/ValidationCollector.cs index ad3b97e..eabf492 100644 --- a/src/SLNX-validator/ValidationCollector.cs +++ b/src/SLNX-validator/ValidationCollector.cs @@ -8,8 +8,7 @@ internal sealed class ValidationCollector(IFileSystem fileSystem, ISlnxValidator { public async Task> CollectAsync( IReadOnlyList files, - IReadOnlyList? matchedRequiredPaths, - string? requiredFilesPattern, + RequiredFilesOptions? requiredFilesOptions, CancellationToken cancellationToken) { var results = new List(files.Count); @@ -36,23 +35,25 @@ public async Task> CollectAsync( continue; } - var content = await File.ReadAllTextAsync(file, cancellationToken); + var content = await fileSystem.ReadAllTextAsync(file, cancellationToken); var directory = Path.GetDirectoryName(file)!; var result = await validator.ValidateAsync(content, directory, cancellationToken); var allErrors = result.Errors.ToList(); - if (matchedRequiredPaths is not null) + if (requiredFilesOptions is not null) { - if (matchedRequiredPaths.Count == 0) + var matched = requiredFilesOptions.MatchedPaths; + if (matched is null || matched.Count == 0) { allErrors.Add(new ValidationError( ValidationErrorCode.RequiredFileDoesntExistOnSystem, - $"Required file does not exist on the system. No files matched: {requiredFilesPattern}")); + $"Required file does not exist on the system. No files matched: {requiredFilesOptions.Pattern}")); } else { - allErrors.AddRange(requiredFilesChecker.CheckInSlnx(matchedRequiredPaths, content, directory)); + var slnxFileRefs = SlnxFileRefs.Parse(content, directory); + allErrors.AddRange(requiredFilesChecker.CheckInSlnx(matched, slnxFileRefs)); } } @@ -75,10 +76,10 @@ private static FileValidationResult Error(string file, ValidationErrorCode code, Errors = [new ValidationError(code, message)], }; - private static bool IsBinaryFile(string path) + private bool IsBinaryFile(string path) { Span buffer = stackalloc byte[8000]; - using var stream = File.OpenRead(path); + using var stream = fileSystem.OpenRead(path); var bytesRead = stream.Read(buffer); return buffer[..bytesRead].Contains((byte)0); } diff --git a/src/SLNX-validator/ValidatorRunner.cs b/src/SLNX-validator/ValidatorRunner.cs index 090242d..0e8f39e 100644 --- a/src/SLNX-validator/ValidatorRunner.cs +++ b/src/SLNX-validator/ValidatorRunner.cs @@ -7,30 +7,32 @@ namespace JulianVerdurmen.SlnxValidator; internal sealed class ValidatorRunner(ISlnxFileResolver resolver, ValidationCollector collector, ISonarReporter sonarReporter, IRequiredFilesChecker requiredFilesChecker) { - public async Task RunAsync(string input, string? sonarqubeReportPath, bool continueOnError, - string? requiredFilesPattern, string workingDirectory, CancellationToken cancellationToken) + public async Task RunAsync(ValidatorRunnerOptions options, CancellationToken cancellationToken) { - var files = resolver.Resolve(input); + var files = resolver.Resolve(options.Input); if (files.Count == 0) { - await Console.Error.WriteLineAsync($"No .slnx files found for input: {input}"); - return continueOnError ? 0 : 1; + await Console.Error.WriteLineAsync($"No .slnx files found for input: {options.Input}"); + return options.ContinueOnError ? 0 : 1; } // Resolve required file glob patterns to absolute disk paths (once for all .slnx files). - IReadOnlyList? matchedRequiredPaths = null; - if (requiredFilesPattern is not null) - matchedRequiredPaths = requiredFilesChecker.ResolveMatchedPaths(requiredFilesPattern, workingDirectory); + RequiredFilesOptions? requiredFilesOptions = null; + if (options.RequiredFilesPattern is not null) + { + var matchedPaths = requiredFilesChecker.ResolveMatchedPaths(options.RequiredFilesPattern, options.WorkingDirectory); + requiredFilesOptions = new RequiredFilesOptions(matchedPaths, options.RequiredFilesPattern); + } - var results = await collector.CollectAsync(files, matchedRequiredPaths, requiredFilesPattern, cancellationToken); + var results = await collector.CollectAsync(files, requiredFilesOptions, cancellationToken); await ValidationReporter.Report(results); - if (sonarqubeReportPath is not null) - await sonarReporter.WriteReportAsync(results, sonarqubeReportPath); + if (options.SonarqubeReportPath is not null) + await sonarReporter.WriteReportAsync(results, options.SonarqubeReportPath); var hasErrors = results.Any(r => r.HasErrors); - return !continueOnError && hasErrors ? 1 : 0; + return !options.ContinueOnError && hasErrors ? 1 : 0; } } diff --git a/src/SLNX-validator/ValidatorRunnerOptions.cs b/src/SLNX-validator/ValidatorRunnerOptions.cs new file mode 100644 index 0000000..f65f5b2 --- /dev/null +++ b/src/SLNX-validator/ValidatorRunnerOptions.cs @@ -0,0 +1,9 @@ +namespace JulianVerdurmen.SlnxValidator; + +/// All options forwarded from the CLI to . +internal sealed record ValidatorRunnerOptions( + string Input, + string? SonarqubeReportPath, + bool ContinueOnError, + string? RequiredFilesPattern, + string WorkingDirectory); diff --git a/tests/SLNX-validator.Core.Tests/MockFileSystem.cs b/tests/SLNX-validator.Core.Tests/MockFileSystem.cs index 34a9098..0d00af1 100644 --- a/tests/SLNX-validator.Core.Tests/MockFileSystem.cs +++ b/tests/SLNX-validator.Core.Tests/MockFileSystem.cs @@ -1,3 +1,4 @@ +using System.Text; using JulianVerdurmen.SlnxValidator.Core.FileSystem; namespace JulianVerdurmen.SlnxValidator.Core.Tests; @@ -19,4 +20,7 @@ public Stream CreateFile(string path) CreatedFiles[path] = stream; return stream; } + public Stream OpenRead(string path) => new MemoryStream(Encoding.UTF8.GetBytes("")); + public Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default) => + Task.FromResult(""); } diff --git a/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs b/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs index d1460e4..d42a3d8 100644 --- a/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs +++ b/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs @@ -15,7 +15,7 @@ private static string CreateTempDir() private static RequiredFilesChecker CreateChecker() => new(); - // ── ResolveMatchedPaths ────────────────────────────────────────────────── + // ── ResolveMatchedPaths (integration: 2 tests) ─────────────────────────── [Test] public void ResolveMatchedPaths_SingleInclude_MatchesFiles_ReturnsNonEmpty() @@ -60,83 +60,7 @@ public void ResolveMatchedPaths_IncludeFollowedByExclude_ExcludesFile() } } - [Test] - public void ResolveMatchedPaths_AllExcluded_ReturnsEmpty() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - File.WriteAllText(Path.Combine(docDir, "readme.md"), "# Readme"); - - var matched = CreateChecker().ResolveMatchedPaths("doc/*.md;!doc/*.md", tempDir); - - matched.Should().BeEmpty(); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public void ResolveMatchedPaths_PatternMatchesNothing_ReturnsEmpty() - { - var tempDir = CreateTempDir(); - try - { - var matched = CreateChecker().ResolveMatchedPaths("nonexistent/**/*.cs", tempDir); - - matched.Should().BeEmpty(); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public void ResolveMatchedPaths_WhitespaceAroundPatterns_IsTrimmed() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - File.WriteAllText(Path.Combine(docDir, "readme.md"), "# Readme"); - - var matched = CreateChecker().ResolveMatchedPaths(" doc/*.md ", tempDir); - - matched.Should().HaveCount(1); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public void ResolveMatchedPaths_EmptyPatternEntries_AreDiscarded() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - File.WriteAllText(Path.Combine(docDir, "readme.md"), "# Readme"); - - var matched = CreateChecker().ResolveMatchedPaths(";;doc/*.md;;", tempDir); - - matched.Should().HaveCount(1); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - // ── CheckInSlnx ───────────────────────────────────────────────────────── + // ── CheckInSlnx (unit tests – no real filesystem) ──────────────────────── private static readonly string SlnxDir = OperatingSystem.IsWindows() ? @"C:\repo" : "/repo"; @@ -152,7 +76,7 @@ public void CheckInSlnx_RequiredFilePresentInSlnx_ReturnsNoErrors() """; - var errors = CreateChecker().CheckInSlnx([requiredPath], slnxContent, SlnxDir); + var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFileRefs.Parse(slnxContent, SlnxDir)); errors.Should().BeEmpty(); } @@ -169,7 +93,7 @@ public void CheckInSlnx_RequiredFileMissingFromSlnx_ReturnsError() """; - var errors = CreateChecker().CheckInSlnx([requiredPath], slnxContent, SlnxDir); + var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFileRefs.Parse(slnxContent, SlnxDir)); errors.Should().HaveCount(1); errors[0].Code.Should().Be(ValidationErrorCode.RequiredFileNotReferencedInSolution); @@ -179,9 +103,8 @@ public void CheckInSlnx_RequiredFileMissingFromSlnx_ReturnsError() public void CheckInSlnx_ErrorMessageContainsFileElement() { var requiredPath = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "readme.md")); - var slnxContent = ""; - var errors = CreateChecker().CheckInSlnx([requiredPath], slnxContent, SlnxDir); + var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFileRefs.Parse("", SlnxDir)); errors.Should().HaveCount(1); errors[0].Message.Should().Contain(" """; - var errors = CreateChecker().CheckInSlnx([requiredPath], slnxContent, slnxDir); + var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFileRefs.Parse(slnxContent, slnxDir)); errors.Should().BeEmpty(); } @@ -215,9 +137,8 @@ public void CheckInSlnx_MultipleRequiredFiles_ReportsAllMissing() { var path1 = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "readme.md")); var path2 = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "contributing.md")); - var slnxContent = ""; - var errors = CreateChecker().CheckInSlnx([path1, path2], slnxContent, SlnxDir); + var errors = CreateChecker().CheckInSlnx([path1, path2], SlnxFileRefs.Parse("", SlnxDir)); errors.Should().HaveCount(2); errors.Should().AllSatisfy(e => e.Code.Should().Be(ValidationErrorCode.RequiredFileNotReferencedInSolution)); @@ -226,10 +147,9 @@ public void CheckInSlnx_MultipleRequiredFiles_ReportsAllMissing() [Test] public void CheckInSlnx_EmptyRequiredPaths_ReturnsNoErrors() { - var slnxContent = ""; - - var errors = CreateChecker().CheckInSlnx([], slnxContent, SlnxDir); + var errors = CreateChecker().CheckInSlnx([], SlnxFileRefs.Parse("", SlnxDir)); errors.Should().BeEmpty(); } } + diff --git a/tests/SLNX-validator.Tests/MockFileSystem.cs b/tests/SLNX-validator.Tests/MockFileSystem.cs index 14d4aa8..bd8528a 100644 --- a/tests/SLNX-validator.Tests/MockFileSystem.cs +++ b/tests/SLNX-validator.Tests/MockFileSystem.cs @@ -1,10 +1,26 @@ +using System.Text; using JulianVerdurmen.SlnxValidator.Core.FileSystem; namespace JulianVerdurmen.SlnxValidator.Tests; -internal sealed class MockFileSystem(params string[] existingPaths) : IFileSystem +internal sealed class MockFileSystem : IFileSystem { - private readonly HashSet _existingPaths = new(existingPaths, StringComparer.OrdinalIgnoreCase); + private readonly HashSet _existingPaths; + private readonly Dictionary _fileContents; + + /// Create a mock with files that exist but have no specific content. + public MockFileSystem(params string[] existingPaths) + { + _existingPaths = new(existingPaths, StringComparer.OrdinalIgnoreCase); + _fileContents = new(StringComparer.OrdinalIgnoreCase); + } + + /// Create a mock where each entry represents a file path → content mapping. + public MockFileSystem(Dictionary fileContents) + { + _fileContents = new(fileContents, StringComparer.OrdinalIgnoreCase); + _existingPaths = new(_fileContents.Keys, StringComparer.OrdinalIgnoreCase); + } public List CreatedDirectories { get; } = []; public Dictionary CreatedFiles { get; } = []; @@ -19,4 +35,8 @@ public Stream CreateFile(string path) CreatedFiles[path] = ms; return ms; } + public Stream OpenRead(string path) => + new MemoryStream(Encoding.UTF8.GetBytes(_fileContents.GetValueOrDefault(path, ""))); + public Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default) => + Task.FromResult(_fileContents.GetValueOrDefault(path, "")); } diff --git a/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs b/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs index 0195b6e..d95b2f8 100644 --- a/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs +++ b/tests/SLNX-validator.Tests/ProgramIntegrationTests.cs @@ -139,85 +139,4 @@ public async Task Invoke_WithBinaryFile_ReturnsNonZeroExitCode() File.Delete(path); } } - - [Test] - [NotInParallel("CurrentDirectory")] - public async Task Invoke_WithRequiredFiles_AllMatchAndReferencedInSlnx_ReturnsZeroExitCode() - { - var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(tempDir); - - var csprojPath = Path.Combine(tempDir, "App.csproj"); - var slnxPath = Path.Combine(tempDir, "test.slnx"); - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - - await File.WriteAllTextAsync(csprojPath, ""); - await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme"); - // The .slnx references doc/readme.md as a so the last check passes. - await File.WriteAllTextAsync(slnxPath, """ - - - - - - - """); - - try - { - var previousDir = Environment.CurrentDirectory; - Environment.CurrentDirectory = tempDir; - try - { - var exitCode = await Program.Main([slnxPath, "--required-files", "doc/*.md"]); - exitCode.Should().Be(0); - } - finally - { - Environment.CurrentDirectory = previousDir; - } - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - [NotInParallel("CurrentDirectory")] - public async Task Invoke_WithRequiredFiles_NoMatch_ReturnsNonZeroExitCode() - { - var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(tempDir); - - var csprojPath = Path.Combine(tempDir, "App.csproj"); - var slnxPath = Path.Combine(tempDir, "test.slnx"); - - await File.WriteAllTextAsync(csprojPath, ""); - await File.WriteAllTextAsync(slnxPath, """ - - - - """); - - try - { - var previousDir = Environment.CurrentDirectory; - Environment.CurrentDirectory = tempDir; - try - { - var exitCode = await Program.Main([slnxPath, "--required-files", "nonexistent/**/*.md"]); - exitCode.Should().NotBe(0); - } - finally - { - Environment.CurrentDirectory = previousDir; - } - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } } diff --git a/tests/SLNX-validator.Tests/ValidationCollectorTests.cs b/tests/SLNX-validator.Tests/ValidationCollectorTests.cs index 7164bdc..d99a886 100644 --- a/tests/SLNX-validator.Tests/ValidationCollectorTests.cs +++ b/tests/SLNX-validator.Tests/ValidationCollectorTests.cs @@ -7,144 +7,83 @@ namespace JulianVerdurmen.SlnxValidator.Tests; public class ValidationCollectorTests { - private static string CreateTempDir() - { - var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(tempDir); - return tempDir; - } + private const string SlnxPath = "/fake/test.slnx"; + private const string SlnxContent = ""; - private static ValidationCollector CreateCollector( - IRequiredFilesChecker? checker = null, - ISlnxValidator? validator = null) + private static (ValidationCollector collector, IRequiredFilesChecker checker) CreateCollector( + string? slnxContent = SlnxContent, + IRequiredFilesChecker? checker = null) { checker ??= Substitute.For(); - validator ??= Substitute.For(); - validator - .ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + var validator = Substitute.For(); + validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new ValidationResult()); - return new ValidationCollector(new MockFileSystem(), validator, checker); + var fileSystem = new MockFileSystem(new Dictionary + { + [SlnxPath] = slnxContent ?? "" + }); + return (new ValidationCollector(fileSystem, validator, checker), checker); } [Test] public async Task CollectAsync_RequiredFilesPatternNoMatch_AddsRequiredFileDoesntExistOnSystemError() { - var tempDir = CreateTempDir(); - try - { - var slnxPath = Path.Combine(tempDir, "test.slnx"); - await File.WriteAllTextAsync(slnxPath, ""); - - var fileSystem = new MockFileSystem(slnxPath); - var validator = Substitute.For(); - validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ValidationResult()); - var checker = Substitute.For(); - var collector = new ValidationCollector(fileSystem, validator, checker); - - // Empty matched paths = pattern matched nothing on disk - var results = await collector.CollectAsync([slnxPath], matchedRequiredPaths: [], "*.md", CancellationToken.None); - - results.Should().HaveCount(1); - results[0].HasErrors.Should().BeTrue(); - results[0].Errors.Should().ContainSingle(e => e.Code == ValidationErrorCode.RequiredFileDoesntExistOnSystem); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } + var (collector, _) = CreateCollector(); + var options = new RequiredFilesOptions(MatchedPaths: [], Pattern: "*.md"); + + var results = await collector.CollectAsync([SlnxPath], options, CancellationToken.None); + + results.Should().HaveCount(1); + results[0].HasErrors.Should().BeTrue(); + results[0].Errors.Should().ContainSingle(e => e.Code == ValidationErrorCode.RequiredFileDoesntExistOnSystem); } [Test] public async Task CollectAsync_RequiredFileMissingFromSlnx_AddsRequiredFileNotReferencedInSolutionError() { - var tempDir = CreateTempDir(); - try - { - var slnxPath = Path.Combine(tempDir, "test.slnx"); - await File.WriteAllTextAsync(slnxPath, ""); - var requiredFile = Path.Combine(tempDir, "readme.md"); - - var fileSystem = new MockFileSystem(slnxPath); - var validator = Substitute.For(); - validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ValidationResult()); - var checker = Substitute.For(); - checker.CheckInSlnx(Arg.Any>(), Arg.Any(), Arg.Any()) - .Returns([new ValidationError(ValidationErrorCode.RequiredFileNotReferencedInSolution, - $"Required file is not referenced in the solution: {requiredFile} — add: ")]); - - var collector = new ValidationCollector(fileSystem, validator, checker); - - var results = await collector.CollectAsync([slnxPath], [requiredFile], "*.md", CancellationToken.None); - - results.Should().HaveCount(1); - results[0].HasErrors.Should().BeTrue(); - results[0].Errors.Should().ContainSingle(e => e.Code == ValidationErrorCode.RequiredFileNotReferencedInSolution); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } + var requiredFile = "/fake/doc/readme.md"; + var checker = Substitute.For(); + checker.CheckInSlnx(Arg.Any>(), Arg.Any()) + .Returns([new ValidationError(ValidationErrorCode.RequiredFileNotReferencedInSolution, + $"Required file is not referenced in the solution: {requiredFile} — add: ")]); + + var (collector, _) = CreateCollector(checker: checker); + var options = new RequiredFilesOptions(MatchedPaths: [requiredFile], Pattern: "doc/*.md"); + + var results = await collector.CollectAsync([SlnxPath], options, CancellationToken.None); + + results.Should().HaveCount(1); + results[0].HasErrors.Should().BeTrue(); + results[0].Errors.Should().ContainSingle(e => e.Code == ValidationErrorCode.RequiredFileNotReferencedInSolution); } [Test] public async Task CollectAsync_RequiredFileMatchedAndReferenced_NoExtraErrors() { - var tempDir = CreateTempDir(); - try - { - var slnxPath = Path.Combine(tempDir, "test.slnx"); - await File.WriteAllTextAsync(slnxPath, ""); - var requiredFile = Path.Combine(tempDir, "readme.md"); - - var fileSystem = new MockFileSystem(slnxPath); - var validator = Substitute.For(); - validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ValidationResult()); - var checker = Substitute.For(); - checker.CheckInSlnx(Arg.Any>(), Arg.Any(), Arg.Any()) - .Returns(Array.Empty()); - - var collector = new ValidationCollector(fileSystem, validator, checker); - - var results = await collector.CollectAsync([slnxPath], [requiredFile], "*.md", CancellationToken.None); - - results.Should().HaveCount(1); - results[0].HasErrors.Should().BeFalse(); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } + var requiredFile = "/fake/doc/readme.md"; + var checker = Substitute.For(); + checker.CheckInSlnx(Arg.Any>(), Arg.Any()) + .Returns(Array.Empty()); + + var (collector, _) = CreateCollector(checker: checker); + var options = new RequiredFilesOptions(MatchedPaths: [requiredFile], Pattern: "doc/*.md"); + + var results = await collector.CollectAsync([SlnxPath], options, CancellationToken.None); + + results.Should().HaveCount(1); + results[0].HasErrors.Should().BeFalse(); } [Test] - public async Task CollectAsync_NullMatchedRequiredPaths_SkipsRequiredFilesCheck() + public async Task CollectAsync_NullOptions_SkipsRequiredFilesCheck() { - var tempDir = CreateTempDir(); - try - { - var slnxPath = Path.Combine(tempDir, "test.slnx"); - await File.WriteAllTextAsync(slnxPath, ""); - - var fileSystem = new MockFileSystem(slnxPath); - var validator = Substitute.For(); - validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ValidationResult()); - var checker = Substitute.For(); - var collector = new ValidationCollector(fileSystem, validator, checker); - - // null matchedRequiredPaths = no --required-files option was provided - var results = await collector.CollectAsync([slnxPath], matchedRequiredPaths: null, requiredFilesPattern: null, CancellationToken.None); - - results.Should().HaveCount(1); - results[0].HasErrors.Should().BeFalse(); - checker.DidNotReceive().CheckInSlnx(Arg.Any>(), Arg.Any(), Arg.Any()); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } + var (collector, checker) = CreateCollector(); + + var results = await collector.CollectAsync([SlnxPath], requiredFilesOptions: null, CancellationToken.None); + + results.Should().HaveCount(1); + results[0].HasErrors.Should().BeFalse(); + checker.DidNotReceive().CheckInSlnx(Arg.Any>(), Arg.Any()); } } + diff --git a/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs b/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs index d85c7cc..65e000a 100644 --- a/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs +++ b/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs @@ -2,6 +2,7 @@ using JulianVerdurmen.SlnxValidator.Core.FileSystem; using JulianVerdurmen.SlnxValidator.Core.SonarQubeReporting; using JulianVerdurmen.SlnxValidator.Core.Validation; +using JulianVerdurmen.SlnxValidator.Core.ValidationResults; using NSubstitute; namespace JulianVerdurmen.SlnxValidator.Tests; @@ -15,13 +16,16 @@ private static ValidatorRunner CreateRunner(IFileSystem fileSystem, IRequiredFil return new ValidatorRunner(Substitute.For(), collector, sonarReporter, checker); } + private static ValidatorRunnerOptions Options(string input = "test.slnx", + bool continueOnError = false, string? requiredFilesPattern = null) => + new(input, SonarqubeReportPath: null, continueOnError, requiredFilesPattern, WorkingDirectory: "."); + [Test] public async Task RunAsync_FileNotFound_ContinueOnErrorFalse_ReturnsOne() { var runner = CreateRunner(new MockFileSystem()); - var exitCode = await runner.RunAsync("nonexistent.slnx", sonarqubeReportPath: null, continueOnError: false, - requiredFilesPattern: null, workingDirectory: ".", CancellationToken.None); + var exitCode = await runner.RunAsync(Options("nonexistent.slnx"), CancellationToken.None); exitCode.Should().Be(1); } @@ -31,8 +35,7 @@ public async Task RunAsync_FileNotFound_ContinueOnErrorTrue_ReturnsZero() { var runner = CreateRunner(new MockFileSystem()); - var exitCode = await runner.RunAsync("nonexistent.slnx", sonarqubeReportPath: null, continueOnError: true, - requiredFilesPattern: null, workingDirectory: ".", CancellationToken.None); + var exitCode = await runner.RunAsync(Options("nonexistent.slnx", continueOnError: true), CancellationToken.None); exitCode.Should().Be(0); } @@ -42,8 +45,7 @@ public async Task RunAsync_NoFilesFound_ContinueOnErrorFalse_ReturnsOne() { var runner = CreateRunner(new MockFileSystem()); - var exitCode = await runner.RunAsync("src/*.slnx", sonarqubeReportPath: null, continueOnError: false, - requiredFilesPattern: null, workingDirectory: ".", CancellationToken.None); + var exitCode = await runner.RunAsync(Options("src/*.slnx"), CancellationToken.None); exitCode.Should().Be(1); } @@ -53,10 +55,67 @@ public async Task RunAsync_NoFilesFound_ContinueOnErrorTrue_ReturnsZero() { var runner = CreateRunner(new MockFileSystem()); - var exitCode = await runner.RunAsync("src/*.slnx", sonarqubeReportPath: null, continueOnError: true, - requiredFilesPattern: null, workingDirectory: ".", CancellationToken.None); + var exitCode = await runner.RunAsync(Options("src/*.slnx", continueOnError: true), CancellationToken.None); exitCode.Should().Be(0); } + + // ── --required-files unit tests ─────────────────────────────────────────── + + private static ValidatorRunner CreateRunnerWithSlnx( + string slnxPath, string slnxContent, IRequiredFilesChecker? checker = null) + { + checker ??= Substitute.For(); + var fileSystem = new MockFileSystem(new Dictionary + { + [slnxPath] = slnxContent + }); + var validator = Substitute.For(); + validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ValidationResult()); + var collector = new ValidationCollector(fileSystem, validator, checker); + var sonarReporter = new SonarReporter(fileSystem); + var resolver = Substitute.For(); + resolver.Resolve(Arg.Any()).Returns([slnxPath]); + return new ValidatorRunner(resolver, collector, sonarReporter, checker); + } + + [Test] + public async Task RunAsync_RequiredFiles_AllMatchedAndReferenced_ReturnsZero() + { + var slnxPath = Path.GetFullPath("test.slnx"); + var slnxDir = Path.GetDirectoryName(slnxPath)!; + var requiredFile = Path.GetFullPath(Path.Combine(slnxDir, "doc", "readme.md")); + + var checker = Substitute.For(); + checker.ResolveMatchedPaths(Arg.Any(), Arg.Any()) + .Returns([requiredFile]); + checker.CheckInSlnx(Arg.Any>(), Arg.Any()) + .Returns([]); + + var runner = CreateRunnerWithSlnx(slnxPath, "", checker); + + var exitCode = await runner.RunAsync( + Options(slnxPath, requiredFilesPattern: "doc/*.md"), CancellationToken.None); + + exitCode.Should().Be(0); + } + + [Test] + public async Task RunAsync_RequiredFiles_NoMatchOnDisk_ReturnsOne() + { + var slnxPath = Path.GetFullPath("test.slnx"); + + var checker = Substitute.For(); + checker.ResolveMatchedPaths(Arg.Any(), Arg.Any()) + .Returns([]); // nothing matched on disk + + var runner = CreateRunnerWithSlnx(slnxPath, "", checker); + + var exitCode = await runner.RunAsync( + Options(slnxPath, requiredFilesPattern: "nonexistent/**/*.md"), CancellationToken.None); + + exitCode.Should().Be(1); + } } From e956c345cf52ecafd4a904534cfe4ff6eed45eed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 12:59:45 +0000 Subject: [PATCH 6/6] =?UTF-8?q?Rename=20SlnxFileRefs=E2=86=92SlnxFile,=20r?= =?UTF-8?q?emove=20try-catch,=20add=20#regions/AAA,=20split=20integration?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 304NotModified <5808377+304NotModified@users.noreply.github.com> Agent-Logs-Url: https://github.com/304NotModified/SLNX-validator/sessions/ceaf8ba9-6628-42a8-b534-1dfec61aafc3 --- .../Validation/IRequiredFilesChecker.cs | 4 +- .../Validation/RequiredFilesChecker.cs | 6 +- .../Validation/SlnxFile.cs | 57 +++++++ .../Validation/SlnxFileRefs.cs | 55 ------- src/SLNX-validator/ValidationCollector.cs | 5 +- .../RequiredFilesCheckerIntegrationTests.cs | 69 +++++++++ .../RequiredFilesCheckerTests.cs | 139 ++++++++++-------- .../ValidationCollectorTests.cs | 23 ++- .../ValidatorRunnerTests.cs | 62 +++++--- 9 files changed, 275 insertions(+), 145 deletions(-) create mode 100644 src/SLNX-validator.Core/Validation/SlnxFile.cs delete mode 100644 src/SLNX-validator.Core/Validation/SlnxFileRefs.cs create mode 100644 tests/SLNX-validator.Core.Tests/RequiredFilesCheckerIntegrationTests.cs diff --git a/src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs b/src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs index 0513da9..8ab21aa 100644 --- a/src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs +++ b/src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs @@ -12,11 +12,11 @@ public interface IRequiredFilesChecker /// /// Checks which of the are NOT present in - /// . + /// . /// Returns a for each missing file. /// IReadOnlyList CheckInSlnx( IReadOnlyList requiredAbsolutePaths, - SlnxFileRefs slnxFileRefs); + SlnxFile slnxFile); } diff --git a/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs b/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs index 0a94cfc..5612c49 100644 --- a/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs +++ b/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs @@ -32,14 +32,14 @@ public IReadOnlyList ResolveMatchedPaths(string patternsRaw, string root /// public IReadOnlyList CheckInSlnx( IReadOnlyList requiredAbsolutePaths, - SlnxFileRefs slnxFileRefs) + SlnxFile slnxFile) { var errors = new List(); foreach (var requiredPath in requiredAbsolutePaths) { - if (!slnxFileRefs.AbsoluteFilePaths.Contains(requiredPath, StringComparer.OrdinalIgnoreCase)) + if (!slnxFile.Files.Contains(requiredPath, StringComparer.OrdinalIgnoreCase)) { - var relativePath = Path.GetRelativePath(slnxFileRefs.SlnxDirectory, requiredPath).Replace('\\', '/'); + var relativePath = Path.GetRelativePath(slnxFile.SlnxDirectory, requiredPath).Replace('\\', '/'); errors.Add(new ValidationError( ValidationErrorCode.RequiredFileNotReferencedInSolution, $"Required file is not referenced in the solution: {requiredPath}" + diff --git a/src/SLNX-validator.Core/Validation/SlnxFile.cs b/src/SLNX-validator.Core/Validation/SlnxFile.cs new file mode 100644 index 0000000..83a7d2b --- /dev/null +++ b/src/SLNX-validator.Core/Validation/SlnxFile.cs @@ -0,0 +1,57 @@ +using System.Xml.Linq; + +namespace JulianVerdurmen.SlnxValidator.Core.Validation; + +/// +/// Represents the set of absolute file paths that are referenced as +/// <File Path="..."> elements inside a .slnx solution file. +/// +public sealed class SlnxFile +{ + /// The directory that contains the .slnx file. + public string SlnxDirectory { get; } + + /// Absolute, normalised paths for every <File> entry in the solution. + public IReadOnlyList Files { get; } + + private SlnxFile(string slnxDirectory, IReadOnlyList files) + { + SlnxDirectory = slnxDirectory; + Files = files; + } + + /// + /// Parses and returns the resolved absolute paths of all + /// <File Path="..."> elements. Relative paths are resolved against + /// . + /// + /// The parsed , or when the XML is malformed. + public static SlnxFile? Parse(string slnxContent, string slnxDirectory) + { + XDocument doc; + try + { + doc = XDocument.Parse(slnxContent); + } + catch (Exception) + { + return null; + } + + var refs = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var fileElement in doc.Descendants("File")) + { + var path = fileElement.Attribute("Path")?.Value; + if (path is null) + continue; + + var fullPath = Path.IsPathRooted(path) + ? Path.GetFullPath(path) + : Path.GetFullPath(Path.Combine(slnxDirectory, path)); + + refs.Add(fullPath); + } + + return new SlnxFile(slnxDirectory, [.. refs]); + } +} diff --git a/src/SLNX-validator.Core/Validation/SlnxFileRefs.cs b/src/SLNX-validator.Core/Validation/SlnxFileRefs.cs deleted file mode 100644 index 3ca3da1..0000000 --- a/src/SLNX-validator.Core/Validation/SlnxFileRefs.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Xml.Linq; - -namespace JulianVerdurmen.SlnxValidator.Core.Validation; - -/// -/// Represents the set of absolute file paths that are referenced as -/// <File Path="..."> elements inside a .slnx solution file. -/// -public sealed class SlnxFileRefs -{ - /// The directory that contains the .slnx file. - public string SlnxDirectory { get; } - - /// Absolute, normalised paths for every <File> entry in the solution. - public IReadOnlyList AbsoluteFilePaths { get; } - - private SlnxFileRefs(string slnxDirectory, IReadOnlyList absoluteFilePaths) - { - SlnxDirectory = slnxDirectory; - AbsoluteFilePaths = absoluteFilePaths; - } - - /// - /// Parses and returns the resolved absolute paths of all - /// <File Path="..."> elements. Relative paths are resolved against - /// . Returns an empty set when the XML is malformed. - /// - public static SlnxFileRefs Parse(string slnxContent, string slnxDirectory) - { - var refs = new HashSet(StringComparer.OrdinalIgnoreCase); - - try - { - var doc = XDocument.Parse(slnxContent); - foreach (var fileElement in doc.Descendants("File")) - { - var path = fileElement.Attribute("Path")?.Value; - if (path is null) - continue; - - var fullPath = Path.IsPathRooted(path) - ? Path.GetFullPath(path) - : Path.GetFullPath(Path.Combine(slnxDirectory, path)); - - refs.Add(fullPath); - } - } - catch (Exception) - { - // Malformed XML is already reported by the XML validator. - } - - return new SlnxFileRefs(slnxDirectory, [.. refs]); - } -} diff --git a/src/SLNX-validator/ValidationCollector.cs b/src/SLNX-validator/ValidationCollector.cs index eabf492..2b44bc8 100644 --- a/src/SLNX-validator/ValidationCollector.cs +++ b/src/SLNX-validator/ValidationCollector.cs @@ -52,8 +52,9 @@ public async Task> CollectAsync( } else { - var slnxFileRefs = SlnxFileRefs.Parse(content, directory); - allErrors.AddRange(requiredFilesChecker.CheckInSlnx(matched, slnxFileRefs)); + var slnxFile = SlnxFile.Parse(content, directory); + if (slnxFile is not null) + allErrors.AddRange(requiredFilesChecker.CheckInSlnx(matched, slnxFile)); } } diff --git a/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerIntegrationTests.cs b/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerIntegrationTests.cs new file mode 100644 index 0000000..da76213 --- /dev/null +++ b/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerIntegrationTests.cs @@ -0,0 +1,69 @@ +using AwesomeAssertions; +using JulianVerdurmen.SlnxValidator.Core.Validation; + +namespace JulianVerdurmen.SlnxValidator.Core.Tests; + +public class RequiredFilesCheckerIntegrationTests +{ + private static string CreateTempDir() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + return tempDir; + } + + private static RequiredFilesChecker CreateChecker() => new(); + + #region ResolveMatchedPaths + + [Test] + public void ResolveMatchedPaths_SingleInclude_MatchesFiles_ReturnsNonEmpty() + { + // Arrange + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + File.WriteAllText(Path.Combine(docDir, "readme.md"), "# Readme"); + File.WriteAllText(Path.Combine(docDir, "contributing.md"), "# Contributing"); + + // Act + var matched = CreateChecker().ResolveMatchedPaths("doc/*.md", tempDir); + + // Assert + matched.Should().HaveCount(2); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public void ResolveMatchedPaths_IncludeFollowedByExclude_ExcludesFile() + { + // Arrange + var tempDir = CreateTempDir(); + try + { + var docDir = Path.Combine(tempDir, "doc"); + Directory.CreateDirectory(docDir); + File.WriteAllText(Path.Combine(docDir, "readme.md"), "# Readme"); + File.WriteAllText(Path.Combine(docDir, "contributing.md"), "# Contributing"); + + // Act + var matched = CreateChecker().ResolveMatchedPaths("doc/*.md;!doc/contributing.md", tempDir); + + // Assert + matched.Should().HaveCount(1); + matched[0].Should().EndWith("readme.md"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + #endregion +} diff --git a/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs b/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs index d42a3d8..961eeb7 100644 --- a/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs +++ b/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs @@ -6,67 +6,16 @@ namespace JulianVerdurmen.SlnxValidator.Core.Tests; public class RequiredFilesCheckerTests { - private static string CreateTempDir() - { - var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(tempDir); - return tempDir; - } - private static RequiredFilesChecker CreateChecker() => new(); - // ── ResolveMatchedPaths (integration: 2 tests) ─────────────────────────── - - [Test] - public void ResolveMatchedPaths_SingleInclude_MatchesFiles_ReturnsNonEmpty() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - File.WriteAllText(Path.Combine(docDir, "readme.md"), "# Readme"); - File.WriteAllText(Path.Combine(docDir, "contributing.md"), "# Contributing"); - - var matched = CreateChecker().ResolveMatchedPaths("doc/*.md", tempDir); - - matched.Should().HaveCount(2); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - [Test] - public void ResolveMatchedPaths_IncludeFollowedByExclude_ExcludesFile() - { - var tempDir = CreateTempDir(); - try - { - var docDir = Path.Combine(tempDir, "doc"); - Directory.CreateDirectory(docDir); - File.WriteAllText(Path.Combine(docDir, "readme.md"), "# Readme"); - File.WriteAllText(Path.Combine(docDir, "contributing.md"), "# Contributing"); - - var matched = CreateChecker().ResolveMatchedPaths("doc/*.md;!doc/contributing.md", tempDir); - - matched.Should().HaveCount(1); - matched[0].Should().EndWith("readme.md"); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } - } - - // ── CheckInSlnx (unit tests – no real filesystem) ──────────────────────── - private static readonly string SlnxDir = OperatingSystem.IsWindows() ? @"C:\repo" : "/repo"; + #region CheckInSlnx + [Test] public void CheckInSlnx_RequiredFilePresentInSlnx_ReturnsNoErrors() { + // Arrange var requiredPath = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "readme.md")); var slnxContent = """ @@ -76,14 +25,17 @@ public void CheckInSlnx_RequiredFilePresentInSlnx_ReturnsNoErrors() """; - var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFileRefs.Parse(slnxContent, SlnxDir)); + // Act + var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFile.Parse(slnxContent, SlnxDir)!); + // Assert errors.Should().BeEmpty(); } [Test] public void CheckInSlnx_RequiredFileMissingFromSlnx_ReturnsError() { + // Arrange var requiredPath = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "readme.md")); var slnxContent = """ @@ -93,8 +45,10 @@ public void CheckInSlnx_RequiredFileMissingFromSlnx_ReturnsError() """; - var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFileRefs.Parse(slnxContent, SlnxDir)); + // Act + var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFile.Parse(slnxContent, SlnxDir)!); + // Assert errors.Should().HaveCount(1); errors[0].Code.Should().Be(ValidationErrorCode.RequiredFileNotReferencedInSolution); } @@ -102,10 +56,13 @@ public void CheckInSlnx_RequiredFileMissingFromSlnx_ReturnsError() [Test] public void CheckInSlnx_ErrorMessageContainsFileElement() { + // Arrange var requiredPath = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "readme.md")); - var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFileRefs.Parse("", SlnxDir)); + // Act + var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFile.Parse("", SlnxDir)!); + // Assert errors.Should().HaveCount(1); errors[0].Message.Should().Contain(" @@ -127,19 +84,24 @@ public void CheckInSlnx_RelativeDoubleDotPath_NormalizesCorrectly() """; - var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFileRefs.Parse(slnxContent, slnxDir)); + // Act + var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFile.Parse(slnxContent, slnxDir)!); + // Assert errors.Should().BeEmpty(); } [Test] public void CheckInSlnx_MultipleRequiredFiles_ReportsAllMissing() { + // Arrange var path1 = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "readme.md")); var path2 = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "contributing.md")); - var errors = CreateChecker().CheckInSlnx([path1, path2], SlnxFileRefs.Parse("", SlnxDir)); + // Act + var errors = CreateChecker().CheckInSlnx([path1, path2], SlnxFile.Parse("", SlnxDir)!); + // Assert errors.Should().HaveCount(2); errors.Should().AllSatisfy(e => e.Code.Should().Be(ValidationErrorCode.RequiredFileNotReferencedInSolution)); } @@ -147,9 +109,64 @@ public void CheckInSlnx_MultipleRequiredFiles_ReportsAllMissing() [Test] public void CheckInSlnx_EmptyRequiredPaths_ReturnsNoErrors() { - var errors = CreateChecker().CheckInSlnx([], SlnxFileRefs.Parse("", SlnxDir)); + // Arrange + var slnxFile = SlnxFile.Parse("", SlnxDir)!; + // Act + var errors = CreateChecker().CheckInSlnx([], slnxFile); + + // Assert errors.Should().BeEmpty(); } + + #endregion + + #region SlnxFile.Parse + + [Test] + public void SlnxFileParse_ValidXml_ReturnsNonNull() + { + // Arrange + var content = ""; + + // Act + var result = SlnxFile.Parse(content, SlnxDir); + + // Assert + result.Should().NotBeNull(); + } + + [Test] + public void SlnxFileParse_InvalidXml_ReturnsNull() + { + // Arrange + var content = "<<>>"; + + // Act + var result = SlnxFile.Parse(content, SlnxDir); + + // Assert + result.Should().BeNull(); + } + + [Test] + public void SlnxFileParse_FileElements_AreNormalisedToAbsolutePaths() + { + // Arrange + var content = """ + + + + """; + + // Act + var result = SlnxFile.Parse(content, SlnxDir)!; + + // Assert + result.Files.Should().HaveCount(1); + result.Files[0].Should().Be(Path.GetFullPath(Path.Combine(SlnxDir, "doc", "readme.md"))); + } + + #endregion } diff --git a/tests/SLNX-validator.Tests/ValidationCollectorTests.cs b/tests/SLNX-validator.Tests/ValidationCollectorTests.cs index d99a886..83199ff 100644 --- a/tests/SLNX-validator.Tests/ValidationCollectorTests.cs +++ b/tests/SLNX-validator.Tests/ValidationCollectorTests.cs @@ -25,14 +25,19 @@ private static (ValidationCollector collector, IRequiredFilesChecker checker) Cr return (new ValidationCollector(fileSystem, validator, checker), checker); } + #region CollectAsync + [Test] public async Task CollectAsync_RequiredFilesPatternNoMatch_AddsRequiredFileDoesntExistOnSystemError() { + // Arrange var (collector, _) = CreateCollector(); var options = new RequiredFilesOptions(MatchedPaths: [], Pattern: "*.md"); + // Act var results = await collector.CollectAsync([SlnxPath], options, CancellationToken.None); + // Assert results.Should().HaveCount(1); results[0].HasErrors.Should().BeTrue(); results[0].Errors.Should().ContainSingle(e => e.Code == ValidationErrorCode.RequiredFileDoesntExistOnSystem); @@ -41,17 +46,20 @@ public async Task CollectAsync_RequiredFilesPatternNoMatch_AddsRequiredFileDoesn [Test] public async Task CollectAsync_RequiredFileMissingFromSlnx_AddsRequiredFileNotReferencedInSolutionError() { + // Arrange var requiredFile = "/fake/doc/readme.md"; var checker = Substitute.For(); - checker.CheckInSlnx(Arg.Any>(), Arg.Any()) + checker.CheckInSlnx(Arg.Any>(), Arg.Any()) .Returns([new ValidationError(ValidationErrorCode.RequiredFileNotReferencedInSolution, $"Required file is not referenced in the solution: {requiredFile} — add: ")]); var (collector, _) = CreateCollector(checker: checker); var options = new RequiredFilesOptions(MatchedPaths: [requiredFile], Pattern: "doc/*.md"); + // Act var results = await collector.CollectAsync([SlnxPath], options, CancellationToken.None); + // Assert results.Should().HaveCount(1); results[0].HasErrors.Should().BeTrue(); results[0].Errors.Should().ContainSingle(e => e.Code == ValidationErrorCode.RequiredFileNotReferencedInSolution); @@ -60,16 +68,19 @@ public async Task CollectAsync_RequiredFileMissingFromSlnx_AddsRequiredFileNotRe [Test] public async Task CollectAsync_RequiredFileMatchedAndReferenced_NoExtraErrors() { + // Arrange var requiredFile = "/fake/doc/readme.md"; var checker = Substitute.For(); - checker.CheckInSlnx(Arg.Any>(), Arg.Any()) + checker.CheckInSlnx(Arg.Any>(), Arg.Any()) .Returns(Array.Empty()); var (collector, _) = CreateCollector(checker: checker); var options = new RequiredFilesOptions(MatchedPaths: [requiredFile], Pattern: "doc/*.md"); + // Act var results = await collector.CollectAsync([SlnxPath], options, CancellationToken.None); + // Assert results.Should().HaveCount(1); results[0].HasErrors.Should().BeFalse(); } @@ -77,13 +88,19 @@ public async Task CollectAsync_RequiredFileMatchedAndReferenced_NoExtraErrors() [Test] public async Task CollectAsync_NullOptions_SkipsRequiredFilesCheck() { + // Arrange var (collector, checker) = CreateCollector(); + // Act var results = await collector.CollectAsync([SlnxPath], requiredFilesOptions: null, CancellationToken.None); + // Assert results.Should().HaveCount(1); results[0].HasErrors.Should().BeFalse(); - checker.DidNotReceive().CheckInSlnx(Arg.Any>(), Arg.Any()); + checker.DidNotReceive().CheckInSlnx(Arg.Any>(), Arg.Any()); } + + #endregion } + diff --git a/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs b/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs index 65e000a..7c5fa6b 100644 --- a/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs +++ b/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs @@ -20,69 +20,86 @@ private static ValidatorRunnerOptions Options(string input = "test.slnx", bool continueOnError = false, string? requiredFilesPattern = null) => new(input, SonarqubeReportPath: null, continueOnError, requiredFilesPattern, WorkingDirectory: "."); + private static ValidatorRunner CreateRunnerWithSlnx( + string slnxPath, string slnxContent, IRequiredFilesChecker? checker = null) + { + checker ??= Substitute.For(); + var fileSystem = new MockFileSystem(new Dictionary + { + [slnxPath] = slnxContent + }); + var validator = Substitute.For(); + validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ValidationResult()); + var collector = new ValidationCollector(fileSystem, validator, checker); + var sonarReporter = new SonarReporter(fileSystem); + var resolver = Substitute.For(); + resolver.Resolve(Arg.Any()).Returns([slnxPath]); + return new ValidatorRunner(resolver, collector, sonarReporter, checker); + } + + #region RunAsync – file resolution + [Test] public async Task RunAsync_FileNotFound_ContinueOnErrorFalse_ReturnsOne() { + // Arrange var runner = CreateRunner(new MockFileSystem()); + // Act var exitCode = await runner.RunAsync(Options("nonexistent.slnx"), CancellationToken.None); + // Assert exitCode.Should().Be(1); } [Test] public async Task RunAsync_FileNotFound_ContinueOnErrorTrue_ReturnsZero() { + // Arrange var runner = CreateRunner(new MockFileSystem()); + // Act var exitCode = await runner.RunAsync(Options("nonexistent.slnx", continueOnError: true), CancellationToken.None); + // Assert exitCode.Should().Be(0); } [Test] public async Task RunAsync_NoFilesFound_ContinueOnErrorFalse_ReturnsOne() { + // Arrange var runner = CreateRunner(new MockFileSystem()); + // Act var exitCode = await runner.RunAsync(Options("src/*.slnx"), CancellationToken.None); + // Assert exitCode.Should().Be(1); } [Test] public async Task RunAsync_NoFilesFound_ContinueOnErrorTrue_ReturnsZero() { + // Arrange var runner = CreateRunner(new MockFileSystem()); + // Act var exitCode = await runner.RunAsync(Options("src/*.slnx", continueOnError: true), CancellationToken.None); + // Assert exitCode.Should().Be(0); } - // ── --required-files unit tests ─────────────────────────────────────────── + #endregion - private static ValidatorRunner CreateRunnerWithSlnx( - string slnxPath, string slnxContent, IRequiredFilesChecker? checker = null) - { - checker ??= Substitute.For(); - var fileSystem = new MockFileSystem(new Dictionary - { - [slnxPath] = slnxContent - }); - var validator = Substitute.For(); - validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ValidationResult()); - var collector = new ValidationCollector(fileSystem, validator, checker); - var sonarReporter = new SonarReporter(fileSystem); - var resolver = Substitute.For(); - resolver.Resolve(Arg.Any()).Returns([slnxPath]); - return new ValidatorRunner(resolver, collector, sonarReporter, checker); - } + #region RunAsync – --required-files [Test] public async Task RunAsync_RequiredFiles_AllMatchedAndReferenced_ReturnsZero() { + // Arrange var slnxPath = Path.GetFullPath("test.slnx"); var slnxDir = Path.GetDirectoryName(slnxPath)!; var requiredFile = Path.GetFullPath(Path.Combine(slnxDir, "doc", "readme.md")); @@ -90,20 +107,23 @@ public async Task RunAsync_RequiredFiles_AllMatchedAndReferenced_ReturnsZero() var checker = Substitute.For(); checker.ResolveMatchedPaths(Arg.Any(), Arg.Any()) .Returns([requiredFile]); - checker.CheckInSlnx(Arg.Any>(), Arg.Any()) + checker.CheckInSlnx(Arg.Any>(), Arg.Any()) .Returns([]); var runner = CreateRunnerWithSlnx(slnxPath, "", checker); + // Act var exitCode = await runner.RunAsync( Options(slnxPath, requiredFilesPattern: "doc/*.md"), CancellationToken.None); + // Assert exitCode.Should().Be(0); } [Test] public async Task RunAsync_RequiredFiles_NoMatchOnDisk_ReturnsOne() { + // Arrange var slnxPath = Path.GetFullPath("test.slnx"); var checker = Substitute.For(); @@ -112,10 +132,14 @@ public async Task RunAsync_RequiredFiles_NoMatchOnDisk_ReturnsOne() var runner = CreateRunnerWithSlnx(slnxPath, "", checker); + // Act var exitCode = await runner.RunAsync( Options(slnxPath, requiredFilesPattern: "nonexistent/**/*.md"), CancellationToken.None); + // Assert exitCode.Should().Be(1); } + + #endregion }