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