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 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
155 let strategy = strategy.unwrap_or("unique-id");
156 let strategy = if ["unique-id", "grouped-by-tag"].contains(&strategy) {
157 strategy
158 } else {
159 log::warn!(
160 "Unsupported strategy \"{}\", defaulting to \"unique-id\".",
161 strategy
162 );
163 "unique-id"
164 };
165
166 self.load_ignore_rules(ignore_path).await;
167
168 let path = Path::new(file_path);
169 let meta = fs::metadata(path).await?;
170 let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
171 let relative_path = path.strip_prefix(&cwd).unwrap_or(path).to_string_lossy();
172 let relative_path = Self::posix_path(&relative_path);
173
174 let multi_level_rules = multi_level_rules.filter(|rules| !rules.is_empty());
176
177 if meta.is_file() {
178 self.handle_file(
179 file_path,
180 &relative_path,
181 unique_id_elements,
182 strategy,
183 pre_purge,
184 post_purge,
185 format,
186 multi_level_rules,
187 decompose_rules,
188 sidecar_specs,
189 )
190 .await?;
191 } else {
192 self.handle_directory(
195 file_path,
196 unique_id_elements,
197 strategy,
198 pre_purge,
199 post_purge,
200 format,
201 multi_level_rules,
202 decompose_rules,
203 sidecar_specs,
204 )
205 .await?;
206 }
207
208 Ok(())
209 }
210
211 #[allow(clippy::too_many_arguments)]
212 async fn handle_file(
213 &self,
214 file_path: &str,
215 relative_path: &str,
216 unique_id_elements: Option<&str>,
217 strategy: &str,
218 pre_purge: bool,
219 post_purge: bool,
220 format: &str,
221 multi_level_rules: Option<&[MultiLevelRule]>,
222 decompose_rules: Option<&[DecomposeRule]>,
223 sidecar_specs: Option<&[SidecarSpec]>,
224 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
225 let resolved = Path::new(file_path)
226 .canonicalize()
227 .unwrap_or_else(|_| Path::new(file_path).to_path_buf());
228 let resolved_str = normalize_path_unix(&resolved.to_string_lossy());
229
230 if !Self::is_xml_file(&resolved_str) {
231 log::error!(
232 "The file path provided is not an XML file: {}",
233 resolved_str
234 );
235 return Ok(());
236 }
237
238 if self.is_ignored(relative_path) {
239 log::warn!("File ignored by ignore rules: {}", resolved_str);
240 return Ok(());
241 }
242
243 let dir_path = resolved.parent().unwrap_or(Path::new("."));
244 let dir_path_str = normalize_path_unix(&dir_path.to_string_lossy());
245 Self::process_file(
246 dir_path_str,
247 strategy.to_string(),
248 resolved_str,
249 unique_id_elements.map(str::to_string),
250 pre_purge,
251 post_purge,
252 format.to_string(),
253 multi_level_rules.map(<[MultiLevelRule]>::to_vec),
254 decompose_rules.map(<[DecomposeRule]>::to_vec),
255 sidecar_specs.map(<[SidecarSpec]>::to_vec),
256 )
257 .await
258 }
259
260 #[allow(clippy::too_many_arguments)]
261 async fn handle_directory(
262 &self,
263 dir_path: &str,
264 unique_id_elements: Option<&str>,
265 strategy: &str,
266 pre_purge: bool,
267 post_purge: bool,
268 format: &str,
269 multi_level_rules: Option<&[MultiLevelRule]>,
270 decompose_rules: Option<&[DecomposeRule]>,
271 sidecar_specs: Option<&[SidecarSpec]>,
272 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
273 let dir_path = normalize_path_unix(dir_path);
274 let mut entries = fs::read_dir(&dir_path).await?;
275 let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
276
277 let mut files_to_process: Vec<String> = Vec::new();
280 while let Some(entry) = entries.next_entry().await? {
281 let sub_path = entry.path();
282 let sub_file_path = sub_path.to_string_lossy();
283 let relative_sub = sub_path
284 .strip_prefix(&cwd)
285 .unwrap_or(&sub_path)
286 .to_string_lossy();
287 let relative_sub = Self::posix_path(&relative_sub);
288
289 if !Self::is_processable_xml_entry(sub_path.is_file(), &sub_file_path) {
290 continue;
291 }
292 if self.is_ignored(&relative_sub) {
293 log::warn!("File ignored by ignore rules: {}", sub_file_path);
294 continue;
295 }
296 files_to_process.push(normalize_path_unix(&sub_file_path));
297 }
298
299 if files_to_process.len() <= 1 {
305 for file_path in files_to_process {
306 Self::process_file(
307 dir_path.clone(),
308 strategy.to_string(),
309 file_path,
310 unique_id_elements.map(str::to_string),
311 pre_purge,
312 post_purge,
313 format.to_string(),
314 multi_level_rules.map(<[MultiLevelRule]>::to_vec),
315 decompose_rules.map(<[DecomposeRule]>::to_vec),
316 sidecar_specs.map(<[SidecarSpec]>::to_vec),
317 )
318 .await?;
319 }
320 return Ok(());
321 }
322
323 let semaphore = Arc::new(Semaphore::new(DIRECTORY_CONCURRENCY));
331 let mut join_set = JoinSet::new();
332 for file_path in files_to_process {
333 let dir_path = dir_path.clone();
334 let strategy = strategy.to_string();
335 let unique_id_elements = unique_id_elements.map(str::to_string);
336 let format = format.to_string();
337 let multi_level_rules = multi_level_rules.map(<[MultiLevelRule]>::to_vec);
338 let decompose_rules = decompose_rules.map(<[DecomposeRule]>::to_vec);
339 let sidecar_specs = sidecar_specs.map(<[SidecarSpec]>::to_vec);
340 let semaphore = Arc::clone(&semaphore);
341
342 join_set.spawn(async move {
343 let _permit = semaphore.acquire_owned().await.expect("semaphore closed");
344 Self::process_file(
345 dir_path,
346 strategy,
347 file_path,
348 unique_id_elements,
349 pre_purge,
350 post_purge,
351 format,
352 multi_level_rules,
353 decompose_rules,
354 sidecar_specs,
355 )
356 .await
357 });
358 }
359
360 while let Some(joined) = join_set.join_next().await {
361 joined.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)??;
362 }
363
364 Ok(())
365 }
366
367 #[allow(clippy::too_many_arguments)]
368 async fn process_file(
369 dir_path: String,
370 strategy: String,
371 file_path: String,
372 unique_id_elements: Option<String>,
373 pre_purge: bool,
374 post_purge: bool,
375 format: String,
376 multi_level_rules: Option<Vec<MultiLevelRule>>,
377 decompose_rules: Option<Vec<DecomposeRule>>,
378 sidecar_specs: Option<Vec<SidecarSpec>>,
379 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
380 let dir_path = dir_path.as_str();
384 let file_path = file_path.as_str();
385 let strategy = strategy.as_str();
386 let format = format.as_str();
387 let unique_id_elements = unique_id_elements.as_deref();
388 let multi_level_rules = multi_level_rules.as_deref();
389 let decompose_rules = decompose_rules.as_deref();
390 let sidecar_specs = sidecar_specs.as_deref();
391
392 log::debug!("Parsing file to disassemble: {}", file_path);
393
394 let file_name = Path::new(file_path)
395 .file_stem()
396 .and_then(|s| s.to_str())
397 .unwrap_or("output");
398 let base_name = Self::output_dir_basename(file_name);
399 let output_path = Path::new(dir_path).join(base_name);
400
401 if Self::should_pre_purge_output(pre_purge, output_path.exists()) {
402 fs::remove_dir_all(&output_path).await.ok();
403 }
404
405 let pre_extraction_key_order: Option<Vec<String>> =
408 if sidecar_specs.is_some_and(|s| !s.is_empty()) {
409 parse_xml(file_path).await.and_then(|parsed| {
410 let obj = parsed.as_object()?;
411 let root_key = obj.keys().find(|k| *k != "?xml")?;
412 obj.get(root_key)?.as_object().map(|root_obj| {
413 root_obj
414 .keys()
415 .filter(|k| !k.starts_with('@'))
416 .cloned()
417 .collect()
418 })
419 })
420 } else {
421 None
422 };
423
424 let extraction_result = if let Some(specs) = sidecar_specs {
430 if !specs.is_empty() {
431 extract_sidecar_elements(file_path, specs).await?
432 } else {
433 None
434 }
435 } else {
436 None
437 };
438
439 let temp_file: Option<tempfile::NamedTempFile>;
440 let disassemble_path: &str;
441 if let Some((xml, _)) = &extraction_result {
442 let mut tmp = tempfile::Builder::new()
443 .suffix(".xml")
444 .tempfile_in(Path::new(file_path).parent().unwrap_or(Path::new(".")))?;
445 tmp.write_all(xml.as_bytes())?;
446 temp_file = Some(tmp);
447 disassemble_path = temp_file
448 .as_ref()
449 .unwrap()
450 .path()
451 .to_str()
452 .unwrap_or(file_path);
453 } else {
454 temp_file = None;
455 disassemble_path = file_path;
456 }
457
458 build_disassembled_files_unified(BuildDisassembledFilesOptions {
459 file_path: disassemble_path,
460 disassembled_path: output_path.to_str().unwrap_or("."),
461 base_name: file_name,
462 post_purge,
463 format,
464 unique_id_elements,
465 strategy,
466 decompose_rules,
467 })
468 .await?;
469
470 drop(temp_file); if let Some((_, sidecars)) = &extraction_result {
475 for (_, extension, content, _) in sidecars {
476 let sidecar_path = output_path.join(format!("{}.{}", base_name, extension));
477 fs::write(&sidecar_path, content).await?;
478 }
479 if let Some(specs) = sidecar_specs {
480 let enriched: Vec<SidecarSpec> = specs
483 .iter()
484 .map(|spec| {
485 let original_format = sidecars
486 .iter()
487 .find(|(el, _, _, _)| el == &spec.element)
488 .and_then(|(_, _, _, fmt)| fmt.clone());
489 SidecarSpec {
490 element: spec.element.clone(),
491 extension: spec.extension.clone(),
492 original_format,
493 }
494 })
495 .collect();
496 if let Ok(json) = serde_json::to_string(&enriched) {
497 let _ = fs::write(output_path.join(".sidecars.json"), json).await;
498 }
499 }
500 }
501
502 if let Some(full_order) = pre_extraction_key_order {
505 let key_order_path = output_path.join(".key_order.json");
506 if let Ok(json) = serde_json::to_string(&full_order) {
507 let _ = fs::write(&key_order_path, json).await;
508 }
509 }
510
511 if let Some(rules) = multi_level_rules {
515 for rule in rules {
516 Self::recursively_disassemble_multi_level(&output_path, rule, format).await?;
517 }
518 }
519
520 Ok(())
521 }
522
523 async fn recursively_disassemble_multi_level(
526 dir_path: &Path,
527 rule: &MultiLevelRule,
528 format: &str,
529 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
530 let mut config = crate::xml::multi_level::load_multi_level_config(dir_path)
531 .await
532 .unwrap_or_default();
533
534 let mut stack = vec![dir_path.to_path_buf()];
535 while let Some(current) = stack.pop() {
536 let mut entries = Vec::new();
537 let mut read_dir = fs::read_dir(¤t).await?;
538 while let Some(entry) = read_dir.next_entry().await? {
539 entries.push(entry);
540 }
541
542 for entry in entries {
543 let path = entry.path();
544 let path_str = path.to_string_lossy().to_string();
545
546 if path.is_dir() {
547 stack.push(path);
548 continue;
549 }
550 {
552 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
553 let path_str_check = path.to_string_lossy();
554 if !Self::file_matches_multi_level_rule(
555 name,
556 &path_str_check,
557 &rule.file_pattern,
558 ) {
559 continue;
560 }
561
562 let parsed = match parse_xml(&path_str).await {
563 Some(p) => p,
564 None => continue,
565 };
566 if !Self::has_element_to_strip(&parsed, &rule.root_to_strip) {
567 continue;
568 }
569
570 let wrap_xmlns = capture_xmlns_from_root(&parsed).unwrap_or_default();
571
572 let stripped_xml = match strip_root_and_build_xml(&parsed, &rule.root_to_strip)
573 {
574 Some(xml) => xml,
575 None => continue,
576 };
577
578 fs::write(&path, stripped_xml).await?;
579
580 let file_stem = path
581 .file_stem()
582 .and_then(|s| s.to_str())
583 .unwrap_or("output");
584 let output_dir_name = Self::output_dir_basename(file_stem);
585 let parent = path.parent().unwrap_or(dir_path);
586 let second_level_output = parent.join(output_dir_name);
587
588 build_disassembled_files_unified(BuildDisassembledFilesOptions {
589 file_path: &path_str,
590 disassembled_path: second_level_output.to_str().unwrap_or("."),
591 base_name: output_dir_name,
592 post_purge: true,
593 format,
594 unique_id_elements: Some(&rule.unique_id_elements),
595 strategy: "unique-id",
596 decompose_rules: None,
597 })
598 .await?;
599
600 let existing_idx = config
604 .rules
605 .iter()
606 .position(|r| Self::rules_have_same_identity(r, rule));
607 match existing_idx {
608 None => {
609 let wrap_root = Self::root_element_name_from_parsed(
610 &parsed,
611 &rule.wrap_root_element,
612 );
613 let path_segment = if rule.path_segment.is_empty() {
614 path_segment_from_file_pattern(&rule.file_pattern)
615 } else {
616 rule.path_segment.clone()
617 };
618 let stored_xmlns = if rule.wrap_xmlns.is_empty() {
619 wrap_xmlns
620 } else {
621 rule.wrap_xmlns.clone()
622 };
623 config.rules.push(MultiLevelRule {
624 file_pattern: rule.file_pattern.clone(),
625 root_to_strip: rule.root_to_strip.clone(),
626 unique_id_elements: rule.unique_id_elements.clone(),
627 path_segment,
628 wrap_root_element: wrap_root,
631 wrap_xmlns: stored_xmlns,
632 });
633 }
634 Some(idx) => {
635 if config.rules[idx].wrap_xmlns.is_empty() {
638 config.rules[idx].wrap_xmlns = wrap_xmlns;
639 }
640 }
641 }
642 }
643 }
644 }
645
646 if !config.rules.is_empty() {
647 save_multi_level_config(dir_path, &config).await?;
648 }
649
650 Ok(())
651 }
652}
653
654impl Default for DisassembleXmlFileHandler {
655 fn default() -> Self {
656 Self::new()
657 }
658}
659
660async fn extract_sidecar_elements(
672 file_path: &str,
673 specs: &[SidecarSpec],
674) -> Result<
675 Option<(String, Vec<(String, String, String, Option<String>)>)>,
676 Box<dyn std::error::Error + Send + Sync>,
677> {
678 let raw = fs::read_to_string(file_path).await?;
679 let Some(mut parsed) = parse_xml_from_str(&raw, file_path) else {
680 return Ok(None);
681 };
682
683 if let (Some(obj), Some(decl)) = (
688 parsed.as_object_mut(),
689 extract_xml_declaration_from_raw(&raw),
690 ) {
691 obj.insert("?xml".to_string(), decl);
692 }
693
694 let root_key = parsed
695 .as_object()
696 .and_then(|o| o.keys().find(|k| *k != "?xml").cloned());
697 let Some(root_key) = root_key else {
698 return Ok(None);
699 };
700
701 let mut sidecars: Vec<(String, String, String, Option<String>)> = Vec::new();
703 if let Some(root_val) = parsed.as_object_mut().and_then(|o| o.get_mut(&root_key)) {
704 if let Some(root_obj) = root_val.as_object_mut() {
705 for spec in specs {
706 let Some(elem_val) = root_obj.remove(&spec.element) else {
707 continue;
708 };
709 let text = match &elem_val {
712 serde_json::Value::Object(obj) => obj
713 .get("#text")
714 .and_then(|v| v.as_str())
715 .unwrap_or("")
716 .to_string(),
717 _ => {
718 root_obj.insert(spec.element.clone(), elem_val);
719 continue;
720 }
721 };
722 let original_format = detect_content_format(&text);
723 sidecars.push((
724 spec.element.clone(),
725 spec.extension.clone(),
726 convert_sidecar_content(&text, &spec.extension),
727 original_format,
728 ));
729 }
730 }
731 }
732
733 if sidecars.is_empty() {
734 Ok(None)
735 } else {
736 Ok(Some((build_xml_string(&parsed), sidecars)))
737 }
738}
739
740fn detect_content_format(text: &str) -> Option<String> {
743 if serde_json::from_str::<serde_json::Value>(text).is_ok() {
744 Some("json".to_string())
745 } else if serde_yaml::from_str::<serde_yaml::Value>(text).is_ok() {
746 Some("yaml".to_string())
747 } else {
748 None
749 }
750}
751
752fn convert_sidecar_content(text: &str, extension: &str) -> String {
761 match extension.to_ascii_lowercase().as_str() {
762 "json" => {
763 match serde_yaml::from_str::<serde_yaml::Value>(text) {
767 Ok(val) => match serde_json::to_string_pretty(&val) {
768 Ok(json) => json,
769 Err(e) => {
770 log::warn!("sidecar: JSON serialization failed ({e}); using raw text");
771 text.to_string()
772 }
773 },
774 Err(e) => {
775 log::warn!(
776 "sidecar: could not parse content for JSON conversion ({e}); using raw text"
777 );
778 text.to_string()
779 }
780 }
781 }
782 "yaml" | "yml" => {
783 if serde_json::from_str::<serde_json::Value>(text).is_ok() {
786 match serde_yaml::from_str::<serde_yaml::Value>(text)
787 .ok()
788 .and_then(|v| serde_yaml::to_string(&v).ok())
789 {
790 Some(yaml) => yaml,
791 None => {
792 log::warn!("sidecar: YAML serialization failed; using raw text");
793 text.to_string()
794 }
795 }
796 } else {
797 text.to_string()
798 }
799 }
800 _ => text.to_string(),
801 }
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807
808 #[test]
809 #[allow(clippy::default_constructed_unit_structs)]
810 fn disassemble_handler_default_equals_new() {
811 let _ = DisassembleXmlFileHandler::default();
812 }
813
814 #[test]
815 fn is_xml_file_matches_case_insensitively() {
816 assert!(DisassembleXmlFileHandler::is_xml_file("foo.xml"));
817 assert!(DisassembleXmlFileHandler::is_xml_file("BAR.XML"));
818 assert!(!DisassembleXmlFileHandler::is_xml_file("foo.txt"));
819 }
820
821 #[test]
822 fn posix_path_converts_backslashes() {
823 assert_eq!(
824 DisassembleXmlFileHandler::posix_path(r"C:\Users\name\file.xml"),
825 "C:/Users/name/file.xml"
826 );
827 }
828
829 #[tokio::test]
830 async fn load_ignore_rules_noop_when_path_missing() {
831 let mut handler = DisassembleXmlFileHandler::new();
832 handler
833 .load_ignore_rules("/definitely/does/not/exist/.ignore")
834 .await;
835 assert!(handler.ign.is_none());
836 }
837
838 #[tokio::test]
839 async fn load_ignore_rules_builds_matcher() {
840 let temp = tempfile::tempdir().unwrap();
841 let path = temp.path().join(".ignore");
842 tokio::fs::write(&path, "*.xml\n").await.unwrap();
843 let mut handler = DisassembleXmlFileHandler::new();
844 handler.load_ignore_rules(path.to_str().unwrap()).await;
845 assert!(handler.ign.is_some());
846 assert!(handler.is_ignored("file.xml"));
847 assert!(!handler.is_ignored("file.txt"));
848 }
849
850 #[test]
851 fn is_ignored_default_false_without_rules() {
852 let handler = DisassembleXmlFileHandler::new();
853 assert!(!handler.is_ignored("some/path.xml"));
854 }
855
856 #[test]
857 fn output_dir_basename_strips_only_last_dot_segment() {
858 assert_eq!(
860 DisassembleXmlFileHandler::output_dir_basename("HR_Admin.permissionset-meta"),
861 "HR_Admin"
862 );
863 assert_eq!(
864 DisassembleXmlFileHandler::output_dir_basename("Get_Info.flow-meta"),
865 "Get_Info"
866 );
867 }
868
869 #[test]
870 fn output_dir_basename_preserves_dotted_full_names() {
871 assert_eq!(
876 DisassembleXmlFileHandler::output_dir_basename(
877 "Account_Merge__c.New_Account_Merges_2.approvalProcess-meta"
878 ),
879 "Account_Merge__c.New_Account_Merges_2"
880 );
881 assert_eq!(
882 DisassembleXmlFileHandler::output_dir_basename(
883 "Account_Merge__c.New_Account_Merges_3.approvalProcess-meta"
884 ),
885 "Account_Merge__c.New_Account_Merges_3"
886 );
887 assert_eq!(
889 DisassembleXmlFileHandler::output_dir_basename("Case.LogACall.quickAction-meta"),
890 "Case.LogACall"
891 );
892 }
893
894 #[test]
895 fn is_processable_xml_entry_true_only_for_regular_xml_files() {
896 assert!(DisassembleXmlFileHandler::is_processable_xml_entry(
900 true, "foo.xml"
901 ));
902 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
903 false, "foo.xml"
904 ));
905 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
906 true, "foo.txt"
907 ));
908 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
909 false, "foo.txt"
910 ));
911 }
912
913 #[test]
914 fn should_pre_purge_output_requires_both_flag_and_existing_dir() {
915 assert!(DisassembleXmlFileHandler::should_pre_purge_output(
919 true, true
920 ));
921 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
922 true, false
923 ));
924 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
925 false, true
926 ));
927 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
928 false, false
929 ));
930 }
931
932 #[test]
933 fn file_matches_multi_level_rule_requires_xml_extension() {
934 assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
936 "Foo.txt",
937 "/dir/Foo.txt",
938 "Foo"
939 ));
940 }
941
942 #[test]
943 fn file_matches_multi_level_rule_when_filename_contains_pattern() {
944 assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
945 "MyPattern.xml",
946 "/dir/MyPattern.xml",
947 "MyPattern"
948 ));
949 }
950
951 #[test]
952 fn file_matches_multi_level_rule_when_only_full_path_contains_pattern() {
953 assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
956 "child.xml",
957 "/parentPattern/child.xml",
958 "parentPattern"
959 ));
960 }
961
962 #[test]
963 fn file_matches_multi_level_rule_false_when_pattern_absent_everywhere() {
964 assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
965 "Foo.xml",
966 "/dir/Foo.xml",
967 "MissingPattern"
968 ));
969 }
970
971 #[test]
972 fn has_element_to_strip_when_root_key_matches() {
973 let parsed = serde_json::json!({"Foo": {"a": "b"}});
974 assert!(DisassembleXmlFileHandler::has_element_to_strip(
975 &parsed, "Foo"
976 ));
977 }
978
979 #[test]
980 fn has_element_to_strip_when_root_contains_target_child() {
981 let parsed = serde_json::json!({"Foo": {"Bar": {"a": "b"}}});
982 assert!(DisassembleXmlFileHandler::has_element_to_strip(
983 &parsed, "Bar"
984 ));
985 }
986
987 #[test]
988 fn has_element_to_strip_false_when_target_absent() {
989 let parsed = serde_json::json!({"Foo": {"a": "b"}});
990 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
991 &parsed, "Missing"
992 ));
993 }
994
995 #[test]
996 fn has_element_to_strip_false_for_non_object_or_decl_only() {
997 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
998 &serde_json::json!("primitive"),
999 "Foo"
1000 ));
1001 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
1002 &serde_json::json!({"?xml": {}}),
1003 "Foo"
1004 ));
1005 }
1006
1007 fn rule(pattern: &str, root: &str) -> MultiLevelRule {
1008 MultiLevelRule {
1009 file_pattern: pattern.to_string(),
1010 root_to_strip: root.to_string(),
1011 unique_id_elements: String::new(),
1012 path_segment: String::new(),
1013 wrap_root_element: String::new(),
1014 wrap_xmlns: String::new(),
1015 }
1016 }
1017
1018 #[test]
1019 fn rules_share_identity_when_pattern_and_root_match() {
1020 assert!(DisassembleXmlFileHandler::rules_have_same_identity(
1021 &rule("p", "R"),
1022 &rule("p", "R"),
1023 ));
1024 }
1025
1026 #[test]
1027 fn rules_differ_when_file_pattern_differs() {
1028 assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
1029 &rule("p1", "R"),
1030 &rule("p2", "R"),
1031 ));
1032 }
1033
1034 #[test]
1035 fn rules_differ_when_root_to_strip_differs() {
1036 assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
1037 &rule("p", "R1"),
1038 &rule("p", "R2"),
1039 ));
1040 }
1041
1042 #[test]
1043 fn root_element_name_finds_first_non_declaration_key() {
1044 let parsed = serde_json::json!({"?xml": {}, "MyRoot": {"a": "b"}});
1045 assert_eq!(
1046 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "fallback"),
1047 "MyRoot"
1048 );
1049 }
1050
1051 #[test]
1052 fn root_element_name_falls_back_when_only_declaration_present() {
1053 let parsed = serde_json::json!({"?xml": {}});
1054 assert_eq!(
1055 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "FallbackRoot"),
1056 "FallbackRoot"
1057 );
1058 }
1059
1060 #[test]
1061 fn root_element_name_falls_back_for_non_object() {
1062 let parsed = serde_json::json!("primitive");
1063 assert_eq!(
1064 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "Fb"),
1065 "Fb"
1066 );
1067 }
1068
1069 #[test]
1070 fn output_dir_basename_no_dot_returns_stem_unchanged() {
1071 assert_eq!(DisassembleXmlFileHandler::output_dir_basename("Foo"), "Foo");
1073 assert_eq!(DisassembleXmlFileHandler::output_dir_basename(""), "");
1074 }
1075
1076 #[test]
1077 fn convert_sidecar_content_yaml_to_json() {
1078 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";
1082 let out = convert_sidecar_content(yaml, "json");
1083 let val: serde_json::Value = serde_json::from_str(&out).expect("output must be valid JSON");
1084 assert_eq!(val["openapi"], "3.0.1");
1085 assert_eq!(val["info"]["title"], "@AuraEnabled Apex method APIs");
1086 assert_eq!(val["info"]["version"], "1.0.0");
1087 assert_eq!(
1088 val["paths"]["/uploadFile"]["post"]["operationId"],
1089 "uploadFile"
1090 );
1091 }
1092
1093 #[test]
1094 fn convert_sidecar_content_json_to_yaml() {
1095 let json = r#"{"key":"value","num":42}"#;
1096 let out = convert_sidecar_content(json, "yaml");
1097 assert!(
1101 serde_json::from_str::<serde_json::Value>(&out).is_err(),
1102 "output must be YAML format, not raw JSON: {out}"
1103 );
1104 let val: serde_json::Value = serde_yaml::from_str(&out).expect("output must be valid YAML");
1105 assert_eq!(val["key"], "value");
1106 assert_eq!(val["num"], 42);
1107 }
1108
1109 #[test]
1110 fn convert_sidecar_content_json_to_json_prettifies() {
1111 let compact = r#"{"a":1}"#;
1112 let out = convert_sidecar_content(compact, "json");
1113 assert!(out.contains('\n'), "expected pretty JSON, got: {out}");
1115 let val: serde_json::Value = serde_json::from_str(&out).unwrap();
1116 assert_eq!(val["a"], 1);
1117 }
1118
1119 #[test]
1120 fn convert_sidecar_content_unknown_extension_passes_through() {
1121 let raw = "arbitrary: content: here";
1122 assert_eq!(convert_sidecar_content(raw, "txt"), raw);
1123 assert_eq!(convert_sidecar_content(raw, ""), raw);
1124 }
1125
1126 #[test]
1127 fn convert_sidecar_content_malformed_falls_back_to_raw() {
1128 let bad = "{{{{ not valid json or yaml at all >>>>>";
1130 assert_eq!(convert_sidecar_content(bad, "json"), bad);
1131 assert_eq!(convert_sidecar_content(bad, "yaml"), bad);
1132 }
1133
1134 #[test]
1135 fn convert_sidecar_content_yml_extension_same_as_yaml() {
1136 let json = r#"{"x":true}"#;
1137 let out = convert_sidecar_content(json, "yml");
1138 let val: serde_json::Value = serde_yaml::from_str(&out).unwrap();
1139 assert_eq!(val["x"], true);
1140 }
1141
1142 #[test]
1143 fn convert_sidecar_content_yaml_passes_through_unchanged() {
1144 let yaml = "title: \"@AuraEnabled Apex method APIs\"\nversion: 1.0.0\n";
1147 assert_eq!(convert_sidecar_content(yaml, "yaml"), yaml);
1148 assert_eq!(convert_sidecar_content(yaml, "yml"), yaml);
1149 }
1150}