Skip to main content

tldr_cli/commands/
coverage.rs

1//! Coverage command - Parse and report code coverage from existing reports
2//!
3//! Parses coverage reports in multiple formats:
4//! - Cobertura XML (GitLab/Jenkins standard)
5//! - LCOV (llvm-cov, gcov)
6//! - coverage.py JSON
7//!
8//! **Note:** This command parses existing coverage reports. It does NOT run tests
9//! or generate coverage data. Use your test framework with coverage enabled first.
10//!
11//! # Example
12//! ```bash
13//! # Parse a Cobertura XML report
14//! tldr coverage coverage.xml
15//!
16//! # Parse LCOV with explicit format
17//! tldr coverage coverage.lcov --format lcov
18//!
19//! # Show per-file breakdown and uncovered code
20//! tldr coverage coverage.xml --by-file --uncovered
21//!
22//! # Check against 80% threshold
23//! tldr coverage coverage.xml --threshold 80
24//! ```
25
26use std::path::PathBuf;
27
28use anyhow::Result;
29use clap::{Args, ValueEnum};
30
31use tldr_core::quality::coverage::{
32    parse_coverage, CoverageFormat as CoreCoverageFormat, CoverageOptions, CoverageReport,
33};
34
35use crate::output::{OutputFormat, OutputWriter};
36
37/// Coverage report format options
38#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
39pub enum CoverageFormat {
40    /// Cobertura XML format (GitLab/Jenkins standard)
41    Cobertura,
42    /// LCOV format (llvm-cov, gcov)
43    Lcov,
44    /// coverage.py JSON format
45    #[value(name = "coveragepy")]
46    CoveragePy,
47    /// Auto-detect from file content
48    Auto,
49}
50
51impl From<CoverageFormat> for Option<CoreCoverageFormat> {
52    fn from(format: CoverageFormat) -> Self {
53        match format {
54            CoverageFormat::Cobertura => Some(CoreCoverageFormat::Cobertura),
55            CoverageFormat::Lcov => Some(CoreCoverageFormat::Lcov),
56            CoverageFormat::CoveragePy => Some(CoreCoverageFormat::CoveragePy),
57            CoverageFormat::Auto => None,
58        }
59    }
60}
61
62/// Sort order for files
63#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
64pub enum SortOrder {
65    /// Ascending order (lowest coverage first)
66    Asc,
67    /// Descending order (highest coverage first)
68    Desc,
69}
70
71/// Parse and report code coverage from existing reports
72///
73/// This command parses coverage reports generated by test frameworks.
74/// It does NOT run tests or generate coverage data.
75///
76/// Supported formats:
77/// - Cobertura XML (GitLab CI, Jenkins, pytest-cov)
78/// - LCOV (llvm-cov, gcov, c8)
79/// - coverage.py JSON (Python coverage.py)
80#[derive(Debug, Args)]
81pub struct CoverageArgs {
82    /// Path to coverage report file
83    pub report: PathBuf,
84
85    /// Coverage report format (auto-detect if not specified)
86    #[arg(
87        long = "report-format",
88        short = 'R',
89        value_enum,
90        default_value = "auto"
91    )]
92    pub report_format: CoverageFormat,
93
94    /// Minimum coverage threshold (default: 80%)
95    #[arg(long, default_value = "80.0")]
96    pub threshold: f64,
97
98    /// Show per-file coverage breakdown
99    #[arg(long)]
100    pub by_file: bool,
101
102    /// List uncovered lines and functions
103    #[arg(long)]
104    pub uncovered: bool,
105
106    /// Filter to files matching pattern (can be repeated)
107    #[arg(long)]
108    pub filter: Vec<String>,
109
110    /// Sort files by coverage
111    #[arg(long, value_enum)]
112    pub sort: Option<SortOrder>,
113
114    /// Base path for resolving file paths (for existence checking)
115    #[arg(long)]
116    pub base_path: Option<PathBuf>,
117
118    /// Show only files below threshold
119    #[arg(long)]
120    pub uncovered_only: bool,
121}
122
123impl CoverageArgs {
124    /// Run the coverage command
125    pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
126        let writer = OutputWriter::new(format, quiet);
127
128        writer.progress(&format!(
129            "Parsing coverage report: {}...",
130            self.report.display()
131        ));
132
133        // Build options
134        let options = CoverageOptions {
135            threshold: self.threshold,
136            by_file: self.by_file || self.uncovered_only,
137            include_uncovered: self.uncovered,
138            filter: self.filter.clone(),
139            base_path: self.base_path.clone(),
140        };
141
142        // Parse the report
143        let mut report = parse_coverage(&self.report, self.report_format.into(), &options)?;
144
145        // Apply sorting if requested
146        if let Some(sort_order) = self.sort {
147            report.files.sort_by(|a, b| {
148                let cmp = a.line_coverage.partial_cmp(&b.line_coverage).unwrap();
149                match sort_order {
150                    SortOrder::Asc => cmp,
151                    SortOrder::Desc => cmp.reverse(),
152                }
153            });
154        }
155
156        // Filter to uncovered only if requested
157        if self.uncovered_only {
158            report.files.retain(|f| f.line_coverage < self.threshold);
159        }
160
161        // Output based on format
162        if writer.is_text() {
163            let text = format_coverage_text(&report, self.threshold);
164            writer.write_text(&text)?;
165        } else {
166            writer.write(&report)?;
167        }
168
169        Ok(())
170    }
171}
172
173/// Format coverage report for text output
174fn format_coverage_text(report: &CoverageReport, threshold: f64) -> String {
175    use colored::Colorize;
176    use comfy_table::{presets::UTF8_FULL, Cell, Color, ContentArrangement, Table};
177
178    let mut output = String::new();
179
180    // Header
181    output.push_str(&format!(
182        "Coverage Report ({})\n",
183        report.format.to_string().cyan()
184    ));
185    output.push_str("============================\n\n");
186
187    // Summary section
188    let summary = &report.summary;
189    output.push_str(&"Summary:\n".bold().to_string());
190    output.push_str(&format!(
191        "  Line Coverage:     {:.1}% ({}/{})\n",
192        summary.line_coverage,
193        summary.covered_lines.to_string().as_str().green(),
194        summary.total_lines
195    ));
196
197    if let Some(branch_cov) = summary.branch_coverage {
198        output.push_str(&format!("  Branch Coverage:   {:.1}%", branch_cov));
199        if let (Some(covered), Some(total)) = (summary.covered_branches, summary.total_branches) {
200            output.push_str(&format!(" ({}/{})", covered, total));
201        }
202        output.push('\n');
203    }
204
205    if let Some(func_cov) = summary.function_coverage {
206        output.push_str(&format!("  Function Coverage: {:.1}%", func_cov));
207        if let (Some(covered), Some(total)) = (summary.covered_functions, summary.total_functions) {
208            output.push_str(&format!(" ({}/{})", covered, total));
209        }
210        output.push('\n');
211    }
212
213    // Threshold status
214    let threshold_status = if summary.threshold_met {
215        format!("PASS (>= {:.0}%)", threshold).green().to_string()
216    } else {
217        format!("FAIL (< {:.0}%)", threshold).red().to_string()
218    };
219    output.push_str(&format!("  Threshold:         {}\n", threshold_status));
220
221    output.push('\n');
222
223    // Warnings
224    for warning in &report.warnings {
225        output.push_str(&format!("{} {}\n", "Warning:".yellow(), warning));
226    }
227    if !report.warnings.is_empty() {
228        output.push('\n');
229    }
230
231    // Per-file breakdown
232    if !report.files.is_empty() {
233        output.push_str(&"Per-File Coverage:\n".bold().to_string());
234
235        let mut table = Table::new();
236        table
237            .load_preset(UTF8_FULL)
238            .set_content_arrangement(ContentArrangement::Dynamic)
239            .set_header(vec![
240                Cell::new("File").fg(Color::Cyan),
241                Cell::new("Line %").fg(Color::Cyan),
242                Cell::new("Lines").fg(Color::Cyan),
243                Cell::new("Branch %").fg(Color::Cyan),
244                Cell::new("Status").fg(Color::Cyan),
245            ]);
246
247        for file in &report.files {
248            let cov_color = if file.line_coverage >= threshold {
249                Color::Green
250            } else if file.line_coverage >= threshold * 0.8 {
251                Color::Yellow
252            } else {
253                Color::Red
254            };
255
256            let status = if file.line_coverage >= threshold {
257                "OK".to_string()
258            } else {
259                "LOW".to_string()
260            };
261
262            let branch_str = file
263                .branch_coverage
264                .map(|b| format!("{:.1}%", b))
265                .unwrap_or_else(|| "-".to_string());
266
267            table.add_row(vec![
268                Cell::new(&file.path),
269                Cell::new(format!("{:.1}%", file.line_coverage)).fg(cov_color),
270                Cell::new(format!("{}/{}", file.covered_lines, file.total_lines)),
271                Cell::new(branch_str),
272                Cell::new(status).fg(cov_color),
273            ]);
274        }
275
276        output.push_str(&table.to_string());
277        output.push_str("\n\n");
278    }
279
280    // Uncovered code section
281    if let Some(uncovered) = &report.uncovered {
282        if !uncovered.functions.is_empty() {
283            output.push_str(&"Uncovered Functions:\n".bold().to_string());
284            for func in &uncovered.functions {
285                output.push_str(&format!(
286                    "  {}:{} - {}\n",
287                    func.file.dimmed(),
288                    func.line.to_string().cyan(),
289                    func.name.red()
290                ));
291            }
292            output.push('\n');
293        }
294
295        if !uncovered.line_ranges.is_empty() {
296            output.push_str(&"Uncovered Line Ranges:\n".bold().to_string());
297
298            // Group by file
299            let mut by_file: std::collections::HashMap<&str, Vec<(u32, u32)>> =
300                std::collections::HashMap::new();
301            for range in &uncovered.line_ranges {
302                by_file
303                    .entry(&range.file)
304                    .or_default()
305                    .push((range.start, range.end));
306            }
307
308            for (file, ranges) in by_file {
309                let range_strs: Vec<String> = ranges
310                    .iter()
311                    .map(|(s, e)| {
312                        if s == e {
313                            format!("{}", s)
314                        } else {
315                            format!("{}-{}", s, e)
316                        }
317                    })
318                    .collect();
319                output.push_str(&format!(
320                    "  {}: {}\n",
321                    file.dimmed(),
322                    range_strs.join(", ").red()
323                ));
324            }
325        }
326    }
327
328    output
329}