Skip to main content

code_split_core/
diff.rs

1// Import from the defining modules (not the crate-root re-exports) so this module
2// does not depend "up" on the crate root, which would close a dependency cycle.
3use 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    /// Nodes participating in cycles (SCCs with ≥ 2 members).
21    pub cycle_nodes_before: usize,
22    pub cycle_nodes_after: usize,
23    /// Number of SCCs.
24    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
82// Mirrors computeDiff() from assets/diff.js.
83fn diff_graph(before: &Graph, after: &Graph) -> LevelDiff {
84    // Non-external node id sets.
85    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    // Node status: 0 = unchanged, 1 = added, 2 = removed, 3 = affected.
99    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    // Edge key: "from\0to\0Kind".
110    let ekey = |e: &Edge| format!("{}\x00{}\x00{:?}", e.from, e.to, e.kind);
111
112    // Local edges: both endpoints present in node_status.
113    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    // Collect all edges with status.
125    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    // Propagate "affected" to unchanged nodes adjacent to changed edges.
140    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    // Count nodes.
152    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    // Count edges (unchanged edge connecting non-unchanged nodes → affected).
168    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    // ── diff_graph: node / edge / affected / external / cycle counts ─────────
254
255    #[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        // The external node is filtered out — its disappearance is not a removal.
278        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        // before: a→b ; after: a→b, a→c (plus the new node c).
288        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        // c added; a is affected (adjacent to the new edge); b is unchanged.
295        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        // a→c is added; a→b persists but its endpoint a changed → affected.
299        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    // ── compare_snapshots + snap_meta ───────────────────────────────────────
326
327    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}