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;
15
16async fn read_key_order(path: &Path) -> Option<Vec<String>> {
18 let bytes = fs::read(path).await.ok()?;
19 serde_json::from_slice::<Vec<String>>(&bytes).ok()
20}
21
22async fn read_trailing_newline(path: &Path) -> Option<bool> {
26 let bytes = fs::read(path).await.ok()?;
27 String::from_utf8(bytes).ok()?.trim().parse::<bool>().ok()
28}
29
30fn strip_xmlns_from_value(v: Value) -> Value {
32 match v {
33 Value::Object(obj) => {
34 Value::Object(obj.into_iter().filter(|(k, _)| k != "@xmlns").collect())
35 }
36 other => other,
37 }
38}
39
40fn deeper_candidate_rules(
48 all_rules: &[MultiLevelRule],
49 exclude_path_segment: &str,
50) -> Vec<MultiLevelRule> {
51 all_rules
52 .iter()
53 .filter(|r| r.path_segment != exclude_path_segment)
54 .cloned()
55 .collect()
56}
57
58fn is_at_base_path(dir_path: &str, base_segments: &[(String, String, bool)]) -> bool {
65 base_segments.iter().any(|(base, _, _)| dir_path == base)
66}
67
68type ProcessDirFuture<'a> = Pin<
69 Box<
70 dyn Future<Output = Result<Vec<XmlElement>, Box<dyn std::error::Error + Send + Sync>>>
71 + Send
72 + 'a,
73 >,
74>;
75
76type SegmentFuture<'a> =
77 Pin<Box<dyn Future<Output = Result<(), Box<dyn std::error::Error + Send + Sync>>> + Send + 'a>>;
78
79pub struct ReassembleXmlFileHandler;
80
81impl ReassembleXmlFileHandler {
82 pub fn new() -> Self {
83 Self
84 }
85
86 pub async fn reassemble(
87 &self,
88 file_path: &str,
89 file_extension: Option<&str>,
90 post_purge: bool,
91 sidecar_specs: Option<&[SidecarSpec]>,
92 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
93 let file_path = normalize_path_unix(file_path);
94 if !self.validate_directory(&file_path).await? {
95 return Ok(());
96 }
97
98 let path = Path::new(&file_path);
99 let config = load_multi_level_config(path).await;
100 if let Some(ref config) = config {
101 for (i, rule) in config.rules.iter().enumerate() {
106 let segment_path = path.join(&rule.path_segment);
107 if !segment_path.is_dir() {
108 continue;
109 }
110 let nested: Vec<MultiLevelRule> = config
111 .rules
112 .iter()
113 .enumerate()
114 .filter(|(j, _)| *j != i)
115 .map(|(_, r)| r.clone())
116 .collect();
117 self.reassemble_multi_level_segment(&segment_path, rule, &nested)
118 .await?;
119 }
120 }
121
122 let base_segments: Vec<(String, String, bool)> = config
125 .as_ref()
126 .map(|c| {
127 c.rules
128 .iter()
129 .map(|r| (file_path.clone(), r.path_segment.clone(), true))
130 .collect()
131 })
132 .unwrap_or_default();
133 let post_purge_final = post_purge || config.is_some();
135 self.reassemble_plain(
136 &file_path,
137 file_extension,
138 post_purge_final,
139 &base_segments,
140 sidecar_specs,
141 )
142 .await
143 }
144
145 fn reassemble_multi_level_segment<'a>(
170 &'a self,
171 segment_path: &'a Path,
172 rule: &'a MultiLevelRule,
173 nested_rules: &'a [MultiLevelRule],
174 ) -> SegmentFuture<'a> {
175 let segment_path = segment_path.to_path_buf();
176 let rule = rule.clone();
177 let nested_rules = nested_rules.to_vec();
178 Box::pin(async move {
179 self.reassemble_multi_level_segment_inner(&segment_path, &rule, &nested_rules)
180 .await
181 })
182 }
183
184 async fn reassemble_multi_level_segment_inner(
185 &self,
186 segment_path: &Path,
187 rule: &MultiLevelRule,
188 nested_rules: &[MultiLevelRule],
189 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
190 if !segment_path.is_dir() {
191 return Ok(());
192 }
193 let mut entries = Vec::new();
194 let mut read_dir = fs::read_dir(segment_path).await?;
195 while let Some(entry) = read_dir.next_entry().await? {
196 entries.push(entry);
197 }
198 entries.sort_by_key(|e| e.file_name());
199 for entry in entries {
200 let process_path = entry.path();
201 if !process_path.is_dir() {
202 continue;
203 }
204 let process_path_str = normalize_path_unix(&process_path.to_string_lossy());
205 let mut sub_entries = Vec::new();
206 let mut sub_read = fs::read_dir(&process_path).await?;
207 while let Some(e) = sub_read.next_entry().await? {
208 sub_entries.push(e);
209 }
210 sub_entries.sort_by_key(|e| e.file_name());
211
212 let mut handled: HashSet<OsString> = HashSet::new();
215 for sub_entry in &sub_entries {
216 let sub_path: PathBuf = sub_entry.path();
217 if !sub_path.is_dir() {
218 continue;
219 }
220 let sub_name = sub_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
221 let Some(nested_rule) = nested_rules.iter().find(|r| r.path_segment == sub_name)
222 else {
223 continue;
224 };
225 let deeper = deeper_candidate_rules(nested_rules, &nested_rule.path_segment);
229 self.reassemble_multi_level_segment(&sub_path, nested_rule, &deeper)
230 .await?;
231 handled.insert(sub_entry.file_name());
232 }
233
234 for sub_entry in &sub_entries {
237 let sub_path = sub_entry.path();
238 if !sub_path.is_dir() {
239 continue;
240 }
241 if handled.contains(&sub_entry.file_name()) {
242 continue;
243 }
244 let sub_path_str = normalize_path_unix(&sub_path.to_string_lossy());
245 self.reassemble_plain(&sub_path_str, Some("xml"), true, &[], None)
246 .await?;
247 }
248
249 self.reassemble_plain(&process_path_str, Some("xml"), true, &[], None)
251 .await?;
252 }
253 ensure_segment_files_structure(
254 segment_path,
255 &rule.wrap_root_element,
256 &rule.path_segment,
257 &rule.wrap_xmlns,
258 )
259 .await?;
260 Ok(())
261 }
262
263 async fn reassemble_plain(
271 &self,
272 file_path: &str,
273 file_extension: Option<&str>,
274 post_purge: bool,
275 base_segments: &[(String, String, bool)],
276 sidecar_specs: Option<&[SidecarSpec]>,
277 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
278 let file_path = normalize_path_unix(file_path);
279 log::debug!("Parsing directory to reassemble: {}", file_path);
280 let parsed_objects = self
281 .process_files_in_directory(file_path.to_string(), base_segments.to_vec())
282 .await?;
283
284 if parsed_objects.is_empty() {
285 log::error!(
286 "No files under {} were parsed successfully. A reassembled XML file was not created.",
287 file_path
288 );
289 return Ok(());
290 }
291
292 let Some(mut merged) = merge_xml_elements(&parsed_objects) else {
296 log::error!(
297 "No usable root element found while merging files under {}. A reassembled XML file was not created.",
298 file_path
299 );
300 return Ok(());
301 };
302
303 let auto_specs: Vec<crate::xml::types::SidecarSpec>;
306 let effective_specs: Option<&[crate::xml::types::SidecarSpec]> =
307 if sidecar_specs.is_some_and(|s| !s.is_empty()) {
308 sidecar_specs
309 } else {
310 let meta_path = Path::new(&file_path).join(".sidecars.json");
311 if let Ok(content) = fs::read_to_string(&meta_path).await {
312 if let Ok(parsed) =
313 serde_json::from_str::<Vec<crate::xml::types::SidecarSpec>>(&content)
314 {
315 auto_specs = parsed;
316 Some(auto_specs.as_slice())
317 } else {
318 None
319 }
320 } else {
321 None
322 }
323 };
324
325 if let Some(specs) = effective_specs {
328 inject_sidecar_elements(&file_path, &mut merged, specs).await?;
329 }
330
331 let key_order_path = Path::new(&file_path).join(".key_order.json");
333 if let Some(reordered) = read_key_order(&key_order_path)
334 .await
335 .and_then(|order| reorder_root_keys(&merged, &order))
336 {
337 merged = reordered;
338 }
339
340 let mut final_xml = build_xml_string(&merged);
341
342 let trailing_newline_path = Path::new(&file_path).join(".trailing_newline.json");
345 if read_trailing_newline(&trailing_newline_path)
346 .await
347 .unwrap_or(false)
348 {
349 final_xml.push('\n');
350 }
351
352 let output_path = self.get_output_path(&file_path, file_extension);
353
354 fs::write(&output_path, &final_xml).await?;
355
356 if post_purge {
360 if let Some(specs) = effective_specs {
361 let path = Path::new(&file_path);
362 let base = path
363 .file_name()
364 .and_then(|n| n.to_str())
365 .unwrap_or("output");
366 for spec in specs {
367 let sidecar = path.join(format!("{}.{}", base, spec.extension));
368 fs::remove_file(&sidecar).await.ok();
369 }
370 }
371 fs::remove_dir_all(file_path).await.ok();
372 }
373
374 Ok(())
375 }
376
377 fn process_files_in_directory<'a>(
378 &'a self,
379 dir_path: String,
380 base_segments: Vec<(String, String, bool)>,
381 ) -> ProcessDirFuture<'a> {
382 Box::pin(async move {
383 let mut parsed = Vec::new();
384 let mut entries = Vec::new();
385 let mut read_dir = fs::read_dir(&dir_path).await?;
386 while let Some(entry) = read_dir.next_entry().await? {
387 entries.push(entry);
388 }
389 entries.sort_by(|a, b| {
391 let a_name = a.file_name().to_string_lossy().to_string();
392 let b_name = b.file_name().to_string_lossy().to_string();
393 a_name.cmp(&b_name)
394 });
395
396 let is_base = is_at_base_path(&dir_path, &base_segments);
401
402 for entry in entries {
403 let path = entry.path();
404 let file_path = normalize_path_unix(&path.to_string_lossy()).to_string();
405
406 if path.is_file() {
407 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
408 if !name.starts_with('.') && self.is_parsable_file(name) {
409 if let Some(parsed_obj) = parse_to_xml_object(&file_path).await {
410 parsed.push(parsed_obj);
411 }
412 }
413 } else {
414 let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
417 let matched_segment = if is_base {
418 base_segments
419 .iter()
420 .find(|(_, seg_name, _)| seg_name == dir_name)
421 .cloned()
422 } else {
423 None
424 };
425 if let Some((_, segment_name, extract_inner)) = matched_segment {
426 let segment_element = self
427 .collect_segment_as_array(&file_path, &segment_name, extract_inner)
428 .await?;
429 if let Some(el) = segment_element {
430 parsed.push(el);
431 }
432 } else {
433 let sub_parsed = self
434 .process_files_in_directory(file_path, base_segments.clone())
435 .await?;
436 parsed.extend(sub_parsed);
437 }
438 }
439 }
440
441 Ok(parsed)
442 })
443 }
444
445 async fn collect_segment_as_array(
449 &self,
450 segment_dir: &str,
451 segment_name: &str,
452 extract_inner: bool,
453 ) -> Result<Option<XmlElement>, Box<dyn std::error::Error + Send + Sync>> {
454 let mut xml_files = Vec::new();
455 let mut read_dir = fs::read_dir(segment_dir).await?;
456 while let Some(entry) = read_dir.next_entry().await? {
457 let path = entry.path();
458 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
459 if path.is_file() && !name.starts_with('.') && self.is_parsable_file(name) {
460 xml_files.push(normalize_path_unix(&path.to_string_lossy()));
461 }
462 }
463 xml_files.sort();
464
465 let mut root_contents = Vec::new();
466 let mut first_xml: Option<(String, Option<Value>)> = None;
467 for file_path in &xml_files {
468 let Some(parsed) = parse_to_xml_object(file_path).await else {
471 continue;
472 };
473 let obj_owned = parsed.as_object().cloned().unwrap_or_default();
474 let obj = &obj_owned;
475 let Some(root_key) = obj.keys().find(|k| *k != "?xml").cloned() else {
476 continue;
477 };
478 let root_val = obj
479 .get(&root_key)
480 .cloned()
481 .unwrap_or(Value::Object(serde_json::Map::new()));
482 let mut content = if extract_inner {
483 root_val
484 .get(segment_name)
485 .cloned()
486 .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
487 } else {
488 root_val
489 };
490 if extract_inner {
492 content = strip_xmlns_from_value(content);
493 }
494 root_contents.push(content);
495 if first_xml.is_none() {
496 first_xml = Some((root_key, obj.get("?xml").cloned()));
497 }
498 }
499 if root_contents.is_empty() {
500 return Ok(None);
501 }
502 let (root_key, decl_opt) = first_xml.unwrap();
503 let mut content = serde_json::Map::new();
504 content.insert(segment_name.to_string(), Value::Array(root_contents));
505 let mut top = serde_json::Map::new();
506 if let Some(decl) = decl_opt {
507 top.insert("?xml".to_string(), decl);
508 } else {
509 let mut d = serde_json::Map::new();
510 d.insert("@version".to_string(), Value::String("1.0".to_string()));
511 d.insert("@encoding".to_string(), Value::String("UTF-8".to_string()));
512 top.insert("?xml".to_string(), Value::Object(d));
513 }
514 top.insert(root_key, Value::Object(content));
515 Ok(Some(Value::Object(top)))
516 }
517
518 fn is_parsable_file(&self, file_name: &str) -> bool {
519 let lower = file_name.to_lowercase();
520 lower.ends_with(".xml")
521 || lower.ends_with(".json")
522 || lower.ends_with(".json5")
523 || lower.ends_with(".yaml")
524 || lower.ends_with(".yml")
525 }
526
527 async fn validate_directory(
528 &self,
529 path: &str,
530 ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
531 let meta = fs::metadata(path).await?;
532 if !meta.is_dir() {
533 log::error!(
534 "The provided path to reassemble is not a directory: {}",
535 path
536 );
537 return Ok(false);
538 }
539 Ok(true)
540 }
541
542 fn get_output_path(&self, dir_path: &str, extension: Option<&str>) -> String {
543 let path = Path::new(dir_path);
544 let parent = path.parent().unwrap_or(Path::new("."));
545 let base_name = path
546 .file_name()
547 .and_then(|n| n.to_str())
548 .unwrap_or("output");
549 let ext = extension.unwrap_or("xml");
550 parent
551 .join(format!("{}.{}", base_name, ext))
552 .to_string_lossy()
553 .to_string()
554 }
555}
556
557impl Default for ReassembleXmlFileHandler {
558 fn default() -> Self {
559 Self::new()
560 }
561}
562
563fn convert_to_format(content: &str, target_format: &str) -> String {
571 match target_format.to_ascii_lowercase().as_str() {
572 "json" => {
573 match serde_yaml::from_str::<serde_yaml::Value>(content) {
574 Ok(val) => match serde_json::to_string_pretty(&val) {
575 Ok(json) => json,
576 Err(e) => {
577 log::warn!("sidecar reassemble: JSON serialization failed ({e}); using raw content");
578 content.to_string()
579 }
580 },
581 Err(e) => {
582 log::warn!("sidecar reassemble: could not parse content for JSON conversion ({e}); using raw content");
583 content.to_string()
584 }
585 }
586 }
587 "yaml" | "yml" => {
588 if serde_json::from_str::<serde_json::Value>(content).is_ok() {
589 match serde_yaml::from_str::<serde_yaml::Value>(content)
590 .ok()
591 .and_then(|v| serde_yaml::to_string(&v).ok())
592 {
593 Some(yaml) => yaml,
594 None => {
595 log::warn!(
596 "sidecar reassemble: YAML serialization failed; using raw content"
597 );
598 content.to_string()
599 }
600 }
601 } else {
602 content.to_string()
603 }
604 }
605 _ => content.to_string(),
606 }
607}
608
609async fn inject_sidecar_elements(
620 dir_path: &str,
621 merged: &mut XmlElement,
622 specs: &[SidecarSpec],
623) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
624 let path = Path::new(dir_path);
625 let base = path
626 .file_name()
627 .and_then(|n| n.to_str())
628 .unwrap_or("output");
629
630 let root_key = merged
631 .as_object()
632 .and_then(|o| o.keys().find(|k| *k != "?xml").cloned());
633 let Some(root_key) = root_key else {
634 return Ok(());
635 };
636
637 if let Some(root_val) = merged.as_object_mut().and_then(|o| o.get_mut(&root_key)) {
638 if let Some(root_obj) = root_val.as_object_mut() {
639 for spec in specs {
640 let sidecar_path = path.join(format!("{}.{}", base, spec.extension));
641 let Ok(content) = fs::read_to_string(&sidecar_path).await else {
642 continue;
643 };
644 let final_content = match &spec.original_format {
648 Some(fmt) => convert_to_format(&content, fmt),
649 None => content,
650 };
651 root_obj.insert(
652 spec.element.clone(),
653 serde_json::json!({ "#raw-text": final_content }),
654 );
655 }
656 }
657 }
658
659 Ok(())
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665 use serde_json::json;
666
667 #[test]
668 #[allow(clippy::default_constructed_unit_structs)]
669 fn reassemble_handler_default_equals_new() {
670 let _ = ReassembleXmlFileHandler::default();
671 }
672
673 #[test]
674 fn strip_xmlns_from_value_passes_non_object_through() {
675 let s = Value::String("hello".to_string());
676 assert_eq!(
677 strip_xmlns_from_value(s),
678 Value::String("hello".to_string())
679 );
680 let arr = json!([1, 2]);
681 assert_eq!(strip_xmlns_from_value(arr.clone()), arr);
682 }
683
684 #[test]
685 fn strip_xmlns_from_value_removes_xmlns_key() {
686 let obj = json!({ "@xmlns": "ns", "child": 1 });
687 let stripped = strip_xmlns_from_value(obj);
688 let map = stripped.as_object().unwrap();
689 assert!(map.get("@xmlns").is_none());
690 assert_eq!(map.get("child").and_then(|v| v.as_i64()), Some(1));
691 }
692
693 #[test]
694 fn is_parsable_file_recognises_supported_extensions() {
695 let h = ReassembleXmlFileHandler::new();
696 assert!(h.is_parsable_file("a.xml"));
697 assert!(h.is_parsable_file("a.json"));
698 assert!(h.is_parsable_file("a.json5"));
699 assert!(h.is_parsable_file("a.yaml"));
700 assert!(h.is_parsable_file("a.yml"));
701 assert!(h.is_parsable_file("A.XML"));
702 assert!(!h.is_parsable_file("a.txt"));
703 }
704
705 #[test]
706 fn get_output_path_appends_extension_and_uses_parent_dir() {
707 let h = ReassembleXmlFileHandler::new();
708 let out = h.get_output_path("/tmp/foo", Some("xml"));
709 assert!(out.ends_with("foo.xml"));
710 let out_default = h.get_output_path("/tmp/bar", None);
711 assert!(out_default.ends_with("bar.xml"));
712 assert_eq!(h.get_output_path("only", Some("json")), "only.json");
714 }
715
716 #[tokio::test]
717 async fn reassemble_multi_level_segment_noop_when_not_dir() {
718 let h = ReassembleXmlFileHandler::new();
719 let tmp = tempfile::tempdir().unwrap();
720 let file = tmp.path().join("not_a_dir.txt");
721 tokio::fs::write(&file, "hi").await.unwrap();
722 let rule = crate::xml::types::MultiLevelRule {
723 file_pattern: String::new(),
724 root_to_strip: String::new(),
725 unique_id_elements: String::new(),
726 path_segment: String::new(),
727 wrap_root_element: "Root".to_string(),
728 wrap_xmlns: String::new(),
729 };
730 h.reassemble_multi_level_segment(&file, &rule, &[])
731 .await
732 .unwrap();
733 }
734
735 #[tokio::test]
736 async fn reassemble_multi_level_segment_skips_files_in_segment_root() {
737 let h = ReassembleXmlFileHandler::new();
738 let tmp = tempfile::tempdir().unwrap();
739 let segment = tmp.path().join("segment");
740 tokio::fs::create_dir(&segment).await.unwrap();
741 tokio::fs::write(segment.join("stray.txt"), "x")
743 .await
744 .unwrap();
745 let rule = crate::xml::types::MultiLevelRule {
746 file_pattern: String::new(),
747 root_to_strip: String::new(),
748 unique_id_elements: String::new(),
749 path_segment: "segment".to_string(),
750 wrap_root_element: "Root".to_string(),
751 wrap_xmlns: "http://example.com".to_string(),
752 };
753 h.reassemble_multi_level_segment(&segment, &rule, &[])
754 .await
755 .unwrap();
756 }
757
758 #[tokio::test]
759 async fn collect_segment_as_array_returns_none_for_empty_dir() {
760 let h = ReassembleXmlFileHandler::new();
761 let tmp = tempfile::tempdir().unwrap();
762 let out = h
763 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
764 .await
765 .unwrap();
766 assert!(out.is_none());
767 }
768
769 #[tokio::test]
770 async fn collect_segment_as_array_skips_unparseable_and_empty_roots() {
771 let h = ReassembleXmlFileHandler::new();
772 let tmp = tempfile::tempdir().unwrap();
773 tokio::fs::write(tmp.path().join("bad.xml"), "<<")
775 .await
776 .unwrap();
777 tokio::fs::write(tmp.path().join("only-decl.xml"), "")
779 .await
780 .unwrap();
781 tokio::fs::write(tmp.path().join(".hidden.xml"), "<r/>")
783 .await
784 .unwrap();
785 let out = h
786 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", false)
787 .await
788 .unwrap();
789 assert!(out.is_none());
790 }
791
792 #[tokio::test]
793 async fn collect_segment_as_array_without_xml_decl_inserts_default_decl() {
794 let h = ReassembleXmlFileHandler::new();
798 let tmp = tempfile::tempdir().unwrap();
799 tokio::fs::write(
800 tmp.path().join("a.xml"),
801 r#"<Root><seg><x>1</x></seg></Root>"#,
802 )
803 .await
804 .unwrap();
805 let out = h
806 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
807 .await
808 .unwrap()
809 .unwrap();
810 let obj = out.as_object().unwrap();
811 let decl = obj
812 .get("?xml")
813 .and_then(|v| v.as_object())
814 .expect("default declaration must be inserted when XML has none");
815 assert_eq!(decl.get("@version").and_then(|v| v.as_str()), Some("1.0"));
816 assert_eq!(
817 decl.get("@encoding").and_then(|v| v.as_str()),
818 Some("UTF-8")
819 );
820 }
821
822 #[tokio::test]
823 async fn collect_segment_as_array_with_xml_decl_preserves_it() {
824 let h = ReassembleXmlFileHandler::new();
827 let tmp = tempfile::tempdir().unwrap();
828 tokio::fs::write(
829 tmp.path().join("a.xml"),
830 r#"<?xml version="1.0" encoding="UTF-8"?><Root><seg><x>1</x></seg></Root>"#,
831 )
832 .await
833 .unwrap();
834 let out = h
835 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
836 .await
837 .unwrap()
838 .unwrap();
839 let obj = out.as_object().unwrap();
840 let decl = obj
841 .get("?xml")
842 .and_then(|v| v.as_object())
843 .expect("?xml declaration must be preserved from source");
844 assert_eq!(decl.get("@version").and_then(|v| v.as_str()), Some("1.0"));
845 assert_eq!(
846 decl.get("@encoding").and_then(|v| v.as_str()),
847 Some("UTF-8")
848 );
849 }
850
851 #[tokio::test]
852 async fn collect_segment_as_array_without_extract_inner_wraps_root() {
853 let h = ReassembleXmlFileHandler::new();
854 let tmp = tempfile::tempdir().unwrap();
855 tokio::fs::write(tmp.path().join("a.xml"), r#"<Root><child>1</child></Root>"#)
856 .await
857 .unwrap();
858 let out = h
859 .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", false)
860 .await
861 .unwrap()
862 .unwrap();
863 let obj = out.as_object().unwrap();
864 assert!(obj.contains_key("?xml"));
865 let root = obj.get("Root").and_then(|r| r.as_object()).unwrap();
866 assert!(root.get("seg").and_then(|v| v.as_array()).is_some());
867 }
868
869 fn rule_with_segment(segment: &str) -> MultiLevelRule {
870 MultiLevelRule {
871 file_pattern: String::new(),
872 root_to_strip: String::new(),
873 unique_id_elements: String::new(),
874 path_segment: segment.to_string(),
875 wrap_root_element: String::new(),
876 wrap_xmlns: String::new(),
877 }
878 }
879
880 #[test]
881 fn deeper_candidate_rules_excludes_the_matched_segment() {
882 let rules = vec![rule_with_segment("seg_a"), rule_with_segment("seg_b")];
886 let deeper = deeper_candidate_rules(&rules, "seg_a");
887 assert_eq!(deeper.len(), 1);
888 assert_eq!(deeper[0].path_segment, "seg_b");
889 }
890
891 #[test]
892 fn deeper_candidate_rules_keeps_all_when_no_segment_matches() {
893 let rules = vec![rule_with_segment("seg_a"), rule_with_segment("seg_b")];
897 let deeper = deeper_candidate_rules(&rules, "missing");
898 assert_eq!(deeper.len(), 2);
899 }
900
901 #[test]
902 fn deeper_candidate_rules_returns_empty_for_empty_input() {
903 let deeper: Vec<MultiLevelRule> = deeper_candidate_rules(&[], "anything");
904 assert!(deeper.is_empty());
905 }
906
907 #[test]
908 fn is_at_base_path_true_when_dir_matches_any_segment() {
909 let segs = vec![
910 ("/base/other".to_string(), "seg1".to_string(), false),
911 ("/base/here".to_string(), "seg2".to_string(), false),
912 ];
913 assert!(is_at_base_path("/base/here", &segs));
914 }
915
916 #[test]
917 fn is_at_base_path_false_when_dir_matches_nothing() {
918 let segs = vec![("/base/a".to_string(), "seg".to_string(), false)];
919 assert!(!is_at_base_path("/base/b", &segs));
920 }
921
922 #[test]
923 fn is_at_base_path_false_for_empty_segments() {
924 let segs: Vec<(String, String, bool)> = Vec::new();
925 assert!(!is_at_base_path("/anywhere", &segs));
926 }
927
928 #[tokio::test]
933 async fn reassemble_plain_some_empty_sidecar_specs_falls_through_to_auto_detect() {
934 let h = ReassembleXmlFileHandler::new();
935 let tmp = tempfile::tempdir().unwrap();
936 let dir = tmp.path().join("mydir");
937 tokio::fs::create_dir(&dir).await.unwrap();
938
939 tokio::fs::write(
940 dir.join("a.xml"),
941 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
942 )
943 .await
944 .unwrap();
945
946 tokio::fs::write(
947 dir.join(".sidecars.json"),
948 r#"[{"element":"Notes","extension":"yaml"}]"#,
949 )
950 .await
951 .unwrap();
952
953 tokio::fs::write(dir.join("mydir.yaml"), "key: value")
955 .await
956 .unwrap();
957
958 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], Some(&[]))
959 .await
960 .unwrap();
961
962 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
963 .await
964 .unwrap();
965 assert!(
966 output.contains("key: value"),
967 "sidecar content missing — auto-detect did not run:\n{output}"
968 );
969 }
970
971 #[test]
974 fn convert_to_format_yaml_to_json() {
975 let yaml = "openapi: 3.0.1\ninfo:\n title: \"Test API\"\n version: 1.0.0\n";
976 let out = convert_to_format(yaml, "json");
977 let val: serde_json::Value = serde_json::from_str(&out).expect("output must be valid JSON");
978 assert_eq!(val["openapi"], "3.0.1");
979 assert_eq!(val["info"]["title"], "Test API");
980 assert_eq!(val["info"]["version"], "1.0.0");
981 }
982
983 #[test]
986 fn convert_to_format_json_to_yaml() {
987 let json = r#"{"key":"value","num":42}"#;
988 let out = convert_to_format(json, "yaml");
989 assert!(
990 serde_json::from_str::<serde_json::Value>(&out).is_err(),
991 "output must be YAML format, not raw JSON: {out}"
992 );
993 let val: serde_json::Value = serde_yaml::from_str(&out).expect("output must be valid YAML");
994 assert_eq!(val["key"], "value");
995 assert_eq!(val["num"], 42);
996 }
997
998 #[test]
999 fn convert_to_format_yml_extension_same_as_yaml() {
1000 let json = r#"{"x":true}"#;
1001 let out = convert_to_format(json, "yml");
1002 let val: serde_json::Value = serde_yaml::from_str(&out).unwrap();
1003 assert_eq!(val["x"], true);
1004 }
1005
1006 #[test]
1007 fn convert_to_format_yaml_passes_through_unchanged() {
1008 let yaml = "title: \"@AuraEnabled\"\nversion: 1.0.0\n";
1010 assert_eq!(convert_to_format(yaml, "yaml"), yaml);
1011 }
1012
1013 #[test]
1014 fn convert_to_format_unknown_extension_passes_through() {
1015 let raw = "arbitrary content";
1016 assert_eq!(convert_to_format(raw, "txt"), raw);
1017 assert_eq!(convert_to_format(raw, ""), raw);
1018 }
1019
1020 #[test]
1021 fn convert_to_format_malformed_falls_back_to_raw() {
1022 let bad = "{{{{ not valid json or yaml at all >>>>>";
1023 assert_eq!(convert_to_format(bad, "json"), bad);
1024 }
1025
1026 #[test]
1027 fn convert_to_format_json_serialize_failure_falls_back_to_raw() {
1028 let yaml_with_sequence_key = "? [a, b]\n: value\n";
1033 assert_eq!(
1034 convert_to_format(yaml_with_sequence_key, "json"),
1035 yaml_with_sequence_key
1036 );
1037 }
1038
1039 #[tokio::test]
1040 async fn reassemble_plain_uses_caller_supplied_sidecar_specs_directly() {
1041 let h = ReassembleXmlFileHandler::new();
1044 let tmp = tempfile::tempdir().unwrap();
1045 let dir = tmp.path().join("mydir");
1046 tokio::fs::create_dir(&dir).await.unwrap();
1047
1048 tokio::fs::write(
1049 dir.join("a.xml"),
1050 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1051 )
1052 .await
1053 .unwrap();
1054 tokio::fs::write(dir.join("mydir.yaml"), "key: value")
1056 .await
1057 .unwrap();
1058
1059 let specs = [SidecarSpec {
1060 element: "Notes".to_string(),
1061 extension: "yaml".to_string(),
1062 original_format: None,
1063 }];
1064 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], Some(&specs))
1065 .await
1066 .unwrap();
1067
1068 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1069 .await
1070 .unwrap();
1071 assert!(
1072 output.contains("key: value"),
1073 "caller-supplied sidecar spec was not used:\n{output}"
1074 );
1075 }
1076
1077 #[tokio::test]
1078 async fn reassemble_plain_ignores_malformed_sidecars_json() {
1079 let h = ReassembleXmlFileHandler::new();
1082 let tmp = tempfile::tempdir().unwrap();
1083 let dir = tmp.path().join("mydir");
1084 tokio::fs::create_dir(&dir).await.unwrap();
1085
1086 tokio::fs::write(
1087 dir.join("a.xml"),
1088 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1089 )
1090 .await
1091 .unwrap();
1092 tokio::fs::write(dir.join(".sidecars.json"), "not valid json")
1093 .await
1094 .unwrap();
1095
1096 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], None)
1097 .await
1098 .unwrap();
1099
1100 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1101 .await
1102 .unwrap();
1103 assert!(output.contains("Child"), "reassembly must still succeed");
1104 }
1105
1106 #[tokio::test]
1107 async fn reassemble_plain_post_purge_removes_sidecar_files() {
1108 let h = ReassembleXmlFileHandler::new();
1111 let tmp = tempfile::tempdir().unwrap();
1112 let dir = tmp.path().join("mydir");
1113 tokio::fs::create_dir(&dir).await.unwrap();
1114
1115 tokio::fs::write(
1116 dir.join("a.xml"),
1117 r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1118 )
1119 .await
1120 .unwrap();
1121 let sidecar_path = dir.join("mydir.yaml");
1122 tokio::fs::write(&sidecar_path, "key: value").await.unwrap();
1123
1124 let specs = [SidecarSpec {
1125 element: "Notes".to_string(),
1126 extension: "yaml".to_string(),
1127 original_format: None,
1128 }];
1129 h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), true, &[], Some(&specs))
1130 .await
1131 .unwrap();
1132
1133 assert!(!dir.exists(), "post_purge must remove the disassembled dir");
1134 let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1135 .await
1136 .unwrap();
1137 assert!(output.contains("key: value"));
1138 }
1139
1140 #[tokio::test]
1141 async fn inject_sidecar_elements_ok_when_no_root_key() {
1142 let mut merged: XmlElement = json!({ "?xml": { "@version": "1.0" } });
1145 let specs = [SidecarSpec {
1146 element: "Notes".to_string(),
1147 extension: "yaml".to_string(),
1148 original_format: None,
1149 }];
1150 inject_sidecar_elements("does-not-matter", &mut merged, &specs)
1151 .await
1152 .unwrap();
1153 assert_eq!(merged, json!({ "?xml": { "@version": "1.0" } }));
1154 }
1155
1156 #[tokio::test]
1157 async fn inject_sidecar_elements_skips_missing_sidecar_file() {
1158 let tmp = tempfile::tempdir().unwrap();
1161 let dir = tmp.path().join("mydir");
1162 tokio::fs::create_dir(&dir).await.unwrap();
1163 let mut merged: XmlElement = json!({ "Root": { "Child": { "#text": "hello" } } });
1164 let specs = [SidecarSpec {
1165 element: "Notes".to_string(),
1166 extension: "yaml".to_string(),
1167 original_format: None,
1168 }];
1169 inject_sidecar_elements(dir.to_str().unwrap(), &mut merged, &specs)
1170 .await
1171 .unwrap();
1172 assert_eq!(merged, json!({ "Root": { "Child": { "#text": "hello" } } }));
1173 }
1174}