1use crate::xml::builders::{build_xml_string, merge_xml_elements, reorder_root_keys};
4use crate::xml::multi_level::{ensure_segment_files_structure, load_multi_level_config};
5use crate::xml::parsers::parse_to_xml_object;
6use crate::xml::types::{MultiLevelRule, SidecarSpec, XmlElement};
7use crate::xml::utils::normalize_path_unix;
8use serde_json::Value;
9use std::collections::HashSet;
10use std::ffi::OsString;
11use std::future::Future;
12use std::path::{Path, PathBuf};
13use std::pin::Pin;
14use tokio::fs;
15use tokio::sync::Semaphore;
16use tokio::task::JoinSet;
17
18const SHARD_PARSE_CONCURRENCY: usize = 32;
25static SHARD_PARSE_PERMITS: Semaphore = Semaphore::const_new(SHARD_PARSE_CONCURRENCY);
26
27async fn is_regular_file(entry: &fs::DirEntry, path: &Path) -> bool {
32 match entry.file_type().await {
33 Ok(file_type) if !file_type.is_symlink() => file_type.is_file(),
34 _ => fs::metadata(path)
35 .await
36 .map(|meta| meta.is_file())
37 .unwrap_or(false),
38 }
39}
40
41async fn parse_shards_in_order(
46 files: Vec<String>,
47) -> Result<Vec<Option<XmlElement>>, Box<dyn std::error::Error + Send + Sync>> {
48 if files.len() <= 1 {
50 let mut out = Vec::with_capacity(files.len());
51 for file in files {
52 out.push(parse_to_xml_object(&file).await);
53 }
54 return Ok(out);
55 }
56
57 let mut results: Vec<Option<XmlElement>> = vec![None; files.len()];
58 let mut join_set = JoinSet::new();
59 for (index, file) in files.into_iter().enumerate() {
60 join_set.spawn(async move {
61 let _permit = SHARD_PARSE_PERMITS
62 .acquire()
63 .await
64 .expect("static semaphore is never closed");
65 (index, parse_to_xml_object(&file).await)
66 });
67 }
68 while let Some(joined) = join_set.join_next().await {
69 let (index, parsed) =
70 joined.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
71 results[index] = parsed;
72 }
73 Ok(results)
74}
75
76async fn read_key_order(path: &Path) -> Option<Vec<String>> {
78 let bytes = fs::read(path).await.ok()?;
79 serde_json::from_slice::<Vec<String>>(&bytes).ok()
80}
81
82async fn read_trailing_newline(path: &Path) -> Option<bool> {
86 let bytes = fs::read(path).await.ok()?;
87 String::from_utf8(bytes).ok()?.trim().parse::<bool>().ok()
88}
89
90fn strip_xmlns_from_value(v: Value) -> Value {
92 match v {
93 Value::Object(obj) => {
94 Value::Object(obj.into_iter().filter(|(k, _)| k != "@xmlns").collect())
95 }
96 other => other,
97 }
98}
99
100fn deeper_candidate_rules(
108 all_rules: &[MultiLevelRule],
109 exclude_path_segment: &str,
110) -> Vec<MultiLevelRule> {
111 all_rules
112 .iter()
113 .filter(|r| r.path_segment != exclude_path_segment)
114 .cloned()
115 .collect()
116}
117
118fn is_at_base_path(dir_path: &str, base_segments: &[(String, String, bool)]) -> bool {
125 base_segments.iter().any(|(base, _, _)| dir_path == base)
126}
127
128type ProcessDirFuture<'a> = Pin<
129 Box<
130 dyn Future<Output = Result<Vec<XmlElement>, Box<dyn std::error::Error + Send + Sync>>>
131 + Send
132 + 'a,
133 >,
134>;
135
136type SegmentFuture<'a> =
137 Pin<Box<dyn Future<Output = Result<(), Box<dyn std::error::Error + Send + Sync>>> + Send + 'a>>;
138
139pub struct ReassembleXmlFileHandler;
140
141impl ReassembleXmlFileHandler {
142 pub fn new() -> Self {
143 Self
144 }
145
146 pub async fn reassemble(
147 &self,
148 file_path: &str,
149 file_extension: Option<&str>,
150 post_purge: bool,
151 sidecar_specs: Option<&[SidecarSpec]>,
152 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
153 let file_path = normalize_path_unix(file_path);
154 if !self.validate_directory(&file_path).await? {
155 return Ok(());
156 }
157
158 let path = Path::new(&file_path);
159 let config = load_multi_level_config(path).await;
160 if let Some(ref config) = config {
161 for (i, rule) in config.rules.iter().enumerate() {
166 let segment_path = path.join(&rule.path_segment);
167 if !segment_path.is_dir() {
168 continue;
169 }
170 let nested: Vec<MultiLevelRule> = config
171 .rules
172 .iter()
173 .enumerate()
174 .filter(|(j, _)| *j != i)
175 .map(|(_, r)| r.clone())
176 .collect();
177 self.reassemble_multi_level_segment(&segment_path, rule, &nested)
178 .await?;
179 }
180 }
181
182 let base_segments: Vec<(String, String, bool)> = config
185 .as_ref()
186 .map(|c| {
187 c.rules
188 .iter()
189 .map(|r| (file_path.clone(), r.path_segment.clone(), true))
190 .collect()
191 })
192 .unwrap_or_default();
193 let post_purge_final = post_purge || config.is_some();
195 self.reassemble_plain(
196 &file_path,
197 file_extension,
198 post_purge_final,
199 &base_segments,
200 sidecar_specs,
201 )
202 .await
203 }
204
205 fn reassemble_multi_level_segment<'a>(
230 &'a self,
231 segment_path: &'a Path,
232 rule: &'a MultiLevelRule,
233 nested_rules: &'a [MultiLevelRule],
234 ) -> SegmentFuture<'a> {
235 let segment_path = segment_path.to_path_buf();
236 let rule = rule.clone();
237 let nested_rules = nested_rules.to_vec();
238 Box::pin(async move {
239 self.reassemble_multi_level_segment_inner(&segment_path, &rule, &nested_rules)
240 .await
241 })
242 }
243
244 async fn reassemble_multi_level_segment_inner(
245 &self,
246 segment_path: &Path,
247 rule: &MultiLevelRule,
248 nested_rules: &[MultiLevelRule],
249 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
250 if !segment_path.is_dir() {
251 return Ok(());
252 }
253 let mut entries = Vec::new();
254 let mut read_dir = fs::read_dir(segment_path).await?;
255 while let Some(entry) = read_dir.next_entry().await? {
256 entries.push(entry);
257 }
258 entries.sort_by_key(|e| e.file_name());
259 for entry in entries {
260 let process_path = entry.path();
261 if !process_path.is_dir() {
262 continue;
263 }
264 let process_path_str = normalize_path_unix(&process_path.to_string_lossy());
265 let mut sub_entries = Vec::new();
266 let mut sub_read = fs::read_dir(&process_path).await?;
267 while let Some(e) = sub_read.next_entry().await? {
268 sub_entries.push(e);
269 }
270 sub_entries.sort_by_key(|e| e.file_name());
271
272 let mut handled: HashSet<OsString> = HashSet::new();
275 for sub_entry in &sub_entries {
276 let sub_path: PathBuf = sub_entry.path();
277 if !sub_path.is_dir() {
278 continue;
279 }
280 let sub_name = sub_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
281 let Some(nested_rule) = nested_rules.iter().find(|r| r.path_segment == sub_name)
282 else {
283 continue;
284 };
285 let deeper = deeper_candidate_rules(nested_rules, &nested_rule.path_segment);
289 self.reassemble_multi_level_segment(&sub_path, nested_rule, &deeper)
290 .await?;
291 handled.insert(sub_entry.file_name());
292 }
293
294 for sub_entry in &sub_entries {
297 let sub_path = sub_entry.path();
298 if !sub_path.is_dir() {
299 continue;
300 }
301 if handled.contains(&sub_entry.file_name()) {
302 continue;
303 }
304 let sub_path_str = normalize_path_unix(&sub_path.to_string_lossy());
305 self.reassemble_plain(&sub_path_str, Some("xml"), true, &[], None)
306 .await?;
307 }
308
309 self.reassemble_plain(&process_path_str, Some("xml"), true, &[], None)
311 .await?;
312 }
313 ensure_segment_files_structure(
314 segment_path,
315 &rule.wrap_root_element,
316 &rule.path_segment,
317 &rule.wrap_xmlns,
318 )
319 .await?;
320 Ok(())
321 }
322
323 async fn reassemble_plain(
331 &self,
332 file_path: &str,
333 file_extension: Option<&str>,
334 post_purge: bool,
335 base_segments: &[(String, String, bool)],
336 sidecar_specs: Option<&[SidecarSpec]>,
337 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
338 let file_path = normalize_path_unix(file_path);
339 log::debug!("Parsing directory to reassemble: {}", file_path);
340 let parsed_objects = self
341 .process_files_in_directory(file_path.to_string(), base_segments.to_vec())
342 .await?;
343
344 if parsed_objects.is_empty() {
345 log::error!(
346 "No files under {} were parsed successfully. A reassembled XML file was not created.",
347 file_path
348 );
349 return Ok(());
350 }
351
352 let Some(mut merged) = merge_xml_elements(&parsed_objects) else {
356 log::error!(
357 "No usable root element found while merging files under {}. A reassembled XML file was not created.",
358 file_path
359 );
360 return Ok(());
361 };
362
363 let auto_specs: Vec<crate::xml::types::SidecarSpec>;
366 let effective_specs: Option<&[crate::xml::types::SidecarSpec]> =
367 if sidecar_specs.is_some_and(|s| !s.is_empty()) {
368 sidecar_specs
369 } else {
370 let meta_path = Path::new(&file_path).join(".sidecars.json");
371 if let Ok(content) = fs::read_to_string(&meta_path).await {
372 if let Ok(parsed) =
373 serde_json::from_str::<Vec<crate::xml::types::SidecarSpec>>(&content)
374 {
375 auto_specs = parsed;
376 Some(auto_specs.as_slice())
377 } else {
378 None
379 }
380 } else {
381 None
382 }
383 };
384
385 if let Some(specs) = effective_specs {
388 inject_sidecar_elements(&file_path, &mut merged, specs).await?;
389 }
390
391 let key_order_path = Path::new(&file_path).join(".key_order.json");
393 if let Some(reordered) = read_key_order(&key_order_path)
394 .await
395 .and_then(|order| reorder_root_keys(&merged, &order))
396 {
397 merged = reordered;
398 }
399
400 let mut final_xml = build_xml_string(&merged);
401
402 let trailing_newline_path = Path::new(&file_path).join(".trailing_newline.json");
405 if read_trailing_newline(&trailing_newline_path)
406 .await
407 .unwrap_or(false)
408 {
409 final_xml.push('\n');
410 }
411
412 let output_path = self.get_output_path(&file_path, file_extension);
413
414 fs::write(&output_path, &final_xml).await?;
415
416 if post_purge {
420 if let Some(specs) = effective_specs {
421 let path = Path::new(&file_path);
422 let base = path
423 .file_name()
424 .and_then(|n| n.to_str())
425 .unwrap_or("output");
426 for spec in specs {
427 let sidecar = path.join(format!("{}.{}", base, spec.extension));
428 fs::remove_file(&sidecar).await.ok();
429 }
430 }
431 fs::remove_dir_all(file_path).await.ok();
432 }
433
434 Ok(())
435 }
436
437 fn process_files_in_directory<'a>(
438 &'a self,
439 dir_path: String,
440 base_segments: Vec<(String, String, bool)>,
441 ) -> ProcessDirFuture<'a> {
442 Box::pin(async move {
443 let mut parsed = Vec::new();
444 let mut entries = Vec::new();
445 let mut read_dir = fs::read_dir(&dir_path).await?;
446 while let Some(entry) = read_dir.next_entry().await? {
447 entries.push(entry);
448 }
449 entries.sort_by(|a, b| {
451 let a_name = a.file_name().to_string_lossy().to_string();
452 let b_name = b.file_name().to_string_lossy().to_string();
453 a_name.cmp(&b_name)
454 });
455
456 let is_base = is_at_base_path(&dir_path, &base_segments);
461
462 enum Slot {
466 Shard(usize),
467 Dir(PathBuf, String),
468 }
469 let mut slots = Vec::with_capacity(entries.len());
470 let mut shard_files = Vec::new();
471 for entry in entries {
472 let path = entry.path();
473 let file_path = normalize_path_unix(&path.to_string_lossy()).to_string();
474 if is_regular_file(&entry, &path).await {
475 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
476 if !name.starts_with('.') && self.is_parsable_file(name) {
477 slots.push(Slot::Shard(shard_files.len()));
478 shard_files.push(file_path);
479 }
480 } else {
481 slots.push(Slot::Dir(path, file_path));
482 }
483 }
484
485 let mut shard_results = parse_shards_in_order(shard_files).await?;
490
491 for slot in slots {
493 let (path, file_path) = match slot {
494 Slot::Shard(index) => {
495 if let Some(parsed_obj) = shard_results[index].take() {
496 parsed.push(parsed_obj);
497 }
498 continue;
499 }
500 Slot::Dir(path, file_path) => (path, file_path),
501 };
502 let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
505 let matched_segment = if is_base {
506 base_segments
507 .iter()
508 .find(|(_, seg_name, _)| seg_name == dir_name)
509 .cloned()
510 } else {
511 None
512 };
513 if let Some((_, segment_name, extract_inner)) = matched_segment {
514 let segment_element = self
515 .collect_segment_as_array(&file_path, &segment_name, extract_inner)
516 .await?;
517 if let Some(el) = segment_element {
518 parsed.push(el);
519 }
520 } else {
521 let sub_parsed = self
522 .process_files_in_directory(file_path, base_segments.clone())
523 .await?;
524 parsed.extend(sub_parsed);
525 }
526 }
527
528 Ok(parsed)
529 })
530 }
531
532 async fn collect_segment_as_array(
536 &self,
537 segment_dir: &str,
538 segment_name: &str,
539 extract_inner: bool,
540 ) -> Result<Option<XmlElement>, Box<dyn std::error::Error + Send + Sync>> {
541 let mut xml_files = Vec::new();
542 let mut read_dir = fs::read_dir(segment_dir).await?;
543 while let Some(entry) = read_dir.next_entry().await? {
544 let path = entry.path();
545 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
546 if !name.starts_with('.')
547 && self.is_parsable_file(name)
548 && is_regular_file(&entry, &path).await
549 {
550 xml_files.push(normalize_path_unix(&path.to_string_lossy()).to_string());
551 }
552 }
553 xml_files.sort();
554
555 let mut root_contents = Vec::new();
556 let mut first_xml: Option<(String, Option<Value>)> = None;
557 for parsed in parse_shards_in_order(xml_files).await? {
559 let Some(parsed) = parsed else {
562 continue;
563 };
564 let obj_owned = parsed.as_object().cloned().unwrap_or_default();
565 let obj = &obj_owned;
566 let Some(root_key) = obj.keys().find(|k| *k != "?xml").cloned() else {
567 continue;
568 };
569 let root_val = obj
570 .get(&root_key)
571 .cloned()
572 .unwrap_or(Value::Object(serde_json::Map::new()));
573 let mut content = if extract_inner {
574 root_val
575 .get(segment_name)
576 .cloned()
577 .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
578 } else {
579 root_val
580 };
581 if extract_inner {
583 content = strip_xmlns_from_value(content);
584 }
585 root_contents.push(content);
586 if first_xml.is_none() {
587 first_xml = Some((root_key, obj.get("?xml").cloned()));
588 }
589 }
590 if root_contents.is_empty() {
591 return Ok(None);
592 }
593 let (root_key, decl_opt) = first_xml.unwrap();
594 let mut content = serde_json::Map::new();
595 content.insert(segment_name.to_string(), Value::Array(root_contents));
596 let mut top = serde_json::Map::new();
597 if let Some(decl) = decl_opt {
598 top.insert("?xml".to_string(), decl);
599 } else {
600 let mut d = serde_json::Map::new();
601 d.insert("@version".to_string(), Value::String("1.0".to_string()));
602 d.insert("@encoding".to_string(), Value::String("UTF-8".to_string()));
603 top.insert("?xml".to_string(), Value::Object(d));
604 }
605 top.insert(root_key, Value::Object(content));
606 Ok(Some(Value::Object(top)))
607 }
608
609 fn is_parsable_file(&self, file_name: &str) -> bool {
610 let lower = file_name.to_lowercase();
611 lower.ends_with(".xml")
612 || lower.ends_with(".json")
613 || lower.ends_with(".json5")
614 || lower.ends_with(".yaml")
615 || lower.ends_with(".yml")
616 }
617
618 async fn validate_directory(
619 &self,
620 path: &str,
621 ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
622 let meta = fs::metadata(path).await?;
623 if !meta.is_dir() {
624 log::error!(
625 "The provided path to reassemble is not a directory: {}",
626 path
627 );
628 return Ok(false);
629 }
630 Ok(true)
631 }
632
633 fn get_output_path(&self, dir_path: &str, extension: Option<&str>) -> String {
634 let path = Path::new(dir_path);
635 let parent = path.parent().unwrap_or(Path::new("."));
636 let base_name = path
637 .file_name()
638 .and_then(|n| n.to_str())
639 .unwrap_or("output");
640 let ext = extension.unwrap_or("xml");
641 parent
642 .join(format!("{}.{}", base_name, ext))
643 .to_string_lossy()
644 .to_string()
645 }
646}
647
648impl Default for ReassembleXmlFileHandler {
649 fn default() -> Self {
650 Self::new()
651 }
652}
653
654fn convert_to_format(content: &str, target_format: &str) -> String {
662 match target_format.to_ascii_lowercase().as_str() {
663 "json" => {
664 match serde_saphyr::from_str::<serde_json::Value>(content) {
665 Ok(val) => {
667 serde_json::to_string_pretty(&val).unwrap_or_else(|_| content.to_string())
668 }
669 Err(e) => {
670 log::warn!("sidecar reassemble: could not parse content for JSON conversion ({e}); using raw content");
671 content.to_string()
672 }
673 }
674 }
675 "yaml" | "yml" => {
676 if serde_json::from_str::<serde_json::Value>(content).is_ok() {
677 match serde_json::from_str::<serde_json::Value>(content)
678 .ok()
679 .and_then(|v| serde_saphyr::to_string(&v).ok())
680 {
681 Some(yaml) => yaml,
682 None => {
683 log::warn!(
684 "sidecar reassemble: YAML serialization failed; using raw content"
685 );
686 content.to_string()
687 }
688 }
689 } else {
690 content.to_string()
691 }
692 }
693 _ => content.to_string(),
694 }
695}
696
697async fn inject_sidecar_elements(
708 dir_path: &str,
709 merged: &mut XmlElement,
710 specs: &[SidecarSpec],
711) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
712 let path = Path::new(dir_path);
713 let base = path
714 .file_name()
715 .and_then(|n| n.to_str())
716 .unwrap_or("output");
717
718 let root_key = merged
719 .as_object()
720 .and_then(|o| o.keys().find(|k| *k != "?xml").cloned());
721 let Some(root_key) = root_key else {
722 return Ok(());
723 };
724
725 if let Some(root_val) = merged.as_object_mut().and_then(|o| o.get_mut(&root_key)) {
726 if let Some(root_obj) = root_val.as_object_mut() {
727 for spec in specs {
728 let sidecar_path = path.join(format!("{}.{}", base, spec.extension));
729 let Ok(content) = fs::read_to_string(&sidecar_path).await else {
730 continue;
731 };
732 let final_content = match &spec.original_format {
736 Some(fmt) => convert_to_format(&content, fmt),
737 None => content,
738 };
739 root_obj.insert(
740 spec.element.clone(),
741 serde_json::json!({ "#raw-text": final_content }),
742 );
743 }
744 }
745 }
746
747 Ok(())
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753 use serde_json::json;
754
755 #[test]
756 #[allow(clippy::default_constructed_unit_structs)]
757 fn reassemble_handler_default_equals_new() {
758 let _ = ReassembleXmlFileHandler::default();
759 }
760
761 #[test]
762 fn strip_xmlns_from_value_passes_non_object_through() {
763 let s = Value::String("hello".to_string());
764 assert_eq!(
765 strip_xmlns_from_value(s),
766 Value::String("hello".to_string())
767 );
768 let arr = json!([1, 2]);
769 assert_eq!(strip_xmlns_from_value(arr.clone()), arr);
770 }
771
772 #[test]
773 fn strip_xmlns_from_value_removes_xmlns_key() {
774 let obj = json!({ "@xmlns": "ns", "child": 1 });
775 let stripped = strip_xmlns_from_value(obj);
776 let map = stripped.as_object().unwrap();
777 assert!(map.get("@xmlns").is_none());
778 assert_eq!(map.get("child").and_then(|v| v.as_i64()), Some(1));
779 }
780
781 #[test]
782 fn is_parsable_file_recognises_supported_extensions() {
783 let h = ReassembleXmlFileHandler::new();
784 assert!(h.is_parsable_file("a.xml"));
785 assert!(h.is_parsable_file("a.json"));
786 assert!(h.is_parsable_file("a.json5"));
787 assert!(h.is_parsable_file("a.yaml"));
788 assert!(h.is_parsable_file("a.yml"));
789 assert!(h.is_parsable_file("A.XML"));
790 assert!(!h.is_parsable_file("a.txt"));
791 }
792
793 #[test]
794 fn get_output_path_appends_extension_and_uses_parent_dir() {
795 let h = ReassembleXmlFileHandler::new();
796 let out = h.get_output_path("/tmp/foo", Some("xml"));
797 assert!(out.ends_with("foo.xml"));
798 let out_default = h.get_output_path("/tmp/bar", None);
799 assert!(out_default.ends_with("bar.xml"));
800 assert_eq!(h.get_output_path("only", Some("json")), "only.json");
802 }
803
804 #[tokio::test]
805 async fn reassemble_multi_level_segment_noop_when_not_dir() {
806 let h = ReassembleXmlFileHandler::new();
807 let tmp = tempfile::tempdir().unwrap();
808 let file = tmp.path().join("not_a_dir.txt");
809 tokio::fs::write(&file, "hi").await.unwrap();
810 let rule = crate::xml::types::MultiLevelRule {
811 file_pattern: String::new(),
812 root_to_strip: String::new(),
813 unique_id_elements: String::new(),
814 path_segment: String::new(),
815 wrap_root_element: "Root".to_string(),
816 wrap_xmlns: String::new(),
817 };
818 h.reassemble_multi_level_segment(&file, &rule, &[])
819 .await
820 .unwrap();
821 }
822
823 #[tokio::test]
824 async fn reassemble_multi_level_segment_skips_files_in_segment_root() {
825 let h = ReassembleXmlFileHandler::new();
826 let tmp = tempfile::tempdir().unwrap();
827 let segment = tmp.path().join("segment");
828 tokio::fs::create_dir(&segment).await.unwrap();
829 tokio::fs::write(segment.join("stray.txt"), "x")
831 .await
832 .unwrap();
833 let rule = crate::xml::types::MultiLevelRule {
834 file_pattern: String::new(),
835 root_to_strip: String::new(),
836 unique_id_elements: String::new(),
837 path_segment: "segment".to_string(),
838 wrap_root_element: "Root".to_string(),
839 wrap_xmlns: "http://example.com".to_string(),
840 };
841 h.reassemble_multi_level_segment(&segment, &rule, &[])
842 .await
843 .unwrap();
844 }
845
846 #[tokio::test]
847 async fn collect_segment_as_array_returns_none_for_empty_dir() {
848 let h = ReassembleXmlFileHandler::new();
849 let tmp = tempfile::tempdir().unwrap();
850 let out = h
851 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
852 .await
853 .unwrap();
854 assert!(out.is_none());
855 }
856
857 #[tokio::test]
858 async fn collect_segment_as_array_skips_unparseable_and_empty_roots() {
859 let h = ReassembleXmlFileHandler::new();
860 let tmp = tempfile::tempdir().unwrap();
861 tokio::fs::write(tmp.path().join("bad.xml"), "<<")
863 .await
864 .unwrap();
865 tokio::fs::write(tmp.path().join("only-decl.xml"), "")
867 .await
868 .unwrap();
869 tokio::fs::write(tmp.path().join(".hidden.xml"), "<r/>")
871 .await
872 .unwrap();
873 let out = h
874 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", false)
875 .await
876 .unwrap();
877 assert!(out.is_none());
878 }
879
880 #[tokio::test]
881 async fn collect_segment_as_array_without_xml_decl_inserts_default_decl() {
882 let h = ReassembleXmlFileHandler::new();
886 let tmp = tempfile::tempdir().unwrap();
887 tokio::fs::write(
888 tmp.path().join("a.xml"),
889 r#"<Root><seg><x>1</x></seg></Root>"#,
890 )
891 .await
892 .unwrap();
893 let out = h
894 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
895 .await
896 .unwrap()
897 .unwrap();
898 let obj = out.as_object().unwrap();
899 let decl = obj
900 .get("?xml")
901 .and_then(|v| v.as_object())
902 .expect("default declaration must be inserted when XML has none");
903 assert_eq!(decl.get("@version").and_then(|v| v.as_str()), Some("1.0"));
904 assert_eq!(
905 decl.get("@encoding").and_then(|v| v.as_str()),
906 Some("UTF-8")
907 );
908 }
909
910 #[tokio::test]
911 async fn collect_segment_as_array_with_xml_decl_preserves_it() {
912 let h = ReassembleXmlFileHandler::new();
915 let tmp = tempfile::tempdir().unwrap();
916 tokio::fs::write(
917 tmp.path().join("a.xml"),
918 r#"<?xml version="1.0" encoding="UTF-8"?><Root><seg><x>1</x></seg></Root>"#,
919 )
920 .await
921 .unwrap();
922 let out = h
923 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
924 .await
925 .unwrap()
926 .unwrap();
927 let obj = out.as_object().unwrap();
928 let decl = obj
929 .get("?xml")
930 .and_then(|v| v.as_object())
931 .expect("?xml declaration must be preserved from source");
932 assert_eq!(decl.get("@version").and_then(|v| v.as_str()), Some("1.0"));
933 assert_eq!(
934 decl.get("@encoding").and_then(|v| v.as_str()),
935 Some("UTF-8")
936 );
937 }
938
939 #[tokio::test]
940 async fn collect_segment_as_array_without_extract_inner_wraps_root() {
941 let h = ReassembleXmlFileHandler::new();
942 let tmp = tempfile::tempdir().unwrap();
943 tokio::fs::write(tmp.path().join("a.xml"), r#"<Root><child>1</child></Root>"#)
944 .await
945 .unwrap();
946 let out = h
947 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", false)
948 .await
949 .unwrap()
950 .unwrap();
951 let obj = out.as_object().unwrap();
952 assert!(obj.contains_key("?xml"));
953 let root = obj.get("Root").and_then(|r| r.as_object()).unwrap();
954 assert!(root.get("seg").and_then(|v| v.as_array()).is_some());
955 }
956
957 fn rule_with_segment(segment: &str) -> MultiLevelRule {
958 MultiLevelRule {
959 file_pattern: String::new(),
960 root_to_strip: String::new(),
961 unique_id_elements: String::new(),
962 path_segment: segment.to_string(),
963 wrap_root_element: String::new(),
964 wrap_xmlns: String::new(),
965 }
966 }
967
968 #[test]
969 fn deeper_candidate_rules_excludes_the_matched_segment() {
970 let rules = vec![rule_with_segment("seg_a"), rule_with_segment("seg_b")];
974 let deeper = deeper_candidate_rules(&rules, "seg_a");
975 assert_eq!(deeper.len(), 1);
976 assert_eq!(deeper[0].path_segment, "seg_b");
977 }
978
979 #[test]
980 fn deeper_candidate_rules_keeps_all_when_no_segment_matches() {
981 let rules = vec![rule_with_segment("seg_a"), rule_with_segment("seg_b")];
985 let deeper = deeper_candidate_rules(&rules, "missing");
986 assert_eq!(deeper.len(), 2);
987 }
988
989 #[test]
990 fn deeper_candidate_rules_returns_empty_for_empty_input() {
991 let deeper: Vec<MultiLevelRule> = deeper_candidate_rules(&[], "anything");
992 assert!(deeper.is_empty());
993 }
994
995 #[test]
996 fn is_at_base_path_true_when_dir_matches_any_segment() {
997 let segs = vec![
998 ("/base/other".to_string(), "seg1".to_string(), false),
999 ("/base/here".to_string(), "seg2".to_string(), false),
1000 ];
1001 assert!(is_at_base_path("/base/here", &segs));
1002 }
1003
1004 #[test]
1005 fn is_at_base_path_false_when_dir_matches_nothing() {
1006 let segs = vec![("/base/a".to_string(), "seg".to_string(), false)];
1007 assert!(!is_at_base_path("/base/b", &segs));
1008 }
1009
1010 #[test]
1011 fn is_at_base_path_false_for_empty_segments() {
1012 let segs: Vec<(String, String, bool)> = Vec::new();
1013 assert!(!is_at_base_path("/anywhere", &segs));
1014 }
1015
1016 #[tokio::test]
1017 async fn parse_shards_in_order_keeps_input_order_and_marks_failures() {
1018 let tmp = tempfile::tempdir().unwrap();
1019 let mut files = Vec::new();
1020 for i in 0..100 {
1021 let path = tmp.path().join(format!("{i:03}.xml"));
1023 if i % 10 != 0 {
1024 tokio::fs::write(&path, format!("<Root><n>{i}</n></Root>"))
1025 .await
1026 .unwrap();
1027 }
1028 files.push(path.to_string_lossy().to_string());
1029 }
1030
1031 let results = parse_shards_in_order(files).await.unwrap();
1032
1033 assert_eq!(results.len(), 100);
1034 for (i, result) in results.iter().enumerate() {
1035 if i % 10 == 0 {
1036 assert!(result.is_none(), "missing shard {i} should be None");
1037 } else {
1038 let n = result.as_ref().unwrap()["Root"]["n"]["#text"]
1039 .as_str()
1040 .unwrap();
1041 assert_eq!(n, i.to_string(), "shard {i} out of order");
1042 }
1043 }
1044 }
1045
1046 #[cfg(unix)]
1051 #[tokio::test]
1052 async fn reassemble_follows_symlinked_shards_and_directories() {
1053 use std::os::unix::fs::symlink;
1054
1055 let tmp = tempfile::tempdir().unwrap();
1056 let outside = tmp.path().join("outside");
1057 tokio::fs::create_dir_all(outside.join("nested"))
1058 .await
1059 .unwrap();
1060 tokio::fs::write(outside.join("b.xml"), "<Root><n>b</n></Root>")
1061 .await
1062 .unwrap();
1063 tokio::fs::write(
1064 outside.join("nested").join("c.xml"),
1065 "<Root><n>c</n></Root>",
1066 )
1067 .await
1068 .unwrap();
1069
1070 let dir = tmp.path().join("Bench");
1071 tokio::fs::create_dir(&dir).await.unwrap();
1072 tokio::fs::write(dir.join("a.xml"), "<Root><n>a</n></Root>")
1073 .await
1074 .unwrap();
1075 symlink(outside.join("b.xml"), dir.join("b.xml")).unwrap();
1076 symlink(outside.join("nested"), dir.join("c_dir")).unwrap();
1077
1078 ReassembleXmlFileHandler::new()
1079 .reassemble(dir.to_str().unwrap(), Some("xml"), false, None)
1080 .await
1081 .unwrap();
1082
1083 let output = tokio::fs::read_to_string(tmp.path().join("Bench.xml"))
1084 .await
1085 .unwrap();
1086 let a = output.find("<n>a</n>").expect("regular shard a");
1087 let b = output.find("<n>b</n>").expect("symlinked shard b");
1088 let c = output
1089 .find("<n>c</n>")
1090 .expect("shard c under symlinked dir");
1091 assert!(a < b && b < c, "sorted order a, b, c_dir/: {output}");
1092 }
1093
1094 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1095 async fn reassemble_many_shards_preserves_sorted_order() {
1096 let tmp = tempfile::tempdir().unwrap();
1100 let dir = tmp.path().join("Bench");
1101 tokio::fs::create_dir(&dir).await.unwrap();
1102 for i in 0..200 {
1103 let padding = "x".repeat((i * 7919) % 4096);
1105 tokio::fs::write(
1106 dir.join(format!("{i:04}.field-meta.xml")),
1107 format!(
1108 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Root><fields><n>{i}</n><pad>{padding}</pad></fields></Root>"
1109 ),
1110 )
1111 .await
1112 .unwrap();
1113 }
1114
1115 ReassembleXmlFileHandler::new()
1116 .reassemble(dir.to_str().unwrap(), Some("xml"), false, None)
1117 .await
1118 .unwrap();
1119
1120 let output = tokio::fs::read_to_string(tmp.path().join("Bench.xml"))
1121 .await
1122 .unwrap();
1123 let order: Vec<usize> = output
1124 .match_indices("<n>")
1125 .map(|(at, _)| {
1126 let rest = &output[at + 3..];
1127 rest[..rest.find('<').unwrap()].parse().unwrap()
1128 })
1129 .collect();
1130 assert_eq!(order, (0..200).collect::<Vec<_>>());
1131 }
1132
1133 #[tokio::test]
1138 async fn reassemble_plain_some_empty_sidecar_specs_falls_through_to_auto_detect() {
1139 let h = ReassembleXmlFileHandler::new();
1140 let tmp = tempfile::tempdir().unwrap();
1141 let dir = tmp.path().join("mydir");
1142 tokio::fs::create_dir(&dir).await.unwrap();
1143
1144 tokio::fs::write(
1145 dir.join("a.xml"),
1146 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1147 )
1148 .await
1149 .unwrap();
1150
1151 tokio::fs::write(
1152 dir.join(".sidecars.json"),
1153 r#"[{"element":"Notes","extension":"yaml"}]"#,
1154 )
1155 .await
1156 .unwrap();
1157
1158 tokio::fs::write(dir.join("mydir.yaml"), "key: value")
1160 .await
1161 .unwrap();
1162
1163 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], Some(&[]))
1164 .await
1165 .unwrap();
1166
1167 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1168 .await
1169 .unwrap();
1170 assert!(
1171 output.contains("key: value"),
1172 "sidecar content missing — auto-detect did not run:\n{output}"
1173 );
1174 }
1175
1176 #[test]
1179 fn convert_to_format_yaml_to_json() {
1180 let yaml = "openapi: 3.0.1\ninfo:\n title: \"Test API\"\n version: 1.0.0\n";
1181 let out = convert_to_format(yaml, "json");
1182 let val: serde_json::Value = serde_json::from_str(&out).expect("output must be valid JSON");
1183 assert_eq!(val["openapi"], "3.0.1");
1184 assert_eq!(val["info"]["title"], "Test API");
1185 assert_eq!(val["info"]["version"], "1.0.0");
1186 }
1187
1188 #[test]
1191 fn convert_to_format_json_to_yaml() {
1192 let json = r#"{"key":"value","num":42}"#;
1193 let out = convert_to_format(json, "yaml");
1194 assert!(
1195 serde_json::from_str::<serde_json::Value>(&out).is_err(),
1196 "output must be YAML format, not raw JSON: {out}"
1197 );
1198 let val: serde_json::Value =
1199 serde_saphyr::from_str(&out).expect("output must be valid YAML");
1200 assert_eq!(val["key"], "value");
1201 assert_eq!(val["num"], 42);
1202 }
1203
1204 #[test]
1205 fn convert_to_format_yml_extension_same_as_yaml() {
1206 let json = r#"{"x":true}"#;
1207 let out = convert_to_format(json, "yml");
1208 let val: serde_json::Value = serde_saphyr::from_str(&out).unwrap();
1209 assert_eq!(val["x"], true);
1210 }
1211
1212 #[test]
1213 fn convert_to_format_yaml_passes_through_unchanged() {
1214 let yaml = "title: \"@AuraEnabled\"\nversion: 1.0.0\n";
1216 assert_eq!(convert_to_format(yaml, "yaml"), yaml);
1217 }
1218
1219 #[test]
1220 fn convert_to_format_unknown_extension_passes_through() {
1221 let raw = "arbitrary content";
1222 assert_eq!(convert_to_format(raw, "txt"), raw);
1223 assert_eq!(convert_to_format(raw, ""), raw);
1224 }
1225
1226 #[test]
1227 fn convert_to_format_malformed_falls_back_to_raw() {
1228 let bad = "{{{{ not valid json or yaml at all >>>>>";
1229 assert_eq!(convert_to_format(bad, "json"), bad);
1230 }
1231
1232 #[test]
1233 fn convert_to_format_non_string_key_falls_back_to_raw() {
1234 let yaml_with_sequence_key = "? [a, b]\n: value\n";
1238 assert_eq!(
1239 convert_to_format(yaml_with_sequence_key, "json"),
1240 yaml_with_sequence_key
1241 );
1242 }
1243
1244 #[tokio::test]
1245 async fn reassemble_plain_uses_caller_supplied_sidecar_specs_directly() {
1246 let h = ReassembleXmlFileHandler::new();
1249 let tmp = tempfile::tempdir().unwrap();
1250 let dir = tmp.path().join("mydir");
1251 tokio::fs::create_dir(&dir).await.unwrap();
1252
1253 tokio::fs::write(
1254 dir.join("a.xml"),
1255 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1256 )
1257 .await
1258 .unwrap();
1259 tokio::fs::write(dir.join("mydir.yaml"), "key: value")
1261 .await
1262 .unwrap();
1263
1264 let specs = [SidecarSpec {
1265 element: "Notes".to_string(),
1266 extension: "yaml".to_string(),
1267 original_format: None,
1268 }];
1269 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], Some(&specs))
1270 .await
1271 .unwrap();
1272
1273 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1274 .await
1275 .unwrap();
1276 assert!(
1277 output.contains("key: value"),
1278 "caller-supplied sidecar spec was not used:\n{output}"
1279 );
1280 }
1281
1282 #[tokio::test]
1283 async fn reassemble_plain_ignores_malformed_sidecars_json() {
1284 let h = ReassembleXmlFileHandler::new();
1287 let tmp = tempfile::tempdir().unwrap();
1288 let dir = tmp.path().join("mydir");
1289 tokio::fs::create_dir(&dir).await.unwrap();
1290
1291 tokio::fs::write(
1292 dir.join("a.xml"),
1293 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1294 )
1295 .await
1296 .unwrap();
1297 tokio::fs::write(dir.join(".sidecars.json"), "not valid json")
1298 .await
1299 .unwrap();
1300
1301 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], None)
1302 .await
1303 .unwrap();
1304
1305 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1306 .await
1307 .unwrap();
1308 assert!(output.contains("Child"), "reassembly must still succeed");
1309 }
1310
1311 #[tokio::test]
1312 async fn reassemble_plain_post_purge_removes_sidecar_files() {
1313 let h = ReassembleXmlFileHandler::new();
1316 let tmp = tempfile::tempdir().unwrap();
1317 let dir = tmp.path().join("mydir");
1318 tokio::fs::create_dir(&dir).await.unwrap();
1319
1320 tokio::fs::write(
1321 dir.join("a.xml"),
1322 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1323 )
1324 .await
1325 .unwrap();
1326 let sidecar_path = dir.join("mydir.yaml");
1327 tokio::fs::write(&sidecar_path, "key: value").await.unwrap();
1328
1329 let specs = [SidecarSpec {
1330 element: "Notes".to_string(),
1331 extension: "yaml".to_string(),
1332 original_format: None,
1333 }];
1334 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), true, &[], Some(&specs))
1335 .await
1336 .unwrap();
1337
1338 assert!(!dir.exists(), "post_purge must remove the disassembled dir");
1339 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1340 .await
1341 .unwrap();
1342 assert!(output.contains("key: value"));
1343 }
1344
1345 #[tokio::test]
1346 async fn inject_sidecar_elements_ok_when_no_root_key() {
1347 let mut merged: XmlElement = json!({ "?xml": { "@version": "1.0" } });
1350 let specs = [SidecarSpec {
1351 element: "Notes".to_string(),
1352 extension: "yaml".to_string(),
1353 original_format: None,
1354 }];
1355 inject_sidecar_elements("does-not-matter", &mut merged, &specs)
1356 .await
1357 .unwrap();
1358 assert_eq!(merged, json!({ "?xml": { "@version": "1.0" } }));
1359 }
1360
1361 #[tokio::test]
1362 async fn inject_sidecar_elements_skips_missing_sidecar_file() {
1363 let tmp = tempfile::tempdir().unwrap();
1366 let dir = tmp.path().join("mydir");
1367 tokio::fs::create_dir(&dir).await.unwrap();
1368 let mut merged: XmlElement = json!({ "Root": { "Child": { "#text": "hello" } } });
1369 let specs = [SidecarSpec {
1370 element: "Notes".to_string(),
1371 extension: "yaml".to_string(),
1372 original_format: None,
1373 }];
1374 inject_sidecar_elements(dir.to_str().unwrap(), &mut merged, &specs)
1375 .await
1376 .unwrap();
1377 assert_eq!(merged, json!({ "Root": { "Child": { "#text": "hello" } } }));
1378 }
1379}