Skip to main content

amql_engine/
sync.rs

1//! Incremental sync between source code changes and `.aqm` sidecar files.
2//!
3//! Given old XML content and new annotations (from extractors), produces a
4//! minimal set of text edits. Only elements whose `bind` values changed are
5//! touched — the rest of the XML is preserved byte-for-byte.
6
7use crate::store::Annotation;
8use crate::types::{Binding, TagName};
9use quick_xml::events::Event;
10use quick_xml::Reader;
11use serde::Serialize;
12
13/// A text edit to apply to an `.aqm` sidecar file.
14#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
15pub struct SidecarEdit {
16    /// Byte offset of the start of the region to replace.
17    pub start_byte: usize,
18    /// Byte offset of the end of the region to replace.
19    pub end_byte: usize,
20    /// Replacement text (empty string = deletion).
21    pub new_text: String,
22}
23
24/// A located element in existing XML, with its byte span and identity.
25struct LocatedElement {
26    tag: TagName,
27    binding: Binding,
28    /// Byte offset in the original (unwrapped) XML where this element starts.
29    start_byte: usize,
30    /// Byte offset in the original (unwrapped) XML where this element ends.
31    end_byte: usize,
32}
33
34/// Compute minimal edits to sync an `.aqm` sidecar with new annotations.
35///
36/// Matches elements by `bind` attribute. Produces edits for:
37/// - Renamed bindings (bind attr value changed) — not applicable (bind is the key)
38/// - Added annotations — appended at end
39/// - Removed annotations — element deleted
40/// - Attribute changes on existing elements — element replaced
41///
42/// Returns edits sorted by `start_byte` descending (apply back-to-front).
43#[must_use = "sync result contains edits to apply"]
44pub fn sync_sidecar(old_xml: &str, new_annotations: &[Annotation]) -> Vec<SidecarEdit> {
45    let existing = locate_elements(old_xml);
46    let mut edits = Vec::new();
47
48    // Index existing elements by (tag, binding) for O(1) lookup
49    let mut existing_by_key: rustc_hash::FxHashMap<(&str, &str), &LocatedElement> =
50        rustc_hash::FxHashMap::default();
51    for elem in &existing {
52        existing_by_key.insert((elem.tag.as_ref(), elem.binding.as_ref()), elem);
53    }
54
55    // Track which existing elements are still present
56    let mut matched_keys: rustc_hash::FxHashSet<(&str, &str)> = rustc_hash::FxHashSet::default();
57
58    // Check each new annotation against existing
59    let mut to_append = Vec::new();
60    for ann in new_annotations {
61        let key = (ann.tag.as_ref(), ann.binding.as_ref());
62        if let Some(elem) = existing_by_key.get(&key) {
63            matched_keys.insert(key);
64            // Element exists — check if attributes changed
65            let new_xml = serialize_annotation(ann);
66            let old_text = &old_xml[elem.start_byte..elem.end_byte];
67            if old_text.trim() != new_xml.trim() {
68                edits.push(SidecarEdit {
69                    start_byte: elem.start_byte,
70                    end_byte: elem.end_byte,
71                    new_text: new_xml,
72                });
73            }
74        } else {
75            // New annotation — append
76            to_append.push(ann);
77        }
78    }
79
80    // Remove annotations that no longer exist in source
81    for elem in &existing {
82        let key = (elem.tag.as_ref(), elem.binding.as_ref());
83        // Skip elements with empty bindings — they're manual annotations, not extracted
84        if elem.binding.is_empty() {
85            continue;
86        }
87        if !matched_keys.contains(&key) {
88            // Include trailing newline in deletion
89            let end = skip_trailing_newline(old_xml, elem.end_byte);
90            edits.push(SidecarEdit {
91                start_byte: elem.start_byte,
92                end_byte: end,
93                new_text: String::new(),
94            });
95        }
96    }
97
98    // Append new annotations at end
99    if !to_append.is_empty() {
100        let insert_pos = old_xml.trim_end().len();
101        let mut text = String::new();
102        for ann in to_append {
103            if insert_pos > 0 || !text.is_empty() {
104                text.push('\n');
105            }
106            text.push_str(&serialize_annotation(ann));
107        }
108        text.push('\n');
109        edits.push(SidecarEdit {
110            start_byte: insert_pos,
111            end_byte: insert_pos,
112            new_text: text,
113        });
114    }
115
116    // Sort descending by start_byte for back-to-front application
117    edits.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
118    edits
119}
120
121/// Apply edits to XML content. Edits must be sorted descending by `start_byte`.
122#[must_use = "returns the modified XML content"]
123pub fn apply_edits(xml: &str, edits: &[SidecarEdit]) -> String {
124    let mut result = xml.to_string();
125    for edit in edits {
126        result.replace_range(edit.start_byte..edit.end_byte, &edit.new_text);
127    }
128    result
129}
130
131/// Locate all top-level XML elements with their byte spans in the original text.
132fn locate_elements(xml: &str) -> Vec<LocatedElement> {
133    let mut elements = Vec::new();
134    let mut reader = Reader::from_str(xml);
135    let mut buf = Vec::new();
136
137    loop {
138        let pos_before = reader.buffer_position();
139        match reader.read_event_into(&mut buf) {
140            Ok(Event::Empty(ref e)) => {
141                let tag = crate::xml::element_name(e).unwrap_or_default();
142                let binding = extract_bind_attr(e);
143                let pos_after = reader.buffer_position();
144                elements.push(LocatedElement {
145                    tag: TagName::from(tag),
146                    binding,
147                    start_byte: pos_before as usize,
148                    end_byte: pos_after as usize,
149                });
150            }
151            Ok(Event::Start(ref e)) => {
152                let tag = crate::xml::element_name(e).unwrap_or_default();
153                let binding = extract_bind_attr(e);
154                // Read until matching end tag
155                let mut depth = 1u32;
156                let mut inner_buf = Vec::new();
157                loop {
158                    match reader.read_event_into(&mut inner_buf) {
159                        Ok(Event::Start(_)) => depth += 1,
160                        Ok(Event::End(_)) => {
161                            depth -= 1;
162                            if depth == 0 {
163                                break;
164                            }
165                        }
166                        Ok(Event::Eof) => break,
167                        Err(_) => break,
168                        _ => {}
169                    }
170                    inner_buf.clear();
171                }
172                let pos_after = reader.buffer_position();
173                elements.push(LocatedElement {
174                    tag: TagName::from(tag),
175                    binding,
176                    start_byte: pos_before as usize,
177                    end_byte: pos_after as usize,
178                });
179            }
180            Ok(Event::Eof) => break,
181            Err(_) => break,
182            _ => {}
183        }
184        buf.clear();
185    }
186
187    elements
188}
189
190/// Extract the `bind` attribute value from an XML element.
191fn extract_bind_attr(e: &quick_xml::events::BytesStart<'_>) -> Binding {
192    let pairs = crate::xml::attr_map(e).unwrap_or_default();
193    Binding::from(
194        pairs
195            .iter()
196            .find(|(k, _)| k == "bind")
197            .map(|(_, v)| v.as_str())
198            .unwrap_or_default(),
199    )
200}
201
202/// Serialize an annotation back to XML.
203fn serialize_annotation(ann: &Annotation) -> String {
204    let mut xml = format!("<{}", ann.tag);
205    if !ann.binding.is_empty() {
206        xml.push_str(&format!(" bind=\"{}\"", escape_attr(ann.binding.as_ref())));
207    }
208    // Sort attributes for deterministic output
209    let mut attrs: Vec<_> = ann.attrs.iter().collect();
210    attrs.sort_by_key(|(k, _)| (*k).clone());
211    for (key, value) in &attrs {
212        let val_str = match value {
213            serde_json::Value::String(s) => s.clone(),
214            serde_json::Value::Bool(b) => b.to_string(),
215            serde_json::Value::Number(n) => n.to_string(),
216            other => other.to_string(),
217        };
218        xml.push_str(&format!(" {}=\"{}\"", key.as_ref(), escape_attr(&val_str)));
219    }
220    if ann.children.is_empty() {
221        xml.push_str(" />");
222    } else {
223        xml.push('>');
224        for child in &ann.children {
225            xml.push_str("\n  ");
226            xml.push_str(&serialize_annotation(child));
227        }
228        xml.push_str(&format!("\n</{}>", ann.tag));
229    }
230    xml
231}
232
233/// Escape XML attribute value characters.
234fn escape_attr(s: &str) -> String {
235    s.replace('&', "&amp;")
236        .replace('"', "&quot;")
237        .replace('<', "&lt;")
238        .replace('>', "&gt;")
239}
240
241/// Skip trailing whitespace and at most one newline after an element end.
242fn skip_trailing_newline(xml: &str, pos: usize) -> usize {
243    let bytes = xml.as_bytes();
244    let mut i = pos;
245    // Skip spaces/tabs
246    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
247        i += 1;
248    }
249    // Skip one newline
250    if i < bytes.len() && bytes[i] == b'\n' {
251        i += 1;
252    } else if i < bytes.len() && bytes[i] == b'\r' {
253        i += 1;
254        if i < bytes.len() && bytes[i] == b'\n' {
255            i += 1;
256        }
257    }
258    i
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::types::{AttrName, RelativePath};
265    use rustc_hash::FxHashMap;
266    use serde_json::Value as JsonValue;
267
268    fn ann(tag: &str, bind: &str, attrs: Vec<(&str, &str)>) -> Annotation {
269        let mut attr_map = FxHashMap::default();
270        for (k, v) in attrs {
271            attr_map.insert(AttrName::from(k), JsonValue::String(v.to_string()));
272        }
273        Annotation {
274            tag: TagName::from(tag),
275            binding: Binding::from(bind),
276            attrs: attr_map,
277            file: RelativePath::from("test.ts"),
278            children: vec![],
279        }
280    }
281
282    #[test]
283    fn no_changes_produces_no_edits() {
284        // Arrange
285        let xml = r#"<controller bind="handle_create" method="POST" />"#;
286        let annotations = vec![ann("controller", "handle_create", vec![("method", "POST")])];
287
288        // Act
289        let edits = sync_sidecar(xml, &annotations);
290
291        // Assert
292        assert!(
293            edits.is_empty(),
294            "identical content should produce no edits"
295        );
296    }
297
298    #[test]
299    fn added_annotation_appended() {
300        // Arrange
301        let xml = r#"<controller bind="handle_create" method="POST" />"#;
302        let annotations = vec![
303            ann("controller", "handle_create", vec![("method", "POST")]),
304            ann("controller", "handle_delete", vec![("method", "DELETE")]),
305        ];
306
307        // Act
308        let edits = sync_sidecar(xml, &annotations);
309
310        // Assert
311        assert_eq!(edits.len(), 1, "should have one append edit");
312        assert!(
313            edits[0].new_text.contains("handle_delete"),
314            "appended text should contain new binding"
315        );
316    }
317
318    #[test]
319    fn removed_annotation_deleted() {
320        // Arrange
321        let xml = "<controller bind=\"handle_create\" method=\"POST\" />\n<controller bind=\"handle_delete\" method=\"DELETE\" />\n";
322        let annotations = vec![ann("controller", "handle_create", vec![("method", "POST")])];
323
324        // Act
325        let edits = sync_sidecar(xml, &annotations);
326
327        // Assert
328        assert_eq!(edits.len(), 1, "should have one deletion edit");
329        assert!(
330            edits[0].new_text.is_empty(),
331            "deletion edit should have empty replacement"
332        );
333    }
334
335    #[test]
336    fn changed_attribute_produces_replacement() {
337        // Arrange
338        let xml = r#"<controller bind="handle_create" method="POST" />"#;
339        let annotations = vec![ann("controller", "handle_create", vec![("method", "PUT")])];
340
341        // Act
342        let edits = sync_sidecar(xml, &annotations);
343
344        // Assert
345        assert_eq!(edits.len(), 1, "should have one replacement edit");
346        assert!(
347            edits[0].new_text.contains("PUT"),
348            "replacement should contain updated attribute"
349        );
350    }
351
352    #[test]
353    fn apply_edits_produces_correct_output() {
354        // Arrange
355        let xml = "<controller bind=\"handle_create\" method=\"POST\" />\n<controller bind=\"handle_delete\" method=\"DELETE\" />\n";
356        let annotations = vec![ann("controller", "handle_create", vec![("method", "PUT")])];
357
358        // Act
359        let edits = sync_sidecar(xml, &annotations);
360        let result = apply_edits(xml, &edits);
361
362        // Assert
363        assert!(
364            result.contains("PUT"),
365            "result should contain updated attribute"
366        );
367        assert!(
368            !result.contains("handle_delete"),
369            "result should not contain removed annotation"
370        );
371    }
372
373    #[test]
374    fn empty_binding_elements_preserved() {
375        // Arrange
376        let xml = "<meta version=\"1\" />\n<controller bind=\"handle_create\" method=\"POST\" />\n";
377        let annotations = vec![ann("controller", "handle_create", vec![("method", "POST")])];
378
379        // Act
380        let edits = sync_sidecar(xml, &annotations);
381
382        // Assert
383        assert!(
384            edits.is_empty(),
385            "elements without bind should not be touched"
386        );
387    }
388}