Skip to main content

config_disassembler/xml/
multi_level.rs

1//! Multi-level disassembly: strip a root element and re-disassemble with different unique-id elements.
2
3use serde_json::{Map, Value};
4
5use crate::xml::builders::build_xml_string;
6use crate::xml::types::{MultiLevelConfig, XmlElement};
7
8/// Strip the given element and build a new XML string.
9/// - If it is the root element: its inner content becomes the new document (with ?xml preserved).
10/// - If it is a child of the root (e.g. programProcesses under LoyaltyProgramSetup): unwrap it so
11///   its inner content becomes the direct children of the root; the root element is kept.
12pub fn strip_root_and_build_xml(parsed: &XmlElement, element_to_strip: &str) -> Option<String> {
13    let obj = parsed.as_object()?;
14    let root_key = obj.keys().find(|k| *k != "?xml")?.clone();
15    let root_val = obj.get(&root_key)?.as_object()?;
16    let decl = obj.get("?xml").cloned().unwrap_or_else(|| {
17        let mut d = Map::new();
18        d.insert("@version".to_string(), Value::String("1.0".to_string()));
19        d.insert("@encoding".to_string(), Value::String("UTF-8".to_string()));
20        Value::Object(d)
21    });
22
23    if root_key == element_to_strip {
24        // Strip the root: new doc = ?xml + inner content of root (element keys only, not @attributes)
25        let mut new_obj = Map::new();
26        new_obj.insert("?xml".to_string(), decl);
27        for (k, v) in root_val {
28            if !k.starts_with('@') {
29                new_obj.insert(k.clone(), v.clone());
30            }
31        }
32        return Some(build_xml_string(&Value::Object(new_obj)));
33    }
34
35    // Strip a child of the root: unwrap it so its inner content becomes direct children of the root
36    let inner = root_val.get(element_to_strip)?.as_object()?;
37    let mut new_root_val = Map::new();
38    for (k, v) in root_val {
39        if k != element_to_strip {
40            new_root_val.insert(k.clone(), v.clone());
41        }
42    }
43    for (k, v) in inner {
44        new_root_val.insert(k.clone(), v.clone());
45    }
46    let mut new_obj = Map::new();
47    new_obj.insert("?xml".to_string(), decl);
48    new_obj.insert(root_key, Value::Object(new_root_val));
49    Some(build_xml_string(&Value::Object(new_obj)))
50}
51
52/// Capture xmlns from the root element (e.g. LoyaltyProgramSetup) for later wrap.
53pub fn capture_xmlns_from_root(parsed: &XmlElement) -> Option<String> {
54    let obj = parsed.as_object()?;
55    let root_key = obj.keys().find(|k| *k != "?xml")?.clone();
56    let root_val = obj.get(&root_key)?.as_object()?;
57    let xmlns = root_val.get("@xmlns")?.as_str()?;
58    Some(xmlns.to_string())
59}
60
61/// Derive path_segment from file_pattern (e.g. "programProcesses-meta" -> "programProcesses").
62pub fn path_segment_from_file_pattern(file_pattern: &str) -> String {
63    // `split('-').next()` always returns `Some(_)` for any string - even an empty one -
64    // so falling back to the original `file_pattern` is unreachable.
65    file_pattern
66        .split('-')
67        .next()
68        .unwrap_or(file_pattern)
69        .to_string()
70}
71
72/// Load multi-level config from a directory (reads .multi_level.json).
73pub async fn load_multi_level_config(dir_path: &std::path::Path) -> Option<MultiLevelConfig> {
74    let path = dir_path.join(".multi_level.json");
75    let content = tokio::fs::read_to_string(&path).await.ok()?;
76    serde_json::from_str(&content).ok()
77}
78
79/// Persist multi-level config to a directory.
80pub async fn save_multi_level_config(
81    dir_path: &std::path::Path,
82    config: &MultiLevelConfig,
83) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
84    let path = dir_path.join(".multi_level.json");
85    let content = serde_json::to_string_pretty(config)?;
86    tokio::fs::write(path, content).await?;
87    Ok(())
88}
89
90/// True when the root element's only non-attribute child has the
91/// inner-wrapper name we're looking for. Pure helper extracted from
92/// `ensure_segment_files_structure` so the
93/// `non_attr_keys.len() == 1 && non_attr_keys[0] == inner_wrapper`
94/// conjunction can be exercised in isolation.
95fn has_single_inner_wrapper(
96    root_val: &serde_json::Map<String, serde_json::Value>,
97    inner_wrapper: &str,
98) -> bool {
99    // Exclude `@xmlns` (an attribute, not a child element) and any internal `#`-prefixed
100    // marker (`#compact`, `#text`, etc.) -- neither counts as a real child element, so a
101    // thin `<root xmlns="..."><inner>...</inner></root>` wrapper whose `inner` happens to
102    // be parsed with such a marker attached must still be recognised as single-child.
103    let non_attr_keys: Vec<&String> = root_val
104        .keys()
105        .filter(|k| *k != "@xmlns" && !k.starts_with('#'))
106        .collect();
107    non_attr_keys.len() == 1 && non_attr_keys[0].as_str() == inner_wrapper
108}
109
110/// True when an already-disassembled segment file is shaped as
111/// `<document_root>…<inner_wrapper>X</inner_wrapper></document_root>`
112/// and we should unwrap the inner content (`X`) before re-wrapping
113/// with a fresh xmlns. The else branch in
114/// `ensure_segment_files_structure` keeps the existing root_val
115/// intact, which produces the *double-wrapped* output
116/// `<document_root>…<inner_wrapper><inner_wrapper>X</inner_wrapper>…</inner_wrapper></document_root>`
117/// — never what we want for a "thin" wrapper file.
118fn should_unwrap_inner_segment(
119    current_root_key: &str,
120    document_root: &str,
121    single_inner: bool,
122) -> bool {
123    current_root_key == document_root && single_inner
124}
125
126/// Ensure all XML files in a segment directory have structure:
127/// document_root (with xmlns) > inner_wrapper (no xmlns) > content.
128/// Used after inner-level reassembly for multi-level (e.g. LoyaltyProgramSetup > programProcesses).
129pub async fn ensure_segment_files_structure(
130    dir_path: &std::path::Path,
131    document_root: &str,
132    inner_wrapper: &str,
133    xmlns: &str,
134) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
135    use crate::xml::parsers::parse_xml_from_str;
136    use serde_json::Map;
137
138    let mut entries = Vec::new();
139    let mut read_dir = tokio::fs::read_dir(dir_path).await?;
140    while let Some(entry) = read_dir.next_entry().await? {
141        entries.push(entry);
142    }
143    // Sort for deterministic cross-platform ordering
144    entries.sort_by_key(|e| e.file_name());
145
146    for entry in entries {
147        let path = entry.path();
148        if !path.is_file() {
149            continue;
150        }
151        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
152        if !name.ends_with(".xml") {
153            continue;
154        }
155        let path_str = path.to_string_lossy();
156        // Read errors on a file the walker just reported as present are essentially impossible
157        // (concurrent deletion); treat the content as empty so downstream lookups skip naturally.
158        let content = tokio::fs::read_to_string(&path).await.unwrap_or_default();
159        let Some(parsed) = parse_xml_from_str(&content, &path_str) else {
160            continue;
161        };
162        // parse_xml_from_str always yields a JSON object when it returns Some; fall back to an
163        // empty map for any unexpected shape so subsequent lookups simply produce None.
164        let obj = parsed.as_object().cloned().unwrap_or_default();
165        let Some(current_root_key) = obj.keys().find(|k| *k != "?xml").cloned() else {
166            continue;
167        };
168        let root_val = obj
169            .get(&current_root_key)
170            .and_then(|v| v.as_object())
171            .cloned()
172            .unwrap_or_default();
173
174        let decl = obj.get("?xml").cloned().unwrap_or_else(|| {
175            let mut d = Map::new();
176            d.insert(
177                "@version".to_string(),
178                serde_json::Value::String("1.0".to_string()),
179            );
180            d.insert(
181                "@encoding".to_string(),
182                serde_json::Value::String("UTF-8".to_string()),
183            );
184            serde_json::Value::Object(d)
185        });
186
187        let single_inner = has_single_inner_wrapper(&root_val, inner_wrapper);
188        let inner_content: serde_json::Value =
189            if should_unwrap_inner_segment(&current_root_key, document_root, single_inner) {
190                let inner_obj = root_val
191                    .get(inner_wrapper)
192                    .and_then(|v| v.as_object())
193                    .cloned()
194                    .unwrap_or_else(Map::new);
195                let mut inner_clean = Map::new();
196                for (k, v) in &inner_obj {
197                    if k != "@xmlns" {
198                        inner_clean.insert(k.clone(), v.clone());
199                    }
200                }
201                serde_json::Value::Object(inner_clean)
202            } else {
203                // The inner wrapper must not carry an `xmlns` attribute (only the document
204                // root keeps it). Strip it from the cloned content so nested-rule wrapping
205                // doesn't emit `<inner_wrapper xmlns="...">` siblings.
206                let mut inner_clean = Map::new();
207                for (k, v) in &root_val {
208                    if k != "@xmlns" {
209                        inner_clean.insert(k.clone(), v.clone());
210                    }
211                }
212                serde_json::Value::Object(inner_clean)
213            };
214
215        let already_correct = current_root_key == document_root
216            && root_val.get("@xmlns").is_some()
217            && single_inner
218            && root_val
219                .get(inner_wrapper)
220                .and_then(|v| v.as_object())
221                .map(|o| !o.contains_key("@xmlns"))
222                .unwrap_or(true);
223        if already_correct {
224            continue;
225        }
226
227        // Build document_root (with @xmlns only on root) > inner_wrapper (no xmlns) > content
228        let mut root_val_new = Map::new();
229        if !xmlns.is_empty() {
230            root_val_new.insert(
231                "@xmlns".to_string(),
232                serde_json::Value::String(xmlns.to_string()),
233            );
234        }
235        root_val_new.insert(inner_wrapper.to_string(), inner_content);
236
237        let mut top = Map::new();
238        top.insert("?xml".to_string(), decl);
239        top.insert(
240            document_root.to_string(),
241            serde_json::Value::Object(root_val_new),
242        );
243        let wrapped = serde_json::Value::Object(top);
244        let xml_string = build_xml_string(&wrapped);
245        tokio::fs::write(&path, xml_string).await?;
246    }
247    Ok(())
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use serde_json::json;
254
255    #[test]
256    fn path_segment_from_file_pattern_strips_suffix() {
257        assert_eq!(
258            path_segment_from_file_pattern("programProcesses-meta"),
259            "programProcesses"
260        );
261    }
262
263    #[test]
264    fn path_segment_from_file_pattern_no_dash() {
265        assert_eq!(path_segment_from_file_pattern("foo"), "foo");
266    }
267
268    #[test]
269    fn strip_root_and_build_xml_strips_child_not_root() {
270        let parsed = json!({
271            "?xml": { "@version": "1.0" },
272            "Root": {
273                "programProcesses": { "a": "1", "b": "2" },
274                "label": "x"
275            }
276        });
277        let out = strip_root_and_build_xml(&parsed, "programProcesses").unwrap();
278        assert!(out.contains("<Root>"));
279        assert!(out.contains("<a>1</a>"));
280        assert!(out.contains("<b>2</b>"));
281        assert!(out.contains("<label>x</label>"));
282    }
283
284    #[test]
285    fn strip_root_and_build_xml_strips_root_excludes_attributes() {
286        let parsed = json!({
287            "?xml": { "@version": "1.0" },
288            "LoyaltyProgramSetup": {
289                "@xmlns": "http://example.com",
290                "programProcesses": { "x": "1" }
291            }
292        });
293        let out = strip_root_and_build_xml(&parsed, "LoyaltyProgramSetup").unwrap();
294        assert!(!out.contains("@xmlns"));
295        assert!(out.contains("programProcesses"));
296    }
297
298    #[test]
299    fn capture_xmlns_from_root_returns_some() {
300        let parsed = json!({
301            "Root": { "@xmlns": "http://ns.example.com" }
302        });
303        assert_eq!(
304            capture_xmlns_from_root(&parsed),
305            Some("http://ns.example.com".to_string())
306        );
307    }
308
309    #[test]
310    fn capture_xmlns_from_root_returns_none_when_absent() {
311        let parsed = json!({ "Root": { "child": "x" } });
312        assert!(capture_xmlns_from_root(&parsed).is_none());
313    }
314
315    #[tokio::test]
316    async fn save_and_load_multi_level_config() {
317        let dir = tempfile::tempdir().unwrap();
318        let config = MultiLevelConfig {
319            rules: vec![crate::xml::types::MultiLevelRule {
320                file_pattern: "test-meta".to_string(),
321                root_to_strip: "Root".to_string(),
322                unique_id_elements: "id".to_string(),
323                path_segment: "test".to_string(),
324                wrap_root_element: "Root".to_string(),
325                wrap_xmlns: "http://example.com".to_string(),
326            }],
327        };
328        save_multi_level_config(dir.path(), &config).await.unwrap();
329        let loaded = load_multi_level_config(dir.path()).await.unwrap();
330        assert_eq!(loaded.rules.len(), 1);
331        assert_eq!(loaded.rules[0].path_segment, "test");
332    }
333
334    #[tokio::test]
335    async fn load_multi_level_config_missing_file_returns_none() {
336        let dir = tempfile::tempdir().unwrap();
337        assert!(load_multi_level_config(dir.path()).await.is_none());
338    }
339
340    #[tokio::test]
341    async fn ensure_segment_files_structure_empty_xmlns_omits_xmlns_attribute() {
342        // When xmlns is an empty string the `if !xmlns.is_empty()` branch must
343        // NOT insert `@xmlns`, so the rewritten file has no xmlns attribute.
344        let dir = tempfile::tempdir().unwrap();
345        let xml = r#"<?xml version="1.0"?><Root><inner><x>1</x></inner></Root>"#;
346        let path = dir.path().join("seg.xml");
347        tokio::fs::write(&path, xml).await.unwrap();
348        ensure_segment_files_structure(
349            dir.path(),
350            "Root",
351            "inner",
352            "", // empty xmlns
353        )
354        .await
355        .unwrap();
356        let out = tokio::fs::read_to_string(&path).await.unwrap();
357        assert!(
358            !out.contains("xmlns"),
359            "empty xmlns must not emit an xmlns attribute: {out}"
360        );
361        assert!(
362            out.contains("<inner>"),
363            "inner wrapper must be present: {out}"
364        );
365    }
366
367    #[tokio::test]
368    async fn ensure_segment_files_structure_adds_xmlns_and_rewrites() {
369        let dir = tempfile::tempdir().unwrap();
370        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
371<Root>
372  <programProcesses><x>1</x></programProcesses>
373</Root>"#;
374        let path = dir.path().join("segment.xml");
375        tokio::fs::write(&path, xml).await.unwrap();
376        ensure_segment_files_structure(
377            dir.path(),
378            "Root",
379            "programProcesses",
380            "http://example.com",
381        )
382        .await
383        .unwrap();
384        let out = tokio::fs::read_to_string(&path).await.unwrap();
385        assert!(out.contains("http://example.com"));
386        assert!(out.contains("<programProcesses>"));
387        assert!(out.contains("<x>1</x>"));
388    }
389
390    #[tokio::test]
391    async fn ensure_segment_files_structure_skips_already_correct_files() {
392        // Root wraps inner_wrapper and has xmlns; inner has no xmlns -> no rewrite.
393        let dir = tempfile::tempdir().unwrap();
394        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
395<Root xmlns="http://example.com"><programProcesses><x>1</x></programProcesses></Root>"#;
396        let path = dir.path().join("ok.xml");
397        tokio::fs::write(&path, xml).await.unwrap();
398        let before = tokio::fs::metadata(&path).await.unwrap().modified().ok();
399        ensure_segment_files_structure(
400            dir.path(),
401            "Root",
402            "programProcesses",
403            "http://example.com",
404        )
405        .await
406        .unwrap();
407        let after = tokio::fs::metadata(&path).await.unwrap().modified().ok();
408        assert_eq!(before, after, "already-correct files must be left as-is");
409    }
410
411    #[tokio::test]
412    async fn ensure_segment_files_structure_skips_non_xml_and_subdirs() {
413        let dir = tempfile::tempdir().unwrap();
414        tokio::fs::create_dir(dir.path().join("nested"))
415            .await
416            .unwrap();
417        tokio::fs::write(dir.path().join("notes.txt"), "hello")
418            .await
419            .unwrap();
420        tokio::fs::write(dir.path().join("broken.xml"), "<<not xml>")
421            .await
422            .unwrap();
423        // No XML payload that matches; should succeed without writing anything.
424        ensure_segment_files_structure(
425            dir.path(),
426            "Root",
427            "programProcesses",
428            "http://example.com",
429        )
430        .await
431        .unwrap();
432        // broken.xml remains unchanged
433        let raw = tokio::fs::read_to_string(dir.path().join("broken.xml"))
434            .await
435            .unwrap();
436        assert_eq!(raw, "<<not xml>");
437    }
438
439    #[tokio::test]
440    async fn ensure_segment_files_structure_skips_xml_missing_root() {
441        // Only a declaration, no root element (empty document)
442        let dir = tempfile::tempdir().unwrap();
443        tokio::fs::write(dir.path().join("empty.xml"), "")
444            .await
445            .unwrap();
446        ensure_segment_files_structure(dir.path(), "Root", "programProcesses", "")
447            .await
448            .unwrap();
449    }
450
451    fn map_from(pairs: &[(&str, serde_json::Value)]) -> serde_json::Map<String, serde_json::Value> {
452        let mut m = serde_json::Map::new();
453        for (k, v) in pairs {
454            m.insert((*k).to_string(), v.clone());
455        }
456        m
457    }
458
459    #[test]
460    fn has_single_inner_wrapper_true_for_single_matching_child() {
461        let m = map_from(&[("inner", json!({"a": 1}))]);
462        assert!(has_single_inner_wrapper(&m, "inner"));
463    }
464
465    #[test]
466    fn has_single_inner_wrapper_true_when_only_attribute_is_xmlns_sibling() {
467        // The `@xmlns` filter on `non_attr_keys` must be honoured so an
468        // xmlns-carrying root still counts as a "thin" wrapper when its
469        // single non-attribute child matches.
470        let m = map_from(&[
471            ("@xmlns", json!("http://example.com")),
472            ("inner", json!({"a": 1})),
473        ]);
474        assert!(has_single_inner_wrapper(&m, "inner"));
475    }
476
477    #[test]
478    fn has_single_inner_wrapper_true_when_sibling_is_internal_compact_marker() {
479        // A `#compact` (or `#text`/`#comment`/etc.) marker on `root_val` is an internal
480        // parser annotation, not a real child element -- it must not count toward the
481        // "single child" total. Without this, a genuinely thin wrapper whose XML source
482        // had zero whitespace around it (making it eligible for the `#compact` marker
483        // itself) would be misdetected as having two children and lose the
484        // already-correct fast path in `ensure_segment_files_structure`.
485        let m = map_from(&[
486            ("@xmlns", json!("http://example.com")),
487            ("inner", json!({"a": 1})),
488            ("#compact", json!(true)),
489        ]);
490        assert!(has_single_inner_wrapper(&m, "inner"));
491    }
492
493    #[test]
494    fn has_single_inner_wrapper_false_when_multiple_non_attribute_children() {
495        let m = map_from(&[("inner", json!({})), ("other", json!({}))]);
496        assert!(!has_single_inner_wrapper(&m, "inner"));
497    }
498
499    #[test]
500    fn has_single_inner_wrapper_false_when_only_child_name_differs() {
501        let m = map_from(&[("notInner", json!({"a": 1}))]);
502        assert!(!has_single_inner_wrapper(&m, "inner"));
503    }
504
505    #[test]
506    fn has_single_inner_wrapper_false_when_empty() {
507        let m = serde_json::Map::new();
508        assert!(!has_single_inner_wrapper(&m, "inner"));
509    }
510
511    #[test]
512    fn should_unwrap_inner_segment_true_when_root_matches_and_single_inner() {
513        // Document root matches and the file already has the thin
514        // `<doc_root>…<inner_wrapper>…</inner_wrapper></doc_root>` shape.
515        // Returning true triggers the inner-content unwrap so we don't
516        // emit a double-wrapped file on the next write.
517        assert!(should_unwrap_inner_segment("Doc", "Doc", true));
518    }
519
520    #[test]
521    fn should_unwrap_inner_segment_false_when_current_root_differs() {
522        // A nested segment file whose current root is the inner
523        // wrapper itself (not the document root) must NOT be unwrapped —
524        // its existing content already lives one level below the inner
525        // wrapper that we'll re-add.
526        assert!(!should_unwrap_inner_segment("Other", "Doc", true));
527    }
528
529    #[test]
530    fn should_unwrap_inner_segment_false_when_not_single_inner() {
531        // Even when the document root matches, a file with multiple
532        // non-attribute children is not the thin-wrapper case.
533        assert!(!should_unwrap_inner_segment("Doc", "Doc", false));
534    }
535
536    #[tokio::test]
537    async fn ensure_segment_files_structure_else_branch_when_root_differs_from_document_root() {
538        // When current_root_key != document_root, should_unwrap_inner_segment returns false
539        // and the `else` branch (lines 195-206) copies root_val directly.
540        let dir = tempfile::tempdir().unwrap();
541        // File has root "Item" but we call with document_root "Root".
542        let xml = r#"<Item><child>x</child></Item>"#;
543        let path = dir.path().join("item.xml");
544        tokio::fs::write(&path, xml).await.unwrap();
545        ensure_segment_files_structure(
546            dir.path(),
547            "Root", // document_root differs from "Item"
548            "child",
549            "http://example.com",
550        )
551        .await
552        .unwrap();
553        let out = tokio::fs::read_to_string(&path).await.unwrap();
554        // Rewritten with "Root" as document root
555        assert!(out.contains("<Root"), "expected Root element: {out}");
556        assert!(
557            out.contains("http://example.com"),
558            "expected xmlns attribute: {out}"
559        );
560    }
561
562    #[tokio::test]
563    async fn ensure_segment_files_structure_else_branch_multiple_children() {
564        // single_inner=false (multiple children) → should_unwrap_inner_segment=false → else branch.
565        let dir = tempfile::tempdir().unwrap();
566        // File has root "Root" but with TWO non-attr children → single_inner=false.
567        let xml = r#"<Root><a>1</a><b>2</b></Root>"#;
568        let path = dir.path().join("multi.xml");
569        tokio::fs::write(&path, xml).await.unwrap();
570        ensure_segment_files_structure(
571            dir.path(),
572            "Root",
573            "inner", // inner_wrapper not present in root_val
574            "http://example.com",
575        )
576        .await
577        .unwrap();
578        let out = tokio::fs::read_to_string(&path).await.unwrap();
579        assert!(
580            out.contains("<Root"),
581            "Root element must be in output: {out}"
582        );
583    }
584}