diff --git a/README.md b/README.md index 4fc01c1..2b1f402 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,59 @@ 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 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. + +- **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. + +Relative paths in the `.slnx` are resolved relative to the solution file's location. + +**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 `.yaml` files except those in the `src/` folder: +``` +slnx-validator MySolution.slnx --required-files "**/*.yaml;!src/**" +``` + +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 files are referenced in the solution. | +| `1` | Any validation error — including required files not existing or not referenced. | + ## SonarQube integration example ```powershell @@ -185,6 +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` | `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/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/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..8ab21aa --- /dev/null +++ b/src/SLNX-validator.Core/Validation/IRequiredFilesChecker.cs @@ -0,0 +1,22 @@ +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 present in + /// . + /// Returns a for each missing file. + /// + IReadOnlyList CheckInSlnx( + IReadOnlyList requiredAbsolutePaths, + SlnxFile slnxFile); +} + diff --git a/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs b/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs new file mode 100644 index 0000000..5612c49 --- /dev/null +++ b/src/SLNX-validator.Core/Validation/RequiredFilesChecker.cs @@ -0,0 +1,53 @@ +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, + SlnxFile slnxFile) + { + var errors = new List(); + foreach (var requiredPath in requiredAbsolutePaths) + { + if (!slnxFile.Files.Contains(requiredPath, StringComparer.OrdinalIgnoreCase)) + { + var relativePath = Path.GetRelativePath(slnxFile.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/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/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 c904754..c0c820e 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 files that must exist on disk and be referenced as elements in the solution." + }; + var rootCommand = new RootCommand("Validates .slnx solution files.") { inputArgument, sonarqubeReportOption, - continueOnErrorOption + continueOnErrorOption, + requiredFilesOption }; var services = new ServiceCollection() @@ -38,10 +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); - return await services.GetRequiredService().RunAsync(input!, sonarqubeReport, continueOnError, 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 5d18d30..2b44bc8 100644 --- a/src/SLNX-validator/ValidationCollector.cs +++ b/src/SLNX-validator/ValidationCollector.cs @@ -4,9 +4,12 @@ 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, + RequiredFilesOptions? requiredFilesOptions, + CancellationToken cancellationToken) { var results = new List(files.Count); @@ -32,15 +35,34 @@ public async Task> CollectAsync(IReadOnlyLis 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 (requiredFilesOptions is not null) + { + 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: {requiredFilesOptions.Pattern}")); + } + else + { + var slnxFile = SlnxFile.Parse(content, directory); + if (slnxFile is not null) + allErrors.AddRange(requiredFilesChecker.CheckInSlnx(matched, slnxFile)); + } + } + results.Add(new FileValidationResult { File = file, - HasErrors = !result.IsValid, - Errors = result.Errors, + HasErrors = allErrors.Count > 0, + Errors = allErrors, }); } @@ -55,11 +77,12 @@ 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 46af0d1..0e8f39e 100644 --- a/src/SLNX-validator/ValidatorRunner.cs +++ b/src/SLNX-validator/ValidatorRunner.cs @@ -1,29 +1,38 @@ 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(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; } - var results = await collector.CollectAsync(files, cancellationToken); + // Resolve required file glob patterns to absolute disk paths (once for all .slnx files). + 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, 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/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 new file mode 100644 index 0000000..961eeb7 --- /dev/null +++ b/tests/SLNX-validator.Core.Tests/RequiredFilesCheckerTests.cs @@ -0,0 +1,172 @@ +using AwesomeAssertions; +using JulianVerdurmen.SlnxValidator.Core.Validation; +using JulianVerdurmen.SlnxValidator.Core.ValidationResults; + +namespace JulianVerdurmen.SlnxValidator.Core.Tests; + +public class RequiredFilesCheckerTests +{ + private static RequiredFilesChecker CreateChecker() => new(); + + 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 = """ + + + + + + """; + + // 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 = """ + + + + + + """; + + // Act + var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFile.Parse(slnxContent, SlnxDir)!); + + // Assert + errors.Should().HaveCount(1); + errors[0].Code.Should().Be(ValidationErrorCode.RequiredFileNotReferencedInSolution); + } + + [Test] + public void CheckInSlnx_ErrorMessageContainsFileElement() + { + // Arrange + var requiredPath = Path.GetFullPath(Path.Combine(SlnxDir, "doc", "readme.md")); + + // Act + var errors = CreateChecker().CheckInSlnx([requiredPath], SlnxFile.Parse("", SlnxDir)!); + + // Assert + errors.Should().HaveCount(1); + errors[0].Message.Should().Contain(" + + + + + """; + + // 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")); + + // 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)); + } + + [Test] + public void CheckInSlnx_EmptyRequiredPaths_ReturnsNoErrors() + { + // 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.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/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/ValidationCollectorTests.cs b/tests/SLNX-validator.Tests/ValidationCollectorTests.cs new file mode 100644 index 0000000..83199ff --- /dev/null +++ b/tests/SLNX-validator.Tests/ValidationCollectorTests.cs @@ -0,0 +1,106 @@ +using AwesomeAssertions; +using JulianVerdurmen.SlnxValidator.Core.Validation; +using JulianVerdurmen.SlnxValidator.Core.ValidationResults; +using NSubstitute; + +namespace JulianVerdurmen.SlnxValidator.Tests; + +public class ValidationCollectorTests +{ + private const string SlnxPath = "/fake/test.slnx"; + private const string SlnxContent = ""; + + private static (ValidationCollector collector, IRequiredFilesChecker checker) CreateCollector( + string? slnxContent = SlnxContent, + IRequiredFilesChecker? checker = null) + { + checker ??= Substitute.For(); + var validator = Substitute.For(); + validator.ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ValidationResult()); + var fileSystem = new MockFileSystem(new Dictionary + { + [SlnxPath] = slnxContent ?? "" + }); + 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); + } + + [Test] + public async Task CollectAsync_RequiredFileMissingFromSlnx_AddsRequiredFileNotReferencedInSolutionError() + { + // Arrange + 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"); + + // 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); + } + + [Test] + public async Task CollectAsync_RequiredFileMatchedAndReferenced_NoExtraErrors() + { + // Arrange + 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"); + + // Act + var results = await collector.CollectAsync([SlnxPath], options, CancellationToken.None); + + // Assert + results.Should().HaveCount(1); + results[0].HasErrors.Should().BeFalse(); + } + + [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()); + } + + #endregion +} + + diff --git a/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs b/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs index a9f5a97..7c5fa6b 100644 --- a/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs +++ b/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs @@ -2,55 +2,144 @@ 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; 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); } + 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()); - var exitCode = await runner.RunAsync("nonexistent.slnx", sonarqubeReportPath: null, continueOnError: false, CancellationToken.None); + // 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()); - var exitCode = await runner.RunAsync("nonexistent.slnx", sonarqubeReportPath: null, continueOnError: true, CancellationToken.None); + // 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()); - var exitCode = await runner.RunAsync("src/*.slnx", sonarqubeReportPath: null, continueOnError: false, CancellationToken.None); + // 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()); - var exitCode = await runner.RunAsync("src/*.slnx", sonarqubeReportPath: null, continueOnError: true, CancellationToken.None); + // Act + var exitCode = await runner.RunAsync(Options("src/*.slnx", continueOnError: true), CancellationToken.None); + + // Assert + exitCode.Should().Be(0); + } + + #endregion + + #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")); + + var checker = Substitute.For(); + checker.ResolveMatchedPaths(Arg.Any(), Arg.Any()) + .Returns([requiredFile]); + 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(); + checker.ResolveMatchedPaths(Arg.Any(), Arg.Any()) + .Returns([]); // nothing matched on disk + + var runner = CreateRunnerWithSlnx(slnxPath, "", checker); + + // Act + var exitCode = await runner.RunAsync( + Options(slnxPath, requiredFilesPattern: "nonexistent/**/*.md"), CancellationToken.None); + + // Assert + exitCode.Should().Be(1); + } + + #endregion } +