Skip to main content

config_disassembler/xml/handlers/
disassemble.rs

1//! Disassemble XML file handler.
2
3use 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        // `GitignoreBuilder::build` only fails on unlikely I/O errors; treat as absent rules.
36        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    /// True when a directory entry is both a regular file and an `.xml`.
48    /// Pure helper extracted from `handle_directory` so the
49    /// `is_file && is_xml_file` predicate can be exercised without a
50    /// real filesystem entry.
51    fn is_processable_xml_entry(is_file: bool, file_name: &str) -> bool {
52        is_file && Self::is_xml_file(file_name)
53    }
54
55    /// True when the unified-build output directory should be purged
56    /// before re-disassembling. Both the flag *and* the existence check
57    /// must hold; `pre_purge=true` against a missing directory is a
58    /// no-op rather than an error.
59    fn should_pre_purge_output(pre_purge: bool, output_exists: bool) -> bool {
60        pre_purge && output_exists
61    }
62
63    /// True when a file inside the disassembly tree should be
64    /// considered by a multi-level rule: it must be `.xml` and either
65    /// its bare name or its full path must contain the rule's pattern.
66    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    /// True when the parsed XML document has the multi-level rule's
72    /// `root_to_strip` either as its root element or as a direct child
73    /// of its root element.
74    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    /// Two multi-level rules share an "identity" — i.e. should be
86    /// deduplicated in `.multi_level.json` — when both their
87    /// `file_pattern` and their `root_to_strip` match. The other
88    /// fields (`unique_id_elements`, `path_segment`, …) are derived
89    /// per-file and may legitimately drift.
90    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    /// First non-`?xml` key of the parsed document, used as the
95    /// `wrap_root_element` for a multi-level rule. Falls back to
96    /// `fallback` when the parsed value is not an object or contains
97    /// only the declaration.
98    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    /// Derive the disassembled-output directory name from a file stem.
113    ///
114    /// We strip only the trailing extension-like segment (everything after the **last** `.`),
115    /// so `HR_Admin.permissionset-meta` collapses to `HR_Admin` while
116    /// `Account.MyApprovalProcess.approvalProcess-meta` collapses to `Account.MyApprovalProcess`.
117    /// Splitting at the *first* dot — the previous behaviour — was lossy for metadata types
118    /// whose fullName itself contains a dot (e.g. Salesforce approval processes, quick actions,
119    /// custom-metadata records) because two files like `A.X.foo-meta.xml` and `A.Y.foo-meta.xml`
120    /// both resolved to `A/`, silently merging unrelated components.
121    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        // Treat an empty rules slice as "no multi-level".
161        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            // Anything that isn't a regular file is treated as a directory; fs::metadata on
178            // the caller already errored out if the path didn't exist.
179            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        // Apply each multi-level rule in order. Each rule walks the same disassembly tree
330        // independently; rules are merged into the shared `.multi_level.json` so reassembly
331        // can replay them in order.
332        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    /// Recursively walk the disassembly output; for XML files matching the rule's file_pattern,
343    /// strip the root and re-disassemble with the rule's unique_id_elements.
344    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(&current).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                // Anything not a directory is processed as a regular file below.
371                {
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                    // Find an existing entry for this rule by (file_pattern, root_to_strip).
421                    // Multiple rules may co-exist in `.multi_level.json` (one per logical
422                    // segment); per-rule deduplication keeps each one a singleton.
423                    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                                // Persist document root (e.g. LoyaltyProgramSetup) so reassembly uses it
449                                // as root with xmlns; path_segment is the inner wrapper in each file.
450                                wrap_root_element: wrap_root,
451                                wrap_xmlns: stored_xmlns,
452                            });
453                        }
454                        Some(idx) => {
455                            // Backfill xmlns from the source if we didn't have one yet; otherwise
456                            // leave the existing entry alone (the first observed file wins).
457                            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        // Plain Salesforce-style metadata: strip the `.<suffix>-meta` tail.
535        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        // Approval processes are named `<sobject>.<process>` which yields a stem containing
548        // *two* dots. The old `split('.').next()` returned just `<sobject>`, causing
549        // distinct processes for the same sobject to land in the same output directory and
550        // silently merge during reassembly. The new behaviour keeps the dotted fullName.
551        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        // Quick actions follow the same `<sobject>.<action>` pattern.
564        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        // Pin both the `is_file && is_xml_file` conjunction and the
573        // outer `!` at the call site. All four quadrants of
574        // (is_file, is_xml) are covered.
575        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        // `pre_purge=true` alone must not delete a missing directory
592        // (that's a benign no-op, not an error); an existing directory
593        // alone must not be deleted unless the caller asked for purge.
594        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        // Non-`.xml` files are skipped regardless of pattern membership.
611        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        // The pattern may live in a parent directory name even if the
630        // bare file name is something generic like `meta.xml`.
631        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        // Stems without any dot are passed through verbatim (no extension to strip).
748        assert_eq!(DisassembleXmlFileHandler::output_dir_basename("Foo"), "Foo");
749        assert_eq!(DisassembleXmlFileHandler::output_dir_basename(""), "");
750    }
751}