1use 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#[derive(Debug, Clone)]
19pub struct ReassembleOptions {
20 pub input_dir: PathBuf,
22 pub output: Option<PathBuf>,
26 pub output_format: Option<Format>,
29 pub post_purge: bool,
31}
32
33pub 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 jsonc_segment_with_comma(&indent_lines(value_text))
277}
278
279fn indent_lines(text: &str) -> String {
286 let mut out = String::new();
287 for (idx, line) in text.lines().enumerate() {
288 if idx > 0 {
289 out.push('\n');
290 }
291 out.push_str(" ");
292 out.push_str(line);
293 }
294 out
295}
296
297fn render_jsonc_object<'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 render_jsonc_array<'a>(segments: impl IntoIterator<Item = &'a String>) -> String {
308 let mut out = String::from("[\n");
309 for segment in segments {
310 out.push_str(&jsonc_segment_with_comma(segment));
311 out.push('\n');
312 }
313 out.push_str("]\n");
314 out
315}
316
317fn jsonc_segment_with_comma(segment: &str) -> String {
318 let segment = segment.trim_matches(|c| c == '\r' || c == '\n');
319 if segment.trim_end().ends_with(',') {
320 return segment.to_string();
321 }
322
323 let last = last_line(segment);
324 let last_line_start = segment.len() - last.len();
325 if let Some(comment_start) = line_comment_start(last) {
326 let comment_start = last_line_start + comment_start;
327 let (before_comment, comment) = segment.split_at(comment_start);
328 return format!("{},{}", before_comment.trim_end(), comment);
329 }
330
331 format!("{segment},")
332}
333
334fn last_line(s: &str) -> &str {
342 s.rsplit('\n').next().unwrap_or(s)
343}
344
345fn line_comment_start(line: &str) -> Option<usize> {
346 let mut chars = line.char_indices().peekable();
347 let mut in_string = false;
348 let mut escaped = false;
349
350 while let Some((idx, ch)) = chars.next() {
351 if in_string {
352 if escaped {
353 escaped = false;
354 } else if ch == '\\' {
355 escaped = true;
356 } else if ch == '"' {
357 in_string = false;
358 }
359 continue;
360 }
361
362 if ch == '"' {
363 in_string = true;
364 } else if ch == '/' && matches!(chars.peek(), Some((_, '/'))) {
365 return Some(idx);
366 }
367 }
368
369 None
370}
371
372fn default_output_path(dir: &Path, meta: &Meta, output_format: Format) -> Result<PathBuf> {
373 let parent = dir.parent().unwrap_or(Path::new("."));
374 let mut name = meta
375 .source_filename
376 .clone()
377 .or_else(|| {
378 dir.file_name()
379 .and_then(|n| n.to_str())
380 .map(|s| s.to_string())
381 })
382 .ok_or_else(|| Error::Invalid("could not determine output file name".into()))?;
383 let stem = match Path::new(&name).file_stem().and_then(|s| s.to_str()) {
384 Some(s) => s.to_string(),
385 None => name.clone(),
386 };
387 name = format!("{stem}.{}", output_format.extension());
388 Ok(parent.join(name))
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use serde_json::json;
395
396 #[test]
397 fn unwrap_per_key_payload_passes_through_non_toml() {
398 let v = json!({"unrelated": 1});
399 let out = unwrap_per_key_payload(Format::Json, "key", "k.json", v.clone()).unwrap();
400 assert_eq!(out, v);
401 }
402
403 #[test]
404 fn unwrap_per_key_payload_extracts_wrapper_key_for_toml() {
405 let v = json!({"servers": [{"host": "a"}]});
406 let out = unwrap_per_key_payload(Format::Toml, "servers", "servers.toml", v).unwrap();
407 assert_eq!(out, json!([{"host": "a"}]));
408 }
409
410 #[test]
411 fn unwrap_per_key_payload_extracts_wrapper_key_for_ini() {
412 let v = json!({"settings": {"host": "db.example.com"}});
413 let out = unwrap_per_key_payload(Format::Ini, "settings", "settings.ini", v).unwrap();
414 assert_eq!(out, json!({"host": "db.example.com"}));
415 }
416
417 #[test]
418 fn unwrap_per_key_payload_errors_when_wrapper_key_missing() {
419 let v = json!({"wrong": 1});
420 let err =
421 unwrap_per_key_payload(Format::Toml, "right", "x.toml", v).expect_err("should error");
422 let msg = err.to_string();
423 assert!(
424 msg.contains("does not contain expected wrapper key"),
425 "got: {msg}"
426 );
427 assert!(msg.contains("right"), "got: {msg}");
428 assert!(msg.contains("x.toml"), "got: {msg}");
429 }
430
431 #[test]
432 fn unwrap_per_key_payload_errors_when_ini_wrapper_key_missing() {
433 let v = json!({"wrong": 1});
434 let err =
435 unwrap_per_key_payload(Format::Ini, "right", "x.ini", v).expect_err("should error");
436 let msg = err.to_string();
437 assert!(
438 msg.contains("does not contain expected wrapper key"),
439 "got: {msg}"
440 );
441 assert!(msg.contains("right"), "got: {msg}");
442 assert!(msg.contains("x.ini"), "got: {msg}");
443 }
444
445 #[test]
446 fn unwrap_per_key_payload_errors_on_non_object_for_toml() {
447 let err = unwrap_per_key_payload(Format::Toml, "k", "k.toml", json!([1, 2, 3]))
452 .expect_err("should error");
453 assert!(
454 err.to_string().contains("did not deserialize to a table"),
455 "got: {err}"
456 );
457 }
458
459 #[test]
460 fn leading_comment_start_at_zero_returns_zero_without_looping() {
461 assert_eq!(leading_comment_start("any leading text", 0), 0);
464 assert_eq!(leading_comment_start("", 0), 0);
465 }
466
467 #[test]
468 fn leading_comment_start_walks_through_consecutive_line_comments() {
469 let text = "// first comment\n// second comment\n \"a\": 1\n";
470 let property_line_start = text.find(" \"a\"").unwrap();
471 assert_eq!(leading_comment_start(text, property_line_start), 0);
474 }
475
476 #[test]
477 fn line_end_returns_pos_plus_newline_offset() {
478 assert_eq!(line_end("abc\ndef", 0), 3);
479 assert_eq!(line_end("abc\ndef", 1), 3);
480 assert_eq!(line_end("abc\ndef", 2), 3);
481 assert_eq!(line_end("no-newline", 0), 10);
482 }
483
484 #[test]
485 fn render_jsonc_property_normalizes_crlf_line_endings_in_value() {
486 let rendered = render_jsonc_property("name", "\r\n\"demo\"\r\n").unwrap();
490 assert!(
491 !rendered.contains('\r'),
492 "expected CR stripped: {rendered:?}"
493 );
494 assert!(rendered.starts_with(" \"name\": \"demo\""));
495 assert!(rendered.ends_with(','));
496 }
497
498 #[test]
499 fn render_jsonc_array_element_first_line_has_no_leading_newline() {
500 let rendered = render_jsonc_array_element("{\n \"a\": 1\n}");
508 assert!(
509 !rendered.starts_with('\n'),
510 "first line should not be prefixed with newline: {rendered:?}"
511 );
512 assert!(rendered.contains("\n"));
513 }
514
515 #[test]
516 fn indent_lines_single_line_has_no_newline() {
517 assert_eq!(indent_lines("a"), " a");
520 }
521
522 #[test]
523 fn indent_lines_multi_line_separator_only_between_lines() {
524 assert_eq!(indent_lines("a\nb"), " a\n b");
528 }
529
530 #[test]
531 fn render_jsonc_array_element_strips_surrounding_newlines() {
532 assert_eq!(render_jsonc_array_element("\nhello\n"), " hello,");
538 assert_eq!(render_jsonc_array_element("\r\nhello\r\n"), " hello,");
539 }
540
541 #[test]
542 fn jsonc_segment_with_comma_inserts_comma_before_trailing_comment_on_multi_line() {
543 let input = " \"a\": \"x\"\n \"b\": 2 // trail";
551 assert_eq!(
552 jsonc_segment_with_comma(input),
553 " \"a\": \"x\"\n \"b\": 2,// trail"
554 );
555 }
556
557 #[test]
558 fn jsonc_segment_with_comma_strips_surrounding_newlines_before_appending_comma() {
559 let with_lf = "\n \"name\": \"demo\"\n";
562 let out = jsonc_segment_with_comma(with_lf);
563 assert!(!out.starts_with('\n'), "stripped leading LF: {out:?}");
564 assert!(out.ends_with(','), "appended trailing comma: {out:?}");
565
566 let with_crlf = "\r\n \"x\": 1\r\n";
567 let out = jsonc_segment_with_comma(with_crlf);
568 assert!(!out.starts_with('\r'), "stripped leading CRLF: {out:?}");
569 assert!(!out.starts_with('\n'), "stripped leading CRLF: {out:?}");
570 }
571
572 #[test]
573 fn default_output_path_uses_meta_source_filename_with_output_extension() {
574 let tmp = tempfile::tempdir().unwrap();
578 let dir = tmp.path().join("config-out");
579 let meta = Meta {
580 source_format: Format::Json,
581 file_format: Format::Json,
582 source_filename: Some("orig.json".into()),
583 root: Root::Object {
584 key_order: vec![],
585 key_files: std::collections::BTreeMap::new(),
586 main_file: None,
587 },
588 };
589 let out = default_output_path(&dir, &meta, Format::Yaml).unwrap();
590 let expected = tmp.path().join("orig.yaml");
591 assert_eq!(out, expected);
592 }
593
594 #[test]
595 fn default_output_path_falls_back_to_dir_name_when_source_filename_missing() {
596 let tmp = tempfile::tempdir().unwrap();
597 let dir = tmp.path().join("settings");
598 let meta = Meta {
599 source_format: Format::Json,
600 file_format: Format::Json,
601 source_filename: None,
602 root: Root::Object {
603 key_order: vec![],
604 key_files: std::collections::BTreeMap::new(),
605 main_file: None,
606 },
607 };
608 let out = default_output_path(&dir, &meta, Format::Json).unwrap();
609 assert_eq!(out, tmp.path().join("settings.json"));
610 }
611
612 #[test]
613 fn reassemble_creates_missing_parent_directory_for_output_path() {
614 let tmp = tempfile::tempdir().unwrap();
619 let src_dir = tmp.path().join("src");
620 std::fs::create_dir_all(&src_dir).unwrap();
621
622 let input = tmp.path().join("orig.json");
624 std::fs::write(&input, r#"{"a": 1}"#).unwrap();
625 crate::disassemble::disassemble(crate::disassemble::DisassembleOptions {
626 input: input.clone(),
627 input_format: Some(Format::Json),
628 output_dir: Some(src_dir.clone()),
629 output_format: Some(Format::Json),
630 unique_id: None,
631 pre_purge: false,
632 post_purge: false,
633 ignore_path: None,
634 })
635 .unwrap();
636
637 let nested_target = tmp.path().join("nested").join("output").join("out.json");
639 let out = reassemble(ReassembleOptions {
640 input_dir: src_dir,
641 output: Some(nested_target.clone()),
642 output_format: Some(Format::Json),
643 post_purge: false,
644 })
645 .unwrap();
646 assert_eq!(out, nested_target);
647 assert!(nested_target.exists());
648 }
649
650 #[test]
651 fn jsonc_segment_with_comma_inserts_before_trailing_line_comment() {
652 assert_eq!(
653 jsonc_segment_with_comma(r#" "name": "demo" // keep this comment"#),
654 r#" "name": "demo",// keep this comment"#
655 );
656 }
657
658 #[test]
659 fn jsonc_segment_with_comma_ignores_urls_inside_strings() {
660 assert_eq!(
661 jsonc_segment_with_comma(r#" "url": "https://example.com/a""#),
662 r#" "url": "https://example.com/a","#
663 );
664 }
665
666 #[test]
667 fn assemble_jsonc_object_errors_when_main_file_is_not_object() {
668 let tmp = tempfile::tempdir().unwrap();
669 fs::write(tmp.path().join("_main.jsonc"), "[]\n").unwrap();
670
671 let err = assemble_jsonc_object(tmp.path(), &[], &Default::default(), Some("_main.jsonc"))
672 .expect_err("should reject non-object main file");
673
674 assert!(
675 err.to_string().contains("did not contain an object"),
676 "got: {err}"
677 );
678 }
679
680 #[test]
681 fn assemble_jsonc_object_errors_when_metadata_key_is_missing() {
682 let tmp = tempfile::tempdir().unwrap();
683 fs::write(tmp.path().join("_main.jsonc"), "{}\n").unwrap();
684
685 let err = assemble_jsonc_object(
686 tmp.path(),
687 &["missing".into()],
688 &Default::default(),
689 Some("_main.jsonc"),
690 )
691 .expect_err("should reject missing scalar key");
692
693 assert!(
694 err.to_string()
695 .contains("metadata references key `missing`"),
696 "got: {err}"
697 );
698 }
699}