diff --git a/README.md b/README.md index c954728..e1fa568 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,11 @@ NetContextServer empowers AI coding assistants like Cursor AI to deeply understa - ๐Ÿ” **Deep Dependency Visualization**: See transitive dependencies with interactive, color-coded graphs - ๐Ÿงฉ **Smart Grouping**: Visually group related packages for easier navigation - ๐Ÿ“Š **Update Recommendations**: Identify outdated packages and security issues +- ๐Ÿ“Š **Test Coverage Analysis**: Deep insights into your test coverage + - ๐ŸŽฏ **Multi-Format Support**: Parse coverage data from Coverlet, LCOV, and Cobertura XML + - ๐Ÿ“ˆ **Detailed Reports**: File-level coverage percentages and uncovered line tracking + - ๐Ÿ”„ **Branch Coverage**: Track method-level branch coverage where available + - ๐Ÿ’ก **Smart Recommendations**: Get suggestions for improving test coverage - โšก **Fast & Efficient**: Quick indexing and response times for large codebases ## ๐Ÿš€ Quick Start @@ -78,6 +83,10 @@ Now Cursor AI can understand your codebase! Try asking it questions like: - "What's the current base directory for file operations?" - "Help me think through the authentication system design" - "Document my reasoning about this architectural decision" +- "Analyze test coverage for MyService.cs" +- "Show me uncovered lines in the authentication module" +- "What's the overall test coverage percentage?" +- "Which files have the lowest test coverage?" ## ๐Ÿ“š Documentation @@ -95,6 +104,11 @@ Now Cursor AI can understand your codebase! Try asking it questions like: - ๐Ÿ“– **File Content Access**: Read source files with safety checks and size limits - ๐Ÿ›ก๏ธ **Security**: Built-in safeguards for sensitive files and directory access - ๐ŸŽฏ **Pattern Management**: Flexible ignore patterns for controlling file access +- ๐Ÿ“Š **Coverage Analysis**: Parse and analyze test coverage data + - ๐Ÿ“ˆ **Coverage Reports**: Support for Coverlet JSON, LCOV, and Cobertura XML formats + - ๐ŸŽฏ **Line Coverage**: Track which lines are covered by tests + - ๐ŸŒณ **Branch Coverage**: Monitor method-level branch coverage + - ๐Ÿ’ก **Recommendations**: Get actionable suggestions to improve coverage - ๐Ÿ’ญ **Structured Thinking**: Document and validate reasoning about complex operations - ๐Ÿงฉ **AI-Optimized Reasoning**: Based on [Anthropic's research](https://www.anthropic.com/engineering/claude-think-tool) on improving LLM problem-solving - ๐Ÿ“‹ **Task Planning**: Break down complex problems into manageable steps @@ -187,6 +201,34 @@ Project: MyProject.csproj โ””โ”€ Microsoft.Extensions.DependencyInjection.Abstractions ``` +6. **Analyze Test Coverage**: +```bash +# Set your base directory first +dotnet run --project src/NetContextClient/NetContextClient.csproj -- set-base-dir --directory "path/to/your/project" + +# Analyze coverage from a Coverlet JSON report +dotnet run --project src/NetContextClient/NetContextClient.csproj -- coverage-analysis --report-path "TestResults/coverage.json" + +# Get a coverage summary +dotnet run --project src/NetContextClient/NetContextClient.csproj -- coverage-summary --report-path "TestResults/coverage.json" +``` + +Example coverage analysis output: +```json +[ + { + "filePath": "src/MyProject/Services/UserService.cs", + "coveragePercentage": 85.3, + "uncoveredLines": [42, 43, 88], + "branchCoverage": { + "ValidateUser()": 75.0, + "GetUserById()": 100.0 + }, + "recommendation": "Consider adding tests for the user validation error paths" + } +] +``` + ### Search Commands 1. **Text Search**: diff --git a/docs/getting-started.md b/docs/getting-started.md index 69908d5..dc340ef 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -122,6 +122,57 @@ dotnet run --project src/NetContextClient/NetContextClient.csproj -- add-ignore- dotnet run --project src/NetContextClient/NetContextClient.csproj -- get-ignore-patterns ``` +## Using Coverage Analysis + +NetContextServer includes powerful test coverage analysis capabilities that help you understand and improve your test coverage. Here's how to get started: + +### 1. Generate Coverage Reports + +First, you'll need to generate a coverage report. NetContextServer supports multiple formats: + +**Using Coverlet (recommended):** +```bash +dotnet test --collect:"XPlat Code Coverage" +``` +This will generate a coverage report in the `TestResults` directory. + +**Using LCOV:** +If you're using LCOV, make sure your test runner is configured to output LCOV format (`.info` files). + +**Using Cobertura:** +For Cobertura XML format, configure your test runner to output `.cobertura.xml` files. + +### 2. Analyze Coverage + +Once you have a coverage report, you can analyze it using NetContextServer: + +```bash +# Analyze coverage for detailed per-file information +dotnet run --project src/NetContextClient/NetContextClient.csproj -- coverage-analysis --report-path "TestResults/coverage.json" + +# Get a summary of overall coverage +dotnet run --project src/NetContextClient/NetContextClient.csproj -- coverage-summary --report-path "TestResults/coverage.json" +``` + +### 3. Interpret Results + +The coverage analysis provides several key insights: + +- **Coverage Percentage**: The percentage of lines covered by tests +- **Uncovered Lines**: Specific line numbers that aren't covered by tests +- **Branch Coverage**: For methods with conditional logic, shows how many branches are covered +- **Recommendations**: Suggestions for improving coverage in specific areas + +### 4. Improve Coverage + +Use the analysis results to: +1. Identify files with low coverage +2. Focus on uncovered lines in critical code paths +3. Add tests for uncovered branches in complex methods +4. Track coverage trends over time + +For more details on coverage analysis commands and options, see the [Tool Reference](tool-reference.md#coverage-analysis-tools). + ## Troubleshooting ### Common Issues diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 8d1af87..2eb14fd 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -422,4 +422,59 @@ The server provides clear error messages for common scenarios: - Invalid patterns - File size limits exceeded - Restricted file types -- Missing environment variables for semantic search \ No newline at end of file +- Missing environment variables for semantic search + +## Coverage Analysis Tools + +### `coverage-analysis` + +Analyzes test coverage data from various formats and provides detailed insights. + +```bash +dotnet run --project src/NetContextClient/NetContextClient.csproj -- coverage-analysis --report-path [--format ] +``` + +**Parameters:** +- `--report-path`: Path to the coverage report file +- `--format` (optional): Coverage report format. Supported values: + - `coverlet-json` (default): Coverlet JSON format + - `lcov`: LCOV format + - `cobertura`: Cobertura XML format + +**Output:** +Returns a list of coverage reports for each file, including: +- File path +- Coverage percentage +- List of uncovered lines +- Branch coverage data (where available) +- Recommendations for improving coverage + +**Example:** +```bash +dotnet run --project src/NetContextClient/NetContextClient.csproj -- coverage-analysis --report-path "TestResults/coverage.json" +``` + +### `coverage-summary` + +Generates a summary of test coverage across all files. + +```bash +dotnet run --project src/NetContextClient/NetContextClient.csproj -- coverage-summary --report-path [--format ] +``` + +**Parameters:** +- `--report-path`: Path to the coverage report file +- `--format` (optional): Coverage report format (same as coverage-analysis) + +**Output:** +Returns a summary object containing: +- Total number of files +- Overall coverage percentage +- Total number of uncovered lines +- List of files with coverage below threshold +- List of files with lowest coverage + +**Example:** +```bash +dotnet run --project src/NetContextClient/NetContextClient.csproj -- coverage-summary --report-path "TestResults/coverage.json" +``` \ No newline at end of file diff --git a/src/NetContextClient/Models/CoverageReport.cs b/src/NetContextClient/Models/CoverageReport.cs new file mode 100644 index 0000000..3d8507d --- /dev/null +++ b/src/NetContextClient/Models/CoverageReport.cs @@ -0,0 +1,73 @@ +namespace NetContextClient.Models; + +/// +/// Represents a code coverage report for a single file or class. +/// +public class CoverageReport +{ + /// + /// Gets or sets the path to the source file, relative to the project root. + /// + public string FilePath { get; set; } = string.Empty; + + /// + /// Gets or sets the overall coverage percentage for the file (0-100). + /// + public float CoveragePercentage { get; set; } + + /// + /// Gets or sets the list of line numbers that are not covered by tests. + /// + public List UncoveredLines { get; set; } = []; + + /// + /// Gets or sets the total number of executable lines in the file. + /// + public int TotalLines { get; set; } + + /// + /// Gets or sets the branch coverage information, mapping method names to their coverage percentage. + /// + public Dictionary BranchCoverage { get; set; } = []; + + /// + /// Gets or sets the list of test files that provide coverage for this file. + /// + public List TestFiles { get; set; } = []; + + /// + /// Gets or sets a suggested action to improve coverage, if applicable. + /// + public string? Recommendation { get; set; } + + /// + /// The type of file being analyzed (Production, Test, Generated, or Unknown) + /// + public CoverageFileType FileType { get; set; } = CoverageFileType.Unknown; +} + +/// +/// Represents the type of file being analyzed for code coverage +/// +public enum CoverageFileType +{ + /// + /// Production code file + /// + Production, + + /// + /// Test code file + /// + Test, + + /// + /// Generated code file + /// + Generated, + + /// + /// Unknown file type + /// + Unknown +} diff --git a/src/NetContextClient/Models/CoverageSummary.cs b/src/NetContextClient/Models/CoverageSummary.cs new file mode 100644 index 0000000..4108d69 --- /dev/null +++ b/src/NetContextClient/Models/CoverageSummary.cs @@ -0,0 +1,52 @@ +namespace NetContextClient.Models; + +/// +/// Represents a summary of code coverage across multiple files. +/// +public class CoverageSummary +{ + /// + /// Gets or sets the overall coverage percentage across all files. + /// + public float TotalCoveragePercentage { get; set; } + + /// + /// Gets or sets the total number of files analyzed. + /// + public int TotalFiles { get; set; } + + /// + /// Gets or sets the number of files with coverage below a warning threshold. + /// + public int FilesWithLowCoverage { get; set; } + + /// + /// Gets or sets the total number of uncovered lines across all files. + /// + public int TotalUncoveredLines { get; set; } + + /// + /// Gets or sets a list of files with the lowest coverage percentages. + /// + public List LowestCoverageFiles { get; set; } = []; + + /// + /// Number of production files analyzed + /// + public int ProductionFiles { get; set; } + + /// + /// Number of test files analyzed + /// + public int TestFiles { get; set; } + + /// + /// Average coverage percentage for production files + /// + public float ProductionCoveragePercentage { get; set; } + + /// + /// Average coverage percentage for test files + /// + public float TestCoveragePercentage { get; set; } +} diff --git a/src/NetContextClient/Program.cs b/src/NetContextClient/Program.cs index 95bcc2c..490f278 100644 --- a/src/NetContextClient/Program.cs +++ b/src/NetContextClient/Program.cs @@ -5,8 +5,6 @@ using System.CommandLine; using System.Text.Encodings.Web; using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Unicode; /// /// Command-line interface for the .NET Context Client, which interacts with the MCP server @@ -793,6 +791,138 @@ static async Task Main(string[] args) } }, thoughtOption); + // Coverage Analysis command + var coverageAnalysisCommand = new Command("coverage-analysis", "Analyze a code coverage report file"); + var reportPathOption = new Option("--report-path", "Path to the coverage report file") { IsRequired = true }; + var formatOption = new Option("--format", "Coverage format (coverlet, lcov, cobertura)") { IsRequired = false }; + coverageAnalysisCommand.AddOption(reportPathOption); + coverageAnalysisCommand.AddOption(formatOption); + coverageAnalysisCommand.SetHandler(async (string reportPath, string? format) => + { + try + { + var args = new Dictionary { ["reportPath"] = reportPath }; + if (!string.IsNullOrEmpty(format)) + { + args["coverageFormat"] = format; + } + + var result = await client.CallToolAsync("coverage_analysis", args); + var jsonText = result.Content.First(c => c.Type == "text").Text; + if (jsonText != null) + { + var reports = JsonSerializer.Deserialize>(jsonText, DefaultJsonOptions); + if (reports == null || reports.Count == 0) + { + await Console.Out.WriteLineAsync("No coverage data found."); + return; + } + + await Console.Out.WriteLineAsync($"Found coverage data for {reports.Count} files:\n"); + foreach (var report in reports) + { + // Add file type indicator emoji + string typeIndicator = report.FileType switch + { + CoverageFileType.Production => "๐Ÿ“„", + CoverageFileType.Test => "๐Ÿงช", + CoverageFileType.Generated => "โš™๏ธ", + _ => "โ“" + }; + + await Console.Out.WriteLineAsync($"File: {typeIndicator} {report.FilePath}"); + await Console.Out.WriteLineAsync($"Coverage: {report.CoveragePercentage:F1}%"); + + if (report.UncoveredLines.Count > 0) + { + await Console.Out.WriteLineAsync($"Uncovered Lines: {string.Join(", ", report.UncoveredLines)}"); + } + + if (report.BranchCoverage.Count > 0) + { + await Console.Out.WriteLineAsync("Branch Coverage:"); + foreach (KeyValuePair pair in report.BranchCoverage) + { + await Console.Out.WriteLineAsync($" {pair.Key}: {pair.Value:F1}%"); + } + } + + if (!string.IsNullOrEmpty(report.Recommendation)) + { + await Console.Out.WriteLineAsync($"\nRecommendation: {report.Recommendation}"); + } + + await Console.Out.WriteLineAsync(new string('-', 80)); + await Console.Out.WriteLineAsync(); + } + } + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Error: {ex.Message}"); + Environment.Exit(1); + } + }, reportPathOption, formatOption); + + // Coverage Summary command + var coverageSummaryCommand = new Command("coverage-summary", "Get a high-level summary of code coverage"); + coverageSummaryCommand.AddOption(reportPathOption); + coverageSummaryCommand.AddOption(formatOption); + coverageSummaryCommand.SetHandler(async (string reportPath, string? format) => + { + try + { + var args = new Dictionary { ["reportPath"] = reportPath }; + if (!string.IsNullOrEmpty(format)) + { + args["coverageFormat"] = format; + } + + var result = await client.CallToolAsync("coverage_summary", args); + var jsonText = result.Content.First(c => c.Type == "text").Text; + if (jsonText != null) + { + var summary = JsonSerializer.Deserialize(jsonText, DefaultJsonOptions); + if (summary == null) + { + await Console.Out.WriteLineAsync("No coverage data found."); + return; + } + + await Console.Out.WriteLineAsync($"Coverage Summary:"); + await Console.Out.WriteLineAsync($"Total Files: {summary.TotalFiles}"); + await Console.Out.WriteLineAsync($"Overall Coverage: {summary.TotalCoveragePercentage:F1}%"); + await Console.Out.WriteLineAsync($"Files with Low Coverage: {summary.FilesWithLowCoverage}"); + await Console.Out.WriteLineAsync($"Total Uncovered Lines: {summary.TotalUncoveredLines}"); + + await Console.Out.WriteLineAsync("\nFile Type Statistics:"); + await Console.Out.WriteLineAsync($"๐Ÿ“„ Production Files: {summary.ProductionFiles} (Coverage: {summary.ProductionCoveragePercentage:F1}%)"); + await Console.Out.WriteLineAsync($"๐Ÿงช Test Files: {summary.TestFiles} (Coverage: {summary.TestCoveragePercentage:F1}%)"); + + if (summary.LowestCoverageFiles.Count > 0) + { + await Console.Out.WriteLineAsync("\nFiles Needing Attention:"); + foreach (var file in summary.LowestCoverageFiles) + { + string typeIndicator = file.FileType switch + { + CoverageFileType.Production => "๐Ÿ“„", + CoverageFileType.Test => "๐Ÿงช", + CoverageFileType.Generated => "โš™๏ธ", + _ => "โ“" + }; + await Console.Out.WriteLineAsync($"{typeIndicator} {file.FilePath}: {file.CoveragePercentage:F1}% coverage"); + } + } + } + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Error: {ex.Message}"); + Environment.Exit(1); + } + }, reportPathOption, formatOption); + rootCommand.AddCommand(helloCommand); rootCommand.AddCommand(setBaseDirCommand); rootCommand.AddCommand(getBaseDirCommand); @@ -811,6 +941,8 @@ static async Task Main(string[] args) rootCommand.AddCommand(semanticSearchCommand); rootCommand.AddCommand(analyzePackagesCommand); rootCommand.AddCommand(thinkCommand); + rootCommand.AddCommand(coverageAnalysisCommand); + rootCommand.AddCommand(coverageSummaryCommand); return await rootCommand.InvokeAsync(args); } diff --git a/src/NetContextServer/Models/CoverageFormat.cs b/src/NetContextServer/Models/CoverageFormat.cs new file mode 100644 index 0000000..b573a77 --- /dev/null +++ b/src/NetContextServer/Models/CoverageFormat.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace NetContextServer.Models; + +/// +/// Represents the supported formats for code coverage reports. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CoverageFormat +{ + /// + /// Coverlet JSON format (default) + /// + CoverletJson, + + /// + /// LCOV format (.info files) + /// + Lcov, + + /// + /// Cobertura XML format + /// + CoberturaXml +} \ No newline at end of file diff --git a/src/NetContextServer/Models/CoverageReport.cs b/src/NetContextServer/Models/CoverageReport.cs new file mode 100644 index 0000000..d01c212 --- /dev/null +++ b/src/NetContextServer/Models/CoverageReport.cs @@ -0,0 +1,55 @@ +namespace NetContextServer.Models; + +/// +/// Represents a code coverage report for a single file or class. +/// +public class CoverageReport +{ + /// + /// Gets or sets the path to the source file, relative to the project root. + /// + public string FilePath { get; set; } = string.Empty; + + /// + /// Gets or sets the overall coverage percentage for the file (0-100). + /// + public float CoveragePercentage { get; set; } + + /// + /// Gets or sets the list of line numbers that are not covered by tests. + /// + public List UncoveredLines { get; set; } = []; + + /// + /// Gets or sets the total number of executable lines in the file. + /// + public int TotalLines { get; set; } + + /// + /// Gets or sets the branch coverage information, mapping method names to their coverage percentage. + /// + public Dictionary BranchCoverage { get; set; } = []; + + /// + /// Gets or sets the list of test files that provide coverage for this file. + /// + public List TestFiles { get; set; } = []; + + /// + /// Gets or sets a suggested action to improve coverage, if applicable. + /// + public string? Recommendation { get; set; } + + /// + /// The type of file being analyzed (Production, Test, Generated, or Unknown) + /// + public CoverageFileType FileType { get; set; } = CoverageFileType.Unknown; +} + +public enum CoverageFileType +{ + Production, + Test, + Generated, + Unknown +} \ No newline at end of file diff --git a/src/NetContextServer/Models/CoverageSummary.cs b/src/NetContextServer/Models/CoverageSummary.cs new file mode 100644 index 0000000..f5ca92e --- /dev/null +++ b/src/NetContextServer/Models/CoverageSummary.cs @@ -0,0 +1,52 @@ +namespace NetContextServer.Models; + +/// +/// Represents a summary of code coverage across multiple files. +/// +public class CoverageSummary +{ + /// + /// Gets or sets the overall coverage percentage across all files. + /// + public float TotalCoveragePercentage { get; set; } + + /// + /// Gets or sets the total number of files analyzed. + /// + public int TotalFiles { get; set; } + + /// + /// Gets or sets the number of files with coverage below a warning threshold. + /// + public int FilesWithLowCoverage { get; set; } + + /// + /// Gets or sets the total number of uncovered lines across all files. + /// + public int TotalUncoveredLines { get; set; } + + /// + /// Gets or sets a list of files with the lowest coverage percentages. + /// + public List LowestCoverageFiles { get; set; } = []; + + /// + /// Number of production files analyzed + /// + public int ProductionFiles { get; set; } + + /// + /// Number of test files analyzed + /// + public int TestFiles { get; set; } + + /// + /// Average coverage percentage for production files + /// + public float ProductionCoveragePercentage { get; set; } + + /// + /// Average coverage percentage for test files + /// + public float TestCoveragePercentage { get; set; } +} \ No newline at end of file diff --git a/src/NetContextServer/Services/CoverageAnalysisService.cs b/src/NetContextServer/Services/CoverageAnalysisService.cs new file mode 100644 index 0000000..533ce32 --- /dev/null +++ b/src/NetContextServer/Services/CoverageAnalysisService.cs @@ -0,0 +1,455 @@ +using NetContextServer.Models; +using System.Text.Json; +using System.Xml.Linq; + +namespace NetContextServer.Services; + +/// +/// Service for analyzing code coverage reports from various formats and providing structured coverage information. +/// +/// +/// This service supports multiple coverage formats: +/// - Coverlet JSON (default) +/// - LCOV +/// - Cobertura XML +/// +/// Coverage data is parsed and normalized into a consistent format for consumption by the MCP tools. +/// +/// +/// Initializes a new instance of the CoverageAnalysisService class. +/// +/// Optional base directory for resolving relative paths. If null, uses the current directory. +public class CoverageAnalysisService(string? baseDirectory = null) +{ + private const float LOW_COVERAGE_THRESHOLD = 70.0f; + private readonly string _baseDirectory = baseDirectory ?? Directory.GetCurrentDirectory(); + + // Common test file patterns - can be made configurable via options in the future + private static readonly string[] DefaultTestDirectoryPatterns = + [ + "/tests/", + "/test/", + ".tests/", + ".test/", + "\\tests\\", + "\\test\\", + ".tests\\", + ".test\\" + ]; + + private static readonly string[] DefaultTestFilePatterns = + [ + "tests.cs", + "test.cs", + ".tests.cs", + ".test.cs", + "spec.cs", + ".spec.cs", + "fixture.cs", + ".fixture.cs" + ]; + + private static readonly string[] DefaultTestNamespacePatterns = + [ + ".Tests.", + ".Test.", + "TestFixtures.", + "UnitTests.", + "IntegrationTests.", + "Fixtures." + ]; + + /// + /// Analyzes a coverage report file and returns detailed coverage information. + /// + /// Path to the coverage report file. + /// Format of the coverage report. + /// A list of coverage reports, one for each source file. + /// Thrown when the coverage file is outside the base directory. + /// Thrown when the coverage file does not exist. + public async Task> AnalyzeCoverageAsync( + string coverageFilePath, + CoverageFormat format) + { + // Validate coverage file path + if (!FileValidationService.IsPathSafe(coverageFilePath)) + { + throw new InvalidOperationException("Coverage file is outside the base directory or not allowed."); + } + if (!File.Exists(coverageFilePath)) + { + throw new FileNotFoundException("Coverage file not found.", coverageFilePath); + } + + return format switch + { + CoverageFormat.Lcov => await ParseLcovAsync(coverageFilePath), + CoverageFormat.CoberturaXml => await ParseCoberturaXmlAsync(coverageFilePath), + _ => await ParseCoverletJsonAsync(coverageFilePath), + }; + } + + /// + /// Generates a summary of coverage across all files. + /// + /// List of individual file coverage reports. + /// A summary of overall coverage statistics. + public static CoverageSummary GenerateSummary(List reports) + { + if (reports.Count == 0) + { + return new CoverageSummary + { + TotalFiles = 0, + TotalCoveragePercentage = 0, + FilesWithLowCoverage = 0, + TotalUncoveredLines = 0, + LowestCoverageFiles = [], + ProductionFiles = 0, + TestFiles = 0, + ProductionCoveragePercentage = 0, + TestCoveragePercentage = 0 + }; + } + + var totalLines = reports.Sum(r => r.TotalLines); + var totalUncoveredLines = reports.Sum(r => r.UncoveredLines.Count); + var totalCoveredLines = totalLines - totalUncoveredLines; + + var productionFiles = reports.Where(r => r.FileType == CoverageFileType.Production).ToList(); + var testFiles = reports.Where(r => r.FileType == CoverageFileType.Test).ToList(); + + var summary = new CoverageSummary + { + TotalFiles = reports.Count, + TotalCoveragePercentage = totalLines > 0 ? (float)totalCoveredLines / totalLines * 100 : 0, + FilesWithLowCoverage = reports.Count(r => r.CoveragePercentage < LOW_COVERAGE_THRESHOLD), + TotalUncoveredLines = totalUncoveredLines, + LowestCoverageFiles = [.. reports + .OrderBy(r => r.CoveragePercentage) + .Take(5)], + ProductionFiles = productionFiles.Count, + TestFiles = testFiles.Count, + ProductionCoveragePercentage = productionFiles.Count != 0 + ? productionFiles.Average(r => r.CoveragePercentage) + : 0, + TestCoveragePercentage = testFiles.Count != 0 + ? testFiles.Average(r => r.CoveragePercentage) + : 0 + }; + + return summary; + } + + private static bool IsGeneratedCode(string filePath) + { + // Check for common generated code patterns + return filePath.Contains("/obj/") || // Generated files in obj directory + filePath.EndsWith(".g.cs") || // Standard generated code suffix + filePath.EndsWith(".generated.cs"); // Alternative generated code suffix + } + + private static bool IsTestFile(string filePath) + { + // Normalize path separators to handle both Windows and Unix styles + var normalizedPath = filePath.Replace('\\', '/').ToLowerInvariant(); + + // Check directory patterns + foreach (var pattern in DefaultTestDirectoryPatterns) + { + var normalizedPattern = pattern.Replace('\\', '/').ToLowerInvariant(); + if (normalizedPath.Contains(normalizedPattern)) + return true; + } + + // Check file name patterns + var fileName = Path.GetFileName(normalizedPath).ToLowerInvariant(); + if (DefaultTestFilePatterns.Any(pattern => + fileName.EndsWith(pattern.ToLowerInvariant()))) + return true; + + // Check namespace patterns (if the file path contains them) + if (DefaultTestNamespacePatterns.Any(pattern => + normalizedPath.Contains(pattern, StringComparison.InvariantCultureIgnoreCase))) + return true; + + // Additional check for files in test directories + return normalizedPath.Contains("/tests/") || + normalizedPath.Contains("/test/"); + } + + private static CoverageFileType DetermineFileType(string filePath) + { + if (IsGeneratedCode(filePath)) + return CoverageFileType.Generated; + if (IsTestFile(filePath)) + return CoverageFileType.Test; + return CoverageFileType.Production; + } + + private async Task> ParseCoverletJsonAsync(string filePath) + { + var text = await File.ReadAllTextAsync(filePath); + using var doc = JsonDocument.Parse(text); + + var result = new List(); + var root = doc.RootElement; + + if (root.TryGetProperty("Modules", out var modulesEl)) + { + foreach (var moduleProp in modulesEl.EnumerateObject()) + { + if (moduleProp.Value.TryGetProperty("Classes", out var classesEl)) + { + foreach (var classProp in classesEl.EnumerateObject()) + { + // Skip generated code files + if (IsGeneratedCode(classProp.Name)) + continue; + + var report = ExtractCoverageFromClass(classProp); + if (report != null) + { + result.Add(report); + } + } + } + } + } + + return result; + } + + private CoverageReport? ExtractCoverageFromClass(JsonProperty classProp) + { + if (!classProp.Value.TryGetProperty("Lines", out var linesEl)) + { + return null; + } + + var filePath = NormalizePath(classProp.Name); + var report = new CoverageReport + { + FilePath = filePath, + FileType = DetermineFileType(filePath) + }; + + var totalLines = 0; + var coveredLines = 0; + var uncoveredLines = new List(); + + foreach (var lineProp in linesEl.EnumerateObject()) + { + if (int.TryParse(lineProp.Name, out var lineNumber)) + { + totalLines++; + var hits = lineProp.Value.GetInt32(); + if (hits == 0) + { + uncoveredLines.Add(lineNumber); + } + else + { + coveredLines++; + } + } + } + + report.UncoveredLines = uncoveredLines; + report.TotalLines = totalLines; + report.CoveragePercentage = totalLines > 0 + ? (float)coveredLines / totalLines * 100 + : 0; + + // Add branch coverage if available + if (classProp.Value.TryGetProperty("Methods", out var methodsEl)) + { + var branchCoverage = new Dictionary(); + foreach (var methodProp in methodsEl.EnumerateObject()) + { + if (methodProp.Value.TryGetProperty("CoveredBranches", out var coveredBranchesEl) && + methodProp.Value.TryGetProperty("TotalBranches", out var totalBranchesEl)) + { + var coveredBranches = coveredBranchesEl.GetInt32(); + var totalBranches = totalBranchesEl.GetInt32(); + + if (totalBranches > 0) + { + branchCoverage[methodProp.Name] = (float)coveredBranches / totalBranches * 100; + } + } + } + report.BranchCoverage = branchCoverage; + } + + // Add recommendations based on coverage + if (report.CoveragePercentage < LOW_COVERAGE_THRESHOLD) + { + report.Recommendation = $"Consider adding tests to improve coverage (currently {report.CoveragePercentage:F1}%)"; + if (report.UncoveredLines.Count > 0) + { + report.Recommendation += $". Focus on lines: {string.Join(", ", report.UncoveredLines.Take(5))}"; + if (report.UncoveredLines.Count > 5) + { + report.Recommendation += $" and {report.UncoveredLines.Count - 5} more"; + } + } + } + + return report; + } + + private async Task> ParseLcovAsync(string filePath) + { + var lines = await File.ReadAllLinesAsync(filePath); + var result = new List(); + CoverageReport? currentReport = null; + int totalLines = 0; + int coveredLines = 0; + + foreach (var line in lines) + { + if (line.StartsWith("SF:")) + { + var sourceFile = line[3..]; + // Skip generated code files + if (IsGeneratedCode(sourceFile)) + { + currentReport = null; + continue; + } + + // New file section + if (currentReport != null) + { + currentReport.TotalLines = totalLines; + currentReport.CoveragePercentage = totalLines > 0 + ? (float)coveredLines / totalLines * 100 + : 0; + result.Add(currentReport); + } + + var normalizedPath = NormalizePath(sourceFile); + currentReport = new CoverageReport + { + FilePath = normalizedPath, + FileType = DetermineFileType(normalizedPath), + UncoveredLines = [] + }; + totalLines = 0; + coveredLines = 0; + } + else if (currentReport != null && line.StartsWith("DA:")) + { + // Line coverage data + var parts = line[3..].Split(','); + if (parts.Length == 2 && + int.TryParse(parts[0], out var lineNum) && + int.TryParse(parts[1], out var hits)) + { + totalLines++; + if (hits == 0) + { + currentReport.UncoveredLines.Add(lineNum); + } + else + { + coveredLines++; + } + } + } + else if (line == "end_of_record" && currentReport != null) + { + currentReport.TotalLines = totalLines; + currentReport.CoveragePercentage = totalLines > 0 + ? (float)coveredLines / totalLines * 100 + : 0; + result.Add(currentReport); + currentReport = null; + totalLines = 0; + coveredLines = 0; + } + } + + if (currentReport != null) + { + currentReport.TotalLines = totalLines; + currentReport.CoveragePercentage = totalLines > 0 + ? (float)coveredLines / totalLines * 100 + : 0; + result.Add(currentReport); + } + + return result; + } + + private async Task> ParseCoberturaXmlAsync(string filePath) + { + var doc = await Task.Run(() => XDocument.Load(filePath)); + var result = new List(); + + var fileElements = doc.Descendants("class") + .GroupBy(x => x.Attribute("filename")?.Value) + .Where(g => g.Key != null && !IsGeneratedCode(g.Key)); + + foreach (var fileGroup in fileElements) + { + var normalizedPath = NormalizePath(fileGroup.Key!); + var report = new CoverageReport + { + FilePath = normalizedPath, + FileType = DetermineFileType(normalizedPath), + UncoveredLines = [] + }; + + var allLines = new HashSet(); + var coveredLines = new HashSet(); + + foreach (var classElement in fileGroup) + { + foreach (var lineElement in classElement.Descendants("line")) + { + if (int.TryParse(lineElement.Attribute("number")?.Value, out var lineNum)) + { + allLines.Add(lineNum); + if (int.TryParse(lineElement.Attribute("hits")?.Value, out var hits) && hits > 0) + { + coveredLines.Add(lineNum); + } + else + { + report.UncoveredLines.Add(lineNum); + } + } + } + } + + if (allLines.Count > 0) + { + report.CoveragePercentage = (float)coveredLines.Count / allLines.Count * 100; + } + + result.Add(report); + } + + return result; + } + + private string NormalizePath(string path) + { + // Convert absolute paths to relative paths based on the base directory + if (Path.IsPathRooted(path)) + { + try + { + return Path.GetRelativePath(_baseDirectory, path); + } + catch + { + // If we can't get a relative path, return the original + return path; + } + } + return path; + } +} \ No newline at end of file diff --git a/src/NetContextServer/Tools/CoverageTools.cs b/src/NetContextServer/Tools/CoverageTools.cs new file mode 100644 index 0000000..4b104cd --- /dev/null +++ b/src/NetContextServer/Tools/CoverageTools.cs @@ -0,0 +1,105 @@ +using ModelContextProtocol.Server; +using NetContextServer.Models; +using NetContextServer.Services; +using System.ComponentModel; +using System.Text.Json; + +namespace NetContextServer.Tools; + +/// +/// Provides MCP tools for analyzing code coverage reports and providing coverage insights. +/// +[McpServerToolType] +public static class CoverageTools +{ + private static readonly JsonSerializerOptions DefaultJsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// Analyzes a coverage report file and returns detailed coverage information. + /// + /// + /// This tool parses coverage data from common formats (Coverlet JSON, LCOV, Cobertura XML) + /// and returns a structured analysis of code coverage, including: + /// - Coverage percentage per file + /// - Uncovered lines + /// - Branch coverage (where available) + /// - Recommendations for improving coverage + /// + [McpServerTool("coverage_analysis")] + [Description("Analyzes a coverage report file and returns detailed coverage information.")] + public static async Task AnalyzeCoverage( + [Description("Path to the coverage report file. Must be within the base directory.")] + string reportPath, + + [Description("Format of the coverage file: 'coverlet' (default), 'lcov', or 'cobertura'.")] + string? coverageFormat = null) + { + try + { + FileValidationService.EnsureBaseDirectorySet(); + + var format = coverageFormat?.ToLowerInvariant() switch + { + "lcov" => CoverageFormat.Lcov, + "cobertura" => CoverageFormat.CoberturaXml, + null or "coverlet" => CoverageFormat.CoverletJson, + _ => throw new ArgumentException($"Unsupported coverage format: {coverageFormat}") + }; + + var service = new CoverageAnalysisService(FileValidationService.BaseDirectory); + var reports = await service.AnalyzeCoverageAsync(reportPath, format); + + return JsonSerializer.Serialize(reports, DefaultJsonOptions); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { error = ex.Message }, DefaultJsonOptions); + } + } + + /// + /// Generates a high-level summary of code coverage across all files. + /// + /// + /// This tool provides an overview of code coverage, including: + /// - Total coverage percentage + /// - Number of files with low coverage + /// - Total uncovered lines + /// - List of files with lowest coverage + /// + [McpServerTool("coverage_summary")] + [Description("Returns a high-level summary of code coverage statistics.")] + public static async Task CoverageSummary( + [Description("Path to the coverage report file. Must be within the base directory.")] + string reportPath, + + [Description("Format of the coverage file: 'coverlet' (default), 'lcov', or 'cobertura'.")] + string? coverageFormat = null) + { + var format = coverageFormat?.ToLowerInvariant() switch + { + "lcov" => CoverageFormat.Lcov, + "cobertura" => CoverageFormat.CoberturaXml, + _ => CoverageFormat.CoverletJson + }; + + try + { + FileValidationService.EnsureBaseDirectorySet(); + + var service = new CoverageAnalysisService(FileValidationService.BaseDirectory); + var reports = await service.AnalyzeCoverageAsync(reportPath, format); + var summary = CoverageAnalysisService.GenerateSummary(reports); + + return JsonSerializer.Serialize(summary, DefaultJsonOptions); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { error = ex.Message }, DefaultJsonOptions); + } + } +} \ No newline at end of file diff --git a/tests/NetContextServer/CoverageOperationTests.cs b/tests/NetContextServer/CoverageOperationTests.cs new file mode 100644 index 0000000..c4b415c --- /dev/null +++ b/tests/NetContextServer/CoverageOperationTests.cs @@ -0,0 +1,517 @@ +using ModelContextProtocol.Client; +using NetContextServer.Models; +using System.Text.Json; + +namespace NetContextServer.Tests; + +[Collection("NetContextServer Collection")] +public class CoverageOperationTests : IAsyncLifetime +{ + private readonly string _testDir; + private readonly string _testCoverageFile; + private readonly IMcpClient _client; + + private static readonly JsonSerializerOptions DefaultJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public CoverageOperationTests(NetContextServerFixture fixture) + { + _client = fixture.Client; + _testDir = Path.Combine(Path.GetTempPath(), "NetContextServerTests_" + Guid.NewGuid()); + _testCoverageFile = Path.Combine(_testDir, "coverage.json"); + } + + public async Task InitializeAsync() + { + await Task.Run(() => + { + Directory.CreateDirectory(_testDir); + // Create a sample coverage file + File.WriteAllText(_testCoverageFile, @"{ + ""Modules"": { + ""TestModule"": { + ""Classes"": { + ""TestNamespace.TestClass"": { + ""Lines"": { + ""10"": 1, + ""11"": 0, + ""12"": 1 + } + } + } + } + } + }"); + }); + } + + public async Task DisposeAsync() + { + try + { + // Reset the base directory + await _client.CallToolAsync("set_base_directory", + new Dictionary { ["directory"] = Directory.GetCurrentDirectory() }); + } + catch + { + // Ignore errors when resetting base directory + } + + try + { + if (Directory.Exists(_testDir)) + { + Directory.Delete(_testDir, true); + } + } + catch + { + // Ignore cleanup errors + } + } + + [Fact] + public async Task CoverageAnalysis_WithValidFile_ReturnsCoverageData() + { + // Arrange + await _client.CallToolAsync("set_base_directory", + new Dictionary { ["directory"] = _testDir }); + + // Act + var result = await _client.CallToolAsync("coverage_analysis", + new Dictionary { + ["reportPath"] = _testCoverageFile, + ["coverageFormat"] = "coverlet" + }); + + // Assert + Assert.NotNull(result); + var content = result.Content.FirstOrDefault(c => c.Type == "text"); + Assert.NotNull(content); + Assert.NotNull(content.Text); + + var reports = JsonSerializer.Deserialize>(content.Text, DefaultJsonOptions); + Assert.NotNull(reports); + Assert.NotEmpty(reports); + + var report = reports[0]; + Assert.Equal("TestNamespace.TestClass", report.FilePath); + Assert.Equal(66.67f, report.CoveragePercentage, 2); // 2/3 lines covered + Assert.Contains(11, report.UncoveredLines); + } + + [Fact] + public async Task CoverageAnalysis_WithInvalidPath_ReturnsError() + { + // Arrange + await _client.CallToolAsync("set_base_directory", + new Dictionary { ["directory"] = _testDir }); + var invalidPath = Path.Combine(_testDir, "nonexistent.json"); + + // Act + var result = await _client.CallToolAsync("coverage_analysis", + new Dictionary { + ["reportPath"] = invalidPath + }); + + // Assert + Assert.NotNull(result); + var content = result.Content.FirstOrDefault(c => c.Type == "text"); + Assert.NotNull(content); + Assert.NotNull(content.Text); + + var response = JsonDocument.Parse(content.Text); + Assert.True(response.RootElement.TryGetProperty("error", out var errorElement)); + Assert.Contains("not found", errorElement.GetString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CoverageAnalysis_OutsideBaseDirectory_ReturnsError() + { + // Arrange + var outsideDir = Path.Combine(Path.GetTempPath(), "OutsideDir_" + Guid.NewGuid()); + Directory.CreateDirectory(outsideDir); + var outsideFile = Path.Combine(outsideDir, "coverage.json"); + File.WriteAllText(outsideFile, "{}"); + + await _client.CallToolAsync("set_base_directory", + new Dictionary { ["directory"] = _testDir }); + + try + { + // Act + var result = await _client.CallToolAsync("coverage_analysis", + new Dictionary { + ["reportPath"] = outsideFile + }); + + // Assert + Assert.NotNull(result); + var content = result.Content.FirstOrDefault(c => c.Type == "text"); + Assert.NotNull(content); + Assert.NotNull(content.Text); + + var response = JsonDocument.Parse(content.Text); + Assert.True(response.RootElement.TryGetProperty("error", out var errorElement)); + Assert.Contains("outside", errorElement.GetString(), StringComparison.OrdinalIgnoreCase); + } + finally + { + // Cleanup + try + { + if (Directory.Exists(outsideDir)) + { + Directory.Delete(outsideDir, true); + } + } + catch + { + // Ignore cleanup errors + } + } + } + + [Fact] + public async Task CoverageSummary_WithValidFile_ReturnsSummaryData() + { + // Arrange + await _client.CallToolAsync("set_base_directory", + new Dictionary { ["directory"] = _testDir }); + + // Act + var result = await _client.CallToolAsync("coverage_summary", + new Dictionary { + ["reportPath"] = _testCoverageFile, + ["coverageFormat"] = "coverlet" + }); + + // Assert + Assert.NotNull(result); + var content = result.Content.FirstOrDefault(c => c.Type == "text"); + Assert.NotNull(content); + Assert.NotNull(content.Text); + + var summary = JsonSerializer.Deserialize(content.Text, DefaultJsonOptions); + Assert.NotNull(summary); + Assert.Equal(1, summary.TotalFiles); + Assert.Equal(66.67f, summary.TotalCoveragePercentage, 2); + Assert.Equal(1, summary.FilesWithLowCoverage); + Assert.Equal(1, summary.TotalUncoveredLines); + } + + [Fact] + public async Task CoverageAnalysis_WithLcovFormat_ReturnsCoverageData() + { + // Arrange + await _client.CallToolAsync("set_base_directory", + new Dictionary { ["directory"] = _testDir }); + + var lcovContent = @"SF:src/MyProject/Service.cs +DA:1,1 +DA:2,0 +DA:3,1 +end_of_record"; + var lcovFile = Path.Combine(_testDir, "coverage.lcov"); + await File.WriteAllTextAsync(lcovFile, lcovContent); + + // Act + var result = await _client.CallToolAsync("coverage_analysis", + new Dictionary { + ["reportPath"] = lcovFile, + ["coverageFormat"] = "lcov" + }); + + // Assert + Assert.NotNull(result); + var content = result.Content.FirstOrDefault(c => c.Type == "text"); + Assert.NotNull(content); + Assert.NotNull(content.Text); + + var reports = JsonSerializer.Deserialize>(content.Text, DefaultJsonOptions); + Assert.NotNull(reports); + Assert.NotEmpty(reports); + + var report = reports[0]; + Assert.Equal("src/MyProject/Service.cs", report.FilePath); + Assert.Equal(66.67f, report.CoveragePercentage, 2); // 2/3 lines covered + Assert.Contains(2, report.UncoveredLines); + } + + [Fact] + public async Task CoverageAnalysis_WithCoberturaFormat_ReturnsCoverageData() + { + // Arrange + await _client.CallToolAsync("set_base_directory", + new Dictionary { ["directory"] = _testDir }); + + var coberturaContent = @" + + + + + + + + + + + + + + +"; + var coberturaFile = Path.Combine(_testDir, "coverage.xml"); + await File.WriteAllTextAsync(coberturaFile, coberturaContent); + + // Act + var result = await _client.CallToolAsync("coverage_analysis", + new Dictionary { + ["reportPath"] = coberturaFile, + ["coverageFormat"] = "cobertura" + }); + + // Assert + Assert.NotNull(result); + var content = result.Content.FirstOrDefault(c => c.Type == "text"); + Assert.NotNull(content); + Assert.NotNull(content.Text); + + var reports = JsonSerializer.Deserialize>(content.Text, DefaultJsonOptions); + Assert.NotNull(reports); + Assert.NotEmpty(reports); + + var report = reports[0]; + Assert.Equal("src/MyProject/Service.cs", report.FilePath); + Assert.Equal(66.67f, report.CoveragePercentage, 2); + Assert.Contains(2, report.UncoveredLines); + } + + [Fact] + public async Task CoverageAnalysis_WithBranchCoverage_ReturnsBranchData() + { + // Arrange + await _client.CallToolAsync("set_base_directory", + new Dictionary { ["directory"] = _testDir }); + + var coverageContent = @"{ + ""Modules"": { + ""TestModule"": { + ""Classes"": { + ""TestNamespace.TestClass"": { + ""Lines"": { + ""10"": 1, + ""11"": 0, + ""12"": 1 + }, + ""Branches"": { + ""10"": { + ""0"": 1, + ""1"": 0 + } + }, + ""Methods"": { + ""ProcessData"": { + ""CoveredBranches"": 1, + ""TotalBranches"": 2 + } + } + } + } + } + } + }"; + await File.WriteAllTextAsync(_testCoverageFile, coverageContent); + + // Act + var result = await _client.CallToolAsync("coverage_analysis", + new Dictionary { + ["reportPath"] = _testCoverageFile, + ["coverageFormat"] = "coverlet" + }); + + // Assert + Assert.NotNull(result); + var content = result.Content.FirstOrDefault(c => c.Type == "text"); + Assert.NotNull(content); + Assert.NotNull(content.Text); + + var reports = JsonSerializer.Deserialize>(content.Text, DefaultJsonOptions); + Assert.NotNull(reports); + Assert.NotEmpty(reports); + + var report = reports[0]; + Assert.Equal("TestNamespace.TestClass", report.FilePath); + Assert.Equal(66.67f, report.CoveragePercentage, 2); + Assert.Contains(11, report.UncoveredLines); + Assert.Contains("ProcessData", report.BranchCoverage.Keys); + Assert.Equal(50.0f, report.BranchCoverage["ProcessData"], 1); + } + + [Fact] + public async Task CoverageSummary_WithMultipleFiles_AggregatesCorrectly() + { + // Arrange + await _client.CallToolAsync("set_base_directory", + new Dictionary { ["directory"] = _testDir }); + + var coverageContent = @"{ + ""Modules"": { + ""TestModule"": { + ""Classes"": { + ""TestNamespace.Class1"": { + ""Lines"": { + ""10"": 1, + ""11"": 0 + } + }, + ""TestNamespace.Class2"": { + ""Lines"": { + ""15"": 1, + ""16"": 1, + ""17"": 0 + } + } + } + } + } + }"; + await File.WriteAllTextAsync(_testCoverageFile, coverageContent); + + // Act + var result = await _client.CallToolAsync("coverage_summary", + new Dictionary { + ["reportPath"] = _testCoverageFile, + ["coverageFormat"] = "coverlet" + }); + + // Assert + Assert.NotNull(result); + var content = result.Content.FirstOrDefault(c => c.Type == "text"); + Assert.NotNull(content); + Assert.NotNull(content.Text); + + var summary = JsonSerializer.Deserialize(content.Text, DefaultJsonOptions); + Assert.NotNull(summary); + Assert.Equal(2, summary.TotalFiles); + Assert.Equal(60.0f, summary.TotalCoveragePercentage, 1); // 3/5 lines covered + Assert.Equal(2, summary.FilesWithLowCoverage); // Both files < 70% + Assert.Equal(2, summary.TotalUncoveredLines); + Assert.Equal(2, summary.LowestCoverageFiles.Count); + } + + [Fact] + public async Task CoverageAnalysis_WithEmptyCoverageFile_ReturnsEmptyResults() + { + // Arrange + await _client.CallToolAsync("set_base_directory", + new Dictionary { ["directory"] = _testDir }); + + await File.WriteAllTextAsync(_testCoverageFile, "{}"); + + // Act + var result = await _client.CallToolAsync("coverage_analysis", + new Dictionary { + ["reportPath"] = _testCoverageFile + }); + + // Assert + Assert.NotNull(result); + var content = result.Content.FirstOrDefault(c => c.Type == "text"); + Assert.NotNull(content); + Assert.NotNull(content.Text); + + var reports = JsonSerializer.Deserialize>(content.Text, DefaultJsonOptions); + Assert.NotNull(reports); + Assert.Empty(reports); + } + + [Fact] + public async Task CoverageAnalysis_WithInvalidFormat_ReturnsError() + { + // Arrange + await _client.CallToolAsync("set_base_directory", + new Dictionary { ["directory"] = _testDir }); + + // Act + var result = await _client.CallToolAsync("coverage_analysis", + new Dictionary { + ["reportPath"] = _testCoverageFile, + ["coverageFormat"] = "invalid" + }); + + // Assert + Assert.NotNull(result); + var content = result.Content.FirstOrDefault(c => c.Type == "text"); + Assert.NotNull(content); + Assert.NotNull(content.Text); + + var response = JsonDocument.Parse(content.Text); + Assert.True(response.RootElement.TryGetProperty("error", out var errorElement)); + Assert.Contains("format", errorElement.GetString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CoverageAnalysis_ExcludesGeneratedCode() + { + // Arrange + await _client.CallToolAsync("set_base_directory", + new Dictionary { ["directory"] = _testDir }); + + // Create a coverage file with both regular and generated code + var coverageContent = @"{ + ""Modules"": { + ""TestModule"": { + ""Classes"": { + ""RegularCode.cs"": { + ""Lines"": { + ""10"": 1, + ""11"": 0, + ""12"": 1 + } + }, + ""obj/Debug/net9.0/Generated.g.cs"": { + ""Lines"": { + ""15"": 1, + ""16"": 0 + } + }, + ""Models/Generated.generated.cs"": { + ""Lines"": { + ""20"": 1, + ""21"": 0 + } + } + } + } + } + }"; + await File.WriteAllTextAsync(_testCoverageFile, coverageContent); + + // Act + var result = await _client.CallToolAsync("coverage_analysis", + new Dictionary { + ["reportPath"] = _testCoverageFile, + ["coverageFormat"] = "coverlet" + }); + + // Assert + Assert.NotNull(result); + var content = result.Content.FirstOrDefault(c => c.Type == "text"); + Assert.NotNull(content); + Assert.NotNull(content.Text); + + var reports = JsonSerializer.Deserialize>(content.Text, DefaultJsonOptions); + Assert.NotNull(reports); + Assert.Single(reports); // Should only contain the regular code file + + var report = reports[0]; + Assert.Equal("RegularCode.cs", report.FilePath); + Assert.Equal(66.67f, report.CoveragePercentage, 2); // 2/3 lines covered + Assert.Contains(11, report.UncoveredLines); + } +} \ No newline at end of file