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 let semaphore = Arc::new(Semaphore::new(DIRECTORY_CONCURRENCY));
307 let mut join_set = JoinSet::new();
308 for file_path in files_to_process {
309 let dir_path = dir_path.clone();
310 let strategy = strategy.to_string();
311 let unique_id_elements = unique_id_elements.map(str::to_string);
312 let format = format.to_string();
313 let multi_level_rules = multi_level_rules.map(<[MultiLevelRule]>::to_vec);
314 let decompose_rules = decompose_rules.map(<[DecomposeRule]>::to_vec);
315 let sidecar_specs = sidecar_specs.map(<[SidecarSpec]>::to_vec);
316 let semaphore = Arc::clone(&semaphore);
317
318 join_set.spawn(async move {
319 let _permit = semaphore.acquire_owned().await.expect("semaphore closed");
320 Self::process_file(
321 dir_path,
322 strategy,
323 file_path,
324 unique_id_elements,
325 pre_purge,
326 post_purge,
327 format,
328 multi_level_rules,
329 decompose_rules,
330 sidecar_specs,
331 )
332 .await
333 });
334 }
335
336 while let Some(joined) = join_set.join_next().await {
337 joined.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)??;
338 }
339
340 Ok(())
341 }
342
343 #[allow(clippy::too_many_arguments)]
344 async fn process_file(
345 dir_path: String,
346 strategy: String,
347 file_path: String,
348 unique_id_elements: Option<String>,
349 pre_purge: bool,
350 post_purge: bool,
351 format: String,
352 multi_level_rules: Option<Vec<MultiLevelRule>>,
353 decompose_rules: Option<Vec<DecomposeRule>>,
354 sidecar_specs: Option<Vec<SidecarSpec>>,
355 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
356 let dir_path = dir_path.as_str();
360 let file_path = file_path.as_str();
361 let strategy = strategy.as_str();
362 let format = format.as_str();
363 let unique_id_elements = unique_id_elements.as_deref();
364 let multi_level_rules = multi_level_rules.as_deref();
365 let decompose_rules = decompose_rules.as_deref();
366 let sidecar_specs = sidecar_specs.as_deref();
367
368 log::debug!("Parsing file to disassemble: {}", file_path);
369
370 let file_name = Path::new(file_path)
371 .file_stem()
372 .and_then(|s| s.to_str())
373 .unwrap_or("output");
374 let base_name = Self::output_dir_basename(file_name);
375 let output_path = Path::new(dir_path).join(base_name);
376
377 if Self::should_pre_purge_output(pre_purge, output_path.exists()) {
378 fs::remove_dir_all(&output_path).await.ok();
379 }
380
381 let pre_extraction_key_order: Option<Vec<String>> =
384 if sidecar_specs.is_some_and(|s| !s.is_empty()) {
385 parse_xml(file_path).await.and_then(|parsed| {
386 let obj = parsed.as_object()?;
387 let root_key = obj.keys().find(|k| *k != "?xml")?;
388 obj.get(root_key)?.as_object().map(|root_obj| {
389 root_obj
390 .keys()
391 .filter(|k| !k.starts_with('@'))
392 .cloned()
393 .collect()
394 })
395 })
396 } else {
397 None
398 };
399
400 let extraction_result = if let Some(specs) = sidecar_specs {
406 if !specs.is_empty() {
407 extract_sidecar_elements(file_path, specs).await?
408 } else {
409 None
410 }
411 } else {
412 None
413 };
414
415 let temp_file: Option<tempfile::NamedTempFile>;
416 let disassemble_path: &str;
417 if let Some((xml, _)) = &extraction_result {
418 let mut tmp = tempfile::Builder::new()
419 .suffix(".xml")
420 .tempfile_in(Path::new(file_path).parent().unwrap_or(Path::new(".")))?;
421 tmp.write_all(xml.as_bytes())?;
422 temp_file = Some(tmp);
423 disassemble_path = temp_file
424 .as_ref()
425 .unwrap()
426 .path()
427 .to_str()
428 .unwrap_or(file_path);
429 } else {
430 temp_file = None;
431 disassemble_path = file_path;
432 }
433
434 build_disassembled_files_unified(BuildDisassembledFilesOptions {
435 file_path: disassemble_path,
436 disassembled_path: output_path.to_str().unwrap_or("."),
437 base_name: file_name,
438 post_purge,
439 format,
440 unique_id_elements,
441 strategy,
442 decompose_rules,
443 })
444 .await?;
445
446 drop(temp_file); if let Some((_, sidecars)) = &extraction_result {
451 for (_, extension, content, _) in sidecars {
452 let sidecar_path = output_path.join(format!("{}.{}", base_name, extension));
453 fs::write(&sidecar_path, content).await?;
454 }
455 if let Some(specs) = sidecar_specs {
456 let enriched: Vec<SidecarSpec> = specs
459 .iter()
460 .map(|spec| {
461 let original_format = sidecars
462 .iter()
463 .find(|(el, _, _, _)| el == &spec.element)
464 .and_then(|(_, _, _, fmt)| fmt.clone());
465 SidecarSpec {
466 element: spec.element.clone(),
467 extension: spec.extension.clone(),
468 original_format,
469 }
470 })
471 .collect();
472 if let Ok(json) = serde_json::to_string(&enriched) {
473 let _ = fs::write(output_path.join(".sidecars.json"), json).await;
474 }
475 }
476 }
477
478 if let Some(full_order) = pre_extraction_key_order {
481 let key_order_path = output_path.join(".key_order.json");
482 if let Ok(json) = serde_json::to_string(&full_order) {
483 let _ = fs::write(&key_order_path, json).await;
484 }
485 }
486
487 if let Some(rules) = multi_level_rules {
491 for rule in rules {
492 Self::recursively_disassemble_multi_level(&output_path, rule, format).await?;
493 }
494 }
495
496 Ok(())
497 }
498
499 async fn recursively_disassemble_multi_level(
502 dir_path: &Path,
503 rule: &MultiLevelRule,
504 format: &str,
505 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
506 let mut config = crate::xml::multi_level::load_multi_level_config(dir_path)
507 .await
508 .unwrap_or_default();
509
510 let mut stack = vec![dir_path.to_path_buf()];
511 while let Some(current) = stack.pop() {
512 let mut entries = Vec::new();
513 let mut read_dir = fs::read_dir(¤t).await?;
514 while let Some(entry) = read_dir.next_entry().await? {
515 entries.push(entry);
516 }
517
518 for entry in entries {
519 let path = entry.path();
520 let path_str = path.to_string_lossy().to_string();
521
522 if path.is_dir() {
523 stack.push(path);
524 continue;
525 }
526 {
528 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
529 let path_str_check = path.to_string_lossy();
530 if !Self::file_matches_multi_level_rule(
531 name,
532 &path_str_check,
533 &rule.file_pattern,
534 ) {
535 continue;
536 }
537
538 let parsed = match parse_xml(&path_str).await {
539 Some(p) => p,
540 None => continue,
541 };
542 if !Self::has_element_to_strip(&parsed, &rule.root_to_strip) {
543 continue;
544 }
545
546 let wrap_xmlns = capture_xmlns_from_root(&parsed).unwrap_or_default();
547
548 let stripped_xml = match strip_root_and_build_xml(&parsed, &rule.root_to_strip)
549 {
550 Some(xml) => xml,
551 None => continue,
552 };
553
554 fs::write(&path, stripped_xml).await?;
555
556 let file_stem = path
557 .file_stem()
558 .and_then(|s| s.to_str())
559 .unwrap_or("output");
560 let output_dir_name = Self::output_dir_basename(file_stem);
561 let parent = path.parent().unwrap_or(dir_path);
562 let second_level_output = parent.join(output_dir_name);
563
564 build_disassembled_files_unified(BuildDisassembledFilesOptions {
565 file_path: &path_str,
566 disassembled_path: second_level_output.to_str().unwrap_or("."),
567 base_name: output_dir_name,
568 post_purge: true,
569 format,
570 unique_id_elements: Some(&rule.unique_id_elements),
571 strategy: "unique-id",
572 decompose_rules: None,
573 })
574 .await?;
575
576 let existing_idx = config
580 .rules
581 .iter()
582 .position(|r| Self::rules_have_same_identity(r, rule));
583 match existing_idx {
584 None => {
585 let wrap_root = Self::root_element_name_from_parsed(
586 &parsed,
587 &rule.wrap_root_element,
588 );
589 let path_segment = if rule.path_segment.is_empty() {
590 path_segment_from_file_pattern(&rule.file_pattern)
591 } else {
592 rule.path_segment.clone()
593 };
594 let stored_xmlns = if rule.wrap_xmlns.is_empty() {
595 wrap_xmlns
596 } else {
597 rule.wrap_xmlns.clone()
598 };
599 config.rules.push(MultiLevelRule {
600 file_pattern: rule.file_pattern.clone(),
601 root_to_strip: rule.root_to_strip.clone(),
602 unique_id_elements: rule.unique_id_elements.clone(),
603 path_segment,
604 wrap_root_element: wrap_root,
607 wrap_xmlns: stored_xmlns,
608 });
609 }
610 Some(idx) => {
611 if config.rules[idx].wrap_xmlns.is_empty() {
614 config.rules[idx].wrap_xmlns = wrap_xmlns;
615 }
616 }
617 }
618 }
619 }
620 }
621
622 if !config.rules.is_empty() {
623 save_multi_level_config(dir_path, &config).await?;
624 }
625
626 Ok(())
627 }
628}
629
630impl Default for DisassembleXmlFileHandler {
631 fn default() -> Self {
632 Self::new()
633 }
634}
635
636async fn extract_sidecar_elements(
648 file_path: &str,
649 specs: &[SidecarSpec],
650) -> Result<
651 Option<(String, Vec<(String, String, String, Option<String>)>)>,
652 Box<dyn std::error::Error + Send + Sync>,
653> {
654 let raw = fs::read_to_string(file_path).await?;
655 let Some(mut parsed) = parse_xml_from_str(&raw, file_path) else {
656 return Ok(None);
657 };
658
659 if let (Some(obj), Some(decl)) = (
664 parsed.as_object_mut(),
665 extract_xml_declaration_from_raw(&raw),
666 ) {
667 obj.insert("?xml".to_string(), decl);
668 }
669
670 let root_key = parsed
671 .as_object()
672 .and_then(|o| o.keys().find(|k| *k != "?xml").cloned());
673 let Some(root_key) = root_key else {
674 return Ok(None);
675 };
676
677 let mut sidecars: Vec<(String, String, String, Option<String>)> = Vec::new();
679 if let Some(root_val) = parsed.as_object_mut().and_then(|o| o.get_mut(&root_key)) {
680 if let Some(root_obj) = root_val.as_object_mut() {
681 for spec in specs {
682 let Some(elem_val) = root_obj.remove(&spec.element) else {
683 continue;
684 };
685 let text = match &elem_val {
688 serde_json::Value::Object(obj) => obj
689 .get("#text")
690 .and_then(|v| v.as_str())
691 .unwrap_or("")
692 .to_string(),
693 _ => {
694 root_obj.insert(spec.element.clone(), elem_val);
695 continue;
696 }
697 };
698 let original_format = detect_content_format(&text);
699 sidecars.push((
700 spec.element.clone(),
701 spec.extension.clone(),
702 convert_sidecar_content(&text, &spec.extension),
703 original_format,
704 ));
705 }
706 }
707 }
708
709 if sidecars.is_empty() {
710 Ok(None)
711 } else {
712 Ok(Some((build_xml_string(&parsed), sidecars)))
713 }
714}
715
716fn detect_content_format(text: &str) -> Option<String> {
719 if serde_json::from_str::<serde_json::Value>(text).is_ok() {
720 Some("json".to_string())
721 } else if serde_yaml::from_str::<serde_yaml::Value>(text).is_ok() {
722 Some("yaml".to_string())
723 } else {
724 None
725 }
726}
727
728fn convert_sidecar_content(text: &str, extension: &str) -> String {
737 match extension.to_ascii_lowercase().as_str() {
738 "json" => {
739 match serde_yaml::from_str::<serde_yaml::Value>(text) {
743 Ok(val) => match serde_json::to_string_pretty(&val) {
744 Ok(json) => json,
745 Err(e) => {
746 log::warn!("sidecar: JSON serialization failed ({e}); using raw text");
747 text.to_string()
748 }
749 },
750 Err(e) => {
751 log::warn!(
752 "sidecar: could not parse content for JSON conversion ({e}); using raw text"
753 );
754 text.to_string()
755 }
756 }
757 }
758 "yaml" | "yml" => {
759 if serde_json::from_str::<serde_json::Value>(text).is_ok() {
762 match serde_yaml::from_str::<serde_yaml::Value>(text)
763 .ok()
764 .and_then(|v| serde_yaml::to_string(&v).ok())
765 {
766 Some(yaml) => yaml,
767 None => {
768 log::warn!("sidecar: YAML serialization failed; using raw text");
769 text.to_string()
770 }
771 }
772 } else {
773 text.to_string()
774 }
775 }
776 _ => text.to_string(),
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[test]
785 #[allow(clippy::default_constructed_unit_structs)]
786 fn disassemble_handler_default_equals_new() {
787 let _ = DisassembleXmlFileHandler::default();
788 }
789
790 #[test]
791 fn is_xml_file_matches_case_insensitively() {
792 assert!(DisassembleXmlFileHandler::is_xml_file("foo.xml"));
793 assert!(DisassembleXmlFileHandler::is_xml_file("BAR.XML"));
794 assert!(!DisassembleXmlFileHandler::is_xml_file("foo.txt"));
795 }
796
797 #[test]
798 fn posix_path_converts_backslashes() {
799 assert_eq!(
800 DisassembleXmlFileHandler::posix_path(r"C:\Users\name\file.xml"),
801 "C:/Users/name/file.xml"
802 );
803 }
804
805 #[tokio::test]
806 async fn load_ignore_rules_noop_when_path_missing() {
807 let mut handler = DisassembleXmlFileHandler::new();
808 handler
809 .load_ignore_rules("/definitely/does/not/exist/.ignore")
810 .await;
811 assert!(handler.ign.is_none());
812 }
813
814 #[tokio::test]
815 async fn load_ignore_rules_builds_matcher() {
816 let temp = tempfile::tempdir().unwrap();
817 let path = temp.path().join(".ignore");
818 tokio::fs::write(&path, "*.xml\n").await.unwrap();
819 let mut handler = DisassembleXmlFileHandler::new();
820 handler.load_ignore_rules(path.to_str().unwrap()).await;
821 assert!(handler.ign.is_some());
822 assert!(handler.is_ignored("file.xml"));
823 assert!(!handler.is_ignored("file.txt"));
824 }
825
826 #[test]
827 fn is_ignored_default_false_without_rules() {
828 let handler = DisassembleXmlFileHandler::new();
829 assert!(!handler.is_ignored("some/path.xml"));
830 }
831
832 #[test]
833 fn output_dir_basename_strips_only_last_dot_segment() {
834 assert_eq!(
836 DisassembleXmlFileHandler::output_dir_basename("HR_Admin.permissionset-meta"),
837 "HR_Admin"
838 );
839 assert_eq!(
840 DisassembleXmlFileHandler::output_dir_basename("Get_Info.flow-meta"),
841 "Get_Info"
842 );
843 }
844
845 #[test]
846 fn output_dir_basename_preserves_dotted_full_names() {
847 assert_eq!(
852 DisassembleXmlFileHandler::output_dir_basename(
853 "Account_Merge__c.New_Account_Merges_2.approvalProcess-meta"
854 ),
855 "Account_Merge__c.New_Account_Merges_2"
856 );
857 assert_eq!(
858 DisassembleXmlFileHandler::output_dir_basename(
859 "Account_Merge__c.New_Account_Merges_3.approvalProcess-meta"
860 ),
861 "Account_Merge__c.New_Account_Merges_3"
862 );
863 assert_eq!(
865 DisassembleXmlFileHandler::output_dir_basename("Case.LogACall.quickAction-meta"),
866 "Case.LogACall"
867 );
868 }
869
870 #[test]
871 fn is_processable_xml_entry_true_only_for_regular_xml_files() {
872 assert!(DisassembleXmlFileHandler::is_processable_xml_entry(
876 true, "foo.xml"
877 ));
878 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
879 false, "foo.xml"
880 ));
881 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
882 true, "foo.txt"
883 ));
884 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
885 false, "foo.txt"
886 ));
887 }
888
889 #[test]
890 fn should_pre_purge_output_requires_both_flag_and_existing_dir() {
891 assert!(DisassembleXmlFileHandler::should_pre_purge_output(
895 true, true
896 ));
897 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
898 true, false
899 ));
900 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
901 false, true
902 ));
903 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
904 false, false
905 ));
906 }
907
908 #[test]
909 fn file_matches_multi_level_rule_requires_xml_extension() {
910 assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
912 "Foo.txt",
913 "/dir/Foo.txt",
914 "Foo"
915 ));
916 }
917
918 #[test]
919 fn file_matches_multi_level_rule_when_filename_contains_pattern() {
920 assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
921 "MyPattern.xml",
922 "/dir/MyPattern.xml",
923 "MyPattern"
924 ));
925 }
926
927 #[test]
928 fn file_matches_multi_level_rule_when_only_full_path_contains_pattern() {
929 assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
932 "child.xml",
933 "/parentPattern/child.xml",
934 "parentPattern"
935 ));
936 }
937
938 #[test]
939 fn file_matches_multi_level_rule_false_when_pattern_absent_everywhere() {
940 assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
941 "Foo.xml",
942 "/dir/Foo.xml",
943 "MissingPattern"
944 ));
945 }
946
947 #[test]
948 fn has_element_to_strip_when_root_key_matches() {
949 let parsed = serde_json::json!({"Foo": {"a": "b"}});
950 assert!(DisassembleXmlFileHandler::has_element_to_strip(
951 &parsed, "Foo"
952 ));
953 }
954
955 #[test]
956 fn has_element_to_strip_when_root_contains_target_child() {
957 let parsed = serde_json::json!({"Foo": {"Bar": {"a": "b"}}});
958 assert!(DisassembleXmlFileHandler::has_element_to_strip(
959 &parsed, "Bar"
960 ));
961 }
962
963 #[test]
964 fn has_element_to_strip_false_when_target_absent() {
965 let parsed = serde_json::json!({"Foo": {"a": "b"}});
966 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
967 &parsed, "Missing"
968 ));
969 }
970
971 #[test]
972 fn has_element_to_strip_false_for_non_object_or_decl_only() {
973 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
974 &serde_json::json!("primitive"),
975 "Foo"
976 ));
977 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
978 &serde_json::json!({"?xml": {}}),
979 "Foo"
980 ));
981 }
982
983 fn rule(pattern: &str, root: &str) -> MultiLevelRule {
984 MultiLevelRule {
985 file_pattern: pattern.to_string(),
986 root_to_strip: root.to_string(),
987 unique_id_elements: String::new(),
988 path_segment: String::new(),
989 wrap_root_element: String::new(),
990 wrap_xmlns: String::new(),
991 }
992 }
993
994 #[test]
995 fn rules_share_identity_when_pattern_and_root_match() {
996 assert!(DisassembleXmlFileHandler::rules_have_same_identity(
997 &rule("p", "R"),
998 &rule("p", "R"),
999 ));
1000 }
1001
1002 #[test]
1003 fn rules_differ_when_file_pattern_differs() {
1004 assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
1005 &rule("p1", "R"),
1006 &rule("p2", "R"),
1007 ));
1008 }
1009
1010 #[test]
1011 fn rules_differ_when_root_to_strip_differs() {
1012 assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
1013 &rule("p", "R1"),
1014 &rule("p", "R2"),
1015 ));
1016 }
1017
1018 #[test]
1019 fn root_element_name_finds_first_non_declaration_key() {
1020 let parsed = serde_json::json!({"?xml": {}, "MyRoot": {"a": "b"}});
1021 assert_eq!(
1022 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "fallback"),
1023 "MyRoot"
1024 );
1025 }
1026
1027 #[test]
1028 fn root_element_name_falls_back_when_only_declaration_present() {
1029 let parsed = serde_json::json!({"?xml": {}});
1030 assert_eq!(
1031 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "FallbackRoot"),
1032 "FallbackRoot"
1033 );
1034 }
1035
1036 #[test]
1037 fn root_element_name_falls_back_for_non_object() {
1038 let parsed = serde_json::json!("primitive");
1039 assert_eq!(
1040 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "Fb"),
1041 "Fb"
1042 );
1043 }
1044
1045 #[test]
1046 fn output_dir_basename_no_dot_returns_stem_unchanged() {
1047 assert_eq!(DisassembleXmlFileHandler::output_dir_basename("Foo"), "Foo");
1049 assert_eq!(DisassembleXmlFileHandler::output_dir_basename(""), "");
1050 }
1051
1052 #[test]
1053 fn convert_sidecar_content_yaml_to_json() {
1054 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";
1058 let out = convert_sidecar_content(yaml, "json");
1059 let val: serde_json::Value = serde_json::from_str(&out).expect("output must be valid JSON");
1060 assert_eq!(val["openapi"], "3.0.1");
1061 assert_eq!(val["info"]["title"], "@AuraEnabled Apex method APIs");
1062 assert_eq!(val["info"]["version"], "1.0.0");
1063 assert_eq!(
1064 val["paths"]["/uploadFile"]["post"]["operationId"],
1065 "uploadFile"
1066 );
1067 }
1068
1069 #[test]
1070 fn convert_sidecar_content_json_to_yaml() {
1071 let json = r#"{"key":"value","num":42}"#;
1072 let out = convert_sidecar_content(json, "yaml");
1073 assert!(
1077 serde_json::from_str::<serde_json::Value>(&out).is_err(),
1078 "output must be YAML format, not raw JSON: {out}"
1079 );
1080 let val: serde_json::Value = serde_yaml::from_str(&out).expect("output must be valid YAML");
1081 assert_eq!(val["key"], "value");
1082 assert_eq!(val["num"], 42);
1083 }
1084
1085 #[test]
1086 fn convert_sidecar_content_json_to_json_prettifies() {
1087 let compact = r#"{"a":1}"#;
1088 let out = convert_sidecar_content(compact, "json");
1089 assert!(out.contains('\n'), "expected pretty JSON, got: {out}");
1091 let val: serde_json::Value = serde_json::from_str(&out).unwrap();
1092 assert_eq!(val["a"], 1);
1093 }
1094
1095 #[test]
1096 fn convert_sidecar_content_unknown_extension_passes_through() {
1097 let raw = "arbitrary: content: here";
1098 assert_eq!(convert_sidecar_content(raw, "txt"), raw);
1099 assert_eq!(convert_sidecar_content(raw, ""), raw);
1100 }
1101
1102 #[test]
1103 fn convert_sidecar_content_malformed_falls_back_to_raw() {
1104 let bad = "{{{{ not valid json or yaml at all >>>>>";
1106 assert_eq!(convert_sidecar_content(bad, "json"), bad);
1107 assert_eq!(convert_sidecar_content(bad, "yaml"), bad);
1108 }
1109
1110 #[test]
1111 fn convert_sidecar_content_yml_extension_same_as_yaml() {
1112 let json = r#"{"x":true}"#;
1113 let out = convert_sidecar_content(json, "yml");
1114 let val: serde_json::Value = serde_yaml::from_str(&out).unwrap();
1115 assert_eq!(val["x"], true);
1116 }
1117
1118 #[test]
1119 fn convert_sidecar_content_yaml_passes_through_unchanged() {
1120 let yaml = "title: \"@AuraEnabled Apex method APIs\"\nversion: 1.0.0\n";
1123 assert_eq!(convert_sidecar_content(yaml, "yaml"), yaml);
1124 assert_eq!(convert_sidecar_content(yaml, "yml"), yaml);
1125 }
1126}