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 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 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
300fn 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
355fn 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 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 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 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 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 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 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 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 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 assert_eq!(indent_lines("a", " "), " a");
599 }
600
601 #[test]
602 fn indent_lines_multi_line_separator_only_between_lines() {
603 assert_eq!(indent_lines("a\nb", " "), " a\n b");
607 }
608
609 #[test]
610 fn render_jsonc_array_element_strips_surrounding_newlines() {
611 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 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 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 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 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 let tmp = tempfile::tempdir().unwrap();
726 let src_dir = tmp.path().join("src");
727 std::fs::create_dir_all(&src_dir).unwrap();
728
729 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 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 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 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 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 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 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 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 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}