1use crate::store::Annotation;
8use crate::types::{Binding, TagName};
9use quick_xml::events::Event;
10use quick_xml::Reader;
11use serde::Serialize;
12
13#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
15pub struct SidecarEdit {
16 pub start_byte: usize,
18 pub end_byte: usize,
20 pub new_text: String,
22}
23
24struct LocatedElement {
26 tag: TagName,
27 binding: Binding,
28 start_byte: usize,
30 end_byte: usize,
32}
33
34#[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 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 let mut matched_keys: rustc_hash::FxHashSet<(&str, &str)> = rustc_hash::FxHashSet::default();
57
58 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 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 to_append.push(ann);
77 }
78 }
79
80 for elem in &existing {
82 let key = (elem.tag.as_ref(), elem.binding.as_ref());
83 if elem.binding.is_empty() {
85 continue;
86 }
87 if !matched_keys.contains(&key) {
88 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 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 edits.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
118 edits
119}
120
121#[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
131fn 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 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
190fn 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
202fn 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 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
233fn escape_attr(s: &str) -> String {
235 s.replace('&', "&")
236 .replace('"', """)
237 .replace('<', "<")
238 .replace('>', ">")
239}
240
241fn skip_trailing_newline(xml: &str, pos: usize) -> usize {
243 let bytes = xml.as_bytes();
244 let mut i = pos;
245 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
247 i += 1;
248 }
249 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 let xml = r#"<controller bind="handle_create" method="POST" />"#;
286 let annotations = vec![ann("controller", "handle_create", vec![("method", "POST")])];
287
288 let edits = sync_sidecar(xml, &annotations);
290
291 assert!(
293 edits.is_empty(),
294 "identical content should produce no edits"
295 );
296 }
297
298 #[test]
299 fn added_annotation_appended() {
300 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 let edits = sync_sidecar(xml, &annotations);
309
310 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 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 let edits = sync_sidecar(xml, &annotations);
326
327 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 let xml = r#"<controller bind="handle_create" method="POST" />"#;
339 let annotations = vec![ann("controller", "handle_create", vec![("method", "PUT")])];
340
341 let edits = sync_sidecar(xml, &annotations);
343
344 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 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 let edits = sync_sidecar(xml, &annotations);
360 let result = apply_edits(xml, &edits);
361
362 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 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 let edits = sync_sidecar(xml, &annotations);
381
382 assert!(
384 edits.is_empty(),
385 "elements without bind should not be touched"
386 );
387 }
388}