diff --git a/requirements.yaml b/requirements.yaml index 2536894..06e361a 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -244,6 +244,28 @@ sections: - Program_Run_WithElaborateFlag_UnknownId_ReportsError - ReviewMark_Elaborate + - id: ReviewMark-Cmd-Lint + title: The tool shall support --lint flag to validate the definition file and report issues. + justification: | + Users need a way to verify that the .reviewmark.yaml configuration file is valid + before running the main tool, providing clear error messages about the cause and + location of any issues. + tests: + - Context_Create_LintFlag_SetsLintTrue + - Context_Create_NoArguments_LintIsFalse + - Program_Run_WithHelpFlag_IncludesLintOption + - Program_Run_WithLintFlag_ValidConfig_ReportsSuccess + - Program_Run_WithLintFlag_MissingConfig_ReportsError + - Program_Run_WithLintFlag_DuplicateIds_ReportsError + - Program_Run_WithLintFlag_UnknownSourceType_ReportsError + - Program_Run_WithLintFlag_CorruptedYaml_ReportsError + - Program_Run_WithLintFlag_MissingEvidenceSource_ReportsError + - Program_Run_WithLintFlag_MultipleErrors_ReportsAll + - ReviewMarkConfiguration_Load_InvalidYaml_ErrorIncludesFilenameAndLine + - ReviewMarkConfiguration_Load_MissingEvidenceSource_ErrorIncludesFilename + - ReviewMarkConfiguration_Lint_MultipleErrors_ReturnsAll + - ReviewMark_Lint + - title: Configuration Reading requirements: - id: ReviewMark-Config-Reading @@ -321,6 +343,7 @@ sections: - "windows@ReviewMark_Enforce" - "windows@ReviewMark_WorkingDirectoryOverride" - "windows@ReviewMark_Elaborate" + - "windows@ReviewMark_Lint" - id: ReviewMark-Platform-Linux title: The tool shall build and run on Linux platforms. @@ -336,6 +359,7 @@ sections: - "ubuntu@ReviewMark_Enforce" - "ubuntu@ReviewMark_WorkingDirectoryOverride" - "ubuntu@ReviewMark_Elaborate" + - "ubuntu@ReviewMark_Lint" - id: ReviewMark-Platform-MacOS title: The tool shall build and run on macOS platforms. @@ -351,6 +375,7 @@ sections: - "macos@ReviewMark_Enforce" - "macos@ReviewMark_WorkingDirectoryOverride" - "macos@ReviewMark_Elaborate" + - "macos@ReviewMark_Lint" - id: ReviewMark-Platform-Net8 title: The tool shall support .NET 8 runtime. @@ -365,6 +390,7 @@ sections: - "dotnet8.x@ReviewMark_Enforce" - "dotnet8.x@ReviewMark_WorkingDirectoryOverride" - "dotnet8.x@ReviewMark_Elaborate" + - "dotnet8.x@ReviewMark_Lint" - id: ReviewMark-Platform-Net9 title: The tool shall support .NET 9 runtime. @@ -379,6 +405,7 @@ sections: - "dotnet9.x@ReviewMark_Enforce" - "dotnet9.x@ReviewMark_WorkingDirectoryOverride" - "dotnet9.x@ReviewMark_Elaborate" + - "dotnet9.x@ReviewMark_Lint" - id: ReviewMark-Platform-Net10 title: The tool shall support .NET 10 runtime. @@ -393,6 +420,7 @@ sections: - "dotnet10.x@ReviewMark_Enforce" - "dotnet10.x@ReviewMark_WorkingDirectoryOverride" - "dotnet10.x@ReviewMark_Elaborate" + - "dotnet10.x@ReviewMark_Lint" - title: OTS Software requirements: diff --git a/src/DemaConsulting.ReviewMark/Context.cs b/src/DemaConsulting.ReviewMark/Context.cs index bf7fa11..e2315e7 100644 --- a/src/DemaConsulting.ReviewMark/Context.cs +++ b/src/DemaConsulting.ReviewMark/Context.cs @@ -60,6 +60,11 @@ internal sealed class Context : IDisposable /// public bool Validate { get; private init; } + /// + /// Gets a value indicating whether the lint flag was specified. + /// + public bool Lint { get; private init; } + /// /// Gets the validation results file path. /// @@ -159,6 +164,7 @@ public static Context Create(string[] args) Help = parser.Help, Silent = parser.Silent, Validate = parser.Validate, + Lint = parser.Lint, ResultsFile = parser.ResultsFile, DefinitionFile = parser.DefinitionFile, PlanFile = parser.PlanFile, @@ -226,6 +232,11 @@ private sealed class ArgumentParser /// public bool Validate { get; private set; } + /// + /// Gets a value indicating whether the lint flag was specified. + /// + public bool Lint { get; private set; } + /// /// Gets the log file path. /// @@ -328,6 +339,10 @@ private int ParseArgument(string arg, string[] args, int index) Validate = true; return index; + case "--lint": + Lint = true; + return index; + case "--log": LogFile = GetRequiredStringArgument(arg, args, index, FilenameArgument); return index + 1; diff --git a/src/DemaConsulting.ReviewMark/Program.cs b/src/DemaConsulting.ReviewMark/Program.cs index a87a942..5276a11 100644 --- a/src/DemaConsulting.ReviewMark/Program.cs +++ b/src/DemaConsulting.ReviewMark/Program.cs @@ -112,7 +112,14 @@ public static void Run(Context context) return; } - // Priority 4: Main tool functionality + // Priority 4: Lint + if (context.Lint) + { + RunLintLogic(context); + return; + } + + // Priority 5: Main tool functionality RunToolLogic(context); } @@ -140,6 +147,7 @@ private static void PrintHelp(Context context) context.WriteLine(" -?, -h, --help Display this help message"); context.WriteLine(" --silent Suppress console output"); context.WriteLine(" --validate Run self-validation"); + context.WriteLine(" --lint Lint the definition file and report issues"); context.WriteLine(" --results Write validation results to file (.trx or .xml)"); context.WriteLine(" --log Write output to log file"); context.WriteLine(" --definition Specify the definition YAML file (default: .reviewmark.yaml)"); @@ -154,6 +162,32 @@ private static void PrintHelp(Context context) context.WriteLine(" --elaborate Print a Markdown elaboration of the specified review set"); } + /// + /// Runs the lint logic to validate the definition file. + /// + /// The context containing command line arguments and program state. + private static void RunLintLogic(Context context) + { + // Determine the definition file path (explicit or default) + var directory = context.WorkingDirectory ?? Directory.GetCurrentDirectory(); + var definitionFile = context.DefinitionFile ?? PathHelpers.SafePathCombine(directory, ".reviewmark.yaml"); + + context.WriteLine($"Linting '{definitionFile}'..."); + + // Lint the file, collecting all detectable errors in one pass. + var errors = ReviewMarkConfiguration.Lint(definitionFile); + foreach (var error in errors) + { + context.WriteError($"Error: {error}"); + } + + // Report overall result + if (errors.Count == 0) + { + context.WriteLine($"'{definitionFile}' is valid."); + } + } + /// /// Runs the main tool logic. /// diff --git a/src/DemaConsulting.ReviewMark/ReviewMarkConfiguration.cs b/src/DemaConsulting.ReviewMark/ReviewMarkConfiguration.cs index 6800b5a..b47e2ec 100644 --- a/src/DemaConsulting.ReviewMark/ReviewMarkConfiguration.cs +++ b/src/DemaConsulting.ReviewMark/ReviewMarkConfiguration.cs @@ -123,6 +123,136 @@ file sealed class ReviewSetYaml public List? Paths { get; set; } } +// --------------------------------------------------------------------------- +// File-local helpers — use file-local YAML types +// --------------------------------------------------------------------------- + +/// +/// File-local static helper that encapsulates YAML deserialization and model validation +/// on behalf of . Because both this class and +/// are file-local, C# allows them to appear in the +/// method signatures here. +/// +file static class ReviewMarkConfigurationHelpers +{ + /// + /// Deserializes a YAML string into the raw model. + /// + /// YAML content to parse. + /// + /// Optional file path used to produce actionable error messages. When null, + /// YAML errors are thrown as (preserving the + /// contract). When non-null, + /// they are thrown as and include the + /// file name, line, and column. + /// + /// The deserialized . + /// + /// Thrown when is null and the YAML is invalid. + /// + /// + /// Thrown when is set and the YAML is invalid. + /// + public static ReviewMarkYaml DeserializeRaw(string yaml, string? filePath) + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(NullNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + try + { + if (filePath != null) + { + return deserializer.Deserialize(yaml) + ?? throw new InvalidOperationException( + $"Configuration file '{filePath}' is empty or null."); + } + + return deserializer.Deserialize(yaml) + ?? throw new ArgumentException("YAML content is empty or invalid.", nameof(yaml)); + } + catch (YamlException ex) + { + if (filePath != null) + { + throw new InvalidOperationException( + $"Failed to parse '{filePath}' at line {ex.Start.Line}, column {ex.Start.Column}: {ex.Message}", + ex); + } + + throw new ArgumentException($"Invalid YAML content: {ex.Message}", nameof(yaml), ex); + } + } + + /// + /// Validates a raw model and builds a + /// from it. + /// + /// The deserialized raw model to validate. + /// A validated . + /// + /// Thrown when required fields are absent or malformed. + /// + public static ReviewMarkConfiguration BuildConfiguration(ReviewMarkYaml raw) + { + // Map needs-review patterns (default to empty list if absent) + var needsReviewPatterns = (IReadOnlyList)(raw.NeedsReview ?? []); + + // Map evidence-source (required: evidence-source block, type, and location) + if (raw.EvidenceSource is not { } es) + { + throw new ArgumentException("Configuration is missing required 'evidence-source' block."); + } + + if (string.IsNullOrWhiteSpace(es.Type)) + { + throw new ArgumentException("Configuration 'evidence-source' is missing a required 'type' field."); + } + + if (string.IsNullOrWhiteSpace(es.Location)) + { + throw new ArgumentException("Configuration 'evidence-source' is missing a required 'location' field."); + } + + var evidenceSource = new EvidenceSource( + Type: es.Type, + Location: es.Location, + UsernameEnv: es.Credentials?.UsernameEnv, + PasswordEnv: es.Credentials?.PasswordEnv); + + // Map review sets, requiring id, title, and paths for each entry + var reviews = (raw.Reviews ?? []) + .Select((r, i) => + { + // Each review set must have an id + if (string.IsNullOrWhiteSpace(r.Id)) + { + throw new ArgumentException($"Review set at index {i} is missing a required 'id' field."); + } + + // Each review set must have a title + if (string.IsNullOrWhiteSpace(r.Title)) + { + throw new ArgumentException($"Review set '{r.Id}' is missing a required 'title' field."); + } + + // Each review set must have at least one non-empty path pattern + var paths = r.Paths; + if (paths is null || !paths.Any(p => !string.IsNullOrWhiteSpace(p))) + { + throw new ArgumentException( + $"Review set '{r.Id}' at index {i} is missing required 'paths' entries."); + } + + return new ReviewSet(r.Id, r.Title, paths); + }) + .ToList(); + + return new ReviewMarkConfiguration(needsReviewPatterns, evidenceSource, reviews); + } +} + // --------------------------------------------------------------------------- // Public API — internal to the assembly // --------------------------------------------------------------------------- @@ -281,7 +411,7 @@ internal sealed class ReviewMarkConfiguration /// Glob patterns for files requiring review. /// Evidence-source configuration. /// Review set definitions. - private ReviewMarkConfiguration( + internal ReviewMarkConfiguration( IReadOnlyList needsReviewPatterns, EvidenceSource evidenceSource, IReadOnlyList reviews) @@ -297,7 +427,11 @@ private ReviewMarkConfiguration( /// Absolute or relative path to the configuration file. /// A populated instance. /// Thrown when is null or empty. - /// Thrown when the file cannot be read. + /// + /// Thrown when the file cannot be read, the YAML is invalid, or required configuration fields are + /// missing. The exception message always identifies the problematic file and, for YAML syntax + /// errors, the line and column number. + /// internal static ReviewMarkConfiguration Load(string filePath) { // Validate the file path argument @@ -321,8 +455,23 @@ internal static ReviewMarkConfiguration Load(string filePath) throw new InvalidOperationException($"Failed to read configuration file '{filePath}': {ex.Message}", ex); } - // Delegate to Parse for deserialization and apply path resolution - var config = Parse(yaml); + // Deserialize the raw YAML model, embedding the file path and line number in any parse error. + var raw = ReviewMarkConfigurationHelpers.DeserializeRaw(yaml, filePath); + + // Determine the base directory for resolving relative fileshare locations. + var baseDirectory = Path.GetDirectoryName(Path.GetFullPath(filePath)) + ?? throw new InvalidOperationException($"Cannot determine base directory for configuration file '{filePath}'."); + + // Validate the raw model, embedding the file path in any semantic error. + ReviewMarkConfiguration config; + try + { + config = ReviewMarkConfigurationHelpers.BuildConfiguration(raw); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException($"Invalid configuration in '{filePath}': {ex.Message}", ex); + } // Resolve relative fileshare locations against the config file's directory so that // a relative location (e.g., "index.json") works correctly regardless of the process @@ -330,8 +479,6 @@ internal static ReviewMarkConfiguration Load(string filePath) if (string.Equals(config.EvidenceSource.Type, "fileshare", StringComparison.OrdinalIgnoreCase) && !Path.IsPathRooted(config.EvidenceSource.Location)) { - var baseDirectory = Path.GetDirectoryName(Path.GetFullPath(filePath)) - ?? throw new InvalidOperationException($"Cannot determine base directory for configuration file '{filePath}'."); var absoluteLocation = Path.GetFullPath(config.EvidenceSource.Location, baseDirectory); return new ReviewMarkConfiguration( config.NeedsReviewPatterns, @@ -343,88 +490,137 @@ internal static ReviewMarkConfiguration Load(string filePath) } /// - /// Parses a YAML string into a . + /// Lints a .reviewmark.yaml file and returns all detected issues. + /// Unlike , this method does not stop at the first error; + /// it accumulates every detectable problem and returns them all so the caller + /// can report a complete list in a single pass. /// - /// The YAML content to parse. - /// A populated instance. - /// Thrown when is null. - /// Thrown when the YAML is invalid or missing required fields. - internal static ReviewMarkConfiguration Parse(string yaml) + /// Absolute or relative path to the configuration file. + /// + /// A read-only list of error messages. The list is empty when the file is + /// structurally and semantically valid. + /// + /// Thrown when is null or empty. + internal static IReadOnlyList Lint(string filePath) { - // Validate the yaml input - ArgumentNullException.ThrowIfNull(yaml); + // Validate the file path argument + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("File path must not be null or empty.", nameof(filePath)); + } - // Build a YamlDotNet deserializer that ignores unmatched fields - var deserializer = new DeserializerBuilder() - .WithNamingConvention(NullNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); + var errors = new List(); - // Deserialize the raw YAML into the internal model - ReviewMarkYaml raw; + // Try to read the file; if this fails we cannot continue. + string yaml; try { - raw = deserializer.Deserialize(yaml) - ?? throw new ArgumentException("YAML content is empty or invalid.", nameof(yaml)); + yaml = File.ReadAllText(filePath); } - catch (YamlException ex) + catch (Exception ex) when (ex is not InvalidOperationException) { - throw new ArgumentException($"Invalid YAML content: {ex.Message}", nameof(yaml), ex); + errors.Add($"Failed to read configuration file '{filePath}': {ex.Message}"); + return errors; } - // Map needs-review patterns (default to empty list if absent) - var needsReviewPatterns = (IReadOnlyList)(raw.NeedsReview ?? []); - - // Map evidence-source (required: evidence-source block, type, and location) - if (raw.EvidenceSource is not { } es) + // Try to parse the raw YAML model; if this fails we cannot do semantic checks. + ReviewMarkYaml raw; + try { - throw new ArgumentException("Configuration is missing required 'evidence-source' block.", nameof(yaml)); + raw = ReviewMarkConfigurationHelpers.DeserializeRaw(yaml, filePath); } - - if (string.IsNullOrWhiteSpace(es.Type)) + catch (InvalidOperationException ex) { - throw new ArgumentException("Configuration 'evidence-source' is missing a required 'type' field.", nameof(yaml)); + errors.Add(ex.Message); + return errors; } - if (string.IsNullOrWhiteSpace(es.Location)) + // Validate the evidence-source block, collecting all field-level errors. + var es = raw.EvidenceSource; + if (es == null) { - throw new ArgumentException("Configuration 'evidence-source' is missing a required 'location' field.", nameof(yaml)); + errors.Add( + $"Invalid configuration in '{filePath}': Configuration is missing required 'evidence-source' block."); } + else + { + if (string.IsNullOrWhiteSpace(es.Type)) + { + errors.Add( + $"Invalid configuration in '{filePath}': 'evidence-source' is missing a required 'type' field."); + } + else if (!string.Equals(es.Type, "url", StringComparison.OrdinalIgnoreCase) && + !string.Equals(es.Type, "fileshare", StringComparison.OrdinalIgnoreCase)) + { + errors.Add( + $"evidence-source type '{es.Type}' is not supported (must be 'url' or 'fileshare')."); + } - var evidenceSource = new EvidenceSource( - Type: es.Type, - Location: es.Location, - UsernameEnv: es.Credentials?.UsernameEnv, - PasswordEnv: es.Credentials?.PasswordEnv); - // Map review sets, requiring id, title, and paths for each entry - var reviews = (raw.Reviews ?? []) - .Select((r, i) => + if (string.IsNullOrWhiteSpace(es.Location)) { - // Each review set must have an id - if (string.IsNullOrWhiteSpace(r.Id)) - { - throw new ArgumentException($"Review set at index {i} is missing a required 'id' field."); - } + errors.Add( + $"Invalid configuration in '{filePath}': 'evidence-source' is missing a required 'location' field."); + } + } - // Each review set must have a title - if (string.IsNullOrWhiteSpace(r.Title)) - { - throw new ArgumentException($"Review set '{r.Id}' is missing a required 'title' field."); - } + // Validate each review set, accumulating all structural and uniqueness errors. + // Review IDs are treated as case-sensitive identifiers (Ordinal), which is intentional: + // "Core-Logic" and "core-logic" are distinct IDs. Evidence-source type uses OrdinalIgnoreCase + // because YAML convention allows any casing for keyword values like "url" or "fileshare". + var seenIds = new Dictionary(StringComparer.Ordinal); + var reviews = raw.Reviews ?? []; + for (var i = 0; i < reviews.Count; i++) + { + var r = reviews[i]; - // Each review set must have at least one non-empty path pattern - var paths = r.Paths; - if (paths is null || !paths.Any(p => !string.IsNullOrWhiteSpace(p))) - { - throw new ArgumentException( - $"Review set '{r.Id}' at index {i} is missing required 'paths' entries."); - } + if (string.IsNullOrWhiteSpace(r.Id)) + { + errors.Add( + $"Invalid configuration in '{filePath}': Review set at index {i} is missing a required 'id' field."); + } + else if (seenIds.TryGetValue(r.Id, out var firstIndex)) + { + errors.Add( + $"reviews[{i}] has duplicate ID '{r.Id}' (first defined at reviews[{firstIndex}])."); + } + else + { + seenIds[r.Id] = i; + } - return new ReviewSet(r.Id, r.Title, paths); - }) - .ToList(); + if (string.IsNullOrWhiteSpace(r.Title)) + { + errors.Add( + $"Invalid configuration in '{filePath}': Review set at index {i} is missing a required 'title' field."); + } - return new ReviewMarkConfiguration(needsReviewPatterns, evidenceSource, reviews); + if (r.Paths == null || !r.Paths.Any(p => !string.IsNullOrWhiteSpace(p))) + { + errors.Add( + $"Invalid configuration in '{filePath}': Review set at index {i} is missing required 'paths' entries."); + } + } + + return errors; + } + + /// + /// Parses a YAML string into a . + /// + /// The YAML content to parse. + /// A populated instance. + /// Thrown when is null. + /// Thrown when the YAML is invalid or missing required fields. + internal static ReviewMarkConfiguration Parse(string yaml) + { + // Validate the yaml input + ArgumentNullException.ThrowIfNull(yaml); + + // Deserialize without a file path so YAML errors are wrapped as ArgumentException (not + // InvalidOperationException) which is what callers of Parse (unit tests) expect. + var raw = ReviewMarkConfigurationHelpers.DeserializeRaw(yaml, filePath: null); + + return ReviewMarkConfigurationHelpers.BuildConfiguration(raw); } /// diff --git a/src/DemaConsulting.ReviewMark/Validation.cs b/src/DemaConsulting.ReviewMark/Validation.cs index ce93b16..41a2950 100644 --- a/src/DemaConsulting.ReviewMark/Validation.cs +++ b/src/DemaConsulting.ReviewMark/Validation.cs @@ -56,6 +56,7 @@ public static void Run(Context context) RunDirTest(context, testResults); RunEnforceTest(context, testResults); RunElaborateTest(context, testResults); + RunLintTest(context, testResults); // Calculate totals var totalTests = testResults.Results.Count; @@ -378,6 +379,38 @@ private static void RunElaborateTest(Context context, DemaConsulting.TestResults }); } + /// + /// Runs a test for lint functionality. + /// + /// The context for output. + /// The test results collection. + private static void RunLintTest(Context context, DemaConsulting.TestResults.TestResults testResults) + { + RunValidationTest(context, testResults, "ReviewMark_Lint", () => + { + using var tempDir = new TemporaryDirectory(); + var (definitionFile, _) = CreateTestDefinitionFixtures(tempDir.DirectoryPath); + var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "lint-test.log"); + + // Run the program to lint the definition file + int exitCode; + using (var testContext = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile])) + { + Program.Run(testContext); + exitCode = testContext.ExitCode; + } + + if (exitCode != 0) + { + return $"Program exited with code {exitCode}"; + } + + // Verify the log contains a success message + var logContent = File.ReadAllText(logFile); + return logContent.Contains("is valid") ? null : "Lint output does not contain 'is valid'"; + }); + } + /// /// Runs a single validation test, recording the outcome in the test results collection. /// diff --git a/test/DemaConsulting.ReviewMark.Tests/ContextTests.cs b/test/DemaConsulting.ReviewMark.Tests/ContextTests.cs index 9df417d..e360756 100644 --- a/test/DemaConsulting.ReviewMark.Tests/ContextTests.cs +++ b/test/DemaConsulting.ReviewMark.Tests/ContextTests.cs @@ -712,5 +712,34 @@ public void Context_Create_ElaborateFlag_WithoutValue_ThrowsArgumentException() // Act & Assert - --elaborate without an ID argument should throw Assert.Throws(() => Context.Create(["--elaborate"])); } + + /// + /// Test that --lint flag sets Lint to true. + /// + [TestMethod] + public void Context_Create_LintFlag_SetsLintTrue() + { + // Act + using var context = Context.Create(["--lint"]); + + // Assert — Lint is true, other flags remain false, and exit code is zero + Assert.IsTrue(context.Lint); + Assert.IsFalse(context.Version); + Assert.IsFalse(context.Help); + Assert.AreEqual(0, context.ExitCode); + } + + /// + /// Test that Lint is false when --lint is not specified. + /// + [TestMethod] + public void Context_Create_NoArguments_LintIsFalse() + { + // Act + using var context = Context.Create([]); + + // Assert — Lint is false when --lint is not specified + Assert.IsFalse(context.Lint); + } } diff --git a/test/DemaConsulting.ReviewMark.Tests/ProgramTests.cs b/test/DemaConsulting.ReviewMark.Tests/ProgramTests.cs index 18ab989..692e80d 100644 --- a/test/DemaConsulting.ReviewMark.Tests/ProgramTests.cs +++ b/test/DemaConsulting.ReviewMark.Tests/ProgramTests.cs @@ -26,6 +26,10 @@ namespace DemaConsulting.ReviewMark.Tests; [TestClass] public class ProgramTests { + /// + /// Log file name used across lint tests. + /// + private const string LintLogFile = "lint.log"; /// /// Test that Run with version flag displays version only. /// @@ -187,63 +191,51 @@ public void Program_Run_WithHelpFlag_IncludesElaborateOption() public void Program_Run_WithElaborateFlag_OutputsElaboration() { // Arrange — create temp directory with a definition file and source file - var testDirectory = PathHelpers.SafePathCombine( - Path.GetTempPath(), $"ProgramTests_Elaborate_{Guid.NewGuid()}"); + using var tempDir = new TestDirectory(); + var srcDir = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "src"); + Directory.CreateDirectory(srcDir); + File.WriteAllText(PathHelpers.SafePathCombine(srcDir, "A.cs"), "class A {}"); + + var indexFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "index.json"); + File.WriteAllText(indexFile, """{"reviews":[]}"""); + + var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml"); + File.WriteAllText(definitionFile, $""" + needs-review: + - "src/**/*.cs" + evidence-source: + type: fileshare + location: {indexFile} + reviews: + - id: Core-Logic + title: Review of core business logic + paths: + - "src/**/*.cs" + """); + + var originalOut = Console.Out; try { - Directory.CreateDirectory(testDirectory); - var srcDir = PathHelpers.SafePathCombine(testDirectory, "src"); - Directory.CreateDirectory(srcDir); - File.WriteAllText(PathHelpers.SafePathCombine(srcDir, "A.cs"), "class A {}"); + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + using var context = Context.Create([ + "--definition", definitionFile, + "--dir", tempDir.DirectoryPath, + "--elaborate", "Core-Logic"]); - var indexFile = PathHelpers.SafePathCombine(testDirectory, "index.json"); - File.WriteAllText(indexFile, """{"reviews":[]}"""); + // Act + Program.Run(context); - var definitionFile = PathHelpers.SafePathCombine(testDirectory, "definition.yaml"); - File.WriteAllText(definitionFile, $""" - needs-review: - - "src/**/*.cs" - evidence-source: - type: fileshare - location: {indexFile} - reviews: - - id: Core-Logic - title: Review of core business logic - paths: - - "src/**/*.cs" - """); - - var originalOut = Console.Out; - try - { - using var outWriter = new StringWriter(); - Console.SetOut(outWriter); - using var context = Context.Create([ - "--definition", definitionFile, - "--dir", testDirectory, - "--elaborate", "Core-Logic"]); - - // Act - Program.Run(context); - - // Assert — output contains the review set ID and fingerprint heading - var output = outWriter.ToString(); - Assert.Contains("Core-Logic", output); - Assert.Contains("Fingerprint", output); - Assert.Contains("Files", output); - Assert.AreEqual(0, context.ExitCode); - } - finally - { - Console.SetOut(originalOut); - } + // Assert — output contains the review set ID and fingerprint heading + var output = outWriter.ToString(); + Assert.Contains("Core-Logic", output); + Assert.Contains("Fingerprint", output); + Assert.Contains("Files", output); + Assert.AreEqual(0, context.ExitCode); } finally { - if (Directory.Exists(testDirectory)) - { - Directory.Delete(testDirectory, recursive: true); - } + Console.SetOut(originalOut); } } @@ -254,56 +246,308 @@ public void Program_Run_WithElaborateFlag_OutputsElaboration() public void Program_Run_WithElaborateFlag_UnknownId_ReportsError() { // Arrange — create temp directory with a definition file - var testDirectory = PathHelpers.SafePathCombine( - Path.GetTempPath(), $"ProgramTests_ElaborateUnknown_{Guid.NewGuid()}"); + using var tempDir = new TestDirectory(); + + var indexFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "index.json"); + File.WriteAllText(indexFile, """{"reviews":[]}"""); + + var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml"); + File.WriteAllText(definitionFile, $""" + needs-review: + - "src/**/*.cs" + evidence-source: + type: fileshare + location: {indexFile} + reviews: + - id: Core-Logic + title: Review of core business logic + paths: + - "src/**/*.cs" + """); + + var originalError = Console.Error; try { - Directory.CreateDirectory(testDirectory); + using var errWriter = new StringWriter(); + Console.SetError(errWriter); + using var context = Context.Create([ + "--silent", + "--definition", definitionFile, + "--elaborate", "Unknown-Id"]); - var indexFile = PathHelpers.SafePathCombine(testDirectory, "index.json"); - File.WriteAllText(indexFile, """{"reviews":[]}"""); + // Act + Program.Run(context); - var definitionFile = PathHelpers.SafePathCombine(testDirectory, "definition.yaml"); - File.WriteAllText(definitionFile, $""" - needs-review: - - "src/**/*.cs" - evidence-source: - type: fileshare - location: {indexFile} - reviews: - - id: Core-Logic - title: Review of core business logic - paths: - - "src/**/*.cs" - """); - - var originalError = Console.Error; - try - { - using var errWriter = new StringWriter(); - Console.SetError(errWriter); - using var context = Context.Create([ - "--silent", - "--definition", definitionFile, - "--elaborate", "Unknown-Id"]); - - // Act - Program.Run(context); - - // Assert — non-zero exit code when the review-set ID is not found - Assert.AreEqual(1, context.ExitCode); - } - finally - { - Console.SetError(originalError); - } + // Assert — non-zero exit code when the review-set ID is not found + Assert.AreEqual(1, context.ExitCode); } finally { - if (Directory.Exists(testDirectory)) - { - Directory.Delete(testDirectory, recursive: true); - } + Console.SetError(originalError); } } + + /// + /// Test that Run with --help flag includes --lint in the usage information. + /// + [TestMethod] + public void Program_Run_WithHelpFlag_IncludesLintOption() + { + // Arrange + var originalOut = Console.Out; + try + { + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + using var context = Context.Create(["--help"]); + + // Act + Program.Run(context); + + // Assert — help text includes the --lint option + var output = outWriter.ToString(); + Assert.Contains("--lint", output); + } + finally + { + Console.SetOut(originalOut); + } + } + + /// + /// Test that Run with --lint flag on a valid definition file reports success. + /// + [TestMethod] + public void Program_Run_WithLintFlag_ValidConfig_ReportsSuccess() + { + // Arrange — create temp directory with a valid definition file + using var tempDir = new TestDirectory(); + var indexFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "index.json"); + File.WriteAllText(indexFile, """{"reviews":[]}"""); + + var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml"); + File.WriteAllText(definitionFile, $""" + needs-review: + - "src/**/*.cs" + evidence-source: + type: fileshare + location: {indexFile} + reviews: + - id: Core-Logic + title: Review of core business logic + paths: + - "src/**/*.cs" + """); + + var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile); + using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]); + + // Act + Program.Run(context); + + // Assert — exit code is zero and log contains success message + var logContent = File.ReadAllText(logFile); + Assert.AreEqual(0, context.ExitCode); + Assert.Contains("is valid", logContent); + } + + /// + /// Test that Run with --lint flag on a missing definition file reports an error. + /// + [TestMethod] + public void Program_Run_WithLintFlag_MissingConfig_ReportsError() + { + // Arrange — use a non-existent definition file + using var tempDir = new TestDirectory(); + var nonExistentFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "nonexistent.yaml"); + var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile); + using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", nonExistentFile]); + + // Act + Program.Run(context); + + // Assert — non-zero exit code and log contains an error mentioning the missing file + var logContent = File.ReadAllText(logFile); + Assert.AreEqual(1, context.ExitCode); + Assert.Contains("Error:", logContent); + Assert.Contains("nonexistent.yaml", logContent); + } + + /// + /// Test that Run with --lint flag detects duplicate review set IDs and reports an error. + /// + [TestMethod] + public void Program_Run_WithLintFlag_DuplicateIds_ReportsError() + { + // Arrange — create temp directory with a definition file containing duplicate IDs + using var tempDir = new TestDirectory(); + var indexFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "index.json"); + File.WriteAllText(indexFile, """{"reviews":[]}"""); + + var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml"); + File.WriteAllText(definitionFile, $""" + needs-review: + - "src/**/*.cs" + evidence-source: + type: fileshare + location: {indexFile} + reviews: + - id: Core-Logic + title: Review of core business logic + paths: + - "src/**/*.cs" + - id: Core-Logic + title: Duplicate review set + paths: + - "src/**/*.cs" + """); + + var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile); + using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]); + + // Act + Program.Run(context); + + // Assert — non-zero exit code and log contains a clear duplicate-ID error message + var logContent = File.ReadAllText(logFile); + Assert.AreEqual(1, context.ExitCode); + Assert.Contains("Error:", logContent); + Assert.Contains("duplicate ID", logContent); + Assert.Contains("Core-Logic", logContent); + } + + /// + /// Test that Run with --lint flag detects unknown evidence-source type and reports an error. + /// + [TestMethod] + public void Program_Run_WithLintFlag_UnknownSourceType_ReportsError() + { + // Arrange — create temp directory with a definition file having an unknown source type + using var tempDir = new TestDirectory(); + var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml"); + File.WriteAllText(definitionFile, """ + needs-review: + - "src/**/*.cs" + evidence-source: + type: ftp + location: ftp://example.com/index.json + reviews: + - id: Core-Logic + title: Review of core business logic + paths: + - "src/**/*.cs" + """); + + var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile); + using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]); + + // Act + Program.Run(context); + + // Assert — non-zero exit code and log contains a clear unsupported-type error message + var logContent = File.ReadAllText(logFile); + Assert.AreEqual(1, context.ExitCode); + Assert.Contains("Error:", logContent); + Assert.Contains("ftp", logContent); + Assert.Contains("not supported", logContent); + } + + /// + /// Test that Run with --lint flag reports a clear error for corrupted (invalid) YAML. + /// + [TestMethod] + public void Program_Run_WithLintFlag_CorruptedYaml_ReportsError() + { + // Arrange — create a definition file with invalid YAML syntax + using var tempDir = new TestDirectory(); + var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml"); + File.WriteAllText(definitionFile, """ + {{{this is not valid yaml + """); + + var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile); + using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]); + + // Act + Program.Run(context); + + // Assert — non-zero exit code and log contains an error naming the definition file and a line number + var logContent = File.ReadAllText(logFile); + Assert.AreEqual(1, context.ExitCode); + Assert.Contains("Error:", logContent); + Assert.Contains("definition.yaml", logContent); + Assert.Contains("at line", logContent); + } + + /// + /// Test that Run with --lint flag reports a clear error when required fields are missing. + /// + [TestMethod] + public void Program_Run_WithLintFlag_MissingEvidenceSource_ReportsError() + { + // Arrange — create a definition file with no evidence-source block + using var tempDir = new TestDirectory(); + var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml"); + File.WriteAllText(definitionFile, """ + needs-review: + - "src/**/*.cs" + reviews: + - id: Core-Logic + title: Review of core business logic + paths: + - "src/**/*.cs" + """); + + var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile); + using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]); + + // Act + Program.Run(context); + + // Assert — non-zero exit code and log names the file and the missing field + var logContent = File.ReadAllText(logFile); + Assert.AreEqual(1, context.ExitCode); + Assert.Contains("Error:", logContent); + Assert.Contains("definition.yaml", logContent); + Assert.Contains("evidence-source", logContent); + } + + /// + /// Test that Run with --lint flag reports ALL errors in one pass when the file has + /// multiple detectable issues (missing evidence-source AND duplicate review IDs). + /// + [TestMethod] + public void Program_Run_WithLintFlag_MultipleErrors_ReportsAll() + { + // Arrange — create a definition file that is missing evidence-source AND has duplicate IDs + using var tempDir = new TestDirectory(); + var definitionFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "definition.yaml"); + File.WriteAllText(definitionFile, """ + needs-review: + - "src/**/*.cs" + reviews: + - id: Core-Logic + title: Review of core business logic + paths: + - "src/**/*.cs" + - id: Core-Logic + title: Duplicate review set + paths: + - "src/**/*.cs" + """); + + var logFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, LintLogFile); + using var context = Context.Create(["--silent", "--log", logFile, "--lint", "--definition", definitionFile]); + + // Act + Program.Run(context); + + // Assert — non-zero exit code and log contains BOTH the missing evidence-source error + // AND the duplicate ID error, proving all errors are accumulated in one pass. + var logContent = File.ReadAllText(logFile); + Assert.AreEqual(1, context.ExitCode); + Assert.Contains("evidence-source", logContent); + Assert.Contains("duplicate ID", logContent); + Assert.Contains("Core-Logic", logContent); + } } diff --git a/test/DemaConsulting.ReviewMark.Tests/ReviewMarkConfigurationTests.cs b/test/DemaConsulting.ReviewMark.Tests/ReviewMarkConfigurationTests.cs index 1836039..26ab57a 100644 --- a/test/DemaConsulting.ReviewMark.Tests/ReviewMarkConfigurationTests.cs +++ b/test/DemaConsulting.ReviewMark.Tests/ReviewMarkConfigurationTests.cs @@ -301,6 +301,82 @@ public void ReviewMarkConfiguration_Load_NonExistentFile_ThrowsException() ReviewMarkConfiguration.Load(nonExistentPath)); } + /// + /// Test that Load includes the file name in the error message when YAML is invalid. + /// + [TestMethod] + public void ReviewMarkConfiguration_Load_InvalidYaml_ErrorIncludesFilenameAndLine() + { + // Arrange — write a configuration file with invalid YAML syntax + var configPath = PathHelpers.SafePathCombine(_testDirectory, ".reviewmark.yaml"); + File.WriteAllText(configPath, "{{{invalid yaml"); + + // Act & Assert + var ex = Assert.Throws(() => + ReviewMarkConfiguration.Load(configPath)); + Assert.Contains(".reviewmark.yaml", ex.Message); + Assert.Contains("at line", ex.Message); + } + + /// + /// Test that Load includes the file name in the error message when required fields are missing. + /// + [TestMethod] + public void ReviewMarkConfiguration_Load_MissingEvidenceSource_ErrorIncludesFilename() + { + // Arrange — write a valid YAML file that is missing the required evidence-source block + var configPath = PathHelpers.SafePathCombine(_testDirectory, ".reviewmark.yaml"); + File.WriteAllText(configPath, """ + needs-review: + - "src/**/*.cs" + reviews: + - id: Core-Logic + title: Review of core business logic + paths: + - "src/**/*.cs" + """); + + // Act & Assert + var ex = Assert.Throws(() => + ReviewMarkConfiguration.Load(configPath)); + Assert.Contains(".reviewmark.yaml", ex.Message); + Assert.Contains("evidence-source", ex.Message); + } + + /// + /// Test that Lint returns all errors from a file with multiple detectable issues + /// (missing evidence-source AND duplicate review IDs) without stopping at the first. + /// + [TestMethod] + public void ReviewMarkConfiguration_Lint_MultipleErrors_ReturnsAll() + { + // Arrange — write a YAML file missing evidence-source and containing duplicate IDs + var configPath = PathHelpers.SafePathCombine(_testDirectory, ".reviewmark.yaml"); + File.WriteAllText(configPath, """ + needs-review: + - "src/**/*.cs" + reviews: + - id: Core-Logic + title: Review of core business logic + paths: + - "src/**/*.cs" + - id: Core-Logic + title: Duplicate review set + paths: + - "src/**/*.cs" + """); + + // Act + var errors = ReviewMarkConfiguration.Lint(configPath); + + // Assert — both the missing evidence-source error and the duplicate ID error are returned + Assert.AreEqual(2, errors.Count); + Assert.IsTrue(errors.Any(e => e.Contains("evidence-source")), + "Expected an error about missing evidence-source."); + Assert.IsTrue(errors.Any(e => e.Contains("duplicate ID") && e.Contains("Core-Logic")), + "Expected an error about duplicate ID 'Core-Logic'."); + } + /// /// Test that Load resolves a relative fileshare location against the config file's directory. /// diff --git a/test/DemaConsulting.ReviewMark.Tests/TestDirectory.cs b/test/DemaConsulting.ReviewMark.Tests/TestDirectory.cs new file mode 100644 index 0000000..926a38d --- /dev/null +++ b/test/DemaConsulting.ReviewMark.Tests/TestDirectory.cs @@ -0,0 +1,52 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +namespace DemaConsulting.ReviewMark.Tests; + +/// +/// Represents a temporary directory that is automatically deleted when disposed. +/// +internal sealed class TestDirectory : IDisposable +{ + /// + /// Gets the path to the temporary directory. + /// + public string DirectoryPath { get; } + + /// + /// Initializes a new instance of the class. + /// + public TestDirectory() + { + DirectoryPath = PathHelpers.SafePathCombine(Path.GetTempPath(), $"reviewmark_test_{Guid.NewGuid()}"); + Directory.CreateDirectory(DirectoryPath); + } + + /// + /// Deletes the temporary directory and all its contents. + /// + public void Dispose() + { + if (Directory.Exists(DirectoryPath)) + { + Directory.Delete(DirectoryPath, recursive: true); + } + } +}