1use crate::xml::builders::{build_disassembled_files_unified, build_xml_string};
4use crate::xml::multi_level::{
5 capture_xmlns_from_root, path_segment_from_file_pattern, save_multi_level_config,
6 strip_root_and_build_xml,
7};
8use crate::xml::parsers::{extract_xml_declaration_from_raw, parse_xml, parse_xml_from_str};
9use crate::xml::types::{
10 BuildDisassembledFilesOptions, DecomposeRule, MultiLevelRule, SidecarSpec,
11};
12use crate::xml::utils::normalize_path_unix;
13use ignore::gitignore::GitignoreBuilder;
14use std::io::Write as _;
15use std::path::Path;
16use std::sync::Arc;
17use tokio::fs;
18use tokio::sync::Semaphore;
19use tokio::task::JoinSet;
20
21const DIRECTORY_CONCURRENCY: usize = 16;
27
28pub struct DisassembleXmlFileHandler {
29 ign: Option<ignore::gitignore::Gitignore>,
30}
31
32impl DisassembleXmlFileHandler {
33 pub fn new() -> Self {
34 Self { ign: None }
35 }
36
37 async fn load_ignore_rules(&mut self, ignore_path: &str) {
38 let path = Path::new(ignore_path);
39 let content = match fs::read_to_string(path).await {
40 Ok(c) => c,
41 Err(_) => return,
42 };
43 let root = path.parent().unwrap_or(Path::new("."));
44 let mut builder = GitignoreBuilder::new(root);
45 for line in content.lines() {
46 let _ = builder.add_line(None, line);
47 }
48 self.ign = builder.build().ok();
50 }
51
52 fn posix_path(path: &str) -> String {
53 path.replace('\\', "/")
54 }
55
56 fn is_xml_file(file_path: &str) -> bool {
57 file_path.to_lowercase().ends_with(".xml")
58 }
59
60 fn is_processable_xml_entry(is_file: bool, file_name: &str) -> bool {
65 is_file && Self::is_xml_file(file_name)
66 }
67
68 fn should_pre_purge_output(pre_purge: bool, output_exists: bool) -> bool {
73 pre_purge && output_exists
74 }
75
76 fn file_matches_multi_level_rule(file_name: &str, full_path: &str, file_pattern: &str) -> bool {
80 file_name.ends_with(".xml")
81 && (file_name.contains(file_pattern) || full_path.contains(file_pattern))
82 }
83
84 fn has_element_to_strip(parsed: &serde_json::Value, root_to_strip: &str) -> bool {
88 parsed
89 .as_object()
90 .and_then(|o| {
91 let root_key = o.keys().find(|k| *k != "?xml")?;
92 let root_val = o.get(root_key)?.as_object()?;
93 Some(root_key == root_to_strip || root_val.contains_key(root_to_strip))
94 })
95 .unwrap_or(false)
96 }
97
98 fn rules_have_same_identity(a: &MultiLevelRule, b: &MultiLevelRule) -> bool {
104 a.file_pattern == b.file_pattern && a.root_to_strip == b.root_to_strip
105 }
106
107 fn root_element_name_from_parsed(parsed: &serde_json::Value, fallback: &str) -> String {
112 parsed
113 .as_object()
114 .and_then(|o| o.keys().find(|k| *k != "?xml").cloned())
115 .unwrap_or_else(|| fallback.to_string())
116 }
117
118 fn is_ignored(&self, path: &str) -> bool {
119 self.ign
120 .as_ref()
121 .map(|ign| ign.matched(path, false).is_ignore())
122 .unwrap_or(false)
123 }
124
125 fn output_dir_basename(file_stem: &str) -> &str {
135 file_stem
136 .rsplit_once('.')
137 .map(|(prefix, _)| prefix)
138 .unwrap_or(file_stem)
139 }
140
141 #[allow(clippy::too_many_arguments)]
142 pub async fn disassemble(
143 &mut self,
144 file_path: &str,
145 unique_id_elements: Option<&str>,
146 strategy: Option<&str>,
147 pre_purge: bool,
148 post_purge: bool,
149 ignore_path: &str,
150 format: &str,
151 multi_level_rules: Option<&[MultiLevelRule]>,
152 decompose_rules: Option<&[DecomposeRule]>,
153 sidecar_specs: Option<&[SidecarSpec]>,
154 base_dir: Option<&str>,
155 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
156 let strategy = strategy.unwrap_or("unique-id");
157 let strategy = if ["unique-id", "grouped-by-tag"].contains(&strategy) {
158 strategy
159 } else {
160 log::warn!(
161 "Unsupported strategy \"{}\", defaulting to \"unique-id\".",
162 strategy
163 );
164 "unique-id"
165 };
166
167 self.load_ignore_rules(ignore_path).await;
168
169 let path = Path::new(file_path);
170 let meta = fs::metadata(path).await?;
171 let cwd = Self::resolve_base_dir(base_dir);
172 let relative_path = path.strip_prefix(&cwd).unwrap_or(path).to_string_lossy();
173 let relative_path = Self::posix_path(&relative_path);
174
175 let multi_level_rules = multi_level_rules.filter(|rules| !rules.is_empty());
177
178 if meta.is_file() {
179 self.handle_file(
180 file_path,
181 &relative_path,
182 unique_id_elements,
183 strategy,
184 pre_purge,
185 post_purge,
186 format,
187 multi_level_rules,
188 decompose_rules,
189 sidecar_specs,
190 )
191 .await?;
192 } else {
193 self.handle_directory(
196 file_path,
197 unique_id_elements,
198 strategy,
199 pre_purge,
200 post_purge,
201 format,
202 multi_level_rules,
203 decompose_rules,
204 sidecar_specs,
205 base_dir,
206 )
207 .await?;
208 }
209
210 Ok(())
211 }
212
213 fn resolve_base_dir(base_dir: Option<&str>) -> std::path::PathBuf {
219 match base_dir {
220 Some(dir) => Path::new(dir).to_path_buf(),
221 None => std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf()),
222 }
223 }
224
225 #[allow(clippy::too_many_arguments)]
226 async fn handle_file(
227 &self,
228 file_path: &str,
229 relative_path: &str,
230 unique_id_elements: Option<&str>,
231 strategy: &str,
232 pre_purge: bool,
233 post_purge: bool,
234 format: &str,
235 multi_level_rules: Option<&[MultiLevelRule]>,
236 decompose_rules: Option<&[DecomposeRule]>,
237 sidecar_specs: Option<&[SidecarSpec]>,
238 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
239 let resolved = Path::new(file_path)
240 .canonicalize()
241 .unwrap_or_else(|_| Path::new(file_path).to_path_buf());
242 let resolved_str = normalize_path_unix(&resolved.to_string_lossy());
243
244 if !Self::is_xml_file(&resolved_str) {
245 log::error!(
246 "The file path provided is not an XML file: {}",
247 resolved_str
248 );
249 return Ok(());
250 }
251
252 if self.is_ignored(relative_path) {
253 log::warn!("File ignored by ignore rules: {}", resolved_str);
254 return Ok(());
255 }
256
257 let dir_path = resolved.parent().unwrap_or(Path::new("."));
258 let dir_path_str = normalize_path_unix(&dir_path.to_string_lossy());
259 Self::process_file(
260 dir_path_str,
261 strategy.to_string(),
262 resolved_str,
263 unique_id_elements.map(str::to_string),
264 pre_purge,
265 post_purge,
266 format.to_string(),
267 multi_level_rules.map(<[MultiLevelRule]>::to_vec),
268 decompose_rules.map(<[DecomposeRule]>::to_vec),
269 sidecar_specs.map(<[SidecarSpec]>::to_vec),
270 )
271 .await
272 }
273
274 #[allow(clippy::too_many_arguments)]
275 async fn handle_directory(
276 &self,
277 dir_path: &str,
278 unique_id_elements: Option<&str>,
279 strategy: &str,
280 pre_purge: bool,
281 post_purge: bool,
282 format: &str,
283 multi_level_rules: Option<&[MultiLevelRule]>,
284 decompose_rules: Option<&[DecomposeRule]>,
285 sidecar_specs: Option<&[SidecarSpec]>,
286 base_dir: Option<&str>,
287 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
288 let dir_path = normalize_path_unix(dir_path);
289 let mut entries = fs::read_dir(&dir_path).await?;
290 let cwd = Self::resolve_base_dir(base_dir);
291
292 let mut files_to_process: Vec<String> = Vec::new();
295 while let Some(entry) = entries.next_entry().await? {
296 let sub_path = entry.path();
297 let sub_file_path = sub_path.to_string_lossy();
298 let relative_sub = sub_path
299 .strip_prefix(&cwd)
300 .unwrap_or(&sub_path)
301 .to_string_lossy();
302 let relative_sub = Self::posix_path(&relative_sub);
303
304 if !Self::is_processable_xml_entry(sub_path.is_file(), &sub_file_path) {
305 continue;
306 }
307 if self.is_ignored(&relative_sub) {
308 log::warn!("File ignored by ignore rules: {}", sub_file_path);
309 continue;
310 }
311 files_to_process.push(normalize_path_unix(&sub_file_path));
312 }
313
314 if files_to_process.len() <= 1 {
320 for file_path in files_to_process {
321 Self::process_file(
322 dir_path.clone(),
323 strategy.to_string(),
324 file_path,
325 unique_id_elements.map(str::to_string),
326 pre_purge,
327 post_purge,
328 format.to_string(),
329 multi_level_rules.map(<[MultiLevelRule]>::to_vec),
330 decompose_rules.map(<[DecomposeRule]>::to_vec),
331 sidecar_specs.map(<[SidecarSpec]>::to_vec),
332 )
333 .await?;
334 }
335 return Ok(());
336 }
337
338 let semaphore = Arc::new(Semaphore::new(DIRECTORY_CONCURRENCY));
346 let mut join_set = JoinSet::new();
347 for file_path in files_to_process {
348 let dir_path = dir_path.clone();
349 let strategy = strategy.to_string();
350 let unique_id_elements = unique_id_elements.map(str::to_string);
351 let format = format.to_string();
352 let multi_level_rules = multi_level_rules.map(<[MultiLevelRule]>::to_vec);
353 let decompose_rules = decompose_rules.map(<[DecomposeRule]>::to_vec);
354 let sidecar_specs = sidecar_specs.map(<[SidecarSpec]>::to_vec);
355 let semaphore = Arc::clone(&semaphore);
356
357 join_set.spawn(async move {
358 let _permit = semaphore.acquire_owned().await.expect("semaphore closed");
359 Self::process_file(
360 dir_path,
361 strategy,
362 file_path,
363 unique_id_elements,
364 pre_purge,
365 post_purge,
366 format,
367 multi_level_rules,
368 decompose_rules,
369 sidecar_specs,
370 )
371 .await
372 });
373 }
374
375 while let Some(joined) = join_set.join_next().await {
376 joined.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)??;
377 }
378
379 Ok(())
380 }
381
382 #[allow(clippy::too_many_arguments)]
383 async fn process_file(
384 dir_path: String,
385 strategy: String,
386 file_path: String,
387 unique_id_elements: Option<String>,
388 pre_purge: bool,
389 post_purge: bool,
390 format: String,
391 multi_level_rules: Option<Vec<MultiLevelRule>>,
392 decompose_rules: Option<Vec<DecomposeRule>>,
393 sidecar_specs: Option<Vec<SidecarSpec>>,
394 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
395 let dir_path = dir_path.as_str();
399 let file_path = file_path.as_str();
400 let strategy = strategy.as_str();
401 let format = format.as_str();
402 let unique_id_elements = unique_id_elements.as_deref();
403 let multi_level_rules = multi_level_rules.as_deref();
404 let decompose_rules = decompose_rules.as_deref();
405 let sidecar_specs = sidecar_specs.as_deref();
406
407 log::debug!("Parsing file to disassemble: {}", file_path);
408
409 let file_name = Path::new(file_path)
410 .file_stem()
411 .and_then(|s| s.to_str())
412 .unwrap_or("output");
413 let base_name = Self::output_dir_basename(file_name);
414 let output_path = Path::new(dir_path).join(base_name);
415
416 if Self::should_pre_purge_output(pre_purge, output_path.exists()) {
417 fs::remove_dir_all(&output_path).await.ok();
418 }
419
420 let pre_extraction_key_order: Option<Vec<String>> =
423 if sidecar_specs.is_some_and(|s| !s.is_empty()) {
424 parse_xml(file_path).await.and_then(|parsed| {
425 let obj = parsed.as_object()?;
426 let root_key = obj.keys().find(|k| *k != "?xml")?;
427 obj.get(root_key)?.as_object().map(|root_obj| {
428 root_obj
429 .keys()
430 .filter(|k| !k.starts_with('@'))
431 .cloned()
432 .collect()
433 })
434 })
435 } else {
436 None
437 };
438
439 let extraction_result = if let Some(specs) = sidecar_specs {
445 if !specs.is_empty() {
446 extract_sidecar_elements(file_path, specs).await?
447 } else {
448 None
449 }
450 } else {
451 None
452 };
453
454 let temp_file: Option<tempfile::NamedTempFile>;
455 let disassemble_path: &str;
456 if let Some((xml, _)) = &extraction_result {
457 let mut tmp = tempfile::Builder::new()
458 .suffix(".xml")
459 .tempfile_in(Path::new(file_path).parent().unwrap_or(Path::new(".")))?;
460 tmp.write_all(xml.as_bytes())?;
461 temp_file = Some(tmp);
462 disassemble_path = temp_file
463 .as_ref()
464 .unwrap()
465 .path()
466 .to_str()
467 .unwrap_or(file_path);
468 } else {
469 temp_file = None;
470 disassemble_path = file_path;
471 }
472
473 build_disassembled_files_unified(BuildDisassembledFilesOptions {
474 file_path: disassemble_path,
475 disassembled_path: output_path.to_str().unwrap_or("."),
476 base_name: file_name,
477 post_purge,
478 format,
479 unique_id_elements,
480 strategy,
481 decompose_rules,
482 })
483 .await?;
484
485 drop(temp_file); if let Some((_, sidecars)) = &extraction_result {
490 for (_, extension, content, _) in sidecars {
491 let sidecar_path = output_path.join(format!("{}.{}", base_name, extension));
492 fs::write(&sidecar_path, content).await?;
493 }
494 if let Some(specs) = sidecar_specs {
495 let enriched: Vec<SidecarSpec> = specs
498 .iter()
499 .map(|spec| {
500 let original_format = sidecars
501 .iter()
502 .find(|(el, _, _, _)| el == &spec.element)
503 .and_then(|(_, _, _, fmt)| fmt.clone());
504 SidecarSpec {
505 element: spec.element.clone(),
506 extension: spec.extension.clone(),
507 original_format,
508 }
509 })
510 .collect();
511 if let Ok(json) = serde_json::to_string(&enriched) {
512 let _ = fs::write(output_path.join(".sidecars.json"), json).await;
513 }
514 }
515 }
516
517 if let Some(full_order) = pre_extraction_key_order {
520 let key_order_path = output_path.join(".key_order.json");
521 if let Ok(json) = serde_json::to_string(&full_order) {
522 let _ = fs::write(&key_order_path, json).await;
523 }
524 }
525
526 if let Some(rules) = multi_level_rules {
530 for rule in rules {
531 Self::recursively_disassemble_multi_level(&output_path, rule, format).await?;
532 }
533 }
534
535 Ok(())
536 }
537
538 async fn recursively_disassemble_multi_level(
541 dir_path: &Path,
542 rule: &MultiLevelRule,
543 format: &str,
544 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
545 let mut config = crate::xml::multi_level::load_multi_level_config(dir_path)
546 .await
547 .unwrap_or_default();
548
549 let mut stack = vec![dir_path.to_path_buf()];
550 while let Some(current) = stack.pop() {
551 let mut entries = Vec::new();
552 let mut read_dir = fs::read_dir(¤t).await?;
553 while let Some(entry) = read_dir.next_entry().await? {
554 entries.push(entry);
555 }
556
557 for entry in entries {
558 let path = entry.path();
559 let path_str = path.to_string_lossy().to_string();
560
561 if path.is_dir() {
562 stack.push(path);
563 continue;
564 }
565 {
567 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
568 let path_str_check = path.to_string_lossy();
569 if !Self::file_matches_multi_level_rule(
570 name,
571 &path_str_check,
572 &rule.file_pattern,
573 ) {
574 continue;
575 }
576
577 let parsed = match parse_xml(&path_str).await {
578 Some(p) => p,
579 None => continue,
580 };
581 if !Self::has_element_to_strip(&parsed, &rule.root_to_strip) {
582 continue;
583 }
584
585 let wrap_xmlns = capture_xmlns_from_root(&parsed).unwrap_or_default();
586
587 let stripped_xml = match strip_root_and_build_xml(&parsed, &rule.root_to_strip)
588 {
589 Some(xml) => xml,
590 None => continue,
591 };
592
593 fs::write(&path, stripped_xml).await?;
594
595 let file_stem = path
596 .file_stem()
597 .and_then(|s| s.to_str())
598 .unwrap_or("output");
599 let output_dir_name = Self::output_dir_basename(file_stem);
600 let parent = path.parent().unwrap_or(dir_path);
601 let second_level_output = parent.join(output_dir_name);
602
603 build_disassembled_files_unified(BuildDisassembledFilesOptions {
604 file_path: &path_str,
605 disassembled_path: second_level_output.to_str().unwrap_or("."),
606 base_name: output_dir_name,
607 post_purge: true,
608 format,
609 unique_id_elements: Some(&rule.unique_id_elements),
610 strategy: "unique-id",
611 decompose_rules: None,
612 })
613 .await?;
614
615 let existing_idx = config
619 .rules
620 .iter()
621 .position(|r| Self::rules_have_same_identity(r, rule));
622 match existing_idx {
623 None => {
624 let wrap_root = Self::root_element_name_from_parsed(
625 &parsed,
626 &rule.wrap_root_element,
627 );
628 let path_segment = if rule.path_segment.is_empty() {
629 path_segment_from_file_pattern(&rule.file_pattern)
630 } else {
631 rule.path_segment.clone()
632 };
633 let stored_xmlns = if rule.wrap_xmlns.is_empty() {
634 wrap_xmlns
635 } else {
636 rule.wrap_xmlns.clone()
637 };
638 config.rules.push(MultiLevelRule {
639 file_pattern: rule.file_pattern.clone(),
640 root_to_strip: rule.root_to_strip.clone(),
641 unique_id_elements: rule.unique_id_elements.clone(),
642 path_segment,
643 wrap_root_element: wrap_root,
646 wrap_xmlns: stored_xmlns,
647 });
648 }
649 Some(idx) => {
650 if config.rules[idx].wrap_xmlns.is_empty() {
653 config.rules[idx].wrap_xmlns = wrap_xmlns;
654 }
655 }
656 }
657 }
658 }
659 }
660
661 if !config.rules.is_empty() {
662 save_multi_level_config(dir_path, &config).await?;
663 }
664
665 Ok(())
666 }
667}
668
669impl Default for DisassembleXmlFileHandler {
670 fn default() -> Self {
671 Self::new()
672 }
673}
674
675async fn extract_sidecar_elements(
687 file_path: &str,
688 specs: &[SidecarSpec],
689) -> Result<
690 Option<(String, Vec<(String, String, String, Option<String>)>)>,
691 Box<dyn std::error::Error + Send + Sync>,
692> {
693 let raw = fs::read_to_string(file_path).await?;
694 let Some(mut parsed) = parse_xml_from_str(&raw, file_path) else {
695 return Ok(None);
696 };
697
698 if let (Some(obj), Some(decl)) = (
703 parsed.as_object_mut(),
704 extract_xml_declaration_from_raw(&raw),
705 ) {
706 obj.insert("?xml".to_string(), decl);
707 }
708
709 let root_key = parsed
710 .as_object()
711 .and_then(|o| o.keys().find(|k| *k != "?xml").cloned());
712 let Some(root_key) = root_key else {
713 return Ok(None);
714 };
715
716 let mut sidecars: Vec<(String, String, String, Option<String>)> = Vec::new();
718 if let Some(root_val) = parsed.as_object_mut().and_then(|o| o.get_mut(&root_key)) {
719 if let Some(root_obj) = root_val.as_object_mut() {
720 for spec in specs {
721 let Some(elem_val) = root_obj.remove(&spec.element) else {
722 continue;
723 };
724 let text = match &elem_val {
727 serde_json::Value::Object(obj) => obj
728 .get("#text")
729 .and_then(|v| v.as_str())
730 .unwrap_or("")
731 .to_string(),
732 _ => {
733 root_obj.insert(spec.element.clone(), elem_val);
734 continue;
735 }
736 };
737 let original_format = detect_content_format(&text);
738 sidecars.push((
739 spec.element.clone(),
740 spec.extension.clone(),
741 convert_sidecar_content(&text, &spec.extension),
742 original_format,
743 ));
744 }
745 }
746 }
747
748 if sidecars.is_empty() {
749 Ok(None)
750 } else {
751 Ok(Some((build_xml_string(&parsed), sidecars)))
752 }
753}
754
755fn detect_content_format(text: &str) -> Option<String> {
758 if serde_json::from_str::<serde_json::Value>(text).is_ok() {
759 Some("json".to_string())
760 } else if serde_saphyr::from_str::<serde_json::Value>(text).is_ok() {
761 Some("yaml".to_string())
762 } else {
763 None
764 }
765}
766
767fn convert_sidecar_content(text: &str, extension: &str) -> String {
776 match extension.to_ascii_lowercase().as_str() {
777 "json" => match serde_saphyr::from_str::<serde_json::Value>(text) {
778 Ok(val) => serde_json::to_string_pretty(&val).unwrap_or_else(|_| text.to_string()),
780 Err(e) => {
781 log::warn!(
782 "sidecar: could not parse content for JSON conversion ({e}); using raw text"
783 );
784 text.to_string()
785 }
786 },
787 "yaml" | "yml" => {
788 if serde_json::from_str::<serde_json::Value>(text).is_ok() {
791 match serde_json::from_str::<serde_json::Value>(text)
792 .ok()
793 .and_then(|v| serde_saphyr::to_string(&v).ok())
794 {
795 Some(yaml) => yaml,
796 None => {
797 log::warn!("sidecar: YAML serialization failed; using raw text");
798 text.to_string()
799 }
800 }
801 } else {
802 text.to_string()
803 }
804 }
805 _ => text.to_string(),
806 }
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812
813 #[test]
814 #[allow(clippy::default_constructed_unit_structs)]
815 fn disassemble_handler_default_equals_new() {
816 let _ = DisassembleXmlFileHandler::default();
817 }
818
819 #[test]
820 fn is_xml_file_matches_case_insensitively() {
821 assert!(DisassembleXmlFileHandler::is_xml_file("foo.xml"));
822 assert!(DisassembleXmlFileHandler::is_xml_file("BAR.XML"));
823 assert!(!DisassembleXmlFileHandler::is_xml_file("foo.txt"));
824 }
825
826 #[test]
827 fn posix_path_converts_backslashes() {
828 assert_eq!(
829 DisassembleXmlFileHandler::posix_path(r"C:\Users\name\file.xml"),
830 "C:/Users/name/file.xml"
831 );
832 }
833
834 #[tokio::test]
835 async fn load_ignore_rules_noop_when_path_missing() {
836 let mut handler = DisassembleXmlFileHandler::new();
837 handler
838 .load_ignore_rules("/definitely/does/not/exist/.ignore")
839 .await;
840 assert!(handler.ign.is_none());
841 }
842
843 #[tokio::test]
844 async fn load_ignore_rules_builds_matcher() {
845 let temp = tempfile::tempdir().unwrap();
846 let path = temp.path().join(".ignore");
847 tokio::fs::write(&path, "*.xml\n").await.unwrap();
848 let mut handler = DisassembleXmlFileHandler::new();
849 handler.load_ignore_rules(path.to_str().unwrap()).await;
850 assert!(handler.ign.is_some());
851 assert!(handler.is_ignored("file.xml"));
852 assert!(!handler.is_ignored("file.txt"));
853 }
854
855 #[test]
856 fn is_ignored_default_false_without_rules() {
857 let handler = DisassembleXmlFileHandler::new();
858 assert!(!handler.is_ignored("some/path.xml"));
859 }
860
861 #[test]
862 fn output_dir_basename_strips_only_last_dot_segment() {
863 assert_eq!(
865 DisassembleXmlFileHandler::output_dir_basename("HR_Admin.permissionset-meta"),
866 "HR_Admin"
867 );
868 assert_eq!(
869 DisassembleXmlFileHandler::output_dir_basename("Get_Info.flow-meta"),
870 "Get_Info"
871 );
872 }
873
874 #[test]
875 fn output_dir_basename_preserves_dotted_full_names() {
876 assert_eq!(
881 DisassembleXmlFileHandler::output_dir_basename(
882 "Account_Merge__c.New_Account_Merges_2.approvalProcess-meta"
883 ),
884 "Account_Merge__c.New_Account_Merges_2"
885 );
886 assert_eq!(
887 DisassembleXmlFileHandler::output_dir_basename(
888 "Account_Merge__c.New_Account_Merges_3.approvalProcess-meta"
889 ),
890 "Account_Merge__c.New_Account_Merges_3"
891 );
892 assert_eq!(
894 DisassembleXmlFileHandler::output_dir_basename("Case.LogACall.quickAction-meta"),
895 "Case.LogACall"
896 );
897 }
898
899 #[test]
900 fn is_processable_xml_entry_true_only_for_regular_xml_files() {
901 assert!(DisassembleXmlFileHandler::is_processable_xml_entry(
905 true, "foo.xml"
906 ));
907 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
908 false, "foo.xml"
909 ));
910 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
911 true, "foo.txt"
912 ));
913 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
914 false, "foo.txt"
915 ));
916 }
917
918 #[test]
919 fn should_pre_purge_output_requires_both_flag_and_existing_dir() {
920 assert!(DisassembleXmlFileHandler::should_pre_purge_output(
924 true, true
925 ));
926 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
927 true, false
928 ));
929 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
930 false, true
931 ));
932 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
933 false, false
934 ));
935 }
936
937 #[test]
938 fn file_matches_multi_level_rule_requires_xml_extension() {
939 assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
941 "Foo.txt",
942 "/dir/Foo.txt",
943 "Foo"
944 ));
945 }
946
947 #[test]
948 fn file_matches_multi_level_rule_when_filename_contains_pattern() {
949 assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
950 "MyPattern.xml",
951 "/dir/MyPattern.xml",
952 "MyPattern"
953 ));
954 }
955
956 #[test]
957 fn file_matches_multi_level_rule_when_only_full_path_contains_pattern() {
958 assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
961 "child.xml",
962 "/parentPattern/child.xml",
963 "parentPattern"
964 ));
965 }
966
967 #[test]
968 fn file_matches_multi_level_rule_false_when_pattern_absent_everywhere() {
969 assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
970 "Foo.xml",
971 "/dir/Foo.xml",
972 "MissingPattern"
973 ));
974 }
975
976 #[test]
977 fn has_element_to_strip_when_root_key_matches() {
978 let parsed = serde_json::json!({"Foo": {"a": "b"}});
979 assert!(DisassembleXmlFileHandler::has_element_to_strip(
980 &parsed, "Foo"
981 ));
982 }
983
984 #[test]
985 fn has_element_to_strip_when_root_contains_target_child() {
986 let parsed = serde_json::json!({"Foo": {"Bar": {"a": "b"}}});
987 assert!(DisassembleXmlFileHandler::has_element_to_strip(
988 &parsed, "Bar"
989 ));
990 }
991
992 #[test]
993 fn has_element_to_strip_false_when_target_absent() {
994 let parsed = serde_json::json!({"Foo": {"a": "b"}});
995 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
996 &parsed, "Missing"
997 ));
998 }
999
1000 #[test]
1001 fn has_element_to_strip_false_for_non_object_or_decl_only() {
1002 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
1003 &serde_json::json!("primitive"),
1004 "Foo"
1005 ));
1006 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
1007 &serde_json::json!({"?xml": {}}),
1008 "Foo"
1009 ));
1010 }
1011
1012 fn rule(pattern: &str, root: &str) -> MultiLevelRule {
1013 MultiLevelRule {
1014 file_pattern: pattern.to_string(),
1015 root_to_strip: root.to_string(),
1016 unique_id_elements: String::new(),
1017 path_segment: String::new(),
1018 wrap_root_element: String::new(),
1019 wrap_xmlns: String::new(),
1020 }
1021 }
1022
1023 #[test]
1024 fn rules_share_identity_when_pattern_and_root_match() {
1025 assert!(DisassembleXmlFileHandler::rules_have_same_identity(
1026 &rule("p", "R"),
1027 &rule("p", "R"),
1028 ));
1029 }
1030
1031 #[test]
1032 fn rules_differ_when_file_pattern_differs() {
1033 assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
1034 &rule("p1", "R"),
1035 &rule("p2", "R"),
1036 ));
1037 }
1038
1039 #[test]
1040 fn rules_differ_when_root_to_strip_differs() {
1041 assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
1042 &rule("p", "R1"),
1043 &rule("p", "R2"),
1044 ));
1045 }
1046
1047 #[test]
1048 fn root_element_name_finds_first_non_declaration_key() {
1049 let parsed = serde_json::json!({"?xml": {}, "MyRoot": {"a": "b"}});
1050 assert_eq!(
1051 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "fallback"),
1052 "MyRoot"
1053 );
1054 }
1055
1056 #[test]
1057 fn root_element_name_falls_back_when_only_declaration_present() {
1058 let parsed = serde_json::json!({"?xml": {}});
1059 assert_eq!(
1060 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "FallbackRoot"),
1061 "FallbackRoot"
1062 );
1063 }
1064
1065 #[test]
1066 fn root_element_name_falls_back_for_non_object() {
1067 let parsed = serde_json::json!("primitive");
1068 assert_eq!(
1069 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "Fb"),
1070 "Fb"
1071 );
1072 }
1073
1074 #[test]
1075 fn output_dir_basename_no_dot_returns_stem_unchanged() {
1076 assert_eq!(DisassembleXmlFileHandler::output_dir_basename("Foo"), "Foo");
1078 assert_eq!(DisassembleXmlFileHandler::output_dir_basename(""), "");
1079 }
1080
1081 #[test]
1082 fn convert_sidecar_content_yaml_to_json() {
1083 let yaml = "openapi: 3.0.1\ninfo:\n title: \"@AuraEnabled Apex method APIs\"\n version: 1.0.0\npaths:\n /uploadFile:\n post:\n operationId: uploadFile\n responses:\n \"200\":\n description: OK\n";
1087 let out = convert_sidecar_content(yaml, "json");
1088 let val: serde_json::Value = serde_json::from_str(&out).expect("output must be valid JSON");
1089 assert_eq!(val["openapi"], "3.0.1");
1090 assert_eq!(val["info"]["title"], "@AuraEnabled Apex method APIs");
1091 assert_eq!(val["info"]["version"], "1.0.0");
1092 assert_eq!(
1093 val["paths"]["/uploadFile"]["post"]["operationId"],
1094 "uploadFile"
1095 );
1096 }
1097
1098 #[test]
1099 fn convert_sidecar_content_json_to_yaml() {
1100 let json = r#"{"key":"value","num":42}"#;
1101 let out = convert_sidecar_content(json, "yaml");
1102 assert!(
1106 serde_json::from_str::<serde_json::Value>(&out).is_err(),
1107 "output must be YAML format, not raw JSON: {out}"
1108 );
1109 let val: serde_json::Value =
1110 serde_saphyr::from_str(&out).expect("output must be valid YAML");
1111 assert_eq!(val["key"], "value");
1112 assert_eq!(val["num"], 42);
1113 }
1114
1115 #[test]
1116 fn convert_sidecar_content_json_to_json_prettifies() {
1117 let compact = r#"{"a":1}"#;
1118 let out = convert_sidecar_content(compact, "json");
1119 assert!(out.contains('\n'), "expected pretty JSON, got: {out}");
1121 let val: serde_json::Value = serde_json::from_str(&out).unwrap();
1122 assert_eq!(val["a"], 1);
1123 }
1124
1125 #[test]
1126 fn convert_sidecar_content_unknown_extension_passes_through() {
1127 let raw = "arbitrary: content: here";
1128 assert_eq!(convert_sidecar_content(raw, "txt"), raw);
1129 assert_eq!(convert_sidecar_content(raw, ""), raw);
1130 }
1131
1132 #[test]
1133 fn convert_sidecar_content_malformed_falls_back_to_raw() {
1134 let bad = "{{{{ not valid json or yaml at all >>>>>";
1136 assert_eq!(convert_sidecar_content(bad, "json"), bad);
1137 assert_eq!(convert_sidecar_content(bad, "yaml"), bad);
1138 }
1139
1140 #[test]
1141 fn convert_sidecar_content_yml_extension_same_as_yaml() {
1142 let json = r#"{"x":true}"#;
1143 let out = convert_sidecar_content(json, "yml");
1144 let val: serde_json::Value = serde_saphyr::from_str(&out).unwrap();
1145 assert_eq!(val["x"], true);
1146 }
1147
1148 #[test]
1149 fn convert_sidecar_content_yaml_passes_through_unchanged() {
1150 let yaml = "title: \"@AuraEnabled Apex method APIs\"\nversion: 1.0.0\n";
1153 assert_eq!(convert_sidecar_content(yaml, "yaml"), yaml);
1154 assert_eq!(convert_sidecar_content(yaml, "yml"), yaml);
1155 }
1156
1157 #[test]
1158 fn detect_content_format_recognizes_json_yaml_and_neither() {
1159 assert_eq!(
1160 detect_content_format(r#"{"a":1}"#),
1161 Some("json".to_string())
1162 );
1163 assert_eq!(
1164 detect_content_format("a: 1\nb: 2\n"),
1165 Some("yaml".to_string())
1166 );
1167 assert_eq!(detect_content_format("\tnot: \tvalid: \tanything"), None);
1169 }
1170
1171 #[test]
1172 fn convert_sidecar_content_non_string_key_falls_back_to_raw() {
1173 let yaml_with_sequence_key = "? [a, b]\n: value\n";
1177 assert_eq!(
1178 convert_sidecar_content(yaml_with_sequence_key, "json"),
1179 yaml_with_sequence_key
1180 );
1181 }
1182
1183 #[tokio::test]
1184 async fn extract_sidecar_elements_returns_none_for_unparseable_xml() {
1185 let tmp = tempfile::tempdir().unwrap();
1186 let path = tmp.path().join("bad.xml");
1187 tokio::fs::write(&path, "<<not xml").await.unwrap();
1188 let specs = [SidecarSpec {
1189 element: "schema".to_string(),
1190 extension: "yaml".to_string(),
1191 original_format: None,
1192 }];
1193 let result = extract_sidecar_elements(path.to_str().unwrap(), &specs)
1194 .await
1195 .unwrap();
1196 assert!(result.is_none());
1197 }
1198
1199 #[tokio::test]
1200 async fn extract_sidecar_elements_returns_none_when_no_root_element() {
1201 let tmp = tempfile::tempdir().unwrap();
1202 let path = tmp.path().join("decl_only.xml");
1203 tokio::fs::write(&path, r#"<?xml version="1.0" encoding="UTF-8"?>"#)
1204 .await
1205 .unwrap();
1206 let specs = [SidecarSpec {
1207 element: "schema".to_string(),
1208 extension: "yaml".to_string(),
1209 original_format: None,
1210 }];
1211 let result = extract_sidecar_elements(path.to_str().unwrap(), &specs)
1212 .await
1213 .unwrap();
1214 assert!(result.is_none());
1215 }
1216
1217 #[tokio::test]
1218 async fn extract_sidecar_elements_returns_none_when_no_spec_element_present() {
1219 let tmp = tempfile::tempdir().unwrap();
1220 let path = tmp.path().join("no_match.xml");
1221 tokio::fs::write(
1222 &path,
1223 r#"<?xml version="1.0" encoding="UTF-8"?><Root><child>value</child></Root>"#,
1224 )
1225 .await
1226 .unwrap();
1227 let specs = [SidecarSpec {
1228 element: "schema".to_string(),
1229 extension: "yaml".to_string(),
1230 original_format: None,
1231 }];
1232 let result = extract_sidecar_elements(path.to_str().unwrap(), &specs)
1233 .await
1234 .unwrap();
1235 assert!(result.is_none());
1236 }
1237
1238 #[tokio::test]
1239 async fn extract_sidecar_elements_extracts_matching_element() {
1240 let tmp = tempfile::tempdir().unwrap();
1241 let path = tmp.path().join("match.xml");
1242 tokio::fs::write(
1243 &path,
1244 r#"<?xml version="1.0" encoding="UTF-8"?><Root><schema>openapi: 3.0.1</schema><child>value</child></Root>"#,
1245 )
1246 .await
1247 .unwrap();
1248 let specs = [SidecarSpec {
1249 element: "schema".to_string(),
1250 extension: "yaml".to_string(),
1251 original_format: None,
1252 }];
1253 let (stripped, sidecars) = extract_sidecar_elements(path.to_str().unwrap(), &specs)
1254 .await
1255 .unwrap()
1256 .expect("schema element should be extracted");
1257 assert!(!stripped.contains("<schema>"));
1258 assert_eq!(sidecars.len(), 1);
1259 assert_eq!(sidecars[0].0, "schema");
1260 }
1261}