Skip to main content

agm_core/diff/
relation.rs

1//! Specialized element-level diff for list and relationship fields.
2
3use std::collections::BTreeSet;
4
5use super::ChangeKind;
6use super::fields::{FieldChange, FieldValueSnapshot, classify_severity};
7
8/// Performs element-level diffing of a relationship list field.
9///
10/// Instead of comparing the entire list as a single value, this function
11/// reports individual elements that were added or removed. Reordering
12/// is NOT reported as a change for unordered relationship fields
13/// (related_to, replaces, conflicts, see_also).
14///
15/// For `depends`, order changes ARE reported as Minor (dependency
16/// resolution order may matter for execution).
17///
18/// For `steps`, order changes ARE reported as Minor (steps are ordered).
19///
20/// For all other list fields (items, tags, etc.), order is ignored (set comparison).
21#[must_use]
22pub(crate) fn diff_relation_field(
23    field_name: &str,
24    old: &[String],
25    new: &[String],
26) -> Vec<FieldChange> {
27    let mut changes = Vec::new();
28
29    if is_ordered_field(field_name) {
30        diff_ordered(field_name, old, new, &mut changes);
31    } else {
32        diff_unordered(field_name, old, new, &mut changes);
33    }
34
35    changes
36}
37
38/// Diffs an unordered field using set semantics.
39fn diff_unordered(
40    field_name: &str,
41    old: &[String],
42    new: &[String],
43    changes: &mut Vec<FieldChange>,
44) {
45    let old_set: BTreeSet<&str> = old.iter().map(String::as_str).collect();
46    let new_set: BTreeSet<&str> = new.iter().map(String::as_str).collect();
47
48    // Elements removed (in old, not in new)
49    for elem in old_set.difference(&new_set) {
50        let severity = classify_severity(field_name, ChangeKind::Removed);
51        changes.push(FieldChange {
52            field: field_name.to_owned(),
53            kind: ChangeKind::Removed,
54            severity,
55            old_value: Some(FieldValueSnapshot::Scalar((*elem).to_owned())),
56            new_value: None,
57        });
58    }
59
60    // Elements added (in new, not in old)
61    for elem in new_set.difference(&old_set) {
62        let severity = classify_severity(field_name, ChangeKind::Added);
63        changes.push(FieldChange {
64            field: field_name.to_owned(),
65            kind: ChangeKind::Added,
66            severity,
67            old_value: None,
68            new_value: Some(FieldValueSnapshot::Scalar((*elem).to_owned())),
69        });
70    }
71}
72
73/// Diffs an ordered field.
74///
75/// Elements not in old are Added; elements not in new are Removed.
76/// For `depends`, if the sets are equal but order differs, report as Minor.
77fn diff_ordered(field_name: &str, old: &[String], new: &[String], changes: &mut Vec<FieldChange>) {
78    let old_set: BTreeSet<&str> = old.iter().map(String::as_str).collect();
79    let new_set: BTreeSet<&str> = new.iter().map(String::as_str).collect();
80
81    // Removed elements
82    for elem in old_set.difference(&new_set) {
83        let severity = classify_severity(field_name, ChangeKind::Removed);
84        changes.push(FieldChange {
85            field: field_name.to_owned(),
86            kind: ChangeKind::Removed,
87            severity,
88            old_value: Some(FieldValueSnapshot::Scalar((*elem).to_owned())),
89            new_value: None,
90        });
91    }
92
93    // Added elements
94    for elem in new_set.difference(&old_set) {
95        let severity = classify_severity(field_name, ChangeKind::Added);
96        changes.push(FieldChange {
97            field: field_name.to_owned(),
98            kind: ChangeKind::Added,
99            severity,
100            old_value: None,
101            new_value: Some(FieldValueSnapshot::Scalar((*elem).to_owned())),
102        });
103    }
104
105    // Order change: sets equal but order differs
106    if old_set == new_set && old != new {
107        changes.push(FieldChange {
108            field: field_name.to_owned(),
109            kind: ChangeKind::Modified,
110            severity: super::ChangeSeverity::Minor,
111            old_value: Some(FieldValueSnapshot::List(old.to_vec())),
112            new_value: Some(FieldValueSnapshot::List(new.to_vec())),
113        });
114    }
115}
116
117/// Returns true if the field should be compared as an ordered sequence
118/// (order changes count as modifications) rather than as a set.
119fn is_ordered_field(field_name: &str) -> bool {
120    matches!(field_name, "steps" | "depends")
121}
122
123// ---------------------------------------------------------------------------
124// Tests
125// ---------------------------------------------------------------------------
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::diff::ChangeSeverity;
131
132    fn strs(v: &[&str]) -> Vec<String> {
133        v.iter().map(|s| (*s).to_owned()).collect()
134    }
135
136    #[test]
137    fn test_diff_relation_depends_element_removed_returns_breaking() {
138        let old = strs(&["a", "b"]);
139        let new = strs(&["a"]);
140        let changes = diff_relation_field("depends", &old, &new);
141        assert_eq!(changes.len(), 1);
142        assert_eq!(changes[0].kind, ChangeKind::Removed);
143        assert_eq!(changes[0].severity, ChangeSeverity::Breaking);
144    }
145
146    #[test]
147    fn test_diff_relation_depends_element_added_returns_minor() {
148        let old = strs(&["a"]);
149        let new = strs(&["a", "b"]);
150        let changes = diff_relation_field("depends", &old, &new);
151        assert_eq!(changes.len(), 1);
152        assert_eq!(changes[0].kind, ChangeKind::Added);
153        assert_eq!(changes[0].severity, ChangeSeverity::Minor);
154    }
155
156    #[test]
157    fn test_diff_relation_related_to_element_added_returns_info() {
158        let old = strs(&["a"]);
159        let new = strs(&["a", "b"]);
160        let changes = diff_relation_field("related_to", &old, &new);
161        assert_eq!(changes.len(), 1);
162        assert_eq!(changes[0].kind, ChangeKind::Added);
163        assert_eq!(changes[0].severity, ChangeSeverity::Info);
164    }
165
166    #[test]
167    fn test_diff_relation_related_to_element_removed_returns_info() {
168        let old = strs(&["a", "b"]);
169        let new = strs(&["a"]);
170        let changes = diff_relation_field("related_to", &old, &new);
171        assert_eq!(changes.len(), 1);
172        assert_eq!(changes[0].kind, ChangeKind::Removed);
173        assert_eq!(changes[0].severity, ChangeSeverity::Info);
174    }
175
176    #[test]
177    fn test_diff_relation_see_also_reordered_returns_empty() {
178        let old = strs(&["a", "b", "c"]);
179        let new = strs(&["c", "a", "b"]);
180        let changes = diff_relation_field("see_also", &old, &new);
181        assert!(changes.is_empty());
182    }
183
184    #[test]
185    fn test_diff_relation_steps_element_removed_returns_breaking() {
186        let old = strs(&["step1", "step2", "step3"]);
187        let new = strs(&["step1", "step3"]);
188        let changes = diff_relation_field("steps", &old, &new);
189        // Should have a removal for step2
190        let removed: Vec<_> = changes
191            .iter()
192            .filter(|c| c.kind == ChangeKind::Removed)
193            .collect();
194        assert!(!removed.is_empty());
195        assert_eq!(removed[0].severity, ChangeSeverity::Breaking);
196    }
197
198    #[test]
199    fn test_diff_relation_tags_reordered_returns_empty() {
200        let old = strs(&["alpha", "beta"]);
201        let new = strs(&["beta", "alpha"]);
202        let changes = diff_relation_field("tags", &old, &new);
203        assert!(changes.is_empty());
204    }
205
206    #[test]
207    fn test_diff_relation_empty_to_empty_returns_empty() {
208        let changes = diff_relation_field("items", &[], &[]);
209        assert!(changes.is_empty());
210    }
211
212    #[test]
213    fn test_diff_relation_empty_to_some_returns_added() {
214        let old: Vec<String> = vec![];
215        let new = strs(&["x", "y"]);
216        let changes = diff_relation_field("items", &old, &new);
217        assert_eq!(changes.len(), 2);
218        assert!(changes.iter().all(|c| c.kind == ChangeKind::Added));
219    }
220
221    #[test]
222    fn test_diff_relation_some_to_empty_returns_removed() {
223        let old = strs(&["x", "y"]);
224        let new: Vec<String> = vec![];
225        let changes = diff_relation_field("items", &old, &new);
226        assert_eq!(changes.len(), 2);
227        assert!(changes.iter().all(|c| c.kind == ChangeKind::Removed));
228    }
229}