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