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 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}