Skip to main content

config_disassembler/xml/handlers/
disassemble.rs

1//! Disassemble XML file handler.
2
3use 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
21/// Upper bound on how many files inside one directory-mode `disassemble()` call are
22/// processed concurrently. Each spawned task is real OS-thread parallelism (not just
23/// cooperative async interleaving), so this also caps how many XML parses/serializations
24/// run at once; bounded rather than unbounded so a directory with thousands of files
25/// doesn't spawn thousands of tasks (and their file handles) simultaneously.
26const 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        // `GitignoreBuilder::build` only fails on unlikely I/O errors; treat as absent rules.
49        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    /// True when a directory entry is both a regular file and an `.xml`.
61    /// Pure helper extracted from `handle_directory` so the
62    /// `is_file && is_xml_file` predicate can be exercised without a
63    /// real filesystem entry.
64    fn is_processable_xml_entry(is_file: bool, file_name: &str) -> bool {
65        is_file && Self::is_xml_file(file_name)
66    }
67
68    /// True when the unified-build output directory should be purged
69    /// before re-disassembling. Both the flag *and* the existence check
70    /// must hold; `pre_purge=true` against a missing directory is a
71    /// no-op rather than an error.
72    fn should_pre_purge_output(pre_purge: bool, output_exists: bool) -> bool {
73        pre_purge && output_exists
74    }
75
76    /// True when a file inside the disassembly tree should be
77    /// considered by a multi-level rule: it must be `.xml` and either
78    /// its bare name or its full path must contain the rule's pattern.
79    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    /// True when the parsed XML document has the multi-level rule's
85    /// `root_to_strip` either as its root element or as a direct child
86    /// of its root element.
87    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    /// Two multi-level rules share an "identity" — i.e. should be
99    /// deduplicated in `.multi_level.json` — when both their
100    /// `file_pattern` and their `root_to_strip` match. The other
101    /// fields (`unique_id_elements`, `path_segment`, …) are derived
102    /// per-file and may legitimately drift.
103    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    /// First non-`?xml` key of the parsed document, used as the
108    /// `wrap_root_element` for a multi-level rule. Falls back to
109    /// `fallback` when the parsed value is not an object or contains
110    /// only the declaration.
111    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    /// Derive the disassembled-output directory name from a file stem.
126    ///
127    /// We strip only the trailing extension-like segment (everything after the **last** `.`),
128    /// so `HR_Admin.permissionset-meta` collapses to `HR_Admin` while
129    /// `Account.MyApprovalProcess.approvalProcess-meta` collapses to `Account.MyApprovalProcess`.
130    /// Splitting at the *first* dot — the previous behaviour — was lossy for metadata types
131    /// whose fullName itself contains a dot (e.g. Salesforce approval processes, quick actions,
132    /// custom-metadata records) because two files like `A.X.foo-meta.xml` and `A.Y.foo-meta.xml`
133    /// both resolved to `A/`, silently merging unrelated components.
134    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        base_dir: Option<&str>,
155    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
156        let strategy = strategy.unwrap_or("unique-id");
157        let strategy = if ["unique-id", "grouped-by-tag"].contains(&strategy) {
158            strategy
159        } else {
160            log::warn!(
161                "Unsupported strategy \"{}\", defaulting to \"unique-id\".",
162                strategy
163            );
164            "unique-id"
165        };
166
167        self.load_ignore_rules(ignore_path).await;
168
169        let path = Path::new(file_path);
170        let meta = fs::metadata(path).await?;
171        let cwd = Self::resolve_base_dir(base_dir);
172        let relative_path = path.strip_prefix(&cwd).unwrap_or(path).to_string_lossy();
173        let relative_path = Self::posix_path(&relative_path);
174
175        // Treat an empty rules slice as "no multi-level".
176        let multi_level_rules = multi_level_rules.filter(|rules| !rules.is_empty());
177
178        if meta.is_file() {
179            self.handle_file(
180                file_path,
181                &relative_path,
182                unique_id_elements,
183                strategy,
184                pre_purge,
185                post_purge,
186                format,
187                multi_level_rules,
188                decompose_rules,
189                sidecar_specs,
190            )
191            .await?;
192        } else {
193            // Anything that isn't a regular file is treated as a directory; fs::metadata on
194            // the caller already errored out if the path didn't exist.
195            self.handle_directory(
196                file_path,
197                unique_id_elements,
198                strategy,
199                pre_purge,
200                post_purge,
201                format,
202                multi_level_rules,
203                decompose_rules,
204                sidecar_specs,
205                base_dir,
206            )
207            .await?;
208        }
209
210        Ok(())
211    }
212
213    /// Resolve the directory that ignore-matching paths are computed relative to.
214    /// `Some(dir)` is used verbatim (the caller — e.g. Node bindings running
215    /// inside a `worker_threads` pool, where `process.chdir()` is unavailable —
216    /// already knows its project root); `None` falls back to the process's
217    /// actual working directory, preserving the original CLI behavior.
218    fn resolve_base_dir(base_dir: Option<&str>) -> std::path::PathBuf {
219        match base_dir {
220            Some(dir) => Path::new(dir).to_path_buf(),
221            None => std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf()),
222        }
223    }
224
225    #[allow(clippy::too_many_arguments)]
226    async fn handle_file(
227        &self,
228        file_path: &str,
229        relative_path: &str,
230        unique_id_elements: Option<&str>,
231        strategy: &str,
232        pre_purge: bool,
233        post_purge: bool,
234        format: &str,
235        multi_level_rules: Option<&[MultiLevelRule]>,
236        decompose_rules: Option<&[DecomposeRule]>,
237        sidecar_specs: Option<&[SidecarSpec]>,
238    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
239        let resolved = Path::new(file_path)
240            .canonicalize()
241            .unwrap_or_else(|_| Path::new(file_path).to_path_buf());
242        let resolved_str = normalize_path_unix(&resolved.to_string_lossy());
243
244        if !Self::is_xml_file(&resolved_str) {
245            log::error!(
246                "The file path provided is not an XML file: {}",
247                resolved_str
248            );
249            return Ok(());
250        }
251
252        if self.is_ignored(relative_path) {
253            log::warn!("File ignored by ignore rules: {}", resolved_str);
254            return Ok(());
255        }
256
257        let dir_path = resolved.parent().unwrap_or(Path::new("."));
258        let dir_path_str = normalize_path_unix(&dir_path.to_string_lossy());
259        Self::process_file(
260            dir_path_str,
261            strategy.to_string(),
262            resolved_str,
263            unique_id_elements.map(str::to_string),
264            pre_purge,
265            post_purge,
266            format.to_string(),
267            multi_level_rules.map(<[MultiLevelRule]>::to_vec),
268            decompose_rules.map(<[DecomposeRule]>::to_vec),
269            sidecar_specs.map(<[SidecarSpec]>::to_vec),
270        )
271        .await
272    }
273
274    #[allow(clippy::too_many_arguments)]
275    async fn handle_directory(
276        &self,
277        dir_path: &str,
278        unique_id_elements: Option<&str>,
279        strategy: &str,
280        pre_purge: bool,
281        post_purge: bool,
282        format: &str,
283        multi_level_rules: Option<&[MultiLevelRule]>,
284        decompose_rules: Option<&[DecomposeRule]>,
285        sidecar_specs: Option<&[SidecarSpec]>,
286        base_dir: Option<&str>,
287    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
288        let dir_path = normalize_path_unix(dir_path);
289        let mut entries = fs::read_dir(&dir_path).await?;
290        let cwd = Self::resolve_base_dir(base_dir);
291
292        // Ignore-checking needs `&self`, so this discovery pass stays sequential; it's cheap
293        // (just readdir + string matching, no XML parsing or file writes).
294        let mut files_to_process: Vec<String> = Vec::new();
295        while let Some(entry) = entries.next_entry().await? {
296            let sub_path = entry.path();
297            let sub_file_path = sub_path.to_string_lossy();
298            let relative_sub = sub_path
299                .strip_prefix(&cwd)
300                .unwrap_or(&sub_path)
301                .to_string_lossy();
302            let relative_sub = Self::posix_path(&relative_sub);
303
304            if !Self::is_processable_xml_entry(sub_path.is_file(), &sub_file_path) {
305                continue;
306            }
307            if self.is_ignored(&relative_sub) {
308                log::warn!("File ignored by ignore rules: {}", sub_file_path);
309                continue;
310            }
311            files_to_process.push(normalize_path_unix(&sub_file_path));
312        }
313
314        // A directory with 0 or 1 processable files -- e.g. a bot/flow/custom-metadata
315        // component folder holding a single record -- gets nothing from the concurrency
316        // machinery below: there's no second file to overlap it against, only the fixed cost
317        // of standing up a semaphore and JoinSet for one task. Skip straight to processing it
318        // inline instead.
319        if files_to_process.len() <= 1 {
320            for file_path in files_to_process {
321                Self::process_file(
322                    dir_path.clone(),
323                    strategy.to_string(),
324                    file_path,
325                    unique_id_elements.map(str::to_string),
326                    pre_purge,
327                    post_purge,
328                    format.to_string(),
329                    multi_level_rules.map(<[MultiLevelRule]>::to_vec),
330                    decompose_rules.map(<[DecomposeRule]>::to_vec),
331                    sidecar_specs.map(<[SidecarSpec]>::to_vec),
332                )
333                .await?;
334            }
335            return Ok(());
336        }
337
338        // Fan out across the shared multi-threaded tokio runtime instead of disassembling one
339        // file at a time: this directory-mode call is the common case (a whole metadata type's
340        // parent XML files in one call), so a plain sequential loop here left every core but one
341        // idle no matter how many files there were. `process_file` needs no instance state, so
342        // each task is spawned with owned copies of its inputs and runs fully independently;
343        // bounded by a semaphore so a directory with thousands of files doesn't spawn thousands
344        // of tasks (and open file handles) at once.
345        let semaphore = Arc::new(Semaphore::new(DIRECTORY_CONCURRENCY));
346        let mut join_set = JoinSet::new();
347        for file_path in files_to_process {
348            let dir_path = dir_path.clone();
349            let strategy = strategy.to_string();
350            let unique_id_elements = unique_id_elements.map(str::to_string);
351            let format = format.to_string();
352            let multi_level_rules = multi_level_rules.map(<[MultiLevelRule]>::to_vec);
353            let decompose_rules = decompose_rules.map(<[DecomposeRule]>::to_vec);
354            let sidecar_specs = sidecar_specs.map(<[SidecarSpec]>::to_vec);
355            let semaphore = Arc::clone(&semaphore);
356
357            join_set.spawn(async move {
358                let _permit = semaphore.acquire_owned().await.expect("semaphore closed");
359                Self::process_file(
360                    dir_path,
361                    strategy,
362                    file_path,
363                    unique_id_elements,
364                    pre_purge,
365                    post_purge,
366                    format,
367                    multi_level_rules,
368                    decompose_rules,
369                    sidecar_specs,
370                )
371                .await
372            });
373        }
374
375        while let Some(joined) = join_set.join_next().await {
376            joined.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)??;
377        }
378
379        Ok(())
380    }
381
382    #[allow(clippy::too_many_arguments)]
383    async fn process_file(
384        dir_path: String,
385        strategy: String,
386        file_path: String,
387        unique_id_elements: Option<String>,
388        pre_purge: bool,
389        post_purge: bool,
390        format: String,
391        multi_level_rules: Option<Vec<MultiLevelRule>>,
392        decompose_rules: Option<Vec<DecomposeRule>>,
393        sidecar_specs: Option<Vec<SidecarSpec>>,
394    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
395        // Owned params so this can be `tokio::spawn`ed from `handle_directory` (spawned
396        // futures must be `'static`); shadow back to borrowed views under the same names
397        // so the body below is unchanged from the pre-concurrency version.
398        let dir_path = dir_path.as_str();
399        let file_path = file_path.as_str();
400        let strategy = strategy.as_str();
401        let format = format.as_str();
402        let unique_id_elements = unique_id_elements.as_deref();
403        let multi_level_rules = multi_level_rules.as_deref();
404        let decompose_rules = decompose_rules.as_deref();
405        let sidecar_specs = sidecar_specs.as_deref();
406
407        log::debug!("Parsing file to disassemble: {}", file_path);
408
409        let file_name = Path::new(file_path)
410            .file_stem()
411            .and_then(|s| s.to_str())
412            .unwrap_or("output");
413        let base_name = Self::output_dir_basename(file_name);
414        let output_path = Path::new(dir_path).join(base_name);
415
416        if Self::should_pre_purge_output(pre_purge, output_path.exists()) {
417            fs::remove_dir_all(&output_path).await.ok();
418        }
419
420        // Capture root key order BEFORE sidecar extraction so the sidecar element
421        // names appear at their original positions in .key_order.json.
422        let pre_extraction_key_order: Option<Vec<String>> =
423            if sidecar_specs.is_some_and(|s| !s.is_empty()) {
424                parse_xml(file_path).await.and_then(|parsed| {
425                    let obj = parsed.as_object()?;
426                    let root_key = obj.keys().find(|k| *k != "?xml")?;
427                    obj.get(root_key)?.as_object().map(|root_obj| {
428                        root_obj
429                            .keys()
430                            .filter(|k| !k.starts_with('@'))
431                            .cloned()
432                            .collect()
433                    })
434                })
435            } else {
436                None
437            };
438
439        // Extract sidecar elements before normal disassembly so the disassembler
440        // sees schema-free XML and does not try to shard the embedded blob.
441        // The original file is never modified; stripped content is written to a
442        // temp file that is deleted after disassembly. Sidecar files are written
443        // into the output directory after disassembly creates it.
444        let extraction_result = if let Some(specs) = sidecar_specs {
445            if !specs.is_empty() {
446                extract_sidecar_elements(file_path, specs).await?
447            } else {
448                None
449            }
450        } else {
451            None
452        };
453
454        let temp_file: Option<tempfile::NamedTempFile>;
455        let disassemble_path: &str;
456        if let Some((xml, _)) = &extraction_result {
457            let mut tmp = tempfile::Builder::new()
458                .suffix(".xml")
459                .tempfile_in(Path::new(file_path).parent().unwrap_or(Path::new(".")))?;
460            tmp.write_all(xml.as_bytes())?;
461            temp_file = Some(tmp);
462            disassemble_path = temp_file
463                .as_ref()
464                .unwrap()
465                .path()
466                .to_str()
467                .unwrap_or(file_path);
468        } else {
469            temp_file = None;
470            disassemble_path = file_path;
471        }
472
473        build_disassembled_files_unified(BuildDisassembledFilesOptions {
474            file_path: disassemble_path,
475            disassembled_path: output_path.to_str().unwrap_or("."),
476            base_name: file_name,
477            post_purge,
478            format,
479            unique_id_elements,
480            strategy,
481            decompose_rules,
482        })
483        .await?;
484
485        drop(temp_file); // deletes the temp file
486
487        // Write sidecar files into the output directory, plus a .sidecars.json
488        // metadata file so reassembly can auto-detect specs without CLI flags.
489        if let Some((_, sidecars)) = &extraction_result {
490            for (_, extension, content, _) in sidecars {
491                let sidecar_path = output_path.join(format!("{}.{}", base_name, extension));
492                fs::write(&sidecar_path, content).await?;
493            }
494            if let Some(specs) = sidecar_specs {
495                // Enrich each spec with the original_format detected at extraction time
496                // so reassembly can convert the sidecar content back to the correct format.
497                let enriched: Vec<SidecarSpec> = specs
498                    .iter()
499                    .map(|spec| {
500                        let original_format = sidecars
501                            .iter()
502                            .find(|(el, _, _, _)| el == &spec.element)
503                            .and_then(|(_, _, _, fmt)| fmt.clone());
504                        SidecarSpec {
505                            element: spec.element.clone(),
506                            extension: spec.extension.clone(),
507                            original_format,
508                        }
509                    })
510                    .collect();
511                if let Ok(json) = serde_json::to_string(&enriched) {
512                    let _ = fs::write(output_path.join(".sidecars.json"), json).await;
513                }
514            }
515        }
516
517        // Overwrite .key_order.json with the pre-extraction order so sidecar
518        // element names appear at their original positions during reassembly.
519        if let Some(full_order) = pre_extraction_key_order {
520            let key_order_path = output_path.join(".key_order.json");
521            if let Ok(json) = serde_json::to_string(&full_order) {
522                let _ = fs::write(&key_order_path, json).await;
523            }
524        }
525
526        // Apply each multi-level rule in order. Each rule walks the same disassembly tree
527        // independently; rules are merged into the shared `.multi_level.json` so reassembly
528        // can replay them in order.
529        if let Some(rules) = multi_level_rules {
530            for rule in rules {
531                Self::recursively_disassemble_multi_level(&output_path, rule, format).await?;
532            }
533        }
534
535        Ok(())
536    }
537
538    /// Recursively walk the disassembly output; for XML files matching the rule's file_pattern,
539    /// strip the root and re-disassemble with the rule's unique_id_elements.
540    async fn recursively_disassemble_multi_level(
541        dir_path: &Path,
542        rule: &MultiLevelRule,
543        format: &str,
544    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
545        let mut config = crate::xml::multi_level::load_multi_level_config(dir_path)
546            .await
547            .unwrap_or_default();
548
549        let mut stack = vec![dir_path.to_path_buf()];
550        while let Some(current) = stack.pop() {
551            let mut entries = Vec::new();
552            let mut read_dir = fs::read_dir(&current).await?;
553            while let Some(entry) = read_dir.next_entry().await? {
554                entries.push(entry);
555            }
556
557            for entry in entries {
558                let path = entry.path();
559                let path_str = path.to_string_lossy().to_string();
560
561                if path.is_dir() {
562                    stack.push(path);
563                    continue;
564                }
565                // Anything not a directory is processed as a regular file below.
566                {
567                    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
568                    let path_str_check = path.to_string_lossy();
569                    if !Self::file_matches_multi_level_rule(
570                        name,
571                        &path_str_check,
572                        &rule.file_pattern,
573                    ) {
574                        continue;
575                    }
576
577                    let parsed = match parse_xml(&path_str).await {
578                        Some(p) => p,
579                        None => continue,
580                    };
581                    if !Self::has_element_to_strip(&parsed, &rule.root_to_strip) {
582                        continue;
583                    }
584
585                    let wrap_xmlns = capture_xmlns_from_root(&parsed).unwrap_or_default();
586
587                    let stripped_xml = match strip_root_and_build_xml(&parsed, &rule.root_to_strip)
588                    {
589                        Some(xml) => xml,
590                        None => continue,
591                    };
592
593                    fs::write(&path, stripped_xml).await?;
594
595                    let file_stem = path
596                        .file_stem()
597                        .and_then(|s| s.to_str())
598                        .unwrap_or("output");
599                    let output_dir_name = Self::output_dir_basename(file_stem);
600                    let parent = path.parent().unwrap_or(dir_path);
601                    let second_level_output = parent.join(output_dir_name);
602
603                    build_disassembled_files_unified(BuildDisassembledFilesOptions {
604                        file_path: &path_str,
605                        disassembled_path: second_level_output.to_str().unwrap_or("."),
606                        base_name: output_dir_name,
607                        post_purge: true,
608                        format,
609                        unique_id_elements: Some(&rule.unique_id_elements),
610                        strategy: "unique-id",
611                        decompose_rules: None,
612                    })
613                    .await?;
614
615                    // Find an existing entry for this rule by (file_pattern, root_to_strip).
616                    // Multiple rules may co-exist in `.multi_level.json` (one per logical
617                    // segment); per-rule deduplication keeps each one a singleton.
618                    let existing_idx = config
619                        .rules
620                        .iter()
621                        .position(|r| Self::rules_have_same_identity(r, rule));
622                    match existing_idx {
623                        None => {
624                            let wrap_root = Self::root_element_name_from_parsed(
625                                &parsed,
626                                &rule.wrap_root_element,
627                            );
628                            let path_segment = if rule.path_segment.is_empty() {
629                                path_segment_from_file_pattern(&rule.file_pattern)
630                            } else {
631                                rule.path_segment.clone()
632                            };
633                            let stored_xmlns = if rule.wrap_xmlns.is_empty() {
634                                wrap_xmlns
635                            } else {
636                                rule.wrap_xmlns.clone()
637                            };
638                            config.rules.push(MultiLevelRule {
639                                file_pattern: rule.file_pattern.clone(),
640                                root_to_strip: rule.root_to_strip.clone(),
641                                unique_id_elements: rule.unique_id_elements.clone(),
642                                path_segment,
643                                // Persist document root (e.g. LoyaltyProgramSetup) so reassembly uses it
644                                // as root with xmlns; path_segment is the inner wrapper in each file.
645                                wrap_root_element: wrap_root,
646                                wrap_xmlns: stored_xmlns,
647                            });
648                        }
649                        Some(idx) => {
650                            // Backfill xmlns from the source if we didn't have one yet; otherwise
651                            // leave the existing entry alone (the first observed file wins).
652                            if config.rules[idx].wrap_xmlns.is_empty() {
653                                config.rules[idx].wrap_xmlns = wrap_xmlns;
654                            }
655                        }
656                    }
657                }
658            }
659        }
660
661        if !config.rules.is_empty() {
662            save_multi_level_config(dir_path, &config).await?;
663        }
664
665        Ok(())
666    }
667}
668
669impl Default for DisassembleXmlFileHandler {
670    fn default() -> Self {
671        Self::new()
672    }
673}
674
675/// Extract the text content of named XML elements in memory and return the
676/// stripped XML plus the sidecar payloads. The caller is responsible for
677/// writing sidecar files; the original file on disk is never modified.
678///
679/// Returns `None` when no matching element was found.
680/// Returns `Some((stripped_xml, sidecars))` where each sidecar entry is
681/// `(element, extension, content, original_format)`.
682///
683/// Quick-xml's parser automatically unescapes entity references in text
684/// content, so the sidecar receives the raw, unescaped bytes of the embedded
685/// document — exactly what you'd write by hand.
686async fn extract_sidecar_elements(
687    file_path: &str,
688    specs: &[SidecarSpec],
689) -> Result<
690    Option<(String, Vec<(String, String, String, Option<String>)>)>,
691    Box<dyn std::error::Error + Send + Sync>,
692> {
693    let raw = fs::read_to_string(file_path).await?;
694    let Some(mut parsed) = parse_xml_from_str(&raw, file_path) else {
695        return Ok(None);
696    };
697
698    // parse_xml_cdata drops the XML declaration; recover it from the raw bytes and
699    // re-inject so build_xml_string emits it in the temp file. Without this the
700    // shards produced by build_disassembled_files_unified lack the declaration and
701    // the reassembler falls back to a synthetic default instead of the original.
702    if let (Some(obj), Some(decl)) = (
703        parsed.as_object_mut(),
704        extract_xml_declaration_from_raw(&raw),
705    ) {
706        obj.insert("?xml".to_string(), decl);
707    }
708
709    let root_key = parsed
710        .as_object()
711        .and_then(|o| o.keys().find(|k| *k != "?xml").cloned());
712    let Some(root_key) = root_key else {
713        return Ok(None);
714    };
715
716    // (element, extension, content, original_format)
717    let mut sidecars: Vec<(String, String, String, Option<String>)> = Vec::new();
718    if let Some(root_val) = parsed.as_object_mut().and_then(|o| o.get_mut(&root_key)) {
719        if let Some(root_obj) = root_val.as_object_mut() {
720            for spec in specs {
721                let Some(elem_val) = root_obj.remove(&spec.element) else {
722                    continue;
723                };
724                // The XML parser always yields Value::Object for element values;
725                // non-Object shapes are unexpected — restore and skip to preserve data.
726                let text = match &elem_val {
727                    serde_json::Value::Object(obj) => obj
728                        .get("#text")
729                        .and_then(|v| v.as_str())
730                        .unwrap_or("")
731                        .to_string(),
732                    _ => {
733                        root_obj.insert(spec.element.clone(), elem_val);
734                        continue;
735                    }
736                };
737                let original_format = detect_content_format(&text);
738                sidecars.push((
739                    spec.element.clone(),
740                    spec.extension.clone(),
741                    convert_sidecar_content(&text, &spec.extension),
742                    original_format,
743                ));
744            }
745        }
746    }
747
748    if sidecars.is_empty() {
749        Ok(None)
750    } else {
751        Ok(Some((build_xml_string(&parsed), sidecars)))
752    }
753}
754
755/// Detect whether `text` is JSON or YAML. Returns `Some("json")`, `Some("yaml")`,
756/// or `None` for content that cannot be parsed as either.
757fn detect_content_format(text: &str) -> Option<String> {
758    if serde_json::from_str::<serde_json::Value>(text).is_ok() {
759        Some("json".to_string())
760    } else if serde_yaml::from_str::<serde_yaml::Value>(text).is_ok() {
761        Some("yaml".to_string())
762    } else {
763        None
764    }
765}
766
767/// Convert raw text extracted from an XML element to the format implied by `extension`.
768///
769/// - `json` → parse as YAML (superset of JSON) then re-emit as pretty JSON
770/// - `yaml` / `yml` → convert only when source is strict JSON; YAML content passes through
771///   unchanged so quote style, indentation, and formatting are preserved on round-trip
772/// - anything else → pass through unchanged
773///
774/// Falls back to raw text with a warning when the content cannot be parsed.
775fn convert_sidecar_content(text: &str, extension: &str) -> String {
776    match extension.to_ascii_lowercase().as_str() {
777        "json" => {
778            // Parse into serde_yaml::Value first (the native representation) then
779            // serialize to JSON. Going directly to serde_json::Value fails for
780            // complex YAML in serde_yaml 0.9 due to cross-crate numeric type conflicts.
781            match serde_yaml::from_str::<serde_yaml::Value>(text) {
782                Ok(val) => match serde_json::to_string_pretty(&val) {
783                    Ok(json) => json,
784                    Err(e) => {
785                        log::warn!("sidecar: JSON serialization failed ({e}); using raw text");
786                        text.to_string()
787                    }
788                },
789                Err(e) => {
790                    log::warn!(
791                        "sidecar: could not parse content for JSON conversion ({e}); using raw text"
792                    );
793                    text.to_string()
794                }
795            }
796        }
797        "yaml" | "yml" => {
798            // Only convert when the source is strict JSON — YAML content passes through
799            // unchanged to avoid re-serialization changing quote style or formatting.
800            if serde_json::from_str::<serde_json::Value>(text).is_ok() {
801                match serde_yaml::from_str::<serde_yaml::Value>(text)
802                    .ok()
803                    .and_then(|v| serde_yaml::to_string(&v).ok())
804                {
805                    Some(yaml) => yaml,
806                    None => {
807                        log::warn!("sidecar: YAML serialization failed; using raw text");
808                        text.to_string()
809                    }
810                }
811            } else {
812                text.to_string()
813            }
814        }
815        _ => text.to_string(),
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    #[test]
824    #[allow(clippy::default_constructed_unit_structs)]
825    fn disassemble_handler_default_equals_new() {
826        let _ = DisassembleXmlFileHandler::default();
827    }
828
829    #[test]
830    fn is_xml_file_matches_case_insensitively() {
831        assert!(DisassembleXmlFileHandler::is_xml_file("foo.xml"));
832        assert!(DisassembleXmlFileHandler::is_xml_file("BAR.XML"));
833        assert!(!DisassembleXmlFileHandler::is_xml_file("foo.txt"));
834    }
835
836    #[test]
837    fn posix_path_converts_backslashes() {
838        assert_eq!(
839            DisassembleXmlFileHandler::posix_path(r"C:\Users\name\file.xml"),
840            "C:/Users/name/file.xml"
841        );
842    }
843
844    #[tokio::test]
845    async fn load_ignore_rules_noop_when_path_missing() {
846        let mut handler = DisassembleXmlFileHandler::new();
847        handler
848            .load_ignore_rules("/definitely/does/not/exist/.ignore")
849            .await;
850        assert!(handler.ign.is_none());
851    }
852
853    #[tokio::test]
854    async fn load_ignore_rules_builds_matcher() {
855        let temp = tempfile::tempdir().unwrap();
856        let path = temp.path().join(".ignore");
857        tokio::fs::write(&path, "*.xml\n").await.unwrap();
858        let mut handler = DisassembleXmlFileHandler::new();
859        handler.load_ignore_rules(path.to_str().unwrap()).await;
860        assert!(handler.ign.is_some());
861        assert!(handler.is_ignored("file.xml"));
862        assert!(!handler.is_ignored("file.txt"));
863    }
864
865    #[test]
866    fn is_ignored_default_false_without_rules() {
867        let handler = DisassembleXmlFileHandler::new();
868        assert!(!handler.is_ignored("some/path.xml"));
869    }
870
871    #[test]
872    fn output_dir_basename_strips_only_last_dot_segment() {
873        // Plain Salesforce-style metadata: strip the `.<suffix>-meta` tail.
874        assert_eq!(
875            DisassembleXmlFileHandler::output_dir_basename("HR_Admin.permissionset-meta"),
876            "HR_Admin"
877        );
878        assert_eq!(
879            DisassembleXmlFileHandler::output_dir_basename("Get_Info.flow-meta"),
880            "Get_Info"
881        );
882    }
883
884    #[test]
885    fn output_dir_basename_preserves_dotted_full_names() {
886        // Approval processes are named `<sobject>.<process>` which yields a stem containing
887        // *two* dots. The old `split('.').next()` returned just `<sobject>`, causing
888        // distinct processes for the same sobject to land in the same output directory and
889        // silently merge during reassembly. The new behaviour keeps the dotted fullName.
890        assert_eq!(
891            DisassembleXmlFileHandler::output_dir_basename(
892                "Account_Merge__c.New_Account_Merges_2.approvalProcess-meta"
893            ),
894            "Account_Merge__c.New_Account_Merges_2"
895        );
896        assert_eq!(
897            DisassembleXmlFileHandler::output_dir_basename(
898                "Account_Merge__c.New_Account_Merges_3.approvalProcess-meta"
899            ),
900            "Account_Merge__c.New_Account_Merges_3"
901        );
902        // Quick actions follow the same `<sobject>.<action>` pattern.
903        assert_eq!(
904            DisassembleXmlFileHandler::output_dir_basename("Case.LogACall.quickAction-meta"),
905            "Case.LogACall"
906        );
907    }
908
909    #[test]
910    fn is_processable_xml_entry_true_only_for_regular_xml_files() {
911        // Pin both the `is_file && is_xml_file` conjunction and the
912        // outer `!` at the call site. All four quadrants of
913        // (is_file, is_xml) are covered.
914        assert!(DisassembleXmlFileHandler::is_processable_xml_entry(
915            true, "foo.xml"
916        ));
917        assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
918            false, "foo.xml"
919        ));
920        assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
921            true, "foo.txt"
922        ));
923        assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
924            false, "foo.txt"
925        ));
926    }
927
928    #[test]
929    fn should_pre_purge_output_requires_both_flag_and_existing_dir() {
930        // `pre_purge=true` alone must not delete a missing directory
931        // (that's a benign no-op, not an error); an existing directory
932        // alone must not be deleted unless the caller asked for purge.
933        assert!(DisassembleXmlFileHandler::should_pre_purge_output(
934            true, true
935        ));
936        assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
937            true, false
938        ));
939        assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
940            false, true
941        ));
942        assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
943            false, false
944        ));
945    }
946
947    #[test]
948    fn file_matches_multi_level_rule_requires_xml_extension() {
949        // Non-`.xml` files are skipped regardless of pattern membership.
950        assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
951            "Foo.txt",
952            "/dir/Foo.txt",
953            "Foo"
954        ));
955    }
956
957    #[test]
958    fn file_matches_multi_level_rule_when_filename_contains_pattern() {
959        assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
960            "MyPattern.xml",
961            "/dir/MyPattern.xml",
962            "MyPattern"
963        ));
964    }
965
966    #[test]
967    fn file_matches_multi_level_rule_when_only_full_path_contains_pattern() {
968        // The pattern may live in a parent directory name even if the
969        // bare file name is something generic like `meta.xml`.
970        assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
971            "child.xml",
972            "/parentPattern/child.xml",
973            "parentPattern"
974        ));
975    }
976
977    #[test]
978    fn file_matches_multi_level_rule_false_when_pattern_absent_everywhere() {
979        assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
980            "Foo.xml",
981            "/dir/Foo.xml",
982            "MissingPattern"
983        ));
984    }
985
986    #[test]
987    fn has_element_to_strip_when_root_key_matches() {
988        let parsed = serde_json::json!({"Foo": {"a": "b"}});
989        assert!(DisassembleXmlFileHandler::has_element_to_strip(
990            &parsed, "Foo"
991        ));
992    }
993
994    #[test]
995    fn has_element_to_strip_when_root_contains_target_child() {
996        let parsed = serde_json::json!({"Foo": {"Bar": {"a": "b"}}});
997        assert!(DisassembleXmlFileHandler::has_element_to_strip(
998            &parsed, "Bar"
999        ));
1000    }
1001
1002    #[test]
1003    fn has_element_to_strip_false_when_target_absent() {
1004        let parsed = serde_json::json!({"Foo": {"a": "b"}});
1005        assert!(!DisassembleXmlFileHandler::has_element_to_strip(
1006            &parsed, "Missing"
1007        ));
1008    }
1009
1010    #[test]
1011    fn has_element_to_strip_false_for_non_object_or_decl_only() {
1012        assert!(!DisassembleXmlFileHandler::has_element_to_strip(
1013            &serde_json::json!("primitive"),
1014            "Foo"
1015        ));
1016        assert!(!DisassembleXmlFileHandler::has_element_to_strip(
1017            &serde_json::json!({"?xml": {}}),
1018            "Foo"
1019        ));
1020    }
1021
1022    fn rule(pattern: &str, root: &str) -> MultiLevelRule {
1023        MultiLevelRule {
1024            file_pattern: pattern.to_string(),
1025            root_to_strip: root.to_string(),
1026            unique_id_elements: String::new(),
1027            path_segment: String::new(),
1028            wrap_root_element: String::new(),
1029            wrap_xmlns: String::new(),
1030        }
1031    }
1032
1033    #[test]
1034    fn rules_share_identity_when_pattern_and_root_match() {
1035        assert!(DisassembleXmlFileHandler::rules_have_same_identity(
1036            &rule("p", "R"),
1037            &rule("p", "R"),
1038        ));
1039    }
1040
1041    #[test]
1042    fn rules_differ_when_file_pattern_differs() {
1043        assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
1044            &rule("p1", "R"),
1045            &rule("p2", "R"),
1046        ));
1047    }
1048
1049    #[test]
1050    fn rules_differ_when_root_to_strip_differs() {
1051        assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
1052            &rule("p", "R1"),
1053            &rule("p", "R2"),
1054        ));
1055    }
1056
1057    #[test]
1058    fn root_element_name_finds_first_non_declaration_key() {
1059        let parsed = serde_json::json!({"?xml": {}, "MyRoot": {"a": "b"}});
1060        assert_eq!(
1061            DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "fallback"),
1062            "MyRoot"
1063        );
1064    }
1065
1066    #[test]
1067    fn root_element_name_falls_back_when_only_declaration_present() {
1068        let parsed = serde_json::json!({"?xml": {}});
1069        assert_eq!(
1070            DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "FallbackRoot"),
1071            "FallbackRoot"
1072        );
1073    }
1074
1075    #[test]
1076    fn root_element_name_falls_back_for_non_object() {
1077        let parsed = serde_json::json!("primitive");
1078        assert_eq!(
1079            DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "Fb"),
1080            "Fb"
1081        );
1082    }
1083
1084    #[test]
1085    fn output_dir_basename_no_dot_returns_stem_unchanged() {
1086        // Stems without any dot are passed through verbatim (no extension to strip).
1087        assert_eq!(DisassembleXmlFileHandler::output_dir_basename("Foo"), "Foo");
1088        assert_eq!(DisassembleXmlFileHandler::output_dir_basename(""), "");
1089    }
1090
1091    #[test]
1092    fn convert_sidecar_content_yaml_to_json() {
1093        // Uses nested YAML matching the fixture shape (quoted strings, string-keyed
1094        // mappings, dotted version strings) to catch serde_yaml→serde_json cross-crate
1095        // numeric type failures that affect simple-key tests but not complex YAML.
1096        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";
1097        let out = convert_sidecar_content(yaml, "json");
1098        let val: serde_json::Value = serde_json::from_str(&out).expect("output must be valid JSON");
1099        assert_eq!(val["openapi"], "3.0.1");
1100        assert_eq!(val["info"]["title"], "@AuraEnabled Apex method APIs");
1101        assert_eq!(val["info"]["version"], "1.0.0");
1102        assert_eq!(
1103            val["paths"]["/uploadFile"]["post"]["operationId"],
1104            "uploadFile"
1105        );
1106    }
1107
1108    #[test]
1109    fn convert_sidecar_content_json_to_yaml() {
1110        let json = r#"{"key":"value","num":42}"#;
1111        let out = convert_sidecar_content(json, "yaml");
1112        // Output must be YAML, not raw JSON — if the yaml arm were deleted, the `_ =>` fallback
1113        // would return the original JSON string, which is also parseable as YAML and would fool
1114        // a parse-only assertion. Asserting strict-JSON parse fails pins the arm deletion mutant.
1115        assert!(
1116            serde_json::from_str::<serde_json::Value>(&out).is_err(),
1117            "output must be YAML format, not raw JSON: {out}"
1118        );
1119        let val: serde_json::Value = serde_yaml::from_str(&out).expect("output must be valid YAML");
1120        assert_eq!(val["key"], "value");
1121        assert_eq!(val["num"], 42);
1122    }
1123
1124    #[test]
1125    fn convert_sidecar_content_json_to_json_prettifies() {
1126        let compact = r#"{"a":1}"#;
1127        let out = convert_sidecar_content(compact, "json");
1128        // Pretty JSON has newlines and indentation.
1129        assert!(out.contains('\n'), "expected pretty JSON, got: {out}");
1130        let val: serde_json::Value = serde_json::from_str(&out).unwrap();
1131        assert_eq!(val["a"], 1);
1132    }
1133
1134    #[test]
1135    fn convert_sidecar_content_unknown_extension_passes_through() {
1136        let raw = "arbitrary: content: here";
1137        assert_eq!(convert_sidecar_content(raw, "txt"), raw);
1138        assert_eq!(convert_sidecar_content(raw, ""), raw);
1139    }
1140
1141    #[test]
1142    fn convert_sidecar_content_malformed_falls_back_to_raw() {
1143        // Tabs inside a YAML flow scalar make it unparseable as YAML/JSON.
1144        let bad = "{{{{ not valid json or yaml at all >>>>>";
1145        assert_eq!(convert_sidecar_content(bad, "json"), bad);
1146        assert_eq!(convert_sidecar_content(bad, "yaml"), bad);
1147    }
1148
1149    #[test]
1150    fn convert_sidecar_content_yml_extension_same_as_yaml() {
1151        let json = r#"{"x":true}"#;
1152        let out = convert_sidecar_content(json, "yml");
1153        let val: serde_json::Value = serde_yaml::from_str(&out).unwrap();
1154        assert_eq!(val["x"], true);
1155    }
1156
1157    #[test]
1158    fn convert_sidecar_content_yaml_passes_through_unchanged() {
1159        // YAML content with a yaml extension must NOT be re-serialized — serde_yaml changes
1160        // double quotes to single quotes, breaking byte-for-byte round-trip assertions.
1161        let yaml = "title: \"@AuraEnabled Apex method APIs\"\nversion: 1.0.0\n";
1162        assert_eq!(convert_sidecar_content(yaml, "yaml"), yaml);
1163        assert_eq!(convert_sidecar_content(yaml, "yml"), yaml);
1164    }
1165}