Skip to main content

rac_engine/
coverage.rs

1//! Traceability coverage report (`decided.services.coverage`) — typed
2//! completeness gaps derived from the resolved relationship graph.
3//! Advisory, never a build failure: `decided coverage` always exits 0 on a
4//! real directory. Three gap classes, one type and one expected edge
5//! direction each:
6//!
7//! - **unscheduled** — a requirement with no resolved INCOMING edge from a
8//!   roadmap,
9//! - **unapplied** — a decision with no resolved incoming edge from a
10//!   requirement or roadmap,
11//! - **unscoped** — a roadmap with no resolved OUTGOING edge to a
12//!   requirement.
13//!
14//! Self-edges (`resolved_path == source_path`) are skipped; external and
15//! unresolved references contribute nothing (`resolved_path` is None).
16//! Order is deterministic: gap class (unscheduled, unapplied, unscoped),
17//! then ascending path.
18
19use std::collections::{HashMap, HashSet};
20
21use crate::identity::artifact_identifier;
22use crate::relationships::{corpus_items, relationships_from_corpus};
23
24pub const GAP_UNSCHEDULED: &str = "unscheduled";
25pub const GAP_UNAPPLIED: &str = "unapplied";
26pub const GAP_UNSCOPED: &str = "unscoped";
27
28/// The per-class missing-coverage description (`_MISSING`).
29fn missing_text(gap: &str) -> &'static str {
30    match gap {
31        GAP_UNSCHEDULED => "no roadmap schedules this requirement",
32        GAP_UNAPPLIED => "no requirement or roadmap applies this decision",
33        _ => "this roadmap references no requirement",
34    }
35}
36
37/// One typed traceability gap (`CoverageGap`).
38#[derive(Debug)]
39pub struct CoverageGap {
40    pub path: String,
41    pub id: String,
42    pub artifact_type: String,
43    pub gap: &'static str,
44    pub missing: &'static str,
45}
46
47/// The coverage report for a directory (`CoverageReport`).
48#[derive(Debug)]
49pub struct CoverageReport {
50    pub directory: String,
51    pub gaps: Vec<CoverageGap>,
52}
53
54impl CoverageReport {
55    /// `counts` — `(unscheduled, unapplied, unscoped)`.
56    pub fn counts(&self) -> (usize, usize, usize) {
57        let mut out = (0usize, 0usize, 0usize);
58        for gap in &self.gaps {
59            match gap.gap {
60                GAP_UNSCHEDULED => out.0 += 1,
61                GAP_UNAPPLIED => out.1 += 1,
62                _ => out.2 += 1,
63            }
64        }
65        out
66    }
67}
68
69fn class_order(gap: &str) -> usize {
70    match gap {
71        GAP_UNSCHEDULED => 0,
72        GAP_UNAPPLIED => 1,
73        _ => 2,
74    }
75}
76
77/// `analyze_coverage(directory)` — always recursive, no writes, no git.
78pub fn analyze_coverage(directory: &str) -> CoverageReport {
79    let items = corpus_items(directory, true);
80    // The identity index rows coverage reads: (path, id, type) per artifact,
81    // unknown documents included with type "unknown" (they never gap).
82    let index: Vec<(String, String, String)> = items
83        .iter()
84        .map(|item| {
85            let artifact_type = item
86                .spec
87                .map(|s| s.name.clone())
88                .unwrap_or_else(|| "unknown".to_string());
89            let id = artifact_identifier(&item.artifact, item.spec, &item.path);
90            (item.path.clone(), id, artifact_type)
91        })
92        .collect();
93    let type_by_path: HashMap<&str, &str> = index
94        .iter()
95        .map(|(path, _, artifact_type)| (path.as_str(), artifact_type.as_str()))
96        .collect();
97    let relationships = relationships_from_corpus(&items);
98
99    // Resolved incoming source types and resolved outgoing target types.
100    let mut incoming_types: HashMap<&str, HashSet<&str>> = index
101        .iter()
102        .map(|(path, _, _)| (path.as_str(), HashSet::new()))
103        .collect();
104    let mut outgoing_types: HashMap<&str, HashSet<&str>> = index
105        .iter()
106        .map(|(path, _, _)| (path.as_str(), HashSet::new()))
107        .collect();
108    for rel in &relationships {
109        let Some(resolved) = rel.resolved_path.as_deref() else {
110            continue;
111        };
112        if resolved == rel.source_path {
113            continue;
114        }
115        let source_type = type_by_path.get(rel.source_path.as_str()).copied();
116        let target_type = type_by_path.get(resolved).copied();
117        if let (Some(types), Some(source_type)) = (incoming_types.get_mut(resolved), source_type) {
118            types.insert(source_type);
119        }
120        if let (Some(types), Some(target_type)) =
121            (outgoing_types.get_mut(rel.source_path.as_str()), target_type)
122        {
123            types.insert(target_type);
124        }
125    }
126
127    let mut gaps: Vec<CoverageGap> = Vec::new();
128    for (path, id, artifact_type) in &index {
129        let incoming = &incoming_types[path.as_str()];
130        let gap = match artifact_type.as_str() {
131            "requirement" if !incoming.contains("roadmap") => GAP_UNSCHEDULED,
132            "decision" if !incoming.contains("requirement") && !incoming.contains("roadmap") => {
133                GAP_UNAPPLIED
134            }
135            "roadmap" if !outgoing_types[path.as_str()].contains("requirement") => GAP_UNSCOPED,
136            _ => continue,
137        };
138        gaps.push(CoverageGap {
139            path: path.clone(),
140            id: id.clone(),
141            artifact_type: artifact_type.clone(),
142            gap,
143            missing: missing_text(gap),
144        });
145    }
146
147    // Deterministic order: gap class, then ascending path (REQ-003).
148    gaps.sort_by(|a, b| {
149        class_order(a.gap)
150            .cmp(&class_order(b.gap))
151            .then_with(|| a.path.cmp(&b.path))
152    });
153    CoverageReport {
154        directory: directory.to_string(),
155        gaps,
156    }
157}