use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::Path;
use miette::Result;
use serde::Serialize;
use crate::AppExit;
use crate::cli::{CoverageArgs, CoverageOutputFormat};
use crate::config::{
ImpactFileDescriptor, LoadedCoverageConfig, LoadedDocInventoryConfig, LoadedRule,
list_impact_files, load_coverage_configs, load_doc_inventory_configs, load_impact_files,
resolve_rule_path, root_dir_from_option,
};
use crate::git::get_tracked_paths;
use crate::reporters::OutputWarning;
use crate::rules::matches_pattern;
pub const COVERAGE_AUDIT_SCHEMA_VERSION: &str = "docpact.coverage.v1";
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct CoverageReport {
pub schema_version: String,
pub tool_name: String,
pub tool_version: String,
pub command: String,
pub warnings: Vec<OutputWarning>,
pub rule_coverage: RuleCoverageReport,
pub doc_reachability: DocReachabilityReport,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct RuleCoverageReport {
pub governed_path_count: usize,
pub covered_path_count: usize,
pub uncovered_path_count: usize,
pub matched_rule_count: usize,
pub dead_rule_count: usize,
pub coverage_ratio: f64,
pub uncovered_paths: Vec<String>,
pub uncovered_hotspots: Vec<PathCount>,
pub dead_rules: Vec<DeadRuleRecord>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct DocReachabilityReport {
pub inventoried_doc_count: usize,
pub reachable_doc_count: usize,
pub orphan_doc_count: usize,
pub reachable_ratio: f64,
pub orphan_docs: Vec<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PathCount {
pub pattern: String,
pub count: usize,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DeadRuleRecord {
pub rule_id: String,
pub source: String,
pub trigger_patterns: Vec<String>,
}
pub fn run(args: CoverageArgs) -> Result<AppExit> {
let report = execute(&args)?;
emit_report(&report, args.format);
Ok(AppExit::Success)
}
pub fn execute(args: &CoverageArgs) -> Result<CoverageReport> {
let root_dir = root_dir_from_option(args.root.as_deref())?;
let impact_files = list_impact_files(&root_dir, args.config.as_deref())?;
let tracked_paths = collect_tracked_paths(&root_dir, &impact_files)?;
let loaded_rules = load_impact_files(&root_dir, args.config.as_deref())?;
let coverage_configs = load_coverage_configs(&root_dir, args.config.as_deref())?;
let doc_inventory_configs = load_doc_inventory_configs(&root_dir, args.config.as_deref())?;
let governed_paths = tracked_paths
.iter()
.filter(|path| path_in_scope(path, &coverage_configs, ScopeSelector::Coverage))
.cloned()
.collect::<Vec<_>>();
let covered_paths = governed_paths
.iter()
.filter(|path| path_matches_any_rule_trigger(path, &loaded_rules))
.cloned()
.collect::<Vec<_>>();
let covered_set = covered_paths.iter().cloned().collect::<HashSet<_>>();
let uncovered_paths = governed_paths
.iter()
.filter(|path| !covered_set.contains(path.as_str()))
.cloned()
.collect::<Vec<_>>();
let dead_rules = loaded_rules
.iter()
.filter(|loaded| {
!governed_paths.iter().any(|path| {
loaded.rule.triggers.iter().any(|trigger| {
matches_pattern(path, &resolve_rule_path(&loaded.base_dir, &trigger.path))
})
})
})
.map(|loaded| DeadRuleRecord {
rule_id: loaded.rule.id.clone(),
source: loaded.source.clone(),
trigger_patterns: loaded
.rule
.triggers
.iter()
.map(|trigger| resolve_rule_path(&loaded.base_dir, &trigger.path))
.collect(),
})
.collect::<Vec<_>>();
let required_docs = loaded_rules
.iter()
.flat_map(|loaded| {
loaded
.rule
.required_docs
.iter()
.map(|doc| resolve_rule_path(&loaded.base_dir, &doc.path))
})
.collect::<BTreeSet<_>>();
let inventoried_docs = tracked_paths
.iter()
.filter(|path| is_doc_path(path))
.filter(|path| path_in_scope(path, &doc_inventory_configs, ScopeSelector::DocInventory))
.cloned()
.collect::<Vec<_>>();
let orphan_docs = inventoried_docs
.iter()
.filter(|path| !required_docs.contains(path.as_str()))
.cloned()
.collect::<Vec<_>>();
Ok(CoverageReport {
schema_version: COVERAGE_AUDIT_SCHEMA_VERSION.into(),
tool_name: env!("CARGO_PKG_NAME").into(),
tool_version: env!("CARGO_PKG_VERSION").into(),
command: "coverage".into(),
warnings: Vec::new(),
rule_coverage: RuleCoverageReport {
governed_path_count: governed_paths.len(),
covered_path_count: covered_paths.len(),
uncovered_path_count: uncovered_paths.len(),
matched_rule_count: loaded_rules.len().saturating_sub(dead_rules.len()),
dead_rule_count: dead_rules.len(),
coverage_ratio: ratio(covered_paths.len(), governed_paths.len()),
uncovered_paths: uncovered_paths.clone(),
uncovered_hotspots: path_hotspots(&uncovered_paths),
dead_rules,
},
doc_reachability: DocReachabilityReport {
inventoried_doc_count: inventoried_docs.len(),
reachable_doc_count: inventoried_docs.len().saturating_sub(orphan_docs.len()),
orphan_doc_count: orphan_docs.len(),
reachable_ratio: ratio(
inventoried_docs.len().saturating_sub(orphan_docs.len()),
inventoried_docs.len(),
),
orphan_docs,
},
})
}
fn emit_report(report: &CoverageReport, format: CoverageOutputFormat) {
match format {
CoverageOutputFormat::Text => emit_text_report(report),
CoverageOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(report).expect("coverage report should serialize")
),
}
}
fn emit_text_report(report: &CoverageReport) {
let status = if report.rule_coverage.uncovered_path_count > 0
|| report.rule_coverage.dead_rule_count > 0
|| report.doc_reachability.orphan_doc_count > 0
{
"attention required"
} else {
"pass"
};
println!("Docpact coverage: {status}.");
println!(
"Rule coverage: governed_paths={}, covered_paths={}, uncovered_paths={}, dead_rules={}, coverage_ratio={:.3}",
report.rule_coverage.governed_path_count,
report.rule_coverage.covered_path_count,
report.rule_coverage.uncovered_path_count,
report.rule_coverage.dead_rule_count,
report.rule_coverage.coverage_ratio,
);
emit_top_path_counts(
"Uncovered hotspots",
&report.rule_coverage.uncovered_hotspots,
);
emit_top_dead_rules(&report.rule_coverage.dead_rules);
println!(
"Doc reachability: inventoried_docs={}, reachable_docs={}, orphan_docs={}, reachable_ratio={:.3}",
report.doc_reachability.inventoried_doc_count,
report.doc_reachability.reachable_doc_count,
report.doc_reachability.orphan_doc_count,
report.doc_reachability.reachable_ratio,
);
emit_top_paths("Orphan docs", &report.doc_reachability.orphan_docs);
if status == "pass" {
println!("Next: no coverage action required.");
} else {
println!(
"Next: add missing rule triggers, remove dead rules, or exclude intentional orphan docs."
);
}
}
fn emit_top_path_counts(label: &str, items: &[PathCount]) {
println!("{label}:");
if items.is_empty() {
println!("- none");
return;
}
for item in items.iter().take(10) {
println!(
"- {} matched {} uncovered path(s)",
item.pattern, item.count
);
}
if items.len() > 10 {
println!("- ... {} more", items.len() - 10);
}
}
fn emit_top_dead_rules(dead_rules: &[DeadRuleRecord]) {
println!("Dead rules:");
if dead_rules.is_empty() {
println!("- none");
return;
}
for rule in dead_rules.iter().take(10) {
println!(
"- remove or update rule {} from {} (triggers: {})",
rule.rule_id,
rule.source,
rule.trigger_patterns.join(",")
);
}
if dead_rules.len() > 10 {
println!("- ... {} more", dead_rules.len() - 10);
}
}
fn emit_top_paths(label: &str, items: &[String]) {
println!("{label}:");
if items.is_empty() {
println!("- none");
return;
}
for item in items.iter().take(10) {
println!("- {item}");
}
if items.len() > 10 {
println!("- ... {} more", items.len() - 10);
}
}
fn collect_tracked_paths(
root_dir: &Path,
impact_files: &[ImpactFileDescriptor],
) -> Result<Vec<String>> {
let mut tracked = BTreeSet::new();
for descriptor in impact_files {
let repo_root = if descriptor.base_dir.is_empty() {
root_dir.to_path_buf()
} else {
root_dir.join(&descriptor.base_dir)
};
for path in get_tracked_paths(&repo_root)? {
let normalized = if descriptor.base_dir.is_empty() {
path
} else {
resolve_rule_path(&descriptor.base_dir, &path)
};
tracked.insert(normalized);
}
}
Ok(tracked.into_iter().collect())
}
fn path_in_scope<T>(path: &str, loaded_configs: &[T], selector: ScopeSelector) -> bool
where
T: ScopedPatterns,
{
if matches_any_scope_pattern(path, loaded_configs, selector, PatternKind::Exclude) {
return false;
}
let has_include = loaded_configs
.iter()
.any(|loaded| !loaded.patterns(PatternKind::Include).is_empty());
if !has_include {
return true;
}
matches_any_scope_pattern(path, loaded_configs, selector, PatternKind::Include)
}
fn matches_any_scope_pattern<T>(
path: &str,
loaded_configs: &[T],
selector: ScopeSelector,
pattern_kind: PatternKind,
) -> bool
where
T: ScopedPatterns,
{
loaded_configs.iter().any(|loaded| {
let _ = selector;
loaded
.patterns(pattern_kind)
.iter()
.any(|pattern| matches_pattern(path, &resolve_rule_path(loaded.base_dir(), pattern)))
})
}
fn path_matches_any_rule_trigger(path: &str, loaded_rules: &[LoadedRule]) -> bool {
loaded_rules.iter().any(|loaded| {
loaded.rule.triggers.iter().any(|trigger| {
matches_pattern(path, &resolve_rule_path(&loaded.base_dir, &trigger.path))
})
})
}
fn path_hotspots(paths: &[String]) -> Vec<PathCount> {
let mut counts = BTreeMap::new();
for path in paths {
let pattern = hotspot_pattern(path);
*counts.entry(pattern).or_insert(0usize) += 1;
}
let mut hotspots = counts
.into_iter()
.map(|(pattern, count)| PathCount { pattern, count })
.collect::<Vec<_>>();
hotspots.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then_with(|| left.pattern.cmp(&right.pattern))
});
hotspots
}
fn hotspot_pattern(path: &str) -> String {
match path.rsplit_once('/') {
Some((parent, _)) => format!("{parent}/**"),
None => path.to_string(),
}
}
fn is_doc_path(path: &str) -> bool {
path.ends_with(".md") || path.ends_with(".yaml") || path.ends_with(".yml")
}
fn ratio(numerator: usize, denominator: usize) -> f64 {
if denominator == 0 {
1.0
} else {
numerator as f64 / denominator as f64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScopeSelector {
Coverage,
DocInventory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PatternKind {
Include,
Exclude,
}
trait ScopedPatterns {
fn base_dir(&self) -> &str;
fn patterns(&self, pattern_kind: PatternKind) -> &[String];
}
impl ScopedPatterns for LoadedCoverageConfig {
fn base_dir(&self) -> &str {
&self.base_dir
}
fn patterns(&self, pattern_kind: PatternKind) -> &[String] {
match pattern_kind {
PatternKind::Include => &self.coverage.include,
PatternKind::Exclude => &self.coverage.exclude,
}
}
}
impl ScopedPatterns for LoadedDocInventoryConfig {
fn base_dir(&self) -> &str {
&self.base_dir
}
fn patterns(&self, pattern_kind: PatternKind) -> &[String] {
match pattern_kind {
PatternKind::Include => &self.doc_inventory.include,
PatternKind::Exclude => &self.doc_inventory.exclude,
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::AppExit;
use crate::cli::{CoverageArgs, CoverageOutputFormat};
use super::{COVERAGE_AUDIT_SCHEMA_VERSION, execute, run};
fn temp_dir(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be valid")
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{nanos}-{}", std::process::id()));
fs::create_dir_all(&path).expect("temp dir should be created");
path
}
fn git(root: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(root)
.status()
.expect("git should run");
assert!(
status.success(),
"git command failed: git {}",
args.join(" ")
);
}
fn init_git_repo(root: &Path) {
git(root, &["init"]);
git(root, &["config", "user.name", "Codex"]);
git(root, &["config", "user.email", "codex@example.com"]);
}
fn base_args(root: PathBuf) -> CoverageArgs {
CoverageArgs {
root: Some(root),
config: None,
format: CoverageOutputFormat::Json,
}
}
#[test]
fn repository_coverage_reports_uncovered_paths_dead_rules_and_orphans() {
let root = temp_dir("docpact-coverage-repo");
init_git_repo(&root);
fs::create_dir_all(root.join(".docpact")).expect("doc dir");
fs::create_dir_all(root.join("src/api")).expect("src api dir");
fs::create_dir_all(root.join("src/payments")).expect("src payments dir");
fs::create_dir_all(root.join("src/commands")).expect("src commands dir");
fs::create_dir_all(root.join("docs")).expect("docs dir");
fs::write(
root.join(".docpact/config.yaml"),
r#"
version: 1
layout: repo
lastReviewedAt: "2026-04-21"
lastReviewedCommit: "abc"
coverage:
include:
- src/**
docInventory:
include:
- docs/**
rules:
- id: api-rule
scope: repo
repo: example
triggers:
- path: src/api/**
kind: code
requiredDocs:
- path: docs/api.md
mode: review_or_update
reason: api
- id: dead-rule
scope: repo
repo: example
triggers:
- path: src/commands/**
kind: code
requiredDocs:
- path: docs/commands.md
mode: review_or_update
reason: commands
"#,
)
.expect("config");
fs::write(root.join("src/api/client.ts"), "export const api = 1;\n").expect("api");
fs::write(
root.join("src/payments/charge.ts"),
"export const charge = 1;\n",
)
.expect("payments");
fs::write(root.join("docs/api.md"), "# API\n").expect("api doc");
fs::write(root.join("docs/old.md"), "# Old\n").expect("old doc");
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let report = execute(&base_args(root)).expect("coverage should execute");
assert_eq!(report.schema_version, COVERAGE_AUDIT_SCHEMA_VERSION);
assert_eq!(report.command, "coverage");
assert!(report.warnings.is_empty());
assert_eq!(report.rule_coverage.governed_path_count, 2);
assert_eq!(report.rule_coverage.covered_path_count, 1);
assert_eq!(
report.rule_coverage.uncovered_paths,
vec!["src/payments/charge.ts"]
);
assert_eq!(report.rule_coverage.dead_rule_count, 1);
assert_eq!(report.rule_coverage.dead_rules[0].rule_id, "dead-rule");
assert_eq!(report.doc_reachability.inventoried_doc_count, 2);
assert_eq!(report.doc_reachability.orphan_docs, vec!["docs/old.md"]);
}
#[test]
fn workspace_coverage_uses_repo_relative_scopes() {
let root = temp_dir("docpact-coverage-workspace");
init_git_repo(&root);
fs::create_dir_all(root.join(".docpact")).expect("root doc dir");
fs::create_dir_all(root.join("subrepo/.docpact")).expect("subrepo doc dir");
fs::create_dir_all(root.join("subrepo/src/api")).expect("subrepo src api dir");
fs::create_dir_all(root.join("subrepo/src/payments")).expect("subrepo src payments dir");
fs::create_dir_all(root.join("subrepo/docs")).expect("subrepo docs dir");
fs::write(
root.join(".docpact/config.yaml"),
r#"
version: 1
layout: workspace
lastReviewedAt: "2026-04-21"
lastReviewedCommit: "abc"
rules: []
"#,
)
.expect("root config");
fs::write(
root.join("subrepo/.docpact/config.yaml"),
r#"
version: 1
layout: repo
lastReviewedAt: "2026-04-21"
lastReviewedCommit: "abc"
coverage:
include:
- src/**
docInventory:
include:
- docs/**
repo:
id: subrepo
rules:
- id: api-rule
scope: repo
repo: subrepo
triggers:
- path: src/api/**
kind: code
requiredDocs:
- path: docs/api.md
mode: review_or_update
reason: api
"#,
)
.expect("subrepo config");
fs::write(
root.join("subrepo/src/api/client.ts"),
"export const api = 1;\n",
)
.expect("api");
fs::write(
root.join("subrepo/src/payments/charge.ts"),
"export const charge = 1;\n",
)
.expect("payments");
fs::write(root.join("subrepo/docs/api.md"), "# API\n").expect("api doc");
fs::write(root.join("subrepo/docs/notes.md"), "# Notes\n").expect("notes doc");
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let report = execute(&base_args(root)).expect("coverage should execute");
assert_eq!(
report.rule_coverage.uncovered_paths,
vec!["subrepo/src/payments/charge.ts"]
);
assert_eq!(
report.doc_reachability.orphan_docs,
vec!["subrepo/docs/notes.md"]
);
}
#[test]
fn coverage_command_returns_success_exit() {
let root = temp_dir("docpact-coverage-run");
init_git_repo(&root);
fs::create_dir_all(root.join(".docpact")).expect("doc dir");
fs::write(
root.join(".docpact/config.yaml"),
r#"
version: 1
layout: repo
lastReviewedAt: "2026-04-21"
lastReviewedCommit: "abc"
rules: []
"#,
)
.expect("config");
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let exit = run(base_args(root)).expect("coverage run should execute");
assert_eq!(exit, AppExit::Success);
}
}