Skip to main content

config_disassembler/
reassemble.rs

1//! Reassemble a directory of split files (produced by [`disassemble`])
2//! back into a single configuration file.
3//!
4//! [`disassemble`]: crate::disassemble::disassemble
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use serde_json::{Map, Value};
10
11use crate::error::{Error, Result};
12use crate::format::Format;
13use crate::meta::{Meta, Root};
14
15/// Options controlling reassembly.
16#[derive(Debug, Clone)]
17pub struct ReassembleOptions {
18    /// Directory containing the disassembled files and metadata.
19    pub input_dir: PathBuf,
20    /// Path to write the reassembled file to. If `None`, written next to
21    /// the input directory using the original source filename (or the
22    /// directory name with the chosen format's extension).
23    pub output: Option<PathBuf>,
24    /// Format to write the reassembled file in. Defaults to the format
25    /// recorded as the original source format in the metadata.
26    pub output_format: Option<Format>,
27    /// Remove the input directory after a successful reassembly.
28    pub post_purge: bool,
29}
30
31/// Reassemble a configuration file from a disassembled directory.
32///
33/// Returns the path of the reassembled output file.
34pub fn reassemble(opts: ReassembleOptions) -> Result<PathBuf> {
35    let dir = &opts.input_dir;
36    if !dir.is_dir() {
37        return Err(Error::Invalid(format!(
38            "input is not a directory: {}",
39            dir.display()
40        )));
41    }
42    let meta = Meta::read(dir)?;
43    let file_format: Format = meta.file_format.into();
44    let output_format: Format = opts
45        .output_format
46        .unwrap_or_else(|| meta.source_format.into());
47
48    if (file_format == Format::Toml) != (output_format == Format::Toml) {
49        return Err(Error::Invalid(format!(
50            "TOML can only be reassembled to and from TOML; the disassembled \
51             directory was written in {file_format} but reassembly target is \
52             {output_format}"
53        )));
54    }
55
56    let value = match &meta.root {
57        Root::Object {
58            key_order,
59            key_files,
60            main_file,
61        } => assemble_object(dir, key_order, key_files, main_file.as_deref(), file_format)?,
62        Root::Array { files } => assemble_array(dir, files, file_format)?,
63    };
64
65    let output_path = match opts.output.clone() {
66        Some(p) => p,
67        None => default_output_path(dir, &meta, output_format)?,
68    };
69    if let Some(parent) = output_path.parent() {
70        if !parent.as_os_str().is_empty() {
71            fs::create_dir_all(parent)?;
72        }
73    }
74    fs::write(&output_path, output_format.serialize(&value)?)?;
75
76    if opts.post_purge {
77        fs::remove_dir_all(dir)?;
78    }
79    Ok(output_path)
80}
81
82fn assemble_object(
83    dir: &Path,
84    key_order: &[String],
85    key_files: &std::collections::BTreeMap<String, String>,
86    main_file: Option<&str>,
87    file_format: Format,
88) -> Result<Value> {
89    let main_object: Map<String, Value> = match main_file {
90        Some(name) => match file_format.load(&dir.join(name))? {
91            Value::Object(map) => map,
92            _ => {
93                return Err(Error::Invalid(format!(
94                    "main scalar file {name} did not contain an object"
95                )));
96            }
97        },
98        None => Map::new(),
99    };
100
101    let mut out = Map::new();
102    for key in key_order {
103        if let Some(filename) = key_files.get(key) {
104            let loaded = file_format.load(&dir.join(filename))?;
105            let value = unwrap_per_key_payload(file_format, key, filename, loaded)?;
106            out.insert(key.clone(), value);
107        } else if let Some(value) = main_object.get(key) {
108            out.insert(key.clone(), value.clone());
109        } else {
110            return Err(Error::Invalid(format!(
111                "metadata references key `{key}` but no file or scalar found"
112            )));
113        }
114    }
115    Ok(Value::Object(out))
116}
117
118/// Reverse of [`disassemble::wrap_per_key_payload`]: TOML per-key files
119/// wrap their payload under the parent key (since TOML cannot have a
120/// non-table root), so unwrap to recover the original value here.
121///
122/// TOML always deserializes to a table at the root, so the only failure
123/// mode is a missing wrapper key (e.g., the on-disk file was edited so
124/// its top-level key no longer matches the metadata).
125///
126/// [`disassemble::wrap_per_key_payload`]: crate::disassemble
127fn unwrap_per_key_payload(
128    file_format: Format,
129    key: &str,
130    filename: &str,
131    loaded: Value,
132) -> Result<Value> {
133    if file_format != Format::Toml {
134        return Ok(loaded);
135    }
136    let Value::Object(mut map) = loaded else {
137        // TOML's grammar guarantees a table root; this branch is
138        // defensive and should never be reached for a file that was
139        // successfully parsed via `Format::Toml`.
140        return Err(Error::Invalid(format!(
141            "TOML file `{filename}` did not deserialize to a table"
142        )));
143    };
144    map.remove(key).ok_or_else(|| {
145        Error::Invalid(format!(
146            "TOML file `{filename}` does not contain expected wrapper key `{key}`"
147        ))
148    })
149}
150
151fn assemble_array(dir: &Path, files: &[String], file_format: Format) -> Result<Value> {
152    let mut items = Vec::with_capacity(files.len());
153    for name in files {
154        items.push(file_format.load(&dir.join(name))?);
155    }
156    Ok(Value::Array(items))
157}
158
159fn default_output_path(dir: &Path, meta: &Meta, output_format: Format) -> Result<PathBuf> {
160    let parent = dir.parent().unwrap_or(Path::new("."));
161    let mut name = meta
162        .source_filename
163        .clone()
164        .or_else(|| {
165            dir.file_name()
166                .and_then(|n| n.to_str())
167                .map(|s| s.to_string())
168        })
169        .ok_or_else(|| Error::Invalid("could not determine output file name".into()))?;
170    let stem = match Path::new(&name).file_stem().and_then(|s| s.to_str()) {
171        Some(s) => s.to_string(),
172        None => name.clone(),
173    };
174    name = format!("{stem}.{}", output_format.extension());
175    Ok(parent.join(name))
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use serde_json::json;
182
183    #[test]
184    fn unwrap_per_key_payload_passes_through_non_toml() {
185        let v = json!({"unrelated": 1});
186        let out = unwrap_per_key_payload(Format::Json, "key", "k.json", v.clone()).unwrap();
187        assert_eq!(out, v);
188    }
189
190    #[test]
191    fn unwrap_per_key_payload_extracts_wrapper_key_for_toml() {
192        let v = json!({"servers": [{"host": "a"}]});
193        let out = unwrap_per_key_payload(Format::Toml, "servers", "servers.toml", v).unwrap();
194        assert_eq!(out, json!([{"host": "a"}]));
195    }
196
197    #[test]
198    fn unwrap_per_key_payload_errors_when_wrapper_key_missing() {
199        let v = json!({"wrong": 1});
200        let err =
201            unwrap_per_key_payload(Format::Toml, "right", "x.toml", v).expect_err("should error");
202        let msg = err.to_string();
203        assert!(
204            msg.contains("does not contain expected wrapper key"),
205            "got: {msg}"
206        );
207        assert!(msg.contains("right"), "got: {msg}");
208        assert!(msg.contains("x.toml"), "got: {msg}");
209    }
210
211    #[test]
212    fn unwrap_per_key_payload_errors_on_non_object_for_toml() {
213        // TOML's grammar guarantees this never occurs through Format::load,
214        // but the defensive arm is still exercised here so any future
215        // refactor that reaches it returns a clear error rather than
216        // panicking.
217        let err = unwrap_per_key_payload(Format::Toml, "k", "k.toml", json!([1, 2, 3]))
218            .expect_err("should error");
219        assert!(
220            err.to_string().contains("did not deserialize to a table"),
221            "got: {err}"
222        );
223    }
224}