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 );
78 }
79 }
80 None => findings.push(
82 Finding::warn(
83 "new-edge",
84 &edge.source,
85 edge_provenance(edge),
86 "not locked",
87 )
88 .with_target(&edge.target),
89 ),
90 }
91 }
92
93 for (path, locked_node) in &lock.nodes {
95 match graph.nodes.get(path) {
96 None => {
97 findings.push(Finding::warn(
99 "removed-node",
100 path,
101 Vec::new(),
102 "node is no longer present",
103 ));
104 }
105 Some(node) => {
106 for target in locked_node.edges.keys() {
107 if !current_pairs.contains(&(path.as_str(), target.as_str())) {
108 findings.push(
109 Finding::warn(
110 "removed-edge",
111 path,
112 provenance(&node.metadata),
113 "edge no longer present",
114 )
115 .with_target(target),
116 );
117 }
118 }
119 }
120 }
121 }
122
123 findings
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::compose::compose;
130 use crate::model::{Edge, GraphSet, Node};
131 use serde_json::json;
132
133 fn fs_node(hash: &str) -> Node {
134 Node::new(
135 json!({ "type": "file", "hash": hash })
136 .as_object()
137 .unwrap()
138 .clone(),
139 )
140 }
141
142 fn composed_with(index_hash: &str, setup_hash: &str) -> Graph {
144 let mut fs = Graph::labeled("fs");
145 fs.set_node("index.md", fs_node(index_hash));
146 fs.set_node("setup.md", fs_node(setup_hash));
147 fs.add_edge(Edge::new("index.md", "setup.md"));
148 compose(&GraphSet::new(vec![fs]))
149 }
150
151 fn names(findings: &[Finding]) -> Vec<(&str, &str)> {
152 findings
153 .iter()
154 .map(|f| (f.name.as_str(), f.subject.as_str()))
155 .collect()
156 }
157
158 #[test]
159 fn edited_dependency_produces_stale_node_and_dependent_stale_edge() {
160 let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
161 let current = composed_with("b3:idx", "b3:setup2");
163 let findings = evaluate(¤t, &locked);
164
165 let n = names(&findings);
166 assert!(n.contains(&("stale-node", "setup.md")), "got {n:?}");
167 assert!(n.contains(&("stale-edge", "index.md")), "got {n:?}");
168 }
169
170 #[test]
171 fn clean_graph_has_no_findings() {
172 let composed = composed_with("b3:idx", "b3:setup");
173 let locked = Lock::from_composed(&composed);
174 assert!(evaluate(&composed, &locked).is_empty());
175 }
176
177 #[test]
178 fn stale_source_subsumes_its_outbound_stale_edge() {
179 let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
182 let current = composed_with("b3:idx2", "b3:setup2");
183 let findings = evaluate(¤t, &locked);
184 let n = names(&findings);
185 assert!(n.contains(&("stale-node", "index.md")));
186 assert!(n.contains(&("stale-node", "setup.md")));
187 assert!(
188 !n.contains(&("stale-edge", "index.md")),
189 "stale node subsumes its outbound stale-edge, got {n:?}"
190 );
191 }
192
193 #[test]
194 fn removed_node_subsumes_its_outbound_removed_edge() {
195 let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
198 let mut fs = Graph::labeled("fs");
199 fs.set_node("setup.md", fs_node("b3:setup"));
200 let current = compose(&GraphSet::new(vec![fs]));
201
202 let findings = evaluate(¤t, &locked);
203 let n = names(&findings);
204 assert!(n.contains(&("removed-node", "index.md")), "got {n:?}");
205 assert!(
206 !n.iter().any(|(name, _)| *name == "removed-edge"),
207 "a removed source node subsumes its outbound removed-edge, got {n:?}"
208 );
209 }
210
211 #[test]
212 fn removed_edge_fires_when_source_survives() {
213 let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
215 let mut fs = Graph::labeled("fs");
216 fs.set_node("index.md", fs_node("b3:idx"));
217 fs.set_node("setup.md", fs_node("b3:setup"));
218 let current = compose(&GraphSet::new(vec![fs]));
219
220 let findings = evaluate(¤t, &locked);
221 assert!(names(&findings).contains(&("removed-edge", "index.md")));
222 }
223
224 #[test]
225 fn new_edge_when_unlocked_edge_appears() {
226 let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
227 let mut fs = Graph::labeled("fs");
229 fs.set_node("index.md", fs_node("b3:idx"));
230 fs.set_node("setup.md", fs_node("b3:setup"));
231 fs.set_node("extra.md", fs_node("b3:extra"));
232 fs.add_edge(Edge::new("index.md", "setup.md"));
233 fs.add_edge(Edge::new("index.md", "extra.md"));
234 let current = compose(&GraphSet::new(vec![fs]));
235
236 let findings = evaluate(¤t, &locked);
237 assert!(names(&findings).contains(&("new-edge", "index.md")));
238 }
239}