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 tokio::fs;
17
18pub struct DisassembleXmlFileHandler {
19 ign: Option<ignore::gitignore::Gitignore>,
20}
21
22impl DisassembleXmlFileHandler {
23 pub fn new() -> Self {
24 Self { ign: None }
25 }
26
27 async fn load_ignore_rules(&mut self, ignore_path: &str) {
28 let path = Path::new(ignore_path);
29 let content = match fs::read_to_string(path).await {
30 Ok(c) => c,
31 Err(_) => return,
32 };
33 let root = path.parent().unwrap_or(Path::new("."));
34 let mut builder = GitignoreBuilder::new(root);
35 for line in content.lines() {
36 let _ = builder.add_line(None, line);
37 }
38 self.ign = builder.build().ok();
40 }
41
42 fn posix_path(path: &str) -> String {
43 path.replace('\\', "/")
44 }
45
46 fn is_xml_file(file_path: &str) -> bool {
47 file_path.to_lowercase().ends_with(".xml")
48 }
49
50 fn is_processable_xml_entry(is_file: bool, file_name: &str) -> bool {
55 is_file && Self::is_xml_file(file_name)
56 }
57
58 fn should_pre_purge_output(pre_purge: bool, output_exists: bool) -> bool {
63 pre_purge && output_exists
64 }
65
66 fn file_matches_multi_level_rule(file_name: &str, full_path: &str, file_pattern: &str) -> bool {
70 file_name.ends_with(".xml")
71 && (file_name.contains(file_pattern) || full_path.contains(file_pattern))
72 }
73
74 fn has_element_to_strip(parsed: &serde_json::Value, root_to_strip: &str) -> bool {
78 parsed
79 .as_object()
80 .and_then(|o| {
81 let root_key = o.keys().find(|k| *k != "?xml")?;
82 let root_val = o.get(root_key)?.as_object()?;
83 Some(root_key == root_to_strip || root_val.contains_key(root_to_strip))
84 })
85 .unwrap_or(false)
86 }
87
88 fn rules_have_same_identity(a: &MultiLevelRule, b: &MultiLevelRule) -> bool {
94 a.file_pattern == b.file_pattern && a.root_to_strip == b.root_to_strip
95 }
96
97 fn root_element_name_from_parsed(parsed: &serde_json::Value, fallback: &str) -> String {
102 parsed
103 .as_object()
104 .and_then(|o| o.keys().find(|k| *k != "?xml").cloned())
105 .unwrap_or_else(|| fallback.to_string())
106 }
107
108 fn is_ignored(&self, path: &str) -> bool {
109 self.ign
110 .as_ref()
111 .map(|ign| ign.matched(path, false).is_ignore())
112 .unwrap_or(false)
113 }
114
115 fn output_dir_basename(file_stem: &str) -> &str {
125 file_stem
126 .rsplit_once('.')
127 .map(|(prefix, _)| prefix)
128 .unwrap_or(file_stem)
129 }
130
131 #[allow(clippy::too_many_arguments)]
132 pub async fn disassemble(
133 &mut self,
134 file_path: &str,
135 unique_id_elements: Option<&str>,
136 strategy: Option<&str>,
137 pre_purge: bool,
138 post_purge: bool,
139 ignore_path: &str,
140 format: &str,
141 multi_level_rules: Option<&[MultiLevelRule]>,
142 decompose_rules: Option<&[DecomposeRule]>,
143 sidecar_specs: Option<&[SidecarSpec]>,
144 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
145 let strategy = strategy.unwrap_or("unique-id");
146 let strategy = if ["unique-id", "grouped-by-tag"].contains(&strategy) {
147 strategy
148 } else {
149 log::warn!(
150 "Unsupported strategy \"{}\", defaulting to \"unique-id\".",
151 strategy
152 );
153 "unique-id"
154 };
155
156 self.load_ignore_rules(ignore_path).await;
157
158 let path = Path::new(file_path);
159 let meta = fs::metadata(path).await?;
160 let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
161 let relative_path = path.strip_prefix(&cwd).unwrap_or(path).to_string_lossy();
162 let relative_path = Self::posix_path(&relative_path);
163
164 let multi_level_rules = multi_level_rules.filter(|rules| !rules.is_empty());
166
167 if meta.is_file() {
168 self.handle_file(
169 file_path,
170 &relative_path,
171 unique_id_elements,
172 strategy,
173 pre_purge,
174 post_purge,
175 format,
176 multi_level_rules,
177 decompose_rules,
178 sidecar_specs,
179 )
180 .await?;
181 } else {
182 self.handle_directory(
185 file_path,
186 unique_id_elements,
187 strategy,
188 pre_purge,
189 post_purge,
190 format,
191 multi_level_rules,
192 decompose_rules,
193 sidecar_specs,
194 )
195 .await?;
196 }
197
198 Ok(())
199 }
200
201 #[allow(clippy::too_many_arguments)]
202 async fn handle_file(
203 &self,
204 file_path: &str,
205 relative_path: &str,
206 unique_id_elements: Option<&str>,
207 strategy: &str,
208 pre_purge: bool,
209 post_purge: bool,
210 format: &str,
211 multi_level_rules: Option<&[MultiLevelRule]>,
212 decompose_rules: Option<&[DecomposeRule]>,
213 sidecar_specs: Option<&[SidecarSpec]>,
214 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
215 let resolved = Path::new(file_path)
216 .canonicalize()
217 .unwrap_or_else(|_| Path::new(file_path).to_path_buf());
218 let resolved_str = normalize_path_unix(&resolved.to_string_lossy());
219
220 if !Self::is_xml_file(&resolved_str) {
221 log::error!(
222 "The file path provided is not an XML file: {}",
223 resolved_str
224 );
225 return Ok(());
226 }
227
228 if self.is_ignored(relative_path) {
229 log::warn!("File ignored by ignore rules: {}", resolved_str);
230 return Ok(());
231 }
232
233 let dir_path = resolved.parent().unwrap_or(Path::new("."));
234 let dir_path_str = normalize_path_unix(&dir_path.to_string_lossy());
235 self.process_file(
236 &dir_path_str,
237 strategy,
238 &resolved_str,
239 unique_id_elements,
240 pre_purge,
241 post_purge,
242 format,
243 multi_level_rules,
244 decompose_rules,
245 sidecar_specs,
246 )
247 .await
248 }
249
250 #[allow(clippy::too_many_arguments)]
251 async fn handle_directory(
252 &self,
253 dir_path: &str,
254 unique_id_elements: Option<&str>,
255 strategy: &str,
256 pre_purge: bool,
257 post_purge: bool,
258 format: &str,
259 multi_level_rules: Option<&[MultiLevelRule]>,
260 decompose_rules: Option<&[DecomposeRule]>,
261 sidecar_specs: Option<&[SidecarSpec]>,
262 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
263 let dir_path = normalize_path_unix(dir_path);
264 let mut entries = fs::read_dir(&dir_path).await?;
265 let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
266
267 while let Some(entry) = entries.next_entry().await? {
268 let sub_path = entry.path();
269 let sub_file_path = sub_path.to_string_lossy();
270 let relative_sub = sub_path
271 .strip_prefix(&cwd)
272 .unwrap_or(&sub_path)
273 .to_string_lossy();
274 let relative_sub = Self::posix_path(&relative_sub);
275
276 if !Self::is_processable_xml_entry(sub_path.is_file(), &sub_file_path) {
277 continue;
278 }
279 if self.is_ignored(&relative_sub) {
280 log::warn!("File ignored by ignore rules: {}", sub_file_path);
281 continue;
282 }
283 let sub_file_path_norm = normalize_path_unix(&sub_file_path);
284 self.process_file(
285 &dir_path,
286 strategy,
287 &sub_file_path_norm,
288 unique_id_elements,
289 pre_purge,
290 post_purge,
291 format,
292 multi_level_rules,
293 decompose_rules,
294 sidecar_specs,
295 )
296 .await?;
297 }
298 Ok(())
299 }
300
301 #[allow(clippy::too_many_arguments)]
302 async fn process_file(
303 &self,
304 dir_path: &str,
305 strategy: &str,
306 file_path: &str,
307 unique_id_elements: Option<&str>,
308 pre_purge: bool,
309 post_purge: bool,
310 format: &str,
311 multi_level_rules: Option<&[MultiLevelRule]>,
312 decompose_rules: Option<&[DecomposeRule]>,
313 sidecar_specs: Option<&[SidecarSpec]>,
314 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
315 log::debug!("Parsing file to disassemble: {}", file_path);
316
317 let file_name = Path::new(file_path)
318 .file_stem()
319 .and_then(|s| s.to_str())
320 .unwrap_or("output");
321 let base_name = Self::output_dir_basename(file_name);
322 let output_path = Path::new(dir_path).join(base_name);
323
324 if Self::should_pre_purge_output(pre_purge, output_path.exists()) {
325 fs::remove_dir_all(&output_path).await.ok();
326 }
327
328 let pre_extraction_key_order: Option<Vec<String>> =
331 if sidecar_specs.is_some_and(|s| !s.is_empty()) {
332 parse_xml(file_path).await.and_then(|parsed| {
333 let obj = parsed.as_object()?;
334 let root_key = obj.keys().find(|k| *k != "?xml")?;
335 obj.get(root_key)?.as_object().map(|root_obj| {
336 root_obj
337 .keys()
338 .filter(|k| !k.starts_with('@'))
339 .cloned()
340 .collect()
341 })
342 })
343 } else {
344 None
345 };
346
347 let extraction_result = if let Some(specs) = sidecar_specs {
353 if !specs.is_empty() {
354 extract_sidecar_elements(file_path, specs).await?
355 } else {
356 None
357 }
358 } else {
359 None
360 };
361
362 let temp_file: Option<tempfile::NamedTempFile>;
363 let disassemble_path: &str;
364 if let Some((xml, _)) = &extraction_result {
365 let mut tmp = tempfile::Builder::new()
366 .suffix(".xml")
367 .tempfile_in(Path::new(file_path).parent().unwrap_or(Path::new(".")))?;
368 tmp.write_all(xml.as_bytes())?;
369 temp_file = Some(tmp);
370 disassemble_path = temp_file
371 .as_ref()
372 .unwrap()
373 .path()
374 .to_str()
375 .unwrap_or(file_path);
376 } else {
377 temp_file = None;
378 disassemble_path = file_path;
379 }
380
381 build_disassembled_files_unified(BuildDisassembledFilesOptions {
382 file_path: disassemble_path,
383 disassembled_path: output_path.to_str().unwrap_or("."),
384 base_name: file_name,
385 post_purge,
386 format,
387 unique_id_elements,
388 strategy,
389 decompose_rules,
390 })
391 .await?;
392
393 drop(temp_file); if let Some((_, sidecars)) = &extraction_result {
398 for (extension, content) in sidecars {
399 let sidecar_path = output_path.join(format!("{}.{}", base_name, extension));
400 fs::write(&sidecar_path, content).await?;
401 }
402 if let Some(specs) = sidecar_specs {
403 if let Ok(json) = serde_json::to_string(specs) {
404 let _ = fs::write(output_path.join(".sidecars.json"), json).await;
405 }
406 }
407 }
408
409 if let Some(full_order) = pre_extraction_key_order {
412 let key_order_path = output_path.join(".key_order.json");
413 if let Ok(json) = serde_json::to_string(&full_order) {
414 let _ = fs::write(&key_order_path, json).await;
415 }
416 }
417
418 if let Some(rules) = multi_level_rules {
422 for rule in rules {
423 self.recursively_disassemble_multi_level(&output_path, rule, format)
424 .await?;
425 }
426 }
427
428 Ok(())
429 }
430
431 async fn recursively_disassemble_multi_level(
434 &self,
435 dir_path: &Path,
436 rule: &MultiLevelRule,
437 format: &str,
438 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
439 let mut config = crate::xml::multi_level::load_multi_level_config(dir_path)
440 .await
441 .unwrap_or_default();
442
443 let mut stack = vec![dir_path.to_path_buf()];
444 while let Some(current) = stack.pop() {
445 let mut entries = Vec::new();
446 let mut read_dir = fs::read_dir(¤t).await?;
447 while let Some(entry) = read_dir.next_entry().await? {
448 entries.push(entry);
449 }
450
451 for entry in entries {
452 let path = entry.path();
453 let path_str = path.to_string_lossy().to_string();
454
455 if path.is_dir() {
456 stack.push(path);
457 continue;
458 }
459 {
461 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
462 let path_str_check = path.to_string_lossy();
463 if !Self::file_matches_multi_level_rule(
464 name,
465 &path_str_check,
466 &rule.file_pattern,
467 ) {
468 continue;
469 }
470
471 let parsed = match parse_xml(&path_str).await {
472 Some(p) => p,
473 None => continue,
474 };
475 if !Self::has_element_to_strip(&parsed, &rule.root_to_strip) {
476 continue;
477 }
478
479 let wrap_xmlns = capture_xmlns_from_root(&parsed).unwrap_or_default();
480
481 let stripped_xml = match strip_root_and_build_xml(&parsed, &rule.root_to_strip)
482 {
483 Some(xml) => xml,
484 None => continue,
485 };
486
487 fs::write(&path, stripped_xml).await?;
488
489 let file_stem = path
490 .file_stem()
491 .and_then(|s| s.to_str())
492 .unwrap_or("output");
493 let output_dir_name = Self::output_dir_basename(file_stem);
494 let parent = path.parent().unwrap_or(dir_path);
495 let second_level_output = parent.join(output_dir_name);
496
497 build_disassembled_files_unified(BuildDisassembledFilesOptions {
498 file_path: &path_str,
499 disassembled_path: second_level_output.to_str().unwrap_or("."),
500 base_name: output_dir_name,
501 post_purge: true,
502 format,
503 unique_id_elements: Some(&rule.unique_id_elements),
504 strategy: "unique-id",
505 decompose_rules: None,
506 })
507 .await?;
508
509 let existing_idx = config
513 .rules
514 .iter()
515 .position(|r| Self::rules_have_same_identity(r, rule));
516 match existing_idx {
517 None => {
518 let wrap_root = Self::root_element_name_from_parsed(
519 &parsed,
520 &rule.wrap_root_element,
521 );
522 let path_segment = if rule.path_segment.is_empty() {
523 path_segment_from_file_pattern(&rule.file_pattern)
524 } else {
525 rule.path_segment.clone()
526 };
527 let stored_xmlns = if rule.wrap_xmlns.is_empty() {
528 wrap_xmlns
529 } else {
530 rule.wrap_xmlns.clone()
531 };
532 config.rules.push(MultiLevelRule {
533 file_pattern: rule.file_pattern.clone(),
534 root_to_strip: rule.root_to_strip.clone(),
535 unique_id_elements: rule.unique_id_elements.clone(),
536 path_segment,
537 wrap_root_element: wrap_root,
540 wrap_xmlns: stored_xmlns,
541 });
542 }
543 Some(idx) => {
544 if config.rules[idx].wrap_xmlns.is_empty() {
547 config.rules[idx].wrap_xmlns = wrap_xmlns;
548 }
549 }
550 }
551 }
552 }
553 }
554
555 if !config.rules.is_empty() {
556 save_multi_level_config(dir_path, &config).await?;
557 }
558
559 Ok(())
560 }
561}
562
563impl Default for DisassembleXmlFileHandler {
564 fn default() -> Self {
565 Self::new()
566 }
567}
568
569async fn extract_sidecar_elements(
581 file_path: &str,
582 specs: &[SidecarSpec],
583) -> Result<Option<(String, Vec<(String, String)>)>, Box<dyn std::error::Error + Send + Sync>> {
584 let raw = fs::read_to_string(file_path).await?;
585 let Some(mut parsed) = parse_xml_from_str(&raw, file_path) else {
586 return Ok(None);
587 };
588
589 if let (Some(obj), Some(decl)) = (
594 parsed.as_object_mut(),
595 extract_xml_declaration_from_raw(&raw),
596 ) {
597 obj.insert("?xml".to_string(), decl);
598 }
599
600 let root_key = parsed
601 .as_object()
602 .and_then(|o| o.keys().find(|k| *k != "?xml").cloned());
603 let Some(root_key) = root_key else {
604 return Ok(None);
605 };
606
607 let mut sidecars: Vec<(String, String)> = Vec::new();
608 if let Some(root_val) = parsed.as_object_mut().and_then(|o| o.get_mut(&root_key)) {
609 if let Some(root_obj) = root_val.as_object_mut() {
610 for spec in specs {
611 let Some(elem_val) = root_obj.remove(&spec.element) else {
612 continue;
613 };
614 let text = match &elem_val {
617 serde_json::Value::Object(obj) => obj
618 .get("#text")
619 .and_then(|v| v.as_str())
620 .unwrap_or("")
621 .to_string(),
622 _ => {
623 root_obj.insert(spec.element.clone(), elem_val);
624 continue;
625 }
626 };
627 sidecars.push((
628 spec.extension.clone(),
629 convert_sidecar_content(&text, &spec.extension),
630 ));
631 }
632 }
633 }
634
635 if sidecars.is_empty() {
636 Ok(None)
637 } else {
638 Ok(Some((build_xml_string(&parsed), sidecars)))
639 }
640}
641
642fn convert_sidecar_content(text: &str, extension: &str) -> String {
651 match extension.to_ascii_lowercase().as_str() {
652 "json" => {
653 match serde_yaml::from_str::<serde_yaml::Value>(text) {
657 Ok(val) => match serde_json::to_string_pretty(&val) {
658 Ok(json) => json,
659 Err(e) => {
660 log::warn!("sidecar: JSON serialization failed ({e}); using raw text");
661 text.to_string()
662 }
663 },
664 Err(e) => {
665 log::warn!(
666 "sidecar: could not parse content for JSON conversion ({e}); using raw text"
667 );
668 text.to_string()
669 }
670 }
671 }
672 "yaml" | "yml" => {
673 if serde_json::from_str::<serde_json::Value>(text).is_ok() {
676 match serde_yaml::from_str::<serde_yaml::Value>(text)
677 .ok()
678 .and_then(|v| serde_yaml::to_string(&v).ok())
679 {
680 Some(yaml) => yaml,
681 None => {
682 log::warn!("sidecar: YAML serialization failed; using raw text");
683 text.to_string()
684 }
685 }
686 } else {
687 text.to_string()
688 }
689 }
690 _ => text.to_string(),
691 }
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697
698 #[test]
699 #[allow(clippy::default_constructed_unit_structs)]
700 fn disassemble_handler_default_equals_new() {
701 let _ = DisassembleXmlFileHandler::default();
702 }
703
704 #[test]
705 fn is_xml_file_matches_case_insensitively() {
706 assert!(DisassembleXmlFileHandler::is_xml_file("foo.xml"));
707 assert!(DisassembleXmlFileHandler::is_xml_file("BAR.XML"));
708 assert!(!DisassembleXmlFileHandler::is_xml_file("foo.txt"));
709 }
710
711 #[test]
712 fn posix_path_converts_backslashes() {
713 assert_eq!(
714 DisassembleXmlFileHandler::posix_path(r"C:\Users\name\file.xml"),
715 "C:/Users/name/file.xml"
716 );
717 }
718
719 #[tokio::test]
720 async fn load_ignore_rules_noop_when_path_missing() {
721 let mut handler = DisassembleXmlFileHandler::new();
722 handler
723 .load_ignore_rules("/definitely/does/not/exist/.ignore")
724 .await;
725 assert!(handler.ign.is_none());
726 }
727
728 #[tokio::test]
729 async fn load_ignore_rules_builds_matcher() {
730 let temp = tempfile::tempdir().unwrap();
731 let path = temp.path().join(".ignore");
732 tokio::fs::write(&path, "*.xml\n").await.unwrap();
733 let mut handler = DisassembleXmlFileHandler::new();
734 handler.load_ignore_rules(path.to_str().unwrap()).await;
735 assert!(handler.ign.is_some());
736 assert!(handler.is_ignored("file.xml"));
737 assert!(!handler.is_ignored("file.txt"));
738 }
739
740 #[test]
741 fn is_ignored_default_false_without_rules() {
742 let handler = DisassembleXmlFileHandler::new();
743 assert!(!handler.is_ignored("some/path.xml"));
744 }
745
746 #[test]
747 fn output_dir_basename_strips_only_last_dot_segment() {
748 assert_eq!(
750 DisassembleXmlFileHandler::output_dir_basename("HR_Admin.permissionset-meta"),
751 "HR_Admin"
752 );
753 assert_eq!(
754 DisassembleXmlFileHandler::output_dir_basename("Get_Info.flow-meta"),
755 "Get_Info"
756 );
757 }
758
759 #[test]
760 fn output_dir_basename_preserves_dotted_full_names() {
761 assert_eq!(
766 DisassembleXmlFileHandler::output_dir_basename(
767 "Account_Merge__c.New_Account_Merges_2.approvalProcess-meta"
768 ),
769 "Account_Merge__c.New_Account_Merges_2"
770 );
771 assert_eq!(
772 DisassembleXmlFileHandler::output_dir_basename(
773 "Account_Merge__c.New_Account_Merges_3.approvalProcess-meta"
774 ),
775 "Account_Merge__c.New_Account_Merges_3"
776 );
777 assert_eq!(
779 DisassembleXmlFileHandler::output_dir_basename("Case.LogACall.quickAction-meta"),
780 "Case.LogACall"
781 );
782 }
783
784 #[test]
785 fn is_processable_xml_entry_true_only_for_regular_xml_files() {
786 assert!(DisassembleXmlFileHandler::is_processable_xml_entry(
790 true, "foo.xml"
791 ));
792 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
793 false, "foo.xml"
794 ));
795 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
796 true, "foo.txt"
797 ));
798 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
799 false, "foo.txt"
800 ));
801 }
802
803 #[test]
804 fn should_pre_purge_output_requires_both_flag_and_existing_dir() {
805 assert!(DisassembleXmlFileHandler::should_pre_purge_output(
809 true, true
810 ));
811 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
812 true, false
813 ));
814 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
815 false, true
816 ));
817 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
818 false, false
819 ));
820 }
821
822 #[test]
823 fn file_matches_multi_level_rule_requires_xml_extension() {
824 assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
826 "Foo.txt",
827 "/dir/Foo.txt",
828 "Foo"
829 ));
830 }
831
832 #[test]
833 fn file_matches_multi_level_rule_when_filename_contains_pattern() {
834 assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
835 "MyPattern.xml",
836 "/dir/MyPattern.xml",
837 "MyPattern"
838 ));
839 }
840
841 #[test]
842 fn file_matches_multi_level_rule_when_only_full_path_contains_pattern() {
843 assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
846 "child.xml",
847 "/parentPattern/child.xml",
848 "parentPattern"
849 ));
850 }
851
852 #[test]
853 fn file_matches_multi_level_rule_false_when_pattern_absent_everywhere() {
854 assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
855 "Foo.xml",
856 "/dir/Foo.xml",
857 "MissingPattern"
858 ));
859 }
860
861 #[test]
862 fn has_element_to_strip_when_root_key_matches() {
863 let parsed = serde_json::json!({"Foo": {"a": "b"}});
864 assert!(DisassembleXmlFileHandler::has_element_to_strip(
865 &parsed, "Foo"
866 ));
867 }
868
869 #[test]
870 fn has_element_to_strip_when_root_contains_target_child() {
871 let parsed = serde_json::json!({"Foo": {"Bar": {"a": "b"}}});
872 assert!(DisassembleXmlFileHandler::has_element_to_strip(
873 &parsed, "Bar"
874 ));
875 }
876
877 #[test]
878 fn has_element_to_strip_false_when_target_absent() {
879 let parsed = serde_json::json!({"Foo": {"a": "b"}});
880 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
881 &parsed, "Missing"
882 ));
883 }
884
885 #[test]
886 fn has_element_to_strip_false_for_non_object_or_decl_only() {
887 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
888 &serde_json::json!("primitive"),
889 "Foo"
890 ));
891 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
892 &serde_json::json!({"?xml": {}}),
893 "Foo"
894 ));
895 }
896
897 fn rule(pattern: &str, root: &str) -> MultiLevelRule {
898 MultiLevelRule {
899 file_pattern: pattern.to_string(),
900 root_to_strip: root.to_string(),
901 unique_id_elements: String::new(),
902 path_segment: String::new(),
903 wrap_root_element: String::new(),
904 wrap_xmlns: String::new(),
905 }
906 }
907
908 #[test]
909 fn rules_share_identity_when_pattern_and_root_match() {
910 assert!(DisassembleXmlFileHandler::rules_have_same_identity(
911 &rule("p", "R"),
912 &rule("p", "R"),
913 ));
914 }
915
916 #[test]
917 fn rules_differ_when_file_pattern_differs() {
918 assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
919 &rule("p1", "R"),
920 &rule("p2", "R"),
921 ));
922 }
923
924 #[test]
925 fn rules_differ_when_root_to_strip_differs() {
926 assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
927 &rule("p", "R1"),
928 &rule("p", "R2"),
929 ));
930 }
931
932 #[test]
933 fn root_element_name_finds_first_non_declaration_key() {
934 let parsed = serde_json::json!({"?xml": {}, "MyRoot": {"a": "b"}});
935 assert_eq!(
936 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "fallback"),
937 "MyRoot"
938 );
939 }
940
941 #[test]
942 fn root_element_name_falls_back_when_only_declaration_present() {
943 let parsed = serde_json::json!({"?xml": {}});
944 assert_eq!(
945 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "FallbackRoot"),
946 "FallbackRoot"
947 );
948 }
949
950 #[test]
951 fn root_element_name_falls_back_for_non_object() {
952 let parsed = serde_json::json!("primitive");
953 assert_eq!(
954 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "Fb"),
955 "Fb"
956 );
957 }
958
959 #[test]
960 fn output_dir_basename_no_dot_returns_stem_unchanged() {
961 assert_eq!(DisassembleXmlFileHandler::output_dir_basename("Foo"), "Foo");
963 assert_eq!(DisassembleXmlFileHandler::output_dir_basename(""), "");
964 }
965
966 #[test]
967 fn convert_sidecar_content_yaml_to_json() {
968 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";
972 let out = convert_sidecar_content(yaml, "json");
973 let val: serde_json::Value = serde_json::from_str(&out).expect("output must be valid JSON");
974 assert_eq!(val["openapi"], "3.0.1");
975 assert_eq!(val["info"]["title"], "@AuraEnabled Apex method APIs");
976 assert_eq!(val["info"]["version"], "1.0.0");
977 assert_eq!(
978 val["paths"]["/uploadFile"]["post"]["operationId"],
979 "uploadFile"
980 );
981 }
982
983 #[test]
984 fn convert_sidecar_content_json_to_yaml() {
985 let json = r#"{"key":"value","num":42}"#;
986 let out = convert_sidecar_content(json, "yaml");
987 assert!(
991 serde_json::from_str::<serde_json::Value>(&out).is_err(),
992 "output must be YAML format, not raw JSON: {out}"
993 );
994 let val: serde_json::Value = serde_yaml::from_str(&out).expect("output must be valid YAML");
995 assert_eq!(val["key"], "value");
996 assert_eq!(val["num"], 42);
997 }
998
999 #[test]
1000 fn convert_sidecar_content_json_to_json_prettifies() {
1001 let compact = r#"{"a":1}"#;
1002 let out = convert_sidecar_content(compact, "json");
1003 assert!(out.contains('\n'), "expected pretty JSON, got: {out}");
1005 let val: serde_json::Value = serde_json::from_str(&out).unwrap();
1006 assert_eq!(val["a"], 1);
1007 }
1008
1009 #[test]
1010 fn convert_sidecar_content_unknown_extension_passes_through() {
1011 let raw = "arbitrary: content: here";
1012 assert_eq!(convert_sidecar_content(raw, "txt"), raw);
1013 assert_eq!(convert_sidecar_content(raw, ""), raw);
1014 }
1015
1016 #[test]
1017 fn convert_sidecar_content_malformed_falls_back_to_raw() {
1018 let bad = "{{{{ not valid json or yaml at all >>>>>";
1020 assert_eq!(convert_sidecar_content(bad, "json"), bad);
1021 assert_eq!(convert_sidecar_content(bad, "yaml"), bad);
1022 }
1023
1024 #[test]
1025 fn convert_sidecar_content_yml_extension_same_as_yaml() {
1026 let json = r#"{"x":true}"#;
1027 let out = convert_sidecar_content(json, "yml");
1028 let val: serde_json::Value = serde_yaml::from_str(&out).unwrap();
1029 assert_eq!(val["x"], true);
1030 }
1031
1032 #[test]
1033 fn convert_sidecar_content_yaml_passes_through_unchanged() {
1034 let yaml = "title: \"@AuraEnabled Apex method APIs\"\nversion: 1.0.0\n";
1037 assert_eq!(convert_sidecar_content(yaml, "yaml"), yaml);
1038 assert_eq!(convert_sidecar_content(yaml, "yml"), yaml);
1039 }
1040}