Skip to main content

config_disassembler/xml/builders/
build_disassembled_file.rs

1//! Build a single disassembled file.
2
3use crate::xml::builders::build_xml_string;
4use crate::xml::parsers::parse_unique_id_element;
5use crate::xml::transformers::transform_format;
6use crate::xml::types::BuildDisassembledFileOptions;
7use serde_json::{Map, Value};
8use std::path::Path;
9use tokio::fs;
10use tokio::io::AsyncWriteExt;
11
12pub async fn build_disassembled_file(
13    options: BuildDisassembledFileOptions<'_>,
14) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
15    let BuildDisassembledFileOptions {
16        content,
17        disassembled_path,
18        output_file_name,
19        subdirectory,
20        wrap_key,
21        is_grouped_array,
22        root_element_name,
23        root_attributes,
24        xml_declaration,
25        format,
26        unique_id_elements,
27        precomputed_unique_id,
28    } = options;
29
30    let target_directory = if let Some(subdir) = subdirectory {
31        Path::new(disassembled_path).join(subdir)
32    } else {
33        Path::new(disassembled_path).to_path_buf()
34    };
35
36    let file_name = if let Some(name) = output_file_name {
37        name.to_string()
38    } else if let Some(wk) = wrap_key {
39        if !is_grouped_array && content.is_object() {
40            // Caller-supplied id wins. The collision detector in
41            // `disassemble_element_keys` injects a content hash here when
42            // two siblings of the same parent would otherwise resolve to
43            // the same filename - without that override we'd silently
44            // overwrite the first-written sibling on the second write.
45            let id = precomputed_unique_id
46                .map(str::to_string)
47                .unwrap_or_else(|| parse_unique_id_element(&content, unique_id_elements));
48            format!("{}.{}-meta.{}", id, wk, format)
49        } else {
50            "output".to_string()
51        }
52    } else {
53        "output".to_string()
54    };
55
56    let output_path = target_directory.join(&file_name);
57
58    fs::create_dir_all(&target_directory).await?;
59
60    let root_attrs_obj = root_attributes.as_object().cloned().unwrap_or_default();
61    let mut inner = root_attrs_obj.clone();
62
63    if let Some(wk) = wrap_key {
64        inner.insert(wk.to_string(), content.clone());
65    } else if let Some(obj) = content.as_object() {
66        for (k, v) in obj {
67            inner.insert(k.clone(), v.clone());
68        }
69    }
70
71    let mut wrapped_inner = Map::new();
72    wrapped_inner.insert(root_element_name.to_string(), Value::Object(inner));
73
74    if let Some(decl) = xml_declaration.filter(|d| d.is_object()) {
75        let mut root = Map::new();
76        root.insert("?xml".to_string(), decl);
77        for (k, v) in wrapped_inner {
78            root.insert(k, v);
79        }
80        wrapped_inner = root;
81    }
82
83    let wrapped_xml = Value::Object(wrapped_inner);
84
85    let output_string = if let Some(s) = transform_format(format, &wrapped_xml).await {
86        s
87    } else {
88        build_xml_string(&wrapped_xml)
89    };
90
91    // Tokio's `write_all` only guarantees the bytes are queued in the
92    // runtime's userspace buffer; it does NOT guarantee they have reached
93    // the OS or that the file is visible to subsequent readers. Without
94    // an explicit `flush` + `shutdown`, callers that immediately read the
95    // disassembled tree (test harnesses, multi-step pipelines) can race
96    // and observe a partially-written directory - the failure mode is a
97    // "missing" shard whose write was queued but not yet flushed when
98    // the directory was scanned. `shutdown` fully closes the handle and
99    // waits for the underlying file to be flushed, eliminating the race.
100    let mut file = fs::File::create(&output_path).await?;
101    file.write_all(output_string.as_bytes()).await?;
102    file.flush().await?;
103    file.shutdown().await?;
104    log::debug!("Created disassembled file: {}", output_path.display());
105
106    Ok(())
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use serde_json::json;
113
114    fn opts_base(disassembled_path: &str) -> BuildDisassembledFileOptions<'_> {
115        BuildDisassembledFileOptions {
116            content: json!({ "a": "b" }),
117            disassembled_path,
118            output_file_name: Some("out.xml"),
119            subdirectory: None,
120            wrap_key: None,
121            is_grouped_array: false,
122            root_element_name: "Root",
123            root_attributes: Value::Object(Map::new()),
124            xml_declaration: None,
125            format: "xml",
126            unique_id_elements: None,
127            precomputed_unique_id: None,
128        }
129    }
130
131    #[tokio::test]
132    async fn build_disassembled_file_file_name_output_when_wrap_key_no_output_name_grouped_array() {
133        // wrap_key Some, is_grouped_array true → file_name = "output"
134        let temp = tempfile::tempdir().unwrap();
135        let path = temp.path().to_str().unwrap();
136        let mut opts = opts_base(path);
137        opts.output_file_name = None;
138        opts.wrap_key = Some("wrap");
139        opts.is_grouped_array = true;
140        opts.content = json!([{ "x": "1" }]);
141        build_disassembled_file(opts).await.unwrap();
142        assert!(temp.path().join("output").exists());
143    }
144
145    #[tokio::test]
146    async fn build_disassembled_file_file_name_output_when_wrap_key_content_not_object() {
147        // wrap_key Some, content not object (e.g. Array) → file_name = "output"
148        let temp = tempfile::tempdir().unwrap();
149        let path = temp.path().to_str().unwrap();
150        let mut opts = opts_base(path);
151        opts.output_file_name = None;
152        opts.wrap_key = Some("wrap");
153        opts.is_grouped_array = false;
154        opts.content = json!([{ "id": "a" }]);
155        build_disassembled_file(opts).await.unwrap();
156        assert!(temp.path().join("output").exists());
157    }
158
159    #[tokio::test]
160    async fn build_disassembled_file_file_name_output_when_no_wrap_key_no_output_name() {
161        // No output_file_name, no wrap_key → file_name = "output"
162        let temp = tempfile::tempdir().unwrap();
163        let path = temp.path().to_str().unwrap();
164        let mut opts = opts_base(path);
165        opts.output_file_name = None;
166        opts.wrap_key = None;
167        build_disassembled_file(opts).await.unwrap();
168        assert!(temp.path().join("output").exists());
169    }
170
171    #[tokio::test]
172    async fn build_disassembled_file_content_not_object_no_spread() {
173        // No wrap_key, content not object -> inner is only root metadata.
174        let temp = tempfile::tempdir().unwrap();
175        let path = temp.path().to_str().unwrap();
176        let mut opts = opts_base(path);
177        opts.output_file_name = Some("single.xml");
178        opts.wrap_key = None;
179        opts.content = json!(42);
180        opts.root_attributes = json!({ "marker": "kept" });
181        build_disassembled_file(opts).await.unwrap();
182        let out = fs::read_to_string(temp.path().join("single.xml"))
183            .await
184            .unwrap();
185        assert!(out.contains("<Root"), "expected Root element, got: {out}");
186        assert!(out.contains("marker"), "expected root metadata, got: {out}");
187        assert!(
188            out.contains("kept"),
189            "expected root metadata value, got: {out}"
190        );
191        assert!(!out.contains("42"));
192    }
193}