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 std::collections::BTreeMap;
90
91    use crate::model::fields::{NodeType, Span};
92    use crate::model::file::{AgmFile, Header};
93    use crate::model::node::Node;
94
95    use super::*;
96
97    fn header() -> Header {
98        Header {
99            agm: "1.0".to_owned(),
100            package: "test".to_owned(),
101            version: "0.1.0".to_owned(),
102            title: None,
103            owner: None,
104            imports: None,
105            default_load: None,
106            description: None,
107            tags: None,
108            status: None,
109            load_profiles: None,
110            target_runtime: None,
111        }
112    }
113
114    fn minimal_node(id: &str) -> Node {
115        Node {
116            id: id.to_owned(),
117            node_type: NodeType::Facts,
118            summary: "a node".to_owned(),
119            priority: None,
120            stability: None,
121            confidence: None,
122            status: None,
123            depends: None,
124            related_to: None,
125            replaces: None,
126            conflicts: None,
127            see_also: None,
128            items: None,
129            steps: None,
130            fields: None,
131            input: None,
132            output: None,
133            detail: None,
134            rationale: None,
135            tradeoffs: None,
136            resolution: None,
137            examples: None,
138            notes: None,
139            code: None,
140            code_blocks: None,
141            verify: None,
142            agent_context: None,
143            target: None,
144            execution_status: None,
145            executed_by: None,
146            executed_at: None,
147            execution_log: None,
148            retry_count: None,
149            parallel_groups: None,
150            memory: None,
151            scope: None,
152            applies_when: None,
153            valid_from: None,
154            valid_until: None,
155            tags: None,
156            aliases: None,
157            keywords: None,
158            extra_fields: BTreeMap::new(),
159            span: Span::new(1, 5),
160        }
161    }
162
163    fn file_with_nodes(nodes: Vec<Node>) -> AgmFile {
164        AgmFile {
165            header: header(),
166            nodes,
167        }
168    }
169
170    #[test]
171    fn test_diff_nodes_identical_returns_empty() {
172        let node = minimal_node("test.a");
173        let file = file_with_nodes(vec![node]);
174        let result = diff_nodes(&file, &file);
175        assert!(result.added.is_empty());
176        assert!(result.removed.is_empty());
177        assert!(result.modified.is_empty());
178        assert_eq!(result.unchanged_count, 1);
179    }
180
181    #[test]
182    fn test_diff_nodes_added_node_detected() {
183        let left = file_with_nodes(vec![minimal_node("test.a")]);
184        let right = file_with_nodes(vec![minimal_node("test.a"), minimal_node("test.b")]);
185        let result = diff_nodes(&left, &right);
186        assert_eq!(result.added, vec!["test.b".to_owned()]);
187        assert!(result.removed.is_empty());
188    }
189
190    #[test]
191    fn test_diff_nodes_removed_node_detected() {
192        let left = file_with_nodes(vec![minimal_node("test.a"), minimal_node("test.b")]);
193        let right = file_with_nodes(vec![minimal_node("test.a")]);
194        let result = diff_nodes(&left, &right);
195        assert!(result.added.is_empty());
196        assert_eq!(result.removed, vec!["test.b".to_owned()]);
197    }
198
199    #[test]
200    fn test_diff_nodes_modified_node_detected() {
201        let left_node = minimal_node("test.a");
202        let mut right_node = minimal_node("test.a");
203        right_node.summary = "changed summary".to_owned();
204
205        let left = file_with_nodes(vec![left_node]);
206        let right = file_with_nodes(vec![right_node]);
207        let result = diff_nodes(&left, &right);
208        assert!(result.added.is_empty());
209        assert!(result.removed.is_empty());
210        assert_eq!(result.modified.len(), 1);
211        assert_eq!(result.modified[0].node_id, "test.a");
212    }
213
214    #[test]
215    fn test_diff_nodes_multiple_changes_all_tracked() {
216        let left = file_with_nodes(vec![
217            minimal_node("test.a"),
218            minimal_node("test.b"),
219            minimal_node("test.c"),
220        ]);
221        let mut right_a = minimal_node("test.a");
222        right_a.summary = "modified".to_owned();
223        let right = file_with_nodes(vec![
224            right_a,
225            // test.b removed
226            minimal_node("test.c"),
227            minimal_node("test.d"), // added
228        ]);
229        let result = diff_nodes(&left, &right);
230        assert_eq!(result.added, vec!["test.d".to_owned()]);
231        assert_eq!(result.removed, vec!["test.b".to_owned()]);
232        assert_eq!(result.modified.len(), 1);
233        assert_eq!(result.unchanged_count, 1);
234    }
235
236    #[test]
237    fn test_diff_nodes_unchanged_counted() {
238        let nodes = vec![
239            minimal_node("test.a"),
240            minimal_node("test.b"),
241            minimal_node("test.c"),
242        ];
243        let left = file_with_nodes(nodes.clone());
244        let right = file_with_nodes(nodes);
245        let result = diff_nodes(&left, &right);
246        assert_eq!(result.unchanged_count, 3);
247    }
248
249    #[test]
250    fn test_diff_nodes_order_independent() {
251        // Same nodes in different order = no diff
252        let left = file_with_nodes(vec![minimal_node("test.a"), minimal_node("test.b")]);
253        let right = file_with_nodes(vec![minimal_node("test.b"), minimal_node("test.a")]);
254        let result = diff_nodes(&left, &right);
255        assert!(result.added.is_empty());
256        assert!(result.removed.is_empty());
257        assert!(result.modified.is_empty());
258        assert_eq!(result.unchanged_count, 2);
259    }
260}