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 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 minimal_node("test.c"),
186 minimal_node("test.d"), ]);
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 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}