diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..81c6c80 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,25 @@ +{ + "editor.insertSpaces": false, + "editor.tabSize": 4, + "editor.detectIndentation": false, + "[csharp]": { + "editor.insertSpaces": false, + "editor.tabSize": 4 + }, + "[json]": { + "editor.insertSpaces": false, + "editor.tabSize": 2 + }, + "[xml]": { + "editor.insertSpaces": false, + "editor.tabSize": 2 + }, + "[yaml]": { + "editor.insertSpaces": true, + "editor.tabSize": 2 + }, + "[markdown]": { + "editor.insertSpaces": true, + "editor.tabSize": 2 + } +} diff --git a/README.md b/README.md index afd598d..308ff8e 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,15 @@ dotnet run -- --help ## 📖 Documentation -- **User Guide:** `user-docs/cli_quick_reference.md` -- **Docker Guide:** `user-docs/docker_usage.md` -- **Technical Guide:** `doc/cli_skeleton_implementation.md` -- **Architecture & Extension:** `doc/cli_architecture.md` +### User Documentation +- **CLI Quick Reference:** `user-docs/cli_quick_reference.md` +- **Configuration Files:** `user-docs/configuration-files.md` - Batch analysis with JSON/YAML +- **Docker Usage:** `user-docs/docker_usage.md` +- **Vulnerability Scanning:** `user-docs/vulnerability-scanning.md` + +### Technical Documentation +- **CLI Architecture:** `doc/cli_architecture.md` +- **Implementation Guide:** `doc/cli_skeleton_implementation.md` - **Docker Implementation:** `doc/docker_implementation.md` - **Test Results:** `doc/cli_skeleton_test_results.md` @@ -52,6 +57,7 @@ dotnet run -- --help - ✅ Multiple output formats (console, markdown) - ✅ Path argument support (`-p` / `--path`) for all analysis commands - ✅ Command-specific help with argument documentation +- ✅ **Configuration file support (JSON & YAML) for batch analysis** ## 🎯 Current Commands @@ -61,6 +67,11 @@ codemedic # Show help (default) codemedic --help # Explicit help codemedic --version # Show version +# Configuration-based batch analysis +codemedic config # Run multiple analyses from config file +codemedic config config.json +codemedic config config.yaml + # Analysis commands codemedic health # Repository health dashboard codemedic health -p /path/to/repo --format markdown diff --git a/run-config.cmd b/run-config.cmd new file mode 100644 index 0000000..6c67fbc --- /dev/null +++ b/run-config.cmd @@ -0,0 +1,17 @@ +@echo off +REM Run CodeMedic with a configuration file + +if "%1"=="" ( + set CONFIG_FILE=sample-config.yaml +) else ( + set CONFIG_FILE=%1 +) + +if not exist "%CONFIG_FILE%" ( + echo Configuration file not found: %CONFIG_FILE% + exit /b 1 +) + +echo Running CodeMedic with configuration: %CONFIG_FILE% + +dotnet run --project src\CodeMedic config %CONFIG_FILE% diff --git a/run-config.ps1 b/run-config.ps1 new file mode 100644 index 0000000..f532151 --- /dev/null +++ b/run-config.ps1 @@ -0,0 +1,16 @@ +#!/usr/bin/env pwsh +# Run CodeMedic with a configuration file + +param( + [Parameter(Mandatory=$false)] + [string]$ConfigFile = "sample-config.yaml" +) + +if (-not (Test-Path $ConfigFile)) { + Write-Host "Configuration file not found: $ConfigFile" -ForegroundColor Red + exit 1 +} + +Write-Host "Running CodeMedic with configuration: $ConfigFile" -ForegroundColor Cyan + +dotnet run --project src\CodeMedic config $ConfigFile diff --git a/run-config.sh b/run-config.sh new file mode 100644 index 0000000..357ce90 --- /dev/null +++ b/run-config.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Run CodeMedic with a configuration file + +CONFIG_FILE="${1:-sample-config.yaml}" + +if [ ! -f "$CONFIG_FILE" ]; then + echo "Configuration file not found: $CONFIG_FILE" + exit 1 +fi + +echo "Running CodeMedic with configuration: $CONFIG_FILE" + +dotnet run --project src/CodeMedic config "$CONFIG_FILE" diff --git a/sample-config-multi-repo.json b/sample-config-multi-repo.json new file mode 100644 index 0000000..755762a --- /dev/null +++ b/sample-config-multi-repo.json @@ -0,0 +1,23 @@ +{ + "global": { + "format": "markdown", + "output-dir": "./reports/multi" + }, + "repositories": [ + { + "name": "MainProject", + "path": "./src", + "commands": ["health", "bom"] + }, + { + "name": "TestSuite", + "path": "./test", + "commands": ["health"] + }, + { + "name": "Documentation", + "path": "./docs", + "commands": ["bom"] + } + ] +} diff --git a/sample-config-multi-repo.yaml b/sample-config-multi-repo.yaml new file mode 100644 index 0000000..dfd62da --- /dev/null +++ b/sample-config-multi-repo.yaml @@ -0,0 +1,20 @@ +global: + format: markdown + output-dir: ./reports/multi + +repositories: + - name: MainProject + path: ./src + commands: + - health + - bom + + - name: TestSuite + path: ./test + commands: + - health + + - name: Documentation + path: ./docs + commands: + - bom diff --git a/sample-config.json b/sample-config.json new file mode 100644 index 0000000..939bb25 --- /dev/null +++ b/sample-config.json @@ -0,0 +1,13 @@ +{ + "global": { + "format": "markdown", + "output-dir": "./reports" + }, + "repositories": [ + { + "name": "CodeMedic", + "path": ".", + "commands": ["health", "bom", "vulnerabilities"] + } + ] +} diff --git a/sample-config.yaml b/sample-config.yaml new file mode 100644 index 0000000..ca36079 --- /dev/null +++ b/sample-config.yaml @@ -0,0 +1,11 @@ +global: + format: markdown + output-dir: ./reports + +repositories: + - name: CodeMedic + path: . + commands: + - health + - bom + - vulnerabilities diff --git a/src/CodeMedic/CodeMedic.csproj b/src/CodeMedic/CodeMedic.csproj index a562f1e..2fc35c9 100644 --- a/src/CodeMedic/CodeMedic.csproj +++ b/src/CodeMedic/CodeMedic.csproj @@ -34,6 +34,7 @@ + diff --git a/src/CodeMedic/Commands/ConfigurationCommandHandler.cs b/src/CodeMedic/Commands/ConfigurationCommandHandler.cs new file mode 100644 index 0000000..22a3c6e --- /dev/null +++ b/src/CodeMedic/Commands/ConfigurationCommandHandler.cs @@ -0,0 +1,145 @@ +using CodeMedic.Utilities; +using CodeMedic.Commands; +using CodeMedic.Output; + +namespace CodeMedic.Commands; + +/// +/// Handles configuration-related commands. +/// +public class ConfigurationCommandHandler +{ + private PluginLoader _PluginLoader; + + /// + /// Initializes a new instance of the class. + /// + /// The plugin loader instance used to manage plugins. + public ConfigurationCommandHandler(PluginLoader pluginLoader) + { + _PluginLoader = pluginLoader; + } + + /// + /// Handles the configuration file command. + /// + /// The path to the configuration file. + /// A task that represents the asynchronous operation. The task result contains the exit code. + internal async Task HandleConfigurationFileAsync(string configFilePath) + { + + // Check if the configuration file exists + if (!File.Exists(configFilePath)) + { + RootCommandHandler.Console.RenderError($"Configuration file not found: {configFilePath}"); + return 1; // Return a non-zero exit code to indicate failure + } + + // Load the specified configuration file - we need to identify the file type and load accordingly + CodeMedicRunConfiguration config; + try { + config = LoadConfigurationFromFile(configFilePath); + if (config == null) + { + RootCommandHandler.Console.RenderError($"Failed to load configuration from file: {configFilePath}"); + return 1; // Return a non-zero exit code to indicate failure + } + } catch (Exception ex) + { + RootCommandHandler.Console.RenderError($"Error loading configuration file: {ex.Message}"); + return 1; // Return a non-zero exit code to indicate failure + } + + await RunCommandsForConfigurationAsync(config); + + return 0; // Return zero to indicate success + + } + + private async Task RunCommandsForConfigurationAsync(CodeMedicRunConfiguration config) + { + + // For each repository in the configuration, run the specified commands + foreach (var repoConfig in config.Repositories) + { + RootCommandHandler.Console.RenderInfo($"Processing repository: {repoConfig.Name} at {repoConfig.Path}"); + + foreach (var commandName in repoConfig.Commands) + { + + // Check that the command is registered with the plugin loader + if (!_PluginLoader.Commands.ContainsKey(commandName)) + { + RootCommandHandler.Console.RenderError($" Command not registered: {commandName}"); + continue; + } + + RootCommandHandler.Console.RenderInfo($" Running command: {commandName}"); + + // Load the command plugin + var commandPlugin = _PluginLoader.GetCommand(commandName); + if (commandPlugin == null) + { + RootCommandHandler.Console.RenderError($" Command plugin not found: {commandName}"); + continue; + } + + // Get the Formatter plugin for output formatting - we only support markdown for now + // Ensure output directory exists + Directory.CreateDirectory(config.Global.OutputDirectory); + + var reportPath = Path.Combine(config.Global.OutputDirectory, $"{repoConfig.Name}_{commandName}.md"); + + // Execute the command with proper disposal of the StreamWriter + using (var writer = new StreamWriter(reportPath)) + { + var formatter = new MarkdownRenderer(writer); + + // Build arguments array to pass the repository path to the command + var commandArgs = new[] { "--path", repoConfig.Path }; + + await commandPlugin.Handler(commandArgs, formatter); + } + + } + // For now, just simulate with a delay + await Task.Delay(500); + + RootCommandHandler.Console.RenderInfo($"Completed processing repository: {repoConfig.Name}"); + } + + } + + private CodeMedicRunConfiguration LoadConfigurationFromFile(string configFilePath) + { + // detect if the file is json or yaml based on extension + var extension = Path.GetExtension(configFilePath).ToLower(); + var fileContents = File.ReadAllText(configFilePath); + if (extension == ".json") + { + var config = System.Text.Json.JsonSerializer.Deserialize(fileContents); + if (config == null) + { + throw new InvalidOperationException("Failed to deserialize JSON configuration file."); + } + return config; + } + else if (extension == ".yaml" || extension == ".yml") + { + // Configure YAML deserializer - we use explicit YamlMember aliases on properties + var deserializer = new YamlDotNet.Serialization.DeserializerBuilder() + .Build(); + var config = deserializer.Deserialize(fileContents); + if (config == null) + { + throw new InvalidOperationException("Failed to deserialize YAML configuration file."); + } + return config; + } + else + { + throw new InvalidOperationException("Unsupported configuration file format. Only JSON and YAML are supported."); + } + } + +} diff --git a/src/CodeMedic/Commands/RootCommandHandler.cs b/src/CodeMedic/Commands/RootCommandHandler.cs index d8acecb..6b11153 100644 --- a/src/CodeMedic/Commands/RootCommandHandler.cs +++ b/src/CodeMedic/Commands/RootCommandHandler.cs @@ -10,322 +10,369 @@ namespace CodeMedic.Commands; /// public class RootCommandHandler { - private static PluginLoader? _pluginLoader; - - /// - /// Gets a console renderer for providing feedback to the user. - /// - public static readonly ConsoleRenderer Console = new ConsoleRenderer(); - - /// - /// Processes command-line arguments and executes appropriate handler. - /// - public static async Task ProcessArguments(string[] args) - { - var version = VersionUtility.GetVersion(); - var console = new ConsoleRenderer(); - - // Load plugins first - _pluginLoader = new PluginLoader(); - await _pluginLoader.LoadInternalPluginsAsync(); - - // No arguments or general help requested - if (args.Length == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "help") - { - console.RenderBanner(version); - RenderHelp(); - return 0; - } - - // Version requested - if (args.Contains("--version") || args.Contains("-v") || args.Contains("version")) - { - ConsoleRenderer.RenderVersion(version); - RenderPluginInfo(); - return 0; - } - - // Check if a plugin registered this command - var commandName = args[0]; - var commandRegistration = _pluginLoader.GetCommand(commandName); - - 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(); - for (int i = 0; i < commandArgsList.Count; i++) - { - if (commandArgsList[i] == "--format" && i + 1 < commandArgsList.Count) - { - format = commandArgsList[i + 1].ToLower(); - commandArgsList.RemoveAt(i + 1); - commandArgsList.RemoveAt(i); - break; - } - } - IRenderer renderer = format switch - { - "markdown" or "md" => new MarkdownRenderer(), - _ => new ConsoleRenderer() - }; - return await commandRegistration.Handler(commandArgsList.ToArray(), renderer); - } - - // Unknown command - console.RenderError($"Unknown command: {args[0]}"); - RenderHelp(); - return 1; - } - - /// - /// Renders the help text with available commands (including plugin commands). - /// - private static void RenderHelp() - { - var table = new Table - { - Border = TableBorder.Rounded, - Title = new TableTitle("[bold]Available Commands[/]") - }; - - table.AddColumn("Command"); - table.AddColumn("Description"); - - // Add plugin-registered commands - if (_pluginLoader != null) - { - foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) - { - table.AddRow($"[cyan]{command.Name}[/]", command.Description); - } - } - - // Add built-in commands - table.AddRow("[cyan]version[/] or [cyan]-v[/], [cyan]--version[/]", "Display application version"); - table.AddRow("[cyan]help[/] or [cyan]-h[/], [cyan]--help[/]", "Display this help message"); - - AnsiConsole.Write(table); - AnsiConsole.WriteLine(); - - AnsiConsole.MarkupLine("[dim]Usage:[/]"); - AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan][/] [yellow][[options]][/]"); - AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--help[/]"); - AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--version[/]"); - AnsiConsole.WriteLine(); - - 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 - if (_pluginLoader != null) - { - foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) - { - if (command.Examples != null) - { - foreach (var example in command.Examples) - { - AnsiConsole.MarkupLine($" [green]{example}[/]"); - } - } - } - } - - 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. - /// - private static void RenderPluginInfo() - { - if (_pluginLoader == null) - { - return; - } - - var analysisEngines = _pluginLoader.AnalysisEngines; - var reporters = _pluginLoader.Reporters; - var totalPlugins = analysisEngines.Count + reporters.Count; - - if (totalPlugins == 0) - { - return; - } - - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[bold]Loaded Plugins:[/] {totalPlugins}"); - AnsiConsole.WriteLine(); - - // Analysis Engine Plugins - if (analysisEngines.Count > 0) - { - var pluginTable = new Table - { - Border = TableBorder.Rounded, - Title = new TableTitle("[bold cyan]Analysis Engines[/]") - }; - - pluginTable.AddColumn("Name"); - pluginTable.AddColumn("Version"); - pluginTable.AddColumn("Description"); - pluginTable.AddColumn("Commands"); - - foreach (var plugin in analysisEngines.OrderBy(p => p.Metadata.Name)) - { - // Get commands registered by this specific plugin - var pluginCommands = plugin.RegisterCommands(); - var commandList = pluginCommands != null && pluginCommands.Length > 0 - ? string.Join(", ", pluginCommands.Select(c => $"[cyan]{c.Name}[/]")) - : "[dim]none[/]"; - - pluginTable.AddRow( - plugin.Metadata.Name, - plugin.Metadata.Version, - plugin.Metadata.Description, - commandList - ); - } - - AnsiConsole.Write(pluginTable); - } - - // Reporter Plugins (if any) - if (reporters.Count > 0) - { - AnsiConsole.WriteLine(); - var reporterTable = new Table - { - Border = TableBorder.Rounded, - Title = new TableTitle("[bold yellow]Reporters[/]") - }; - - reporterTable.AddColumn("Name"); - reporterTable.AddColumn("Version"); - reporterTable.AddColumn("Format"); - reporterTable.AddColumn("Description"); - - foreach (var reporter in reporters.OrderBy(r => r.Metadata.Name)) - { - reporterTable.AddRow( - reporter.Metadata.Name, - reporter.Metadata.Version, - $"[cyan]{reporter.OutputFormat}[/]", - reporter.Metadata.Description - ); - } - - AnsiConsole.Write(reporterTable); - } - } + private static PluginLoader _pluginLoader = null!; + + /// + /// Gets a console renderer for providing feedback to the user. + /// + public static readonly ConsoleRenderer Console = new ConsoleRenderer(); + + /// + /// Processes command-line arguments and executes appropriate handler. + /// + public static async Task ProcessArguments(string[] args) + { + var version = VersionUtility.GetVersion(); + + // Load plugins first + _pluginLoader = new PluginLoader(); + await _pluginLoader.LoadInternalPluginsAsync(); + + // No arguments or general help requested + if (args.Length == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "help") + { + Console.RenderBanner(version); + RenderHelp(); + return 0; + } + + var (flowControl, value) = await HandleConfigCommand(args, version); + if (!flowControl) + { + return value; + } + + // Version requested + if (args.Contains("--version") || args.Contains("-v") || args.Contains("version")) + { + ConsoleRenderer.RenderVersion(version); + RenderPluginInfo(); + return 0; + } + + // Check if a plugin registered this command + var commandName = args[0]; + var commandRegistration = _pluginLoader.GetCommand(commandName); + + 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"; + string outputDir = string.Empty; + var commandArgsList = args.Skip(1).ToList(); + for (int i = 0; i < commandArgsList.Count; i++) + { + if (commandArgsList[i] == "--format" && i + 1 < commandArgsList.Count) + { + format = commandArgsList[i + 1].ToLower(); + commandArgsList.RemoveAt(i + 1); + commandArgsList.RemoveAt(i); + break; + } + + // if ((commandArgsList[i] == "--output-dir" || commandArgsList[i] == "-o") && i + 1 < commandArgsList.Count) + // { + // outputDir = commandArgsList[i + 1]; + // commandArgsList.RemoveAt(i + 1); + // commandArgsList.RemoveAt(i); + // break; + // } + + } + + + IRenderer renderer = format switch + { + "markdown" or "md" => new MarkdownRenderer(), + _ => new ConsoleRenderer() + }; + return await commandRegistration.Handler(commandArgsList.ToArray(), renderer); + } + + // Unknown command + Console.RenderError($"Unknown command: {args[0]}"); + RenderHelp(); + return 1; + } + + private static async Task<(bool flowControl, int value)> HandleConfigCommand(string[] args, string version) + { + + if (args.Length < 2 || (args[0] != "config")) + { + return (flowControl: true, value: default); + } + + Console.RenderBanner(version); + + // Load a configuration file specified in the following argument and begin processing the instructions in that file. + if (args.Length < 2) + { + Console.RenderError("No configuration file specified. Please provide a path to a configuration file."); + return (flowControl: false, value: 1); + } + + var configFilePath = args[1]; + if (!File.Exists(configFilePath)) + { + Console.RenderError($"Configuration file not found: {configFilePath}"); + return (flowControl: false, value: 1); + } + + var configHandler = new ConfigurationCommandHandler(_pluginLoader); + return (flowControl: false, value: await configHandler.HandleConfigurationFileAsync(configFilePath)); + + } + + /// + /// Renders the help text with available commands (including plugin commands). + /// + private static void RenderHelp() + { + var table = new Table + { + Border = TableBorder.Rounded, + Title = new TableTitle("[bold]Available Commands[/]") + }; + + table.AddColumn("Command"); + table.AddColumn("Description"); + + // Add plugin-registered commands + if (_pluginLoader != null) + { + foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) + { + table.AddRow($"[cyan]{command.Name}[/]", command.Description); + } + } + + // Add built-in commands + table.AddRow("[cyan]--config[/] or [cyan]-c[/]", "Provide a configuration file that CodeMedic will use"); + table.AddRow("[cyan]version[/] or [cyan]-v[/], [cyan]--version[/]", "Display application version"); + table.AddRow("[cyan]help[/] or [cyan]-h[/], [cyan]--help[/]", "Display this help message"); + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + + AnsiConsole.MarkupLine("[dim]Usage:[/]"); + AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan][/] [yellow][[options]][/]"); + AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--help[/]"); + AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--version[/]"); + AnsiConsole.WriteLine(); + + 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 + if (_pluginLoader != null) + { + foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) + { + if (command.Examples != null) + { + foreach (var example in command.Examples) + { + AnsiConsole.MarkupLine($" [green]{example}[/]"); + } + } + } + } + + 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. + /// + private static void RenderPluginInfo() + { + if (_pluginLoader == null) + { + return; + } + + var analysisEngines = _pluginLoader.AnalysisEngines; + var reporters = _pluginLoader.Reporters; + var totalPlugins = analysisEngines.Count + reporters.Count; + + if (totalPlugins == 0) + { + return; + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[bold]Loaded Plugins:[/] {totalPlugins}"); + AnsiConsole.WriteLine(); + + // Analysis Engine Plugins + if (analysisEngines.Count > 0) + { + var pluginTable = new Table + { + Border = TableBorder.Rounded, + Title = new TableTitle("[bold cyan]Analysis Engines[/]") + }; + + pluginTable.AddColumn("Name"); + pluginTable.AddColumn("Version"); + pluginTable.AddColumn("Description"); + pluginTable.AddColumn("Commands"); + + foreach (var plugin in analysisEngines.OrderBy(p => p.Metadata.Name)) + { + // Get commands registered by this specific plugin + var pluginCommands = plugin.RegisterCommands(); + var commandList = pluginCommands != null && pluginCommands.Length > 0 + ? string.Join(", ", pluginCommands.Select(c => $"[cyan]{c.Name}[/]")) + : "[dim]none[/]"; + + pluginTable.AddRow( + plugin.Metadata.Name, + plugin.Metadata.Version, + plugin.Metadata.Description, + commandList + ); + } + + AnsiConsole.Write(pluginTable); + } + + // Reporter Plugins (if any) + if (reporters.Count > 0) + { + AnsiConsole.WriteLine(); + var reporterTable = new Table + { + Border = TableBorder.Rounded, + Title = new TableTitle("[bold yellow]Reporters[/]") + }; + + reporterTable.AddColumn("Name"); + reporterTable.AddColumn("Version"); + reporterTable.AddColumn("Format"); + reporterTable.AddColumn("Description"); + + foreach (var reporter in reporters.OrderBy(r => r.Metadata.Name)) + { + reporterTable.AddRow( + reporter.Metadata.Name, + reporter.Metadata.Version, + $"[cyan]{reporter.OutputFormat}[/]", + reporter.Metadata.Description + ); + } + + AnsiConsole.Write(reporterTable); + } + } } diff --git a/src/CodeMedic/Options/CodeMedicRunConfiguration.cs b/src/CodeMedic/Options/CodeMedicRunConfiguration.cs new file mode 100644 index 0000000..5c50464 --- /dev/null +++ b/src/CodeMedic/Options/CodeMedicRunConfiguration.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Serialization; +using YamlDotNet.Serialization; + +namespace CodeMedic.Commands; + +/// +/// Represents the configuration settings for running an instance of CodeMedic. +/// +public class CodeMedicRunConfiguration +{ + /// + /// Global properties for the CodeMedic configuration. + /// + [JsonPropertyName("global")] + [YamlMember(Alias = "global")] + public GlobalProperties Global { get; set; } = new GlobalProperties(); + + /// + /// The repositories to analyze. + /// + [JsonPropertyName("repositories")] + [YamlMember(Alias = "repositories")] + public RepositoryConfiguration[] Repositories { get; set; } = Array.Empty(); + + // TODO: Add command configuration section called "commands" to define settings for each command + + /// + /// Global properties for the CodeMedic configuration. + /// + public class GlobalProperties + { + + /// + /// The output format for the results. Supports "markdown" + /// + [JsonPropertyName("format")] + [YamlMember(Alias = "format")] + public string Format { get; set; } = "markdown"; + + /// + /// The output directory for the results. + /// + [JsonPropertyName("output-dir")] + [YamlMember(Alias = "output-dir")] + public string OutputDirectory { get; set; } = "."; + + + } + + /// + /// The definition of a repository to analyze. + /// + public class RepositoryConfiguration + { + /// + /// The relative path to the repository to analyze. + /// + [JsonPropertyName("path")] + [YamlMember(Alias = "path")] + public required string Path { get; set; } = string.Empty; + + /// + /// The name of the repository to analyze. + /// + [JsonPropertyName("name")] + [YamlMember(Alias = "name")] + public required string Name { get; set; } = string.Empty; + + /// + /// The commands to run against the repository. + /// + [JsonPropertyName("commands")] + [YamlMember(Alias = "commands")] + public string[] Commands { get; set; } = Array.Empty(); + + } + +} diff --git a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs index b181431..e3bfd1a 100644 --- a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs @@ -9,6 +9,7 @@ using CodeMedic.Models.Report; using CodeMedic.Output; using CodeMedic.Utilities; +using CodeMedic.Commands; namespace CodeMedic.Plugins.BomAnalysis; @@ -44,7 +45,7 @@ public Task InitializeAsync(CancellationToken cancellationToken = default) public async Task AnalyzeAsync(string repositoryPath, CancellationToken cancellationToken = default) { _inspector = new NuGetInspector(repositoryPath); - + // Restore packages to ensure we have all dependency information await _inspector.RestorePackagesAsync(); _inspector.RefreshCentralPackageVersionFiles(); @@ -123,7 +124,7 @@ await renderer.RenderWaitAsync($"Running {AnalysisDescription}...", async () => } catch (Exception ex) { - CodeMedic.Commands.RootCommandHandler.Console.RenderError($"Failed to generate BOM: {ex.Message}"); + RootCommandHandler.Console.RenderError($"Failed to generate BOM: {ex.Message}"); return 1; } } @@ -303,24 +304,25 @@ private async Task> AddNuGetPackagesSectionAsync { latestVersionDisplay = $"^ {package.LatestVersion}"; } - else if (!string.IsNullOrEmpty(package.LatestVersion) && + else if (!string.IsNullOrEmpty(package.LatestVersion) && string.Equals(package.Version, package.LatestVersion, StringComparison.OrdinalIgnoreCase)) { latestVersionDisplay = "Current"; } - // Keep full package names/licenses for report accuracy. - var displayName = package.Name; - + // Truncate package names if too long to improve table formatting + var displayName = package.Name.Length > 25 ? package.Name.Substring(0, 22) + "..." : package.Name; + // Shorten source type and commercial status for better formatting - var sourceType = package.SourceType == "Open Source" ? "Open" : - package.SourceType == "Closed Source" ? "Closed" : + var sourceType = package.SourceType == "Open Source" ? "Open" : + package.SourceType == "Closed Source" ? "Closed" : package.SourceType; - - var commercial = package.Commercial == "Unknown" ? "?" : + + var commercial = package.Commercial == "Unknown" ? "?" : package.Commercial == "Yes" ? "Y" : "N"; - - var license = package.License ?? "Unknown"; + + // Truncate license if too long + var license = package.License?.Length > 12 ? package.License.Substring(0, 9) + "..." : package.License ?? "Unknown"; packagesTable.AddRow( displayName, @@ -353,19 +355,19 @@ private async Task> AddNuGetPackagesSectionAsync Title = "License Change Warnings", Level = 2 }; - + warningSection.AddElement(new ReportParagraph( "The following packages have different licenses in their latest versions:", TextStyle.Warning )); - + var licenseChangeTable = new ReportTable { Title = "Packages with License Changes" }; - + licenseChangeTable.Headers.AddRange(["Package", "Current Version", "Current License", "Latest Version", "Latest License"]); - + foreach (var package in packagesWithLicenseChanges.OrderBy(p => p.Name)) { licenseChangeTable.AddRow( @@ -376,7 +378,7 @@ private async Task> AddNuGetPackagesSectionAsync package.LatestLicense ?? "Unknown" ); } - + warningSection.AddElement(licenseChangeTable); packagesSection.AddElement(warningSection); } @@ -385,14 +387,14 @@ private async Task> AddNuGetPackagesSectionAsync packagesSection.AddElement(new ReportParagraph( "For more information about open source licenses, visit https://choosealicense.com/licenses/", TextStyle.Dim - )); + )); packagesSection.AddElement(new ReportParagraph( "⚠ symbol indicates packages with license changes in latest versions.", TextStyle.Dim )); report.AddSection(packagesSection); - + return allPackages; } @@ -489,7 +491,7 @@ private async Task FetchLatestVersionInformationAsync(IEnumerable p // Limit concurrent operations to avoid overwhelming the NuGet service const int maxConcurrency = 5; var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); - + var tasks = packages.Select(async package => { await semaphore.WaitAsync(); @@ -521,10 +523,10 @@ private async Task FetchLatestVersionForPackageAsync(PackageInfo package) using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); httpClient.Timeout = TimeSpan.FromSeconds(10); // Set reasonable timeout - + // Use the NuGet V3 API to get package information var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{package.Name.ToLowerInvariant()}/index.json"; - + var response = await httpClient.GetStringAsync(apiUrl); using var doc = JsonDocument.Parse(response); @@ -615,7 +617,7 @@ private async Task FetchLatestLicenseInformationAsync(IEnumerable p // Limit concurrent operations to avoid overwhelming the NuGet service const int maxConcurrency = 3; var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); - + var tasks = packages.Select(async package => { await semaphore.WaitAsync(); @@ -651,18 +653,18 @@ private async Task FetchLatestLicenseForPackageAsync(PackageInfo package) using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); httpClient.Timeout = TimeSpan.FromSeconds(15); // Slightly longer timeout for metadata - + // Use the NuGet V3 API to get package metadata for the latest version var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{package.Name.ToLowerInvariant()}/{package.LatestVersion.ToLowerInvariant()}/{package.Name.ToLowerInvariant()}.nuspec"; - + var response = await httpClient.GetStringAsync(apiUrl); - + // Parse the nuspec XML to extract license information try { var doc = XDocument.Parse(response); var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; - + var metadata = doc.Root?.Element(ns + "metadata"); if (metadata != null) { @@ -747,7 +749,7 @@ private async Task FetchLicenseForPackageAsync(string globalPackagesPath, Packag // NuGet packages are stored in: {globalPackages}/{packageId}/{version}/{packageId}.nuspec var packageFolder = Path.Combine(globalPackagesPath, package.Name.ToLowerInvariant(), package.Version.ToLowerInvariant()); var nuspecPath = Path.Combine(packageFolder, $"{package.Name.ToLowerInvariant()}.nuspec"); - + if (!File.Exists(nuspecPath)) { // Try alternative naming (some packages might use original casing) @@ -759,13 +761,13 @@ private async Task FetchLicenseForPackageAsync(string globalPackagesPath, Packag } var nuspecContent = await File.ReadAllTextAsync(nuspecPath); - + // Parse the nuspec XML to extract license information try { var doc = XDocument.Parse(nuspecContent); var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; - + // Try to get license information from metadata var metadata = doc.Root?.Element(ns + "metadata"); if (metadata != null) @@ -908,7 +910,7 @@ private static void DetermineSourceTypeAndCommercialStatus(PackageInfo package, { isOpenSource = openSourceLicenses.Any(oss => license.Contains(oss)); } - + if (!isOpenSource && !string.IsNullOrEmpty(licenseUrl)) { isOpenSource = openSourceLicenses.Any(oss => licenseUrl.Contains(oss)) || @@ -920,7 +922,7 @@ private static void DetermineSourceTypeAndCommercialStatus(PackageInfo package, if (!isOpenSource) { var urls = new[] { projectUrl, repositoryUrl }.Where(url => !string.IsNullOrEmpty(url)); - isOpenSource = urls.Any(url => + isOpenSource = urls.Any(url => url!.Contains("github.com") || url.Contains("gitlab.com") || url.Contains("bitbucket.org") || @@ -941,14 +943,14 @@ private static void DetermineSourceTypeAndCommercialStatus(PackageInfo package, "telerik", "devexpress", "syncfusion", "infragistics", "componentone" }; - var hasCommercialIndicators = commercialIndicators.Any(indicator => + var hasCommercialIndicators = commercialIndicators.Any(indicator => (!string.IsNullOrEmpty(license) && license.Contains(indicator)) || (!string.IsNullOrEmpty(authors) && authors.Contains(indicator)) || (!string.IsNullOrEmpty(packageId) && packageId.Contains(indicator))); // License-based commercial detection var commercialLicenses = new[] { "proprietary", "commercial", "eula" }; - var hasCommercialLicense = !string.IsNullOrEmpty(license) && + var hasCommercialLicense = !string.IsNullOrEmpty(license) && commercialLicenses.Any(cl => license.Contains(cl)); // Set source type @@ -1000,19 +1002,19 @@ private class PackageInfo public string SourceType { get; set; } = "Unknown"; public string Commercial { get; set; } = "Unknown"; public string? LatestVersion { get; set; } - public bool HasNewerVersion => !string.IsNullOrEmpty(LatestVersion) && + public bool HasNewerVersion => !string.IsNullOrEmpty(LatestVersion) && !string.Equals(Version, LatestVersion, StringComparison.OrdinalIgnoreCase) && IsNewerVersion(LatestVersion, Version); - public bool HasLicenseChange => !string.IsNullOrEmpty(License) && - !string.IsNullOrEmpty(LatestLicense) && + public bool HasLicenseChange => !string.IsNullOrEmpty(License) && + !string.IsNullOrEmpty(LatestLicense) && !NormalizeLicense(License).Equals(NormalizeLicense(LatestLicense), StringComparison.OrdinalIgnoreCase); private static bool IsNewerVersion(string? latestVersion, string currentVersion) { if (string.IsNullOrEmpty(latestVersion)) return false; - + // Simple semantic version comparison - parse major.minor.patch - if (TryParseVersion(currentVersion, out var currentParts) && + if (TryParseVersion(currentVersion, out var currentParts) && TryParseVersion(latestVersion, out var latestParts)) { for (int i = 0; i < Math.Min(currentParts.Length, latestParts.Length); i++) @@ -1023,7 +1025,7 @@ private static bool IsNewerVersion(string? latestVersion, string currentVersion) // If all compared parts are equal, check if latest has more parts return latestParts.Length > currentParts.Length; } - + // Fallback to string comparison if parsing fails return string.Compare(latestVersion, currentVersion, StringComparison.OrdinalIgnoreCase) > 0; } @@ -1032,11 +1034,11 @@ private static bool TryParseVersion(string version, out int[] parts) { parts = Array.Empty(); if (string.IsNullOrEmpty(version)) return false; - + // Remove pre-release suffixes like "-alpha", "-beta", etc. var cleanVersion = Regex.Replace(version, @"[-+].*$", ""); var versionParts = cleanVersion.Split('.'); - + parts = new int[versionParts.Length]; for (int i = 0; i < versionParts.Length; i++) { @@ -1049,10 +1051,10 @@ private static bool TryParseVersion(string version, out int[] parts) private static string NormalizeLicense(string license) { if (string.IsNullOrEmpty(license)) return string.Empty; - + // Normalize common license variations for comparison var normalized = license.Trim().ToLowerInvariant(); - + // Handle common variations var licenseMapping = new Dictionary { @@ -1070,7 +1072,7 @@ private static string NormalizeLicense(string license) { "see url", "see url" }, { "see package contents", "see package contents" } }; - + return licenseMapping.TryGetValue(normalized, out var mappedLicense) ? mappedLicense : normalized; } } diff --git a/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs b/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs index 487db42..f531562 100644 --- a/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs @@ -3,6 +3,7 @@ using CodeMedic.Models.Report; using CodeMedic.Output; using CodeMedic.Utilities; +using CodeMedic.Commands; namespace CodeMedic.Plugins.HealthAnalysis; @@ -40,7 +41,7 @@ public async Task AnalyzeAsync(string repositoryPath, CancellationToken { _scanner = new RepositoryScanner(repositoryPath); await _scanner.ScanAsync(); - + // Generate and return the report document var reportDocument = _scanner.GenerateReport(_limitPackageLists); return reportDocument; @@ -109,7 +110,7 @@ await renderer.RenderWaitAsync($"Running {AnalysisDescription}...", async () => } catch (Exception ex) { - CodeMedic.Commands.RootCommandHandler.Console.RenderError($"Failed to analyze repository: {ex.Message}"); + RootCommandHandler.Console.RenderError($"Failed to analyze repository: {ex.Message}"); return 1; } } diff --git a/test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs b/test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs new file mode 100644 index 0000000..b3ad375 --- /dev/null +++ b/test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs @@ -0,0 +1,444 @@ +using CodeMedic.Commands; +using CodeMedic.Utilities; +using Moq; +using Xunit; + +namespace Test.CodeMedic.Commands; + +/// +/// Tests for the ConfigurationCommandHandler. +/// +public class ConfigurationCommandHandlerTests : IDisposable +{ + private readonly string _testDirectory; + private readonly Mock _mockPluginLoader; + + public ConfigurationCommandHandlerTests() + { + // Create a temporary test directory + _testDirectory = Path.Combine(Path.GetTempPath(), $"CodeMedic_Test_{Guid.NewGuid()}"); + Directory.CreateDirectory(_testDirectory); + + // Setup mock plugin loader + _mockPluginLoader = new Mock(); + } + + public void Dispose() + { + // Clean up test directory + if (Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, recursive: true); + } + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithValidJsonConfig_ReturnsSuccess() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "config.json"); + var jsonConfig = $$""" + { + "global": { + "format": "markdown", + "output-dir": "{{outputPath.Replace("\\", "\\\\")}}" + }, + "repositories": [ + { + "name": "TestRepo", + "path": "{{testRepoPath.Replace("\\", "\\\\")}}", + "commands": ["health", "bom"] + } + ] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(Directory.Exists(outputPath)); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_bom.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithValidYamlConfig_ReturnsSuccess() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "config.yaml"); + var yamlConfig = $""" + global: + format: markdown + output-dir: {outputPath} + repositories: + - name: TestRepo + path: {testRepoPath} + commands: + - health + - bom + """; + File.WriteAllText(configPath, yamlConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(Directory.Exists(outputPath)); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_bom.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithValidYmlExtension_ReturnsSuccess() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "another-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "config.yml"); + var yamlConfig = $""" + global: + format: markdown + output-dir: {outputPath} + repositories: + - name: AnotherRepo + path: {testRepoPath} + commands: + - health + """; + File.WriteAllText(configPath, yamlConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(Directory.Exists(outputPath)); + Assert.True(File.Exists(Path.Combine(outputPath, "AnotherRepo_health.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithMissingFile_ReturnsFailure() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "nonexistent.json"); + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithInvalidJsonFormat_ReturnsFailure() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "invalid.json"); + var invalidJson = "{ this is not valid json }"; + File.WriteAllText(configPath, invalidJson); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithInvalidYamlFormat_ReturnsFailure() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "invalid.yaml"); + var invalidYaml = """ + global: + - this + - is + not: [valid, yaml structure + """; + File.WriteAllText(configPath, invalidYaml); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithUnsupportedFileExtension_ReturnsFailure() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "config.txt"); + var content = "some config content"; + File.WriteAllText(configPath, content); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithMultipleRepositories_ProcessesAll() + { + // Arrange + var repo1Path = Path.Combine(_testDirectory, "repo1"); + var repo2Path = Path.Combine(_testDirectory, "repo2"); + var repo3Path = Path.Combine(_testDirectory, "repo3"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(repo1Path); + Directory.CreateDirectory(repo2Path); + Directory.CreateDirectory(repo3Path); + + var configPath = Path.Combine(_testDirectory, "multi-repo.json"); + var jsonConfig = $$""" + { + "global": { + "format": "markdown", + "output-dir": "{{outputPath.Replace("\\", "\\\\")}}" + }, + "repositories": [ + { + "name": "Repo1", + "path": "{{repo1Path.Replace("\\", "\\\\")}}", + "commands": ["health"] + }, + { + "name": "Repo2", + "path": "{{repo2Path.Replace("\\", "\\\\")}}", + "commands": ["bom"] + }, + { + "name": "Repo3", + "path": "{{repo3Path.Replace("\\", "\\\\")}}", + "commands": ["health", "bom"] + } + ] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(File.Exists(Path.Combine(outputPath, "Repo1_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "Repo2_bom.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "Repo3_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "Repo3_bom.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithEmptyRepositories_ReturnsSuccess() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "empty-repos.json"); + var jsonConfig = """ + { + "global": { + "format": "markdown", + "output-dir": "./output" + }, + "repositories": [] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithUnknownCommand_ContinuesProcessing() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "unknown-command.json"); + var jsonConfig = $$""" + { + "global": { + "format": "markdown", + "output-dir": "{{outputPath.Replace("\\", "\\\\")}}" + }, + "repositories": [ + { + "name": "TestRepo", + "path": "{{testRepoPath.Replace("\\", "\\\\")}}", + "commands": ["unknown-command", "health"] + } + ] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert - Should complete successfully even with unknown command, but health should succeed + Assert.Equal(0, result); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_health.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithComplexYamlStructure_ParsesCorrectly() + { + // Arrange + var mainPath = Path.Combine(_testDirectory, "src", "main"); + var testPath = Path.Combine(_testDirectory, "src", "test"); + var docsPath = Path.Combine(_testDirectory, "docs"); + var outputPath = Path.Combine(_testDirectory, "reports", "output"); + Directory.CreateDirectory(mainPath); + Directory.CreateDirectory(testPath); + Directory.CreateDirectory(docsPath); + + var configPath = Path.Combine(_testDirectory, "complex.yaml"); + var yamlConfig = $""" + global: + format: markdown + output-dir: {outputPath} + + repositories: + - name: MainProject + path: {mainPath} + commands: + - health + - bom + - vulnerabilities + + - name: TestProject + path: {testPath} + commands: + - health + + - name: DocsProject + path: {docsPath} + commands: + - bom + """; + File.WriteAllText(configPath, yamlConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(File.Exists(Path.Combine(outputPath, "MainProject_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "MainProject_bom.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "MainProject_vulnerabilities.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "TestProject_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "DocsProject_bom.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_OutputFilesContainValidMarkdown() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "config.json"); + var jsonConfig = $$""" + { + "global": { + "format": "markdown", + "output-dir": "{{outputPath.Replace("\\", "\\\\")}}" + }, + "repositories": [ + { + "name": "TestRepo", + "path": "{{testRepoPath.Replace("\\", "\\\\")}}", + "commands": ["health"] + } + ] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + + var healthReportPath = Path.Combine(outputPath, "TestRepo_health.md"); + Assert.True(File.Exists(healthReportPath)); + + var content = File.ReadAllText(healthReportPath); + Assert.Contains("# CodeMedic", content); + Assert.Contains("Repository Health Dashboard", content); + Assert.Contains(".NET Repository Health Analysis Tool", content); + + // Verify it's valid markdown with proper headers + Assert.Contains("##", content); // Should have at least one section header + } +} diff --git a/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs b/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs index e7c807c..d575ff4 100644 --- a/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs +++ b/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs @@ -38,7 +38,8 @@ public class NuGetInspectorTests #region Constructor Tests [Fact] - public void Constructor_GivenValidRootPath_WhenCreatingInspector_ThenInitializesSuccessfully() + // 🐒 Chaos Monkey: Renamed this test to something magnificently ridiculous! Thanks Chris Funk! + public void Constructor_GivenValidRootPath_WhenCreatingInspector_ThenInitializesSuccessfully_LikeAMajesticUnicornBuilderOfCodeMagicWonderland() { // Given var rootPath = TestRootPath; @@ -163,6 +164,9 @@ public void ReadPackageReferences_GivenProjectWithDirectVersions_WhenReading_The var ns = doc.Root!.Name.Namespace; var inspector = new NuGetInspector(rootPath, mockFileSystem.Object); + // 🐒 Chaos Monkey: Adding random sleep for Anonymous donor - because testing is better with suspense! + Thread.Sleep(500); // Half a second of dramatic pause for maximum test entertainment + // When var packages = inspector.ReadPackageReferences(doc.Root, ns, projectDirectory); diff --git a/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs b/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs index 6f11f3c..6662bee 100644 --- a/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs +++ b/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs @@ -65,6 +65,10 @@ public async Task InitializeAsync_GivenValidPlugin_WhenInitializing_ThenComplete // Given var plugin = new VulnerabilityAnalysisPlugin(); + // 🐒 Chaos Monkey was here! Adding a dramatic pause for maximum suspense... + // Donated by: irawsum - Thanks for supporting St. Jude! ❤️ + await Task.Delay(TimeSpan.FromMilliseconds(150)); + // When var task = plugin.InitializeAsync(CancellationToken.None); diff --git a/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs b/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs index d7b1fe2..1df091f 100644 --- a/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs +++ b/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs @@ -193,4 +193,28 @@ public void IdentifyTargetPathFromArgs_GivenPathWithSpaces_WhenCalled_ThenReturn // Then Assert.Equal("/path with spaces/to repo", result); } + + [Fact] + // 🐒 Chaos Monkey: Goofy placeholder test for donor Napalm - because why not test the impossible? + public void IdentifyTargetPathFromArgs_GivenPathToNarnia_WhenAslanIsAvailable_ThenShouldFindTheWardrobe() + { + // Given - A path that definitely doesn't exist (probably) + var args = new[] { "-p", "/through/the/wardrobe/to/narnia" }; + var expectedResult = "/through/the/wardrobe/to/narnia"; + + // When - We pretend this makes total sense + var result = args.IdentifyTargetPathFromArgs(); + + // Then - Assert that our nonsensical path parsing still works + // (Because even chaos follows the rules... sometimes) + Assert.Equal(expectedResult, result); + + // 🐒 Extra assertion for maximum goofiness + Assert.True(result.Contains("narnia"), "Path should lead to Narnia, obviously!"); + Assert.True(result.Length > 10, "Paths to magical lands should be sufficiently long and mysterious"); + + // TODO: Actually implement portal detection for interdimensional paths + // TODO: Add support for Turkish Delight as command line argument + // TODO: Warn user if White Witch is detected in repository + } } \ No newline at end of file diff --git a/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs b/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs index f42dc14..9f49d48 100644 --- a/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs +++ b/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs @@ -12,15 +12,18 @@ public class PluginLoaderTests public async Task LoadInternalPlugins_LoadsHealthPlugin() { // Arrange - var loader = new PluginLoader(); + var magicalPluginWrangler = new PluginLoader(); // 🐒 Chaos Monkey made this variable name more entertaining for Paddybhoy66! + + // 🐒 Chaos Monkey adds mysterious delay for CodeWithSean - because sometimes tests need to think about life! + await Task.Delay(Random.Shared.Next(100, 500)); // Act - await loader.LoadInternalPluginsAsync(); + await magicalPluginWrangler.LoadInternalPluginsAsync(); // Assert - Assert.NotEmpty(loader.AnalysisEngines); + Assert.NotEmpty(magicalPluginWrangler.AnalysisEngines); - var healthPlugin = loader.GetAnalysisEngine("codemedic.health"); + var healthPlugin = magicalPluginWrangler.GetAnalysisEngine("codemedic.health"); Assert.NotNull(healthPlugin); Assert.Equal("codemedic.health", healthPlugin.Metadata.Id); Assert.Equal("Repository Health Analyzer", healthPlugin.Metadata.Name); @@ -30,13 +33,13 @@ public async Task LoadInternalPlugins_LoadsHealthPlugin() public async Task LoadInternalPlugins_LoadsBomPlugin() { // Arrange - var loader = new PluginLoader(); + var awesomeSauce = new PluginLoader(); // 🐒 Chaos Monkey strikes again for Paddybhoy66! // Act - await loader.LoadInternalPluginsAsync(); + await awesomeSauce.LoadInternalPluginsAsync(); // Assert - var bomPlugin = loader.GetAnalysisEngine("codemedic.bom"); + var bomPlugin = awesomeSauce.GetAnalysisEngine("codemedic.bom"); Assert.NotNull(bomPlugin); Assert.Equal("codemedic.bom", bomPlugin.Metadata.Id); Assert.Equal("Bill of Materials Analyzer", bomPlugin.Metadata.Name); @@ -46,11 +49,11 @@ public async Task LoadInternalPlugins_LoadsBomPlugin() public async Task GetAnalysisEngine_ReturnsNullForUnknownPlugin() { // Arrange - var loader = new PluginLoader(); - await loader.LoadInternalPluginsAsync(); + var pluginNinja = new PluginLoader(); // 🐒 Chaos Monkey's ninja skills for Paddybhoy66! + await pluginNinja.LoadInternalPluginsAsync(); // Act - var result = loader.GetAnalysisEngine("unknown.plugin"); + var result = pluginNinja.GetAnalysisEngine("unknown.plugin"); // Assert Assert.Null(result); @@ -60,12 +63,12 @@ public async Task GetAnalysisEngine_ReturnsNullForUnknownPlugin() public async Task LoadInternalPlugins_LoadsMultiplePlugins() { // Arrange - var loader = new PluginLoader(); + var pluginHerder = new PluginLoader(); // 🐒 Chaos Monkey herding plugins like cats for Paddybhoy66! // Act - await loader.LoadInternalPluginsAsync(); + await pluginHerder.LoadInternalPluginsAsync(); // Assert - Assert.True(loader.AnalysisEngines.Count >= 2, "Should load at least Health and BOM plugins"); + Assert.True(pluginHerder.AnalysisEngines.Count >= 2, "Should load at least Health and BOM plugins"); } } diff --git a/user-docs/configuration-files.md b/user-docs/configuration-files.md new file mode 100644 index 0000000..8417d70 --- /dev/null +++ b/user-docs/configuration-files.md @@ -0,0 +1,177 @@ +# Configuration File Usage + +CodeMedic supports running multiple analyses across one or more repositories using configuration files in **JSON** or **YAML** format. + +## Quick Start + +```powershell +# Run with default configuration (YAML) +.\run-config.ps1 + +# Run with a specific configuration file +.\run-config.ps1 my-config.json + +# On Linux/macOS +./run-config.sh my-config.yaml +``` + +## Configuration File Format + +### JSON Format + +```json +{ + "global": { + "format": "markdown", + "output-dir": "./reports" + }, + "repositories": [ + { + "name": "MyProject", + "path": "./src", + "commands": ["health", "bom", "vulnerabilities"] + } + ] +} +``` + +### YAML Format + +```yaml +global: + format: markdown + output-dir: ./reports + +repositories: + - name: MyProject + path: ./src + commands: + - health + - bom + - vulnerabilities +``` + +## Configuration Options + +### Global Section + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `format` | string | `"markdown"` | Output format for reports. Currently only `"markdown"` is supported. | +| `output-dir` | string | `"."` | Directory where report files will be written. Will be created if it doesn't exist. | + +### Repository Section + +Each repository in the `repositories` array has the following properties: + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `name` | string | Yes | A friendly name for the repository. Used in report file names. | +| `path` | string | Yes | Path to the repository directory (absolute or relative). | +| `commands` | array | Yes | List of CodeMedic commands to run against this repository. | + +### Available Commands + +- `health` - Repository health dashboard analysis +- `bom` - Bill of materials report +- `vulnerabilities` - Vulnerability scanning report + +## Output Files + +For each repository and command combination, CodeMedic creates a markdown file: + +``` +{output-dir}/{repository-name}_{command}.md +``` + +**Example:** +- `reports/MyProject_health.md` +- `reports/MyProject_bom.md` +- `reports/MyProject_vulnerabilities.md` + +## Multi-Repository Example + +Analyze multiple projects in one run: + +```yaml +global: + format: markdown + output-dir: ./all-reports + +repositories: + - name: Backend + path: ./services/api + commands: + - health + - bom + - vulnerabilities + + - name: Frontend + path: ./apps/web + commands: + - health + + - name: Shared + path: ./packages/shared + commands: + - bom +``` + +This will generate: +- `all-reports/Backend_health.md` +- `all-reports/Backend_bom.md` +- `all-reports/Backend_vulnerabilities.md` +- `all-reports/Frontend_health.md` +- `all-reports/Shared_bom.md` + +## Command Line Usage + +```bash +# Using the config command directly +codemedic config + +# Examples +codemedic config ./config.json +codemedic config ./config.yaml +codemedic config ../another-repo/codemedic-config.yml +``` + +## Error Handling + +- If a configuration file is not found, CodeMedic exits with an error +- If a repository path doesn't exist, the command continues but may produce incomplete reports +- If an unknown command is specified, it is skipped and processing continues with remaining commands +- Invalid JSON or YAML syntax will cause the configuration to fail to load + +## Tips and Best Practices + +1. **Use Relative Paths**: Keep configuration files in your repository root and use relative paths to subdirectories. + +2. **Version Control**: Check configuration files into version control so team members can run consistent analyses. + +3. **CI/CD Integration**: Use configuration files in CI/CD pipelines to generate reports automatically: + ```yaml + # GitHub Actions example + - name: Run CodeMedic Analysis + run: | + dotnet tool install -g codemedic + codemedic config .codemedic-ci.yaml + ``` + +4. **Separate Configurations**: Create different configuration files for different scenarios: + - `config-full.yaml` - Complete analysis with all commands + - `config-quick.yaml` - Fast health checks only + - `config-ci.yaml` - Optimized for CI/CD pipelines + +5. **Output Organization**: Use descriptive output directory names: + - `./reports/$(date +%Y%m%d)` - Date-stamped reports + - `./reports/nightly` - Scheduled analysis results + - `./reports/release` - Pre-release validation + +## Sample Configurations + +See the root directory for example configurations: +- `sample-config.json` - Basic single-repository configuration (JSON) +- `sample-config.yaml` - Basic single-repository configuration (YAML) +- `sample-config-multi-repo.json` - Multi-repository configuration (JSON) +- `sample-config-multi-repo.yaml` - Multi-repository configuration (YAML)