1use crate::xml::builders::build_disassembled_files_unified;
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::parse_xml;
9use crate::xml::types::{BuildDisassembledFilesOptions, DecomposeRule, MultiLevelRule};
10use crate::xml::utils::normalize_path_unix;
11use ignore::gitignore::GitignoreBuilder;
12use std::path::Path;
13use tokio::fs;
14
15pub struct DisassembleXmlFileHandler {
16 ign: Option<ignore::gitignore::Gitignore>,
17}
18
19impl DisassembleXmlFileHandler {
20 pub fn new() -> Self {
21 Self { ign: None }
22 }
23
24 async fn load_ignore_rules(&mut self, ignore_path: &str) {
25 let path = Path::new(ignore_path);
26 let content = match fs::read_to_string(path).await {
27 Ok(c) => c,
28 Err(_) => return,
29 };
30 let root = path.parent().unwrap_or(Path::new("."));
31 let mut builder = GitignoreBuilder::new(root);
32 for line in content.lines() {
33 let _ = builder.add_line(None, line);
34 }
35 self.ign = builder.build().ok();
37 }
38
39 fn posix_path(path: &str) -> String {
40 path.replace('\\', "/")
41 }
42
43 fn is_xml_file(file_path: &str) -> bool {
44 file_path.to_lowercase().ends_with(".xml")
45 }
46
47 fn is_processable_xml_entry(is_file: bool, file_name: &str) -> bool {
52 is_file && Self::is_xml_file(file_name)
53 }
54
55 fn should_pre_purge_output(pre_purge: bool, output_exists: bool) -> bool {
60 pre_purge && output_exists
61 }
62
63 fn file_matches_multi_level_rule(file_name: &str, full_path: &str, file_pattern: &str) -> bool {
67 file_name.ends_with(".xml")
68 && (file_name.contains(file_pattern) || full_path.contains(file_pattern))
69 }
70
71 fn has_element_to_strip(parsed: &serde_json::Value, root_to_strip: &str) -> bool {
75 parsed
76 .as_object()
77 .and_then(|o| {
78 let root_key = o.keys().find(|k| *k != "?xml")?;
79 let root_val = o.get(root_key)?.as_object()?;
80 Some(root_key == root_to_strip || root_val.contains_key(root_to_strip))
81 })
82 .unwrap_or(false)
83 }
84
85 fn rules_have_same_identity(a: &MultiLevelRule, b: &MultiLevelRule) -> bool {
91 a.file_pattern == b.file_pattern && a.root_to_strip == b.root_to_strip
92 }
93
94 fn root_element_name_from_parsed(parsed: &serde_json::Value, fallback: &str) -> String {
99 parsed
100 .as_object()
101 .and_then(|o| o.keys().find(|k| *k != "?xml").cloned())
102 .unwrap_or_else(|| fallback.to_string())
103 }
104
105 fn is_ignored(&self, path: &str) -> bool {
106 self.ign
107 .as_ref()
108 .map(|ign| ign.matched(path, false).is_ignore())
109 .unwrap_or(false)
110 }
111
112 fn output_dir_basename(file_stem: &str) -> &str {
122 file_stem
123 .rsplit_once('.')
124 .map(|(prefix, _)| prefix)
125 .unwrap_or(file_stem)
126 }
127
128 #[allow(clippy::too_many_arguments)]
129 pub async fn disassemble(
130 &mut self,
131 file_path: &str,
132 unique_id_elements: Option<&str>,
133 strategy: Option<&str>,
134 pre_purge: bool,
135 post_purge: bool,
136 ignore_path: &str,
137 format: &str,
138 multi_level_rules: Option<&[MultiLevelRule]>,
139 decompose_rules: Option<&[DecomposeRule]>,
140 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
141 let strategy = strategy.unwrap_or("unique-id");
142 let strategy = if ["unique-id", "grouped-by-tag"].contains(&strategy) {
143 strategy
144 } else {
145 log::warn!(
146 "Unsupported strategy \"{}\", defaulting to \"unique-id\".",
147 strategy
148 );
149 "unique-id"
150 };
151
152 self.load_ignore_rules(ignore_path).await;
153
154 let path = Path::new(file_path);
155 let meta = fs::metadata(path).await?;
156 let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
157 let relative_path = path.strip_prefix(&cwd).unwrap_or(path).to_string_lossy();
158 let relative_path = Self::posix_path(&relative_path);
159
160 let multi_level_rules = multi_level_rules.filter(|rules| !rules.is_empty());
162
163 if meta.is_file() {
164 self.handle_file(
165 file_path,
166 &relative_path,
167 unique_id_elements,
168 strategy,
169 pre_purge,
170 post_purge,
171 format,
172 multi_level_rules,
173 decompose_rules,
174 )
175 .await?;
176 } else {
177 self.handle_directory(
180 file_path,
181 unique_id_elements,
182 strategy,
183 pre_purge,
184 post_purge,
185 format,
186 multi_level_rules,
187 decompose_rules,
188 )
189 .await?;
190 }
191
192 Ok(())
193 }
194
195 #[allow(clippy::too_many_arguments)]
196 async fn handle_file(
197 &self,
198 file_path: &str,
199 relative_path: &str,
200 unique_id_elements: Option<&str>,
201 strategy: &str,
202 pre_purge: bool,
203 post_purge: bool,
204 format: &str,
205 multi_level_rules: Option<&[MultiLevelRule]>,
206 decompose_rules: Option<&[DecomposeRule]>,
207 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
208 let resolved = Path::new(file_path)
209 .canonicalize()
210 .unwrap_or_else(|_| Path::new(file_path).to_path_buf());
211 let resolved_str = normalize_path_unix(&resolved.to_string_lossy());
212
213 if !Self::is_xml_file(&resolved_str) {
214 log::error!(
215 "The file path provided is not an XML file: {}",
216 resolved_str
217 );
218 return Ok(());
219 }
220
221 if self.is_ignored(relative_path) {
222 log::warn!("File ignored by ignore rules: {}", resolved_str);
223 return Ok(());
224 }
225
226 let dir_path = resolved.parent().unwrap_or(Path::new("."));
227 let dir_path_str = normalize_path_unix(&dir_path.to_string_lossy());
228 self.process_file(
229 &dir_path_str,
230 strategy,
231 &resolved_str,
232 unique_id_elements,
233 pre_purge,
234 post_purge,
235 format,
236 multi_level_rules,
237 decompose_rules,
238 )
239 .await
240 }
241
242 #[allow(clippy::too_many_arguments)]
243 async fn handle_directory(
244 &self,
245 dir_path: &str,
246 unique_id_elements: Option<&str>,
247 strategy: &str,
248 pre_purge: bool,
249 post_purge: bool,
250 format: &str,
251 multi_level_rules: Option<&[MultiLevelRule]>,
252 decompose_rules: Option<&[DecomposeRule]>,
253 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
254 let dir_path = normalize_path_unix(dir_path);
255 let mut entries = fs::read_dir(&dir_path).await?;
256 let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
257
258 while let Some(entry) = entries.next_entry().await? {
259 let sub_path = entry.path();
260 let sub_file_path = sub_path.to_string_lossy();
261 let relative_sub = sub_path
262 .strip_prefix(&cwd)
263 .unwrap_or(&sub_path)
264 .to_string_lossy();
265 let relative_sub = Self::posix_path(&relative_sub);
266
267 if !Self::is_processable_xml_entry(sub_path.is_file(), &sub_file_path) {
268 continue;
269 }
270 if self.is_ignored(&relative_sub) {
271 log::warn!("File ignored by ignore rules: {}", sub_file_path);
272 continue;
273 }
274 let sub_file_path_norm = normalize_path_unix(&sub_file_path);
275 self.process_file(
276 &dir_path,
277 strategy,
278 &sub_file_path_norm,
279 unique_id_elements,
280 pre_purge,
281 post_purge,
282 format,
283 multi_level_rules,
284 decompose_rules,
285 )
286 .await?;
287 }
288 Ok(())
289 }
290
291 #[allow(clippy::too_many_arguments)]
292 async fn process_file(
293 &self,
294 dir_path: &str,
295 strategy: &str,
296 file_path: &str,
297 unique_id_elements: Option<&str>,
298 pre_purge: bool,
299 post_purge: bool,
300 format: &str,
301 multi_level_rules: Option<&[MultiLevelRule]>,
302 decompose_rules: Option<&[DecomposeRule]>,
303 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
304 log::debug!("Parsing file to disassemble: {}", file_path);
305
306 let file_name = Path::new(file_path)
307 .file_stem()
308 .and_then(|s| s.to_str())
309 .unwrap_or("output");
310 let base_name = Self::output_dir_basename(file_name);
311 let output_path = Path::new(dir_path).join(base_name);
312
313 if Self::should_pre_purge_output(pre_purge, output_path.exists()) {
314 fs::remove_dir_all(&output_path).await.ok();
315 }
316
317 build_disassembled_files_unified(BuildDisassembledFilesOptions {
318 file_path,
319 disassembled_path: output_path.to_str().unwrap_or("."),
320 base_name: file_name,
321 post_purge,
322 format,
323 unique_id_elements,
324 strategy,
325 decompose_rules,
326 })
327 .await?;
328
329 if let Some(rules) = multi_level_rules {
333 for rule in rules {
334 self.recursively_disassemble_multi_level(&output_path, rule, format)
335 .await?;
336 }
337 }
338
339 Ok(())
340 }
341
342 async fn recursively_disassemble_multi_level(
345 &self,
346 dir_path: &Path,
347 rule: &MultiLevelRule,
348 format: &str,
349 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
350 let mut config = crate::xml::multi_level::load_multi_level_config(dir_path)
351 .await
352 .unwrap_or_default();
353
354 let mut stack = vec![dir_path.to_path_buf()];
355 while let Some(current) = stack.pop() {
356 let mut entries = Vec::new();
357 let mut read_dir = fs::read_dir(¤t).await?;
358 while let Some(entry) = read_dir.next_entry().await? {
359 entries.push(entry);
360 }
361
362 for entry in entries {
363 let path = entry.path();
364 let path_str = path.to_string_lossy().to_string();
365
366 if path.is_dir() {
367 stack.push(path);
368 continue;
369 }
370 {
372 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
373 let path_str_check = path.to_string_lossy();
374 if !Self::file_matches_multi_level_rule(
375 name,
376 &path_str_check,
377 &rule.file_pattern,
378 ) {
379 continue;
380 }
381
382 let parsed = match parse_xml(&path_str).await {
383 Some(p) => p,
384 None => continue,
385 };
386 if !Self::has_element_to_strip(&parsed, &rule.root_to_strip) {
387 continue;
388 }
389
390 let wrap_xmlns = capture_xmlns_from_root(&parsed).unwrap_or_default();
391
392 let stripped_xml = match strip_root_and_build_xml(&parsed, &rule.root_to_strip)
393 {
394 Some(xml) => xml,
395 None => continue,
396 };
397
398 fs::write(&path, stripped_xml).await?;
399
400 let file_stem = path
401 .file_stem()
402 .and_then(|s| s.to_str())
403 .unwrap_or("output");
404 let output_dir_name = Self::output_dir_basename(file_stem);
405 let parent = path.parent().unwrap_or(dir_path);
406 let second_level_output = parent.join(output_dir_name);
407
408 build_disassembled_files_unified(BuildDisassembledFilesOptions {
409 file_path: &path_str,
410 disassembled_path: second_level_output.to_str().unwrap_or("."),
411 base_name: output_dir_name,
412 post_purge: true,
413 format,
414 unique_id_elements: Some(&rule.unique_id_elements),
415 strategy: "unique-id",
416 decompose_rules: None,
417 })
418 .await?;
419
420 let existing_idx = config
424 .rules
425 .iter()
426 .position(|r| Self::rules_have_same_identity(r, rule));
427 match existing_idx {
428 None => {
429 let wrap_root = Self::root_element_name_from_parsed(
430 &parsed,
431 &rule.wrap_root_element,
432 );
433 let path_segment = if rule.path_segment.is_empty() {
434 path_segment_from_file_pattern(&rule.file_pattern)
435 } else {
436 rule.path_segment.clone()
437 };
438 let stored_xmlns = if rule.wrap_xmlns.is_empty() {
439 wrap_xmlns
440 } else {
441 rule.wrap_xmlns.clone()
442 };
443 config.rules.push(MultiLevelRule {
444 file_pattern: rule.file_pattern.clone(),
445 root_to_strip: rule.root_to_strip.clone(),
446 unique_id_elements: rule.unique_id_elements.clone(),
447 path_segment,
448 wrap_root_element: wrap_root,
451 wrap_xmlns: stored_xmlns,
452 });
453 }
454 Some(idx) => {
455 if config.rules[idx].wrap_xmlns.is_empty() {
458 config.rules[idx].wrap_xmlns = wrap_xmlns;
459 }
460 }
461 }
462 }
463 }
464 }
465
466 if !config.rules.is_empty() {
467 save_multi_level_config(dir_path, &config).await?;
468 }
469
470 Ok(())
471 }
472}
473
474impl Default for DisassembleXmlFileHandler {
475 fn default() -> Self {
476 Self::new()
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 #[test]
485 #[allow(clippy::default_constructed_unit_structs)]
486 fn disassemble_handler_default_equals_new() {
487 let _ = DisassembleXmlFileHandler::default();
488 }
489
490 #[test]
491 fn is_xml_file_matches_case_insensitively() {
492 assert!(DisassembleXmlFileHandler::is_xml_file("foo.xml"));
493 assert!(DisassembleXmlFileHandler::is_xml_file("BAR.XML"));
494 assert!(!DisassembleXmlFileHandler::is_xml_file("foo.txt"));
495 }
496
497 #[test]
498 fn posix_path_converts_backslashes() {
499 assert_eq!(
500 DisassembleXmlFileHandler::posix_path(r"C:\Users\name\file.xml"),
501 "C:/Users/name/file.xml"
502 );
503 }
504
505 #[tokio::test]
506 async fn load_ignore_rules_noop_when_path_missing() {
507 let mut handler = DisassembleXmlFileHandler::new();
508 handler
509 .load_ignore_rules("/definitely/does/not/exist/.ignore")
510 .await;
511 assert!(handler.ign.is_none());
512 }
513
514 #[tokio::test]
515 async fn load_ignore_rules_builds_matcher() {
516 let temp = tempfile::tempdir().unwrap();
517 let path = temp.path().join(".ignore");
518 tokio::fs::write(&path, "*.xml\n").await.unwrap();
519 let mut handler = DisassembleXmlFileHandler::new();
520 handler.load_ignore_rules(path.to_str().unwrap()).await;
521 assert!(handler.ign.is_some());
522 assert!(handler.is_ignored("file.xml"));
523 assert!(!handler.is_ignored("file.txt"));
524 }
525
526 #[test]
527 fn is_ignored_default_false_without_rules() {
528 let handler = DisassembleXmlFileHandler::new();
529 assert!(!handler.is_ignored("some/path.xml"));
530 }
531
532 #[test]
533 fn output_dir_basename_strips_only_last_dot_segment() {
534 assert_eq!(
536 DisassembleXmlFileHandler::output_dir_basename("HR_Admin.permissionset-meta"),
537 "HR_Admin"
538 );
539 assert_eq!(
540 DisassembleXmlFileHandler::output_dir_basename("Get_Info.flow-meta"),
541 "Get_Info"
542 );
543 }
544
545 #[test]
546 fn output_dir_basename_preserves_dotted_full_names() {
547 assert_eq!(
552 DisassembleXmlFileHandler::output_dir_basename(
553 "Account_Merge__c.New_Account_Merges_2.approvalProcess-meta"
554 ),
555 "Account_Merge__c.New_Account_Merges_2"
556 );
557 assert_eq!(
558 DisassembleXmlFileHandler::output_dir_basename(
559 "Account_Merge__c.New_Account_Merges_3.approvalProcess-meta"
560 ),
561 "Account_Merge__c.New_Account_Merges_3"
562 );
563 assert_eq!(
565 DisassembleXmlFileHandler::output_dir_basename("Case.LogACall.quickAction-meta"),
566 "Case.LogACall"
567 );
568 }
569
570 #[test]
571 fn is_processable_xml_entry_true_only_for_regular_xml_files() {
572 assert!(DisassembleXmlFileHandler::is_processable_xml_entry(
576 true, "foo.xml"
577 ));
578 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
579 false, "foo.xml"
580 ));
581 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
582 true, "foo.txt"
583 ));
584 assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
585 false, "foo.txt"
586 ));
587 }
588
589 #[test]
590 fn should_pre_purge_output_requires_both_flag_and_existing_dir() {
591 assert!(DisassembleXmlFileHandler::should_pre_purge_output(
595 true, true
596 ));
597 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
598 true, false
599 ));
600 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
601 false, true
602 ));
603 assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
604 false, false
605 ));
606 }
607
608 #[test]
609 fn file_matches_multi_level_rule_requires_xml_extension() {
610 assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
612 "Foo.txt",
613 "/dir/Foo.txt",
614 "Foo"
615 ));
616 }
617
618 #[test]
619 fn file_matches_multi_level_rule_when_filename_contains_pattern() {
620 assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
621 "MyPattern.xml",
622 "/dir/MyPattern.xml",
623 "MyPattern"
624 ));
625 }
626
627 #[test]
628 fn file_matches_multi_level_rule_when_only_full_path_contains_pattern() {
629 assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
632 "child.xml",
633 "/parentPattern/child.xml",
634 "parentPattern"
635 ));
636 }
637
638 #[test]
639 fn file_matches_multi_level_rule_false_when_pattern_absent_everywhere() {
640 assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
641 "Foo.xml",
642 "/dir/Foo.xml",
643 "MissingPattern"
644 ));
645 }
646
647 #[test]
648 fn has_element_to_strip_when_root_key_matches() {
649 let parsed = serde_json::json!({"Foo": {"a": "b"}});
650 assert!(DisassembleXmlFileHandler::has_element_to_strip(
651 &parsed, "Foo"
652 ));
653 }
654
655 #[test]
656 fn has_element_to_strip_when_root_contains_target_child() {
657 let parsed = serde_json::json!({"Foo": {"Bar": {"a": "b"}}});
658 assert!(DisassembleXmlFileHandler::has_element_to_strip(
659 &parsed, "Bar"
660 ));
661 }
662
663 #[test]
664 fn has_element_to_strip_false_when_target_absent() {
665 let parsed = serde_json::json!({"Foo": {"a": "b"}});
666 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
667 &parsed, "Missing"
668 ));
669 }
670
671 #[test]
672 fn has_element_to_strip_false_for_non_object_or_decl_only() {
673 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
674 &serde_json::json!("primitive"),
675 "Foo"
676 ));
677 assert!(!DisassembleXmlFileHandler::has_element_to_strip(
678 &serde_json::json!({"?xml": {}}),
679 "Foo"
680 ));
681 }
682
683 fn rule(pattern: &str, root: &str) -> MultiLevelRule {
684 MultiLevelRule {
685 file_pattern: pattern.to_string(),
686 root_to_strip: root.to_string(),
687 unique_id_elements: String::new(),
688 path_segment: String::new(),
689 wrap_root_element: String::new(),
690 wrap_xmlns: String::new(),
691 }
692 }
693
694 #[test]
695 fn rules_share_identity_when_pattern_and_root_match() {
696 assert!(DisassembleXmlFileHandler::rules_have_same_identity(
697 &rule("p", "R"),
698 &rule("p", "R"),
699 ));
700 }
701
702 #[test]
703 fn rules_differ_when_file_pattern_differs() {
704 assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
705 &rule("p1", "R"),
706 &rule("p2", "R"),
707 ));
708 }
709
710 #[test]
711 fn rules_differ_when_root_to_strip_differs() {
712 assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
713 &rule("p", "R1"),
714 &rule("p", "R2"),
715 ));
716 }
717
718 #[test]
719 fn root_element_name_finds_first_non_declaration_key() {
720 let parsed = serde_json::json!({"?xml": {}, "MyRoot": {"a": "b"}});
721 assert_eq!(
722 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "fallback"),
723 "MyRoot"
724 );
725 }
726
727 #[test]
728 fn root_element_name_falls_back_when_only_declaration_present() {
729 let parsed = serde_json::json!({"?xml": {}});
730 assert_eq!(
731 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "FallbackRoot"),
732 "FallbackRoot"
733 );
734 }
735
736 #[test]
737 fn root_element_name_falls_back_for_non_object() {
738 let parsed = serde_json::json!("primitive");
739 assert_eq!(
740 DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "Fb"),
741 "Fb"
742 );
743 }
744
745 #[test]
746 fn output_dir_basename_no_dot_returns_stem_unchanged() {
747 assert_eq!(DisassembleXmlFileHandler::output_dir_basename("Foo"), "Foo");
749 assert_eq!(DisassembleXmlFileHandler::output_dir_basename(""), "");
750 }
751}