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