1use std::collections::HashSet;
11
12use crate::diagnostic::Finding;
13use crate::lock::Lock;
14use crate::model::{Graph, Node};
15use crate::rules::{edge_provenance, provenance, short_hash};
16
17pub fn evaluate(graph: &Graph, lock: &Lock) -> Vec<Finding> {
19 let mut findings = Vec::new();
20 let mut stale_nodes: HashSet<&str> = HashSet::new();
21
22 for (path, node) in &graph.nodes {
24 if let (Some(current), Some(locked)) = (
25 node.fs_hash(),
26 lock.nodes.get(path).and_then(|n| n.hash.as_deref()),
27 ) && current != locked
28 {
29 stale_nodes.insert(path.as_str());
30 findings.push(Finding::warn(
31 "stale-node",
32 path,
33 provenance(&node.metadata),
34 format!(
35 "hash {} ≠ locked {}",
36 short_hash(current),
37 short_hash(locked)
38 ),
39 ));
40 }
41 }
42
43 let mut current_pairs: HashSet<(&str, &str)> = HashSet::new();
45 for edge in &graph.edges {
46 current_pairs.insert((edge.source.as_str(), edge.target.as_str()));
47
48 let locked_target_hash = lock
49 .nodes
50 .get(&edge.source)
51 .and_then(|n| n.edges.get(&edge.target));
52
53 match locked_target_hash {
54 Some(locked_hash) => {
56 if stale_nodes.contains(edge.source.as_str()) {
58 continue;
59 }
60 let current_target_hash = graph.nodes.get(&edge.target).and_then(Node::fs_hash);
61 if let (Some(locked_hash), Some(current)) =
62 (locked_hash.as_deref(), current_target_hash)
63 && locked_hash != current
64 {
65 findings.push(
66 Finding::warn(
67 "stale-edge",
68 &edge.source,
69 edge_provenance(edge),
70 format!(
71 "hash {} ≠ locked {}",
72 short_hash(current),
73 short_hash(locked_hash)
74 ),
75 )
76 .with_target(&edge.target)
77 .with_lines(edge.lines()),
78 );
79 }
80 }
81 None => findings.push(
83 Finding::warn(
84 "new-edge",
85 &edge.source,
86 edge_provenance(edge),
87 "not locked",
88 )
89 .with_target(&edge.target)
90 .with_lines(edge.lines()),
91 ),
92 }
93 }
94
95 for (path, locked_node) in &lock.nodes {
97 match graph.nodes.get(path) {
98 None => {
99 findings.push(Finding::warn(
101 "removed-node",
102 path,
103 Vec::new(),
104 "node is no longer present",
105 ));
106 }
107 Some(node) => {
108 for target in locked_node.edges.keys() {
109 if !current_pairs.contains(&(path.as_str(), target.as_str())) {
110 findings.push(
111 Finding::warn(
112 "removed-edge",
113 path,
114 provenance(&node.metadata),
115 "edge no longer present",
116 )
117 .with_target(target),
118 );
119 }
120 }
121 }
122 }
123 }
124
125 findings
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::compose::compose;
132 use crate::model::{Edge, GraphSet, Node};
133 use serde_json::json;
134
135 fn fs_node(hash: &str) -> Node {
136 Node::new(
137 json!({ "type": "file", "hash": hash })
138 .as_object()
139 .unwrap()
140 .clone(),
141 )
142 }
143
144 fn composed_with(index_hash: &str, setup_hash: &str) -> Graph {
146 let mut fs = Graph::labeled("fs");
147 fs.set_node("index.md", fs_node(index_hash));
148 fs.set_node("setup.md", fs_node(setup_hash));
149 fs.add_edge(Edge::new("index.md", "setup.md"));
150 compose(&GraphSet::new(vec![fs]))
151 }
152
153 fn names(findings: &[Finding]) -> Vec<(&str, &str)> {
154 findings
155 .iter()
156 .map(|f| (f.name.as_str(), f.subject.as_str()))
157 .collect()
158 }
159
160 #[test]
161 fn edited_dependency_produces_stale_node_and_dependent_stale_edge() {
162 let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
163 let current = composed_with("b3:idx", "b3:setup2");
165 let findings = evaluate(¤t, &locked);
166
167 let n = names(&findings);
168 assert!(n.contains(&("stale-node", "setup.md")), "got {n:?}");
169 assert!(n.contains(&("stale-edge", "index.md")), "got {n:?}");
170 }
171
172 #[test]
173 fn clean_graph_has_no_findings() {
174 let composed = composed_with("b3:idx", "b3:setup");
175 let locked = Lock::from_composed(&composed);
176 assert!(evaluate(&composed, &locked).is_empty());
177 }
178
179 #[test]
180 fn stale_source_subsumes_its_outbound_stale_edge() {
181 let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
184 let current = composed_with("b3:idx2", "b3:setup2");
185 let findings = evaluate(¤t, &locked);
186 let n = names(&findings);
187 assert!(n.contains(&("stale-node", "index.md")));
188 assert!(n.contains(&("stale-node", "setup.md")));
189 assert!(
190 !n.contains(&("stale-edge", "index.md")),
191 "stale node subsumes its outbound stale-edge, got {n:?}"
192 );
193 }
194
195 #[test]
196 fn removed_node_subsumes_its_outbound_removed_edge() {
197 let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
200 let mut fs = Graph::labeled("fs");
201 fs.set_node("setup.md", fs_node("b3:setup"));
202 let current = compose(&GraphSet::new(vec![fs]));
203
204 let findings = evaluate(¤t, &locked);
205 let n = names(&findings);
206 assert!(n.contains(&("removed-node", "index.md")), "got {n:?}");
207 assert!(
208 !n.iter().any(|(name, _)| *name == "removed-edge"),
209 "a removed source node subsumes its outbound removed-edge, got {n:?}"
210 );
211 }
212
213 #[test]
214 fn removed_edge_fires_when_source_survives() {
215 let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
217 let mut fs = Graph::labeled("fs");
218 fs.set_node("index.md", fs_node("b3:idx"));
219 fs.set_node("setup.md", fs_node("b3:setup"));
220 let current = compose(&GraphSet::new(vec![fs]));
221
222 let findings = evaluate(¤t, &locked);
223 assert!(names(&findings).contains(&("removed-edge", "index.md")));
224 }
225
226 #[test]
227 fn new_edge_when_unlocked_edge_appears() {
228 let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
229 let mut fs = Graph::labeled("fs");
231 fs.set_node("index.md", fs_node("b3:idx"));
232 fs.set_node("setup.md", fs_node("b3:setup"));
233 fs.set_node("extra.md", fs_node("b3:extra"));
234 fs.add_edge(Edge::new("index.md", "setup.md"));
235 fs.add_edge(Edge::new("index.md", "extra.md"));
236 let current = compose(&GraphSet::new(vec![fs]));
237
238 let findings = evaluate(¤t, &locked);
239 assert!(names(&findings).contains(&("new-edge", "index.md")));
240 }
241}