config_disassembler/
reassemble.rs1use 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#[derive(Debug, Clone)]
17pub struct ReassembleOptions {
18 pub input_dir: PathBuf,
20 pub output: Option<PathBuf>,
24 pub output_format: Option<Format>,
27 pub post_purge: bool,
29}
30
31pub 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
118fn 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 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 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}