Skip to main content

agm_core/diff/
mod.rs

1//! Semantic diff for AGM files.
2//!
3//! Compares two `AgmFile` values at the structural level -- nodes, fields,
4//! and relationships -- rather than raw text. Produces a typed `DiffReport`
5//! that classifies every change by kind and severity.
6
7use 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
17// Re-exports for convenience
18pub use fields::{FieldChange, FieldValueSnapshot};
19pub use header::HeaderChange;
20pub use node::NodeDiff;
21pub use render::DiffFormat;
22
23// ---------------------------------------------------------------------------
24// ChangeKind
25// ---------------------------------------------------------------------------
26
27/// The kind of structural change.
28#[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// ---------------------------------------------------------------------------
47// ChangeSeverity
48// ---------------------------------------------------------------------------
49
50/// Semantic severity of a change, used for CI gating.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum ChangeSeverity {
54    /// Informational (detail text, notes, examples changed).
55    Info,
56    /// Minor structural change (tags, summary, operational fields).
57    Minor,
58    /// Potentially breaking (type changed, dependency removed, node removed).
59    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// ---------------------------------------------------------------------------
73// DiffSummary
74// ---------------------------------------------------------------------------
75
76/// Aggregate statistics for a diff report.
77#[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// ---------------------------------------------------------------------------
89// DiffReport
90// ---------------------------------------------------------------------------
91
92/// The complete semantic diff between two AGM files.
93#[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    /// Returns true if no semantic differences were found.
104    #[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    /// Returns true if any breaking change was detected.
113    #[must_use]
114    pub fn has_breaking_changes(&self) -> bool {
115        self.summary.has_breaking_changes
116    }
117
118    /// Returns a new report containing only breaking changes.
119    #[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        // added_nodes are never breaking (additive)
129        let added_nodes: Vec<String> = vec![];
130
131        // removed_nodes are always breaking
132        let removed_nodes = self.removed_nodes.clone();
133
134        // Keep only modified nodes that have at least one breaking field change,
135        // and within those, keep only the breaking field changes.
136        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// ---------------------------------------------------------------------------
188// diff() -- public entry point
189// ---------------------------------------------------------------------------
190
191/// Computes the semantic diff between two AGM files.
192///
193/// Nodes are matched by ID. Nodes present only in `left` are reported as
194/// removed; nodes present only in `right` are reported as added; nodes
195/// present in both are compared field-by-field.
196///
197/// Header fields are compared independently of nodes.
198#[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// ---------------------------------------------------------------------------
236// Tests
237// ---------------------------------------------------------------------------
238
239#[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}