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/// Ensure all XML files in a segment directory have structure:
91/// document_root (with xmlns) > inner_wrapper (no xmlns) > content.
92/// Used after inner-level reassembly for multi-level (e.g. LoyaltyProgramSetup > programProcesses).
93pub async fn ensure_segment_files_structure(
94    dir_path: &std::path::Path,
95    document_root: &str,
96    inner_wrapper: &str,
97    xmlns: &str,
98) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
99    use crate::xml::parsers::parse_xml_from_str;
100    use serde_json::Map;
101
102    let mut entries = Vec::new();
103    let mut read_dir = tokio::fs::read_dir(dir_path).await?;
104    while let Some(entry) = read_dir.next_entry().await? {
105        entries.push(entry);
106    }
107    // Sort for deterministic cross-platform ordering
108    entries.sort_by_key(|e| e.file_name());
109
110    for entry in entries {
111        let path = entry.path();
112        if !path.is_file() {
113            continue;
114        }
115        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
116        if !name.ends_with(".xml") {
117            continue;
118        }
119        let path_str = path.to_string_lossy();
120        // Read errors on a file the walker just reported as present are essentially impossible
121        // (concurrent deletion); treat the content as empty so downstream lookups skip naturally.
122        let content = tokio::fs::read_to_string(&path).await.unwrap_or_default();
123        let Some(parsed) = parse_xml_from_str(&content, &path_str) else {
124            continue;
125        };
126        // parse_xml_from_str always yields a JSON object when it returns Some; fall back to an
127        // empty map for any unexpected shape so subsequent lookups simply produce None.
128        let obj = parsed.as_object().cloned().unwrap_or_default();
129        let Some(current_root_key) = obj.keys().find(|k| *k != "?xml").cloned() else {
130            continue;
131        };
132        let root_val = obj
133            .get(&current_root_key)
134            .and_then(|v| v.as_object())
135            .cloned()
136            .unwrap_or_default();
137
138        let decl = obj.get("?xml").cloned().unwrap_or_else(|| {
139            let mut d = Map::new();
140            d.insert(
141                "@version".to_string(),
142                serde_json::Value::String("1.0".to_string()),
143            );
144            d.insert(
145                "@encoding".to_string(),
146                serde_json::Value::String("UTF-8".to_string()),
147            );
148            serde_json::Value::Object(d)
149        });
150
151        let non_attr_keys: Vec<&String> = root_val.keys().filter(|k| *k != "@xmlns").collect();
152        let single_inner = non_attr_keys.len() == 1 && non_attr_keys[0].as_str() == inner_wrapper;
153        let inner_content: serde_json::Value = if current_root_key == document_root && single_inner
154        {
155            let inner_obj = root_val
156                .get(inner_wrapper)
157                .and_then(|v| v.as_object())
158                .cloned()
159                .unwrap_or_else(Map::new);
160            let mut inner_clean = Map::new();
161            for (k, v) in &inner_obj {
162                if k != "@xmlns" {
163                    inner_clean.insert(k.clone(), v.clone());
164                }
165            }
166            serde_json::Value::Object(inner_clean)
167        } else {
168            // The inner wrapper must not carry an `xmlns` attribute (only the document
169            // root keeps it). Strip it from the cloned content so nested-rule wrapping
170            // doesn't emit `<inner_wrapper xmlns="...">` siblings.
171            let mut inner_clean = Map::new();
172            for (k, v) in &root_val {
173                if k != "@xmlns" {
174                    inner_clean.insert(k.clone(), v.clone());
175                }
176            }
177            serde_json::Value::Object(inner_clean)
178        };
179
180        let already_correct = current_root_key == document_root
181            && root_val.get("@xmlns").is_some()
182            && single_inner
183            && root_val
184                .get(inner_wrapper)
185                .and_then(|v| v.as_object())
186                .map(|o| !o.contains_key("@xmlns"))
187                .unwrap_or(true);
188        if already_correct {
189            continue;
190        }
191
192        // Build document_root (with @xmlns only on root) > inner_wrapper (no xmlns) > content
193        let mut root_val_new = Map::new();
194        if !xmlns.is_empty() {
195            root_val_new.insert(
196                "@xmlns".to_string(),
197                serde_json::Value::String(xmlns.to_string()),
198            );
199        }
200        root_val_new.insert(inner_wrapper.to_string(), inner_content);
201
202        let mut top = Map::new();
203        top.insert("?xml".to_string(), decl);
204        top.insert(
205            document_root.to_string(),
206            serde_json::Value::Object(root_val_new),
207        );
208        let wrapped = serde_json::Value::Object(top);
209        let xml_string = build_xml_string(&wrapped);
210        tokio::fs::write(&path, xml_string).await?;
211    }
212    Ok(())
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use serde_json::json;
219
220    #[test]
221    fn path_segment_from_file_pattern_strips_suffix() {
222        assert_eq!(
223            path_segment_from_file_pattern("programProcesses-meta"),
224            "programProcesses"
225        );
226    }
227
228    #[test]
229    fn path_segment_from_file_pattern_no_dash() {
230        assert_eq!(path_segment_from_file_pattern("foo"), "foo");
231    }
232
233    #[test]
234    fn strip_root_and_build_xml_strips_child_not_root() {
235        let parsed = json!({
236            "?xml": { "@version": "1.0" },
237            "Root": {
238                "programProcesses": { "a": "1", "b": "2" },
239                "label": "x"
240            }
241        });
242        let out = strip_root_and_build_xml(&parsed, "programProcesses").unwrap();
243        assert!(out.contains("<Root>"));
244        assert!(out.contains("<a>1</a>"));
245        assert!(out.contains("<b>2</b>"));
246        assert!(out.contains("<label>x</label>"));
247    }
248
249    #[test]
250    fn strip_root_and_build_xml_strips_root_excludes_attributes() {
251        let parsed = json!({
252            "?xml": { "@version": "1.0" },
253            "LoyaltyProgramSetup": {
254                "@xmlns": "http://example.com",
255                "programProcesses": { "x": "1" }
256            }
257        });
258        let out = strip_root_and_build_xml(&parsed, "LoyaltyProgramSetup").unwrap();
259        assert!(!out.contains("@xmlns"));
260        assert!(out.contains("programProcesses"));
261    }
262
263    #[test]
264    fn capture_xmlns_from_root_returns_some() {
265        let parsed = json!({
266            "Root": { "@xmlns": "http://ns.example.com" }
267        });
268        assert_eq!(
269            capture_xmlns_from_root(&parsed),
270            Some("http://ns.example.com".to_string())
271        );
272    }
273
274    #[test]
275    fn capture_xmlns_from_root_returns_none_when_absent() {
276        let parsed = json!({ "Root": { "child": "x" } });
277        assert!(capture_xmlns_from_root(&parsed).is_none());
278    }
279
280    #[tokio::test]
281    async fn save_and_load_multi_level_config() {
282        let dir = tempfile::tempdir().unwrap();
283        let config = MultiLevelConfig {
284            rules: vec![crate::xml::types::MultiLevelRule {
285                file_pattern: "test-meta".to_string(),
286                root_to_strip: "Root".to_string(),
287                unique_id_elements: "id".to_string(),
288                path_segment: "test".to_string(),
289                wrap_root_element: "Root".to_string(),
290                wrap_xmlns: "http://example.com".to_string(),
291            }],
292        };
293        save_multi_level_config(dir.path(), &config).await.unwrap();
294        let loaded = load_multi_level_config(dir.path()).await.unwrap();
295        assert_eq!(loaded.rules.len(), 1);
296        assert_eq!(loaded.rules[0].path_segment, "test");
297    }
298
299    #[tokio::test]
300    async fn load_multi_level_config_missing_file_returns_none() {
301        let dir = tempfile::tempdir().unwrap();
302        assert!(load_multi_level_config(dir.path()).await.is_none());
303    }
304
305    #[tokio::test]
306    async fn ensure_segment_files_structure_adds_xmlns_and_rewrites() {
307        let dir = tempfile::tempdir().unwrap();
308        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
309<Root>
310  <programProcesses><x>1</x></programProcesses>
311</Root>"#;
312        let path = dir.path().join("segment.xml");
313        tokio::fs::write(&path, xml).await.unwrap();
314        ensure_segment_files_structure(
315            dir.path(),
316            "Root",
317            "programProcesses",
318            "http://example.com",
319        )
320        .await
321        .unwrap();
322        let out = tokio::fs::read_to_string(&path).await.unwrap();
323        assert!(out.contains("http://example.com"));
324        assert!(out.contains("<programProcesses>"));
325        assert!(out.contains("<x>1</x>"));
326    }
327
328    #[tokio::test]
329    async fn ensure_segment_files_structure_skips_already_correct_files() {
330        // Root wraps inner_wrapper and has xmlns; inner has no xmlns -> no rewrite.
331        let dir = tempfile::tempdir().unwrap();
332        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
333<Root xmlns="http://example.com"><programProcesses><x>1</x></programProcesses></Root>"#;
334        let path = dir.path().join("ok.xml");
335        tokio::fs::write(&path, xml).await.unwrap();
336        let before = tokio::fs::metadata(&path).await.unwrap().modified().ok();
337        ensure_segment_files_structure(
338            dir.path(),
339            "Root",
340            "programProcesses",
341            "http://example.com",
342        )
343        .await
344        .unwrap();
345        let after = tokio::fs::metadata(&path).await.unwrap().modified().ok();
346        assert_eq!(before, after, "already-correct files must be left as-is");
347    }
348
349    #[tokio::test]
350    async fn ensure_segment_files_structure_skips_non_xml_and_subdirs() {
351        let dir = tempfile::tempdir().unwrap();
352        tokio::fs::create_dir(dir.path().join("nested"))
353            .await
354            .unwrap();
355        tokio::fs::write(dir.path().join("notes.txt"), "hello")
356            .await
357            .unwrap();
358        tokio::fs::write(dir.path().join("broken.xml"), "<<not xml>")
359            .await
360            .unwrap();
361        // No XML payload that matches; should succeed without writing anything.
362        ensure_segment_files_structure(
363            dir.path(),
364            "Root",
365            "programProcesses",
366            "http://example.com",
367        )
368        .await
369        .unwrap();
370        // broken.xml remains unchanged
371        let raw = tokio::fs::read_to_string(dir.path().join("broken.xml"))
372            .await
373            .unwrap();
374        assert_eq!(raw, "<<not xml>");
375    }
376
377    #[tokio::test]
378    async fn ensure_segment_files_structure_skips_xml_missing_root() {
379        // Only a declaration, no root element (empty document)
380        let dir = tempfile::tempdir().unwrap();
381        tokio::fs::write(dir.path().join("empty.xml"), "")
382            .await
383            .unwrap();
384        ensure_segment_files_structure(dir.path(), "Root", "programProcesses", "")
385            .await
386            .unwrap();
387    }
388}