1use 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
28fn 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#[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#[derive(Debug)]
49pub struct CoverageReport {
50 pub directory: String,
51 pub gaps: Vec<CoverageGap>,
52}
53
54impl CoverageReport {
55 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
77pub fn analyze_coverage(directory: &str) -> CoverageReport {
79 let items = corpus_items(directory, true);
80 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 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 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}