1use crate::xml::builders::{build_xml_string, merge_xml_elements_owned, reorder_root_keys_owned};
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_owned(parsed_objects) else {
357 log::error!(
358 "No usable root element found while merging files under {}. A reassembled XML file was not created.",
359 file_path
360 );
361 return Ok(());
362 };
363
364 let auto_specs: Vec<crate::xml::types::SidecarSpec>;
367 let effective_specs: Option<&[crate::xml::types::SidecarSpec]> =
368 if sidecar_specs.is_some_and(|s| !s.is_empty()) {
369 sidecar_specs
370 } else {
371 let meta_path = Path::new(&file_path).join(".sidecars.json");
372 if let Ok(content) = fs::read_to_string(&meta_path).await {
373 if let Ok(parsed) =
374 serde_json::from_str::<Vec<crate::xml::types::SidecarSpec>>(&content)
375 {
376 auto_specs = parsed;
377 Some(auto_specs.as_slice())
378 } else {
379 None
380 }
381 } else {
382 None
383 }
384 };
385
386 if let Some(specs) = effective_specs {
389 inject_sidecar_elements(&file_path, &mut merged, specs).await?;
390 }
391
392 let key_order_path = Path::new(&file_path).join(".key_order.json");
394 if let Some(order) = read_key_order(&key_order_path).await {
395 merged = reorder_root_keys_owned(merged, &order);
396 }
397
398 let mut final_xml = build_xml_string(&merged);
399
400 let trailing_newline_path = Path::new(&file_path).join(".trailing_newline.json");
403 if read_trailing_newline(&trailing_newline_path)
404 .await
405 .unwrap_or(false)
406 {
407 final_xml.push('\n');
408 }
409
410 let output_path = self.get_output_path(&file_path, file_extension);
411
412 fs::write(&output_path, &final_xml).await?;
413
414 if post_purge {
418 if let Some(specs) = effective_specs {
419 let path = Path::new(&file_path);
420 let base = path
421 .file_name()
422 .and_then(|n| n.to_str())
423 .unwrap_or("output");
424 for spec in specs {
425 let sidecar = path.join(format!("{}.{}", base, spec.extension));
426 fs::remove_file(&sidecar).await.ok();
427 }
428 }
429 fs::remove_dir_all(file_path).await.ok();
430 }
431
432 Ok(())
433 }
434
435 fn process_files_in_directory<'a>(
436 &'a self,
437 dir_path: String,
438 base_segments: Vec<(String, String, bool)>,
439 ) -> ProcessDirFuture<'a> {
440 Box::pin(async move {
441 let mut parsed = Vec::new();
442 let mut entries = Vec::new();
443 let mut read_dir = fs::read_dir(&dir_path).await?;
444 while let Some(entry) = read_dir.next_entry().await? {
445 entries.push(entry);
446 }
447 entries.sort_by(|a, b| {
449 let a_name = a.file_name().to_string_lossy().to_string();
450 let b_name = b.file_name().to_string_lossy().to_string();
451 a_name.cmp(&b_name)
452 });
453
454 let is_base = is_at_base_path(&dir_path, &base_segments);
459
460 enum Slot {
464 Shard(usize),
465 Dir(PathBuf, String),
466 }
467 let mut slots = Vec::with_capacity(entries.len());
468 let mut shard_files = Vec::new();
469 for entry in entries {
470 let path = entry.path();
471 let file_path = normalize_path_unix(&path.to_string_lossy()).to_string();
472 if is_regular_file(&entry, &path).await {
473 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
474 if !name.starts_with('.') && self.is_parsable_file(name) {
475 slots.push(Slot::Shard(shard_files.len()));
476 shard_files.push(file_path);
477 }
478 } else {
479 slots.push(Slot::Dir(path, file_path));
480 }
481 }
482
483 let mut shard_results = parse_shards_in_order(shard_files).await?;
488
489 for slot in slots {
491 let (path, file_path) = match slot {
492 Slot::Shard(index) => {
493 if let Some(parsed_obj) = shard_results[index].take() {
494 parsed.push(parsed_obj);
495 }
496 continue;
497 }
498 Slot::Dir(path, file_path) => (path, file_path),
499 };
500 let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
503 let matched_segment = if is_base {
504 base_segments
505 .iter()
506 .find(|(_, seg_name, _)| seg_name == dir_name)
507 .cloned()
508 } else {
509 None
510 };
511 if let Some((_, segment_name, extract_inner)) = matched_segment {
512 let segment_element = self
513 .collect_segment_as_array(&file_path, &segment_name, extract_inner)
514 .await?;
515 if let Some(el) = segment_element {
516 parsed.push(el);
517 }
518 } else {
519 let sub_parsed = self
520 .process_files_in_directory(file_path, base_segments.clone())
521 .await?;
522 parsed.extend(sub_parsed);
523 }
524 }
525
526 Ok(parsed)
527 })
528 }
529
530 async fn collect_segment_as_array(
534 &self,
535 segment_dir: &str,
536 segment_name: &str,
537 extract_inner: bool,
538 ) -> Result<Option<XmlElement>, Box<dyn std::error::Error + Send + Sync>> {
539 let mut xml_files = Vec::new();
540 let mut read_dir = fs::read_dir(segment_dir).await?;
541 while let Some(entry) = read_dir.next_entry().await? {
542 let path = entry.path();
543 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
544 if !name.starts_with('.')
545 && self.is_parsable_file(name)
546 && is_regular_file(&entry, &path).await
547 {
548 xml_files.push(normalize_path_unix(&path.to_string_lossy()).to_string());
549 }
550 }
551 xml_files.sort();
552
553 let mut root_contents = Vec::new();
554 let mut first_xml: Option<(String, Option<Value>)> = None;
555 for parsed in parse_shards_in_order(xml_files).await? {
557 let Some(parsed) = parsed else {
560 continue;
561 };
562 let obj_owned = parsed.as_object().cloned().unwrap_or_default();
563 let obj = &obj_owned;
564 let Some(root_key) = obj.keys().find(|k| *k != "?xml").cloned() else {
565 continue;
566 };
567 let root_val = obj
568 .get(&root_key)
569 .cloned()
570 .unwrap_or(Value::Object(serde_json::Map::new()));
571 let mut content = if extract_inner {
572 root_val
573 .get(segment_name)
574 .cloned()
575 .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
576 } else {
577 root_val
578 };
579 if extract_inner {
581 content = strip_xmlns_from_value(content);
582 }
583 root_contents.push(content);
584 if first_xml.is_none() {
585 first_xml = Some((root_key, obj.get("?xml").cloned()));
586 }
587 }
588 if root_contents.is_empty() {
589 return Ok(None);
590 }
591 let (root_key, decl_opt) = first_xml.unwrap();
592 let mut content = serde_json::Map::new();
593 content.insert(segment_name.to_string(), Value::Array(root_contents));
594 let mut top = serde_json::Map::new();
595 if let Some(decl) = decl_opt {
596 top.insert("?xml".to_string(), decl);
597 } else {
598 let mut d = serde_json::Map::new();
599 d.insert("@version".to_string(), Value::String("1.0".to_string()));
600 d.insert("@encoding".to_string(), Value::String("UTF-8".to_string()));
601 top.insert("?xml".to_string(), Value::Object(d));
602 }
603 top.insert(root_key, Value::Object(content));
604 Ok(Some(Value::Object(top)))
605 }
606
607 fn is_parsable_file(&self, file_name: &str) -> bool {
608 let lower = file_name.to_lowercase();
609 lower.ends_with(".xml")
610 || lower.ends_with(".json")
611 || lower.ends_with(".json5")
612 || lower.ends_with(".yaml")
613 || lower.ends_with(".yml")
614 }
615
616 async fn validate_directory(
617 &self,
618 path: &str,
619 ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
620 let meta = fs::metadata(path).await?;
621 if !meta.is_dir() {
622 log::error!(
623 "The provided path to reassemble is not a directory: {}",
624 path
625 );
626 return Ok(false);
627 }
628 Ok(true)
629 }
630
631 fn get_output_path(&self, dir_path: &str, extension: Option<&str>) -> String {
632 let path = Path::new(dir_path);
633 let parent = path.parent().unwrap_or(Path::new("."));
634 let base_name = path
635 .file_name()
636 .and_then(|n| n.to_str())
637 .unwrap_or("output");
638 let ext = extension.unwrap_or("xml");
639 parent
640 .join(format!("{}.{}", base_name, ext))
641 .to_string_lossy()
642 .to_string()
643 }
644}
645
646impl Default for ReassembleXmlFileHandler {
647 fn default() -> Self {
648 Self::new()
649 }
650}
651
652fn convert_to_format(content: &str, target_format: &str) -> String {
660 match target_format.to_ascii_lowercase().as_str() {
661 "json" => {
662 match serde_saphyr::from_str::<serde_json::Value>(content) {
663 Ok(val) => {
665 serde_json::to_string_pretty(&val).unwrap_or_else(|_| content.to_string())
666 }
667 Err(e) => {
668 log::warn!("sidecar reassemble: could not parse content for JSON conversion ({e}); using raw content");
669 content.to_string()
670 }
671 }
672 }
673 "yaml" | "yml" => {
674 if serde_json::from_str::<serde_json::Value>(content).is_ok() {
675 match serde_json::from_str::<serde_json::Value>(content)
676 .ok()
677 .and_then(|v| serde_saphyr::to_string(&v).ok())
678 {
679 Some(yaml) => yaml,
680 None => {
681 log::warn!(
682 "sidecar reassemble: YAML serialization failed; using raw content"
683 );
684 content.to_string()
685 }
686 }
687 } else {
688 content.to_string()
689 }
690 }
691 _ => content.to_string(),
692 }
693}
694
695async fn inject_sidecar_elements(
706 dir_path: &str,
707 merged: &mut XmlElement,
708 specs: &[SidecarSpec],
709) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
710 let path = Path::new(dir_path);
711 let base = path
712 .file_name()
713 .and_then(|n| n.to_str())
714 .unwrap_or("output");
715
716 let root_key = merged
717 .as_object()
718 .and_then(|o| o.keys().find(|k| *k != "?xml").cloned());
719 let Some(root_key) = root_key else {
720 return Ok(());
721 };
722
723 if let Some(root_val) = merged.as_object_mut().and_then(|o| o.get_mut(&root_key)) {
724 if let Some(root_obj) = root_val.as_object_mut() {
725 for spec in specs {
726 let sidecar_path = path.join(format!("{}.{}", base, spec.extension));
727 let Ok(content) = fs::read_to_string(&sidecar_path).await else {
728 continue;
729 };
730 let final_content = match &spec.original_format {
734 Some(fmt) => convert_to_format(&content, fmt),
735 None => content,
736 };
737 root_obj.insert(
738 spec.element.clone(),
739 serde_json::json!({ "#raw-text": final_content }),
740 );
741 }
742 }
743 }
744
745 Ok(())
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751 use serde_json::json;
752
753 #[test]
754 #[allow(clippy::default_constructed_unit_structs)]
755 fn reassemble_handler_default_equals_new() {
756 let _ = ReassembleXmlFileHandler::default();
757 }
758
759 #[test]
760 fn strip_xmlns_from_value_passes_non_object_through() {
761 let s = Value::String("hello".to_string());
762 assert_eq!(
763 strip_xmlns_from_value(s),
764 Value::String("hello".to_string())
765 );
766 let arr = json!([1, 2]);
767 assert_eq!(strip_xmlns_from_value(arr.clone()), arr);
768 }
769
770 #[test]
771 fn strip_xmlns_from_value_removes_xmlns_key() {
772 let obj = json!({ "@xmlns": "ns", "child": 1 });
773 let stripped = strip_xmlns_from_value(obj);
774 let map = stripped.as_object().unwrap();
775 assert!(map.get("@xmlns").is_none());
776 assert_eq!(map.get("child").and_then(|v| v.as_i64()), Some(1));
777 }
778
779 #[test]
780 fn is_parsable_file_recognises_supported_extensions() {
781 let h = ReassembleXmlFileHandler::new();
782 assert!(h.is_parsable_file("a.xml"));
783 assert!(h.is_parsable_file("a.json"));
784 assert!(h.is_parsable_file("a.json5"));
785 assert!(h.is_parsable_file("a.yaml"));
786 assert!(h.is_parsable_file("a.yml"));
787 assert!(h.is_parsable_file("A.XML"));
788 assert!(!h.is_parsable_file("a.txt"));
789 }
790
791 #[test]
792 fn get_output_path_appends_extension_and_uses_parent_dir() {
793 let h = ReassembleXmlFileHandler::new();
794 let out = h.get_output_path("/tmp/foo", Some("xml"));
795 assert!(out.ends_with("foo.xml"));
796 let out_default = h.get_output_path("/tmp/bar", None);
797 assert!(out_default.ends_with("bar.xml"));
798 assert_eq!(h.get_output_path("only", Some("json")), "only.json");
800 }
801
802 #[tokio::test]
803 async fn reassemble_multi_level_segment_noop_when_not_dir() {
804 let h = ReassembleXmlFileHandler::new();
805 let tmp = tempfile::tempdir().unwrap();
806 let file = tmp.path().join("not_a_dir.txt");
807 tokio::fs::write(&file, "hi").await.unwrap();
808 let rule = crate::xml::types::MultiLevelRule {
809 file_pattern: String::new(),
810 root_to_strip: String::new(),
811 unique_id_elements: String::new(),
812 path_segment: String::new(),
813 wrap_root_element: "Root".to_string(),
814 wrap_xmlns: String::new(),
815 };
816 h.reassemble_multi_level_segment(&file, &rule, &[])
817 .await
818 .unwrap();
819 }
820
821 #[tokio::test]
822 async fn reassemble_multi_level_segment_skips_files_in_segment_root() {
823 let h = ReassembleXmlFileHandler::new();
824 let tmp = tempfile::tempdir().unwrap();
825 let segment = tmp.path().join("segment");
826 tokio::fs::create_dir(&segment).await.unwrap();
827 tokio::fs::write(segment.join("stray.txt"), "x")
829 .await
830 .unwrap();
831 let rule = crate::xml::types::MultiLevelRule {
832 file_pattern: String::new(),
833 root_to_strip: String::new(),
834 unique_id_elements: String::new(),
835 path_segment: "segment".to_string(),
836 wrap_root_element: "Root".to_string(),
837 wrap_xmlns: "http://example.com".to_string(),
838 };
839 h.reassemble_multi_level_segment(&segment, &rule, &[])
840 .await
841 .unwrap();
842 }
843
844 #[tokio::test]
845 async fn collect_segment_as_array_returns_none_for_empty_dir() {
846 let h = ReassembleXmlFileHandler::new();
847 let tmp = tempfile::tempdir().unwrap();
848 let out = h
849 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
850 .await
851 .unwrap();
852 assert!(out.is_none());
853 }
854
855 #[tokio::test]
856 async fn collect_segment_as_array_skips_unparseable_and_empty_roots() {
857 let h = ReassembleXmlFileHandler::new();
858 let tmp = tempfile::tempdir().unwrap();
859 tokio::fs::write(tmp.path().join("bad.xml"), "<<")
861 .await
862 .unwrap();
863 tokio::fs::write(tmp.path().join("only-decl.xml"), "")
865 .await
866 .unwrap();
867 tokio::fs::write(tmp.path().join(".hidden.xml"), "<r/>")
869 .await
870 .unwrap();
871 let out = h
872 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", false)
873 .await
874 .unwrap();
875 assert!(out.is_none());
876 }
877
878 #[tokio::test]
879 async fn collect_segment_as_array_without_xml_decl_inserts_default_decl() {
880 let h = ReassembleXmlFileHandler::new();
884 let tmp = tempfile::tempdir().unwrap();
885 tokio::fs::write(
886 tmp.path().join("a.xml"),
887 r#"<Root><seg><x>1</x></seg></Root>"#,
888 )
889 .await
890 .unwrap();
891 let out = h
892 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
893 .await
894 .unwrap()
895 .unwrap();
896 let obj = out.as_object().unwrap();
897 let decl = obj
898 .get("?xml")
899 .and_then(|v| v.as_object())
900 .expect("default declaration must be inserted when XML has none");
901 assert_eq!(decl.get("@version").and_then(|v| v.as_str()), Some("1.0"));
902 assert_eq!(
903 decl.get("@encoding").and_then(|v| v.as_str()),
904 Some("UTF-8")
905 );
906 }
907
908 #[tokio::test]
909 async fn collect_segment_as_array_with_xml_decl_preserves_it() {
910 let h = ReassembleXmlFileHandler::new();
913 let tmp = tempfile::tempdir().unwrap();
914 tokio::fs::write(
915 tmp.path().join("a.xml"),
916 r#"<?xml version="1.0" encoding="UTF-8"?><Root><seg><x>1</x></seg></Root>"#,
917 )
918 .await
919 .unwrap();
920 let out = h
921 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
922 .await
923 .unwrap()
924 .unwrap();
925 let obj = out.as_object().unwrap();
926 let decl = obj
927 .get("?xml")
928 .and_then(|v| v.as_object())
929 .expect("?xml declaration must be preserved from source");
930 assert_eq!(decl.get("@version").and_then(|v| v.as_str()), Some("1.0"));
931 assert_eq!(
932 decl.get("@encoding").and_then(|v| v.as_str()),
933 Some("UTF-8")
934 );
935 }
936
937 #[tokio::test]
938 async fn collect_segment_as_array_without_extract_inner_wraps_root() {
939 let h = ReassembleXmlFileHandler::new();
940 let tmp = tempfile::tempdir().unwrap();
941 tokio::fs::write(tmp.path().join("a.xml"), r#"<Root><child>1</child></Root>"#)
942 .await
943 .unwrap();
944 let out = h
945 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", false)
946 .await
947 .unwrap()
948 .unwrap();
949 let obj = out.as_object().unwrap();
950 assert!(obj.contains_key("?xml"));
951 let root = obj.get("Root").and_then(|r| r.as_object()).unwrap();
952 assert!(root.get("seg").and_then(|v| v.as_array()).is_some());
953 }
954
955 fn rule_with_segment(segment: &str) -> MultiLevelRule {
956 MultiLevelRule {
957 file_pattern: String::new(),
958 root_to_strip: String::new(),
959 unique_id_elements: String::new(),
960 path_segment: segment.to_string(),
961 wrap_root_element: String::new(),
962 wrap_xmlns: String::new(),
963 }
964 }
965
966 #[test]
967 fn deeper_candidate_rules_excludes_the_matched_segment() {
968 let rules = vec![rule_with_segment("seg_a"), rule_with_segment("seg_b")];
972 let deeper = deeper_candidate_rules(&rules, "seg_a");
973 assert_eq!(deeper.len(), 1);
974 assert_eq!(deeper[0].path_segment, "seg_b");
975 }
976
977 #[test]
978 fn deeper_candidate_rules_keeps_all_when_no_segment_matches() {
979 let rules = vec![rule_with_segment("seg_a"), rule_with_segment("seg_b")];
983 let deeper = deeper_candidate_rules(&rules, "missing");
984 assert_eq!(deeper.len(), 2);
985 }
986
987 #[test]
988 fn deeper_candidate_rules_returns_empty_for_empty_input() {
989 let deeper: Vec<MultiLevelRule> = deeper_candidate_rules(&[], "anything");
990 assert!(deeper.is_empty());
991 }
992
993 #[test]
994 fn is_at_base_path_true_when_dir_matches_any_segment() {
995 let segs = vec![
996 ("/base/other".to_string(), "seg1".to_string(), false),
997 ("/base/here".to_string(), "seg2".to_string(), false),
998 ];
999 assert!(is_at_base_path("/base/here", &segs));
1000 }
1001
1002 #[test]
1003 fn is_at_base_path_false_when_dir_matches_nothing() {
1004 let segs = vec![("/base/a".to_string(), "seg".to_string(), false)];
1005 assert!(!is_at_base_path("/base/b", &segs));
1006 }
1007
1008 #[test]
1009 fn is_at_base_path_false_for_empty_segments() {
1010 let segs: Vec<(String, String, bool)> = Vec::new();
1011 assert!(!is_at_base_path("/anywhere", &segs));
1012 }
1013
1014 #[tokio::test]
1015 async fn parse_shards_in_order_keeps_input_order_and_marks_failures() {
1016 let tmp = tempfile::tempdir().unwrap();
1017 let mut files = Vec::new();
1018 for i in 0..100 {
1019 let path = tmp.path().join(format!("{i:03}.xml"));
1021 if i % 10 != 0 {
1022 tokio::fs::write(&path, format!("<Root><n>{i}</n></Root>"))
1023 .await
1024 .unwrap();
1025 }
1026 files.push(path.to_string_lossy().to_string());
1027 }
1028
1029 let results = parse_shards_in_order(files).await.unwrap();
1030
1031 assert_eq!(results.len(), 100);
1032 for (i, result) in results.iter().enumerate() {
1033 if i % 10 == 0 {
1034 assert!(result.is_none(), "missing shard {i} should be None");
1035 } else {
1036 let n = result.as_ref().unwrap()["Root"]["n"]["#text"]
1037 .as_str()
1038 .unwrap();
1039 assert_eq!(n, i.to_string(), "shard {i} out of order");
1040 }
1041 }
1042 }
1043
1044 #[cfg(unix)]
1049 #[tokio::test]
1050 async fn reassemble_follows_symlinked_shards_and_directories() {
1051 use std::os::unix::fs::symlink;
1052
1053 let tmp = tempfile::tempdir().unwrap();
1054 let outside = tmp.path().join("outside");
1055 tokio::fs::create_dir_all(outside.join("nested"))
1056 .await
1057 .unwrap();
1058 tokio::fs::write(outside.join("b.xml"), "<Root><n>b</n></Root>")
1059 .await
1060 .unwrap();
1061 tokio::fs::write(
1062 outside.join("nested").join("c.xml"),
1063 "<Root><n>c</n></Root>",
1064 )
1065 .await
1066 .unwrap();
1067
1068 let dir = tmp.path().join("Bench");
1069 tokio::fs::create_dir(&dir).await.unwrap();
1070 tokio::fs::write(dir.join("a.xml"), "<Root><n>a</n></Root>")
1071 .await
1072 .unwrap();
1073 symlink(outside.join("b.xml"), dir.join("b.xml")).unwrap();
1074 symlink(outside.join("nested"), dir.join("c_dir")).unwrap();
1075
1076 ReassembleXmlFileHandler::new()
1077 .reassemble(dir.to_str().unwrap(), Some("xml"), false, None)
1078 .await
1079 .unwrap();
1080
1081 let output = tokio::fs::read_to_string(tmp.path().join("Bench.xml"))
1082 .await
1083 .unwrap();
1084 let a = output.find("<n>a</n>").expect("regular shard a");
1085 let b = output.find("<n>b</n>").expect("symlinked shard b");
1086 let c = output
1087 .find("<n>c</n>")
1088 .expect("shard c under symlinked dir");
1089 assert!(a < b && b < c, "sorted order a, b, c_dir/: {output}");
1090 }
1091
1092 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1093 async fn reassemble_many_shards_preserves_sorted_order() {
1094 let tmp = tempfile::tempdir().unwrap();
1098 let dir = tmp.path().join("Bench");
1099 tokio::fs::create_dir(&dir).await.unwrap();
1100 for i in 0..200 {
1101 let padding = "x".repeat((i * 7919) % 4096);
1103 tokio::fs::write(
1104 dir.join(format!("{i:04}.field-meta.xml")),
1105 format!(
1106 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Root><fields><n>{i}</n><pad>{padding}</pad></fields></Root>"
1107 ),
1108 )
1109 .await
1110 .unwrap();
1111 }
1112
1113 ReassembleXmlFileHandler::new()
1114 .reassemble(dir.to_str().unwrap(), Some("xml"), false, None)
1115 .await
1116 .unwrap();
1117
1118 let output = tokio::fs::read_to_string(tmp.path().join("Bench.xml"))
1119 .await
1120 .unwrap();
1121 let order: Vec<usize> = output
1122 .match_indices("<n>")
1123 .map(|(at, _)| {
1124 let rest = &output[at + 3..];
1125 rest[..rest.find('<').unwrap()].parse().unwrap()
1126 })
1127 .collect();
1128 assert_eq!(order, (0..200).collect::<Vec<_>>());
1129 }
1130
1131 #[tokio::test]
1136 async fn reassemble_plain_some_empty_sidecar_specs_falls_through_to_auto_detect() {
1137 let h = ReassembleXmlFileHandler::new();
1138 let tmp = tempfile::tempdir().unwrap();
1139 let dir = tmp.path().join("mydir");
1140 tokio::fs::create_dir(&dir).await.unwrap();
1141
1142 tokio::fs::write(
1143 dir.join("a.xml"),
1144 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1145 )
1146 .await
1147 .unwrap();
1148
1149 tokio::fs::write(
1150 dir.join(".sidecars.json"),
1151 r#"[{"element":"Notes","extension":"yaml"}]"#,
1152 )
1153 .await
1154 .unwrap();
1155
1156 tokio::fs::write(dir.join("mydir.yaml"), "key: value")
1158 .await
1159 .unwrap();
1160
1161 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], Some(&[]))
1162 .await
1163 .unwrap();
1164
1165 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1166 .await
1167 .unwrap();
1168 assert!(
1169 output.contains("key: value"),
1170 "sidecar content missing — auto-detect did not run:\n{output}"
1171 );
1172 }
1173
1174 #[test]
1177 fn convert_to_format_yaml_to_json() {
1178 let yaml = "openapi: 3.0.1\ninfo:\n title: \"Test API\"\n version: 1.0.0\n";
1179 let out = convert_to_format(yaml, "json");
1180 let val: serde_json::Value = serde_json::from_str(&out).expect("output must be valid JSON");
1181 assert_eq!(val["openapi"], "3.0.1");
1182 assert_eq!(val["info"]["title"], "Test API");
1183 assert_eq!(val["info"]["version"], "1.0.0");
1184 }
1185
1186 #[test]
1189 fn convert_to_format_json_to_yaml() {
1190 let json = r#"{"key":"value","num":42}"#;
1191 let out = convert_to_format(json, "yaml");
1192 assert!(
1193 serde_json::from_str::<serde_json::Value>(&out).is_err(),
1194 "output must be YAML format, not raw JSON: {out}"
1195 );
1196 let val: serde_json::Value =
1197 serde_saphyr::from_str(&out).expect("output must be valid YAML");
1198 assert_eq!(val["key"], "value");
1199 assert_eq!(val["num"], 42);
1200 }
1201
1202 #[test]
1203 fn convert_to_format_yml_extension_same_as_yaml() {
1204 let json = r#"{"x":true}"#;
1205 let out = convert_to_format(json, "yml");
1206 let val: serde_json::Value = serde_saphyr::from_str(&out).unwrap();
1207 assert_eq!(val["x"], true);
1208 }
1209
1210 #[test]
1211 fn convert_to_format_yaml_passes_through_unchanged() {
1212 let yaml = "title: \"@AuraEnabled\"\nversion: 1.0.0\n";
1214 assert_eq!(convert_to_format(yaml, "yaml"), yaml);
1215 }
1216
1217 #[test]
1218 fn convert_to_format_unknown_extension_passes_through() {
1219 let raw = "arbitrary content";
1220 assert_eq!(convert_to_format(raw, "txt"), raw);
1221 assert_eq!(convert_to_format(raw, ""), raw);
1222 }
1223
1224 #[test]
1225 fn convert_to_format_malformed_falls_back_to_raw() {
1226 let bad = "{{{{ not valid json or yaml at all >>>>>";
1227 assert_eq!(convert_to_format(bad, "json"), bad);
1228 }
1229
1230 #[test]
1231 fn convert_to_format_non_string_key_falls_back_to_raw() {
1232 let yaml_with_sequence_key = "? [a, b]\n: value\n";
1236 assert_eq!(
1237 convert_to_format(yaml_with_sequence_key, "json"),
1238 yaml_with_sequence_key
1239 );
1240 }
1241
1242 #[tokio::test]
1243 async fn reassemble_plain_uses_caller_supplied_sidecar_specs_directly() {
1244 let h = ReassembleXmlFileHandler::new();
1247 let tmp = tempfile::tempdir().unwrap();
1248 let dir = tmp.path().join("mydir");
1249 tokio::fs::create_dir(&dir).await.unwrap();
1250
1251 tokio::fs::write(
1252 dir.join("a.xml"),
1253 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1254 )
1255 .await
1256 .unwrap();
1257 tokio::fs::write(dir.join("mydir.yaml"), "key: value")
1259 .await
1260 .unwrap();
1261
1262 let specs = [SidecarSpec {
1263 element: "Notes".to_string(),
1264 extension: "yaml".to_string(),
1265 original_format: None,
1266 }];
1267 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], Some(&specs))
1268 .await
1269 .unwrap();
1270
1271 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1272 .await
1273 .unwrap();
1274 assert!(
1275 output.contains("key: value"),
1276 "caller-supplied sidecar spec was not used:\n{output}"
1277 );
1278 }
1279
1280 #[tokio::test]
1281 async fn reassemble_plain_ignores_malformed_sidecars_json() {
1282 let h = ReassembleXmlFileHandler::new();
1285 let tmp = tempfile::tempdir().unwrap();
1286 let dir = tmp.path().join("mydir");
1287 tokio::fs::create_dir(&dir).await.unwrap();
1288
1289 tokio::fs::write(
1290 dir.join("a.xml"),
1291 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1292 )
1293 .await
1294 .unwrap();
1295 tokio::fs::write(dir.join(".sidecars.json"), "not valid json")
1296 .await
1297 .unwrap();
1298
1299 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], None)
1300 .await
1301 .unwrap();
1302
1303 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1304 .await
1305 .unwrap();
1306 assert!(output.contains("Child"), "reassembly must still succeed");
1307 }
1308
1309 #[tokio::test]
1310 async fn reassemble_plain_post_purge_removes_sidecar_files() {
1311 let h = ReassembleXmlFileHandler::new();
1314 let tmp = tempfile::tempdir().unwrap();
1315 let dir = tmp.path().join("mydir");
1316 tokio::fs::create_dir(&dir).await.unwrap();
1317
1318 tokio::fs::write(
1319 dir.join("a.xml"),
1320 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1321 )
1322 .await
1323 .unwrap();
1324 let sidecar_path = dir.join("mydir.yaml");
1325 tokio::fs::write(&sidecar_path, "key: value").await.unwrap();
1326
1327 let specs = [SidecarSpec {
1328 element: "Notes".to_string(),
1329 extension: "yaml".to_string(),
1330 original_format: None,
1331 }];
1332 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), true, &[], Some(&specs))
1333 .await
1334 .unwrap();
1335
1336 assert!(!dir.exists(), "post_purge must remove the disassembled dir");
1337 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1338 .await
1339 .unwrap();
1340 assert!(output.contains("key: value"));
1341 }
1342
1343 #[tokio::test]
1344 async fn inject_sidecar_elements_ok_when_no_root_key() {
1345 let mut merged: XmlElement = json!({ "?xml": { "@version": "1.0" } });
1348 let specs = [SidecarSpec {
1349 element: "Notes".to_string(),
1350 extension: "yaml".to_string(),
1351 original_format: None,
1352 }];
1353 inject_sidecar_elements("does-not-matter", &mut merged, &specs)
1354 .await
1355 .unwrap();
1356 assert_eq!(merged, json!({ "?xml": { "@version": "1.0" } }));
1357 }
1358
1359 #[tokio::test]
1360 async fn inject_sidecar_elements_skips_missing_sidecar_file() {
1361 let tmp = tempfile::tempdir().unwrap();
1364 let dir = tmp.path().join("mydir");
1365 tokio::fs::create_dir(&dir).await.unwrap();
1366 let mut merged: XmlElement = json!({ "Root": { "Child": { "#text": "hello" } } });
1367 let specs = [SidecarSpec {
1368 element: "Notes".to_string(),
1369 extension: "yaml".to_string(),
1370 original_format: None,
1371 }];
1372 inject_sidecar_elements(dir.to_str().unwrap(), &mut merged, &specs)
1373 .await
1374 .unwrap();
1375 assert_eq!(merged, json!({ "Root": { "Child": { "#text": "hello" } } }));
1376 }
1377}