diff --git a/README.md b/README.md index 8871431..afd598d 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ dotnet run -- --help - ✅ Bill of Materials (BOM) generation - ✅ NuGet package vulnerability scanning - ✅ Multiple output formats (console, markdown) +- ✅ Path argument support (`-p` / `--path`) for all analysis commands +- ✅ Command-specific help with argument documentation ## 🎯 Current Commands @@ -61,13 +63,13 @@ codemedic --version # Show version # Analysis commands codemedic health # Repository health dashboard -codemedic health --format markdown +codemedic health -p /path/to/repo --format markdown codemedic bom # Bill of Materials -codemedic bom --format md > bom.md +codemedic bom --path /path/to/repo --format md > bom.md codemedic vulnerabilities # Scan for NuGet vulnerabilities -codemedic vulnerabilities --format markdown > vulns.md +codemedic vulnerabilities -p /path/to/repo --format markdown > vulns.md ``` ## 🔧 Technology Stack diff --git a/doc/plugin_architecture.md b/doc/plugin_architecture.md index 0e21c66..bc2a69d 100644 --- a/doc/plugin_architecture.md +++ b/doc/plugin_architecture.md @@ -231,6 +231,123 @@ public class PluginMetadata --- +## Command Registration System + +Analysis engine plugins can register CLI commands with arguments using the `RegisterCommands()` method. This enables plugins to expose their functionality through dedicated CLI commands with proper argument parsing and help integration. + +### CommandRegistration & CommandArgument + +```csharp +namespace CodeMedic.Abstractions.Plugins; + +/// +/// Represents a command that can be registered with the CLI. +/// +public class CommandRegistration +{ + public required string Name { get; init; } // Command name (e.g., "health") + public required string Description { get; init; } // Command description for help + public required Func> Handler { get; init; } // Command handler + public string[]? Examples { get; init; } // Usage examples + public CommandArgument[]? Arguments { get; init; } // Command arguments +} + +/// +/// Represents a command-line argument specification. +/// +public record CommandArgument( + string Description, // Required: what this argument does + string? ShortName = null, // Short form: "p" for "-p" + string? LongName = null, // Long form: "path" for "--path" + bool IsRequired = false, // Whether argument is mandatory + bool HasValue = true, // Whether argument takes a value + string? DefaultValue = null, // Default value description + string? ValueName = null); // Value type for help display +``` + +### Command Registration Features + +- **Automatic Help Integration**: Commands with arguments automatically appear in `--help` output +- **Command-Specific Help**: `codemedic mycommand --help` shows detailed argument information +- **Argument Parsing**: Use built-in utilities like `IdentifyTargetPathFromArgs()` for common patterns +- **Rich Help Display**: Arguments show descriptions, default values, and usage examples + +### Built-in Path Argument Support + +All current analysis plugins support the standard path argument pattern: + +```bash +# Current directory (default) +codemedic health + +# Specific path (short form) +codemedic health -p /path/to/repo + +# Specific path (long form) +codemedic health --path /path/to/repo + +# Combined with format +codemedic bom -p ../other-project --format markdown +``` + +### Example: Plugin with Command Registration + +```csharp +public class MyAnalysisPlugin : IAnalysisEnginePlugin +{ + // ... other plugin implementation ... + + public CommandRegistration[]? RegisterCommands() + { + return + [ + new CommandRegistration + { + Name = "myanalysis", + Description = "Run my custom analysis", + Handler = ExecuteMyAnalysisAsync, + Arguments = + [ + new CommandArgument( + Description: "Path to the repository to analyze", + ShortName: "p", + LongName: "path", + ValueName: "path", + DefaultValue: "current directory"), + new CommandArgument( + Description: "Enable verbose output", + ShortName: "v", + LongName: "verbose", + HasValue: false) // Flag argument + ], + Examples = + [ + "codemedic myanalysis", + "codemedic myanalysis -p /path/to/repo", + "codemedic myanalysis --path /path/to/repo --verbose", + "codemedic myanalysis -p . -v --format markdown" + ] + } + ]; + } + + private async Task ExecuteMyAnalysisAsync(string[] args, IRenderer renderer) + { + // Parse path argument using built-in extension + var targetPath = args.IdentifyTargetPathFromArgs(); + + // Run analysis on the specified path + var result = await AnalyzeAsync(targetPath); + + // Render results + renderer.RenderReport(result); + return 0; + } +} +``` + +--- + ## Plugin Development Workflow ### Step 1: Create the Plugin Project diff --git a/src/CodeMedic.Abstractions/Plugins/CommandRegistration.cs b/src/CodeMedic.Abstractions/Plugins/CommandRegistration.cs index 4f4246f..e305538 100644 --- a/src/CodeMedic.Abstractions/Plugins/CommandRegistration.cs +++ b/src/CodeMedic.Abstractions/Plugins/CommandRegistration.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace CodeMedic.Abstractions.Plugins; /// @@ -24,4 +26,28 @@ public class CommandRegistration /// Gets or sets example usage strings for help text. /// public string[]? Examples { get; init; } + + /// + /// Gets or sets the command arguments specification. + /// + public CommandArgument[]? Arguments { get; init; } } + +/// +/// Represents a command-line argument specification. +/// +/// The description of what this argument does. +/// The short name of the argument (e.g., "p" for "-p"). +/// The long name of the argument (e.g., "path" for "--path"). +/// Whether this argument is required. +/// Whether this argument takes a value. +/// The default value for this argument. +/// The value type name for help display (e.g., "path", "format", "count"). +public record CommandArgument( + string Description, + string? ShortName = null, + string? LongName = null, + bool IsRequired = false, + bool HasValue = true, + string? DefaultValue = null, + string? ValueName = null); diff --git a/src/CodeMedic/Commands/RootCommandHandler.cs b/src/CodeMedic/Commands/RootCommandHandler.cs index 87abaaf..d8acecb 100644 --- a/src/CodeMedic/Commands/RootCommandHandler.cs +++ b/src/CodeMedic/Commands/RootCommandHandler.cs @@ -29,8 +29,8 @@ public static async Task ProcessArguments(string[] args) _pluginLoader = new PluginLoader(); await _pluginLoader.LoadInternalPluginsAsync(); - // No arguments or help requested - if (args.Length == 0 || args.Contains("--help") || args.Contains("-h") || args.Contains("help")) + // No arguments or general help requested + if (args.Length == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "help") { console.RenderBanner(version); RenderHelp(); @@ -51,6 +51,15 @@ public static async Task ProcessArguments(string[] args) if (commandRegistration != null) { + // Check for command-specific help + var commandArgs = args.Skip(1).ToArray(); + if (commandArgs.Contains("--help") || commandArgs.Contains("-h")) + { + console.RenderBanner(version); + RenderCommandHelp(commandRegistration); + return 0; + } + // Parse --format argument (default: console) string format = "console"; var commandArgsList = args.Skip(1).ToList(); @@ -114,10 +123,36 @@ private static void RenderHelp() AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--version[/]"); AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[dim]Options:[/]"); + AnsiConsole.MarkupLine("[dim]Global Options:[/]"); AnsiConsole.MarkupLine(" [yellow]--format [/] Output format: [cyan]console[/] (default), [cyan]markdown[/] (or [cyan]md[/])"); AnsiConsole.WriteLine(); + // Show command-specific arguments + if (_pluginLoader != null) + { + foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) + { + if (command.Arguments != null && command.Arguments.Length > 0) + { + AnsiConsole.MarkupLine($"[dim]{command.Name} Command Options:[/]"); + + foreach (var arg in command.Arguments) + { + var shortName = !string.IsNullOrEmpty(arg.ShortName) ? $"-{arg.ShortName}" : ""; + var longName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : ""; + var names = string.Join(", ", new[] { shortName, longName }.Where(s => !string.IsNullOrEmpty(s))); + + var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; + var requiredIndicator = arg.IsRequired ? " [red](required)[/]" : ""; + var defaultPart = !string.IsNullOrEmpty(arg.DefaultValue) ? $" (default: {arg.DefaultValue})" : ""; + + AnsiConsole.MarkupLine($" [yellow]{names}{valuePart}[/] {arg.Description}{requiredIndicator}{defaultPart}"); + } + AnsiConsole.WriteLine(); + } + } + } + AnsiConsole.MarkupLine("[dim]Examples:[/]"); // Display examples from plugins @@ -138,6 +173,77 @@ private static void RenderHelp() AnsiConsole.MarkupLine(" [green]codemedic --version[/]"); } + /// + /// Renders help text for a specific command. + /// + private static void RenderCommandHelp(CodeMedic.Abstractions.Plugins.CommandRegistration command) + { + AnsiConsole.MarkupLine($"[bold]Command: {command.Name}[/]"); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"{command.Description}"); + AnsiConsole.WriteLine(); + + AnsiConsole.MarkupLine("[dim]Usage:[/]"); + var usage = $"codemedic {command.Name}"; + + if (command.Arguments != null && command.Arguments.Length > 0) + { + foreach (var arg in command.Arguments) + { + var argName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : $"-{arg.ShortName}"; + var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; + var optionalWrapper = arg.IsRequired ? "" : "[ ]"; + + if (arg.IsRequired) + { + usage += $" {argName}{valuePart}"; + } + else + { + usage += $" [{argName}{valuePart}]"; + } + } + } + + usage += " [--format ]"; + AnsiConsole.MarkupLine($" [green]{usage.EscapeMarkup()}[/]"); + AnsiConsole.WriteLine(); + + if (command.Arguments != null && command.Arguments.Length > 0) + { + AnsiConsole.MarkupLine("[dim]Command Options:[/]"); + + foreach (var arg in command.Arguments) + { + var shortName = !string.IsNullOrEmpty(arg.ShortName) ? $"-{arg.ShortName}" : ""; + var longName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : ""; + var names = string.Join(", ", new[] { shortName, longName }.Where(s => !string.IsNullOrEmpty(s))); + + var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; + var requiredIndicator = arg.IsRequired ? " [red](required)[/]" : ""; + var defaultPart = !string.IsNullOrEmpty(arg.DefaultValue) ? $" (default: {arg.DefaultValue})" : ""; + + AnsiConsole.MarkupLine($" [yellow]{(names + valuePart).EscapeMarkup()}[/] {arg.Description.EscapeMarkup()}{requiredIndicator}{defaultPart.EscapeMarkup()}"); + } + AnsiConsole.WriteLine(); + } + + AnsiConsole.MarkupLine("[dim]Global Options:[/]"); + AnsiConsole.MarkupLine(" [yellow]--format [/] Output format: [cyan]console[/] (default), [cyan]markdown[/] (or [cyan]md[/])"); + AnsiConsole.MarkupLine(" [yellow]-h, --help[/] Show this help message"); + AnsiConsole.WriteLine(); + + if (command.Examples != null && command.Examples.Length > 0) + { + AnsiConsole.MarkupLine("[dim]Examples:[/]"); + foreach (var example in command.Examples) + { + AnsiConsole.MarkupLine($" [green]{example.EscapeMarkup()}[/]"); + } + AnsiConsole.WriteLine(); + } + } + /// /// Renders information about loaded plugins. /// diff --git a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs index 79f0703..f73e217 100644 --- a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs @@ -60,10 +60,21 @@ public async Task AnalyzeAsync(string repositoryPath, CancellationToken Name = "bom", Description = "Generate bill of materials report", Handler = ExecuteBomCommandAsync, + Arguments = + [ + new CommandArgument( + Description: "Path to the repository to analyze", + ShortName: "p", + LongName: "path", + HasValue: true, + ValueName: "path", + DefaultValue: "current directory") + ], Examples = [ "codemedic bom", - "codemedic bom --format markdown", + "codemedic bom -p /path/to/repo", + "codemedic bom --path /path/to/repo --format markdown", "codemedic bom --format md > bom.md" ] } diff --git a/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs b/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs index 3830ff1..487db42 100644 --- a/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs @@ -56,10 +56,21 @@ public async Task AnalyzeAsync(string repositoryPath, CancellationToken Name = "health", Description = "Display repository health dashboard", Handler = ExecuteHealthCommandAsync, + Arguments = + [ + new CommandArgument( + Description: "Path to the repository to analyze", + ShortName: "p", + LongName: "path", + HasValue: true, + ValueName: "path", + DefaultValue: "current directory") + ], Examples = [ "codemedic health", - "codemedic health --format markdown", + "codemedic health -p /path/to/repo", + "codemedic health --path /path/to/repo --format markdown", "codemedic health --format md > report.md" ] } @@ -71,14 +82,7 @@ private async Task ExecuteHealthCommandAsync(string[] args, IRenderer rende try { // Parse arguments (target path only) - string? targetPath = null; - for (int i = 0; i < args.Length; i++) - { - if (!args[i].StartsWith("--")) - { - targetPath = args[i]; - } - } + string? targetPath = args.IdentifyTargetPathFromArgs(); _limitPackageLists = renderer is ConsoleRenderer; diff --git a/src/CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPlugin.cs b/src/CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPlugin.cs index a2a3787..50443ac 100644 --- a/src/CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPlugin.cs @@ -4,6 +4,7 @@ using CodeMedic.Models; using CodeMedic.Models.Report; using CodeMedic.Output; +using CodeMedic.Plugins.HealthAnalysis; using CodeMedic.Utilities; namespace CodeMedic.Plugins.VulnerabilityAnalysis; @@ -220,10 +221,21 @@ public async Task AnalyzeAsync(string repositoryPath, CancellationToken Name = "vulnerabilities", Description = "Scan for known vulnerabilities in NuGet packages", Handler = ExecuteVulnerabilityCommandAsync, + Arguments = + [ + new CommandArgument( + Description: "Path to the repository to scan", + ShortName: "p", + LongName: "path", + HasValue: true, + ValueName: "path", + DefaultValue: "current directory") + ], Examples = [ "codemedic vulnerabilities", - "codemedic vulnerabilities --format markdown", + "codemedic vulnerabilities -p /path/to/repo", + "codemedic vulnerabilities --path /path/to/repo --format markdown", "codemedic vulnerabilities > vulnerabilities-report.txt" ] } @@ -235,14 +247,7 @@ private async Task ExecuteVulnerabilityCommandAsync(string[] args, IRendere try { // Parse arguments (target path only) - string? targetPath = null; - for (int i = 0; i < args.Length; i++) - { - if (!args[i].StartsWith("--")) - { - targetPath = args[i]; - } - } + string? targetPath = args.IdentifyTargetPathFromArgs(); // Render banner and header renderer.RenderBanner(); diff --git a/src/CodeMedic/Utilities/CommandLineArgumentExtensions.cs b/src/CodeMedic/Utilities/CommandLineArgumentExtensions.cs new file mode 100644 index 0000000..ac273c6 --- /dev/null +++ b/src/CodeMedic/Utilities/CommandLineArgumentExtensions.cs @@ -0,0 +1,29 @@ +namespace CodeMedic.Plugins.HealthAnalysis; + +/// +/// String extensions for command-line argument parsing. +/// +public static class CommandLineArgumentExtensions +{ + + /// + /// Identifies the target path from command-line arguments using the standard -p or --path. + /// + /// Command line arguments collection + /// the folder path, if any that was submitted + public static string IdentifyTargetPathFromArgs(this string[] args) + { + string? targetPath = null; + for (int i = 0; i < args.Length; i++) + { + if ((args[i].StartsWith("-p") || args[i].StartsWith("--path")) && i + 1 < args.Length) + { + targetPath = args[i + 1]; + break; // Return the first found path argument + } + } + + return targetPath ?? Directory.GetCurrentDirectory(); + } + +} \ No newline at end of file diff --git a/test/Test.CodeMedic/Plugins/BomAnalysis/BomAnalysisPluginPathTests.cs b/test/Test.CodeMedic/Plugins/BomAnalysis/BomAnalysisPluginPathTests.cs new file mode 100644 index 0000000..bcef9a0 --- /dev/null +++ b/test/Test.CodeMedic/Plugins/BomAnalysis/BomAnalysisPluginPathTests.cs @@ -0,0 +1,196 @@ +using CodeMedic.Abstractions.Plugins; +using CodeMedic.Plugins.BomAnalysis; +using Moq; +using CodeMedic.Abstractions; + +namespace Test.CodeMedic.Plugins.BomAnalysis; + +/// +/// Unit tests for BomAnalysisPlugin path argument functionality. +/// +public class BomAnalysisPluginPathTests +{ + private readonly BomAnalysisPlugin _plugin; + + public BomAnalysisPluginPathTests() + { + _plugin = new BomAnalysisPlugin(); + } + + [Fact] + public void RegisterCommands_WhenCalled_ThenReturnsCommandWithPathArgument() + { + // When + var commands = _plugin.RegisterCommands(); + + // Then + Assert.NotNull(commands); + Assert.Single(commands); + + var command = commands[0]; + Assert.Equal("bom", command.Name); + Assert.Equal("Generate bill of materials report", command.Description); + + Assert.NotNull(command.Arguments); + Assert.Single(command.Arguments); + + var pathArg = command.Arguments[0]; + Assert.Equal("Path to the repository to analyze", pathArg.Description); + Assert.Equal("p", pathArg.ShortName); + Assert.Equal("path", pathArg.LongName); + Assert.False(pathArg.IsRequired); + Assert.True(pathArg.HasValue); + Assert.Equal("current directory", pathArg.DefaultValue); + Assert.Equal("path", pathArg.ValueName); + } + + [Fact] + public void RegisterCommands_WhenCalled_ThenReturnsCommandWithCorrectExamples() + { + // When + var commands = _plugin.RegisterCommands(); + + // Then + var command = commands![0]; + Assert.NotNull(command.Examples); + Assert.Contains("codemedic bom", command.Examples); + Assert.Contains("codemedic bom -p /path/to/repo", command.Examples); + Assert.Contains("codemedic bom --path /path/to/repo --format markdown", command.Examples); + Assert.Contains("codemedic bom --format md > bom.md", command.Examples); + } + + [Fact] + public async Task ExecuteBomCommandAsync_GivenEmptyArgs_WhenCalled_ThenUsesCurrentDirectory() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var args = Array.Empty(); + + // Setup the renderer to avoid actual rendering + mockRenderer.Setup(r => r.RenderBanner()).Verifiable(); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())).Verifiable(); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()) + .Verifiable(); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())).Verifiable(); + + // When & Then - Should not throw and should call renderer methods + var result = await command.Handler(args, mockRenderer.Object); + + // Verify renderer was called appropriately + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("Bill of Materials (BOM)"), Times.Once); + mockRenderer.Verify(r => r.RenderWaitAsync( + It.Is(s => s.Contains("Comprehensive dependency and service inventory (BOM)")), + It.IsAny>()), Times.Once); + } + + [Fact] + public async Task ExecuteBomCommandAsync_GivenShortPathArg_WhenCalled_ThenUsesSpecifiedPath() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var testPath = Path.GetTempPath(); + var args = new[] { "-p", testPath }; + + // Setup the renderer + mockRenderer.Setup(r => r.RenderBanner()).Verifiable(); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())).Verifiable(); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()) + .Verifiable(); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())).Verifiable(); + + // When + var result = await command.Handler(args, mockRenderer.Object); + + // Then - Should complete without throwing + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("Bill of Materials (BOM)"), Times.Once); + } + + [Fact] + public async Task ExecuteBomCommandAsync_GivenLongPathArg_WhenCalled_ThenUsesSpecifiedPath() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var testPath = Path.GetTempPath(); + var args = new[] { "--path", testPath }; + + // Setup the renderer + mockRenderer.Setup(r => r.RenderBanner()).Verifiable(); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())).Verifiable(); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()) + .Verifiable(); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())).Verifiable(); + + // When + var result = await command.Handler(args, mockRenderer.Object); + + // Then - Should complete without throwing + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("Bill of Materials (BOM)"), Times.Once); + } + + [Fact] + public void Metadata_WhenAccessed_ThenReturnsCorrectInformation() + { + // When + var metadata = _plugin.Metadata; + + // Then + Assert.Equal("codemedic.bom", metadata.Id); + Assert.Equal("Bill of Materials Analyzer", metadata.Name); + Assert.Equal("Generates comprehensive Bill of Materials including NuGet packages, frameworks, services, and vendors", metadata.Description); + Assert.Equal("CodeMedic Team", metadata.Author); + Assert.NotNull(metadata.Tags); + Assert.Contains("bom", metadata.Tags); + Assert.Contains("dependencies", metadata.Tags); + Assert.Contains("packages", metadata.Tags); + Assert.Contains("inventory", metadata.Tags); + } + + [Fact] + public void AnalysisDescription_WhenAccessed_ThenReturnsCorrectDescription() + { + // When + var description = _plugin.AnalysisDescription; + + // Then + Assert.Equal("Comprehensive dependency and service inventory (BOM)", description); + } + + [Fact] + public async Task InitializeAsync_WhenCalled_ThenCompletesSuccessfully() + { + // When & Then - Should complete without throwing + await _plugin.InitializeAsync(); + } + + [Fact] + public async Task ExecuteBomCommandAsync_GivenRelativePathArg_WhenCalled_ThenProcessesSuccessfully() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var args = new[] { "-p", "." }; + + // Setup minimal renderer mock + mockRenderer.Setup(r => r.RenderBanner()); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())); + + // When & Then - Should not throw + var result = await command.Handler(args, mockRenderer.Object); + + // Verify basic renderer calls were made + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("Bill of Materials (BOM)"), Times.Once); + } +} \ No newline at end of file diff --git a/test/Test.CodeMedic/Plugins/CommandArgumentTests.cs b/test/Test.CodeMedic/Plugins/CommandArgumentTests.cs new file mode 100644 index 0000000..f6a6753 --- /dev/null +++ b/test/Test.CodeMedic/Plugins/CommandArgumentTests.cs @@ -0,0 +1,142 @@ +using CodeMedic.Abstractions.Plugins; + +namespace Test.CodeMedic.Plugins; + +/// +/// Unit tests for CommandArgument record. +/// +public class CommandArgumentTests +{ + [Fact] + public void CommandArgument_GivenDescriptionOnly_WhenCreated_ThenHasCorrectDefaults() + { + // Given & When + var argument = new CommandArgument("Test description"); + + // Then + Assert.Equal("Test description", argument.Description); + Assert.Null(argument.ShortName); + Assert.Null(argument.LongName); + Assert.False(argument.IsRequired); + Assert.True(argument.HasValue); + Assert.Null(argument.DefaultValue); + Assert.Null(argument.ValueName); + } + + [Fact] + public void CommandArgument_GivenAllParameters_WhenCreated_ThenHasCorrectValues() + { + // Given & When + var argument = new CommandArgument( + Description: "Path to analyze", + ShortName: "p", + LongName: "path", + IsRequired: true, + HasValue: true, + DefaultValue: "current directory", + ValueName: "path"); + + // Then + Assert.Equal("Path to analyze", argument.Description); + Assert.Equal("p", argument.ShortName); + Assert.Equal("path", argument.LongName); + Assert.True(argument.IsRequired); + Assert.True(argument.HasValue); + Assert.Equal("current directory", argument.DefaultValue); + Assert.Equal("path", argument.ValueName); + } + + [Fact] + public void CommandArgument_GivenSameValues_WhenCompared_ThenAreEqual() + { + // Given + var argument1 = new CommandArgument( + Description: "Path to analyze", + ShortName: "p", + LongName: "path"); + + var argument2 = new CommandArgument( + Description: "Path to analyze", + ShortName: "p", + LongName: "path"); + + // When & Then + Assert.Equal(argument1, argument2); + Assert.True(argument1 == argument2); + Assert.Equal(argument1.GetHashCode(), argument2.GetHashCode()); + } + + [Fact] + public void CommandArgument_GivenDifferentValues_WhenCompared_ThenAreNotEqual() + { + // Given + var argument1 = new CommandArgument( + Description: "Path to analyze", + ShortName: "p"); + + var argument2 = new CommandArgument( + Description: "Path to analyze", + ShortName: "f"); + + // When & Then + Assert.NotEqual(argument1, argument2); + Assert.False(argument1 == argument2); + } + + [Fact] + public void CommandArgument_GivenFlagArgument_WhenCreated_ThenHasNoValue() + { + // Given & When + var argument = new CommandArgument( + Description: "Enable verbose output", + ShortName: "v", + LongName: "verbose", + HasValue: false); + + // Then + Assert.Equal("Enable verbose output", argument.Description); + Assert.Equal("v", argument.ShortName); + Assert.Equal("verbose", argument.LongName); + Assert.False(argument.HasValue); + } + + [Fact] + public void CommandArgument_GivenRequiredArgument_WhenCreated_ThenIsRequired() + { + // Given & When + var argument = new CommandArgument( + Description: "Required input file", + ShortName: "i", + LongName: "input", + IsRequired: true, + ValueName: "file"); + + // Then + Assert.Equal("Required input file", argument.Description); + Assert.True(argument.IsRequired); + Assert.Equal("file", argument.ValueName); + } + + [Theory] + [InlineData("path", "p", "path", true, "directory")] + [InlineData("format", "f", "format", false, "format")] + [InlineData("verbose", "v", "verbose", false, null)] + public void CommandArgument_GivenVariousParameters_WhenCreated_ThenMatchesExpected( + string description, string shortName, string longName, bool isRequired, string? valueName) + { + // Given & When + var argument = new CommandArgument( + Description: description, + ShortName: shortName, + LongName: longName, + IsRequired: isRequired, + ValueName: valueName); + + // Then + Assert.Equal(description, argument.Description); + Assert.Equal(shortName, argument.ShortName); + Assert.Equal(longName, argument.LongName); + Assert.Equal(isRequired, argument.IsRequired); + Assert.Equal(valueName, argument.ValueName); + } +} \ No newline at end of file diff --git a/test/Test.CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPluginPathTests.cs b/test/Test.CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPluginPathTests.cs new file mode 100644 index 0000000..27b27cb --- /dev/null +++ b/test/Test.CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPluginPathTests.cs @@ -0,0 +1,219 @@ +using CodeMedic.Abstractions.Plugins; +using CodeMedic.Plugins.HealthAnalysis; +using Moq; +using CodeMedic.Abstractions; + +namespace Test.CodeMedic.Plugins.HealthAnalysis; + +/// +/// Unit tests for HealthAnalysisPlugin path argument functionality. +/// +public class HealthAnalysisPluginPathTests +{ + private readonly HealthAnalysisPlugin _plugin; + + public HealthAnalysisPluginPathTests() + { + _plugin = new HealthAnalysisPlugin(); + } + + [Fact] + public void RegisterCommands_WhenCalled_ThenReturnsCommandWithPathArgument() + { + // When + var commands = _plugin.RegisterCommands(); + + // Then + Assert.NotNull(commands); + Assert.Single(commands); + + var command = commands[0]; + Assert.Equal("health", command.Name); + Assert.Equal("Display repository health dashboard", command.Description); + + Assert.NotNull(command.Arguments); + Assert.Single(command.Arguments); + + var pathArg = command.Arguments[0]; + Assert.Equal("Path to the repository to analyze", pathArg.Description); + Assert.Equal("p", pathArg.ShortName); + Assert.Equal("path", pathArg.LongName); + Assert.False(pathArg.IsRequired); + Assert.True(pathArg.HasValue); + Assert.Equal("current directory", pathArg.DefaultValue); + Assert.Equal("path", pathArg.ValueName); + } + + [Fact] + public void RegisterCommands_WhenCalled_ThenReturnsCommandWithCorrectExamples() + { + // When + var commands = _plugin.RegisterCommands(); + + // Then + var command = commands![0]; + Assert.NotNull(command.Examples); + Assert.Contains("codemedic health", command.Examples); + Assert.Contains("codemedic health -p /path/to/repo", command.Examples); + Assert.Contains("codemedic health --path /path/to/repo --format markdown", command.Examples); + Assert.Contains("codemedic health --format md > report.md", command.Examples); + } + + [Fact] + public async Task ExecuteHealthCommandAsync_GivenEmptyArgs_WhenCalled_ThenUsesCurrentDirectory() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var args = Array.Empty(); + + // Setup the renderer to avoid actual rendering + mockRenderer.Setup(r => r.RenderBanner()).Verifiable(); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())).Verifiable(); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()) + .Verifiable(); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())).Verifiable(); + + // When & Then - Should not throw and should call renderer methods + var result = await command.Handler(args, mockRenderer.Object); + + // Verify renderer was called appropriately + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("Repository Health Dashboard"), Times.Once); + mockRenderer.Verify(r => r.RenderWaitAsync( + It.Is(s => s.Contains("Repository health and code quality analysis")), + It.IsAny>()), Times.Once); + } + + [Fact] + public async Task ExecuteHealthCommandAsync_GivenShortPathArg_WhenCalled_ThenUsesSpecifiedPath() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var testPath = Path.GetTempPath(); + var args = new[] { "-p", testPath }; + + // Setup the renderer + mockRenderer.Setup(r => r.RenderBanner()).Verifiable(); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())).Verifiable(); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()) + .Verifiable(); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())).Verifiable(); + + // When + var result = await command.Handler(args, mockRenderer.Object); + + // Then - Should complete without throwing + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("Repository Health Dashboard"), Times.Once); + } + + [Fact] + public async Task ExecuteHealthCommandAsync_GivenLongPathArg_WhenCalled_ThenUsesSpecifiedPath() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var testPath = Path.GetTempPath(); + var args = new[] { "--path", testPath }; + + // Setup the renderer + mockRenderer.Setup(r => r.RenderBanner()).Verifiable(); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())).Verifiable(); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()) + .Verifiable(); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())).Verifiable(); + + // When + var result = await command.Handler(args, mockRenderer.Object); + + // Then - Should complete without throwing + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("Repository Health Dashboard"), Times.Once); + } + + [Fact] + public async Task ExecuteHealthCommandAsync_GivenCurrentDirectoryPath_WhenCalled_ThenProcessesSuccessfully() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var args = new[] { "-p", "." }; + + // Setup minimal renderer mock + mockRenderer.Setup(r => r.RenderBanner()); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())); + + // When & Then - Should not throw + var result = await command.Handler(args, mockRenderer.Object); + + // Verify basic renderer calls were made + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("Repository Health Dashboard"), Times.Once); + } + + [Fact] + public async Task ExecuteHealthCommandAsync_GivenParentDirectoryPath_WhenCalled_ThenProcessesSuccessfully() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var args = new[] { "--path", ".." }; + + // Setup minimal renderer mock + mockRenderer.Setup(r => r.RenderBanner()); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())); + + // When & Then - Should not throw + var result = await command.Handler(args, mockRenderer.Object); + + // Verify basic renderer calls were made + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("Repository Health Dashboard"), Times.Once); + } + + [Fact] + public void Metadata_WhenAccessed_ThenReturnsCorrectInformation() + { + // When + var metadata = _plugin.Metadata; + + // Then + Assert.Equal("codemedic.health", metadata.Id); + Assert.Equal("Repository Health Analyzer", metadata.Name); + Assert.Equal("Analyzes .NET repository health, including projects, dependencies, and code quality indicators", metadata.Description); + Assert.Equal("CodeMedic Team", metadata.Author); + Assert.NotNull(metadata.Tags); + Assert.Contains("health", metadata.Tags); + Assert.Contains("analysis", metadata.Tags); + Assert.Contains("repository", metadata.Tags); + Assert.Contains("dotnet", metadata.Tags); + } + + [Fact] + public void AnalysisDescription_WhenAccessed_ThenReturnsCorrectDescription() + { + // When + var description = _plugin.AnalysisDescription; + + // Then + Assert.Equal("Repository health and code quality analysis", description); + } + + [Fact] + public async Task InitializeAsync_WhenCalled_ThenCompletesSuccessfully() + { + // When & Then - Should complete without throwing + await _plugin.InitializeAsync(); + } +} \ No newline at end of file diff --git a/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginPathTests.cs b/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginPathTests.cs new file mode 100644 index 0000000..c3f8c03 --- /dev/null +++ b/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginPathTests.cs @@ -0,0 +1,219 @@ +using CodeMedic.Abstractions.Plugins; +using CodeMedic.Plugins.VulnerabilityAnalysis; +using Moq; +using CodeMedic.Abstractions; + +namespace Test.CodeMedic.Plugins.VulnerabilityAnalysis; + +/// +/// Unit tests for VulnerabilityAnalysisPlugin path argument functionality. +/// +public class VulnerabilityAnalysisPluginPathTests +{ + private readonly VulnerabilityAnalysisPlugin _plugin; + + public VulnerabilityAnalysisPluginPathTests() + { + _plugin = new VulnerabilityAnalysisPlugin(); + } + + [Fact] + public void RegisterCommands_WhenCalled_ThenReturnsCommandWithPathArgument() + { + // When + var commands = _plugin.RegisterCommands(); + + // Then + Assert.NotNull(commands); + Assert.Single(commands); + + var command = commands[0]; + Assert.Equal("vulnerabilities", command.Name); + Assert.Equal("Scan for known vulnerabilities in NuGet packages", command.Description); + + Assert.NotNull(command.Arguments); + Assert.Single(command.Arguments); + + var pathArg = command.Arguments[0]; + Assert.Equal("Path to the repository to scan", pathArg.Description); + Assert.Equal("p", pathArg.ShortName); + Assert.Equal("path", pathArg.LongName); + Assert.False(pathArg.IsRequired); + Assert.True(pathArg.HasValue); + Assert.Equal("current directory", pathArg.DefaultValue); + Assert.Equal("path", pathArg.ValueName); + } + + [Fact] + public void RegisterCommands_WhenCalled_ThenReturnsCommandWithCorrectExamples() + { + // When + var commands = _plugin.RegisterCommands(); + + // Then + var command = commands![0]; + Assert.NotNull(command.Examples); + Assert.Contains("codemedic vulnerabilities", command.Examples); + Assert.Contains("codemedic vulnerabilities -p /path/to/repo", command.Examples); + Assert.Contains("codemedic vulnerabilities --path /path/to/repo --format markdown", command.Examples); + Assert.Contains("codemedic vulnerabilities > vulnerabilities-report.txt", command.Examples); + } + + [Fact] + public async Task ExecuteVulnerabilityCommandAsync_GivenEmptyArgs_WhenCalled_ThenUsesCurrentDirectory() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var args = Array.Empty(); + + // Setup the renderer to avoid actual rendering + mockRenderer.Setup(r => r.RenderBanner()).Verifiable(); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())).Verifiable(); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()) + .Verifiable(); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())).Verifiable(); + + // When & Then - Should not throw and should call renderer methods + var result = await command.Handler(args, mockRenderer.Object); + + // Verify renderer was called appropriately + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("NuGet Package Vulnerability Report"), Times.Once); + mockRenderer.Verify(r => r.RenderWaitAsync( + It.Is(s => s.Contains("NuGet package vulnerability scan")), + It.IsAny>()), Times.Once); + } + + [Fact] + public async Task ExecuteVulnerabilityCommandAsync_GivenShortPathArg_WhenCalled_ThenUsesSpecifiedPath() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var testPath = Path.GetTempPath(); + var args = new[] { "-p", testPath }; + + // Setup the renderer + mockRenderer.Setup(r => r.RenderBanner()).Verifiable(); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())).Verifiable(); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()) + .Verifiable(); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())).Verifiable(); + + // When + var result = await command.Handler(args, mockRenderer.Object); + + // Then - Should complete without throwing + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("NuGet Package Vulnerability Report"), Times.Once); + } + + [Fact] + public async Task ExecuteVulnerabilityCommandAsync_GivenLongPathArg_WhenCalled_ThenUsesSpecifiedPath() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var testPath = Path.GetTempPath(); + var args = new[] { "--path", testPath }; + + // Setup the renderer + mockRenderer.Setup(r => r.RenderBanner()).Verifiable(); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())).Verifiable(); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()) + .Verifiable(); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())).Verifiable(); + + // When + var result = await command.Handler(args, mockRenderer.Object); + + // Then - Should complete without throwing + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("NuGet Package Vulnerability Report"), Times.Once); + } + + [Fact] + public void Metadata_WhenAccessed_ThenReturnsCorrectInformation() + { + // When + var metadata = _plugin.Metadata; + + // Then + Assert.Equal("codemedic.vulnerabilities", metadata.Id); + Assert.Equal("Vulnerability Scanner", metadata.Name); + Assert.Equal("Scans .NET projects for known vulnerabilities in NuGet package dependencies", metadata.Description); + Assert.Equal("CodeMedic Team", metadata.Author); + Assert.NotNull(metadata.Tags); + Assert.Contains("vulnerabilities", metadata.Tags); + Assert.Contains("security", metadata.Tags); + Assert.Contains("packages", metadata.Tags); + Assert.Contains("cve", metadata.Tags); + } + + [Fact] + public void AnalysisDescription_WhenAccessed_ThenReturnsCorrectDescription() + { + // When + var description = _plugin.AnalysisDescription; + + // Then + Assert.Equal("NuGet package vulnerability scan", description); + } + + [Fact] + public async Task InitializeAsync_WhenCalled_ThenCompletesSuccessfully() + { + // When & Then - Should complete without throwing + await _plugin.InitializeAsync(); + } + + [Fact] + public async Task ExecuteVulnerabilityCommandAsync_GivenRelativePathArg_WhenCalled_ThenProcessesSuccessfully() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var args = new[] { "-p", "." }; + + // Setup minimal renderer mock + mockRenderer.Setup(r => r.RenderBanner()); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())); + + // When & Then - Should not throw + var result = await command.Handler(args, mockRenderer.Object); + + // Verify basic renderer calls were made + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("NuGet Package Vulnerability Report"), Times.Once); + } + + [Fact] + public async Task ExecuteVulnerabilityCommandAsync_GivenParentDirectoryPath_WhenCalled_ThenProcessesSuccessfully() + { + // Given + var mockRenderer = new Mock(); + var command = _plugin.RegisterCommands()![0]; + var args = new[] { "--path", ".." }; + + // Setup minimal renderer mock + mockRenderer.Setup(r => r.RenderBanner()); + mockRenderer.Setup(r => r.RenderSectionHeader(It.IsAny())); + mockRenderer.Setup(r => r.RenderWaitAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, action) => action()); + mockRenderer.Setup(r => r.RenderReport(It.IsAny())); + + // When & Then - Should not throw + var result = await command.Handler(args, mockRenderer.Object); + + // Verify basic renderer calls were made + mockRenderer.Verify(r => r.RenderBanner(), Times.Once); + mockRenderer.Verify(r => r.RenderSectionHeader("NuGet Package Vulnerability Report"), Times.Once); + } +} \ No newline at end of file diff --git a/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs b/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs new file mode 100644 index 0000000..ddbe8d9 --- /dev/null +++ b/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs @@ -0,0 +1,195 @@ +using CodeMedic.Plugins.HealthAnalysis; + +namespace Test.CodeMedic.Utilities; + +/// +/// Unit tests for CommandLineArgumentExtensions. +/// +public class CommandLineArgumentExtensionsTests +{ + [Fact] + public void IdentifyTargetPathFromArgs_GivenEmptyArray_WhenCalled_ThenReturnsCurrentDirectory() + { + // Given + var args = Array.Empty(); + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal(Directory.GetCurrentDirectory(), result); + } + + [Fact] + public void IdentifyTargetPathFromArgs_GivenShortPathArgument_WhenCalled_ThenReturnsPath() + { + // Given + var args = new[] { "-p", "/path/to/repo" }; + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal("/path/to/repo", result); + } + + [Fact] + public void IdentifyTargetPathFromArgs_GivenLongPathArgument_WhenCalled_ThenReturnsPath() + { + // Given + var args = new[] { "--path", "/path/to/repo" }; + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal("/path/to/repo", result); + } + + [Fact] + public void IdentifyTargetPathFromArgs_GivenWindowsPath_WhenCalled_ThenReturnsPath() + { + // Given + var args = new[] { "-p", @"C:\Projects\MyRepo" }; + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal(@"C:\Projects\MyRepo", result); + } + + [Fact] + public void IdentifyTargetPathFromArgs_GivenRelativePath_WhenCalled_ThenReturnsPath() + { + // Given + var args = new[] { "--path", "." }; + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal(".", result); + } + + [Fact] + public void IdentifyTargetPathFromArgs_GivenMixedArguments_WhenCalled_ThenReturnsPathValue() + { + // Given + var args = new[] { "--format", "markdown", "-p", "/target/path", "--verbose" }; + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal("/target/path", result); + } + + [Fact] + public void IdentifyTargetPathFromArgs_GivenShortPathInMiddle_WhenCalled_ThenReturnsPath() + { + // Given + var args = new[] { "--format", "json", "-p", "/some/path", "--output", "file.json" }; + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal("/some/path", result); + } + + [Fact] + public void IdentifyTargetPathFromArgs_GivenLongPathInMiddle_WhenCalled_ThenReturnsPath() + { + // Given + var args = new[] { "--verbose", "--path", "/some/other/path", "--format", "markdown" }; + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal("/some/other/path", result); + } + + [Fact] + public void IdentifyTargetPathFromArgs_GivenPathArgumentWithoutValue_WhenCalled_ThenReturnsCurrentDirectory() + { + // Given + var args = new[] { "-p" }; + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal(Directory.GetCurrentDirectory(), result); + } + + [Fact] + public void IdentifyTargetPathFromArgs_GivenLastArgumentIsPath_WhenCalled_ThenReturnsCurrentDirectory() + { + // Given + var args = new[] { "--format", "json", "--path" }; + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal(Directory.GetCurrentDirectory(), result); + } + + [Fact] + public void IdentifyTargetPathFromArgs_GivenMultiplePathArguments_WhenCalled_ThenReturnsFirstPath() + { + // Given + var args = new[] { "-p", "/first/path", "--path", "/second/path" }; + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal("/first/path", result); + } + + [Fact] + public void IdentifyTargetPathFromArgs_GivenNoPathArguments_WhenCalled_ThenReturnsCurrentDirectory() + { + // Given + var args = new[] { "--format", "markdown", "--verbose", "--output", "report.md" }; + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal(Directory.GetCurrentDirectory(), result); + } + + [Theory] + [InlineData(new[] { "-p", "/test/path" }, "/test/path")] + [InlineData(new[] { "--path", "/test/path" }, "/test/path")] + [InlineData(new[] { "-p", "." }, ".")] + [InlineData(new[] { "--path", ".." }, "..")] + [InlineData(new string[0], null)] // null represents current directory expectation + public void IdentifyTargetPathFromArgs_GivenVariousInputs_WhenCalled_ThenReturnsExpectedPath( + string[] args, string? expectedPath) + { + // Given & When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + var expected = expectedPath ?? Directory.GetCurrentDirectory(); + Assert.Equal(expected, result); + } + + [Fact] + public void IdentifyTargetPathFromArgs_GivenPathWithSpaces_WhenCalled_ThenReturnsFullPath() + { + // Given + var args = new[] { "-p", "/path with spaces/to repo" }; + + // When + var result = args.IdentifyTargetPathFromArgs(); + + // Then + Assert.Equal("/path with spaces/to repo", result); + } +} \ No newline at end of file diff --git a/user-docs/cli_quick_reference.md b/user-docs/cli_quick_reference.md index 161baea..c912e8b 100644 --- a/user-docs/cli_quick_reference.md +++ b/user-docs/cli_quick_reference.md @@ -26,20 +26,42 @@ codemedic -v # Repository health analysis codemedic health -codemedic health --format markdown > report.md +codemedic health -p /path/to/repo +codemedic health --path /path/to/repo --format markdown > report.md # Bill of materials codemedic bom -codemedic bom --format json -codemedic bom --format markdown +codemedic bom -p /path/to/repo +codemedic bom --path /path/to/repo --format markdown # Vulnerability scanning codemedic vulnerabilities -codemedic vulnerabilities --format markdown > vulnerabilities-report.md -codemedic vulnerabilities /path/to/repo +codemedic vulnerabilities -p /path/to/repo +codemedic vulnerabilities --path /path/to/repo --format markdown > vulnerabilities-report.md +``` + +## Command Options + +### Path Argument +All analysis commands support a path argument to specify which repository to analyze: + +- `-p ` or `--path ` - Specify the path to the repository +- If not provided, uses the current directory + +```bash +# Analyze current directory +codemedic health + +# Analyze specific directory +codemedic health -p /path/to/repo +codemedic health --path /path/to/repo + +# Relative paths work too +codemedic bom -p ../other-project +codemedic vulnerabilities --path . ``` -## Output Formats +### Output Formats All commands support `--format` option: - `console` (default) - Rich formatted output for terminal diff --git a/user-docs/docker_usage.md b/user-docs/docker_usage.md index 2ca1384..1a86327 100644 --- a/user-docs/docker_usage.md +++ b/user-docs/docker_usage.md @@ -38,18 +38,27 @@ docker run --rm codemedic:latest --help **Run health check on current directory:** ```bash # Windows (PowerShell) -docker run --rm -v ${PWD}:/repo codemedic:latest health /repo +docker run --rm -v ${PWD}:/repo codemedic:latest health --path /repo # Windows (Command Prompt) -docker run --rm -v %cd%:/repo codemedic:latest health /repo +docker run --rm -v %cd%:/repo codemedic:latest health --path /repo # Linux/macOS -docker run --rm -v $(pwd):/repo codemedic:latest health /repo +docker run --rm -v $(pwd):/repo codemedic:latest health --path /repo ``` **Run health check with output to file:** ```bash -docker run --rm -v ${PWD}:/repo codemedic:latest health /repo --output /repo/health-report.md +docker run --rm -v ${PWD}:/repo codemedic:latest health -p /repo --format markdown > health-report.md +``` + +**Run other commands:** +```bash +# Bill of Materials +docker run --rm -v $(pwd):/repo codemedic:latest bom --path /repo + +# Vulnerability scan +docker run --rm -v $(pwd):/repo codemedic:latest vulnerabilities -p /repo --format markdown ``` ## Build Script Options diff --git a/user-docs/vulnerability-scanning.md b/user-docs/vulnerability-scanning.md index 89ca920..d42f244 100644 --- a/user-docs/vulnerability-scanning.md +++ b/user-docs/vulnerability-scanning.md @@ -14,39 +14,44 @@ codemedic vulnerabilities ### Scan Specific Repository ```bash -codemedic vulnerabilities /path/to/my/repo +codemedic vulnerabilities -p /path/to/my/repo +codemedic vulnerabilities --path /path/to/my/repo ``` ### Generate Report ```bash codemedic vulnerabilities --format markdown > vulnerabilities-report.md +codemedic vulnerabilities -p /path/to/repo --format markdown > vulnerabilities-report.md ``` ## Command Reference ### Syntax ``` -codemedic vulnerabilities [path] [--format FORMAT] +codemedic vulnerabilities [-p|--path PATH] [--format FORMAT] ``` ### Parameters -- `path` (optional) - Directory to scan (defaults to current directory) -- `--format` (optional) - Output format: `console` (default) or `markdown`/`md` +- `-p, --path PATH` (optional) - Path to the repository to scan (defaults to current directory) +- `--format FORMAT` (optional) - Output format: `console` (default) or `markdown`/`md` ### Examples ```bash -# Scan with console output +# Scan current directory with console output codemedic vulnerabilities -# Scan specific path -codemedic vulnerabilities ~/projects/myapp +# Scan specific path using short form +codemedic vulnerabilities -p ~/projects/myapp -# Save markdown report +# Scan specific path using long form +codemedic vulnerabilities --path ~/projects/myapp + +# Save markdown report from current directory codemedic vulnerabilities --format markdown > vulns.md -# Combined -codemedic vulnerabilities ~/projects/myapp --format markdown > report.md +# Combined: scan specific path and save markdown report +codemedic vulnerabilities -p ~/projects/myapp --format markdown > report.md ``` ## Understanding the Report