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 jsonc_parser::ast;
10use jsonc_parser::common::Ranged;
11use serde_json::{Map, Value};
12
13use crate::error::{Error, Result};
14use crate::format::{jsonc_parse_options, ConversionOperation, Format};
15use crate::meta::{Meta, Root};
16
17/// Options controlling reassembly.
18#[derive(Debug, Clone)]
19pub struct ReassembleOptions {
20    /// Directory containing the disassembled files and metadata.
21    pub input_dir: PathBuf,
22    /// Path to write the reassembled file to. If `None`, written next to
23    /// the input directory using the original source filename (or the
24    /// directory name with the chosen format's extension).
25    pub output: Option<PathBuf>,
26    /// Format to write the reassembled file in. Defaults to the format
27    /// recorded as the original source format in the metadata.
28    pub output_format: Option<Format>,
29    /// Remove the input directory after a successful reassembly.
30    pub post_purge: bool,
31}
32
33/// Reassemble a configuration file from a disassembled directory.
34///
35/// Returns the path of the reassembled output file.
36pub fn reassemble(opts: ReassembleOptions) -> Result<PathBuf> {
37    let dir = &opts.input_dir;
38    if !dir.is_dir() {
39        return Err(Error::Invalid(format!(
40            "input is not a directory: {}",
41            dir.display()
42        )));
43    }
44    let meta = Meta::read(dir)?;
45    let file_format = meta.file_format;
46    let output_format: Format = opts.output_format.unwrap_or(meta.source_format);
47
48    file_format.ensure_can_convert_to(output_format, ConversionOperation::Reassemble)?;
49
50    let output_path = match opts.output.clone() {
51        Some(p) => p,
52        None => default_output_path(dir, &meta, output_format)?,
53    };
54    if let Some(parent) = output_path.parent() {
55        if !parent.as_os_str().is_empty() {
56            fs::create_dir_all(parent)?;
57        }
58    }
59
60    if file_format == Format::Jsonc && output_format == Format::Jsonc {
61        fs::write(&output_path, assemble_jsonc_preserving(dir, &meta)?)?;
62    } else {
63        let value = match &meta.root {
64            Root::Object {
65                key_order,
66                key_files,
67                main_file,
68            } => assemble_object(dir, key_order, key_files, main_file.as_deref(), file_format)?,
69            Root::Array { files } => assemble_array(dir, files, file_format)?,
70        };
71        fs::write(&output_path, output_format.serialize(&value)?)?;
72    }
73
74    if opts.post_purge {
75        fs::remove_dir_all(dir)?;
76    }
77    Ok(output_path)
78}
79
80fn assemble_object(
81    dir: &Path,
82    key_order: &[String],
83    key_files: &std::collections::BTreeMap<String, String>,
84    main_file: Option<&str>,
85    file_format: Format,
86) -> Result<Value> {
87    let main_object: Map<String, Value> = match main_file {
88        Some(name) => match file_format.load(&dir.join(name))? {
89            Value::Object(map) => map,
90            _ => {
91                return Err(Error::Invalid(format!(
92                    "main scalar file {name} did not contain an object"
93                )));
94            }
95        },
96        None => Map::new(),
97    };
98
99    let mut out = Map::new();
100    for key in key_order {
101        if let Some(filename) = key_files.get(key) {
102            let loaded = file_format.load(&dir.join(filename))?;
103            let value = unwrap_per_key_payload(file_format, key, filename, loaded)?;
104            out.insert(key.clone(), value);
105        } else if let Some(value) = main_object.get(key) {
106            out.insert(key.clone(), value.clone());
107        } else {
108            return Err(Error::Invalid(format!(
109                "metadata references key `{key}` but no file or scalar found"
110            )));
111        }
112    }
113    Ok(Value::Object(out))
114}
115
116fn unwrap_per_key_payload(
117    file_format: Format,
118    key: &str,
119    filename: &str,
120    loaded: Value,
121) -> Result<Value> {
122    file_format.unwrap_split_payload(key, filename, loaded)
123}
124
125fn assemble_array(dir: &Path, files: &[String], file_format: Format) -> Result<Value> {
126    let mut items = Vec::with_capacity(files.len());
127    for name in files {
128        items.push(file_format.load(&dir.join(name))?);
129    }
130    Ok(Value::Array(items))
131}
132
133fn assemble_jsonc_preserving(dir: &Path, meta: &Meta) -> Result<String> {
134    match &meta.root {
135        Root::Object {
136            key_order,
137            key_files,
138            main_file,
139        } => assemble_jsonc_object(dir, key_order, key_files, main_file.as_deref()),
140        Root::Array { files } => assemble_jsonc_array(dir, files),
141    }
142}
143
144fn assemble_jsonc_object(
145    dir: &Path,
146    key_order: &[String],
147    key_files: &std::collections::BTreeMap<String, String>,
148    main_file: Option<&str>,
149) -> Result<String> {
150    let main_properties = match main_file {
151        Some(name) => {
152            let text = fs::read_to_string(dir.join(name))?;
153            let ast = parse_jsonc_ast(&text)?;
154            let ast::Value::Object(object) = ast else {
155                return Err(Error::Invalid(format!(
156                    "main scalar file {name} did not contain an object"
157                )));
158            };
159            jsonc_object_properties(&text, object)
160        }
161        None => Vec::new(),
162    };
163
164    let mut segments = Vec::with_capacity(key_order.len());
165    for key in key_order {
166        if let Some(filename) = key_files.get(key) {
167            let path = dir.join(filename);
168            let text = fs::read_to_string(&path)?;
169            Format::Jsonc.load(&path)?;
170            segments.push(render_jsonc_property(key, &text)?);
171        } else if let Some(property) = main_properties.iter().find(|property| &property.key == key)
172        {
173            segments.push(property.segment.clone());
174        } else {
175            return Err(Error::Invalid(format!(
176                "metadata references key `{key}` but no file or scalar found"
177            )));
178        }
179    }
180
181    Ok(render_jsonc_object(segments.iter()))
182}
183
184fn assemble_jsonc_array(dir: &Path, files: &[String]) -> Result<String> {
185    let mut segments = Vec::with_capacity(files.len());
186    for name in files {
187        let path = dir.join(name);
188        let text = fs::read_to_string(&path)?;
189        Format::Jsonc.load(&path)?;
190        segments.push(render_jsonc_array_element(&text));
191    }
192    Ok(render_jsonc_array(segments.iter()))
193}
194
195struct JsoncPropertySyntax {
196    key: String,
197    segment: String,
198}
199
200fn jsonc_object_properties(text: &str, object: ast::Object<'_>) -> Vec<JsoncPropertySyntax> {
201    object
202        .properties
203        .into_iter()
204        .map(|property| {
205            let key = property.name.clone().into_string();
206            let property_range = property.range();
207            let value_range = property.value.range();
208            JsoncPropertySyntax {
209                key,
210                segment: jsonc_property_segment(text, property_range.start, value_range.end)
211                    .to_string(),
212            }
213        })
214        .collect()
215}
216
217fn parse_jsonc_ast(text: &str) -> Result<ast::Value<'_>> {
218    jsonc_parser::parse_to_ast(text, &Default::default(), &jsonc_parse_options())
219        .map_err(|e| Error::Invalid(format!("jsonc parse error: {e}")))?
220        .value
221        .ok_or_else(|| Error::Invalid("JSONC document did not contain a value".into()))
222}
223
224fn jsonc_property_segment(text: &str, property_start: usize, value_end: usize) -> &str {
225    let start = leading_comment_start(text, line_start(text, property_start));
226    let end = line_end(text, value_end);
227    &text[start..end]
228}
229
230fn leading_comment_start(text: &str, mut start: usize) -> usize {
231    while start > 0 {
232        let previous_line_end = start.saturating_sub(1);
233        let previous_line_start = line_start(text, previous_line_end);
234        let line = &text[previous_line_start..previous_line_end];
235        let trimmed = line.trim();
236        if trimmed.is_empty()
237            || trimmed.starts_with("//")
238            || trimmed.starts_with("/*")
239            || trimmed.starts_with('*')
240            || trimmed.ends_with("*/")
241        {
242            start = previous_line_start;
243        } else {
244            break;
245        }
246    }
247    start
248}
249
250fn line_start(text: &str, pos: usize) -> usize {
251    text[..pos].rfind('\n').map(|idx| idx + 1).unwrap_or(0)
252}
253
254fn line_end(text: &str, pos: usize) -> usize {
255    text[pos..]
256        .find('\n')
257        .map(|idx| pos + idx)
258        .unwrap_or(text.len())
259}
260
261fn render_jsonc_property(key: &str, value_text: &str) -> Result<String> {
262    let key = serde_json::to_string(key)?;
263    let value_text = value_text.trim_matches(|c| c == '\r' || c == '\n');
264    let mut lines = value_text.lines();
265    let first = lines.next().unwrap_or("");
266    let mut out = format!("  {key}: {first}");
267    for line in lines {
268        out.push('\n');
269        out.push_str(line);
270    }
271    Ok(jsonc_segment_with_comma(&out))
272}
273
274fn render_jsonc_array_element(value_text: &str) -> String {
275    let value_text = value_text.trim_matches(|c| c == '\r' || c == '\n');
276    let mut out = String::new();
277    for (idx, line) in value_text.lines().enumerate() {
278        if idx > 0 {
279            out.push('\n');
280        }
281        out.push_str("  ");
282        out.push_str(line);
283    }
284    jsonc_segment_with_comma(&out)
285}
286
287fn render_jsonc_object<'a>(segments: impl IntoIterator<Item = &'a String>) -> String {
288    let mut out = String::from("{\n");
289    for segment in segments {
290        out.push_str(&jsonc_segment_with_comma(segment));
291        out.push('\n');
292    }
293    out.push_str("}\n");
294    out
295}
296
297fn render_jsonc_array<'a>(segments: impl IntoIterator<Item = &'a String>) -> String {
298    let mut out = String::from("[\n");
299    for segment in segments {
300        out.push_str(&jsonc_segment_with_comma(segment));
301        out.push('\n');
302    }
303    out.push_str("]\n");
304    out
305}
306
307fn jsonc_segment_with_comma(segment: &str) -> String {
308    let segment = segment.trim_matches(|c| c == '\r' || c == '\n');
309    if segment.trim_end().ends_with(',') {
310        return segment.to_string();
311    }
312
313    let last_line_start = segment.rfind('\n').map(|idx| idx + 1).unwrap_or(0);
314    let last_line = &segment[last_line_start..];
315    if let Some(comment_start) = line_comment_start(last_line) {
316        let comment_start = last_line_start + comment_start;
317        let (before_comment, comment) = segment.split_at(comment_start);
318        return format!("{},{}", before_comment.trim_end(), comment);
319    }
320
321    format!("{segment},")
322}
323
324fn line_comment_start(line: &str) -> Option<usize> {
325    let mut chars = line.char_indices().peekable();
326    let mut in_string = false;
327    let mut escaped = false;
328
329    while let Some((idx, ch)) = chars.next() {
330        if in_string {
331            if escaped {
332                escaped = false;
333            } else if ch == '\\' {
334                escaped = true;
335            } else if ch == '"' {
336                in_string = false;
337            }
338            continue;
339        }
340
341        if ch == '"' {
342            in_string = true;
343        } else if ch == '/' && matches!(chars.peek(), Some((_, '/'))) {
344            return Some(idx);
345        }
346    }
347
348    None
349}
350
351fn default_output_path(dir: &Path, meta: &Meta, output_format: Format) -> Result<PathBuf> {
352    let parent = dir.parent().unwrap_or(Path::new("."));
353    let mut name = meta
354        .source_filename
355        .clone()
356        .or_else(|| {
357            dir.file_name()
358                .and_then(|n| n.to_str())
359                .map(|s| s.to_string())
360        })
361        .ok_or_else(|| Error::Invalid("could not determine output file name".into()))?;
362    let stem = match Path::new(&name).file_stem().and_then(|s| s.to_str()) {
363        Some(s) => s.to_string(),
364        None => name.clone(),
365    };
366    name = format!("{stem}.{}", output_format.extension());
367    Ok(parent.join(name))
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use serde_json::json;
374
375    #[test]
376    fn unwrap_per_key_payload_passes_through_non_toml() {
377        let v = json!({"unrelated": 1});
378        let out = unwrap_per_key_payload(Format::Json, "key", "k.json", v.clone()).unwrap();
379        assert_eq!(out, v);
380    }
381
382    #[test]
383    fn unwrap_per_key_payload_extracts_wrapper_key_for_toml() {
384        let v = json!({"servers": [{"host": "a"}]});
385        let out = unwrap_per_key_payload(Format::Toml, "servers", "servers.toml", v).unwrap();
386        assert_eq!(out, json!([{"host": "a"}]));
387    }
388
389    #[test]
390    fn unwrap_per_key_payload_extracts_wrapper_key_for_ini() {
391        let v = json!({"settings": {"host": "db.example.com"}});
392        let out = unwrap_per_key_payload(Format::Ini, "settings", "settings.ini", v).unwrap();
393        assert_eq!(out, json!({"host": "db.example.com"}));
394    }
395
396    #[test]
397    fn unwrap_per_key_payload_errors_when_wrapper_key_missing() {
398        let v = json!({"wrong": 1});
399        let err =
400            unwrap_per_key_payload(Format::Toml, "right", "x.toml", v).expect_err("should error");
401        let msg = err.to_string();
402        assert!(
403            msg.contains("does not contain expected wrapper key"),
404            "got: {msg}"
405        );
406        assert!(msg.contains("right"), "got: {msg}");
407        assert!(msg.contains("x.toml"), "got: {msg}");
408    }
409
410    #[test]
411    fn unwrap_per_key_payload_errors_when_ini_wrapper_key_missing() {
412        let v = json!({"wrong": 1});
413        let err =
414            unwrap_per_key_payload(Format::Ini, "right", "x.ini", v).expect_err("should error");
415        let msg = err.to_string();
416        assert!(
417            msg.contains("does not contain expected wrapper key"),
418            "got: {msg}"
419        );
420        assert!(msg.contains("right"), "got: {msg}");
421        assert!(msg.contains("x.ini"), "got: {msg}");
422    }
423
424    #[test]
425    fn unwrap_per_key_payload_errors_on_non_object_for_toml() {
426        // TOML's grammar guarantees this never occurs through Format::load,
427        // but the defensive arm is still exercised here so any future
428        // refactor that reaches it returns a clear error rather than
429        // panicking.
430        let err = unwrap_per_key_payload(Format::Toml, "k", "k.toml", json!([1, 2, 3]))
431            .expect_err("should error");
432        assert!(
433            err.to_string().contains("did not deserialize to a table"),
434            "got: {err}"
435        );
436    }
437
438    #[test]
439    fn jsonc_segment_with_comma_inserts_before_trailing_line_comment() {
440        assert_eq!(
441            jsonc_segment_with_comma(r#"  "name": "demo" // keep this comment"#),
442            r#"  "name": "demo",// keep this comment"#
443        );
444    }
445
446    #[test]
447    fn jsonc_segment_with_comma_ignores_urls_inside_strings() {
448        assert_eq!(
449            jsonc_segment_with_comma(r#"  "url": "https://example.com/a""#),
450            r#"  "url": "https://example.com/a","#
451        );
452    }
453
454    #[test]
455    fn assemble_jsonc_object_errors_when_main_file_is_not_object() {
456        let tmp = tempfile::tempdir().unwrap();
457        fs::write(tmp.path().join("_main.jsonc"), "[]\n").unwrap();
458
459        let err = assemble_jsonc_object(tmp.path(), &[], &Default::default(), Some("_main.jsonc"))
460            .expect_err("should reject non-object main file");
461
462        assert!(
463            err.to_string().contains("did not contain an object"),
464            "got: {err}"
465        );
466    }
467
468    #[test]
469    fn assemble_jsonc_object_errors_when_metadata_key_is_missing() {
470        let tmp = tempfile::tempdir().unwrap();
471        fs::write(tmp.path().join("_main.jsonc"), "{}\n").unwrap();
472
473        let err = assemble_jsonc_object(
474            tmp.path(),
475            &["missing".into()],
476            &Default::default(),
477            Some("_main.jsonc"),
478        )
479        .expect_err("should reject missing scalar key");
480
481        assert!(
482            err.to_string()
483                .contains("metadata references key `missing`"),
484            "got: {err}"
485        );
486    }
487}