Skip to main content

config_disassembler/xml/handlers/
reassemble.rs

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