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([
210        "-y",
211        "--force",
212        "--maximum-resolution-timeout",
213        "60",
214        package,
215    ]);
216    let status = command
217        .stdin(Stdio::inherit())
218        .stdout(Stdio::inherit())
219        .stderr(Stdio::inherit())
220        .status()
221        .with_context(|| format!("failed to install {package}"))?;
222    if !status.success() {
223        bail!("failed to install {package}");
224    }
225    if !command_exists(bin) {
226        bail!("{package} install reported success but `{bin}` is not on PATH");
227    }
228    Ok(())
229}
230
231fn ensure_llvm_tools(offline: bool) -> Result<()> {
232    if !command_exists("rustup") {
233        bail!("rustup is required to add llvm-tools-preview");
234    }
235
236    let output = Command::new("rustup")
237        .args(["component", "list", "--installed"])
238        .stdout(Stdio::piped())
239        .stderr(Stdio::inherit())
240        .output()
241        .context("failed to inspect rustup components")?;
242    let stdout = String::from_utf8(output.stdout).context("rustup output was not valid UTF-8")?;
243    if stdout
244        .lines()
245        .any(|line| line.trim() == "llvm-tools-preview")
246    {
247        return Ok(());
248    }
249
250    if offline {
251        bail!("llvm-tools-preview is missing and offline mode is enabled");
252    }
253
254    log("installing llvm-tools-preview");
255    let status = Command::new("rustup")
256        .args(["component", "add", "llvm-tools-preview"])
257        .stdin(Stdio::inherit())
258        .stdout(Stdio::inherit())
259        .stderr(Stdio::inherit())
260        .status()
261        .context("failed to install llvm-tools-preview")?;
262    if !status.success() {
263        bail!("failed to install llvm-tools-preview");
264    }
265    Ok(())
266}
267
268#[derive(Debug)]
269struct CoveragePolicy {
270    global_line_min: f64,
271    default_per_file_min: f64,
272    excluded_paths: BTreeSet<String>,
273    per_file_line_min: BTreeMap<String, f64>,
274}
275
276impl CoveragePolicy {
277    fn load(path: &Path) -> Result<Self> {
278        let raw = fs::read_to_string(path)
279            .with_context(|| format!("failed to read {}", path.display()))?;
280        let json: JsonValue = serde_json::from_str(&raw)
281            .with_context(|| format!("failed to parse {}", path.display()))?;
282        let global_line_min = json
283            .get("global")
284            .and_then(|v| v.get("line_coverage_min"))
285            .and_then(JsonValue::as_f64)
286            .unwrap_or(0.0);
287        let default_per_file_min = json
288            .get("defaults")
289            .and_then(|v| v.get("per_file_line_coverage_min"))
290            .and_then(JsonValue::as_f64)
291            .unwrap_or(global_line_min);
292
293        let mut excluded_paths = BTreeSet::new();
294        if let Some(files) = json
295            .get("exclusions")
296            .and_then(|v| v.get("files"))
297            .and_then(JsonValue::as_array)
298        {
299            for entry in files {
300                match entry {
301                    JsonValue::String(path) => {
302                        excluded_paths.insert(path.clone());
303                    }
304                    JsonValue::Object(map) => {
305                        if let Some(path) = map.get("path").and_then(JsonValue::as_str) {
306                            excluded_paths.insert(path.to_string());
307                        }
308                    }
309                    _ => {}
310                }
311            }
312        }
313
314        let mut per_file_line_min = BTreeMap::new();
315        if let Some(per_file) = json.get("per_file").and_then(JsonValue::as_object) {
316            for (path, cfg) in per_file {
317                if let Some(min) = cfg.get("line_coverage_min").and_then(JsonValue::as_f64) {
318                    per_file_line_min.insert(path.clone(), min);
319                }
320            }
321        }
322
323        Ok(Self {
324            global_line_min,
325            default_per_file_min,
326            excluded_paths,
327            per_file_line_min,
328        })
329    }
330}
331
332#[derive(Debug)]
333struct CoverageReport {
334    files: Vec<FileCoverage>,
335    total_line_percent: f64,
336}
337
338#[derive(Debug)]
339struct FileCoverage {
340    rel_path: String,
341    line_percent: f64,
342    line_count: u64,
343    line_covered: u64,
344}
345
346impl CoverageReport {
347    fn load(path: &Path) -> Result<Self> {
348        let raw = fs::read_to_string(path)
349            .with_context(|| format!("failed to read {}", path.display()))?;
350        let json: JsonValue = serde_json::from_str(&raw)
351            .with_context(|| format!("failed to parse {}", path.display()))?;
352
353        let root = std::env::current_dir()?;
354        let data0 = json
355            .get("data")
356            .and_then(JsonValue::as_array)
357            .and_then(|arr| arr.first())
358            .cloned()
359            .unwrap_or_else(|| json.clone());
360
361        let total_line_percent = data0
362            .get("totals")
363            .and_then(|v| v.get("lines"))
364            .and_then(|v| v.get("percent"))
365            .and_then(JsonValue::as_f64)
366            .or_else(|| {
367                json.get("totals")
368                    .and_then(|v| v.get("lines"))
369                    .and_then(|v| v.get("percent"))
370                    .and_then(JsonValue::as_f64)
371            })
372            .unwrap_or(0.0);
373
374        let files_json = data0
375            .get("files")
376            .and_then(JsonValue::as_array)
377            .or_else(|| json.get("files").and_then(JsonValue::as_array))
378            .cloned()
379            .unwrap_or_default();
380
381        let mut files = Vec::new();
382        for file in files_json {
383            let Some(filename) = file.get("filename").and_then(JsonValue::as_str) else {
384                continue;
385            };
386            let rel_path = relativize_path(&root, filename);
387            let line_summary = file
388                .get("summary")
389                .and_then(|v| v.get("lines"))
390                .cloned()
391                .unwrap_or(JsonValue::Null);
392            files.push(FileCoverage {
393                rel_path,
394                line_percent: line_summary
395                    .get("percent")
396                    .and_then(JsonValue::as_f64)
397                    .unwrap_or(0.0),
398                line_count: line_summary
399                    .get("count")
400                    .and_then(JsonValue::as_u64)
401                    .unwrap_or(0),
402                line_covered: line_summary
403                    .get("covered")
404                    .and_then(JsonValue::as_u64)
405                    .unwrap_or(0),
406            });
407        }
408
409        Ok(Self {
410            files,
411            total_line_percent,
412        })
413    }
414}
415
416fn relativize_path(root: &Path, raw: &str) -> String {
417    let path = PathBuf::from(raw);
418    let canonical_root = root.canonicalize().ok();
419    path.canonicalize()
420        .ok()
421        .and_then(|canon| {
422            let root = canonical_root.as_deref().unwrap_or(root);
423            canon
424                .strip_prefix(root)
425                .ok()
426                .map(|rel| rel.to_string_lossy().replace('\\', "/"))
427        })
428        .unwrap_or_else(|| raw.replace('\\', "/"))
429}
430
431#[derive(Debug)]
432struct PolicyEvaluation {
433    workspace_line_percent: f64,
434    violations: Vec<String>,
435}
436
437fn evaluate_policy(
438    policy: &CoveragePolicy,
439    report: &CoverageReport,
440    _repo_root: &Path,
441) -> PolicyEvaluation {
442    let mut effective_line_count = 0u64;
443    let mut effective_line_covered = 0u64;
444    let mut violations = Vec::new();
445
446    for file in &report.files {
447        if policy.excluded_paths.contains(&file.rel_path) {
448            continue;
449        }
450
451        effective_line_count += file.line_count;
452        effective_line_covered += file.line_covered;
453        let expected = policy
454            .per_file_line_min
455            .get(&file.rel_path)
456            .copied()
457            .unwrap_or(policy.default_per_file_min);
458        if file.line_percent < expected {
459            violations.push(format!(
460                "{} line coverage {:.2}% is below required minimum {:.2}%",
461                file.rel_path, file.line_percent, expected
462            ));
463        }
464    }
465
466    let workspace_line_percent = if effective_line_count == 0 {
467        report.total_line_percent
468    } else {
469        (effective_line_covered as f64 / effective_line_count as f64) * 100.0
470    };
471
472    if workspace_line_percent < policy.global_line_min {
473        violations.insert(
474            0,
475            format!(
476                "workspace line coverage {:.2}% is below global minimum {:.2}%",
477                workspace_line_percent, policy.global_line_min
478            ),
479        );
480    }
481
482    PolicyEvaluation {
483        workspace_line_percent,
484        violations,
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::{CoveragePolicy, CoverageReport, evaluate_policy, relativize_path};
491    use std::collections::BTreeMap;
492    use std::path::Path;
493    use tempfile::tempdir;
494
495    #[test]
496    fn relativize_path_prefers_repo_relative_paths() {
497        let dir = tempdir().unwrap();
498        let file = dir.path().join("src").join("demo.rs");
499        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
500        std::fs::write(&file, "fn main() {}\n").unwrap();
501
502        let rel = relativize_path(dir.path(), file.to_str().unwrap());
503        assert_eq!(rel, "src/demo.rs");
504    }
505
506    #[test]
507    fn policy_loader_supports_exclusions_and_overrides() {
508        let dir = tempdir().unwrap();
509        let path = dir.path().join("coverage-policy.json");
510        std::fs::write(
511            &path,
512            r#"{
513              "global": { "line_coverage_min": 60.0 },
514              "defaults": { "per_file_line_coverage_min": 55.0 },
515              "exclusions": { "files": [ { "path": "src/generated.rs" }, "src/wrapper.rs" ] },
516              "per_file": { "src/core.rs": { "line_coverage_min": 80.0 } }
517            }"#,
518        )
519        .unwrap();
520
521        let policy = CoveragePolicy::load(&path).unwrap();
522        assert_eq!(policy.global_line_min, 60.0);
523        assert_eq!(policy.default_per_file_min, 55.0);
524        assert!(policy.excluded_paths.contains("src/generated.rs"));
525        assert_eq!(policy.per_file_line_min["src/core.rs"], 80.0);
526    }
527
528    #[test]
529    fn report_loader_reads_llvm_cov_json_shape() {
530        let dir = tempdir().unwrap();
531        let report_path = dir.path().join("coverage.json");
532        let file = dir.path().join("src").join("demo.rs");
533        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
534        std::fs::write(&file, "fn demo() {}\n").unwrap();
535
536        std::fs::write(
537            &report_path,
538            format!(
539                r#"{{
540                  "data": [{{
541                    "totals": {{ "lines": {{ "percent": 50.0 }} }},
542                    "files": [{{
543                      "filename": "{}",
544                      "summary": {{ "lines": {{ "percent": 75.0, "count": 4, "covered": 3 }} }}
545                    }}]
546                  }}]
547                }}"#,
548                file.display()
549            ),
550        )
551        .unwrap();
552
553        let old_cwd = std::env::current_dir().unwrap();
554        std::env::set_current_dir(dir.path()).unwrap();
555        let report = CoverageReport::load(&report_path).unwrap();
556        std::env::set_current_dir(old_cwd).unwrap();
557
558        assert_eq!(report.files.len(), 1);
559        assert_eq!(report.files[0].rel_path, "src/demo.rs");
560        assert_eq!(report.files[0].line_percent, 75.0);
561    }
562
563    #[test]
564    fn evaluation_uses_excluded_files_for_neither_global_nor_per_file_checks() {
565        let report = CoverageReport {
566            total_line_percent: 10.0,
567            files: vec![
568                super::FileCoverage {
569                    rel_path: "src/generated.rs".to_string(),
570                    line_percent: 0.0,
571                    line_count: 100,
572                    line_covered: 0,
573                },
574                super::FileCoverage {
575                    rel_path: "src/core.rs".to_string(),
576                    line_percent: 75.0,
577                    line_count: 4,
578                    line_covered: 3,
579                },
580            ],
581        };
582        let policy = CoveragePolicy {
583            global_line_min: 60.0,
584            default_per_file_min: 60.0,
585            excluded_paths: ["src/generated.rs".to_string()].into_iter().collect(),
586            per_file_line_min: BTreeMap::new(),
587        };
588
589        let result = evaluate_policy(&policy, &report, Path::new("."));
590        assert!(result.violations.is_empty());
591        assert_eq!(format!("{:.2}", result.workspace_line_percent), "75.00");
592    }
593}