1use serde::Serialize;
9use std::collections::BTreeMap;
10
11use crate::config::Config;
12use crate::model::{Edge, Graph, ResolvedTarget};
13use crate::rules::{Violation, check_all};
14
15use super::detect::{OrphanEntry, StaleEntry, find_orphans, find_stale};
16
17pub mod categories {
22 pub const ORPHAN: &str = "orphan";
23 pub const STALE: &str = "stale";
24 pub const UNRESOLVED_EDGE: &str = "unresolved_edge";
25 pub const VIOLATION_PREFIX: &str = "violation_";
26}
27
28#[derive(Debug, Clone, Serialize)]
31pub struct UnresolvedEdge {
32 pub source_id: String,
33 pub source_path: String,
34 pub relation: String,
35 pub raw_target: String,
36 pub reason: String,
37 pub location: String,
38}
39
40#[derive(Debug, Serialize)]
42pub struct IssueReport {
43 pub orphans: Vec<OrphanEntry>,
44 pub stale: Vec<StaleEntry>,
45 pub unresolved_edges: Vec<UnresolvedEdge>,
46 pub violations: Vec<Violation>,
47 pub summary: IssueSummary,
48}
49
50#[derive(Debug, Serialize)]
53pub struct IssueSummary {
54 pub total: usize,
55 pub by_category: BTreeMap<String, usize>,
56}
57
58pub fn collect_issues(graph: &Graph, config: &Config) -> IssueReport {
65 let orphans = find_orphans(graph, config);
66 let stale = find_stale(graph, config);
67 let unresolved_edges = find_unresolved_edges(graph);
68 let violations = check_all(graph, config);
69
70 let mut by_category: BTreeMap<String, usize> = BTreeMap::new();
71 if !orphans.is_empty() {
72 by_category.insert(categories::ORPHAN.to_string(), orphans.len());
73 }
74 if !stale.is_empty() {
75 by_category.insert(categories::STALE.to_string(), stale.len());
76 }
77 if !unresolved_edges.is_empty() {
78 by_category.insert(
79 categories::UNRESOLVED_EDGE.to_string(),
80 unresolved_edges.len(),
81 );
82 }
83 for v in &violations {
84 let key = format!("{}{}", categories::VIOLATION_PREFIX, v.rule_id);
85 *by_category.entry(key).or_insert(0) += 1;
86 }
87
88 let total = orphans.len() + stale.len() + unresolved_edges.len() + violations.len();
89
90 IssueReport {
91 orphans,
92 stale,
93 unresolved_edges,
94 violations,
95 summary: IssueSummary { total, by_category },
96 }
97}
98
99pub fn find_unresolved_edges(graph: &Graph) -> Vec<UnresolvedEdge> {
101 let mut entries: Vec<UnresolvedEdge> = graph
102 .edges()
103 .iter()
104 .filter_map(|edge| unresolved_from(graph, edge))
105 .collect();
106
107 entries.sort_by(|a, b| {
108 a.source_id
109 .cmp(&b.source_id)
110 .then_with(|| a.relation.cmp(&b.relation))
111 .then_with(|| a.raw_target.cmp(&b.raw_target))
112 });
113
114 entries
115}
116
117fn unresolved_from(graph: &Graph, edge: &Edge) -> Option<UnresolvedEdge> {
118 let ResolvedTarget::Unresolved { raw, reason } = &edge.target else {
119 return None;
120 };
121 let source_path = graph
122 .nodes()
123 .get(&edge.source)
124 .map(|n| n.path.to_string_lossy().to_string())
125 .unwrap_or_default();
126 Some(UnresolvedEdge {
127 source_id: edge.source.clone(),
128 source_path,
129 relation: edge.relation.clone(),
130 raw_target: raw.clone(),
131 reason: reason.clone(),
132 location: edge.location.clone(),
133 })
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::model::{Confidence, Kind, Node, Status};
140 use indexmap::IndexMap;
141 use std::path::PathBuf;
142
143 fn node(id: &str) -> Node {
144 Node {
145 id: id.to_string(),
146 path: PathBuf::from(format!("{id}.md")),
147 title: id.to_string(),
148 kind: Kind::new("generic"),
149 status: Status::new("active"),
150 created: None,
151 updated: None,
152 reviewed: None,
153 owner: None,
154 supersedes: vec![],
155 superseded_by: None,
156 implements: vec![],
157 related: vec![],
158 tags: vec![],
159 orphan_ok: true, attrs: Default::default(),
161 }
162 }
163
164 #[test]
165 fn finds_unresolved_edges() {
166 let mut map = IndexMap::new();
167 map.insert("a".into(), node("a"));
168 let edges = vec![Edge {
169 source: "a".to_string(),
170 target: ResolvedTarget::unresolved("missing.md", "path not in scope"),
171 relation: "references".to_string(),
172 confidence: Confidence::Extracted,
173 location: "L42".to_string(),
174 }];
175 let graph = Graph::new(map, edges);
176
177 let unresolved = find_unresolved_edges(&graph);
178 assert_eq!(unresolved.len(), 1);
179 assert_eq!(unresolved[0].source_id, "a");
180 assert_eq!(unresolved[0].raw_target, "missing.md");
181 assert_eq!(unresolved[0].reason, "path not in scope");
182 }
183
184 #[test]
185 fn empty_graph_has_no_issues() {
186 let graph = Graph::new(IndexMap::new(), vec![]);
187 let report = collect_issues(&graph, &Config::default());
188 assert_eq!(report.summary.total, 0);
189 assert!(report.summary.by_category.is_empty());
190 }
191
192 #[test]
193 fn summary_counts_are_additive() {
194 let mut map = IndexMap::new();
195 map.insert("a".into(), node("a"));
196 let edges = vec![
197 Edge {
198 source: "a".to_string(),
199 target: ResolvedTarget::unresolved("x.md", "not found"),
200 relation: "references".to_string(),
201 confidence: Confidence::Extracted,
202 location: "L1".to_string(),
203 },
204 Edge {
205 source: "a".to_string(),
206 target: ResolvedTarget::unresolved("y.md", "not found"),
207 relation: "references".to_string(),
208 confidence: Confidence::Extracted,
209 location: "L2".to_string(),
210 },
211 ];
212 let graph = Graph::new(map, edges);
213 let report = collect_issues(&graph, &Config::default());
214 assert_eq!(report.unresolved_edges.len(), 2);
215 assert_eq!(report.summary.by_category[categories::UNRESOLVED_EDGE], 2);
216 }
217}