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_yaml::from_str::<serde_yaml::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" => {
778 match serde_yaml::from_str::<serde_yaml::Value>(text) {
782 Ok(val) => match serde_json::to_string_pretty(&val) {
783 Ok(json) => json,
784 Err(e) => {
785 log::warn!("sidecar: JSON serialization failed ({e}); using raw text");
786 text.to_string()
787 }
788 },
789 Err(e) => {
790 log::warn!(
791 "sidecar: could not parse content for JSON conversion ({e}); using raw text"
792 );
793 text.to_string()
794 }
795 }
796 }
797 "yaml" | "yml" => {
798 if serde_json::from_str::<serde_json::Value>(text).is_ok() {
801 match serde_yaml::from_str::<serde_yaml::Value>(text)
802 .ok()
803 .and_then(|v| serde_yaml::to_string(&v).ok())
804 {
805 Some(yaml) => yaml,
806 None => {
807 log::warn!("sidecar: YAML serialization failed; using raw text");
808 text.to_string()
809 }
810 }
811 } else {
812 text.to_string()
813 }
814 }
815 _ => text.to_string(),
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 #[allow(clippy::default_constructed_unit_structs)]
825 fn disassemble_handler_default_equals_new() {
826 let _ = DisassembleXmlFileHandler::default();
827 }
828
829 #[test]
830 fn is_xml_file_matches_case_insensitively() {
831 assert!(DisassembleXmlFileHandler::is_xml_file("foo.xml"));
832 assert!(DisassembleXmlFileHandler::is_xml_file("BAR.XML"));
833 assert!(!DisassembleXmlFileHandler::is_xml_file("foo.txt"));
834 }
835
836 #[test]
837 fn posix_path_converts_backslashes() {
838 assert_eq!(
839 DisassembleXmlFileHandler::posix_path(r"C:\Users\name\file.xml"),
840 "C:/Users/name/file.xml"
841 );
842 }
843
844 #[tokio::test]
845 async fn load_ignore_rules_noop_when_path_missing() {
846 let mut handler = DisassembleXmlFileHandler::new();
847 handler
848 .load_ignore_rules("/definitely/does/not/exist/.ignore")
849 .await;
850 assert!(handler.ign.is_none());
851 }
852
853 #[tokio::test]
854 async fn load_ignore_rules_builds_matcher() {
855 let temp = tempfile::tempdir().unwrap();
856 let path = temp.path().join(".ignore");
857 tokio::fs::write(&path, "*.xml\n").await.unwrap();
858 let mut handler = DisassembleXmlFileHandler::new();
859 handler.load_ignore_rules(path.to_str().unwrap()).await;
860 assert!(handler.ign.is_some());
861 assert!(handler.is_ignored("file.xml"));
862 assert!(!handler.is_ignored("file.txt"));
863 }
864
865 #[test]
866 fn is_ignored_default_false_without_rules() {
867 let handler = DisassembleXmlFileHandler::new();
868 assert!(!handler.is_ignored("some/path.xml"));
869 }
870
871 #[test]
872 fn output_dir_basename_strips_only_last_dot_segment() {
873 assert_eq!(
875 DisassembleXmlFileHandler::output_dir_basename("HR_Admin.permissionset-meta"),
876 "HR_Admin"
877 );
878 assert_eq!(
879 DisassembleXmlFileHandler::output_dir_basename("Get_Info.flow-meta"),
880 "Get_Info"
881 );
882 }
883
884 #[test]
885 fn output_dir_basename_preserves_dotted_full_names() {
886 assert_eq!(
891 DisassembleXmlFileHandler::output_dir_basename(
892 "Account_Merge__c.New_Account_Merges_2.approvalProcess-meta"
893 ),
894 "Account_Merge__c.New_Account_Merges_2"
895 );
896 assert_eq!(
897 DisassembleXmlFileHandler::output_dir_basename(
898 "Account_Merge__c.New_Account_Merges_3.approvalProcess-meta"
899 ),
900 "Account_Merge__c.New_Account_Merges_3"
901 );
902 assert_eq!(
904 DisassembleXmlFileHandler::output_dir_basename("Case.LogACall.quickAction-meta"),
905 "Case.LogACall"
906 );
907 }
908
909 #[test]
910 fn is_processable_xml_entry_true_only_for_regular_xml_files() {
911 assert!(DisassembleXmlFileHandler::is_processable_xml_entry(
915 true, "foo.xml"
916 ));
917 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
918 false, "foo.xml"
919 ));
920 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
921 true, "foo.txt"
922 ));
923 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
924 false, "foo.txt"
925 ));
926 }
927
928 #[test]
929 fn should_pre_purge_output_requires_both_flag_and_existing_dir() {
930 assert!(DisassembleXmlFileHandler::should_pre_purge_output(
934 true, true
935 ));
936 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
937 true, false
938 ));
939 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
940 false, true
941 ));
942 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
943 false, false
944 ));
945 }
946
947 #[test]
948 fn file_matches_multi_level_rule_requires_xml_extension() {
949 assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
951 "Foo.txt",
952 "/dir/Foo.txt",
953 "Foo"
954 ));
955 }
956
957 #[test]
958 fn file_matches_multi_level_rule_when_filename_contains_pattern() {
959 assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
960 "MyPattern.xml",
961 "/dir/MyPattern.xml",
962 "MyPattern"
963 ));
964 }
965
966 #[test]
967 fn file_matches_multi_level_rule_when_only_full_path_contains_pattern() {
968 assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
971 "child.xml",
972 "/parentPattern/child.xml",
973 "parentPattern"
974 ));
975 }
976
977 #[test]
978 fn file_matches_multi_level_rule_false_when_pattern_absent_everywhere() {
979 assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
980 "Foo.xml",
981 "/dir/Foo.xml",
982 "MissingPattern"
983 ));
984 }
985
986 #[test]
987 fn has_element_to_strip_when_root_key_matches() {
988 let parsed = serde_json::json!({"Foo": {"a": "b"}});
989 assert!(DisassembleXmlFileHandler::has_element_to_strip(
990 &parsed, "Foo"
991 ));
992 }
993
994 #[test]
995 fn has_element_to_strip_when_root_contains_target_child() {
996 let parsed = serde_json::json!({"Foo": {"Bar": {"a": "b"}}});
997 assert!(DisassembleXmlFileHandler::has_element_to_strip(
998 &parsed, "Bar"
999 ));
1000 }
1001
1002 #[test]
1003 fn has_element_to_strip_false_when_target_absent() {
1004 let parsed = serde_json::json!({"Foo": {"a": "b"}});
1005 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
1006 &parsed, "Missing"
1007 ));
1008 }
1009
1010 #[test]
1011 fn has_element_to_strip_false_for_non_object_or_decl_only() {
1012 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
1013 &serde_json::json!("primitive"),
1014 "Foo"
1015 ));
1016 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
1017 &serde_json::json!({"?xml": {}}),
1018 "Foo"
1019 ));
1020 }
1021
1022 fn rule(pattern: &str, root: &str) -> MultiLevelRule {
1023 MultiLevelRule {
1024 file_pattern: pattern.to_string(),
1025 root_to_strip: root.to_string(),
1026 unique_id_elements: String::new(),
1027 path_segment: String::new(),
1028 wrap_root_element: String::new(),
1029 wrap_xmlns: String::new(),
1030 }
1031 }
1032
1033 #[test]
1034 fn rules_share_identity_when_pattern_and_root_match() {
1035 assert!(DisassembleXmlFileHandler::rules_have_same_identity(
1036 &rule("p", "R"),
1037 &rule("p", "R"),
1038 ));
1039 }
1040
1041 #[test]
1042 fn rules_differ_when_file_pattern_differs() {
1043 assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
1044 &rule("p1", "R"),
1045 &rule("p2", "R"),
1046 ));
1047 }
1048
1049 #[test]
1050 fn rules_differ_when_root_to_strip_differs() {
1051 assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
1052 &rule("p", "R1"),
1053 &rule("p", "R2"),
1054 ));
1055 }
1056
1057 #[test]
1058 fn root_element_name_finds_first_non_declaration_key() {
1059 let parsed = serde_json::json!({"?xml": {}, "MyRoot": {"a": "b"}});
1060 assert_eq!(
1061 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "fallback"),
1062 "MyRoot"
1063 );
1064 }
1065
1066 #[test]
1067 fn root_element_name_falls_back_when_only_declaration_present() {
1068 let parsed = serde_json::json!({"?xml": {}});
1069 assert_eq!(
1070 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "FallbackRoot"),
1071 "FallbackRoot"
1072 );
1073 }
1074
1075 #[test]
1076 fn root_element_name_falls_back_for_non_object() {
1077 let parsed = serde_json::json!("primitive");
1078 assert_eq!(
1079 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "Fb"),
1080 "Fb"
1081 );
1082 }
1083
1084 #[test]
1085 fn output_dir_basename_no_dot_returns_stem_unchanged() {
1086 assert_eq!(DisassembleXmlFileHandler::output_dir_basename("Foo"), "Foo");
1088 assert_eq!(DisassembleXmlFileHandler::output_dir_basename(""), "");
1089 }
1090
1091 #[test]
1092 fn convert_sidecar_content_yaml_to_json() {
1093 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";
1097 let out = convert_sidecar_content(yaml, "json");
1098 let val: serde_json::Value = serde_json::from_str(&out).expect("output must be valid JSON");
1099 assert_eq!(val["openapi"], "3.0.1");
1100 assert_eq!(val["info"]["title"], "@AuraEnabled Apex method APIs");
1101 assert_eq!(val["info"]["version"], "1.0.0");
1102 assert_eq!(
1103 val["paths"]["/uploadFile"]["post"]["operationId"],
1104 "uploadFile"
1105 );
1106 }
1107
1108 #[test]
1109 fn convert_sidecar_content_json_to_yaml() {
1110 let json = r#"{"key":"value","num":42}"#;
1111 let out = convert_sidecar_content(json, "yaml");
1112 assert!(
1116 serde_json::from_str::<serde_json::Value>(&out).is_err(),
1117 "output must be YAML format, not raw JSON: {out}"
1118 );
1119 let val: serde_json::Value = serde_yaml::from_str(&out).expect("output must be valid YAML");
1120 assert_eq!(val["key"], "value");
1121 assert_eq!(val["num"], 42);
1122 }
1123
1124 #[test]
1125 fn convert_sidecar_content_json_to_json_prettifies() {
1126 let compact = r#"{"a":1}"#;
1127 let out = convert_sidecar_content(compact, "json");
1128 assert!(out.contains('\n'), "expected pretty JSON, got: {out}");
1130 let val: serde_json::Value = serde_json::from_str(&out).unwrap();
1131 assert_eq!(val["a"], 1);
1132 }
1133
1134 #[test]
1135 fn convert_sidecar_content_unknown_extension_passes_through() {
1136 let raw = "arbitrary: content: here";
1137 assert_eq!(convert_sidecar_content(raw, "txt"), raw);
1138 assert_eq!(convert_sidecar_content(raw, ""), raw);
1139 }
1140
1141 #[test]
1142 fn convert_sidecar_content_malformed_falls_back_to_raw() {
1143 let bad = "{{{{ not valid json or yaml at all >>>>>";
1145 assert_eq!(convert_sidecar_content(bad, "json"), bad);
1146 assert_eq!(convert_sidecar_content(bad, "yaml"), bad);
1147 }
1148
1149 #[test]
1150 fn convert_sidecar_content_yml_extension_same_as_yaml() {
1151 let json = r#"{"x":true}"#;
1152 let out = convert_sidecar_content(json, "yml");
1153 let val: serde_json::Value = serde_yaml::from_str(&out).unwrap();
1154 assert_eq!(val["x"], true);
1155 }
1156
1157 #[test]
1158 fn convert_sidecar_content_yaml_passes_through_unchanged() {
1159 let yaml = "title: \"@AuraEnabled Apex method APIs\"\nversion: 1.0.0\n";
1162 assert_eq!(convert_sidecar_content(yaml, "yaml"), yaml);
1163 assert_eq!(convert_sidecar_content(yaml, "yml"), yaml);
1164 }
1165}