1use serde::{Deserialize, Serialize};
8
9use crate::model::file::AgmFile;
10
11pub mod fields;
12pub mod header;
13pub mod node;
14pub mod relation;
15pub mod render;
16
17pub use fields::{FieldChange, FieldValueSnapshot};
19pub use header::HeaderChange;
20pub use node::NodeDiff;
21pub use render::DiffFormat;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ChangeKind {
31 Added,
32 Removed,
33 Modified,
34}
35
36impl std::fmt::Display for ChangeKind {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 Self::Added => write!(f, "added"),
40 Self::Removed => write!(f, "removed"),
41 Self::Modified => write!(f, "modified"),
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum ChangeSeverity {
54 Info,
56 Minor,
58 Breaking,
60}
61
62impl std::fmt::Display for ChangeSeverity {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 Self::Info => write!(f, "info"),
66 Self::Minor => write!(f, "minor"),
67 Self::Breaking => write!(f, "breaking"),
68 }
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct DiffSummary {
79 pub nodes_added: usize,
80 pub nodes_removed: usize,
81 pub nodes_modified: usize,
82 pub nodes_unchanged: usize,
83 pub header_changes: usize,
84 pub total_field_changes: usize,
85 pub has_breaking_changes: bool,
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct DiffReport {
95 pub header_changes: Vec<HeaderChange>,
96 pub added_nodes: Vec<String>,
97 pub removed_nodes: Vec<String>,
98 pub modified_nodes: Vec<NodeDiff>,
99 pub summary: DiffSummary,
100}
101
102impl DiffReport {
103 #[must_use]
105 pub fn is_empty(&self) -> bool {
106 self.header_changes.is_empty()
107 && self.added_nodes.is_empty()
108 && self.removed_nodes.is_empty()
109 && self.modified_nodes.is_empty()
110 }
111
112 #[must_use]
114 pub fn has_breaking_changes(&self) -> bool {
115 self.summary.has_breaking_changes
116 }
117
118 #[must_use]
120 pub fn breaking_only(&self) -> DiffReport {
121 let header_changes: Vec<HeaderChange> = self
122 .header_changes
123 .iter()
124 .filter(|c| c.severity == ChangeSeverity::Breaking)
125 .cloned()
126 .collect();
127
128 let added_nodes: Vec<String> = vec![];
130
131 let removed_nodes = self.removed_nodes.clone();
133
134 let modified_nodes: Vec<NodeDiff> = self
137 .modified_nodes
138 .iter()
139 .filter(|nd| nd.has_breaking_change)
140 .map(|nd| {
141 let breaking_changes: Vec<FieldChange> = nd
142 .field_changes
143 .iter()
144 .filter(|fc| fc.severity == ChangeSeverity::Breaking)
145 .cloned()
146 .collect();
147 NodeDiff {
148 node_id: nd.node_id.clone(),
149 field_changes: breaking_changes,
150 has_breaking_change: true,
151 }
152 })
153 .collect();
154
155 let total_field_changes = modified_nodes
156 .iter()
157 .map(|nd| nd.field_changes.len())
158 .sum::<usize>()
159 + header_changes.len();
160
161 let has_breaking = !removed_nodes.is_empty()
162 || header_changes
163 .iter()
164 .any(|c| c.severity == ChangeSeverity::Breaking)
165 || modified_nodes.iter().any(|nd| nd.has_breaking_change);
166
167 let summary = DiffSummary {
168 nodes_added: 0,
169 nodes_removed: removed_nodes.len(),
170 nodes_modified: modified_nodes.len(),
171 nodes_unchanged: self.summary.nodes_unchanged,
172 header_changes: header_changes.len(),
173 total_field_changes,
174 has_breaking_changes: has_breaking,
175 };
176
177 DiffReport {
178 header_changes,
179 added_nodes,
180 removed_nodes,
181 modified_nodes,
182 summary,
183 }
184 }
185}
186
187#[must_use]
199pub fn diff(left: &AgmFile, right: &AgmFile) -> DiffReport {
200 let header_changes = header::diff_headers(&left.header, &right.header);
201 let node_result = node::diff_nodes(left, right);
202
203 let has_breaking = !node_result.removed.is_empty()
204 || header_changes
205 .iter()
206 .any(|c| c.severity == ChangeSeverity::Breaking)
207 || node_result.modified.iter().any(|nd| nd.has_breaking_change);
208
209 let total_field_changes = node_result
210 .modified
211 .iter()
212 .map(|nd| nd.field_changes.len())
213 .sum::<usize>()
214 + header_changes.len();
215
216 let summary = DiffSummary {
217 nodes_added: node_result.added.len(),
218 nodes_removed: node_result.removed.len(),
219 nodes_modified: node_result.modified.len(),
220 nodes_unchanged: node_result.unchanged_count,
221 header_changes: header_changes.len(),
222 total_field_changes,
223 has_breaking_changes: has_breaking,
224 };
225
226 DiffReport {
227 header_changes,
228 added_nodes: node_result.added,
229 removed_nodes: node_result.removed,
230 modified_nodes: node_result.modified,
231 summary,
232 }
233}
234
235#[cfg(test)]
240mod tests {
241 use super::*;
242
243 fn empty_report() -> DiffReport {
244 DiffReport {
245 header_changes: vec![],
246 added_nodes: vec![],
247 removed_nodes: vec![],
248 modified_nodes: vec![],
249 summary: DiffSummary {
250 nodes_added: 0,
251 nodes_removed: 0,
252 nodes_modified: 0,
253 nodes_unchanged: 0,
254 header_changes: 0,
255 total_field_changes: 0,
256 has_breaking_changes: false,
257 },
258 }
259 }
260
261 #[test]
262 fn test_diff_report_is_empty_when_no_changes_returns_true() {
263 assert!(empty_report().is_empty());
264 }
265
266 #[test]
267 fn test_diff_report_is_empty_when_has_changes_returns_false() {
268 let mut r = empty_report();
269 r.added_nodes.push("some.node".to_owned());
270 assert!(!r.is_empty());
271 }
272
273 #[test]
274 fn test_diff_report_has_breaking_changes_when_none_returns_false() {
275 assert!(!empty_report().has_breaking_changes());
276 }
277
278 #[test]
279 fn test_diff_report_has_breaking_changes_when_present_returns_true() {
280 let mut r = empty_report();
281 r.summary.has_breaking_changes = true;
282 assert!(r.has_breaking_changes());
283 }
284
285 #[test]
286 fn test_change_kind_display_roundtrip() {
287 assert_eq!(ChangeKind::Added.to_string(), "added");
288 assert_eq!(ChangeKind::Removed.to_string(), "removed");
289 assert_eq!(ChangeKind::Modified.to_string(), "modified");
290 }
291
292 #[test]
293 fn test_change_severity_ordering() {
294 assert!(ChangeSeverity::Info < ChangeSeverity::Minor);
295 assert!(ChangeSeverity::Minor < ChangeSeverity::Breaking);
296 assert!(ChangeSeverity::Info < ChangeSeverity::Breaking);
297 }
298}