Skip to main content

agm_core/diff/
node.rs

1//! Node-level diff: match nodes by ID between two AGM files.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::model::file::AgmFile;
8
9use super::ChangeSeverity;
10use super::fields::FieldChange;
11
12/// Diff of a single node that exists in both left and right files.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct NodeDiff {
15    pub node_id: String,
16    pub field_changes: Vec<FieldChange>,
17    pub has_breaking_change: bool,
18}
19
20/// Result of matching nodes between two files by ID.
21pub(crate) struct NodeMatchResult {
22    pub added: Vec<String>,
23    pub removed: Vec<String>,
24    pub modified: Vec<NodeDiff>,
25    pub unchanged_count: usize,
26}
27
28/// Matches nodes by ID between two files and diffs matched pairs.
29#[must_use]
30pub(crate) fn diff_nodes(left: &AgmFile, right: &AgmFile) -> NodeMatchResult {
31    let left_map: BTreeMap<&str, &crate::model::node::Node> =
32        left.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
33
34    let right_map: BTreeMap<&str, &crate::model::node::Node> =
35        right.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
36
37    let mut added = Vec::new();
38    let mut removed = Vec::new();
39    let mut modified = Vec::new();
40    let mut unchanged_count = 0usize;
41
42    // Nodes only in left -> removed
43    for id in left_map.keys() {
44        if !right_map.contains_key(id) {
45            removed.push((*id).to_owned());
46        }
47    }
48
49    // Nodes only in right -> added
50    for id in right_map.keys() {
51        if !left_map.contains_key(id) {
52            added.push((*id).to_owned());
53        }
54    }
55
56    // Nodes in both -> diff
57    for (id, left_node) in &left_map {
58        if let Some(right_node) = right_map.get(id) {
59            let field_changes = super::fields::diff_all_fields(left_node, right_node);
60            if field_changes.is_empty() {
61                unchanged_count += 1;
62            } else {
63                let has_breaking_change = field_changes
64                    .iter()
65                    .any(|fc| fc.severity == ChangeSeverity::Breaking);
66                modified.push(NodeDiff {
67                    node_id: (*id).to_owned(),
68                    field_changes,
69                    has_breaking_change,
70                });
71            }
72        }
73    }
74
75    NodeMatchResult {
76        added,
77        removed,
78        modified,
79        unchanged_count,
80    }
81}
82
83// ---------------------------------------------------------------------------
84// Tests
85// ---------------------------------------------------------------------------
86
87#[cfg(test)]
88mod tests {
89    use crate::model::fields::{NodeType, Span};
90    use crate::model::file::{AgmFile, Header};
91    use crate::model::node::Node;
92
93    use super::*;
94
95    fn header() -> Header {
96        Header {
97            agm: "1.0".to_owned(),
98            package: "test".to_owned(),
99            version: "0.1.0".to_owned(),
100            title: None,
101            owner: None,
102            imports: None,
103            default_load: None,
104            description: None,
105            tags: None,
106            status: None,
107            load_profiles: None,
108            target_runtime: None,
109        }
110    }
111
112    fn minimal_node(id: &str) -> Node {
113        Node {
114            id: id.to_owned(),
115            node_type: NodeType::Facts,
116            summary: "a node".to_owned(),
117            span: Span::new(1, 5),
118            ..Default::default()
119        }
120    }
121
122    fn file_with_nodes(nodes: Vec<Node>) -> AgmFile {
123        AgmFile {
124            header: header(),
125            nodes,
126        }
127    }
128
129    #[test]
130    fn test_diff_nodes_identical_returns_empty() {
131        let node = minimal_node("test.a");
132        let file = file_with_nodes(vec![node]);
133        let result = diff_nodes(&file, &file);
134        assert!(result.added.is_empty());
135        assert!(result.removed.is_empty());
136        assert!(result.modified.is_empty());
137        assert_eq!(result.unchanged_count, 1);
138    }
139
140    #[test]
141    fn test_diff_nodes_added_node_detected() {
142        let left = file_with_nodes(vec![minimal_node("test.a")]);
143        let right = file_with_nodes(vec![minimal_node("test.a"), minimal_node("test.b")]);
144        let result = diff_nodes(&left, &right);
145        assert_eq!(result.added, vec!["test.b".to_owned()]);
146        assert!(result.removed.is_empty());
147    }
148
149    #[test]
150    fn test_diff_nodes_removed_node_detected() {
151        let left = file_with_nodes(vec![minimal_node("test.a"), minimal_node("test.b")]);
152        let right = file_with_nodes(vec![minimal_node("test.a")]);
153        let result = diff_nodes(&left, &right);
154        assert!(result.added.is_empty());
155        assert_eq!(result.removed, vec!["test.b".to_owned()]);
156    }
157
158    #[test]
159    fn test_diff_nodes_modified_node_detected() {
160        let left_node = minimal_node("test.a");
161        let mut right_node = minimal_node("test.a");
162        right_node.summary = "changed summary".to_owned();
163
164        let left = file_with_nodes(vec![left_node]);
165        let right = file_with_nodes(vec![right_node]);
166        let result = diff_nodes(&left, &right);
167        assert!(result.added.is_empty());
168        assert!(result.removed.is_empty());
169        assert_eq!(result.modified.len(), 1);
170        assert_eq!(result.modified[0].node_id, "test.a");
171    }
172
173    #[test]
174    fn test_diff_nodes_multiple_changes_all_tracked() {
175        let left = file_with_nodes(vec![
176            minimal_node("test.a"),
177            minimal_node("test.b"),
178            minimal_node("test.c"),
179        ]);
180        let mut right_a = minimal_node("test.a");
181        right_a.summary = "modified".to_owned();
182        let right = file_with_nodes(vec![
183            right_a,
184            // test.b removed
185            minimal_node("test.c"),
186            minimal_node("test.d"), // added
187        ]);
188        let result = diff_nodes(&left, &right);
189        assert_eq!(result.added, vec!["test.d".to_owned()]);
190        assert_eq!(result.removed, vec!["test.b".to_owned()]);
191        assert_eq!(result.modified.len(), 1);
192        assert_eq!(result.unchanged_count, 1);
193    }
194
195    #[test]
196    fn test_diff_nodes_unchanged_counted() {
197        let nodes = vec![
198            minimal_node("test.a"),
199            minimal_node("test.b"),
200            minimal_node("test.c"),
201        ];
202        let left = file_with_nodes(nodes.clone());
203        let right = file_with_nodes(nodes);
204        let result = diff_nodes(&left, &right);
205        assert_eq!(result.unchanged_count, 3);
206    }
207
208    #[test]
209    fn test_diff_nodes_order_independent() {
210        // Same nodes in different order = no diff
211        let left = file_with_nodes(vec![minimal_node("test.a"), minimal_node("test.b")]);
212        let right = file_with_nodes(vec![minimal_node("test.b"), minimal_node("test.a")]);
213        let result = diff_nodes(&left, &right);
214        assert!(result.added.is_empty());
215        assert!(result.removed.is_empty());
216        assert!(result.modified.is_empty());
217        assert_eq!(result.unchanged_count, 2);
218    }
219}