1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::model::file::AgmFile;
8
9use super::ChangeSeverity;
10use super::fields::FieldChange;
11
12#[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
20pub(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#[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 for id in left_map.keys() {
44 if !right_map.contains_key(id) {
45 removed.push((*id).to_owned());
46 }
47 }
48
49 for id in right_map.keys() {
51 if !left_map.contains_key(id) {
52 added.push((*id).to_owned());
53 }
54 }
55
56 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#[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 minimal_node("test.c"),
227 minimal_node("test.d"), ]);
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 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}