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::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 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
90// Mirrors computeDiff() from assets/diff.js.
91fn diff_graph(before: &Graph, after: &Graph) -> LevelDiff {
92    // Non-external node id sets.
93    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    // Node status: 0 = unchanged, 1 = added, 2 = removed, 3 = affected.
107    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    // Edge key: "from\0to\0Kind".
118    let ekey = |e: &crate::Edge| format!("{}\x00{}\x00{:?}", e.from, e.to, e.kind);
119
120    // Local edges: both endpoints present in node_status.
121    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    // Collect all edges with status.
133    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    // Propagate "affected" to unchanged nodes adjacent to changed edges.
148    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    // Count nodes.
160    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    // Count edges (unchanged edge connecting non-unchanged nodes → affected).
176    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    // ── diff_graph: node / edge / affected / external / cycle counts ─────────
261
262    #[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        // The external node is filtered out — its disappearance is not a removal.
285        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        // before: a→b ; after: a→b, a→c (plus the new node c).
295        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        // c added; a is affected (adjacent to the new edge); b is unchanged.
302        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        // a→c is added; a→b persists but its endpoint a changed → affected.
306        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    // ── compare_snapshots + snap_meta ───────────────────────────────────────
333
334    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}