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