1use crate::graph::{Edge, Graph};
4use crate::snapshot::Snapshot;
5use serde::Serialize;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Serialize)]
9pub struct DiffCounts {
10 pub added: usize,
11 pub removed: usize,
12 pub affected: usize,
13 pub unchanged: usize,
14}
15
16#[derive(Debug, Clone, Serialize)]
17pub struct LevelDiff {
18 pub nodes: DiffCounts,
19 pub edges: DiffCounts,
20 pub cycle_nodes_before: usize,
22 pub cycle_nodes_after: usize,
23 pub sccs_before: usize,
25 pub sccs_after: usize,
26}
27
28#[derive(Debug, Clone, Serialize)]
29pub struct SnapMeta {
30 pub target: String,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub branch: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub commit: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize)]
38pub struct CompareSummary {
39 pub schema_version: String,
40 pub before: SnapMeta,
41 pub after: SnapMeta,
42 pub identical: bool,
43 pub files: LevelDiff,
44}
45
46pub fn compare_snapshots(before: &Snapshot, after: &Snapshot) -> CompareSummary {
47 let files = diff_graph(&before.graphs.files, &after.graphs.files);
48
49 let identical = files.nodes.added == 0
50 && files.nodes.removed == 0
51 && files.nodes.affected == 0
52 && files.edges.added == 0
53 && files.edges.removed == 0
54 && files.edges.affected == 0;
55
56 CompareSummary {
57 schema_version: "1".to_string(),
58 before: snap_meta(before),
59 after: snap_meta(after),
60 identical,
61 files,
62 }
63}
64
65fn snap_meta(snap: &Snapshot) -> SnapMeta {
66 let commit_short = snap
67 .git
68 .as_ref()
69 .map(|g| g.commit[..8.min(g.commit.len())].to_string());
70 SnapMeta {
71 target: snap
72 .target
73 .split('/')
74 .next_back()
75 .unwrap_or(&snap.target)
76 .to_string(),
77 branch: snap.git.as_ref().map(|g| g.branch.clone()),
78 commit: commit_short,
79 }
80}
81
82fn diff_graph(before: &Graph, after: &Graph) -> LevelDiff {
84 let bg: HashMap<String, ()> = before
86 .nodes
87 .iter()
88 .filter(|n| !n.external.unwrap_or(false))
89 .map(|n| (n.id.clone(), ()))
90 .collect();
91 let ag: HashMap<String, ()> = after
92 .nodes
93 .iter()
94 .filter(|n| !n.external.unwrap_or(false))
95 .map(|n| (n.id.clone(), ()))
96 .collect();
97
98 let mut node_status: HashMap<String, u8> = HashMap::new();
100 for id in ag.keys() {
101 node_status.insert(id.clone(), if bg.contains_key(id) { 0 } else { 1 });
102 }
103 for id in bg.keys() {
104 if !ag.contains_key(id) {
105 node_status.insert(id.clone(), 2);
106 }
107 }
108
109 let ekey = |e: &Edge| format!("{}\x00{}\x00{:?}", e.from, e.to, e.kind);
111
112 let local_edges = |edges: &[Edge]| -> HashMap<String, (String, String)> {
114 edges
115 .iter()
116 .filter(|e| node_status.contains_key(&e.from) && node_status.contains_key(&e.to))
117 .map(|e| (ekey(e), (e.from.clone(), e.to.clone())))
118 .collect()
119 };
120
121 let bg_edges = local_edges(&before.edges);
122 let ag_edges = local_edges(&after.edges);
123
124 let mut edge_list: Vec<(String, String, u8)> = Vec::new();
126 for (key, (from, to)) in &ag_edges {
127 edge_list.push((
128 from.clone(),
129 to.clone(),
130 if bg_edges.contains_key(key) { 0 } else { 1 },
131 ));
132 }
133 for (key, (from, to)) in &bg_edges {
134 if !ag_edges.contains_key(key) {
135 edge_list.push((from.clone(), to.clone(), 2));
136 }
137 }
138
139 for (from, to, status) in &edge_list {
141 if *status != 0 {
142 if node_status.get(from.as_str()) == Some(&0) {
143 node_status.insert(from.clone(), 3);
144 }
145 if node_status.get(to.as_str()) == Some(&0) {
146 node_status.insert(to.clone(), 3);
147 }
148 }
149 }
150
151 let mut nodes = DiffCounts {
153 added: 0,
154 removed: 0,
155 affected: 0,
156 unchanged: 0,
157 };
158 for &s in node_status.values() {
159 match s {
160 1 => nodes.added += 1,
161 2 => nodes.removed += 1,
162 3 => nodes.affected += 1,
163 _ => nodes.unchanged += 1,
164 }
165 }
166
167 let mut edges = DiffCounts {
169 added: 0,
170 removed: 0,
171 affected: 0,
172 unchanged: 0,
173 };
174 for (from, to, status) in &edge_list {
175 let s = if *status == 0
176 && (node_status.get(from.as_str()) != Some(&0)
177 || node_status.get(to.as_str()) != Some(&0))
178 {
179 3u8
180 } else {
181 *status
182 };
183 match s {
184 1 => edges.added += 1,
185 2 => edges.removed += 1,
186 3 => edges.affected += 1,
187 _ => edges.unchanged += 1,
188 }
189 }
190
191 LevelDiff {
192 nodes,
193 edges,
194 cycle_nodes_before: before.cycles.iter().map(|c| c.nodes.len()).sum(),
195 cycle_nodes_after: after.cycles.iter().map(|c| c.nodes.len()).sum(),
196 sccs_before: before.cycles.len(),
197 sccs_after: after.cycles.len(),
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::graph::{CycleGroup, CycleKind, Edge, EdgeKind, Node, NodeKind};
205 use crate::snapshot::{GitInfo, PluginGraphs};
206
207 fn node(id: &str) -> Node {
208 Node {
209 id: id.into(),
210 kind: NodeKind::Module,
211 name: id.into(),
212 path: String::new(),
213 parent: None,
214 external: None,
215 version: None,
216 visibility: None,
217 loc: None,
218 line: None,
219 item_count: None,
220 method_count: None,
221 complexity: None,
222 cycle_kind: None,
223 }
224 }
225
226 fn ext_node(id: &str) -> Node {
227 Node {
228 external: Some(true),
229 ..node(id)
230 }
231 }
232
233 fn edge(from: &str, to: &str) -> Edge {
234 Edge {
235 from: from.into(),
236 to: to.into(),
237 kind: EdgeKind::Uses,
238 unresolved: None,
239 external: None,
240 visibility: None,
241 }
242 }
243
244 fn graph(nodes: Vec<Node>, edges: Vec<Edge>) -> Graph {
245 Graph {
246 nodes,
247 edges,
248 cycles: Vec::new(),
249 stats: None,
250 }
251 }
252
253 #[test]
256 fn identical_graphs_have_only_unchanged() {
257 let g = graph(vec![node("a"), node("b")], vec![edge("a", "b")]);
258 let d = diff_graph(&g, &g);
259 assert_eq!(d.nodes.unchanged, 2);
260 assert_eq!(d.nodes.added + d.nodes.removed + d.nodes.affected, 0);
261 assert_eq!(d.edges.unchanged, 1);
262 assert_eq!(d.edges.added + d.edges.removed + d.edges.affected, 0);
263 }
264
265 #[test]
266 fn added_and_removed_nodes_are_counted() {
267 let before = graph(vec![node("a"), node("b")], vec![]);
268 let after = graph(vec![node("a"), node("c")], vec![]);
269 let d = diff_graph(&before, &after);
270 assert_eq!(d.nodes.added, 1, "c is new");
271 assert_eq!(d.nodes.removed, 1, "b is gone");
272 assert_eq!(d.nodes.unchanged, 1, "a persists");
273 }
274
275 #[test]
276 fn external_nodes_are_ignored() {
277 let before = graph(vec![node("a"), ext_node("ext")], vec![]);
279 let after = graph(vec![node("a")], vec![]);
280 let d = diff_graph(&before, &after);
281 assert_eq!(d.nodes.removed, 0, "external node is not counted");
282 assert_eq!(d.nodes.unchanged, 1);
283 }
284
285 #[test]
286 fn edge_change_propagates_affected_to_nodes_and_unchanged_edges() {
287 let before = graph(vec![node("a"), node("b")], vec![edge("a", "b")]);
289 let after = graph(
290 vec![node("a"), node("b"), node("c")],
291 vec![edge("a", "b"), edge("a", "c")],
292 );
293 let d = diff_graph(&before, &after);
294 assert_eq!(d.nodes.added, 1, "c");
296 assert_eq!(d.nodes.affected, 1, "a touches the new edge");
297 assert_eq!(d.nodes.unchanged, 1, "b");
298 assert_eq!(d.edges.added, 1, "a→c");
300 assert_eq!(d.edges.affected, 1, "a→b is unchanged but its node changed");
301 assert_eq!(d.edges.unchanged, 0);
302 }
303
304 #[test]
305 fn cycle_counts_are_read_from_graph_annotations() {
306 let mut before = graph(
307 vec![node("a"), node("b")],
308 vec![edge("a", "b"), edge("b", "a")],
309 );
310 before.cycles = vec![CycleGroup {
311 kind: CycleKind::Mutual,
312 nodes: vec!["a".into(), "b".into()],
313 }];
314 let after = graph(
315 vec![node("a"), node("b")],
316 vec![edge("a", "b"), edge("b", "a")],
317 );
318 let d = diff_graph(&before, &after);
319 assert_eq!(d.sccs_before, 1);
320 assert_eq!(d.cycle_nodes_before, 2);
321 assert_eq!(d.sccs_after, 0, "after carries no cycle annotations");
322 assert_eq!(d.cycle_nodes_after, 0);
323 }
324
325 fn snap(files: Graph, git: Option<GitInfo>, target: &str) -> Snapshot {
328 let graphs = PluginGraphs { files };
329 Snapshot::new(
330 "report".into(),
331 "/w".into(),
332 target.into(),
333 "rust".into(),
334 None,
335 HashMap::new(),
336 HashMap::new(),
337 git,
338 Vec::new(),
339 graphs,
340 )
341 }
342
343 #[test]
344 fn compare_identical_snapshots_sets_identical_true() {
345 let g = graph(vec![node("a")], vec![]);
346 let s = compare_snapshots(&snap(g.clone(), None, "/x/proj"), &snap(g, None, "/x/proj"));
347 assert!(s.identical, "no node/edge changes at any level");
348 assert_eq!(s.schema_version, "1");
349 }
350
351 #[test]
352 fn compare_differing_snapshots_sets_identical_false() {
353 let before = snap(graph(vec![node("a")], vec![]), None, "/x/proj");
354 let after = snap(graph(vec![node("a"), node("b")], vec![]), None, "/x/proj");
355 let s = compare_snapshots(&before, &after);
356 assert!(!s.identical);
357 assert_eq!(s.files.nodes.added, 1);
358 }
359
360 #[test]
361 fn snap_meta_shortens_target_basename_and_commit() {
362 let git = Some(GitInfo {
363 branch: "main".into(),
364 commit: "0123456789abcdef".into(),
365 dirty_files: 0,
366 origin: None,
367 });
368 let before = snap(
369 graph(vec![node("a")], vec![]),
370 git.clone(),
371 "/home/u/my-project",
372 );
373 let after = snap(graph(vec![node("a")], vec![]), git, "/home/u/my-project");
374 let s = compare_snapshots(&before, &after);
375 assert_eq!(s.before.target, "my-project", "basename only");
376 assert_eq!(
377 s.before.commit.as_deref(),
378 Some("01234567"),
379 "first 8 chars"
380 );
381 assert_eq!(s.before.branch.as_deref(), Some("main"));
382 }
383}