1use crate::xml::builders::{
4 merge_fragments, merge_xml_elements_owned, reorder_root_keys_owned, write_xml_file,
5 ShardFragments,
6};
7use crate::xml::multi_level::{ensure_segment_files_structure, load_multi_level_config};
8use crate::xml::parsers::parse_to_xml_object;
9use crate::xml::types::{MultiLevelRule, SidecarSpec, XmlElement};
10use crate::xml::utils::normalize_path_unix;
11use serde_json::Value;
12use std::collections::HashSet;
13use std::ffi::OsString;
14use std::future::Future;
15use std::path::{Path, PathBuf};
16use std::pin::Pin;
17use tokio::fs;
18use tokio::sync::Semaphore;
19use tokio::task::JoinSet;
20
21const SHARD_PARSE_CONCURRENCY: usize = 32;
28static SHARD_PARSE_PERMITS: Semaphore = Semaphore::const_new(SHARD_PARSE_CONCURRENCY);
29
30async fn is_regular_file(entry: &fs::DirEntry, path: &Path) -> bool {
35 match entry.file_type().await {
36 Ok(file_type) if !file_type.is_symlink() => file_type.is_file(),
37 _ => fs::metadata(path)
38 .await
39 .map(|meta| meta.is_file())
40 .unwrap_or(false),
41 }
42}
43
44async fn parse_shards_in_order<T: Send + 'static>(
53 files: Vec<String>,
54 convert: fn(XmlElement) -> T,
55) -> Result<Vec<Option<T>>, Box<dyn std::error::Error + Send + Sync>> {
56 if files.len() <= 1 {
58 let mut out = Vec::with_capacity(files.len());
59 for file in files {
60 out.push(parse_to_xml_object(&file).await.map(convert));
61 }
62 return Ok(out);
63 }
64
65 let mut results: Vec<Option<T>> = (0..files.len()).map(|_| None).collect();
66 let mut join_set = JoinSet::new();
67 for (index, file) in files.into_iter().enumerate() {
68 join_set.spawn(async move {
69 let _permit = SHARD_PARSE_PERMITS
70 .acquire()
71 .await
72 .expect("static semaphore is never closed");
73 (index, parse_to_xml_object(&file).await.map(convert))
74 });
75 }
76 while let Some(joined) = join_set.join_next().await {
77 let (index, parsed) =
78 joined.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
79 results[index] = parsed;
80 }
81 Ok(results)
82}
83
84fn keep_tree(element: XmlElement) -> XmlElement {
86 element
87}
88
89async fn read_key_order(path: &Path) -> Option<Vec<String>> {
91 let bytes = fs::read(path).await.ok()?;
92 serde_json::from_slice::<Vec<String>>(&bytes).ok()
93}
94
95async fn read_trailing_newline(path: &Path) -> Option<bool> {
99 let bytes = fs::read(path).await.ok()?;
100 String::from_utf8(bytes).ok()?.trim().parse::<bool>().ok()
101}
102
103fn strip_xmlns_from_value(v: Value) -> Value {
105 match v {
106 Value::Object(obj) => {
107 Value::Object(obj.into_iter().filter(|(k, _)| k != "@xmlns").collect())
108 }
109 other => other,
110 }
111}
112
113fn deeper_candidate_rules(
121 all_rules: &[MultiLevelRule],
122 exclude_path_segment: &str,
123) -> Vec<MultiLevelRule> {
124 all_rules
125 .iter()
126 .filter(|r| r.path_segment != exclude_path_segment)
127 .cloned()
128 .collect()
129}
130
131fn is_at_base_path(dir_path: &str, base_segments: &[(String, String, bool)]) -> bool {
138 base_segments.iter().any(|(base, _, _)| dir_path == base)
139}
140
141type ProcessDirFuture<'a, T> = Pin<
142 Box<dyn Future<Output = Result<Vec<T>, Box<dyn std::error::Error + Send + Sync>>> + Send + 'a>,
143>;
144
145type SegmentFuture<'a> =
146 Pin<Box<dyn Future<Output = Result<(), Box<dyn std::error::Error + Send + Sync>>> + Send + 'a>>;
147
148pub struct ReassembleXmlFileHandler;
149
150impl ReassembleXmlFileHandler {
151 pub fn new() -> Self {
152 Self
153 }
154
155 pub async fn reassemble(
156 &self,
157 file_path: &str,
158 file_extension: Option<&str>,
159 post_purge: bool,
160 sidecar_specs: Option<&[SidecarSpec]>,
161 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
162 let file_path = normalize_path_unix(file_path);
163 if !self.validate_directory(&file_path).await? {
164 return Ok(());
165 }
166
167 let path = Path::new(&file_path);
168 let config = load_multi_level_config(path).await;
169 if let Some(ref config) = config {
170 for (i, rule) in config.rules.iter().enumerate() {
175 let segment_path = path.join(&rule.path_segment);
176 if !segment_path.is_dir() {
177 continue;
178 }
179 let nested: Vec<MultiLevelRule> = config
180 .rules
181 .iter()
182 .enumerate()
183 .filter(|(j, _)| *j != i)
184 .map(|(_, r)| r.clone())
185 .collect();
186 self.reassemble_multi_level_segment(&segment_path, rule, &nested)
187 .await?;
188 }
189 }
190
191 let base_segments: Vec<(String, String, bool)> = config
194 .as_ref()
195 .map(|c| {
196 c.rules
197 .iter()
198 .map(|r| (file_path.clone(), r.path_segment.clone(), true))
199 .collect()
200 })
201 .unwrap_or_default();
202 let post_purge_final = post_purge || config.is_some();
204 self.reassemble_plain(
205 &file_path,
206 file_extension,
207 post_purge_final,
208 &base_segments,
209 sidecar_specs,
210 )
211 .await
212 }
213
214 fn reassemble_multi_level_segment<'a>(
239 &'a self,
240 segment_path: &'a Path,
241 rule: &'a MultiLevelRule,
242 nested_rules: &'a [MultiLevelRule],
243 ) -> SegmentFuture<'a> {
244 let segment_path = segment_path.to_path_buf();
245 let rule = rule.clone();
246 let nested_rules = nested_rules.to_vec();
247 Box::pin(async move {
248 self.reassemble_multi_level_segment_inner(&segment_path, &rule, &nested_rules)
249 .await
250 })
251 }
252
253 async fn reassemble_multi_level_segment_inner(
254 &self,
255 segment_path: &Path,
256 rule: &MultiLevelRule,
257 nested_rules: &[MultiLevelRule],
258 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
259 if !segment_path.is_dir() {
260 return Ok(());
261 }
262 let mut entries = Vec::new();
263 let mut read_dir = fs::read_dir(segment_path).await?;
264 while let Some(entry) = read_dir.next_entry().await? {
265 entries.push(entry);
266 }
267 entries.sort_by_cached_key(|e| e.file_name());
268 for entry in entries {
269 let process_path = entry.path();
270 if !process_path.is_dir() {
271 continue;
272 }
273 let process_path_str = normalize_path_unix(&process_path.to_string_lossy());
274 let mut sub_entries = Vec::new();
275 let mut sub_read = fs::read_dir(&process_path).await?;
276 while let Some(e) = sub_read.next_entry().await? {
277 sub_entries.push(e);
278 }
279 sub_entries.sort_by_cached_key(|e| e.file_name());
280
281 let mut handled: HashSet<OsString> = HashSet::new();
284 for sub_entry in &sub_entries {
285 let sub_path: PathBuf = sub_entry.path();
286 if !sub_path.is_dir() {
287 continue;
288 }
289 let sub_name = sub_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
290 let Some(nested_rule) = nested_rules.iter().find(|r| r.path_segment == sub_name)
291 else {
292 continue;
293 };
294 let deeper = deeper_candidate_rules(nested_rules, &nested_rule.path_segment);
298 self.reassemble_multi_level_segment(&sub_path, nested_rule, &deeper)
299 .await?;
300 handled.insert(sub_entry.file_name());
301 }
302
303 for sub_entry in &sub_entries {
306 let sub_path = sub_entry.path();
307 if !sub_path.is_dir() {
308 continue;
309 }
310 if handled.contains(&sub_entry.file_name()) {
311 continue;
312 }
313 let sub_path_str = normalize_path_unix(&sub_path.to_string_lossy());
314 self.reassemble_plain(&sub_path_str, Some("xml"), true, &[], None)
315 .await?;
316 }
317
318 self.reassemble_plain(&process_path_str, Some("xml"), true, &[], None)
320 .await?;
321 }
322 ensure_segment_files_structure(
323 segment_path,
324 &rule.wrap_root_element,
325 &rule.path_segment,
326 &rule.wrap_xmlns,
327 )
328 .await?;
329 Ok(())
330 }
331
332 async fn reassemble_plain(
340 &self,
341 file_path: &str,
342 file_extension: Option<&str>,
343 post_purge: bool,
344 base_segments: &[(String, String, bool)],
345 sidecar_specs: Option<&[SidecarSpec]>,
346 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
347 let file_path = normalize_path_unix(file_path);
348 log::debug!("Parsing directory to reassemble: {}", file_path);
349 let mut shards = self
353 .process_files_in_directory(
354 file_path.to_string(),
355 base_segments.to_vec(),
356 ShardFragments::from_element,
357 )
358 .await?;
359
360 if shards.is_empty() {
361 log_nothing_parsed(&file_path);
362 return Ok(());
363 }
364
365 let Some(mut merged) = merge_fragments(&mut shards) else {
366 log_no_root(&file_path);
367 return Ok(());
368 };
369
370 if merged.needs_tree_path() {
374 drop(merged);
375 drop(shards);
376 return self
377 .reassemble_plain_tree(
378 &file_path,
379 file_extension,
380 post_purge,
381 base_segments,
382 sidecar_specs,
383 )
384 .await;
385 }
386
387 let effective_specs = resolve_sidecar_specs(&file_path, sidecar_specs).await;
388 if let Some(specs) = effective_specs.as_deref() {
391 for (element, value) in load_sidecar_elements(&file_path, specs).await {
392 merged.insert(element, value);
393 }
394 }
395
396 let key_order_path = Path::new(&file_path).join(".key_order.json");
398 if let Some(order) = read_key_order(&key_order_path).await {
399 merged.reorder(&order);
400 }
401
402 let trailing_newline_path = Path::new(&file_path).join(".trailing_newline.json");
405 let trailing_newline = read_trailing_newline(&trailing_newline_path)
406 .await
407 .unwrap_or(false);
408
409 let output_path = self.get_output_path(&file_path, file_extension);
410 merged.write_file(&shards, Path::new(&output_path), trailing_newline)?;
412 drop(merged);
413 drop(shards);
414
415 if post_purge {
416 purge_disassembled(&file_path, effective_specs.as_deref()).await;
417 }
418
419 Ok(())
420 }
421
422 async fn reassemble_plain_tree(
426 &self,
427 file_path: &str,
428 file_extension: Option<&str>,
429 post_purge: bool,
430 base_segments: &[(String, String, bool)],
431 sidecar_specs: Option<&[SidecarSpec]>,
432 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
433 let parsed_objects = self
434 .process_files_in_directory(file_path.to_string(), base_segments.to_vec(), keep_tree)
435 .await?;
436
437 if parsed_objects.is_empty() {
438 log_nothing_parsed(file_path);
439 return Ok(());
440 }
441
442 let Some(mut merged) = merge_xml_elements_owned(parsed_objects) else {
447 log_no_root(file_path);
448 return Ok(());
449 };
450
451 let effective_specs = resolve_sidecar_specs(file_path, sidecar_specs).await;
452 if let Some(specs) = effective_specs.as_deref() {
453 inject_sidecar_elements(file_path, &mut merged, specs).await?;
454 }
455
456 let key_order_path = Path::new(file_path).join(".key_order.json");
457 if let Some(order) = read_key_order(&key_order_path).await {
458 merged = reorder_root_keys_owned(merged, &order);
459 }
460
461 let trailing_newline_path = Path::new(file_path).join(".trailing_newline.json");
462 let trailing_newline = read_trailing_newline(&trailing_newline_path)
463 .await
464 .unwrap_or(false);
465
466 let output_path = self.get_output_path(file_path, file_extension);
467
468 write_xml_file(&merged, Path::new(&output_path), trailing_newline)?;
476 drop(merged);
477
478 if post_purge {
479 purge_disassembled(file_path, effective_specs.as_deref()).await;
480 }
481
482 Ok(())
483 }
484
485 fn process_files_in_directory<'a, T: Send + 'static>(
488 &'a self,
489 dir_path: String,
490 base_segments: Vec<(String, String, bool)>,
491 convert: fn(XmlElement) -> T,
492 ) -> ProcessDirFuture<'a, T> {
493 Box::pin(async move {
494 let mut parsed = Vec::new();
495 let mut entries = Vec::new();
496 let mut read_dir = fs::read_dir(&dir_path).await?;
497 while let Some(entry) = read_dir.next_entry().await? {
498 entries.push(entry);
499 }
500 entries.sort_by_cached_key(|e| e.file_name().to_string_lossy().into_owned());
506
507 let is_base = is_at_base_path(&dir_path, &base_segments);
512
513 enum Slot {
517 Shard(usize),
518 Dir(PathBuf, String),
519 }
520 let mut slots = Vec::with_capacity(entries.len());
521 let mut shard_files = Vec::new();
522 for entry in entries {
523 let path = entry.path();
524 let file_path = normalize_path_unix(&path.to_string_lossy()).to_string();
525 if is_regular_file(&entry, &path).await {
526 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
527 if !name.starts_with('.') && self.is_parsable_file(name) {
528 slots.push(Slot::Shard(shard_files.len()));
529 shard_files.push(file_path);
530 }
531 } else {
532 slots.push(Slot::Dir(path, file_path));
533 }
534 }
535
536 let mut shard_results = parse_shards_in_order(shard_files, convert).await?;
541
542 for slot in slots {
544 let (path, file_path) = match slot {
545 Slot::Shard(index) => {
546 if let Some(parsed_obj) = shard_results[index].take() {
547 parsed.push(parsed_obj);
548 }
549 continue;
550 }
551 Slot::Dir(path, file_path) => (path, file_path),
552 };
553 let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
556 let matched_segment = if is_base {
557 base_segments
558 .iter()
559 .find(|(_, seg_name, _)| seg_name == dir_name)
560 .cloned()
561 } else {
562 None
563 };
564 if let Some((_, segment_name, extract_inner)) = matched_segment {
565 let segment_element = self
566 .collect_segment_as_array(&file_path, &segment_name, extract_inner)
567 .await?;
568 if let Some(el) = segment_element {
569 parsed.push(convert(el));
570 }
571 } else {
572 let sub_parsed = self
573 .process_files_in_directory(file_path, base_segments.clone(), convert)
574 .await?;
575 parsed.extend(sub_parsed);
576 }
577 }
578
579 Ok(parsed)
580 })
581 }
582
583 async fn collect_segment_as_array(
587 &self,
588 segment_dir: &str,
589 segment_name: &str,
590 extract_inner: bool,
591 ) -> Result<Option<XmlElement>, Box<dyn std::error::Error + Send + Sync>> {
592 let mut xml_files = Vec::new();
593 let mut read_dir = fs::read_dir(segment_dir).await?;
594 while let Some(entry) = read_dir.next_entry().await? {
595 let path = entry.path();
596 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
597 if !name.starts_with('.')
598 && self.is_parsable_file(name)
599 && is_regular_file(&entry, &path).await
600 {
601 xml_files.push(normalize_path_unix(&path.to_string_lossy()).to_string());
602 }
603 }
604 xml_files.sort();
605
606 let mut root_contents = Vec::new();
607 let mut first_xml: Option<(String, Option<Value>)> = None;
608 for parsed in parse_shards_in_order(xml_files, keep_tree).await? {
610 let Some(Value::Object(mut obj)) = parsed else {
613 continue;
614 };
615 let Some(root_key) = obj.keys().find(|k| *k != "?xml").cloned() else {
616 continue;
617 };
618 let root_val = obj
622 .get_mut(&root_key)
623 .map(std::mem::take)
624 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
625 let mut content = if extract_inner {
626 match root_val {
627 Value::Object(mut root) => root.get_mut(segment_name).map(std::mem::take),
628 _ => None,
629 }
630 .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
631 } else {
632 root_val
633 };
634 if extract_inner {
636 content = strip_xmlns_from_value(content);
637 }
638 root_contents.push(content);
639 if first_xml.is_none() {
640 first_xml = Some((root_key, obj.get_mut("?xml").map(std::mem::take)));
641 }
642 }
643 if root_contents.is_empty() {
644 return Ok(None);
645 }
646 let (root_key, decl_opt) = first_xml.unwrap();
647 let mut content = serde_json::Map::new();
648 content.insert(segment_name.to_string(), Value::Array(root_contents));
649 let mut top = serde_json::Map::new();
650 if let Some(decl) = decl_opt {
651 top.insert("?xml".to_string(), decl);
652 } else {
653 let mut d = serde_json::Map::new();
654 d.insert("@version".to_string(), Value::String("1.0".to_string()));
655 d.insert("@encoding".to_string(), Value::String("UTF-8".to_string()));
656 top.insert("?xml".to_string(), Value::Object(d));
657 }
658 top.insert(root_key, Value::Object(content));
659 Ok(Some(Value::Object(top)))
660 }
661
662 fn is_parsable_file(&self, file_name: &str) -> bool {
663 let lower = file_name.to_lowercase();
664 lower.ends_with(".xml")
665 || lower.ends_with(".json")
666 || lower.ends_with(".json5")
667 || lower.ends_with(".yaml")
668 || lower.ends_with(".yml")
669 }
670
671 async fn validate_directory(
672 &self,
673 path: &str,
674 ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
675 let meta = fs::metadata(path).await?;
676 if !meta.is_dir() {
677 log::error!(
678 "The provided path to reassemble is not a directory: {}",
679 path
680 );
681 return Ok(false);
682 }
683 Ok(true)
684 }
685
686 fn get_output_path(&self, dir_path: &str, extension: Option<&str>) -> String {
687 let path = Path::new(dir_path);
688 let parent = path.parent().unwrap_or(Path::new("."));
689 let base_name = path
690 .file_name()
691 .and_then(|n| n.to_str())
692 .unwrap_or("output");
693 let ext = extension.unwrap_or("xml");
694 parent
695 .join(format!("{}.{}", base_name, ext))
696 .to_string_lossy()
697 .to_string()
698 }
699}
700
701impl Default for ReassembleXmlFileHandler {
702 fn default() -> Self {
703 Self::new()
704 }
705}
706
707fn convert_to_format(content: &str, target_format: &str) -> String {
715 match target_format.to_ascii_lowercase().as_str() {
716 "json" => {
717 match serde_saphyr::from_str::<serde_json::Value>(content) {
718 Ok(val) => {
720 serde_json::to_string_pretty(&val).unwrap_or_else(|_| content.to_string())
721 }
722 Err(e) => {
723 log::warn!("sidecar reassemble: could not parse content for JSON conversion ({e}); using raw content");
724 content.to_string()
725 }
726 }
727 }
728 "yaml" | "yml" => {
729 if serde_json::from_str::<serde_json::Value>(content).is_ok() {
730 match serde_json::from_str::<serde_json::Value>(content)
731 .ok()
732 .and_then(|v| serde_saphyr::to_string(&v).ok())
733 {
734 Some(yaml) => yaml,
735 None => {
736 log::warn!(
737 "sidecar reassemble: YAML serialization failed; using raw content"
738 );
739 content.to_string()
740 }
741 }
742 } else {
743 content.to_string()
744 }
745 }
746 _ => content.to_string(),
747 }
748}
749
750fn log_nothing_parsed(file_path: &str) {
751 log::error!(
752 "No files under {} were parsed successfully. A reassembled XML file was not created.",
753 file_path
754 );
755}
756
757fn log_no_root(file_path: &str) {
758 log::error!(
759 "No usable root element found while merging files under {}. A reassembled XML file was not created.",
760 file_path
761 );
762}
763
764async fn resolve_sidecar_specs(
767 dir_path: &str,
768 sidecar_specs: Option<&[SidecarSpec]>,
769) -> Option<Vec<SidecarSpec>> {
770 if let Some(specs) = sidecar_specs.filter(|s| !s.is_empty()) {
771 return Some(specs.to_vec());
772 }
773 let meta_path = Path::new(dir_path).join(".sidecars.json");
774 let content = fs::read_to_string(&meta_path).await.ok()?;
775 serde_json::from_str::<Vec<SidecarSpec>>(&content).ok()
776}
777
778async fn purge_disassembled(dir_path: &str, specs: Option<&[SidecarSpec]>) {
782 if let Some(specs) = specs {
783 let path = Path::new(dir_path);
784 let base = path
785 .file_name()
786 .and_then(|n| n.to_str())
787 .unwrap_or("output");
788 for spec in specs {
789 let sidecar = path.join(format!("{}.{}", base, spec.extension));
790 fs::remove_file(&sidecar).await.ok();
791 }
792 }
793 fs::remove_dir_all(dir_path).await.ok();
794}
795
796async fn load_sidecar_elements(dir_path: &str, specs: &[SidecarSpec]) -> Vec<(String, Value)> {
807 let path = Path::new(dir_path);
808 let base = path
809 .file_name()
810 .and_then(|n| n.to_str())
811 .unwrap_or("output");
812 let mut elements = Vec::new();
813 for spec in specs {
814 let sidecar_path = path.join(format!("{}.{}", base, spec.extension));
815 let Ok(content) = fs::read_to_string(&sidecar_path).await else {
816 continue;
817 };
818 let final_content = match &spec.original_format {
822 Some(fmt) => convert_to_format(&content, fmt),
823 None => content,
824 };
825 elements.push((
826 spec.element.clone(),
827 serde_json::json!({ "#raw-text": final_content }),
828 ));
829 }
830 elements
831}
832
833async fn inject_sidecar_elements(
836 dir_path: &str,
837 merged: &mut XmlElement,
838 specs: &[SidecarSpec],
839) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
840 let root_key = merged
841 .as_object()
842 .and_then(|o| o.keys().find(|k| *k != "?xml").cloned());
843 let Some(root_key) = root_key else {
844 return Ok(());
845 };
846
847 if let Some(root_obj) = merged
848 .as_object_mut()
849 .and_then(|o| o.get_mut(&root_key))
850 .and_then(Value::as_object_mut)
851 {
852 for (element, value) in load_sidecar_elements(dir_path, specs).await {
853 root_obj.insert(element, value);
854 }
855 }
856
857 Ok(())
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use serde_json::json;
864
865 #[test]
866 #[allow(clippy::default_constructed_unit_structs)]
867 fn reassemble_handler_default_equals_new() {
868 let _ = ReassembleXmlFileHandler::default();
869 }
870
871 #[test]
872 fn strip_xmlns_from_value_passes_non_object_through() {
873 let s = Value::String("hello".to_string());
874 assert_eq!(
875 strip_xmlns_from_value(s),
876 Value::String("hello".to_string())
877 );
878 let arr = json!([1, 2]);
879 assert_eq!(strip_xmlns_from_value(arr.clone()), arr);
880 }
881
882 #[test]
883 fn strip_xmlns_from_value_removes_xmlns_key() {
884 let obj = json!({ "@xmlns": "ns", "child": 1 });
885 let stripped = strip_xmlns_from_value(obj);
886 let map = stripped.as_object().unwrap();
887 assert!(map.get("@xmlns").is_none());
888 assert_eq!(map.get("child").and_then(|v| v.as_i64()), Some(1));
889 }
890
891 #[test]
892 fn is_parsable_file_recognises_supported_extensions() {
893 let h = ReassembleXmlFileHandler::new();
894 assert!(h.is_parsable_file("a.xml"));
895 assert!(h.is_parsable_file("a.json"));
896 assert!(h.is_parsable_file("a.json5"));
897 assert!(h.is_parsable_file("a.yaml"));
898 assert!(h.is_parsable_file("a.yml"));
899 assert!(h.is_parsable_file("A.XML"));
900 assert!(!h.is_parsable_file("a.txt"));
901 }
902
903 #[test]
904 fn get_output_path_appends_extension_and_uses_parent_dir() {
905 let h = ReassembleXmlFileHandler::new();
906 let out = h.get_output_path("/tmp/foo", Some("xml"));
907 assert!(out.ends_with("foo.xml"));
908 let out_default = h.get_output_path("/tmp/bar", None);
909 assert!(out_default.ends_with("bar.xml"));
910 assert_eq!(h.get_output_path("only", Some("json")), "only.json");
912 }
913
914 #[tokio::test]
915 async fn reassemble_multi_level_segment_noop_when_not_dir() {
916 let h = ReassembleXmlFileHandler::new();
917 let tmp = tempfile::tempdir().unwrap();
918 let file = tmp.path().join("not_a_dir.txt");
919 tokio::fs::write(&file, "hi").await.unwrap();
920 let rule = crate::xml::types::MultiLevelRule {
921 file_pattern: String::new(),
922 root_to_strip: String::new(),
923 unique_id_elements: String::new(),
924 path_segment: String::new(),
925 wrap_root_element: "Root".to_string(),
926 wrap_xmlns: String::new(),
927 };
928 h.reassemble_multi_level_segment(&file, &rule, &[])
929 .await
930 .unwrap();
931 }
932
933 #[tokio::test]
934 async fn reassemble_multi_level_segment_skips_files_in_segment_root() {
935 let h = ReassembleXmlFileHandler::new();
936 let tmp = tempfile::tempdir().unwrap();
937 let segment = tmp.path().join("segment");
938 tokio::fs::create_dir(&segment).await.unwrap();
939 tokio::fs::write(segment.join("stray.txt"), "x")
941 .await
942 .unwrap();
943 let rule = crate::xml::types::MultiLevelRule {
944 file_pattern: String::new(),
945 root_to_strip: String::new(),
946 unique_id_elements: String::new(),
947 path_segment: "segment".to_string(),
948 wrap_root_element: "Root".to_string(),
949 wrap_xmlns: "http://example.com".to_string(),
950 };
951 h.reassemble_multi_level_segment(&segment, &rule, &[])
952 .await
953 .unwrap();
954 }
955
956 #[tokio::test]
957 async fn collect_segment_as_array_returns_none_for_empty_dir() {
958 let h = ReassembleXmlFileHandler::new();
959 let tmp = tempfile::tempdir().unwrap();
960 let out = h
961 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
962 .await
963 .unwrap();
964 assert!(out.is_none());
965 }
966
967 #[tokio::test]
968 async fn collect_segment_as_array_skips_unparseable_and_empty_roots() {
969 let h = ReassembleXmlFileHandler::new();
970 let tmp = tempfile::tempdir().unwrap();
971 tokio::fs::write(tmp.path().join("bad.xml"), "<<")
973 .await
974 .unwrap();
975 tokio::fs::write(tmp.path().join("only-decl.xml"), "")
977 .await
978 .unwrap();
979 tokio::fs::write(tmp.path().join(".hidden.xml"), "<r/>")
981 .await
982 .unwrap();
983 let out = h
984 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", false)
985 .await
986 .unwrap();
987 assert!(out.is_none());
988 }
989
990 #[tokio::test]
991 async fn collect_segment_as_array_without_xml_decl_inserts_default_decl() {
992 let h = ReassembleXmlFileHandler::new();
996 let tmp = tempfile::tempdir().unwrap();
997 tokio::fs::write(
998 tmp.path().join("a.xml"),
999 r#"<Root><seg><x>1</x></seg></Root>"#,
1000 )
1001 .await
1002 .unwrap();
1003 let out = h
1004 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
1005 .await
1006 .unwrap()
1007 .unwrap();
1008 let obj = out.as_object().unwrap();
1009 let decl = obj
1010 .get("?xml")
1011 .and_then(|v| v.as_object())
1012 .expect("default declaration must be inserted when XML has none");
1013 assert_eq!(decl.get("@version").and_then(|v| v.as_str()), Some("1.0"));
1014 assert_eq!(
1015 decl.get("@encoding").and_then(|v| v.as_str()),
1016 Some("UTF-8")
1017 );
1018 }
1019
1020 #[tokio::test]
1021 async fn collect_segment_as_array_with_xml_decl_preserves_it() {
1022 let h = ReassembleXmlFileHandler::new();
1025 let tmp = tempfile::tempdir().unwrap();
1026 tokio::fs::write(
1027 tmp.path().join("a.xml"),
1028 r#"<?xml version="1.0" encoding="UTF-8"?><Root><seg><x>1</x></seg></Root>"#,
1029 )
1030 .await
1031 .unwrap();
1032 let out = h
1033 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
1034 .await
1035 .unwrap()
1036 .unwrap();
1037 let obj = out.as_object().unwrap();
1038 let decl = obj
1039 .get("?xml")
1040 .and_then(|v| v.as_object())
1041 .expect("?xml declaration must be preserved from source");
1042 assert_eq!(decl.get("@version").and_then(|v| v.as_str()), Some("1.0"));
1043 assert_eq!(
1044 decl.get("@encoding").and_then(|v| v.as_str()),
1045 Some("UTF-8")
1046 );
1047 }
1048
1049 #[tokio::test]
1050 async fn collect_segment_as_array_extract_inner_moves_segment_content() {
1051 let h = ReassembleXmlFileHandler::new();
1054 let tmp = tempfile::tempdir().unwrap();
1055 tokio::fs::write(
1056 tmp.path().join("a.xml"),
1057 r#"<?xml version="1.0" encoding="UTF-8"?><Root><seg xmlns="ns"><x>1</x></seg><other>2</other></Root>"#,
1058 )
1059 .await
1060 .unwrap();
1061 tokio::fs::write(tmp.path().join("b.xml"), r#"<Root>text</Root>"#)
1062 .await
1063 .unwrap();
1064 let out = h
1065 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
1066 .await
1067 .unwrap()
1068 .unwrap();
1069 let items = out["Root"]["seg"].as_array().unwrap();
1070 assert_eq!(items.len(), 2);
1071 assert_eq!(items[0]["x"]["#text"], "1");
1072 assert!(items[0].get("@xmlns").is_none(), "xmlns must be stripped");
1073 assert!(items[0].get("other").is_none(), "only the segment is taken");
1074 assert_eq!(items[1], json!({}));
1075 assert_eq!(out["?xml"]["@version"], "1.0");
1076 }
1077
1078 #[tokio::test]
1079 async fn collect_segment_as_array_without_extract_inner_wraps_root() {
1080 let h = ReassembleXmlFileHandler::new();
1081 let tmp = tempfile::tempdir().unwrap();
1082 tokio::fs::write(tmp.path().join("a.xml"), r#"<Root><child>1</child></Root>"#)
1083 .await
1084 .unwrap();
1085 let out = h
1086 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", false)
1087 .await
1088 .unwrap()
1089 .unwrap();
1090 let obj = out.as_object().unwrap();
1091 assert!(obj.contains_key("?xml"));
1092 let root = obj.get("Root").and_then(|r| r.as_object()).unwrap();
1093 assert!(root.get("seg").and_then(|v| v.as_array()).is_some());
1094 }
1095
1096 fn rule_with_segment(segment: &str) -> MultiLevelRule {
1097 MultiLevelRule {
1098 file_pattern: String::new(),
1099 root_to_strip: String::new(),
1100 unique_id_elements: String::new(),
1101 path_segment: segment.to_string(),
1102 wrap_root_element: String::new(),
1103 wrap_xmlns: String::new(),
1104 }
1105 }
1106
1107 #[test]
1108 fn deeper_candidate_rules_excludes_the_matched_segment() {
1109 let rules = vec![rule_with_segment("seg_a"), rule_with_segment("seg_b")];
1113 let deeper = deeper_candidate_rules(&rules, "seg_a");
1114 assert_eq!(deeper.len(), 1);
1115 assert_eq!(deeper[0].path_segment, "seg_b");
1116 }
1117
1118 #[test]
1119 fn deeper_candidate_rules_keeps_all_when_no_segment_matches() {
1120 let rules = vec![rule_with_segment("seg_a"), rule_with_segment("seg_b")];
1124 let deeper = deeper_candidate_rules(&rules, "missing");
1125 assert_eq!(deeper.len(), 2);
1126 }
1127
1128 #[test]
1129 fn deeper_candidate_rules_returns_empty_for_empty_input() {
1130 let deeper: Vec<MultiLevelRule> = deeper_candidate_rules(&[], "anything");
1131 assert!(deeper.is_empty());
1132 }
1133
1134 #[test]
1135 fn is_at_base_path_true_when_dir_matches_any_segment() {
1136 let segs = vec![
1137 ("/base/other".to_string(), "seg1".to_string(), false),
1138 ("/base/here".to_string(), "seg2".to_string(), false),
1139 ];
1140 assert!(is_at_base_path("/base/here", &segs));
1141 }
1142
1143 #[test]
1144 fn is_at_base_path_false_when_dir_matches_nothing() {
1145 let segs = vec![("/base/a".to_string(), "seg".to_string(), false)];
1146 assert!(!is_at_base_path("/base/b", &segs));
1147 }
1148
1149 #[test]
1150 fn is_at_base_path_false_for_empty_segments() {
1151 let segs: Vec<(String, String, bool)> = Vec::new();
1152 assert!(!is_at_base_path("/anywhere", &segs));
1153 }
1154
1155 async fn reassemble_both_ways(files: &[(&str, &str)]) -> (String, String) {
1158 let mut outputs = Vec::new();
1159 for tree in [false, true] {
1160 let dir = tempfile::tempdir().unwrap();
1161 let doc = dir.path().join("Doc");
1162 std::fs::create_dir(&doc).unwrap();
1163 for (name, content) in files {
1164 std::fs::write(doc.join(name), content).unwrap();
1165 }
1166 let doc = normalize_path_unix(&doc.to_string_lossy());
1167 let handler = ReassembleXmlFileHandler::new();
1168 if tree {
1169 handler
1170 .reassemble_plain_tree(&doc, Some("xml"), false, &[], None)
1171 .await
1172 .unwrap();
1173 } else {
1174 handler
1175 .reassemble(&doc, Some("xml"), false, None)
1176 .await
1177 .unwrap();
1178 }
1179 outputs.push(std::fs::read_to_string(dir.path().join("Doc.xml")).unwrap());
1180 }
1181 let tree = outputs.pop().unwrap();
1182 (outputs.pop().unwrap(), tree)
1183 }
1184
1185 #[tokio::test]
1186 async fn compact_root_falls_back_to_tree_path() {
1187 let shard = r#"<?xml version="1.0" encoding="UTF-8"?><Root><only><p>1</p></only></Root>"#;
1190 let (fragments, tree) = reassemble_both_ways(&[("a.xml", shard)]).await;
1191 assert_eq!(fragments, tree);
1192 assert!(fragments.contains("<Root><only>"), "{fragments}");
1193 }
1194
1195 #[tokio::test]
1196 async fn fragment_path_matches_tree_path_with_sidecar_key_order_and_newline() {
1197 let files = [
1198 (
1199 "a.xml",
1200 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Root xmlns=\"ns\">\n <label>A</label>\n <item>\n <name>x</name>\n </item>\n</Root>",
1201 ),
1202 (
1203 "b.xml",
1204 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Root xmlns=\"ns\">\n <item>\n <name>y & z</name>\n </item>\n <flag>true</flag>\n</Root>",
1205 ),
1206 (
1207 ".key_order.json",
1208 r#"["flag", "notes", "item", "label"]"#,
1209 ),
1210 (".trailing_newline.json", "true"),
1211 (
1212 ".sidecars.json",
1213 r#"[{"element": "notes", "extension": "yaml"}]"#,
1214 ),
1215 ("Doc.yaml", "key: \"value\" & more\n"),
1216 ];
1217 let (fragments, tree) = reassemble_both_ways(&files).await;
1218 assert_eq!(fragments, tree);
1219 assert!(fragments.ends_with("</Root>\n"), "{fragments}");
1220 let (flag, notes, item, label) = (
1221 fragments.find("<flag>"),
1222 fragments.find("<notes>"),
1223 fragments.find("<item>"),
1224 fragments.find("<label>"),
1225 );
1226 assert!(flag < notes && notes < item && item < label, "{fragments}");
1227 }
1228
1229 #[tokio::test]
1230 async fn parse_shards_in_order_keeps_input_order_and_marks_failures() {
1231 let tmp = tempfile::tempdir().unwrap();
1232 let mut files = Vec::new();
1233 for i in 0..100 {
1234 let path = tmp.path().join(format!("{i:03}.xml"));
1236 if i % 10 != 0 {
1237 tokio::fs::write(&path, format!("<Root><n>{i}</n></Root>"))
1238 .await
1239 .unwrap();
1240 }
1241 files.push(path.to_string_lossy().to_string());
1242 }
1243
1244 let results = parse_shards_in_order(files, keep_tree).await.unwrap();
1245
1246 assert_eq!(results.len(), 100);
1247 for (i, result) in results.iter().enumerate() {
1248 if i % 10 == 0 {
1249 assert!(result.is_none(), "missing shard {i} should be None");
1250 } else {
1251 let n = result.as_ref().unwrap()["Root"]["n"]["#text"]
1252 .as_str()
1253 .unwrap();
1254 assert_eq!(n, i.to_string(), "shard {i} out of order");
1255 }
1256 }
1257 }
1258
1259 #[cfg(unix)]
1264 #[tokio::test]
1265 async fn reassemble_follows_symlinked_shards_and_directories() {
1266 use std::os::unix::fs::symlink;
1267
1268 let tmp = tempfile::tempdir().unwrap();
1269 let outside = tmp.path().join("outside");
1270 tokio::fs::create_dir_all(outside.join("nested"))
1271 .await
1272 .unwrap();
1273 tokio::fs::write(outside.join("b.xml"), "<Root><n>b</n></Root>")
1274 .await
1275 .unwrap();
1276 tokio::fs::write(
1277 outside.join("nested").join("c.xml"),
1278 "<Root><n>c</n></Root>",
1279 )
1280 .await
1281 .unwrap();
1282
1283 let dir = tmp.path().join("Bench");
1284 tokio::fs::create_dir(&dir).await.unwrap();
1285 tokio::fs::write(dir.join("a.xml"), "<Root><n>a</n></Root>")
1286 .await
1287 .unwrap();
1288 symlink(outside.join("b.xml"), dir.join("b.xml")).unwrap();
1289 symlink(outside.join("nested"), dir.join("c_dir")).unwrap();
1290
1291 ReassembleXmlFileHandler::new()
1292 .reassemble(dir.to_str().unwrap(), Some("xml"), false, None)
1293 .await
1294 .unwrap();
1295
1296 let output = tokio::fs::read_to_string(tmp.path().join("Bench.xml"))
1297 .await
1298 .unwrap();
1299 let a = output.find("<n>a</n>").expect("regular shard a");
1300 let b = output.find("<n>b</n>").expect("symlinked shard b");
1301 let c = output
1302 .find("<n>c</n>")
1303 .expect("shard c under symlinked dir");
1304 assert!(a < b && b < c, "sorted order a, b, c_dir/: {output}");
1305 }
1306
1307 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1308 async fn reassemble_many_shards_preserves_sorted_order() {
1309 let tmp = tempfile::tempdir().unwrap();
1313 let dir = tmp.path().join("Bench");
1314 tokio::fs::create_dir(&dir).await.unwrap();
1315 for i in 0..200 {
1316 let padding = "x".repeat((i * 7919) % 4096);
1318 tokio::fs::write(
1319 dir.join(format!("{i:04}.field-meta.xml")),
1320 format!(
1321 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Root><fields><n>{i}</n><pad>{padding}</pad></fields></Root>"
1322 ),
1323 )
1324 .await
1325 .unwrap();
1326 }
1327
1328 ReassembleXmlFileHandler::new()
1329 .reassemble(dir.to_str().unwrap(), Some("xml"), false, None)
1330 .await
1331 .unwrap();
1332
1333 let output = tokio::fs::read_to_string(tmp.path().join("Bench.xml"))
1334 .await
1335 .unwrap();
1336 let order: Vec<usize> = output
1337 .match_indices("<n>")
1338 .map(|(at, _)| {
1339 let rest = &output[at + 3..];
1340 rest[..rest.find('<').unwrap()].parse().unwrap()
1341 })
1342 .collect();
1343 assert_eq!(order, (0..200).collect::<Vec<_>>());
1344 }
1345
1346 #[tokio::test]
1351 async fn reassemble_plain_some_empty_sidecar_specs_falls_through_to_auto_detect() {
1352 let h = ReassembleXmlFileHandler::new();
1353 let tmp = tempfile::tempdir().unwrap();
1354 let dir = tmp.path().join("mydir");
1355 tokio::fs::create_dir(&dir).await.unwrap();
1356
1357 tokio::fs::write(
1358 dir.join("a.xml"),
1359 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1360 )
1361 .await
1362 .unwrap();
1363
1364 tokio::fs::write(
1365 dir.join(".sidecars.json"),
1366 r#"[{"element":"Notes","extension":"yaml"}]"#,
1367 )
1368 .await
1369 .unwrap();
1370
1371 tokio::fs::write(dir.join("mydir.yaml"), "key: value")
1373 .await
1374 .unwrap();
1375
1376 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], Some(&[]))
1377 .await
1378 .unwrap();
1379
1380 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1381 .await
1382 .unwrap();
1383 assert!(
1384 output.contains("key: value"),
1385 "sidecar content missing — auto-detect did not run:\n{output}"
1386 );
1387 }
1388
1389 #[test]
1392 fn convert_to_format_yaml_to_json() {
1393 let yaml = "openapi: 3.0.1\ninfo:\n title: \"Test API\"\n version: 1.0.0\n";
1394 let out = convert_to_format(yaml, "json");
1395 let val: serde_json::Value = serde_json::from_str(&out).expect("output must be valid JSON");
1396 assert_eq!(val["openapi"], "3.0.1");
1397 assert_eq!(val["info"]["title"], "Test API");
1398 assert_eq!(val["info"]["version"], "1.0.0");
1399 }
1400
1401 #[test]
1404 fn convert_to_format_json_to_yaml() {
1405 let json = r#"{"key":"value","num":42}"#;
1406 let out = convert_to_format(json, "yaml");
1407 assert!(
1408 serde_json::from_str::<serde_json::Value>(&out).is_err(),
1409 "output must be YAML format, not raw JSON: {out}"
1410 );
1411 let val: serde_json::Value =
1412 serde_saphyr::from_str(&out).expect("output must be valid YAML");
1413 assert_eq!(val["key"], "value");
1414 assert_eq!(val["num"], 42);
1415 }
1416
1417 #[test]
1418 fn convert_to_format_yml_extension_same_as_yaml() {
1419 let json = r#"{"x":true}"#;
1420 let out = convert_to_format(json, "yml");
1421 let val: serde_json::Value = serde_saphyr::from_str(&out).unwrap();
1422 assert_eq!(val["x"], true);
1423 }
1424
1425 #[test]
1426 fn convert_to_format_yaml_passes_through_unchanged() {
1427 let yaml = "title: \"@AuraEnabled\"\nversion: 1.0.0\n";
1429 assert_eq!(convert_to_format(yaml, "yaml"), yaml);
1430 }
1431
1432 #[test]
1433 fn convert_to_format_unknown_extension_passes_through() {
1434 let raw = "arbitrary content";
1435 assert_eq!(convert_to_format(raw, "txt"), raw);
1436 assert_eq!(convert_to_format(raw, ""), raw);
1437 }
1438
1439 #[test]
1440 fn convert_to_format_malformed_falls_back_to_raw() {
1441 let bad = "{{{{ not valid json or yaml at all >>>>>";
1442 assert_eq!(convert_to_format(bad, "json"), bad);
1443 }
1444
1445 #[test]
1446 fn convert_to_format_non_string_key_falls_back_to_raw() {
1447 let yaml_with_sequence_key = "? [a, b]\n: value\n";
1451 assert_eq!(
1452 convert_to_format(yaml_with_sequence_key, "json"),
1453 yaml_with_sequence_key
1454 );
1455 }
1456
1457 #[tokio::test]
1458 async fn reassemble_plain_uses_caller_supplied_sidecar_specs_directly() {
1459 let h = ReassembleXmlFileHandler::new();
1462 let tmp = tempfile::tempdir().unwrap();
1463 let dir = tmp.path().join("mydir");
1464 tokio::fs::create_dir(&dir).await.unwrap();
1465
1466 tokio::fs::write(
1467 dir.join("a.xml"),
1468 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1469 )
1470 .await
1471 .unwrap();
1472 tokio::fs::write(dir.join("mydir.yaml"), "key: value")
1474 .await
1475 .unwrap();
1476
1477 let specs = [SidecarSpec {
1478 element: "Notes".to_string(),
1479 extension: "yaml".to_string(),
1480 original_format: None,
1481 }];
1482 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], Some(&specs))
1483 .await
1484 .unwrap();
1485
1486 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1487 .await
1488 .unwrap();
1489 assert!(
1490 output.contains("key: value"),
1491 "caller-supplied sidecar spec was not used:\n{output}"
1492 );
1493 }
1494
1495 #[tokio::test]
1496 async fn reassemble_plain_ignores_malformed_sidecars_json() {
1497 let h = ReassembleXmlFileHandler::new();
1500 let tmp = tempfile::tempdir().unwrap();
1501 let dir = tmp.path().join("mydir");
1502 tokio::fs::create_dir(&dir).await.unwrap();
1503
1504 tokio::fs::write(
1505 dir.join("a.xml"),
1506 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1507 )
1508 .await
1509 .unwrap();
1510 tokio::fs::write(dir.join(".sidecars.json"), "not valid json")
1511 .await
1512 .unwrap();
1513
1514 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], None)
1515 .await
1516 .unwrap();
1517
1518 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1519 .await
1520 .unwrap();
1521 assert!(output.contains("Child"), "reassembly must still succeed");
1522 }
1523
1524 #[tokio::test]
1525 async fn reassemble_plain_post_purge_removes_sidecar_files() {
1526 let h = ReassembleXmlFileHandler::new();
1529 let tmp = tempfile::tempdir().unwrap();
1530 let dir = tmp.path().join("mydir");
1531 tokio::fs::create_dir(&dir).await.unwrap();
1532
1533 tokio::fs::write(
1534 dir.join("a.xml"),
1535 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1536 )
1537 .await
1538 .unwrap();
1539 let sidecar_path = dir.join("mydir.yaml");
1540 tokio::fs::write(&sidecar_path, "key: value").await.unwrap();
1541
1542 let specs = [SidecarSpec {
1543 element: "Notes".to_string(),
1544 extension: "yaml".to_string(),
1545 original_format: None,
1546 }];
1547 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), true, &[], Some(&specs))
1548 .await
1549 .unwrap();
1550
1551 assert!(!dir.exists(), "post_purge must remove the disassembled dir");
1552 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1553 .await
1554 .unwrap();
1555 assert!(output.contains("key: value"));
1556 }
1557
1558 #[tokio::test]
1559 async fn inject_sidecar_elements_ok_when_no_root_key() {
1560 let mut merged: XmlElement = json!({ "?xml": { "@version": "1.0" } });
1563 let specs = [SidecarSpec {
1564 element: "Notes".to_string(),
1565 extension: "yaml".to_string(),
1566 original_format: None,
1567 }];
1568 inject_sidecar_elements("does-not-matter", &mut merged, &specs)
1569 .await
1570 .unwrap();
1571 assert_eq!(merged, json!({ "?xml": { "@version": "1.0" } }));
1572 }
1573
1574 #[tokio::test]
1575 async fn inject_sidecar_elements_skips_missing_sidecar_file() {
1576 let tmp = tempfile::tempdir().unwrap();
1579 let dir = tmp.path().join("mydir");
1580 tokio::fs::create_dir(&dir).await.unwrap();
1581 let mut merged: XmlElement = json!({ "Root": { "Child": { "#text": "hello" } } });
1582 let specs = [SidecarSpec {
1583 element: "Notes".to_string(),
1584 extension: "yaml".to_string(),
1585 original_format: None,
1586 }];
1587 inject_sidecar_elements(dir.to_str().unwrap(), &mut merged, &specs)
1588 .await
1589 .unwrap();
1590 assert_eq!(merged, json!({ "Root": { "Child": { "#text": "hello" } } }));
1591 }
1592}