Skip to main content

amql_engine/
diff.rs

1//! Annotation drift detection between current state and a git baseline.
2//!
3//! Compares the current AnnotationStore against a serialized baseline
4//! (typically from `git show HEAD`) to report added, removed, and changed
5//! annotations.
6
7use crate::store::Annotation;
8use crate::types::{Binding, RelativePath, TagName};
9use serde::Serialize;
10
11/// Summary of annotation changes between two states.
12#[non_exhaustive]
13#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
14#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
15#[cfg_attr(feature = "ts", ts(export))]
16#[cfg_attr(feature = "flow", flow(export))]
17#[derive(Debug, Clone, Serialize)]
18pub struct DiffResult {
19    /// Annotations present now but not in the baseline.
20    pub added: Vec<DiffEntry>,
21    /// Annotations present in the baseline but not now.
22    pub removed: Vec<DiffEntry>,
23    /// Annotations where tag+binding match but attributes differ.
24    pub changed: Vec<DiffChange>,
25}
26
27/// A single annotation in a diff.
28#[non_exhaustive]
29#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
30#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
31#[cfg_attr(feature = "ts", ts(export))]
32#[cfg_attr(feature = "flow", flow(export))]
33#[derive(Debug, Clone, Serialize)]
34pub struct DiffEntry {
35    /// File the annotation belongs to.
36    pub file: RelativePath,
37    /// Annotation tag.
38    pub tag: TagName,
39    /// Annotation binding.
40    pub binding: Binding,
41}
42
43/// An annotation that changed between baseline and current.
44#[non_exhaustive]
45#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
46#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
47#[cfg_attr(feature = "ts", ts(export))]
48#[cfg_attr(feature = "flow", flow(export))]
49#[derive(Debug, Clone, Serialize)]
50pub struct DiffChange {
51    /// File the annotation belongs to.
52    pub file: RelativePath,
53    /// Annotation tag.
54    pub tag: TagName,
55    /// Annotation binding.
56    pub binding: Binding,
57    /// Attributes in the baseline that differ or are absent in current.
58    pub baseline_attrs: Vec<String>,
59    /// Attributes in current that differ or are absent in baseline.
60    pub current_attrs: Vec<String>,
61}
62
63/// Compute the diff between baseline annotations and current annotations.
64///
65/// Both inputs are flat lists of annotations. Matching is by (file, tag, binding).
66#[must_use = "diff result contains added, removed, and changed annotations"]
67pub fn diff_annotations(baseline: &[Annotation], current: &[Annotation]) -> DiffResult {
68    let mut added = Vec::new();
69    let mut removed = Vec::new();
70    let mut changed = Vec::new();
71
72    // Index baseline by (file, tag, binding)
73    let baseline_set: rustc_hash::FxHashMap<(&str, &str, &str), &Annotation> = baseline
74        .iter()
75        .map(|a| (a.file.as_ref(), a.tag.as_ref(), a.binding.as_ref()))
76        .zip(baseline.iter())
77        .collect();
78
79    let current_set: rustc_hash::FxHashMap<(&str, &str, &str), &Annotation> = current
80        .iter()
81        .map(|a| (a.file.as_ref(), a.tag.as_ref(), a.binding.as_ref()))
82        .zip(current.iter())
83        .collect();
84
85    // Find added and changed
86    for (key, curr_ann) in &current_set {
87        match baseline_set.get(key) {
88            None => {
89                added.push(DiffEntry {
90                    file: curr_ann.file.clone(),
91                    tag: curr_ann.tag.clone(),
92                    binding: curr_ann.binding.clone(),
93                });
94            }
95            Some(base_ann) => {
96                let diff = attr_diff(base_ann, curr_ann);
97                if !diff.0.is_empty() || !diff.1.is_empty() {
98                    changed.push(DiffChange {
99                        file: curr_ann.file.clone(),
100                        tag: curr_ann.tag.clone(),
101                        binding: curr_ann.binding.clone(),
102                        baseline_attrs: diff.0,
103                        current_attrs: diff.1,
104                    });
105                }
106            }
107        }
108    }
109
110    // Find removed
111    for (key, base_ann) in &baseline_set {
112        if !current_set.contains_key(key) {
113            removed.push(DiffEntry {
114                file: base_ann.file.clone(),
115                tag: base_ann.tag.clone(),
116                binding: base_ann.binding.clone(),
117            });
118        }
119    }
120
121    DiffResult {
122        added,
123        removed,
124        changed,
125    }
126}
127
128/// Compare attributes between two annotations, returning changed attr names.
129fn attr_diff(baseline: &Annotation, current: &Annotation) -> (Vec<String>, Vec<String>) {
130    let mut baseline_diff = Vec::new();
131    let mut current_diff = Vec::new();
132
133    for (key, base_val) in &baseline.attrs {
134        match current.attrs.get(key.as_ref()) {
135            None => baseline_diff.push(format!("-{key}")),
136            Some(curr_val) if curr_val != base_val => {
137                baseline_diff.push(format!("{key}={base_val}"));
138                current_diff.push(format!("{key}={curr_val}"));
139            }
140            _ => {}
141        }
142    }
143
144    for key in current.attrs.keys() {
145        if !baseline.attrs.contains_key(key.as_ref()) {
146            current_diff.push(format!("+{key}"));
147        }
148    }
149
150    (baseline_diff, current_diff)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::types::{AttrName, Binding, TagName};
157    use rustc_hash::FxHashMap;
158    use serde_json::Value as JsonValue;
159
160    fn ann(tag: &str, binding: &str, file: &str, attrs: Vec<(&str, &str)>) -> Annotation {
161        let mut attr_map = FxHashMap::default();
162        for (k, v) in attrs {
163            attr_map.insert(AttrName::from(k), JsonValue::String(v.to_string()));
164        }
165        Annotation {
166            tag: TagName::from(tag),
167            attrs: attr_map,
168            binding: Binding::from(binding),
169            file: RelativePath::from(file),
170            children: vec![],
171        }
172    }
173
174    #[test]
175    fn detects_added_annotations() {
176        // Arrange
177        let baseline = vec![ann("route", "GET /users", "api.ts", vec![])];
178        let current = vec![
179            ann("route", "GET /users", "api.ts", vec![]),
180            ann("route", "POST /users", "api.ts", vec![]),
181        ];
182
183        // Act
184        let diff = diff_annotations(&baseline, &current);
185
186        // Assert
187        assert_eq!(diff.added.len(), 1, "should detect 1 added annotation");
188        assert_eq!(diff.added[0].binding, "POST /users", "added binding");
189        assert!(diff.removed.is_empty(), "nothing removed");
190    }
191
192    #[test]
193    fn detects_removed_annotations() {
194        // Arrange
195        let baseline = vec![
196            ann("route", "GET /users", "api.ts", vec![]),
197            ann("route", "DELETE /users", "api.ts", vec![]),
198        ];
199        let current = vec![ann("route", "GET /users", "api.ts", vec![])];
200
201        // Act
202        let diff = diff_annotations(&baseline, &current);
203
204        // Assert
205        assert_eq!(diff.removed.len(), 1, "should detect 1 removed annotation");
206        assert_eq!(diff.removed[0].binding, "DELETE /users", "removed binding");
207    }
208
209    #[test]
210    fn detects_changed_attributes() {
211        // Arrange
212        let baseline = vec![ann("route", "GET /users", "api.ts", vec![("auth", "none")])];
213        let current = vec![ann(
214            "route",
215            "GET /users",
216            "api.ts",
217            vec![("auth", "required")],
218        )];
219
220        // Act
221        let diff = diff_annotations(&baseline, &current);
222
223        // Assert
224        assert!(diff.added.is_empty(), "nothing added");
225        assert!(diff.removed.is_empty(), "nothing removed");
226        assert_eq!(diff.changed.len(), 1, "should detect 1 changed annotation");
227    }
228
229    #[test]
230    fn no_diff_on_identical() {
231        // Arrange
232        let anns = vec![ann(
233            "route",
234            "GET /users",
235            "api.ts",
236            vec![("method", "GET")],
237        )];
238
239        // Act
240        let diff = diff_annotations(&anns, &anns);
241
242        // Assert
243        assert!(diff.added.is_empty(), "nothing added");
244        assert!(diff.removed.is_empty(), "nothing removed");
245        assert!(diff.changed.is_empty(), "nothing changed");
246    }
247}