1use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::model::file::Header;
8
9use super::{ChangeKind, ChangeSeverity};
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct HeaderChange {
14 pub field: String,
15 pub kind: ChangeKind,
16 pub severity: ChangeSeverity,
17 pub old_value: Option<String>,
18 pub new_value: Option<String>,
19}
20
21#[must_use]
25pub fn diff_headers(left: &Header, right: &Header) -> Vec<HeaderChange> {
26 let mut changes = Vec::new();
27
28 compare_scalar(
30 &mut changes,
31 "agm",
32 &left.agm,
33 &right.agm,
34 header_field_severity,
35 );
36 compare_scalar(
37 &mut changes,
38 "package",
39 &left.package,
40 &right.package,
41 header_field_severity,
42 );
43 compare_scalar(
44 &mut changes,
45 "version",
46 &left.version,
47 &right.version,
48 header_field_severity,
49 );
50
51 compare_opt_scalar(
53 &mut changes,
54 "title",
55 left.title.as_deref(),
56 right.title.as_deref(),
57 header_field_severity,
58 );
59 compare_opt_scalar(
60 &mut changes,
61 "owner",
62 left.owner.as_deref(),
63 right.owner.as_deref(),
64 header_field_severity,
65 );
66 compare_opt_scalar(
67 &mut changes,
68 "description",
69 left.description.as_deref(),
70 right.description.as_deref(),
71 header_field_severity,
72 );
73 compare_opt_scalar(
74 &mut changes,
75 "status",
76 left.status.as_deref(),
77 right.status.as_deref(),
78 header_field_severity,
79 );
80 compare_opt_scalar(
81 &mut changes,
82 "default_load",
83 left.default_load.as_deref(),
84 right.default_load.as_deref(),
85 header_field_severity,
86 );
87 compare_opt_scalar(
88 &mut changes,
89 "target_runtime",
90 left.target_runtime.as_deref(),
91 right.target_runtime.as_deref(),
92 header_field_severity,
93 );
94
95 compare_opt_tag_list(
97 &mut changes,
98 "tags",
99 left.tags.as_deref(),
100 right.tags.as_deref(),
101 );
102
103 compare_imports(
105 &mut changes,
106 left.imports.as_deref(),
107 right.imports.as_deref(),
108 );
109
110 compare_load_profiles(
112 &mut changes,
113 left.load_profiles.as_ref(),
114 right.load_profiles.as_ref(),
115 );
116
117 changes
118}
119
120fn header_field_severity(field: &str, kind: ChangeKind) -> ChangeSeverity {
122 match (field, kind) {
123 ("agm", _) => ChangeSeverity::Breaking,
124 ("package", _) => ChangeSeverity::Breaking,
125 ("version", _) => ChangeSeverity::Info,
126 ("title", _) => ChangeSeverity::Info,
127 ("owner", _) => ChangeSeverity::Info,
128 ("description", _) => ChangeSeverity::Info,
129 ("tags", _) => ChangeSeverity::Info,
130 ("status", ChangeKind::Added) => ChangeSeverity::Info,
131 ("status", _) => ChangeSeverity::Minor,
132 ("default_load", ChangeKind::Added) => ChangeSeverity::Info,
133 ("default_load", _) => ChangeSeverity::Minor,
134 ("imports", ChangeKind::Added) => ChangeSeverity::Minor,
135 ("imports", ChangeKind::Removed) => ChangeSeverity::Breaking,
136 ("imports", ChangeKind::Modified) => ChangeSeverity::Minor,
137 ("load_profiles", ChangeKind::Added) => ChangeSeverity::Info,
138 ("load_profiles", _) => ChangeSeverity::Minor,
139 ("target_runtime", ChangeKind::Added) => ChangeSeverity::Info,
140 ("target_runtime", _) => ChangeSeverity::Minor,
141 _ => ChangeSeverity::Info,
142 }
143}
144
145fn compare_scalar(
146 changes: &mut Vec<HeaderChange>,
147 field: &str,
148 left: &str,
149 right: &str,
150 severity_fn: fn(&str, ChangeKind) -> ChangeSeverity,
151) {
152 if left != right {
153 changes.push(HeaderChange {
154 field: field.to_owned(),
155 kind: ChangeKind::Modified,
156 severity: severity_fn(field, ChangeKind::Modified),
157 old_value: Some(left.to_owned()),
158 new_value: Some(right.to_owned()),
159 });
160 }
161}
162
163fn compare_opt_scalar(
164 changes: &mut Vec<HeaderChange>,
165 field: &str,
166 left: Option<&str>,
167 right: Option<&str>,
168 severity_fn: fn(&str, ChangeKind) -> ChangeSeverity,
169) {
170 match (left, right) {
171 (None, None) => {}
172 (None, Some(r)) => changes.push(HeaderChange {
173 field: field.to_owned(),
174 kind: ChangeKind::Added,
175 severity: severity_fn(field, ChangeKind::Added),
176 old_value: None,
177 new_value: Some(r.to_owned()),
178 }),
179 (Some(l), None) => changes.push(HeaderChange {
180 field: field.to_owned(),
181 kind: ChangeKind::Removed,
182 severity: severity_fn(field, ChangeKind::Removed),
183 old_value: Some(l.to_owned()),
184 new_value: None,
185 }),
186 (Some(l), Some(r)) => {
187 if l != r {
188 changes.push(HeaderChange {
189 field: field.to_owned(),
190 kind: ChangeKind::Modified,
191 severity: severity_fn(field, ChangeKind::Modified),
192 old_value: Some(l.to_owned()),
193 new_value: Some(r.to_owned()),
194 });
195 }
196 }
197 }
198}
199
200fn compare_opt_tag_list(
201 changes: &mut Vec<HeaderChange>,
202 field: &str,
203 left: Option<&[String]>,
204 right: Option<&[String]>,
205) {
206 let left_set: BTreeSet<&str> = left
207 .unwrap_or_default()
208 .iter()
209 .map(String::as_str)
210 .collect();
211 let right_set: BTreeSet<&str> = right
212 .unwrap_or_default()
213 .iter()
214 .map(String::as_str)
215 .collect();
216
217 if left_set != right_set {
218 let old_val = if left.is_some() {
219 Some(format!("[{}]", left.unwrap_or_default().join(", ")))
220 } else {
221 None
222 };
223 let new_val = if right.is_some() {
224 Some(format!("[{}]", right.unwrap_or_default().join(", ")))
225 } else {
226 None
227 };
228
229 let kind = match (
230 left.is_none() || left_set.is_empty(),
231 right.is_none() || right_set.is_empty(),
232 ) {
233 (true, false) => ChangeKind::Added,
234 (false, true) => ChangeKind::Removed,
235 _ => ChangeKind::Modified,
236 };
237
238 changes.push(HeaderChange {
239 field: field.to_owned(),
240 kind,
241 severity: ChangeSeverity::Info,
242 old_value: old_val,
243 new_value: new_val,
244 });
245 }
246}
247
248fn compare_imports(
249 changes: &mut Vec<HeaderChange>,
250 left: Option<&[crate::model::imports::ImportEntry]>,
251 right: Option<&[crate::model::imports::ImportEntry]>,
252) {
253 let left_map: std::collections::BTreeMap<&str, &crate::model::imports::ImportEntry> = left
254 .unwrap_or_default()
255 .iter()
256 .map(|e| (e.package.as_str(), e))
257 .collect();
258 let right_map: std::collections::BTreeMap<&str, &crate::model::imports::ImportEntry> = right
259 .unwrap_or_default()
260 .iter()
261 .map(|e| (e.package.as_str(), e))
262 .collect();
263
264 for (pkg, entry) in &left_map {
266 if !right_map.contains_key(pkg) {
267 changes.push(HeaderChange {
268 field: "imports".to_owned(),
269 kind: ChangeKind::Removed,
270 severity: ChangeSeverity::Breaking,
271 old_value: Some(entry.to_string()),
272 new_value: None,
273 });
274 }
275 }
276
277 for (pkg, entry) in &right_map {
279 if !left_map.contains_key(pkg) {
280 changes.push(HeaderChange {
281 field: "imports".to_owned(),
282 kind: ChangeKind::Added,
283 severity: ChangeSeverity::Minor,
284 old_value: None,
285 new_value: Some(entry.to_string()),
286 });
287 }
288 }
289
290 for (pkg, left_entry) in &left_map {
292 if let Some(right_entry) = right_map.get(pkg) {
293 if left_entry.version_constraint != right_entry.version_constraint {
294 changes.push(HeaderChange {
295 field: "imports".to_owned(),
296 kind: ChangeKind::Modified,
297 severity: ChangeSeverity::Minor,
298 old_value: Some(left_entry.to_string()),
299 new_value: Some(right_entry.to_string()),
300 });
301 }
302 }
303 }
304}
305
306fn compare_load_profiles(
307 changes: &mut Vec<HeaderChange>,
308 left: Option<&std::collections::BTreeMap<String, crate::model::file::LoadProfile>>,
309 right: Option<&std::collections::BTreeMap<String, crate::model::file::LoadProfile>>,
310) {
311 let empty = std::collections::BTreeMap::new();
312 let left_map = left.unwrap_or(&empty);
313 let right_map = right.unwrap_or(&empty);
314
315 if left_map == right_map {
316 return;
317 }
318
319 for key in left_map.keys() {
321 if !right_map.contains_key(key) {
322 changes.push(HeaderChange {
323 field: "load_profiles".to_owned(),
324 kind: ChangeKind::Removed,
325 severity: ChangeSeverity::Minor,
326 old_value: Some(key.clone()),
327 new_value: None,
328 });
329 }
330 }
331
332 for key in right_map.keys() {
334 if !left_map.contains_key(key) {
335 changes.push(HeaderChange {
336 field: "load_profiles".to_owned(),
337 kind: ChangeKind::Added,
338 severity: ChangeSeverity::Info,
339 old_value: None,
340 new_value: Some(key.clone()),
341 });
342 }
343 }
344
345 for (key, left_lp) in left_map {
347 if let Some(right_lp) = right_map.get(key) {
348 if left_lp != right_lp {
349 changes.push(HeaderChange {
350 field: "load_profiles".to_owned(),
351 kind: ChangeKind::Modified,
352 severity: ChangeSeverity::Minor,
353 old_value: Some(key.clone()),
354 new_value: Some(key.clone()),
355 });
356 }
357 }
358 }
359}
360
361#[cfg(test)]
366mod tests {
367 use std::collections::BTreeMap;
368
369 use crate::model::file::{Header, LoadProfile};
370 use crate::model::imports::ImportEntry;
371
372 use super::*;
373
374 fn base_header() -> Header {
375 Header {
376 agm: "1.0".to_owned(),
377 package: "test.pkg".to_owned(),
378 version: "0.1.0".to_owned(),
379 title: None,
380 owner: None,
381 imports: None,
382 default_load: None,
383 description: None,
384 tags: None,
385 status: None,
386 load_profiles: None,
387 target_runtime: None,
388 }
389 }
390
391 #[test]
392 fn test_diff_headers_identical_returns_empty() {
393 let h = base_header();
394 assert!(diff_headers(&h, &h).is_empty());
395 }
396
397 #[test]
398 fn test_diff_headers_version_changed_returns_info() {
399 let left = base_header();
400 let mut right = left.clone();
401 right.version = "0.2.0".to_owned();
402 let changes = diff_headers(&left, &right);
403 assert_eq!(changes.len(), 1);
404 assert_eq!(changes[0].field, "version");
405 assert_eq!(changes[0].severity, ChangeSeverity::Info);
406 assert_eq!(changes[0].kind, ChangeKind::Modified);
407 }
408
409 #[test]
410 fn test_diff_headers_package_changed_returns_breaking() {
411 let left = base_header();
412 let mut right = left.clone();
413 right.package = "other.pkg".to_owned();
414 let changes = diff_headers(&left, &right);
415 assert_eq!(changes.len(), 1);
416 assert_eq!(changes[0].field, "package");
417 assert_eq!(changes[0].severity, ChangeSeverity::Breaking);
418 }
419
420 #[test]
421 fn test_diff_headers_agm_changed_returns_breaking() {
422 let left = base_header();
423 let mut right = left.clone();
424 right.agm = "2.0".to_owned();
425 let changes = diff_headers(&left, &right);
426 assert_eq!(changes.len(), 1);
427 assert_eq!(changes[0].field, "agm");
428 assert_eq!(changes[0].severity, ChangeSeverity::Breaking);
429 }
430
431 #[test]
432 fn test_diff_headers_title_added_returns_info() {
433 let left = base_header();
434 let mut right = left.clone();
435 right.title = Some("My Title".to_owned());
436 let changes = diff_headers(&left, &right);
437 assert_eq!(changes.len(), 1);
438 assert_eq!(changes[0].field, "title");
439 assert_eq!(changes[0].kind, ChangeKind::Added);
440 assert_eq!(changes[0].severity, ChangeSeverity::Info);
441 }
442
443 #[test]
444 fn test_diff_headers_title_removed_returns_info() {
445 let mut left = base_header();
446 left.title = Some("My Title".to_owned());
447 let right = base_header();
448 let changes = diff_headers(&left, &right);
449 assert_eq!(changes.len(), 1);
450 assert_eq!(changes[0].field, "title");
451 assert_eq!(changes[0].kind, ChangeKind::Removed);
452 assert_eq!(changes[0].severity, ChangeSeverity::Info);
453 }
454
455 #[test]
456 fn test_diff_headers_status_changed_returns_minor() {
457 let mut left = base_header();
458 left.status = Some("draft".to_owned());
459 let mut right = left.clone();
460 right.status = Some("stable".to_owned());
461 let changes = diff_headers(&left, &right);
462 assert_eq!(changes.len(), 1);
463 assert_eq!(changes[0].field, "status");
464 assert_eq!(changes[0].severity, ChangeSeverity::Minor);
465 }
466
467 #[test]
468 fn test_diff_headers_imports_added_returns_minor() {
469 let left = base_header();
470 let mut right = left.clone();
471 right.imports = Some(vec![ImportEntry::new("shared.auth".to_owned(), None)]);
472 let changes = diff_headers(&left, &right);
473 assert_eq!(changes.len(), 1);
474 assert_eq!(changes[0].field, "imports");
475 assert_eq!(changes[0].kind, ChangeKind::Added);
476 assert_eq!(changes[0].severity, ChangeSeverity::Minor);
477 }
478
479 #[test]
480 fn test_diff_headers_imports_removed_returns_breaking() {
481 let mut left = base_header();
482 left.imports = Some(vec![ImportEntry::new("shared.auth".to_owned(), None)]);
483 let right = base_header();
484 let changes = diff_headers(&left, &right);
485 assert_eq!(changes.len(), 1);
486 assert_eq!(changes[0].field, "imports");
487 assert_eq!(changes[0].kind, ChangeKind::Removed);
488 assert_eq!(changes[0].severity, ChangeSeverity::Breaking);
489 }
490
491 #[test]
492 fn test_diff_headers_tags_changed_returns_info() {
493 let mut left = base_header();
494 left.tags = Some(vec!["auth".to_owned()]);
495 let mut right = left.clone();
496 right.tags = Some(vec!["auth".to_owned(), "security".to_owned()]);
497 let changes = diff_headers(&left, &right);
498 assert_eq!(changes.len(), 1);
499 assert_eq!(changes[0].field, "tags");
500 assert_eq!(changes[0].severity, ChangeSeverity::Info);
501 }
502
503 #[test]
504 fn test_diff_headers_load_profiles_added_returns_info() {
505 let left = base_header();
506 let mut right = left.clone();
507 let mut lp = BTreeMap::new();
508 lp.insert(
509 "minimal".to_owned(),
510 LoadProfile {
511 filter: "priority in [critical]".to_owned(),
512 estimated_tokens: None,
513 },
514 );
515 right.load_profiles = Some(lp);
516 let changes = diff_headers(&left, &right);
517 assert_eq!(changes.len(), 1);
518 assert_eq!(changes[0].field, "load_profiles");
519 assert_eq!(changes[0].kind, ChangeKind::Added);
520 assert_eq!(changes[0].severity, ChangeSeverity::Info);
521 }
522
523 #[test]
524 fn test_diff_headers_load_profiles_removed_returns_minor() {
525 let mut left = base_header();
526 let mut lp = BTreeMap::new();
527 lp.insert(
528 "minimal".to_owned(),
529 LoadProfile {
530 filter: "priority in [critical]".to_owned(),
531 estimated_tokens: None,
532 },
533 );
534 left.load_profiles = Some(lp);
535 let right = base_header();
536 let changes = diff_headers(&left, &right);
537 assert_eq!(changes.len(), 1);
538 assert_eq!(changes[0].field, "load_profiles");
539 assert_eq!(changes[0].kind, ChangeKind::Removed);
540 assert_eq!(changes[0].severity, ChangeSeverity::Minor);
541 }
542}