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, reorder_root_keys};
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        let Some(mut merged) = merge_xml_elements(&parsed_objects) else {
356            log::error!(
357                "No usable root element found while merging files under {}. A reassembled XML file was not created.",
358                file_path
359            );
360            return Ok(());
361        };
362
363        // Resolve sidecar specs: use the caller-supplied slice when non-empty,
364        // otherwise auto-detect from .sidecars.json written by disassembly.
365        let auto_specs: Vec<crate::xml::types::SidecarSpec>;
366        let effective_specs: Option<&[crate::xml::types::SidecarSpec]> =
367            if sidecar_specs.is_some_and(|s| !s.is_empty()) {
368                sidecar_specs
369            } else {
370                let meta_path = Path::new(&file_path).join(".sidecars.json");
371                if let Ok(content) = fs::read_to_string(&meta_path).await {
372                    if let Ok(parsed) =
373                        serde_json::from_str::<Vec<crate::xml::types::SidecarSpec>>(&content)
374                    {
375                        auto_specs = parsed;
376                        Some(auto_specs.as_slice())
377                    } else {
378                        None
379                    }
380                } else {
381                    None
382                }
383            };
384
385        // Inject sidecar element content before key reordering so the element
386        // lands at its original position rather than being appended at the end.
387        if let Some(specs) = effective_specs {
388            inject_sidecar_elements(&file_path, &mut merged, specs).await?;
389        }
390
391        // Apply stored key order so reassembled XML matches original document order.
392        let key_order_path = Path::new(&file_path).join(".key_order.json");
393        if let Some(reordered) = read_key_order(&key_order_path)
394            .await
395            .and_then(|order| reorder_root_keys(&merged, &order))
396        {
397            merged = reordered;
398        }
399
400        let mut final_xml = build_xml_string(&merged);
401
402        // Reproduce the original file's trailing newline (or lack of one) instead of
403        // always trimming it away -- see `.trailing_newline.json` in build_disassembled_files.rs.
404        let trailing_newline_path = Path::new(&file_path).join(".trailing_newline.json");
405        if read_trailing_newline(&trailing_newline_path)
406            .await
407            .unwrap_or(false)
408        {
409            final_xml.push('\n');
410        }
411
412        let output_path = self.get_output_path(&file_path, file_extension);
413
414        fs::write(&output_path, &final_xml).await?;
415
416        // Remove sidecar files and metadata after successful injection. They are
417        // decomposition artifacts: the content is now inside the reassembled XML.
418        // Only done when post_purge is requested.
419        if post_purge {
420            if let Some(specs) = effective_specs {
421                let path = Path::new(&file_path);
422                let base = path
423                    .file_name()
424                    .and_then(|n| n.to_str())
425                    .unwrap_or("output");
426                for spec in specs {
427                    let sidecar = path.join(format!("{}.{}", base, spec.extension));
428                    fs::remove_file(&sidecar).await.ok();
429                }
430            }
431            fs::remove_dir_all(file_path).await.ok();
432        }
433
434        Ok(())
435    }
436
437    fn process_files_in_directory<'a>(
438        &'a self,
439        dir_path: String,
440        base_segments: Vec<(String, String, bool)>,
441    ) -> ProcessDirFuture<'a> {
442        Box::pin(async move {
443            let mut parsed = Vec::new();
444            let mut entries = Vec::new();
445            let mut read_dir = fs::read_dir(&dir_path).await?;
446            while let Some(entry) = read_dir.next_entry().await? {
447                entries.push(entry);
448            }
449            // Sort by full filename for deterministic cross-platform ordering
450            entries.sort_by(|a, b| {
451                let a_name = a.file_name().to_string_lossy().to_string();
452                let b_name = b.file_name().to_string_lossy().to_string();
453                a_name.cmp(&b_name)
454            });
455
456            // We are at the disassembly root for a given rule when our dir_path matches
457            // the base_path stored on that rule. Each rule shares the same base_path in
458            // the current implementation, but tracking them per-entry keeps the door open
459            // for future per-rule base_paths without another signature change.
460            let is_base = is_at_base_path(&dir_path, &base_segments);
461
462            // Pass 1: classify entries in sorted order. Shard files are queued for the
463            // concurrent parse below; each slot remembers its position so `parsed` ends
464            // up in exactly the order the old one-file-at-a-time loop produced.
465            enum Slot {
466                Shard(usize),
467                Dir(PathBuf, String),
468            }
469            let mut slots = Vec::with_capacity(entries.len());
470            let mut shard_files = Vec::new();
471            for entry in entries {
472                let path = entry.path();
473                let file_path = normalize_path_unix(&path.to_string_lossy()).to_string();
474                if is_regular_file(&entry, &path).await {
475                    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
476                    if !name.starts_with('.') && self.is_parsable_file(name) {
477                        slots.push(Slot::Shard(shard_files.len()));
478                        shard_files.push(file_path);
479                    }
480                } else {
481                    slots.push(Slot::Dir(path, file_path));
482                }
483            }
484
485            // Pass 2: read + parse every shard in this directory concurrently. This was a
486            // sequential `.await` per file, which left one large component (e.g. a
487            // permission set with thousands of shards) on a single core for its whole
488            // reassemble.
489            let mut shard_results = parse_shards_in_order(shard_files).await?;
490
491            // Pass 3: assemble in the original order; subdirectories recurse as before.
492            for slot in slots {
493                let (path, file_path) = match slot {
494                    Slot::Shard(index) => {
495                        if let Some(parsed_obj) = shard_results[index].take() {
496                            parsed.push(parsed_obj);
497                        }
498                        continue;
499                    }
500                    Slot::Dir(path, file_path) => (path, file_path),
501                };
502                // Anything not a regular file is treated as a directory; symlinks and
503                // other exotic entries simply recurse via read_dir below.
504                let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
505                let matched_segment = if is_base {
506                    base_segments
507                        .iter()
508                        .find(|(_, seg_name, _)| seg_name == dir_name)
509                        .cloned()
510                } else {
511                    None
512                };
513                if let Some((_, segment_name, extract_inner)) = matched_segment {
514                    let segment_element = self
515                        .collect_segment_as_array(&file_path, &segment_name, extract_inner)
516                        .await?;
517                    if let Some(el) = segment_element {
518                        parsed.push(el);
519                    }
520                } else {
521                    let sub_parsed = self
522                        .process_files_in_directory(file_path, base_segments.clone())
523                        .await?;
524                    parsed.extend(sub_parsed);
525                }
526            }
527
528            Ok(parsed)
529        })
530    }
531
532    /// Collect all .xml files in a directory, parse each, and build one element with
533    /// root_key and single key segment_name whose value is array of each file's content.
534    /// When extract_inner is true, each file has root > segment_name > content; we push that content.
535    async fn collect_segment_as_array(
536        &self,
537        segment_dir: &str,
538        segment_name: &str,
539        extract_inner: bool,
540    ) -> Result<Option<XmlElement>, Box<dyn std::error::Error + Send + Sync>> {
541        let mut xml_files = Vec::new();
542        let mut read_dir = fs::read_dir(segment_dir).await?;
543        while let Some(entry) = read_dir.next_entry().await? {
544            let path = entry.path();
545            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
546            if !name.starts_with('.')
547                && self.is_parsable_file(name)
548                && is_regular_file(&entry, &path).await
549            {
550                xml_files.push(normalize_path_unix(&path.to_string_lossy()).to_string());
551            }
552        }
553        xml_files.sort();
554
555        let mut root_contents = Vec::new();
556        let mut first_xml: Option<(String, Option<Value>)> = None;
557        // Parsed concurrently, consumed in sorted order (see parse_shards_in_order).
558        for parsed in parse_shards_in_order(xml_files).await? {
559            // parse_to_xml_object always yields a JSON object on success; treat any other
560            // shape (including parse failure) as a skip without branching explicitly.
561            let Some(parsed) = parsed else {
562                continue;
563            };
564            let obj_owned = parsed.as_object().cloned().unwrap_or_default();
565            let obj = &obj_owned;
566            let Some(root_key) = obj.keys().find(|k| *k != "?xml").cloned() else {
567                continue;
568            };
569            let root_val = obj
570                .get(&root_key)
571                .cloned()
572                .unwrap_or(Value::Object(serde_json::Map::new()));
573            let mut content = if extract_inner {
574                root_val
575                    .get(segment_name)
576                    .cloned()
577                    .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
578            } else {
579                root_val
580            };
581            // Inner segment element (e.g. programProcesses) should not have xmlns in output
582            if extract_inner {
583                content = strip_xmlns_from_value(content);
584            }
585            root_contents.push(content);
586            if first_xml.is_none() {
587                first_xml = Some((root_key, obj.get("?xml").cloned()));
588            }
589        }
590        if root_contents.is_empty() {
591            return Ok(None);
592        }
593        let (root_key, decl_opt) = first_xml.unwrap();
594        let mut content = serde_json::Map::new();
595        content.insert(segment_name.to_string(), Value::Array(root_contents));
596        let mut top = serde_json::Map::new();
597        if let Some(decl) = decl_opt {
598            top.insert("?xml".to_string(), decl);
599        } else {
600            let mut d = serde_json::Map::new();
601            d.insert("@version".to_string(), Value::String("1.0".to_string()));
602            d.insert("@encoding".to_string(), Value::String("UTF-8".to_string()));
603            top.insert("?xml".to_string(), Value::Object(d));
604        }
605        top.insert(root_key, Value::Object(content));
606        Ok(Some(Value::Object(top)))
607    }
608
609    fn is_parsable_file(&self, file_name: &str) -> bool {
610        let lower = file_name.to_lowercase();
611        lower.ends_with(".xml")
612            || lower.ends_with(".json")
613            || lower.ends_with(".json5")
614            || lower.ends_with(".yaml")
615            || lower.ends_with(".yml")
616    }
617
618    async fn validate_directory(
619        &self,
620        path: &str,
621    ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
622        let meta = fs::metadata(path).await?;
623        if !meta.is_dir() {
624            log::error!(
625                "The provided path to reassemble is not a directory: {}",
626                path
627            );
628            return Ok(false);
629        }
630        Ok(true)
631    }
632
633    fn get_output_path(&self, dir_path: &str, extension: Option<&str>) -> String {
634        let path = Path::new(dir_path);
635        let parent = path.parent().unwrap_or(Path::new("."));
636        let base_name = path
637            .file_name()
638            .and_then(|n| n.to_str())
639            .unwrap_or("output");
640        let ext = extension.unwrap_or("xml");
641        parent
642            .join(format!("{}.{}", base_name, ext))
643            .to_string_lossy()
644            .to_string()
645    }
646}
647
648impl Default for ReassembleXmlFileHandler {
649    fn default() -> Self {
650        Self::new()
651    }
652}
653
654/// Convert `content` (in whatever format the sidecar file uses) to `target_format`.
655///
656/// - `"json"` → parse as YAML (superset of JSON) then emit as pretty JSON
657/// - `"yaml"` / `"yml"` → only convert when content is strict JSON; YAML passes through
658/// - anything else → pass through unchanged
659///
660/// Falls back to raw content with a warning when conversion fails.
661fn convert_to_format(content: &str, target_format: &str) -> String {
662    match target_format.to_ascii_lowercase().as_str() {
663        "json" => {
664            match serde_saphyr::from_str::<serde_json::Value>(content) {
665                // Serializing a serde_json::Value cannot fail.
666                Ok(val) => {
667                    serde_json::to_string_pretty(&val).unwrap_or_else(|_| content.to_string())
668                }
669                Err(e) => {
670                    log::warn!("sidecar reassemble: could not parse content for JSON conversion ({e}); using raw content");
671                    content.to_string()
672                }
673            }
674        }
675        "yaml" | "yml" => {
676            if serde_json::from_str::<serde_json::Value>(content).is_ok() {
677                match serde_json::from_str::<serde_json::Value>(content)
678                    .ok()
679                    .and_then(|v| serde_saphyr::to_string(&v).ok())
680                {
681                    Some(yaml) => yaml,
682                    None => {
683                        log::warn!(
684                            "sidecar reassemble: YAML serialization failed; using raw content"
685                        );
686                        content.to_string()
687                    }
688                }
689            } else {
690                content.to_string()
691            }
692        }
693        _ => content.to_string(),
694    }
695}
696
697/// Read sidecar files and inject their content back into the merged XML value
698/// before serialisation.
699///
700/// Each sidecar is located inside the decomposed directory itself
701/// (e.g. `MySvc/MySvc.yaml`). If a sidecar is absent (element was not in
702/// the original XML, or was never extracted), the spec is silently skipped.
703///
704/// The injected value uses the `{"#raw-text": content}` shape that `build_xml_string`
705/// writes with `partial_escape`: mandatory XML chars (`<`, `>`, `&`) are escaped
706/// but `"` is left as-is so YAML content round-trips without `&quot;` inflation.
707async fn inject_sidecar_elements(
708    dir_path: &str,
709    merged: &mut XmlElement,
710    specs: &[SidecarSpec],
711) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
712    let path = Path::new(dir_path);
713    let base = path
714        .file_name()
715        .and_then(|n| n.to_str())
716        .unwrap_or("output");
717
718    let root_key = merged
719        .as_object()
720        .and_then(|o| o.keys().find(|k| *k != "?xml").cloned());
721    let Some(root_key) = root_key else {
722        return Ok(());
723    };
724
725    if let Some(root_val) = merged.as_object_mut().and_then(|o| o.get_mut(&root_key)) {
726        if let Some(root_obj) = root_val.as_object_mut() {
727            for spec in specs {
728                let sidecar_path = path.join(format!("{}.{}", base, spec.extension));
729                let Ok(content) = fs::read_to_string(&sidecar_path).await else {
730                    continue;
731                };
732                // When the original XML embedded a different format than the sidecar
733                // extension (e.g. JSON schema extracted to a .yaml sidecar), convert
734                // the sidecar content back to the original format before injecting.
735                let final_content = match &spec.original_format {
736                    Some(fmt) => convert_to_format(&content, fmt),
737                    None => content,
738                };
739                root_obj.insert(
740                    spec.element.clone(),
741                    serde_json::json!({ "#raw-text": final_content }),
742                );
743            }
744        }
745    }
746
747    Ok(())
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use serde_json::json;
754
755    #[test]
756    #[allow(clippy::default_constructed_unit_structs)]
757    fn reassemble_handler_default_equals_new() {
758        let _ = ReassembleXmlFileHandler::default();
759    }
760
761    #[test]
762    fn strip_xmlns_from_value_passes_non_object_through() {
763        let s = Value::String("hello".to_string());
764        assert_eq!(
765            strip_xmlns_from_value(s),
766            Value::String("hello".to_string())
767        );
768        let arr = json!([1, 2]);
769        assert_eq!(strip_xmlns_from_value(arr.clone()), arr);
770    }
771
772    #[test]
773    fn strip_xmlns_from_value_removes_xmlns_key() {
774        let obj = json!({ "@xmlns": "ns", "child": 1 });
775        let stripped = strip_xmlns_from_value(obj);
776        let map = stripped.as_object().unwrap();
777        assert!(map.get("@xmlns").is_none());
778        assert_eq!(map.get("child").and_then(|v| v.as_i64()), Some(1));
779    }
780
781    #[test]
782    fn is_parsable_file_recognises_supported_extensions() {
783        let h = ReassembleXmlFileHandler::new();
784        assert!(h.is_parsable_file("a.xml"));
785        assert!(h.is_parsable_file("a.json"));
786        assert!(h.is_parsable_file("a.json5"));
787        assert!(h.is_parsable_file("a.yaml"));
788        assert!(h.is_parsable_file("a.yml"));
789        assert!(h.is_parsable_file("A.XML"));
790        assert!(!h.is_parsable_file("a.txt"));
791    }
792
793    #[test]
794    fn get_output_path_appends_extension_and_uses_parent_dir() {
795        let h = ReassembleXmlFileHandler::new();
796        let out = h.get_output_path("/tmp/foo", Some("xml"));
797        assert!(out.ends_with("foo.xml"));
798        let out_default = h.get_output_path("/tmp/bar", None);
799        assert!(out_default.ends_with("bar.xml"));
800        // No parent - uses "." fallback
801        assert_eq!(h.get_output_path("only", Some("json")), "only.json");
802    }
803
804    #[tokio::test]
805    async fn reassemble_multi_level_segment_noop_when_not_dir() {
806        let h = ReassembleXmlFileHandler::new();
807        let tmp = tempfile::tempdir().unwrap();
808        let file = tmp.path().join("not_a_dir.txt");
809        tokio::fs::write(&file, "hi").await.unwrap();
810        let rule = crate::xml::types::MultiLevelRule {
811            file_pattern: String::new(),
812            root_to_strip: String::new(),
813            unique_id_elements: String::new(),
814            path_segment: String::new(),
815            wrap_root_element: "Root".to_string(),
816            wrap_xmlns: String::new(),
817        };
818        h.reassemble_multi_level_segment(&file, &rule, &[])
819            .await
820            .unwrap();
821    }
822
823    #[tokio::test]
824    async fn reassemble_multi_level_segment_skips_files_in_segment_root() {
825        let h = ReassembleXmlFileHandler::new();
826        let tmp = tempfile::tempdir().unwrap();
827        let segment = tmp.path().join("segment");
828        tokio::fs::create_dir(&segment).await.unwrap();
829        // A bare file inside the segment dir should be skipped (not a subdir).
830        tokio::fs::write(segment.join("stray.txt"), "x")
831            .await
832            .unwrap();
833        let rule = crate::xml::types::MultiLevelRule {
834            file_pattern: String::new(),
835            root_to_strip: String::new(),
836            unique_id_elements: String::new(),
837            path_segment: "segment".to_string(),
838            wrap_root_element: "Root".to_string(),
839            wrap_xmlns: "http://example.com".to_string(),
840        };
841        h.reassemble_multi_level_segment(&segment, &rule, &[])
842            .await
843            .unwrap();
844    }
845
846    #[tokio::test]
847    async fn collect_segment_as_array_returns_none_for_empty_dir() {
848        let h = ReassembleXmlFileHandler::new();
849        let tmp = tempfile::tempdir().unwrap();
850        let out = h
851            .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
852            .await
853            .unwrap();
854        assert!(out.is_none());
855    }
856
857    #[tokio::test]
858    async fn collect_segment_as_array_skips_unparseable_and_empty_roots() {
859        let h = ReassembleXmlFileHandler::new();
860        let tmp = tempfile::tempdir().unwrap();
861        // Unparseable XML
862        tokio::fs::write(tmp.path().join("bad.xml"), "<<")
863            .await
864            .unwrap();
865        // Valid XML but only declaration and no root after parse
866        tokio::fs::write(tmp.path().join("only-decl.xml"), "")
867            .await
868            .unwrap();
869        // Hidden file is skipped
870        tokio::fs::write(tmp.path().join(".hidden.xml"), "<r/>")
871            .await
872            .unwrap();
873        let out = h
874            .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", false)
875            .await
876            .unwrap();
877        assert!(out.is_none());
878    }
879
880    #[tokio::test]
881    async fn collect_segment_as_array_without_xml_decl_inserts_default_decl() {
882        // XML files without a `<?xml ?>` declaration: the `else` branch in
883        // collect_segment_as_array must synthesize a default declaration with
884        // version="1.0" and encoding="UTF-8".
885        let h = ReassembleXmlFileHandler::new();
886        let tmp = tempfile::tempdir().unwrap();
887        tokio::fs::write(
888            tmp.path().join("a.xml"),
889            r#"<Root><seg><x>1</x></seg></Root>"#,
890        )
891        .await
892        .unwrap();
893        let out = h
894            .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
895            .await
896            .unwrap()
897            .unwrap();
898        let obj = out.as_object().unwrap();
899        let decl = obj
900            .get("?xml")
901            .and_then(|v| v.as_object())
902            .expect("default declaration must be inserted when XML has none");
903        assert_eq!(decl.get("@version").and_then(|v| v.as_str()), Some("1.0"));
904        assert_eq!(
905            decl.get("@encoding").and_then(|v| v.as_str()),
906            Some("UTF-8")
907        );
908    }
909
910    #[tokio::test]
911    async fn collect_segment_as_array_with_xml_decl_preserves_it() {
912        // XML file WITH a `<?xml ?>` declaration: `decl_opt` is Some → line 438
913        // `top.insert("?xml", decl)` is executed (the `if let Some(decl)` branch).
914        let h = ReassembleXmlFileHandler::new();
915        let tmp = tempfile::tempdir().unwrap();
916        tokio::fs::write(
917            tmp.path().join("a.xml"),
918            r#"<?xml version="1.0" encoding="UTF-8"?><Root><seg><x>1</x></seg></Root>"#,
919        )
920        .await
921        .unwrap();
922        let out = h
923            .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", true)
924            .await
925            .unwrap()
926            .unwrap();
927        let obj = out.as_object().unwrap();
928        let decl = obj
929            .get("?xml")
930            .and_then(|v| v.as_object())
931            .expect("?xml declaration must be preserved from source");
932        assert_eq!(decl.get("@version").and_then(|v| v.as_str()), Some("1.0"));
933        assert_eq!(
934            decl.get("@encoding").and_then(|v| v.as_str()),
935            Some("UTF-8")
936        );
937    }
938
939    #[tokio::test]
940    async fn collect_segment_as_array_without_extract_inner_wraps_root() {
941        let h = ReassembleXmlFileHandler::new();
942        let tmp = tempfile::tempdir().unwrap();
943        tokio::fs::write(tmp.path().join("a.xml"), r#"<Root><child>1</child></Root>"#)
944            .await
945            .unwrap();
946        let out = h
947            .collect_segment_as_array(tmp.path().to_str().unwrap(), "seg", false)
948            .await
949            .unwrap()
950            .unwrap();
951        let obj = out.as_object().unwrap();
952        assert!(obj.contains_key("?xml"));
953        let root = obj.get("Root").and_then(|r| r.as_object()).unwrap();
954        assert!(root.get("seg").and_then(|v| v.as_array()).is_some());
955    }
956
957    fn rule_with_segment(segment: &str) -> MultiLevelRule {
958        MultiLevelRule {
959            file_pattern: String::new(),
960            root_to_strip: String::new(),
961            unique_id_elements: String::new(),
962            path_segment: segment.to_string(),
963            wrap_root_element: String::new(),
964            wrap_xmlns: String::new(),
965        }
966    }
967
968    #[test]
969    fn deeper_candidate_rules_excludes_the_matched_segment() {
970        // The matched rule must be filtered out, otherwise the
971        // recursion would re-enter that rule when a child directory
972        // happens to share its `path_segment`.
973        let rules = vec![rule_with_segment("seg_a"), rule_with_segment("seg_b")];
974        let deeper = deeper_candidate_rules(&rules, "seg_a");
975        assert_eq!(deeper.len(), 1);
976        assert_eq!(deeper[0].path_segment, "seg_b");
977    }
978
979    #[test]
980    fn deeper_candidate_rules_keeps_all_when_no_segment_matches() {
981        // When `exclude_path_segment` doesn't correspond to any rule
982        // the input is forwarded unchanged. Pins the `!= -> ==` mutant
983        // which would otherwise return an empty vec here.
984        let rules = vec![rule_with_segment("seg_a"), rule_with_segment("seg_b")];
985        let deeper = deeper_candidate_rules(&rules, "missing");
986        assert_eq!(deeper.len(), 2);
987    }
988
989    #[test]
990    fn deeper_candidate_rules_returns_empty_for_empty_input() {
991        let deeper: Vec<MultiLevelRule> = deeper_candidate_rules(&[], "anything");
992        assert!(deeper.is_empty());
993    }
994
995    #[test]
996    fn is_at_base_path_true_when_dir_matches_any_segment() {
997        let segs = vec![
998            ("/base/other".to_string(), "seg1".to_string(), false),
999            ("/base/here".to_string(), "seg2".to_string(), false),
1000        ];
1001        assert!(is_at_base_path("/base/here", &segs));
1002    }
1003
1004    #[test]
1005    fn is_at_base_path_false_when_dir_matches_nothing() {
1006        let segs = vec![("/base/a".to_string(), "seg".to_string(), false)];
1007        assert!(!is_at_base_path("/base/b", &segs));
1008    }
1009
1010    #[test]
1011    fn is_at_base_path_false_for_empty_segments() {
1012        let segs: Vec<(String, String, bool)> = Vec::new();
1013        assert!(!is_at_base_path("/anywhere", &segs));
1014    }
1015
1016    #[tokio::test]
1017    async fn parse_shards_in_order_keeps_input_order_and_marks_failures() {
1018        let tmp = tempfile::tempdir().unwrap();
1019        let mut files = Vec::new();
1020        for i in 0..100 {
1021            // Every 10th entry points at a file that doesn't exist -> None, in place.
1022            let path = tmp.path().join(format!("{i:03}.xml"));
1023            if i % 10 != 0 {
1024                tokio::fs::write(&path, format!("<Root><n>{i}</n></Root>"))
1025                    .await
1026                    .unwrap();
1027            }
1028            files.push(path.to_string_lossy().to_string());
1029        }
1030
1031        let results = parse_shards_in_order(files).await.unwrap();
1032
1033        assert_eq!(results.len(), 100);
1034        for (i, result) in results.iter().enumerate() {
1035            if i % 10 == 0 {
1036                assert!(result.is_none(), "missing shard {i} should be None");
1037            } else {
1038                let n = result.as_ref().unwrap()["Root"]["n"]["#text"]
1039                    .as_str()
1040                    .unwrap();
1041                assert_eq!(n, i.to_string(), "shard {i} out of order");
1042            }
1043        }
1044    }
1045
1046    // `is_regular_file` must follow symlinks like the `Path::is_file` it replaced:
1047    // a symlinked shard is parsed as a shard (not recursed into as a directory,
1048    // which would fail), and a symlinked directory is still recursed into.
1049    // Unix-only: creating symlinks on Windows needs elevated privileges.
1050    #[cfg(unix)]
1051    #[tokio::test]
1052    async fn reassemble_follows_symlinked_shards_and_directories() {
1053        use std::os::unix::fs::symlink;
1054
1055        let tmp = tempfile::tempdir().unwrap();
1056        let outside = tmp.path().join("outside");
1057        tokio::fs::create_dir_all(outside.join("nested"))
1058            .await
1059            .unwrap();
1060        tokio::fs::write(outside.join("b.xml"), "<Root><n>b</n></Root>")
1061            .await
1062            .unwrap();
1063        tokio::fs::write(
1064            outside.join("nested").join("c.xml"),
1065            "<Root><n>c</n></Root>",
1066        )
1067        .await
1068        .unwrap();
1069
1070        let dir = tmp.path().join("Bench");
1071        tokio::fs::create_dir(&dir).await.unwrap();
1072        tokio::fs::write(dir.join("a.xml"), "<Root><n>a</n></Root>")
1073            .await
1074            .unwrap();
1075        symlink(outside.join("b.xml"), dir.join("b.xml")).unwrap();
1076        symlink(outside.join("nested"), dir.join("c_dir")).unwrap();
1077
1078        ReassembleXmlFileHandler::new()
1079            .reassemble(dir.to_str().unwrap(), Some("xml"), false, None)
1080            .await
1081            .unwrap();
1082
1083        let output = tokio::fs::read_to_string(tmp.path().join("Bench.xml"))
1084            .await
1085            .unwrap();
1086        let a = output.find("<n>a</n>").expect("regular shard a");
1087        let b = output.find("<n>b</n>").expect("symlinked shard b");
1088        let c = output
1089            .find("<n>c</n>")
1090            .expect("shard c under symlinked dir");
1091        assert!(a < b && b < c, "sorted order a, b, c_dir/: {output}");
1092    }
1093
1094    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1095    async fn reassemble_many_shards_preserves_sorted_order() {
1096        // Well above SHARD_PARSE_CONCURRENCY, on a multi-thread runtime, so shards
1097        // finish parsing out of order; the merged output must still follow the
1098        // sorted file names.
1099        let tmp = tempfile::tempdir().unwrap();
1100        let dir = tmp.path().join("Bench");
1101        tokio::fs::create_dir(&dir).await.unwrap();
1102        for i in 0..200 {
1103            // Uneven shard sizes make completion order differ from input order.
1104            let padding = "x".repeat((i * 7919) % 4096);
1105            tokio::fs::write(
1106                dir.join(format!("{i:04}.field-meta.xml")),
1107                format!(
1108                    "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Root><fields><n>{i}</n><pad>{padding}</pad></fields></Root>"
1109                ),
1110            )
1111            .await
1112            .unwrap();
1113        }
1114
1115        ReassembleXmlFileHandler::new()
1116            .reassemble(dir.to_str().unwrap(), Some("xml"), false, None)
1117            .await
1118            .unwrap();
1119
1120        let output = tokio::fs::read_to_string(tmp.path().join("Bench.xml"))
1121            .await
1122            .unwrap();
1123        let order: Vec<usize> = output
1124            .match_indices("<n>")
1125            .map(|(at, _)| {
1126                let rest = &output[at + 3..];
1127                rest[..rest.find('<').unwrap()].parse().unwrap()
1128            })
1129            .collect();
1130        assert_eq!(order, (0..200).collect::<Vec<_>>());
1131    }
1132
1133    // Pins the `delete ! in is_some_and(|s| !s.is_empty())` mutant at line 299.
1134    // When sidecar_specs is Some(&[]) the caller supplied nothing useful, so the
1135    // code must fall through to auto-detect via .sidecars.json — not treat the
1136    // empty slice as authoritative.
1137    #[tokio::test]
1138    async fn reassemble_plain_some_empty_sidecar_specs_falls_through_to_auto_detect() {
1139        let h = ReassembleXmlFileHandler::new();
1140        let tmp = tempfile::tempdir().unwrap();
1141        let dir = tmp.path().join("mydir");
1142        tokio::fs::create_dir(&dir).await.unwrap();
1143
1144        tokio::fs::write(
1145            dir.join("a.xml"),
1146            r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1147        )
1148        .await
1149        .unwrap();
1150
1151        tokio::fs::write(
1152            dir.join(".sidecars.json"),
1153            r#"[{"element":"Notes","extension":"yaml"}]"#,
1154        )
1155        .await
1156        .unwrap();
1157
1158        // Sidecar file name = directory name + extension (matches inject_sidecar_elements logic).
1159        tokio::fs::write(dir.join("mydir.yaml"), "key: value")
1160            .await
1161            .unwrap();
1162
1163        h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], Some(&[]))
1164            .await
1165            .unwrap();
1166
1167        let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1168            .await
1169            .unwrap();
1170        assert!(
1171            output.contains("key: value"),
1172            "sidecar content missing — auto-detect did not run:\n{output}"
1173        );
1174    }
1175
1176    // Pins `delete match arm "json"` mutant: without the arm, YAML input to
1177    // convert_to_format("json") falls through to `_ =>` and returns raw YAML.
1178    #[test]
1179    fn convert_to_format_yaml_to_json() {
1180        let yaml = "openapi: 3.0.1\ninfo:\n  title: \"Test API\"\n  version: 1.0.0\n";
1181        let out = convert_to_format(yaml, "json");
1182        let val: serde_json::Value = serde_json::from_str(&out).expect("output must be valid JSON");
1183        assert_eq!(val["openapi"], "3.0.1");
1184        assert_eq!(val["info"]["title"], "Test API");
1185        assert_eq!(val["info"]["version"], "1.0.0");
1186    }
1187
1188    // Pins `delete match arm "yaml" | "yml"` mutant: without the arm, JSON input
1189    // to convert_to_format("yaml") falls through to `_ =>` and returns raw JSON.
1190    #[test]
1191    fn convert_to_format_json_to_yaml() {
1192        let json = r#"{"key":"value","num":42}"#;
1193        let out = convert_to_format(json, "yaml");
1194        assert!(
1195            serde_json::from_str::<serde_json::Value>(&out).is_err(),
1196            "output must be YAML format, not raw JSON: {out}"
1197        );
1198        let val: serde_json::Value =
1199            serde_saphyr::from_str(&out).expect("output must be valid YAML");
1200        assert_eq!(val["key"], "value");
1201        assert_eq!(val["num"], 42);
1202    }
1203
1204    #[test]
1205    fn convert_to_format_yml_extension_same_as_yaml() {
1206        let json = r#"{"x":true}"#;
1207        let out = convert_to_format(json, "yml");
1208        let val: serde_json::Value = serde_saphyr::from_str(&out).unwrap();
1209        assert_eq!(val["x"], true);
1210    }
1211
1212    #[test]
1213    fn convert_to_format_yaml_passes_through_unchanged() {
1214        // Pure YAML fails serde_json parse → returned as-is without re-serialization.
1215        let yaml = "title: \"@AuraEnabled\"\nversion: 1.0.0\n";
1216        assert_eq!(convert_to_format(yaml, "yaml"), yaml);
1217    }
1218
1219    #[test]
1220    fn convert_to_format_unknown_extension_passes_through() {
1221        let raw = "arbitrary content";
1222        assert_eq!(convert_to_format(raw, "txt"), raw);
1223        assert_eq!(convert_to_format(raw, ""), raw);
1224    }
1225
1226    #[test]
1227    fn convert_to_format_malformed_falls_back_to_raw() {
1228        let bad = "{{{{ not valid json or yaml at all >>>>>";
1229        assert_eq!(convert_to_format(bad, "json"), bad);
1230    }
1231
1232    #[test]
1233    fn convert_to_format_non_string_key_falls_back_to_raw() {
1234        // YAML permits compound (sequence) mapping keys via explicit-key
1235        // syntax; JSON object keys must be strings, so parsing this into a
1236        // serde_json::Value fails and the raw content must come back unchanged.
1237        let yaml_with_sequence_key = "? [a, b]\n: value\n";
1238        assert_eq!(
1239            convert_to_format(yaml_with_sequence_key, "json"),
1240            yaml_with_sequence_key
1241        );
1242    }
1243
1244    #[tokio::test]
1245    async fn reassemble_plain_uses_caller_supplied_sidecar_specs_directly() {
1246        // Exercises the `sidecar_specs` (non-empty, caller-supplied) arm
1247        // rather than falling through to `.sidecars.json` auto-detection.
1248        let h = ReassembleXmlFileHandler::new();
1249        let tmp = tempfile::tempdir().unwrap();
1250        let dir = tmp.path().join("mydir");
1251        tokio::fs::create_dir(&dir).await.unwrap();
1252
1253        tokio::fs::write(
1254            dir.join("a.xml"),
1255            r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1256        )
1257        .await
1258        .unwrap();
1259        // Deliberately no .sidecars.json — the caller-supplied spec must be used as-is.
1260        tokio::fs::write(dir.join("mydir.yaml"), "key: value")
1261            .await
1262            .unwrap();
1263
1264        let specs = [SidecarSpec {
1265            element: "Notes".to_string(),
1266            extension: "yaml".to_string(),
1267            original_format: None,
1268        }];
1269        h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], Some(&specs))
1270            .await
1271            .unwrap();
1272
1273        let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1274            .await
1275            .unwrap();
1276        assert!(
1277            output.contains("key: value"),
1278            "caller-supplied sidecar spec was not used:\n{output}"
1279        );
1280    }
1281
1282    #[tokio::test]
1283    async fn reassemble_plain_ignores_malformed_sidecars_json() {
1284        // Exercises the `Err(_)` arm of parsing `.sidecars.json`: malformed
1285        // content must not fail reassembly, just skip sidecar injection.
1286        let h = ReassembleXmlFileHandler::new();
1287        let tmp = tempfile::tempdir().unwrap();
1288        let dir = tmp.path().join("mydir");
1289        tokio::fs::create_dir(&dir).await.unwrap();
1290
1291        tokio::fs::write(
1292            dir.join("a.xml"),
1293            r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1294        )
1295        .await
1296        .unwrap();
1297        tokio::fs::write(dir.join(".sidecars.json"), "not valid json")
1298            .await
1299            .unwrap();
1300
1301        h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), false, &[], None)
1302            .await
1303            .unwrap();
1304
1305        let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1306            .await
1307            .unwrap();
1308        assert!(output.contains("Child"), "reassembly must still succeed");
1309    }
1310
1311    #[tokio::test]
1312    async fn reassemble_plain_post_purge_removes_sidecar_files() {
1313        // Exercises the post_purge branch that removes sidecar files (and
1314        // the disassembled directory) once their content has been injected.
1315        let h = ReassembleXmlFileHandler::new();
1316        let tmp = tempfile::tempdir().unwrap();
1317        let dir = tmp.path().join("mydir");
1318        tokio::fs::create_dir(&dir).await.unwrap();
1319
1320        tokio::fs::write(
1321            dir.join("a.xml"),
1322            r#"<?xml version="1.0" encoding="UTF-8"?><Root><Child>hello</Child></Root>"#,
1323        )
1324        .await
1325        .unwrap();
1326        let sidecar_path = dir.join("mydir.yaml");
1327        tokio::fs::write(&sidecar_path, "key: value").await.unwrap();
1328
1329        let specs = [SidecarSpec {
1330            element: "Notes".to_string(),
1331            extension: "yaml".to_string(),
1332            original_format: None,
1333        }];
1334        h.reassemble_plain(dir.to_str().unwrap(), Some("xml"), true, &[], Some(&specs))
1335            .await
1336            .unwrap();
1337
1338        assert!(!dir.exists(), "post_purge must remove the disassembled dir");
1339        let output = tokio::fs::read_to_string(tmp.path().join("mydir.xml"))
1340            .await
1341            .unwrap();
1342        assert!(output.contains("key: value"));
1343    }
1344
1345    #[tokio::test]
1346    async fn inject_sidecar_elements_ok_when_no_root_key() {
1347        // A merged value with no usable root key (only "?xml") must return
1348        // Ok(()) without attempting injection.
1349        let mut merged: XmlElement = json!({ "?xml": { "@version": "1.0" } });
1350        let specs = [SidecarSpec {
1351            element: "Notes".to_string(),
1352            extension: "yaml".to_string(),
1353            original_format: None,
1354        }];
1355        inject_sidecar_elements("does-not-matter", &mut merged, &specs)
1356            .await
1357            .unwrap();
1358        assert_eq!(merged, json!({ "?xml": { "@version": "1.0" } }));
1359    }
1360
1361    #[tokio::test]
1362    async fn inject_sidecar_elements_skips_missing_sidecar_file() {
1363        // When the sidecar file referenced by the spec doesn't exist on disk,
1364        // the spec must be silently skipped rather than erroring.
1365        let tmp = tempfile::tempdir().unwrap();
1366        let dir = tmp.path().join("mydir");
1367        tokio::fs::create_dir(&dir).await.unwrap();
1368        let mut merged: XmlElement = json!({ "Root": { "Child": { "#text": "hello" } } });
1369        let specs = [SidecarSpec {
1370            element: "Notes".to_string(),
1371            extension: "yaml".to_string(),
1372            original_format: None,
1373        }];
1374        inject_sidecar_elements(dir.to_str().unwrap(), &mut merged, &specs)
1375            .await
1376            .unwrap();
1377        assert_eq!(merged, json!({ "Root": { "Child": { "#text": "hello" } } }));
1378    }
1379}