Skip to main content

config_disassembler/xml/handlers/
reassemble.rs

1//! Reassemble XML from disassembled directory.
2
3use crate::xml::builders::{build_xml_string, merge_xml_elements_owned, reorder_root_keys_owned};
4use crate::xml::multi_level::{ensure_segment_files_structure, load_multi_level_config};
5use crate::xml::parsers::parse_to_xml_object;
6use crate::xml::types::{MultiLevelRule, SidecarSpec, XmlElement};
7use crate::xml::utils::normalize_path_unix;
8use serde_json::Value;
9use std::collections::HashSet;
10use std::ffi::OsString;
11use std::future::Future;
12use std::path::{Path, PathBuf};
13use std::pin::Pin;
14use tokio::fs;
15use tokio::sync::Semaphore;
16use tokio::task::JoinSet;
17
18/// Upper bound on shard files being read + parsed at once across the **whole
19/// process**, not per call. Callers such as sf-decomposer run dozens of
20/// `reassemble` calls concurrently; a per-call bound would multiply by that
21/// count and could exhaust file descriptors (Linux's default soft limit is
22/// 1024). Parsing is CPU-bound, so anything well above the core count only
23/// buys I/O overlap.
24const SHARD_PARSE_CONCURRENCY: usize = 32;
25static SHARD_PARSE_PERMITS: Semaphore = Semaphore::const_new(SHARD_PARSE_CONCURRENCY);
26
27/// `true` for a regular file, following symlinks like the `Path::is_file` this
28/// replaces. Uses the file type the directory listing already returned instead
29/// of a blocking `stat` per entry on an async worker; only symlinks (and the
30/// rare platform that can't report a type) pay for a real metadata call.
31async fn is_regular_file(entry: &fs::DirEntry, path: &Path) -> bool {
32    match entry.file_type().await {
33        Ok(file_type) if !file_type.is_symlink() => file_type.is_file(),
34        _ => fs::metadata(path)
35            .await
36            .map(|meta| meta.is_file())
37            .unwrap_or(false),
38    }
39}
40
41/// Read + parse every shard in `files` concurrently (bounded by
42/// [`SHARD_PARSE_PERMITS`]) and return the results **in input order**, so the
43/// merged output is byte-identical to parsing them one by one. `None` marks a
44/// shard that failed to read or parse, exactly as the sequential path skipped it.
45async fn parse_shards_in_order(
46    files: Vec<String>,
47) -> Result<Vec<Option<XmlElement>>, Box<dyn std::error::Error + Send + Sync>> {
48    // 0 or 1 shards: nothing to overlap, skip the JoinSet setup cost.
49    if files.len() <= 1 {
50        let mut out = Vec::with_capacity(files.len());
51        for file in files {
52            out.push(parse_to_xml_object(&file).await);
53        }
54        return Ok(out);
55    }
56
57    let mut results: Vec<Option<XmlElement>> = vec![None; files.len()];
58    let mut join_set = JoinSet::new();
59    for (index, file) in files.into_iter().enumerate() {
60        join_set.spawn(async move {
61            let _permit = SHARD_PARSE_PERMITS
62                .acquire()
63                .await
64                .expect("static semaphore is never closed");
65            (index, parse_to_xml_object(&file).await)
66        });
67    }
68    while let Some(joined) = join_set.join_next().await {
69        let (index, parsed) =
70            joined.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
71        results[index] = parsed;
72    }
73    Ok(results)
74}
75
76/// Read a `.key_order.json` file (if present) and parse it as a list of root key names.
77async fn read_key_order(path: &Path) -> Option<Vec<String>> {
78    let bytes = fs::read(path).await.ok()?;
79    serde_json::from_slice::<Vec<String>>(&bytes).ok()
80}
81
82/// Read a `.trailing_newline.json` file (if present) and parse it as a bool. Absent --
83/// e.g. a directory disassembled by a crate version predating this sidecar -- defaults
84/// to `false`, preserving the pre-existing "always trim trailing whitespace" behavior.
85async fn read_trailing_newline(path: &Path) -> Option<bool> {
86    let bytes = fs::read(path).await.ok()?;
87    String::from_utf8(bytes).ok()?.trim().parse::<bool>().ok()
88}
89
90/// Remove @xmlns from an object so the reassembled segment wrapper (e.g. programProcesses) has no xmlns.
91fn strip_xmlns_from_value(v: Value) -> Value {
92    match v {
93        Value::Object(obj) => {
94            Value::Object(obj.into_iter().filter(|(k, _)| k != "@xmlns").collect())
95        }
96        other => other,
97    }
98}
99
100/// When recursing into a nested multi-level rule's `path_segment`, the
101/// deeper-level recursion needs the *sibling* rules — every rule
102/// except the one we just matched — so a sub-directory that happens
103/// to share its parent's `path_segment` doesn't re-enter the same
104/// rule. Returns the cloned slice with the matched segment filtered
105/// out. Pure helper extracted from
106/// `reassemble_multi_level_segment_inner`.
107fn deeper_candidate_rules(
108    all_rules: &[MultiLevelRule],
109    exclude_path_segment: &str,
110) -> Vec<MultiLevelRule> {
111    all_rules
112        .iter()
113        .filter(|r| r.path_segment != exclude_path_segment)
114        .cloned()
115        .collect()
116}
117
118/// True when the current directory is the disassembly root for any
119/// of the supplied multi-level rules. Each rule stores the base path
120/// it was disassembled from; if `dir_path` matches one, the caller is
121/// allowed to match that rule's child segments. Pure helper extracted
122/// from `process_files_in_directory` so the `dir_path == base`
123/// equality is testable without a temporary directory tree.
124fn is_at_base_path(dir_path: &str, base_segments: &[(String, String, bool)]) -> bool {
125    base_segments.iter().any(|(base, _, _)| dir_path == base)
126}
127
128type ProcessDirFuture<'a> = Pin<
129    Box<
130        dyn Future<Output = Result<Vec<XmlElement>, Box<dyn std::error::Error + Send + Sync>>>
131            + Send
132            + 'a,
133    >,
134>;
135
136type SegmentFuture<'a> =
137    Pin<Box<dyn Future<Output = Result<(), Box<dyn std::error::Error + Send + Sync>>> + Send + 'a>>;
138
139pub struct ReassembleXmlFileHandler;
140
141impl ReassembleXmlFileHandler {
142    pub fn new() -> Self {
143        Self
144    }
145
146    pub async fn reassemble(
147        &self,
148        file_path: &str,
149        file_extension: Option<&str>,
150        post_purge: bool,
151        sidecar_specs: Option<&[SidecarSpec]>,
152    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
153        let file_path = normalize_path_unix(file_path);
154        if !self.validate_directory(&file_path).await? {
155            return Ok(());
156        }
157
158        let path = Path::new(&file_path);
159        let config = load_multi_level_config(path).await;
160        if let Some(ref config) = config {
161            // Process each rule whose path_segment exists as a directory at the
162            // disassembly root. Inner-only rules (whose segment lives nested under another
163            // rule's item dir) are handled dynamically when the parent rule walks its
164            // items; we hand them in as `nested_rules` candidates here.
165            for (i, rule) in config.rules.iter().enumerate() {
166                let segment_path = path.join(&rule.path_segment);
167                if !segment_path.is_dir() {
168                    continue;
169                }
170                let nested: Vec<MultiLevelRule> = config
171                    .rules
172                    .iter()
173                    .enumerate()
174                    .filter(|(j, _)| *j != i)
175                    .map(|(_, r)| r.clone())
176                    .collect();
177                self.reassemble_multi_level_segment(&segment_path, rule, &nested)
178                    .await?;
179            }
180        }
181
182        // Build one base-segment entry per multi-level rule so the recursive walker can
183        // recognize each rule's path_segment under the disassembly root.
184        let base_segments: Vec<(String, String, bool)> = config
185            .as_ref()
186            .map(|c| {
187                c.rules
188                    .iter()
189                    .map(|r| (file_path.clone(), r.path_segment.clone(), true))
190                    .collect()
191            })
192            .unwrap_or_default();
193        // When multi-level reassembly is done, purge the entire disassembled directory
194        let post_purge_final = post_purge || config.is_some();
195        self.reassemble_plain(
196            &file_path,
197            file_extension,
198            post_purge_final,
199            &base_segments,
200            sidecar_specs,
201        )
202        .await
203    }
204
205    /// Reassemble a single multi-level segment directory.
206    ///
207    /// For each item directory under `segment_path` (e.g. each `<dialog>/` under
208    /// `botDialogs/`):
209    ///
210    /// 1. **Phase 1 — nested rules first.** For every immediate sub-directory whose name
211    ///    matches a `nested_rules` candidate's `path_segment`, recursively reassemble
212    ///    that sub-directory as its own segment. This wraps each per-step file in
213    ///    `<wrap_root_element><inner_segment>...</inner_segment></wrap_root_element>` *before*
214    ///    the outer-level merge sees it, so multiple inner items survive as siblings
215    ///    rather than collapsing into a single bag of leaves.
216    ///
217    /// 2. **Phase 2 — flat sub-directories.** Any remaining sub-directory (anything not
218    ///    consumed by phase 1) is collapsed into a per-item `.xml` at the parent level
219    ///    via [`Self::reassemble_plain`], the original behaviour for things like
220    ///    decompose-rule outputs.
221    ///
222    /// 3. **Phase 3 — merge item.** Everything in the item directory (the `.xml` files
223    ///    written by phases 1 and 2 plus any leaf `.xml` already there) is merged into
224    ///    a single `.xml` at the parent level.
225    ///
226    /// Finally, [`ensure_segment_files_structure`] wraps every `.xml` in `segment_path`
227    /// in `<wrap_root_element><path_segment>...</path_segment></wrap_root_element>` so
228    /// the parent reassembly sees correctly-wrapped siblings.
229    fn reassemble_multi_level_segment<'a>(
230        &'a self,
231        segment_path: &'a Path,
232        rule: &'a MultiLevelRule,
233        nested_rules: &'a [MultiLevelRule],
234    ) -> SegmentFuture<'a> {
235        let segment_path = segment_path.to_path_buf();
236        let rule = rule.clone();
237        let nested_rules = nested_rules.to_vec();
238        Box::pin(async move {
239            self.reassemble_multi_level_segment_inner(&segment_path, &rule, &nested_rules)
240                .await
241        })
242    }
243
244    async fn reassemble_multi_level_segment_inner(
245        &self,
246        segment_path: &Path,
247        rule: &MultiLevelRule,
248        nested_rules: &[MultiLevelRule],
249    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
250        if !segment_path.is_dir() {
251            return Ok(());
252        }
253        let mut entries = Vec::new();
254        let mut read_dir = fs::read_dir(segment_path).await?;
255        while let Some(entry) = read_dir.next_entry().await? {
256            entries.push(entry);
257        }
258        entries.sort_by_key(|e| e.file_name());
259        for entry in entries {
260            let process_path = entry.path();
261            if !process_path.is_dir() {
262                continue;
263            }
264            let process_path_str = normalize_path_unix(&process_path.to_string_lossy());
265            let mut sub_entries = Vec::new();
266            let mut sub_read = fs::read_dir(&process_path).await?;
267            while let Some(e) = sub_read.next_entry().await? {
268                sub_entries.push(e);
269            }
270            sub_entries.sort_by_key(|e| e.file_name());
271
272            // Phase 1: drain any sub-directory that matches a nested rule's
273            // `path_segment` so it is re-wrapped before the outer merge runs.
274            let mut handled: HashSet<OsString> = HashSet::new();
275            for sub_entry in &sub_entries {
276                let sub_path: PathBuf = sub_entry.path();
277                if !sub_path.is_dir() {
278                    continue;
279                }
280                let sub_name = sub_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
281                let Some(nested_rule) = nested_rules.iter().find(|r| r.path_segment == sub_name)
282                else {
283                    continue;
284                };
285                // Pass everything *except* the rule we just matched as deeper candidates.
286                // Sibling rules remain candidates further down the tree without re-entering
287                // the same rule on a sub-dir that happens to share its name.
288                let deeper = deeper_candidate_rules(nested_rules, &nested_rule.path_segment);
289                self.reassemble_multi_level_segment(&sub_path, nested_rule, &deeper)
290                    .await?;
291                handled.insert(sub_entry.file_name());
292            }
293
294            // Phase 2: collapse remaining sub-directories into per-item .xml files at
295            // the parent level (preserves existing behaviour for non-nested-rule subdirs).
296            for sub_entry in &sub_entries {
297                let sub_path = sub_entry.path();
298                if !sub_path.is_dir() {
299                    continue;
300                }
301                if handled.contains(&sub_entry.file_name()) {
302                    continue;
303                }
304                let sub_path_str = normalize_path_unix(&sub_path.to_string_lossy());
305                self.reassemble_plain(&sub_path_str, Some("xml"), true, &[], None)
306                    .await?;
307            }
308
309            // Phase 3: merge everything in the item dir into a single .xml at the parent.
310            self.reassemble_plain(&process_path_str, Some("xml"), true, &[], None)
311                .await?;
312        }
313        ensure_segment_files_structure(
314            segment_path,
315            &rule.wrap_root_element,
316            &rule.path_segment,
317            &rule.wrap_xmlns,
318        )
319        .await?;
320        Ok(())
321    }
322
323    /// Merge and write reassembled XML (no multi-level pre-step). Used internally.
324    /// `base_segments` carries one tuple `(base_path, segment_name, extract_inner)` per
325    /// multi-level rule. When the recursive walker reaches `base_path` and finds a subdir
326    /// whose name matches one of the segment_names, that subdir's XML files are folded
327    /// into a single array under the segment_name key. When extract_inner is true, each
328    /// file's structure is `document_root > segment_name > content` and only the content
329    /// is collected; otherwise the whole root is kept.
330    async fn reassemble_plain(
331        &self,
332        file_path: &str,
333        file_extension: Option<&str>,
334        post_purge: bool,
335        base_segments: &[(String, String, bool)],
336        sidecar_specs: Option<&[SidecarSpec]>,
337    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
338        let file_path = normalize_path_unix(file_path);
339        log::debug!("Parsing directory to reassemble: {}", file_path);
340        let parsed_objects = self
341            .process_files_in_directory(file_path.to_string(), base_segments.to_vec())
342            .await?;
343
344        if parsed_objects.is_empty() {
345            log::error!(
346                "No files under {} were parsed successfully. A reassembled XML file was not created.",
347                file_path
348            );
349            return Ok(());
350        }
351
352        // merge_xml_elements only returns None when every parsed element is empty or
353        // declaration-only (no usable root). Treat that the same as "nothing parsed"
354        // rather than emitting an `<root></root>` stub.
355        // Moves the parsed shards into the merged tree (no second copy of the document).
356        let Some(mut merged) = merge_xml_elements_owned(parsed_objects) else {
357            log::error!(
358                "No usable root element found while merging files under {}. A reassembled XML file was not created.",
359                file_path
360            );
361            return Ok(());
362        };
363
364        // Resolve sidecar specs: use the caller-supplied slice when non-empty,
365        // otherwise auto-detect from .sidecars.json written by disassembly.
366        let auto_specs: Vec<crate::xml::types::SidecarSpec>;
367        let effective_specs: Option<&[crate::xml::types::SidecarSpec]> =
368            if sidecar_specs.is_some_and(|s| !s.is_empty()) {
369                sidecar_specs
370            } else {
371                let meta_path = Path::new(&file_path).join(".sidecars.json");
372                if let Ok(content) = fs::read_to_string(&meta_path).await {
373                    if let Ok(parsed) =
374                        serde_json::from_str::<Vec<crate::xml::types::SidecarSpec>>(&content)
375                    {
376                        auto_specs = parsed;
377                        Some(auto_specs.as_slice())
378                    } else {
379                        None
380                    }
381                } else {
382                    None
383                }
384            };
385
386        // Inject sidecar element content before key reordering so the element
387        // lands at its original position rather than being appended at the end.
388        if let Some(specs) = effective_specs {
389            inject_sidecar_elements(&file_path, &mut merged, specs).await?;
390        }
391
392        // Apply stored key order so reassembled XML matches original document order.
393        let key_order_path = Path::new(&file_path).join(".key_order.json");
394        if let Some(order) = read_key_order(&key_order_path).await {
395            merged = reorder_root_keys_owned(merged, &order);
396        }
397
398        let mut final_xml = build_xml_string(&merged);
399
400        // Reproduce the original file's trailing newline (or lack of one) instead of
401        // always trimming it away -- see `.trailing_newline.json` in build_disassembled_files.rs.
402        let trailing_newline_path = Path::new(&file_path).join(".trailing_newline.json");
403        if read_trailing_newline(&trailing_newline_path)
404            .await
405            .unwrap_or(false)
406        {
407            final_xml.push('\n');
408        }
409
410        let output_path = self.get_output_path(&file_path, file_extension);
411
412        fs::write(&output_path, &final_xml).await?;
413
414        // Remove sidecar files and metadata after successful injection. They are
415        // decomposition artifacts: the content is now inside the reassembled XML.
416        // Only done when post_purge is requested.
417        if post_purge {
418            if let Some(specs) = effective_specs {
419                let path = Path::new(&file_path);
420                let base = path
421                    .file_name()
422                    .and_then(|n| n.to_str())
423                    .unwrap_or("output");
424                for spec in specs {
425                    let sidecar = path.join(format!("{}.{}", base, spec.extension));
426                    fs::remove_file(&sidecar).await.ok();
427                }
428            }
429            fs::remove_dir_all(file_path).await.ok();
430        }
431
432        Ok(())
433    }
434
435    fn process_files_in_directory<'a>(
436        &'a self,
437        dir_path: String,
438        base_segments: Vec<(String, String, bool)>,
439    ) -> ProcessDirFuture<'a> {
440        Box::pin(async move {
441            let mut parsed = Vec::new();
442            let mut entries = Vec::new();
443            let mut read_dir = fs::read_dir(&dir_path).await?;
444            while let Some(entry) = read_dir.next_entry().await? {
445                entries.push(entry);
446            }
447            // Sort by full filename for deterministic cross-platform ordering
448            entries.sort_by(|a, b| {
449                let a_name = a.file_name().to_string_lossy().to_string();
450                let b_name = b.file_name().to_string_lossy().to_string();
451                a_name.cmp(&b_name)
452            });
453
454            // We are at the disassembly root for a given rule when our dir_path matches
455            // the base_path stored on that rule. Each rule shares the same base_path in
456            // the current implementation, but tracking them per-entry keeps the door open
457            // for future per-rule base_paths without another signature change.
458            let is_base = is_at_base_path(&dir_path, &base_segments);
459
460            // Pass 1: classify entries in sorted order. Shard files are queued for the
461            // concurrent parse below; each slot remembers its position so `parsed` ends
462            // up in exactly the order the old one-file-at-a-time loop produced.
463            enum Slot {
464                Shard(usize),
465                Dir(PathBuf, String),
466            }
467            let mut slots = Vec::with_capacity(entries.len());
468            let mut shard_files = Vec::new();
469            for entry in entries {
470                let path = entry.path();
471                let file_path = normalize_path_unix(&path.to_string_lossy()).to_string();
472                if is_regular_file(&entry, &path).await {
473                    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
474                    if !name.starts_with('.') && self.is_parsable_file(name) {
475                        slots.push(Slot::Shard(shard_files.len()));
476                        shard_files.push(file_path);
477                    }
478                } else {
479                    slots.push(Slot::Dir(path, file_path));
480                }
481            }
482
483            // Pass 2: read + parse every shard in this directory concurrently. This was a
484            // sequential `.await` per file, which left one large component (e.g. a
485            // permission set with thousands of shards) on a single core for its whole
486            // reassemble.
487            let mut shard_results = parse_shards_in_order(shard_files).await?;
488
489            // Pass 3: assemble in the original order; subdirectories recurse as before.
490            for slot in slots {
491                let (path, file_path) = match slot {
492                    Slot::Shard(index) => {
493                        if let Some(parsed_obj) = shard_results[index].take() {
494                            parsed.push(parsed_obj);
495                        }
496                        continue;
497                    }
498                    Slot::Dir(path, file_path) => (path, file_path),
499                };
500                // Anything not a regular file is treated as a directory; symlinks and
501                // other exotic entries simply recurse via read_dir below.
502                let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
503                let matched_segment = if is_base {
504                    base_segments
505                        .iter()
506                        .find(|(_, seg_name, _)| seg_name == dir_name)
507                        .cloned()
508                } else {
509                    None
510                };
511                if let Some((_, segment_name, extract_inner)) = matched_segment {
512                    let segment_element = self
513                        .collect_segment_as_array(&file_path, &segment_name, extract_inner)
514                        .await?;
515                    if let Some(el) = segment_element {
516                        parsed.push(el);
517                    }
518                } else {
519                    let sub_parsed = self
520                        .process_files_in_directory(file_path, base_segments.clone())
521                        .await?;
522                    parsed.extend(sub_parsed);
523                }
524            }
525
526            Ok(parsed)
527        })
528    }
529
530    /// Collect all .xml files in a directory, parse each, and build one element with
531    /// root_key and single key segment_name whose value is array of each file's content.
532    /// When extract_inner is true, each file has root > segment_name > content; we push that content.
533    async fn collect_segment_as_array(
534        &self,
535        segment_dir: &str,
536        segment_name: &str,
537        extract_inner: bool,
538    ) -> Result<Option<XmlElement>, Box<dyn std::error::Error + Send + Sync>> {
539        let mut xml_files = Vec::new();
540        let mut read_dir = fs::read_dir(segment_dir).await?;
541        while let Some(entry) = read_dir.next_entry().await? {
542            let path = entry.path();
543            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
544            if !name.starts_with('.')
545                && self.is_parsable_file(name)
546                && is_regular_file(&entry, &path).await
547            {
548                xml_files.push(normalize_path_unix(&path.to_string_lossy()).to_string());
549            }
550        }
551        xml_files.sort();
552
553        let mut root_contents = Vec::new();
554        let mut first_xml: Option<(String, Option<Value>)> = None;
555        // Parsed concurrently, consumed in sorted order (see parse_shards_in_order).
556        for parsed in parse_shards_in_order(xml_files).await? {
557            // parse_to_xml_object always yields a JSON object on success; treat any other
558            // shape (including parse failure) as a skip without branching explicitly.
559            let Some(parsed) = parsed else {
560                continue;
561            };
562            let obj_owned = parsed.as_object().cloned().unwrap_or_default();
563            let obj = &obj_owned;
564            let Some(root_key) = obj.keys().find(|k| *k != "?xml").cloned() else {
565                continue;
566            };
567            let root_val = obj
568                .get(&root_key)
569                .cloned()
570                .unwrap_or(Value::Object(serde_json::Map::new()));
571            let mut content = if extract_inner {
572                root_val
573                    .get(segment_name)
574                    .cloned()
575                    .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
576            } else {
577                root_val
578            };
579            // Inner segment element (e.g. programProcesses) should not have xmlns in output
580            if extract_inner {
581                content = strip_xmlns_from_value(content);
582            }
583            root_contents.push(content);
584            if first_xml.is_none() {
585                first_xml = Some((root_key, obj.get("?xml").cloned()));
586            }
587        }
588        if root_contents.is_empty() {
589            return Ok(None);
590        }
591        let (root_key, decl_opt) = first_xml.unwrap();
592        let mut content = serde_json::Map::new();
593        content.insert(segment_name.to_string(), Value::Array(root_contents));
594        let mut top = serde_json::Map::new();
595        if let Some(decl) = decl_opt {
596            top.insert("?xml".to_string(), decl);
597        } else {
598            let mut d = serde_json::Map::new();
599            d.insert("@version".to_string(), Value::String("1.0".to_string()));
600            d.insert("@encoding".to_string(), Value::String("UTF-8".to_string()));
601            top.insert("?xml".to_string(), Value::Object(d));
602        }
603        top.insert(root_key, Value::Object(content));
604        Ok(Some(Value::Object(top)))
605    }
606
607    fn is_parsable_file(&self, file_name: &str) -> bool {
608        let lower = file_name.to_lowercase();
609        lower.ends_with(".xml")
610            || lower.ends_with(".json")
611            || lower.ends_with(".json5")
612            || lower.ends_with(".yaml")
613            || lower.ends_with(".yml")
614    }
615
616    async fn validate_directory(
617        &self,
618        path: &str,
619    ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
620        let meta = fs::metadata(path).await?;
621        if !meta.is_dir() {
622            log::error!(
623                "The provided path to reassemble is not a directory: {}",
624                path
625            );
626            return Ok(false);
627        }
628        Ok(true)
629    }
630
631    fn get_output_path(&self, dir_path: &str, extension: Option<&str>) -> String {
632        let path = Path::new(dir_path);
633        let parent = path.parent().unwrap_or(Path::new("."));
634        let base_name = path
635            .file_name()
636            .and_then(|n| n.to_str())
637            .unwrap_or("output");
638        let ext = extension.unwrap_or("xml");
639        parent
640            .join(format!("{}.{}", base_name, ext))
641            .to_string_lossy()
642            .to_string()
643    }
644}
645
646impl Default for ReassembleXmlFileHandler {
647    fn default() -> Self {
648        Self::new()
649    }
650}
651
652/// Convert `content` (in whatever format the sidecar file uses) to `target_format`.
653///
654/// - `"json"` → parse as YAML (superset of JSON) then emit as pretty JSON
655/// - `"yaml"` / `"yml"` → only convert when content is strict JSON; YAML passes through
656/// - anything else → pass through unchanged
657///
658/// Falls back to raw content with a warning when conversion fails.
659fn convert_to_format(content: &str, target_format: &str) -> String {
660    match target_format.to_ascii_lowercase().as_str() {
661        "json" => {
662            match serde_saphyr::from_str::<serde_json::Value>(content) {
663                // Serializing a serde_json::Value cannot fail.
664                Ok(val) => {
665                    serde_json::to_string_pretty(&val).unwrap_or_else(|_| content.to_string())
666                }
667                Err(e) => {
668                    log::warn!("sidecar reassemble: could not parse content for JSON conversion ({e}); using raw content");
669                    content.to_string()
670                }
671            }
672        }
673        "yaml" | "yml" => {
674            if serde_json::from_str::<serde_json::Value>(content).is_ok() {
675                match serde_json::from_str::<serde_json::Value>(content)
676                    .ok()
677                    .and_then(|v| serde_saphyr::to_string(&v).ok())
678                {
679                    Some(yaml) => yaml,
680                    None => {
681                        log::warn!(
682                            "sidecar reassemble: YAML serialization failed; using raw content"
683                        );
684                        content.to_string()
685                    }
686                }
687            } else {
688                content.to_string()
689            }
690        }
691        _ => content.to_string(),
692    }
693}
694
695/// Read sidecar files and inject their content back into the merged XML value
696/// before serialisation.
697///
698/// Each sidecar is located inside the decomposed directory itself
699/// (e.g. `MySvc/MySvc.yaml`). If a sidecar is absent (element was not in
700/// the original XML, or was never extracted), the spec is silently skipped.
701///
702/// The injected value uses the `{"#raw-text": content}` shape that `build_xml_string`
703/// writes with `partial_escape`: mandatory XML chars (`<`, `>`, `&`) are escaped
704/// but `"` is left as-is so YAML content round-trips without `&quot;` inflation.
705async fn inject_sidecar_elements(
706    dir_path: &str,
707    merged: &mut XmlElement,
708    specs: &[SidecarSpec],
709) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
710    let path = Path::new(dir_path);
711    let base = path
712        .file_name()
713        .and_then(|n| n.to_str())
714        .unwrap_or("output");
715
716    let root_key = merged
717        .as_object()
718        .and_then(|o| o.keys().find(|k| *k != "?xml").cloned());
719    let Some(root_key) = root_key else {
720        return Ok(());
721    };
722
723    if let Some(root_val) = merged.as_object_mut().and_then(|o| o.get_mut(&root_key)) {
724        if let Some(root_obj) = root_val.as_object_mut() {
725            for spec in specs {
726                let sidecar_path = path.join(format!("{}.{}", base, spec.extension));
727                let Ok(content) = fs::read_to_string(&sidecar_path).await else {
728                    continue;
729                };
730                // When the original XML embedded a different format than the sidecar
731                // extension (e.g. JSON schema extracted to a .yaml sidecar), convert
732                // the sidecar content back to the original format before injecting.
733                let final_content = match &spec.original_format {
734                    Some(fmt) => convert_to_format(&content, fmt),
735                    None => content,
736                };
737                root_obj.insert(
738                    spec.element.clone(),
739                    serde_json::json!({ "#raw-text": final_content }),
740                );
741            }
742        }
743    }
744
745    Ok(())
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751    use serde_json::json;
752
753    #[test]
754    #[allow(clippy::default_constructed_unit_structs)]
755    fn reassemble_handler_default_equals_new() {
756        let _ = ReassembleXmlFileHandler::default();
757    }
758
759    #[test]
760    fn strip_xmlns_from_value_passes_non_object_through() {
761        let s = Value::String("hello".to_string());
762        assert_eq!(
763            strip_xmlns_from_value(s),
764            Value::String("hello".to_string())
765        );
766        let arr = json!([1, 2]);
767        assert_eq!(strip_xmlns_from_value(arr.clone()), arr);
768    }
769
770    #[test]
771    fn strip_xmlns_from_value_removes_xmlns_key() {
772        let obj = json!({ "@xmlns": "ns", "child": 1 });
773        let stripped = strip_xmlns_from_value(obj);
774        let map = stripped.as_object().unwrap();
775        assert!(map.get("@xmlns").is_none());
776        assert_eq!(map.get("child").and_then(|v| v.as_i64()), Some(1));
777    }
778
779    #[test]
780    fn is_parsable_file_recognises_supported_extensions() {
781        let h = ReassembleXmlFileHandler::new();
782        assert!(h.is_parsable_file("a.xml"));
783        assert!(h.is_parsable_file("a.json"));
784        assert!(h.is_parsable_file("a.json5"));
785        assert!(h.is_parsable_file("a.yaml"));
786        assert!(h.is_parsable_file("a.yml"));
787        assert!(h.is_parsable_file("A.XML"));
788        assert!(!h.is_parsable_file("a.txt"));
789    }
790
791    #[test]
792    fn get_output_path_appends_extension_and_uses_parent_dir() {
793        let h = ReassembleXmlFileHandler::new();
794        let out = h.get_output_path("/tmp/foo", Some("xml"));
795        assert!(out.ends_with("foo.xml"));
796        let out_default = h.get_output_path("/tmp/bar", None);
797        assert!(out_default.ends_with("bar.xml"));
798        // No parent - uses "." fallback
799        assert_eq!(h.get_output_path("only", Some("json")), "only.json");
800    }
801
802    #[tokio::test]
803    async fn reassemble_multi_level_segment_noop_when_not_dir() {
804        let h = ReassembleXmlFileHandler::new();
805        let tmp = tempfile::tempdir().unwrap();
806        let file = tmp.path().join("not_a_dir.txt");
807        tokio::fs::write(&file, "hi").await.unwrap();
808        let rule = crate::xml::types::MultiLevelRule {
809            file_pattern: String::new(),
810            root_to_strip: String::new(),
811            unique_id_elements: String::new(),
812            path_segment: String::new(),
813            wrap_root_element: "Root".to_string(),
814            wrap_xmlns: String::new(),
815        };
816        h.reassemble_multi_level_segment(&file, &rule, &[])
817            .await
818            .unwrap();
819    }
820
821    #[tokio::test]
822    async fn reassemble_multi_level_segment_skips_files_in_segment_root() {
823        let h = ReassembleXmlFileHandler::new();
824        let tmp = tempfile::tempdir().unwrap();
825        let segment = tmp.path().join("segment");
826        tokio::fs::create_dir(&segment).await.unwrap();
827        // A bare file inside the segment dir should be skipped (not a subdir).
828        tokio::fs::write(segment.join("stray.txt"), "x")
829            .await
830            .unwrap();
831        let rule = crate::xml::types::MultiLevelRule {
832            file_pattern: String::new(),
833            root_to_strip: String::new(),
834            unique_id_elements: String::new(),
835            path_segment: "segment".to_string(),
836            wrap_root_element: "Root".to_string(),
837            wrap_xmlns: "http://example.com".to_string(),
838        };
839        h.reassemble_multi_level_segment(&segment, &rule, &[])
840            .await
841            .unwrap();
842    }
843
844    #[tokio::test]
845    async fn collect_segment_as_array_returns_none_for_empty_dir() {
846        let h = ReassembleXmlFileHandler::new();
847        let tmp = tempfile::tempdir().unwrap();
848        let out = h
849            .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
850            .await
851            .unwrap();
852        assert!(out.is_none());
853    }
854
855    #[tokio::test]
856    async fn collect_segment_as_array_skips_unparseable_and_empty_roots() {
857        let h = ReassembleXmlFileHandler::new();
858        let tmp = tempfile::tempdir().unwrap();
859        // Unparseable XML
860        tokio::fs::write(tmp.path().join("bad.xml"), "<<")
861            .await
862            .unwrap();
863        // Valid XML but only declaration and no root after parse
864        tokio::fs::write(tmp.path().join("only-decl.xml"), "")
865            .await
866            .unwrap();
867        // Hidden file is skipped
868        tokio::fs::write(tmp.path().join(".hidden.xml"), "<r/>")
869            .await
870            .unwrap();
871        let out = h
872            .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", false)
873            .await
874            .unwrap();
875        assert!(out.is_none());
876    }
877
878    #[tokio::test]
879    async fn collect_segment_as_array_without_xml_decl_inserts_default_decl() {
880        // XML files without a `<?xml ?>` declaration: the `else` branch in
881        // collect_segment_as_array must synthesize a default declaration with
882        // version="1.0" and encoding="UTF-8".
883        let h = ReassembleXmlFileHandler::new();
884        let tmp = tempfile::tempdir().unwrap();
885        tokio::fs::write(
886            tmp.path().join("a.xml"),
887            r#"<Root><seg><x>1</x></seg></Root>"#,
888        )
889        .await
890        .unwrap();
891        let out = h
892            .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
893            .await
894            .unwrap()
895            .unwrap();
896        let obj = out.as_object().unwrap();
897        let decl = obj
898            .get("?xml")
899            .and_then(|v| v.as_object())
900            .expect("default declaration must be inserted when XML has none");
901        assert_eq!(decl.get("@version").and_then(|v| v.as_str()), Some("1.0"));
902        assert_eq!(
903            decl.get("@encoding").and_then(|v| v.as_str()),
904            Some("UTF-8")
905        );
906    }
907
908    #[tokio::test]
909    async fn collect_segment_as_array_with_xml_decl_preserves_it() {
910        // XML file WITH a `<?xml ?>` declaration: `decl_opt` is Some → line 438
911        // `top.insert("?xml", decl)` is executed (the `if let Some(decl)` branch).
912        let h = ReassembleXmlFileHandler::new();
913        let tmp = tempfile::tempdir().unwrap();
914        tokio::fs::write(
915            tmp.path().join("a.xml"),
916            r#"<?xml version="1.0" encoding="UTF-8"?><Root><seg><x>1</x></seg></Root>"#,
917        )
918        .await
919        .unwrap();
920        let out = h
921            .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
922            .await
923            .unwrap()
924            .unwrap();
925        let obj = out.as_object().unwrap();
926        let decl = obj
927            .get("?xml")
928            .and_then(|v| v.as_object())
929            .expect("?xml declaration must be preserved from source");
930        assert_eq!(decl.get("@version").and_then(|v| v.as_str()), Some("1.0"));
931        assert_eq!(
932            decl.get("@encoding").and_then(|v| v.as_str()),
933            Some("UTF-8")
934        );
935    }
936
937    #[tokio::test]
938    async fn collect_segment_as_array_without_extract_inner_wraps_root() {
939        let h = ReassembleXmlFileHandler::new();
940        let tmp = tempfile::tempdir().unwrap();
941        tokio::fs::write(tmp.path().join("a.xml"), r#"<Root><child>1</child></Root>"#)
942            .await
943            .unwrap();
944        let out = h
945            .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", false)
946            .await
947            .unwrap()
948            .unwrap();
949        let obj = out.as_object().unwrap();
950        assert!(obj.contains_key("?xml"));
951        let root = obj.get("Root").and_then(|r| r.as_object()).unwrap();
952        assert!(root.get("seg").and_then(|v| v.as_array()).is_some());
953    }
954
955    fn rule_with_segment(segment: &str) -> MultiLevelRule {
956        MultiLevelRule {
957            file_pattern: String::new(),
958            root_to_strip: String::new(),
959            unique_id_elements: String::new(),
960            path_segment: segment.to_string(),
961            wrap_root_element: String::new(),
962            wrap_xmlns: String::new(),
963        }
964    }
965
966    #[test]
967    fn deeper_candidate_rules_excludes_the_matched_segment() {
968        // The matched rule must be filtered out, otherwise the
969        // recursion would re-enter that rule when a child directory
970        // happens to share its `path_segment`.
971        let rules = vec![rule_with_segment("seg_a"), rule_with_segment("seg_b")];
972        let deeper = deeper_candidate_rules(&rules, "seg_a");
973        assert_eq!(deeper.len(), 1);
974        assert_eq!(deeper[0].path_segment, "seg_b");
975    }
976
977    #[test]
978    fn deeper_candidate_rules_keeps_all_when_no_segment_matches() {
979        // When `exclude_path_segment` doesn't correspond to any rule
980        // the input is forwarded unchanged. Pins the `!= -> ==` mutant
981        // which would otherwise return an empty vec here.
982        let rules = vec![rule_with_segment("seg_a"), rule_with_segment("seg_b")];
983        let deeper = deeper_candidate_rules(&rules, "missing");
984        assert_eq!(deeper.len(), 2);
985    }
986
987    #[test]
988    fn deeper_candidate_rules_returns_empty_for_empty_input() {
989        let deeper: Vec<MultiLevelRule> = deeper_candidate_rules(&[], "anything");
990        assert!(deeper.is_empty());
991    }
992
993    #[test]
994    fn is_at_base_path_true_when_dir_matches_any_segment() {
995        let segs = vec![
996            ("/base/other".to_string(), "seg1".to_string(), false),
997            ("/base/here".to_string(), "seg2".to_string(), false),
998        ];
999        assert!(is_at_base_path("/base/here", &segs));
1000    }
1001
1002    #[test]
1003    fn is_at_base_path_false_when_dir_matches_nothing() {
1004        let segs = vec![("/base/a".to_string(), "seg".to_string(), false)];
1005        assert!(!is_at_base_path("/base/b", &segs));
1006    }
1007
1008    #[test]
1009    fn is_at_base_path_false_for_empty_segments() {
1010        let segs: Vec<(String, String, bool)> = Vec::new();
1011        assert!(!is_at_base_path("/anywhere", &segs));
1012    }
1013
1014    #[tokio::test]
1015    async fn parse_shards_in_order_keeps_input_order_and_marks_failures() {
1016        let tmp = tempfile::tempdir().unwrap();
1017        let mut files = Vec::new();
1018        for i in 0..100 {
1019            // Every 10th entry points at a file that doesn't exist -> None, in place.
1020            let path = tmp.path().join(format!("{i:03}.xml"));
1021            if i % 10 != 0 {
1022                tokio::fs::write(&path, format!("<Root><n>{i}</n></Root>"))
1023                    .await
1024                    .unwrap();
1025            }
1026            files.push(path.to_string_lossy().to_string());
1027        }
1028
1029        let results = parse_shards_in_order(files).await.unwrap();
1030
1031        assert_eq!(results.len(), 100);
1032        for (i, result) in results.iter().enumerate() {
1033            if i % 10 == 0 {
1034                assert!(result.is_none(), "missing shard {i} should be None");
1035            } else {
1036                let n = result.as_ref().unwrap()["Root"]["n"]["#text"]
1037                    .as_str()
1038                    .unwrap();
1039                assert_eq!(n, i.to_string(), "shard {i} out of order");
1040            }
1041        }
1042    }
1043
1044    // `is_regular_file` must follow symlinks like the `Path::is_file` it replaced:
1045    // a symlinked shard is parsed as a shard (not recursed into as a directory,
1046    // which would fail), and a symlinked directory is still recursed into.
1047    // Unix-only: creating symlinks on Windows needs elevated privileges.
1048    #[cfg(unix)]
1049    #[tokio::test]
1050    async fn reassemble_follows_symlinked_shards_and_directories() {
1051        use std::os::unix::fs::symlink;
1052
1053        let tmp = tempfile::tempdir().unwrap();
1054        let outside = tmp.path().join("outside");
1055        tokio::fs::create_dir_all(outside.join("nested"))
1056            .await
1057            .unwrap();
1058        tokio::fs::write(outside.join("b.xml"), "<Root><n>b</n></Root>")
1059            .await
1060            .unwrap();
1061        tokio::fs::write(
1062            outside.join("nested").join("c.xml"),
1063            "<Root><n>c</n></Root>",
1064        )
1065        .await
1066        .unwrap();
1067
1068        let dir = tmp.path().join("Bench");
1069        tokio::fs::create_dir(&dir).await.unwrap();
1070        tokio::fs::write(dir.join("a.xml"), "<Root><n>a</n></Root>")
1071            .await
1072            .unwrap();
1073        symlink(outside.join("b.xml"), dir.join("b.xml")).unwrap();
1074        symlink(outside.join("nested"), dir.join("c_dir")).unwrap();
1075
1076        ReassembleXmlFileHandler::new()
1077            .reassemble(dir.to_str().unwrap(), Some("xml"), false, None)
1078            .await
1079            .unwrap();
1080
1081        let output = tokio::fs::read_to_string(tmp.path().join("Bench.xml"))
1082            .await
1083            .unwrap();
1084        let a = output.find("<n>a</n>").expect("regular shard a");
1085        let b = output.find("<n>b</n>").expect("symlinked shard b");
1086        let c = output
1087            .find("<n>c</n>")
1088            .expect("shard c under symlinked dir");
1089        assert!(a < b && b < c, "sorted order a, b, c_dir/: {output}");
1090    }
1091
1092    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1093    async fn reassemble_many_shards_preserves_sorted_order() {
1094        // Well above SHARD_PARSE_CONCURRENCY, on a multi-thread runtime, so shards
1095        // finish parsing out of order; the merged output must still follow the
1096        // sorted file names.
1097        let tmp = tempfile::tempdir().unwrap();
1098        let dir = tmp.path().join("Bench");
1099        tokio::fs::create_dir(&dir).await.unwrap();
1100        for i in 0..200 {
1101            // Uneven shard sizes make completion order differ from input order.
1102            let padding = "x".repeat((i * 7919) % 4096);
1103            tokio::fs::write(
1104                dir.join(format!("{i:04}.field-meta.xml")),
1105                format!(
1106                    "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Root><fields><n>{i}</n><pad>{padding}</pad></fields></Root>"
1107                ),
1108            )
1109            .await
1110            .unwrap();
1111        }
1112
1113        ReassembleXmlFileHandler::new()
1114            .reassemble(dir.to_str().unwrap(), Some("xml"), false, None)
1115            .await
1116            .unwrap();
1117
1118        let output = tokio::fs::read_to_string(tmp.path().join("Bench.xml"))
1119            .await
1120            .unwrap();
1121        let order: Vec<usize> = output
1122            .match_indices("<n>")
1123            .map(|(at, _)| {
1124                let rest = &output[at + 3..];
1125                rest[..rest.find('<').unwrap()].parse().unwrap()
1126            })
1127            .collect();
1128        assert_eq!(order, (0..200).collect::<Vec<_>>());
1129    }
1130
1131    // Pins the `delete ! in is_some_and(|s| !s.is_empty())` mutant at line 299.
1132    // When sidecar_specs is Some(&[]) the caller supplied nothing useful, so the
1133    // code must fall through to auto-detect via .sidecars.json — not treat the
1134    // empty slice as authoritative.
1135    #[tokio::test]
1136    async fn reassemble_plain_some_empty_sidecar_specs_falls_through_to_auto_detect() {
1137        let h = ReassembleXmlFileHandler::new();
1138        let tmp = tempfile::tempdir().unwrap();
1139        let dir = tmp.path().join("mydir");
1140        tokio::fs::create_dir(&dir).await.unwrap();
1141
1142        tokio::fs::write(
1143            dir.join("a.xml"),
1144            r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1145        )
1146        .await
1147        .unwrap();
1148
1149        tokio::fs::write(
1150            dir.join(".sidecars.json"),
1151            r#"[{"element":"Notes","extension":"yaml"}]"#,
1152        )
1153        .await
1154        .unwrap();
1155
1156        // Sidecar file name = directory name + extension (matches inject_sidecar_elements logic).
1157        tokio::fs::write(dir.join("mydir.yaml"), "key: value")
1158            .await
1159            .unwrap();
1160
1161        h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], Some(&[]))
1162            .await
1163            .unwrap();
1164
1165        let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1166            .await
1167            .unwrap();
1168        assert!(
1169            output.contains("key: value"),
1170            "sidecar content missing — auto-detect did not run:\n{output}"
1171        );
1172    }
1173
1174    // Pins `delete match arm "json"` mutant: without the arm, YAML input to
1175    // convert_to_format("json") falls through to `_ =>` and returns raw YAML.
1176    #[test]
1177    fn convert_to_format_yaml_to_json() {
1178        let yaml = "openapi: 3.0.1\ninfo:\n  title: \"Test API\"\n  version: 1.0.0\n";
1179        let out = convert_to_format(yaml, "json");
1180        let val: serde_json::Value = serde_json::from_str(&out).expect("output must be valid JSON");
1181        assert_eq!(val["openapi"], "3.0.1");
1182        assert_eq!(val["info"]["title"], "Test API");
1183        assert_eq!(val["info"]["version"], "1.0.0");
1184    }
1185
1186    // Pins `delete match arm "yaml" | "yml"` mutant: without the arm, JSON input
1187    // to convert_to_format("yaml") falls through to `_ =>` and returns raw JSON.
1188    #[test]
1189    fn convert_to_format_json_to_yaml() {
1190        let json = r#"{"key":"value","num":42}"#;
1191        let out = convert_to_format(json, "yaml");
1192        assert!(
1193            serde_json::from_str::<serde_json::Value>(&out).is_err(),
1194            "output must be YAML format, not raw JSON: {out}"
1195        );
1196        let val: serde_json::Value =
1197            serde_saphyr::from_str(&out).expect("output must be valid YAML");
1198        assert_eq!(val["key"], "value");
1199        assert_eq!(val["num"], 42);
1200    }
1201
1202    #[test]
1203    fn convert_to_format_yml_extension_same_as_yaml() {
1204        let json = r#"{"x":true}"#;
1205        let out = convert_to_format(json, "yml");
1206        let val: serde_json::Value = serde_saphyr::from_str(&out).unwrap();
1207        assert_eq!(val["x"], true);
1208    }
1209
1210    #[test]
1211    fn convert_to_format_yaml_passes_through_unchanged() {
1212        // Pure YAML fails serde_json parse → returned as-is without re-serialization.
1213        let yaml = "title: \"@AuraEnabled\"\nversion: 1.0.0\n";
1214        assert_eq!(convert_to_format(yaml, "yaml"), yaml);
1215    }
1216
1217    #[test]
1218    fn convert_to_format_unknown_extension_passes_through() {
1219        let raw = "arbitrary content";
1220        assert_eq!(convert_to_format(raw, "txt"), raw);
1221        assert_eq!(convert_to_format(raw, ""), raw);
1222    }
1223
1224    #[test]
1225    fn convert_to_format_malformed_falls_back_to_raw() {
1226        let bad = "{{{{ not valid json or yaml at all >>>>>";
1227        assert_eq!(convert_to_format(bad, "json"), bad);
1228    }
1229
1230    #[test]
1231    fn convert_to_format_non_string_key_falls_back_to_raw() {
1232        // YAML permits compound (sequence) mapping keys via explicit-key
1233        // syntax; JSON object keys must be strings, so parsing this into a
1234        // serde_json::Value fails and the raw content must come back unchanged.
1235        let yaml_with_sequence_key = "? [a, b]\n: value\n";
1236        assert_eq!(
1237            convert_to_format(yaml_with_sequence_key, "json"),
1238            yaml_with_sequence_key
1239        );
1240    }
1241
1242    #[tokio::test]
1243    async fn reassemble_plain_uses_caller_supplied_sidecar_specs_directly() {
1244        // Exercises the `sidecar_specs` (non-empty, caller-supplied) arm
1245        // rather than falling through to `.sidecars.json` auto-detection.
1246        let h = ReassembleXmlFileHandler::new();
1247        let tmp = tempfile::tempdir().unwrap();
1248        let dir = tmp.path().join("mydir");
1249        tokio::fs::create_dir(&dir).await.unwrap();
1250
1251        tokio::fs::write(
1252            dir.join("a.xml"),
1253            r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1254        )
1255        .await
1256        .unwrap();
1257        // Deliberately no .sidecars.json — the caller-supplied spec must be used as-is.
1258        tokio::fs::write(dir.join("mydir.yaml"), "key: value")
1259            .await
1260            .unwrap();
1261
1262        let specs = [SidecarSpec {
1263            element: "Notes".to_string(),
1264            extension: "yaml".to_string(),
1265            original_format: None,
1266        }];
1267        h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], Some(&specs))
1268            .await
1269            .unwrap();
1270
1271        let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1272            .await
1273            .unwrap();
1274        assert!(
1275            output.contains("key: value"),
1276            "caller-supplied sidecar spec was not used:\n{output}"
1277        );
1278    }
1279
1280    #[tokio::test]
1281    async fn reassemble_plain_ignores_malformed_sidecars_json() {
1282        // Exercises the `Err(_)` arm of parsing `.sidecars.json`: malformed
1283        // content must not fail reassembly, just skip sidecar injection.
1284        let h = ReassembleXmlFileHandler::new();
1285        let tmp = tempfile::tempdir().unwrap();
1286        let dir = tmp.path().join("mydir");
1287        tokio::fs::create_dir(&dir).await.unwrap();
1288
1289        tokio::fs::write(
1290            dir.join("a.xml"),
1291            r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1292        )
1293        .await
1294        .unwrap();
1295        tokio::fs::write(dir.join(".sidecars.json"), "not valid json")
1296            .await
1297            .unwrap();
1298
1299        h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], None)
1300            .await
1301            .unwrap();
1302
1303        let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1304            .await
1305            .unwrap();
1306        assert!(output.contains("Child"), "reassembly must still succeed");
1307    }
1308
1309    #[tokio::test]
1310    async fn reassemble_plain_post_purge_removes_sidecar_files() {
1311        // Exercises the post_purge branch that removes sidecar files (and
1312        // the disassembled directory) once their content has been injected.
1313        let h = ReassembleXmlFileHandler::new();
1314        let tmp = tempfile::tempdir().unwrap();
1315        let dir = tmp.path().join("mydir");
1316        tokio::fs::create_dir(&dir).await.unwrap();
1317
1318        tokio::fs::write(
1319            dir.join("a.xml"),
1320            r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1321        )
1322        .await
1323        .unwrap();
1324        let sidecar_path = dir.join("mydir.yaml");
1325        tokio::fs::write(&sidecar_path, "key: value").await.unwrap();
1326
1327        let specs = [SidecarSpec {
1328            element: "Notes".to_string(),
1329            extension: "yaml".to_string(),
1330            original_format: None,
1331        }];
1332        h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), true, &[], Some(&specs))
1333            .await
1334            .unwrap();
1335
1336        assert!(!dir.exists(), "post_purge must remove the disassembled dir");
1337        let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1338            .await
1339            .unwrap();
1340        assert!(output.contains("key: value"));
1341    }
1342
1343    #[tokio::test]
1344    async fn inject_sidecar_elements_ok_when_no_root_key() {
1345        // A merged value with no usable root key (only "?xml") must return
1346        // Ok(()) without attempting injection.
1347        let mut merged: XmlElement = json!({ "?xml": { "@version": "1.0" } });
1348        let specs = [SidecarSpec {
1349            element: "Notes".to_string(),
1350            extension: "yaml".to_string(),
1351            original_format: None,
1352        }];
1353        inject_sidecar_elements("does-not-matter", &mut merged, &specs)
1354            .await
1355            .unwrap();
1356        assert_eq!(merged, json!({ "?xml": { "@version": "1.0" } }));
1357    }
1358
1359    #[tokio::test]
1360    async fn inject_sidecar_elements_skips_missing_sidecar_file() {
1361        // When the sidecar file referenced by the spec doesn't exist on disk,
1362        // the spec must be silently skipped rather than erroring.
1363        let tmp = tempfile::tempdir().unwrap();
1364        let dir = tmp.path().join("mydir");
1365        tokio::fs::create_dir(&dir).await.unwrap();
1366        let mut merged: XmlElement = json!({ "Root": { "Child": { "#text": "hello" } } });
1367        let specs = [SidecarSpec {
1368            element: "Notes".to_string(),
1369            extension: "yaml".to_string(),
1370            original_format: None,
1371        }];
1372        inject_sidecar_elements(dir.to_str().unwrap(), &mut merged, &specs)
1373            .await
1374            .unwrap();
1375        assert_eq!(merged, json!({ "Root": { "Child": { "#text": "hello" } } }));
1376    }
1377}