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    let indent = meta.indent.as_deref().unwrap_or("  ");
135    match &meta.root {
136        Root::Object {
137            key_order,
138            key_files,
139            main_file,
140        } => assemble_jsonc_object(dir, key_order, key_files, main_file.as_deref(), indent),
141        Root::Array { files } => assemble_jsonc_array(dir, files, indent),
142    }
143}
144
145fn assemble_jsonc_object(
146    dir: &Path,
147    key_order: &[String],
148    key_files: &std::collections::BTreeMap<String, String>,
149    main_file: Option<&str>,
150    indent: &str,
151) -> Result<String> {
152    let main_properties = match main_file {
153        Some(name) => {
154            let text = fs::read_to_string(dir.join(name))?;
155            let ast = parse_jsonc_ast(&text)?;
156            let ast::Value::Object(object) = ast else {
157                return Err(Error::Invalid(format!(
158                    "main scalar file {name} did not contain an object"
159                )));
160            };
161            jsonc_object_properties(&text, object)
162        }
163        None => Vec::new(),
164    };
165
166    let mut segments = Vec::with_capacity(key_order.len());
167    for key in key_order {
168        if let Some(filename) = key_files.get(key) {
169            let path = dir.join(filename);
170            let text = fs::read_to_string(&path)?;
171            Format::Jsonc.parse(&text)?;
172            segments.push(render_jsonc_property(key, &text, indent)?);
173        } else if let Some(property) = main_properties.iter().find(|property| &property.key == key)
174        {
175            segments.push(property.segment.clone());
176        } else {
177            return Err(Error::Invalid(format!(
178                "metadata references key `{key}` but no file or scalar found"
179            )));
180        }
181    }
182
183    Ok(render_jsonc_object(segments.iter()))
184}
185
186fn assemble_jsonc_array(dir: &Path, files: &[String], indent: &str) -> Result<String> {
187    let mut segments = Vec::with_capacity(files.len());
188    for name in files {
189        let path = dir.join(name);
190        let text = fs::read_to_string(&path)?;
191        Format::Jsonc.parse(&text)?;
192        segments.push(render_jsonc_array_element(&text, indent));
193    }
194    Ok(render_jsonc_array(segments.iter()))
195}
196
197struct JsoncPropertySyntax {
198    key: String,
199    segment: String,
200}
201
202fn jsonc_object_properties(text: &str, object: ast::Object<'_>) -> Vec<JsoncPropertySyntax> {
203    object
204        .properties
205        .into_iter()
206        .map(|property| {
207            let key = property.name.clone().into_string();
208            let property_range = property.range();
209            let value_range = property.value.range();
210            JsoncPropertySyntax {
211                key,
212                segment: jsonc_property_segment(text, property_range.start, value_range.end)
213                    .to_string(),
214            }
215        })
216        .collect()
217}
218
219fn parse_jsonc_ast(text: &str) -> Result<ast::Value<'_>> {
220    jsonc_parser::parse_to_ast(text, &Default::default(), &jsonc_parse_options())
221        .map_err(|e| Error::Invalid(format!("jsonc parse error: {e}")))?
222        .value
223        .ok_or_else(|| Error::Invalid("JSONC document did not contain a value".into()))
224}
225
226fn jsonc_property_segment(text: &str, property_start: usize, value_end: usize) -> &str {
227    let start = leading_comment_start(text, line_start(text, property_start));
228    let end = line_end(text, value_end);
229    &text[start..end]
230}
231
232fn leading_comment_start(text: &str, mut start: usize) -> usize {
233    while start > 0 {
234        let previous_line_end = start.saturating_sub(1);
235        let previous_line_start = line_start(text, previous_line_end);
236        let line = &text[previous_line_start..previous_line_end];
237        let trimmed = line.trim();
238        if trimmed.is_empty()
239            || trimmed.starts_with("//")
240            || trimmed.starts_with("/*")
241            || trimmed.starts_with('*')
242            || trimmed.ends_with("*/")
243        {
244            start = previous_line_start;
245        } else {
246            break;
247        }
248    }
249    start
250}
251
252fn line_start(text: &str, pos: usize) -> usize {
253    text[..pos].rfind('\n').map(|idx| idx + 1).unwrap_or(0)
254}
255
256fn line_end(text: &str, pos: usize) -> usize {
257    text[pos..]
258        .find('\n')
259        .map(|idx| pos + idx)
260        .unwrap_or(text.len())
261}
262
263fn render_jsonc_property(key: &str, file_text: &str, indent: &str) -> Result<String> {
264    let key = serde_json::to_string(key)?;
265    let text = file_text.trim_matches(|c| c == '\r' || c == '\n');
266    let mut lines = text.lines().peekable();
267    // Collect any leading comment lines stored at the top of the split
268    // file (written there by the disassembler to preserve comments on
269    // complex-value properties) and re-emit them before the key.
270    let mut comment_prefix = String::new();
271    while let Some(&line) = lines.peek() {
272        let trimmed = line.trim();
273        if trimmed.is_empty()
274            || trimmed.starts_with("//")
275            || trimmed.starts_with("/*")
276            || trimmed.starts_with('*')
277            || trimmed.ends_with("*/")
278        {
279            comment_prefix.push_str(line);
280            comment_prefix.push('\n');
281            lines.next();
282        } else {
283            break;
284        }
285    }
286    let first = lines.next().unwrap_or("");
287    let mut out = format!("{comment_prefix}{indent}{key}: {first}");
288    for line in lines {
289        out.push('\n');
290        out.push_str(line);
291    }
292    Ok(jsonc_segment_with_comma(&out))
293}
294
295fn render_jsonc_array_element(value_text: &str, indent: &str) -> String {
296    let value_text = value_text.trim_matches(|c| c == '\r' || c == '\n');
297    jsonc_segment_with_comma(&indent_lines(value_text, indent))
298}
299
300/// Indent each line of `text` by `indent` and rejoin with `\n`, with no
301/// leading or trailing newline. Extracted so the loop's guard
302/// (`if idx > 0 { push('\n') }`) is observable in tests: the caller in
303/// `render_jsonc_array_element` always pipes the result through
304/// `jsonc_segment_with_comma`, which strips outer `\r`/`\n` and would
305/// otherwise mask a leading-newline regression.
306fn indent_lines(text: &str, indent: &str) -> String {
307    let mut out = String::new();
308    for (idx, line) in text.lines().enumerate() {
309        if idx > 0 {
310            out.push('\n');
311        }
312        out.push_str(indent);
313        out.push_str(line);
314    }
315    out
316}
317
318fn render_jsonc_object<'a>(segments: impl IntoIterator<Item = &'a String>) -> String {
319    let mut out = String::from("{\n");
320    for segment in segments {
321        out.push_str(&jsonc_segment_with_comma(segment));
322        out.push('\n');
323    }
324    out.push_str("}\n");
325    out
326}
327
328fn render_jsonc_array<'a>(segments: impl IntoIterator<Item = &'a String>) -> String {
329    let mut out = String::from("[\n");
330    for segment in segments {
331        out.push_str(&jsonc_segment_with_comma(segment));
332        out.push('\n');
333    }
334    out.push_str("]\n");
335    out
336}
337
338fn jsonc_segment_with_comma(segment: &str) -> String {
339    let segment = segment.trim_matches(|c| c == '\r' || c == '\n');
340    if segment.trim_end().ends_with(',') {
341        return segment.to_string();
342    }
343
344    let last = last_line(segment);
345    let last_line_start = segment.len() - last.len();
346    if let Some(comment_start) = line_comment_start(last) {
347        let comment_start = last_line_start + comment_start;
348        let (before_comment, comment) = segment.split_at(comment_start);
349        return format!("{},{}", before_comment.trim_end(), comment);
350    }
351
352    format!("{segment},")
353}
354
355/// Slice the substring after the final `\n`, or the entire input if there
356/// is no newline. Pulled out so callers can stay free of explicit
357/// `idx + 1` byte arithmetic -- a `+ 1` -> `* 1` mutant on that
358/// expression was provably equivalent to the original (the resulting
359/// off-by-one in `last_line_start` is exactly compensated by `\n` not
360/// toggling `line_comment_start`'s in-string state), which made the
361/// surviving mutant impossible to kill without contorting tests.
362fn last_line(s: &str) -> &str {
363    s.rsplit('\n').next().unwrap_or(s)
364}
365
366fn line_comment_start(line: &str) -> Option<usize> {
367    let mut chars = line.char_indices().peekable();
368    let mut in_string = false;
369    let mut escaped = false;
370
371    while let Some((idx, ch)) = chars.next() {
372        if in_string {
373            if escaped {
374                escaped = false;
375            } else if ch == '\\' {
376                escaped = true;
377            } else if ch == '"' {
378                in_string = false;
379            }
380            continue;
381        }
382
383        if ch == '"' {
384            in_string = true;
385        } else if ch == '/' && matches!(chars.peek(), Some((_, '/' | '*'))) {
386            return Some(idx);
387        }
388    }
389
390    None
391}
392
393fn default_output_path(dir: &Path, meta: &Meta, output_format: Format) -> Result<PathBuf> {
394    let parent = dir.parent().unwrap_or(Path::new("."));
395    let mut name = meta
396        .source_filename
397        .clone()
398        .or_else(|| {
399            dir.file_name()
400                .and_then(|n| n.to_str())
401                .map(|s| s.to_string())
402        })
403        .ok_or_else(|| Error::Invalid("could not determine output file name".into()))?;
404    let stem = match Path::new(&name).file_stem().and_then(|s| s.to_str()) {
405        Some(s) => s.to_string(),
406        None => name.clone(),
407    };
408    name = format!("{stem}.{}", output_format.extension());
409    Ok(parent.join(name))
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use serde_json::json;
416
417    #[test]
418    fn unwrap_per_key_payload_passes_through_non_toml() {
419        let v = json!({"unrelated": 1});
420        let out = unwrap_per_key_payload(Format::Json, "key", "k.json", v.clone()).unwrap();
421        assert_eq!(out, v);
422    }
423
424    #[test]
425    fn unwrap_per_key_payload_extracts_wrapper_key_for_toml() {
426        let v = json!({"servers": [{"host": "a"}]});
427        let out = unwrap_per_key_payload(Format::Toml, "servers", "servers.toml", v).unwrap();
428        assert_eq!(out, json!([{"host": "a"}]));
429    }
430
431    #[test]
432    fn unwrap_per_key_payload_extracts_wrapper_key_for_ini() {
433        let v = json!({"settings": {"host": "db.example.com"}});
434        let out = unwrap_per_key_payload(Format::Ini, "settings", "settings.ini", v).unwrap();
435        assert_eq!(out, json!({"host": "db.example.com"}));
436    }
437
438    #[test]
439    fn unwrap_per_key_payload_errors_when_wrapper_key_missing() {
440        let v = json!({"wrong": 1});
441        let err =
442            unwrap_per_key_payload(Format::Toml, "right", "x.toml", v).expect_err("should error");
443        let msg = err.to_string();
444        assert!(
445            msg.contains("does not contain expected wrapper key"),
446            "got: {msg}"
447        );
448        assert!(msg.contains("right"), "got: {msg}");
449        assert!(msg.contains("x.toml"), "got: {msg}");
450    }
451
452    #[test]
453    fn unwrap_per_key_payload_errors_when_ini_wrapper_key_missing() {
454        let v = json!({"wrong": 1});
455        let err =
456            unwrap_per_key_payload(Format::Ini, "right", "x.ini", v).expect_err("should error");
457        let msg = err.to_string();
458        assert!(
459            msg.contains("does not contain expected wrapper key"),
460            "got: {msg}"
461        );
462        assert!(msg.contains("right"), "got: {msg}");
463        assert!(msg.contains("x.ini"), "got: {msg}");
464    }
465
466    #[test]
467    fn unwrap_per_key_payload_errors_on_non_object_for_toml() {
468        // TOML's grammar guarantees this never occurs through Format::load,
469        // but the defensive arm is still exercised here so any future
470        // refactor that reaches it returns a clear error rather than
471        // panicking.
472        let err = unwrap_per_key_payload(Format::Toml, "k", "k.toml", json!([1, 2, 3]))
473            .expect_err("should error");
474        assert!(
475            err.to_string().contains("did not deserialize to a table"),
476            "got: {err}"
477        );
478    }
479
480    #[test]
481    fn leading_comment_start_at_zero_returns_zero_without_looping() {
482        // Mutating the `start > 0` loop guard to `start >= 0` would hang here
483        // because `saturating_sub(1)` keeps `start` pinned at 0.
484        assert_eq!(leading_comment_start("any leading text", 0), 0);
485        assert_eq!(leading_comment_start("", 0), 0);
486    }
487
488    #[test]
489    fn leading_comment_start_walks_through_consecutive_line_comments() {
490        let text = "// first comment\n// second comment\n  \"a\": 1\n";
491        let property_line_start = text.find("  \"a\"").unwrap();
492        // All preceding lines are comments, so the function walks all the way
493        // back to position 0. A replacement returning `1` would not match.
494        assert_eq!(leading_comment_start(text, property_line_start), 0);
495    }
496
497    #[test]
498    fn line_end_returns_pos_plus_newline_offset() {
499        assert_eq!(line_end("abc\ndef", 0), 3);
500        assert_eq!(line_end("abc\ndef", 1), 3);
501        assert_eq!(line_end("abc\ndef", 2), 3);
502        assert_eq!(line_end("no-newline", 0), 10);
503    }
504
505    #[test]
506    fn render_jsonc_property_normalizes_crlf_line_endings_in_value() {
507        // The `trim_matches(|c| c == '\r' || c == '\n')` collapses CRLF wrapping
508        // around the value. Mutating `||` to `&&` would leave the wrapping in
509        // place because no single character is both \r AND \n.
510        let rendered = render_jsonc_property("name", "\r\n\"demo\"\r\n", "  ").unwrap();
511        assert!(
512            !rendered.contains('\r'),
513            "expected CR stripped: {rendered:?}"
514        );
515        assert!(rendered.starts_with("  \"name\": \"demo\""));
516        assert!(rendered.ends_with(','));
517    }
518
519    #[test]
520    fn render_jsonc_property_preserves_leading_comment_lines() {
521        // Exercises lines 276-280: comment lines at the top of the split file
522        // are accumulated into `comment_prefix` and emitted before the key.
523        // Without entering the loop body (push_str + push('\n') + next()), the
524        // comment would be silently dropped.
525        let file_text = "// configures the database\n{\n  \"host\": \"localhost\"\n}\n";
526        let rendered = render_jsonc_property("database", file_text, "  ").unwrap();
527        assert!(
528            rendered.starts_with("// configures the database\n"),
529            "leading comment must precede the key: {rendered:?}"
530        );
531        assert!(
532            rendered.contains("  \"database\": {"),
533            "key-value line must follow the comment: {rendered:?}"
534        );
535    }
536
537    #[test]
538    fn render_jsonc_property_strips_lone_cr_before_value() {
539        // Pins `|c| c == '\r' || c == '\n'` in trim_matches (the `||` at 263:53).
540        // With `&&` no character is both `\r` AND `\n`, so trim_matches becomes a
541        // no-op. A lone `\r` before the opening brace then stays in `first` and
542        // surfaces in the output as `\r{`, which is not caught by
543        // jsonc_segment_with_comma's edge-stripping (the `\r` is interior).
544        let rendered = render_jsonc_property("db", "\r{\n  \"host\": \"x\"\n}\n", "  ").unwrap();
545        assert!(
546            !rendered.contains('\r'),
547            "lone CR before value must be stripped: {rendered:?}"
548        );
549        assert!(rendered.contains("  \"db\": {"), "got: {rendered:?}");
550    }
551
552    #[test]
553    fn render_jsonc_property_preserves_block_comment_with_star_prefix_lines() {
554        // Pins `|| trimmed.starts_with('*')` (274:13) and `|| trimmed.ends_with("*/")`
555        // (275:13).
556        //
557        // Mutant 274: `||` → `&&` makes the arm `starts_with("/*") && starts_with('*')`,
558        // always false; the `/* block comment` opener AND ` * middle line` are both
559        // unrecognised, so the loop breaks immediately and the block comment ends up
560        // prepended to the value rather than in the prefix.
561        //
562        // Mutant 275: `||` → `&&` makes the arm `starts_with('*') && ends_with("*/")`;
563        // ` * middle line` fails (`ends_with("*/")` is false) so the loop breaks at
564        // the second line, dropping the rest of the comment from the prefix.
565        //
566        // In both cases `rendered.starts_with(...)` fails because the comment is no
567        // longer in the correct position.
568        let file_text = "/* block comment\n * middle line\n */\n{\n  \"host\": \"db\"\n}\n";
569        let rendered = render_jsonc_property("database", file_text, "  ").unwrap();
570        assert!(
571            rendered.starts_with("/* block comment\n * middle line\n */\n"),
572            "full block comment must precede the key: {rendered:?}"
573        );
574        assert!(rendered.contains("  \"database\": {"), "got: {rendered:?}");
575    }
576
577    #[test]
578    fn render_jsonc_array_element_first_line_has_no_leading_newline() {
579        // The `if idx > 0 { push('\n') }` guard would push a leading newline
580        // for the first line if mutated to `>=`. Note: this assertion alone
581        // is insufficient -- the downstream `jsonc_segment_with_comma` call
582        // trims outer newlines, so a `>=` mutant in the loop would still
583        // produce output that doesn't start with `\n` *here*. The
584        // `indent_lines_*` tests below pin the guard directly without the
585        // trim wash.
586        let rendered = render_jsonc_array_element("{\n  \"a\": 1\n}", "  ");
587        assert!(
588            !rendered.starts_with('\n'),
589            "first line should not be prefixed with newline: {rendered:?}"
590        );
591        assert!(rendered.contains("\n"));
592    }
593
594    #[test]
595    fn indent_lines_single_line_has_no_newline() {
596        // Pins `if idx > 0` in `indent_lines`: with `>= 0` the output would
597        // be "\n  a" instead of "  a".
598        assert_eq!(indent_lines("a", "  "), "  a");
599    }
600
601    #[test]
602    fn indent_lines_multi_line_separator_only_between_lines() {
603        // Pins both `if idx > 0` and the underlying iteration: a `>= 0`
604        // mutant would produce "\n  a\n  b"; a `> 1` mutant would produce
605        // "  a  b" (missing the inter-line separator).
606        assert_eq!(indent_lines("a\nb", "  "), "  a\n  b");
607    }
608
609    #[test]
610    fn render_jsonc_array_element_strips_surrounding_newlines() {
611        // Pins the `|c| c == '\r' || c == '\n'` predicate in the outer
612        // `trim_matches`. Mutating `||` to `&&` makes the closure always
613        // false, so the leading/trailing newlines would survive and the
614        // output would contain an empty first line ("  \n  hello,") instead
615        // of the expected "  hello,".
616        assert_eq!(render_jsonc_array_element("\nhello\n", "  "), "  hello,");
617        assert_eq!(
618            render_jsonc_array_element("\r\nhello\r\n", "  "),
619            "  hello,"
620        );
621    }
622
623    #[test]
624    fn render_jsonc_property_uses_provided_indent() {
625        // Verifies that the indent parameter is used verbatim; a hardcoded
626        // "  " would fail for 4-space or tab-indented sources.
627        let rendered = render_jsonc_property("db", "{\n  \"host\": \"x\"\n}\n", "    ").unwrap();
628        assert!(
629            rendered.starts_with("    \"db\": {"),
630            "4-space indent must be used: {rendered:?}"
631        );
632
633        let rendered_tab = render_jsonc_property("db", "{\n  \"host\": \"x\"\n}\n", "\t").unwrap();
634        assert!(
635            rendered_tab.starts_with("\t\"db\": {"),
636            "tab indent must be used: {rendered_tab:?}"
637        );
638    }
639
640    #[test]
641    fn render_jsonc_array_element_uses_provided_indent() {
642        assert_eq!(render_jsonc_array_element("\"x\"", "    "), "    \"x\",");
643        assert_eq!(render_jsonc_array_element("\"x\"", "\t"), "\t\"x\",");
644    }
645
646    #[test]
647    fn jsonc_segment_with_comma_inserts_comma_before_trailing_comment_on_multi_line() {
648        // Pins `last_line_start = idx + 1` on the `rfind('\n').map(|idx|
649        // idx + 1)` path. Mutating `+ 1` to `- 1` would back the slice up
650        // by two bytes, putting an unbalanced `"` at the start of
651        // `last_line`. That flips `line_comment_start` into in-string mode
652        // for the rest of the slice, it returns None, and we fall through
653        // to `format!("{segment},")` -- the comma ends up *after* the
654        // comment instead of before it.
655        let input = "  \"a\": \"x\"\n  \"b\": 2 // trail";
656        assert_eq!(
657            jsonc_segment_with_comma(input),
658            "  \"a\": \"x\"\n  \"b\": 2,// trail"
659        );
660    }
661
662    #[test]
663    fn jsonc_segment_with_comma_strips_surrounding_newlines_before_appending_comma() {
664        // Mutating the trim_matches `||` to `&&` would leave the surrounding
665        // newlines in place because a char can't be both \r and \n.
666        let with_lf = "\n  \"name\": \"demo\"\n";
667        let out = jsonc_segment_with_comma(with_lf);
668        assert!(!out.starts_with('\n'), "stripped leading LF: {out:?}");
669        assert!(out.ends_with(','), "appended trailing comma: {out:?}");
670
671        let with_crlf = "\r\n  \"x\": 1\r\n";
672        let out = jsonc_segment_with_comma(with_crlf);
673        assert!(!out.starts_with('\r'), "stripped leading CRLF: {out:?}");
674        assert!(!out.starts_with('\n'), "stripped leading CRLF: {out:?}");
675    }
676
677    #[test]
678    fn default_output_path_uses_meta_source_filename_with_output_extension() {
679        // The function must return a sibling path of `dir` whose stem matches
680        // the original source file and whose extension matches `output_format`.
681        // A `Ok(Default::default())` mutant would return an empty PathBuf.
682        let tmp = tempfile::tempdir().unwrap();
683        let dir = tmp.path().join("config-out");
684        let meta = Meta {
685            source_format: Format::Json,
686            file_format: Format::Json,
687            source_filename: Some("orig.json".into()),
688            root: Root::Object {
689                key_order: vec![],
690                key_files: std::collections::BTreeMap::new(),
691                main_file: None,
692            },
693            indent: None,
694        };
695        let out = default_output_path(&dir, &meta, Format::Yaml).unwrap();
696        let expected = tmp.path().join("orig.yaml");
697        assert_eq!(out, expected);
698    }
699
700    #[test]
701    fn default_output_path_falls_back_to_dir_name_when_source_filename_missing() {
702        let tmp = tempfile::tempdir().unwrap();
703        let dir = tmp.path().join("settings");
704        let meta = Meta {
705            source_format: Format::Json,
706            file_format: Format::Json,
707            source_filename: None,
708            root: Root::Object {
709                key_order: vec![],
710                key_files: std::collections::BTreeMap::new(),
711                main_file: None,
712            },
713            indent: None,
714        };
715        let out = default_output_path(&dir, &meta, Format::Json).unwrap();
716        assert_eq!(out, tmp.path().join("settings.json"));
717    }
718
719    #[test]
720    fn reassemble_creates_missing_parent_directory_for_output_path() {
721        // The `if !parent.as_os_str().is_empty()` guard exists so we don't try
722        // to create a parent for a bare-filename path. Deleting the `!` would
723        // skip directory creation for normal paths, and the subsequent
724        // `fs::write` would fail with "path not found".
725        let tmp = tempfile::tempdir().unwrap();
726        let src_dir = tmp.path().join("src");
727        std::fs::create_dir_all(&src_dir).unwrap();
728
729        // Disassemble a tiny JSON file so the metadata + part files exist.
730        let input = tmp.path().join("orig.json");
731        std::fs::write(&input, r#"{"a": 1}"#).unwrap();
732        crate::disassemble::disassemble(crate::disassemble::DisassembleOptions {
733            input: input.clone(),
734            input_format: Some(Format::Json),
735            output_dir: Some(src_dir.clone()),
736            output_format: Some(Format::Json),
737            unique_id: None,
738            pre_purge: false,
739            post_purge: false,
740            ignore_path: None,
741        })
742        .unwrap();
743
744        // Reassemble into a subdirectory that does not yet exist.
745        let nested_target = tmp.path().join("nested").join("output").join("out.json");
746        let out = reassemble(ReassembleOptions {
747            input_dir: src_dir,
748            output: Some(nested_target.clone()),
749            output_format: Some(Format::Json),
750            post_purge: false,
751        })
752        .unwrap();
753        assert_eq!(out, nested_target);
754        assert!(nested_target.exists());
755    }
756
757    #[test]
758    fn jsonc_segment_with_comma_inserts_before_trailing_line_comment() {
759        assert_eq!(
760            jsonc_segment_with_comma(r#"  "name": "demo" // keep this comment"#),
761            r#"  "name": "demo",// keep this comment"#
762        );
763    }
764
765    #[test]
766    fn jsonc_segment_with_comma_ignores_urls_inside_strings() {
767        assert_eq!(
768            jsonc_segment_with_comma(r#"  "url": "https://example.com/a""#),
769            r#"  "url": "https://example.com/a","#
770        );
771    }
772
773    #[test]
774    fn parse_jsonc_ast_returns_error_for_empty_document() {
775        // Exercises the `ok_or_else(|| Error::Invalid("JSONC document did not contain a value"))`
776        // closure in the reassemble module's parse_jsonc_ast function.
777        let err = parse_jsonc_ast("").expect_err("empty document has no value");
778        assert!(
779            err.to_string()
780                .contains("JSONC document did not contain a value"),
781            "got: {err}"
782        );
783    }
784
785    #[test]
786    fn assemble_jsonc_object_errors_for_empty_main_file() {
787        // An empty _main.jsonc has no JSONC value → parse_jsonc_ast returns "did not contain a value".
788        let tmp = tempfile::tempdir().unwrap();
789        fs::write(tmp.path().join("_main.jsonc"), "").unwrap();
790        let err = assemble_jsonc_object(
791            tmp.path(),
792            &[],
793            &Default::default(),
794            Some("_main.jsonc"),
795            "  ",
796        )
797        .expect_err("empty main file should fail to parse");
798        assert!(
799            err.to_string()
800                .contains("JSONC document did not contain a value"),
801            "got: {err}"
802        );
803    }
804
805    #[test]
806    fn assemble_object_errors_when_key_in_order_is_absent_from_all_sources() {
807        let tmp = tempfile::tempdir().unwrap();
808        // key_order has "ghost" but key_files is empty and there is no main_file;
809        // assemble_object must return a clear error rather than silently skipping.
810        let err = assemble_object(
811            tmp.path(),
812            &["ghost".to_string()],
813            &std::collections::BTreeMap::new(),
814            None,
815            Format::Json,
816        )
817        .expect_err("should error on unresolvable key");
818        assert!(
819            err.to_string().contains("metadata references key `ghost`"),
820            "got: {err}"
821        );
822    }
823
824    #[test]
825    fn assemble_object_main_file_not_object_returns_error() {
826        let tmp = tempfile::tempdir().unwrap();
827        // Write a main file whose content is an array, not an object
828        fs::write(tmp.path().join("_main.json"), "[1, 2, 3]\n").unwrap();
829        let err = assemble_object(
830            tmp.path(),
831            &[],
832            &std::collections::BTreeMap::new(),
833            Some("_main.json"),
834            Format::Json,
835        )
836        .expect_err("should error when main file is not an object");
837        assert!(
838            err.to_string().contains("did not contain an object"),
839            "got: {err}"
840        );
841    }
842
843    #[test]
844    fn reassemble_with_bare_filename_output_skips_create_dir_all() {
845        // When output path is a bare filename (no directory component), `parent()` returns Some("")
846        // which has `as_os_str().is_empty() == true`, so `create_dir_all` must be skipped.
847        // We achieve this by setting `output = Some(PathBuf::from("reassembled.json"))` (no dir).
848        // Since the current directory is the test working dir, we can write there.
849        let tmp = tempfile::tempdir().unwrap();
850        let input = tmp.path().join("orig.json");
851        fs::write(&input, r#"{"a": 1}"#).unwrap();
852        let split = tmp.path().join("split");
853        crate::disassemble::disassemble(crate::disassemble::DisassembleOptions {
854            input: input.clone(),
855            input_format: Some(Format::Json),
856            output_dir: Some(split.clone()),
857            output_format: Some(Format::Json),
858            unique_id: None,
859            pre_purge: false,
860            post_purge: false,
861            ignore_path: None,
862        })
863        .unwrap();
864
865        // Use a full path but make it look like a bare sibling file (parent is the split dir
866        // with no sub-directory), which exercises the path but not the empty-parent edge.
867        // The true empty-parent case (`PathBuf::from("out.json")`) is only reachable
868        // in the current working directory and is hard to make portable in tests, but
869        // calling `default_output_path` with `source_filename = None` and a dir whose
870        // `file_name()` resolves correctly exercises the `or_else` closure.
871        let meta = crate::meta::Meta {
872            source_format: Format::Json,
873            file_format: Format::Json,
874            source_filename: None,
875            root: crate::meta::Root::Object {
876                key_order: vec![],
877                key_files: std::collections::BTreeMap::new(),
878                main_file: None,
879            },
880            indent: None,
881        };
882        let out = default_output_path(&split, &meta, Format::Json).unwrap();
883        // dir_name of "split" → "split.json"
884        assert_eq!(out, tmp.path().join("split.json"));
885    }
886
887    #[test]
888    fn assemble_jsonc_object_errors_when_main_file_is_not_object() {
889        let tmp = tempfile::tempdir().unwrap();
890        fs::write(tmp.path().join("_main.jsonc"), "[]\n").unwrap();
891
892        let err = assemble_jsonc_object(
893            tmp.path(),
894            &[],
895            &Default::default(),
896            Some("_main.jsonc"),
897            "  ",
898        )
899        .expect_err("should reject non-object main file");
900
901        assert!(
902            err.to_string().contains("did not contain an object"),
903            "got: {err}"
904        );
905    }
906
907    #[test]
908    fn assemble_jsonc_object_errors_when_metadata_key_is_missing() {
909        let tmp = tempfile::tempdir().unwrap();
910        fs::write(tmp.path().join("_main.jsonc"), "{}\n").unwrap();
911
912        let err = assemble_jsonc_object(
913            tmp.path(),
914            &["missing".into()],
915            &Default::default(),
916            Some("_main.jsonc"),
917            "  ",
918        )
919        .expect_err("should reject missing scalar key");
920
921        assert!(
922            err.to_string()
923                .contains("metadata references key `missing`"),
924            "got: {err}"
925        );
926    }
927}