1use 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#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
39pub enum CoverageFormat {
40 Cobertura,
42 Lcov,
44 #[value(name = "coveragepy")]
46 CoveragePy,
47 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#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
64pub enum SortOrder {
65 Asc,
67 Desc,
69}
70
71#[derive(Debug, Args)]
81pub struct CoverageArgs {
82 pub report: PathBuf,
84
85 #[arg(
87 long = "report-format",
88 short = 'R',
89 value_enum,
90 default_value = "auto"
91 )]
92 pub report_format: CoverageFormat,
93
94 #[arg(long, default_value = "80.0")]
96 pub threshold: f64,
97
98 #[arg(long)]
100 pub by_file: bool,
101
102 #[arg(long)]
104 pub uncovered: bool,
105
106 #[arg(long)]
108 pub filter: Vec<String>,
109
110 #[arg(long, value_enum)]
112 pub sort: Option<SortOrder>,
113
114 #[arg(long)]
116 pub base_path: Option<PathBuf>,
117
118 #[arg(long)]
120 pub uncovered_only: bool,
121}
122
123impl CoverageArgs {
124 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 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 let mut report = parse_coverage(&self.report, self.report_format.into(), &options)?;
144
145 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 if self.uncovered_only {
158 report.files.retain(|f| f.line_coverage < self.threshold);
159 }
160
161 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
173fn 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 output.push_str(&format!(
182 "Coverage Report ({})\n",
183 report.format.to_string().cyan()
184 ));
185 output.push_str("============================\n\n");
186
187 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 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 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 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 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 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}