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