Skip to main content

agm_core/diff/
header.rs

1//! Header field-by-field comparison.
2
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::model::file::Header;
8
9use super::{ChangeKind, ChangeSeverity};
10
11/// A change to a single header field.
12#[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/// Compares two `Header` structs field-by-field.
22///
23/// Returns a list of changes. Unchanged fields are not included.
24#[must_use]
25pub fn diff_headers(left: &Header, right: &Header) -> Vec<HeaderChange> {
26    let mut changes = Vec::new();
27
28    // Required scalars: agm, package, version
29    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    // Optional scalars
52    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    // Optional list: tags (set comparison)
96    compare_opt_tag_list(
97        &mut changes,
98        "tags",
99        left.tags.as_deref(),
100        right.tags.as_deref(),
101    );
102
103    // Optional imports: element-level diff by package name
104    compare_imports(
105        &mut changes,
106        left.imports.as_deref(),
107        right.imports.as_deref(),
108    );
109
110    // Optional load_profiles: element-level diff by key
111    compare_load_profiles(
112        &mut changes,
113        left.load_profiles.as_ref(),
114        right.load_profiles.as_ref(),
115    );
116
117    changes
118}
119
120/// Classifies severity for a header field change.
121fn 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    // Detect removed (in left, not in right) -> Breaking
265    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    // Detect added (in right, not in left) -> Minor
278    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    // Detect modified (constraint changed)
291    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    // Keys only in left -> Removed -> Minor
320    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    // Keys only in right -> Added -> Info
333    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    // Keys in both but different values -> Modified -> Minor
346    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// ---------------------------------------------------------------------------
362// Tests
363// ---------------------------------------------------------------------------
364
365#[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}