Skip to main content

config_disassembler/xml/handlers/
reassemble.rs

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