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