Skip to main content

xml_disassembler/
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::builders::build_xml_string;
6use crate::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    if let Some(prefix) = file_pattern.split('-').next() {
64        prefix.to_string()
65    } else {
66        file_pattern.to_string()
67    }
68}
69
70/// Load multi-level config from a directory (reads .multi_level.json).
71pub async fn load_multi_level_config(dir_path: &std::path::Path) -> Option<MultiLevelConfig> {
72    let path = dir_path.join(".multi_level.json");
73    let content = tokio::fs::read_to_string(&path).await.ok()?;
74    serde_json::from_str(&content).ok()
75}
76
77/// Persist multi-level config to a directory.
78pub async fn save_multi_level_config(
79    dir_path: &std::path::Path,
80    config: &MultiLevelConfig,
81) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
82    let path = dir_path.join(".multi_level.json");
83    let content = serde_json::to_string_pretty(config)?;
84    tokio::fs::write(path, content).await?;
85    Ok(())
86}
87
88/// Ensure all XML files in a segment directory have structure:
89/// document_root (with xmlns) > inner_wrapper (no xmlns) > content.
90/// Used after inner-level reassembly for multi-level (e.g. LoyaltyProgramSetup > programProcesses).
91pub async fn ensure_segment_files_structure(
92    dir_path: &std::path::Path,
93    document_root: &str,
94    inner_wrapper: &str,
95    xmlns: &str,
96) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
97    use crate::parsers::parse_xml_from_str;
98    use serde_json::Map;
99
100    let mut entries = Vec::new();
101    let mut read_dir = tokio::fs::read_dir(dir_path).await?;
102    while let Some(entry) = read_dir.next_entry().await? {
103        entries.push(entry);
104    }
105    // Sort for deterministic cross-platform ordering
106    entries.sort_by_key(|e| e.file_name());
107
108    for entry in entries {
109        let path = entry.path();
110        if !path.is_file() {
111            continue;
112        }
113        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
114        if !name.ends_with(".xml") {
115            continue;
116        }
117        let path_str = path.to_string_lossy();
118        let content = match tokio::fs::read_to_string(&path).await {
119            Ok(c) => c,
120            Err(_) => continue,
121        };
122        let parsed = match parse_xml_from_str(&content, &path_str) {
123            Some(p) => p,
124            None => continue,
125        };
126        let obj = match parsed.as_object() {
127            Some(o) => o,
128            None => continue,
129        };
130        let root_key = obj.keys().find(|k| *k != "?xml").cloned();
131        let Some(current_root_key) = root_key else {
132            continue;
133        };
134        let root_val = obj
135            .get(&current_root_key)
136            .and_then(|v| v.as_object())
137            .cloned();
138        let Some(root_val) = root_val else {
139            continue;
140        };
141
142        let decl = obj.get("?xml").cloned().unwrap_or_else(|| {
143            let mut d = Map::new();
144            d.insert(
145                "@version".to_string(),
146                serde_json::Value::String("1.0".to_string()),
147            );
148            d.insert(
149                "@encoding".to_string(),
150                serde_json::Value::String("UTF-8".to_string()),
151            );
152            serde_json::Value::Object(d)
153        });
154
155        let non_attr_keys: Vec<&String> = root_val.keys().filter(|k| *k != "@xmlns").collect();
156        let single_inner = non_attr_keys.len() == 1 && non_attr_keys[0].as_str() == inner_wrapper;
157        let inner_content: serde_json::Value = if current_root_key == document_root && single_inner
158        {
159            let inner_obj = root_val
160                .get(inner_wrapper)
161                .and_then(|v| v.as_object())
162                .cloned()
163                .unwrap_or_else(Map::new);
164            let mut inner_clean = Map::new();
165            for (k, v) in &inner_obj {
166                if k != "@xmlns" {
167                    inner_clean.insert(k.clone(), v.clone());
168                }
169            }
170            serde_json::Value::Object(inner_clean)
171        } else {
172            serde_json::Value::Object(root_val.clone())
173        };
174
175        let already_correct = current_root_key == document_root
176            && root_val.get("@xmlns").is_some()
177            && single_inner
178            && root_val
179                .get(inner_wrapper)
180                .and_then(|v| v.as_object())
181                .map(|o| !o.contains_key("@xmlns"))
182                .unwrap_or(true);
183        if already_correct {
184            continue;
185        }
186
187        // Build document_root (with @xmlns only on root) > inner_wrapper (no xmlns) > content
188        let mut root_val_new = Map::new();
189        if !xmlns.is_empty() {
190            root_val_new.insert(
191                "@xmlns".to_string(),
192                serde_json::Value::String(xmlns.to_string()),
193            );
194        }
195        root_val_new.insert(inner_wrapper.to_string(), inner_content);
196
197        let mut top = Map::new();
198        top.insert("?xml".to_string(), decl);
199        top.insert(
200            document_root.to_string(),
201            serde_json::Value::Object(root_val_new),
202        );
203        let wrapped = serde_json::Value::Object(top);
204        let xml_string = build_xml_string(&wrapped);
205        tokio::fs::write(&path, xml_string).await?;
206    }
207    Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use serde_json::json;
214
215    #[test]
216    fn path_segment_from_file_pattern_strips_suffix() {
217        assert_eq!(
218            path_segment_from_file_pattern("programProcesses-meta"),
219            "programProcesses"
220        );
221    }
222
223    #[test]
224    fn path_segment_from_file_pattern_no_dash() {
225        assert_eq!(path_segment_from_file_pattern("foo"), "foo");
226    }
227
228    #[test]
229    fn strip_root_and_build_xml_strips_child_not_root() {
230        let parsed = json!({
231            "?xml": { "@version": "1.0" },
232            "Root": {
233                "programProcesses": { "a": "1", "b": "2" },
234                "label": "x"
235            }
236        });
237        let out = strip_root_and_build_xml(&parsed, "programProcesses").unwrap();
238        assert!(out.contains("<Root>"));
239        assert!(out.contains("<a>1</a>"));
240        assert!(out.contains("<b>2</b>"));
241        assert!(out.contains("<label>x</label>"));
242    }
243
244    #[test]
245    fn strip_root_and_build_xml_strips_root_excludes_attributes() {
246        let parsed = json!({
247            "?xml": { "@version": "1.0" },
248            "LoyaltyProgramSetup": {
249                "@xmlns": "http://example.com",
250                "programProcesses": { "x": "1" }
251            }
252        });
253        let out = strip_root_and_build_xml(&parsed, "LoyaltyProgramSetup").unwrap();
254        assert!(!out.contains("@xmlns"));
255        assert!(out.contains("programProcesses"));
256    }
257
258    #[test]
259    fn capture_xmlns_from_root_returns_some() {
260        let parsed = json!({
261            "Root": { "@xmlns": "http://ns.example.com" }
262        });
263        assert_eq!(
264            capture_xmlns_from_root(&parsed),
265            Some("http://ns.example.com".to_string())
266        );
267    }
268
269    #[test]
270    fn capture_xmlns_from_root_returns_none_when_absent() {
271        let parsed = json!({ "Root": { "child": "x" } });
272        assert!(capture_xmlns_from_root(&parsed).is_none());
273    }
274
275    #[tokio::test]
276    async fn save_and_load_multi_level_config() {
277        let dir = tempfile::tempdir().unwrap();
278        let config = MultiLevelConfig {
279            rules: vec![crate::types::MultiLevelRule {
280                file_pattern: "test-meta".to_string(),
281                root_to_strip: "Root".to_string(),
282                unique_id_elements: "id".to_string(),
283                path_segment: "test".to_string(),
284                wrap_root_element: "Root".to_string(),
285                wrap_xmlns: "http://example.com".to_string(),
286            }],
287        };
288        save_multi_level_config(dir.path(), &config).await.unwrap();
289        let loaded = load_multi_level_config(dir.path()).await.unwrap();
290        assert_eq!(loaded.rules.len(), 1);
291        assert_eq!(loaded.rules[0].path_segment, "test");
292    }
293
294    #[tokio::test]
295    async fn load_multi_level_config_missing_file_returns_none() {
296        let dir = tempfile::tempdir().unwrap();
297        assert!(load_multi_level_config(dir.path()).await.is_none());
298    }
299
300    #[tokio::test]
301    async fn ensure_segment_files_structure_adds_xmlns_and_rewrites() {
302        let dir = tempfile::tempdir().unwrap();
303        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
304<Root>
305  <programProcesses><x>1</x></programProcesses>
306</Root>"#;
307        let path = dir.path().join("segment.xml");
308        tokio::fs::write(&path, xml).await.unwrap();
309        ensure_segment_files_structure(
310            dir.path(),
311            "Root",
312            "programProcesses",
313            "http://example.com",
314        )
315        .await
316        .unwrap();
317        let out = tokio::fs::read_to_string(&path).await.unwrap();
318        assert!(out.contains("http://example.com"));
319        assert!(out.contains("<programProcesses>"));
320        assert!(out.contains("<x>1</x>"));
321    }
322}