Skip to main content

greentic_dev/
coverage_cmd.rs

1use anyhow::{Context, Result, bail};
2use serde_json::Value as JsonValue;
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7
8use crate::cli::CoverageArgs;
9
10const SUCCESS_EXIT_CODE: i32 = 0;
11const POLICY_MISSING_EXIT_CODE: i32 = 2;
12const SETUP_FAILURE_EXIT_CODE: i32 = 3;
13const RUN_FAILURE_EXIT_CODE: i32 = 4;
14const POLICY_FAILURE_EXIT_CODE: i32 = 5;
15
16pub fn run(args: CoverageArgs) -> Result<()> {
17    let exit_code = run_inner(args)?;
18    if exit_code == SUCCESS_EXIT_CODE {
19        return Ok(());
20    }
21    std::process::exit(exit_code);
22}
23
24fn run_inner(args: CoverageArgs) -> Result<i32> {
25    let policy_file = PathBuf::from(
26        std::env::var("COVERAGE_POLICY_FILE")
27            .unwrap_or_else(|_| "coverage-policy.json".to_string()),
28    );
29    let report_dir = PathBuf::from(
30        std::env::var("COVERAGE_REPORT_DIR").unwrap_or_else(|_| "target/coverage".to_string()),
31    );
32    let report_file = PathBuf::from(
33        std::env::var("COVERAGE_REPORT_FILE")
34            .unwrap_or_else(|_| report_dir.join("coverage.json").display().to_string()),
35    );
36    let offline = env_true("CARGO_NET_OFFLINE");
37
38    if !policy_file.is_file() {
39        print_policy_missing_instructions(&policy_file);
40        return Ok(POLICY_MISSING_EXIT_CODE);
41    }
42
43    log("ensuring coverage tools are installed");
44    if !args.skip_run {
45        if let Err(err) = ensure_tool("cargo-llvm-cov", "cargo-llvm-cov", offline) {
46            eprintln!("[coverage] {err}");
47            return Ok(SETUP_FAILURE_EXIT_CODE);
48        }
49        if let Err(err) = ensure_tool("cargo-nextest", "cargo-nextest", offline) {
50            eprintln!("[coverage] {err}");
51            return Ok(SETUP_FAILURE_EXIT_CODE);
52        }
53        if let Err(err) = ensure_llvm_tools(offline) {
54            eprintln!("[coverage] {err}");
55            return Ok(SETUP_FAILURE_EXIT_CODE);
56        }
57    }
58
59    fs::create_dir_all(&report_dir)
60        .with_context(|| format!("failed to create {}", report_dir.display()))?;
61
62    if args.skip_run {
63        log(&format!(
64            "skipping coverage run and reusing {}",
65            report_file.display()
66        ));
67    } else {
68        log("running cargo llvm-cov nextest");
69        let status = Command::new("cargo")
70            .args([
71                "llvm-cov",
72                "nextest",
73                "--ignore-run-fail",
74                "--json",
75                "--output-path",
76            ])
77            .arg(&report_file)
78            .args(["--workspace", "--all-features"])
79            .stdin(Stdio::inherit())
80            .stdout(Stdio::inherit())
81            .stderr(Stdio::inherit())
82            .status()
83            .context("failed to execute cargo llvm-cov nextest")?;
84        if !status.success() {
85            eprintln!("[coverage] coverage command failed before policy evaluation");
86            return Ok(RUN_FAILURE_EXIT_CODE);
87        }
88    }
89
90    if !report_file.is_file() {
91        eprintln!(
92            "[coverage] expected coverage report missing: {}",
93            report_file.display()
94        );
95        return Ok(RUN_FAILURE_EXIT_CODE);
96    }
97
98    log(&format!("evaluating policy from {}", policy_file.display()));
99    let policy = CoveragePolicy::load(&policy_file)?;
100    let report = CoverageReport::load(&report_file)?;
101    let result = evaluate_policy(&policy, &report, &std::env::current_dir()?);
102    if !result.violations.is_empty() {
103        println!("[coverage] policy check failed");
104        println!("[coverage] Codex instructions:");
105        println!(
106            "Increase test coverage for the files below or update the exclusion list only for generated code, tooling entrypoints, or thin wiring layers."
107        );
108        println!(
109            "Do not lower thresholds to make the report pass unless the team intentionally changes the policy."
110        );
111        println!("[coverage] violations:");
112        for violation in result.violations {
113            println!("- {violation}");
114        }
115        return Ok(POLICY_FAILURE_EXIT_CODE);
116    }
117
118    println!("[coverage] policy check passed");
119    println!(
120        "[coverage] workspace line coverage: {:.2}%",
121        result.workspace_line_percent
122    );
123    log("success");
124    log(&format!("report written to {}", report_file.display()));
125    Ok(SUCCESS_EXIT_CODE)
126}
127
128fn log(message: &str) {
129    println!("[coverage] {message}");
130}
131
132fn print_policy_missing_instructions(policy_file: &Path) {
133    println!("[coverage] missing policy file: {}", policy_file.display());
134    println!("[coverage] Codex instructions:");
135    println!("Create coverage-policy.json at the repository root with:");
136    println!("- a global line coverage minimum");
137    println!("- a default per-file line coverage minimum");
138    println!("- an explicit exclusion list for generated code or thin entrypoints");
139    println!("- per-file overrides for high-risk modules that need stricter targets");
140    println!("Suggested starting point:");
141    println!("{{");
142    println!("  \"version\": 1,");
143    println!("  \"global\": {{ \"line_coverage_min\": 60.0 }},");
144    println!("  \"defaults\": {{ \"per_file_line_coverage_min\": 60.0 }},");
145    println!("  \"exclusions\": {{ \"files\": [] }},");
146    println!("  \"per_file\": {{}}");
147    println!("}}");
148}
149
150fn env_true(name: &str) -> bool {
151    std::env::var(name)
152        .ok()
153        .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
154        .unwrap_or(false)
155}
156
157fn command_exists(name: &str) -> bool {
158    which::which(name).is_ok()
159}
160
161fn cargo_args_for_network(offline: bool) -> Vec<&'static str> {
162    if offline {
163        Vec::new()
164    } else {
165        vec!["--locked"]
166    }
167}
168
169fn ensure_binstall(offline: bool) -> Result<()> {
170    if command_exists("cargo-binstall") {
171        return Ok(());
172    }
173    if offline {
174        bail!("cargo-binstall is required but offline mode is enabled");
175    }
176
177    log("installing cargo-binstall");
178    let mut args = vec!["install", "cargo-binstall"];
179    args.extend(cargo_args_for_network(offline));
180    let status = Command::new("cargo")
181        .args(&args)
182        .stdin(Stdio::inherit())
183        .stdout(Stdio::inherit())
184        .stderr(Stdio::inherit())
185        .status()
186        .context("failed to install cargo-binstall")?;
187    if !status.success() {
188        bail!("failed to install cargo-binstall");
189    }
190    Ok(())
191}
192
193fn ensure_tool(bin: &str, package: &str, offline: bool) -> Result<()> {
194    if command_exists(bin) {
195        return Ok(());
196    }
197    ensure_binstall(offline)?;
198    if offline {
199        bail!("missing {package} but offline mode is enabled");
200    }
201
202    // Force reinstall: binstall trusts `~/.cargo/.crates.toml` and skips when it
203    // reports "already installed", but a CI cache may restore that metadata
204    // without restoring `~/.cargo/bin/{bin}` itself.
205    log(&format!("installing {package}"));
206    let mut command = Command::new("cargo");
207    command.arg("binstall");
208    command.args(cargo_args_for_network(offline));
209    command.args(["-y", "--force", package]);
210    let status = command
211        .stdin(Stdio::inherit())
212        .stdout(Stdio::inherit())
213        .stderr(Stdio::inherit())
214        .status()
215        .with_context(|| format!("failed to install {package}"))?;
216    if !status.success() {
217        bail!("failed to install {package}");
218    }
219    if !command_exists(bin) {
220        bail!("{package} install reported success but `{bin}` is not on PATH");
221    }
222    Ok(())
223}
224
225fn ensure_llvm_tools(offline: bool) -> Result<()> {
226    if !command_exists("rustup") {
227        bail!("rustup is required to add llvm-tools-preview");
228    }
229
230    let output = Command::new("rustup")
231        .args(["component", "list", "--installed"])
232        .stdout(Stdio::piped())
233        .stderr(Stdio::inherit())
234        .output()
235        .context("failed to inspect rustup components")?;
236    let stdout = String::from_utf8(output.stdout).context("rustup output was not valid UTF-8")?;
237    if stdout
238        .lines()
239        .any(|line| line.trim() == "llvm-tools-preview")
240    {
241        return Ok(());
242    }
243
244    if offline {
245        bail!("llvm-tools-preview is missing and offline mode is enabled");
246    }
247
248    log("installing llvm-tools-preview");
249    let status = Command::new("rustup")
250        .args(["component", "add", "llvm-tools-preview"])
251        .stdin(Stdio::inherit())
252        .stdout(Stdio::inherit())
253        .stderr(Stdio::inherit())
254        .status()
255        .context("failed to install llvm-tools-preview")?;
256    if !status.success() {
257        bail!("failed to install llvm-tools-preview");
258    }
259    Ok(())
260}
261
262#[derive(Debug)]
263struct CoveragePolicy {
264    global_line_min: f64,
265    default_per_file_min: f64,
266    excluded_paths: BTreeSet<String>,
267    per_file_line_min: BTreeMap<String, f64>,
268}
269
270impl CoveragePolicy {
271    fn load(path: &Path) -> Result<Self> {
272        let raw = fs::read_to_string(path)
273            .with_context(|| format!("failed to read {}", path.display()))?;
274        let json: JsonValue = serde_json::from_str(&raw)
275            .with_context(|| format!("failed to parse {}", path.display()))?;
276        let global_line_min = json
277            .get("global")
278            .and_then(|v| v.get("line_coverage_min"))
279            .and_then(JsonValue::as_f64)
280            .unwrap_or(0.0);
281        let default_per_file_min = json
282            .get("defaults")
283            .and_then(|v| v.get("per_file_line_coverage_min"))
284            .and_then(JsonValue::as_f64)
285            .unwrap_or(global_line_min);
286
287        let mut excluded_paths = BTreeSet::new();
288        if let Some(files) = json
289            .get("exclusions")
290            .and_then(|v| v.get("files"))
291            .and_then(JsonValue::as_array)
292        {
293            for entry in files {
294                match entry {
295                    JsonValue::String(path) => {
296                        excluded_paths.insert(path.clone());
297                    }
298                    JsonValue::Object(map) => {
299                        if let Some(path) = map.get("path").and_then(JsonValue::as_str) {
300                            excluded_paths.insert(path.to_string());
301                        }
302                    }
303                    _ => {}
304                }
305            }
306        }
307
308        let mut per_file_line_min = BTreeMap::new();
309        if let Some(per_file) = json.get("per_file").and_then(JsonValue::as_object) {
310            for (path, cfg) in per_file {
311                if let Some(min) = cfg.get("line_coverage_min").and_then(JsonValue::as_f64) {
312                    per_file_line_min.insert(path.clone(), min);
313                }
314            }
315        }
316
317        Ok(Self {
318            global_line_min,
319            default_per_file_min,
320            excluded_paths,
321            per_file_line_min,
322        })
323    }
324}
325
326#[derive(Debug)]
327struct CoverageReport {
328    files: Vec<FileCoverage>,
329    total_line_percent: f64,
330}
331
332#[derive(Debug)]
333struct FileCoverage {
334    rel_path: String,
335    line_percent: f64,
336    line_count: u64,
337    line_covered: u64,
338}
339
340impl CoverageReport {
341    fn load(path: &Path) -> Result<Self> {
342        let raw = fs::read_to_string(path)
343            .with_context(|| format!("failed to read {}", path.display()))?;
344        let json: JsonValue = serde_json::from_str(&raw)
345            .with_context(|| format!("failed to parse {}", path.display()))?;
346
347        let root = std::env::current_dir()?;
348        let data0 = json
349            .get("data")
350            .and_then(JsonValue::as_array)
351            .and_then(|arr| arr.first())
352            .cloned()
353            .unwrap_or_else(|| json.clone());
354
355        let total_line_percent = data0
356            .get("totals")
357            .and_then(|v| v.get("lines"))
358            .and_then(|v| v.get("percent"))
359            .and_then(JsonValue::as_f64)
360            .or_else(|| {
361                json.get("totals")
362                    .and_then(|v| v.get("lines"))
363                    .and_then(|v| v.get("percent"))
364                    .and_then(JsonValue::as_f64)
365            })
366            .unwrap_or(0.0);
367
368        let files_json = data0
369            .get("files")
370            .and_then(JsonValue::as_array)
371            .or_else(|| json.get("files").and_then(JsonValue::as_array))
372            .cloned()
373            .unwrap_or_default();
374
375        let mut files = Vec::new();
376        for file in files_json {
377            let Some(filename) = file.get("filename").and_then(JsonValue::as_str) else {
378                continue;
379            };
380            let rel_path = relativize_path(&root, filename);
381            let line_summary = file
382                .get("summary")
383                .and_then(|v| v.get("lines"))
384                .cloned()
385                .unwrap_or(JsonValue::Null);
386            files.push(FileCoverage {
387                rel_path,
388                line_percent: line_summary
389                    .get("percent")
390                    .and_then(JsonValue::as_f64)
391                    .unwrap_or(0.0),
392                line_count: line_summary
393                    .get("count")
394                    .and_then(JsonValue::as_u64)
395                    .unwrap_or(0),
396                line_covered: line_summary
397                    .get("covered")
398                    .and_then(JsonValue::as_u64)
399                    .unwrap_or(0),
400            });
401        }
402
403        Ok(Self {
404            files,
405            total_line_percent,
406        })
407    }
408}
409
410fn relativize_path(root: &Path, raw: &str) -> String {
411    let path = PathBuf::from(raw);
412    let canonical_root = root.canonicalize().ok();
413    path.canonicalize()
414        .ok()
415        .and_then(|canon| {
416            let root = canonical_root.as_deref().unwrap_or(root);
417            canon
418                .strip_prefix(root)
419                .ok()
420                .map(|rel| rel.to_string_lossy().replace('\\', "/"))
421        })
422        .unwrap_or_else(|| raw.replace('\\', "/"))
423}
424
425#[derive(Debug)]
426struct PolicyEvaluation {
427    workspace_line_percent: f64,
428    violations: Vec<String>,
429}
430
431fn evaluate_policy(
432    policy: &CoveragePolicy,
433    report: &CoverageReport,
434    _repo_root: &Path,
435) -> PolicyEvaluation {
436    let mut effective_line_count = 0u64;
437    let mut effective_line_covered = 0u64;
438    let mut violations = Vec::new();
439
440    for file in &report.files {
441        if policy.excluded_paths.contains(&file.rel_path) {
442            continue;
443        }
444
445        effective_line_count += file.line_count;
446        effective_line_covered += file.line_covered;
447        let expected = policy
448            .per_file_line_min
449            .get(&file.rel_path)
450            .copied()
451            .unwrap_or(policy.default_per_file_min);
452        if file.line_percent < expected {
453            violations.push(format!(
454                "{} line coverage {:.2}% is below required minimum {:.2}%",
455                file.rel_path, file.line_percent, expected
456            ));
457        }
458    }
459
460    let workspace_line_percent = if effective_line_count == 0 {
461        report.total_line_percent
462    } else {
463        (effective_line_covered as f64 / effective_line_count as f64) * 100.0
464    };
465
466    if workspace_line_percent < policy.global_line_min {
467        violations.insert(
468            0,
469            format!(
470                "workspace line coverage {:.2}% is below global minimum {:.2}%",
471                workspace_line_percent, policy.global_line_min
472            ),
473        );
474    }
475
476    PolicyEvaluation {
477        workspace_line_percent,
478        violations,
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::{CoveragePolicy, CoverageReport, evaluate_policy, relativize_path};
485    use std::collections::BTreeMap;
486    use std::path::Path;
487    use tempfile::tempdir;
488
489    #[test]
490    fn relativize_path_prefers_repo_relative_paths() {
491        let dir = tempdir().unwrap();
492        let file = dir.path().join("src").join("demo.rs");
493        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
494        std::fs::write(&file, "fn main() {}\n").unwrap();
495
496        let rel = relativize_path(dir.path(), file.to_str().unwrap());
497        assert_eq!(rel, "src/demo.rs");
498    }
499
500    #[test]
501    fn policy_loader_supports_exclusions_and_overrides() {
502        let dir = tempdir().unwrap();
503        let path = dir.path().join("coverage-policy.json");
504        std::fs::write(
505            &path,
506            r#"{
507              "global": { "line_coverage_min": 60.0 },
508              "defaults": { "per_file_line_coverage_min": 55.0 },
509              "exclusions": { "files": [ { "path": "src/generated.rs" }, "src/wrapper.rs" ] },
510              "per_file": { "src/core.rs": { "line_coverage_min": 80.0 } }
511            }"#,
512        )
513        .unwrap();
514
515        let policy = CoveragePolicy::load(&path).unwrap();
516        assert_eq!(policy.global_line_min, 60.0);
517        assert_eq!(policy.default_per_file_min, 55.0);
518        assert!(policy.excluded_paths.contains("src/generated.rs"));
519        assert_eq!(policy.per_file_line_min["src/core.rs"], 80.0);
520    }
521
522    #[test]
523    fn report_loader_reads_llvm_cov_json_shape() {
524        let dir = tempdir().unwrap();
525        let report_path = dir.path().join("coverage.json");
526        let file = dir.path().join("src").join("demo.rs");
527        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
528        std::fs::write(&file, "fn demo() {}\n").unwrap();
529
530        std::fs::write(
531            &report_path,
532            format!(
533                r#"{{
534                  "data": [{{
535                    "totals": {{ "lines": {{ "percent": 50.0 }} }},
536                    "files": [{{
537                      "filename": "{}",
538                      "summary": {{ "lines": {{ "percent": 75.0, "count": 4, "covered": 3 }} }}
539                    }}]
540                  }}]
541                }}"#,
542                file.display()
543            ),
544        )
545        .unwrap();
546
547        let old_cwd = std::env::current_dir().unwrap();
548        std::env::set_current_dir(dir.path()).unwrap();
549        let report = CoverageReport::load(&report_path).unwrap();
550        std::env::set_current_dir(old_cwd).unwrap();
551
552        assert_eq!(report.files.len(), 1);
553        assert_eq!(report.files[0].rel_path, "src/demo.rs");
554        assert_eq!(report.files[0].line_percent, 75.0);
555    }
556
557    #[test]
558    fn evaluation_uses_excluded_files_for_neither_global_nor_per_file_checks() {
559        let report = CoverageReport {
560            total_line_percent: 10.0,
561            files: vec![
562                super::FileCoverage {
563                    rel_path: "src/generated.rs".to_string(),
564                    line_percent: 0.0,
565                    line_count: 100,
566                    line_covered: 0,
567                },
568                super::FileCoverage {
569                    rel_path: "src/core.rs".to_string(),
570                    line_percent: 75.0,
571                    line_count: 4,
572                    line_covered: 3,
573                },
574            ],
575        };
576        let policy = CoveragePolicy {
577            global_line_min: 60.0,
578            default_per_file_min: 60.0,
579            excluded_paths: ["src/generated.rs".to_string()].into_iter().collect(),
580            per_file_line_min: BTreeMap::new(),
581        };
582
583        let result = evaluate_policy(&policy, &report, Path::new("."));
584        assert!(result.violations.is_empty());
585        assert_eq!(format!("{:.2}", result.workspace_line_percent), "75.00");
586    }
587}