1use std::fs;
9use std::path::Path;
10use std::str::FromStr;
11
12use configparser::ini::{Ini, WriteOptions};
13use serde::{Deserialize, Serialize};
14use serde_json::Map;
15use serde_json::Value;
16
17use crate::error::{Error, Result};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Format {
23 Json,
24 Json5,
25 Jsonc,
26 Yaml,
27 Toon,
28 Toml,
35 Ini,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum FormatFamily {
44 JsonValue,
45 Toml,
46 Ini,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ConversionOperation {
52 Convert,
53 Reassemble,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum SplitPayloadLayout {
58 Direct,
59 WrappedByParentKey,
60}
61
62struct FormatSpec {
63 canonical_name: &'static str,
64 display_name: &'static str,
65 aliases: &'static [&'static str],
66 extensions: &'static [&'static str],
67 family: FormatFamily,
68 split_payload_layout: SplitPayloadLayout,
69}
70
71impl Format {
72 pub const ALL: &'static [Format] = &[
74 Format::Json,
75 Format::Json5,
76 Format::Jsonc,
77 Format::Yaml,
78 Format::Toon,
79 Format::Toml,
80 Format::Ini,
81 ];
82
83 const JSON_VALUE_FAMILY: &'static [Format] = &[
84 Format::Json,
85 Format::Json5,
86 Format::Jsonc,
87 Format::Yaml,
88 Format::Toon,
89 ];
90 const TOML_FAMILY: &'static [Format] = &[Format::Toml];
91 const INI_FAMILY: &'static [Format] = &[Format::Ini];
92
93 fn spec(self) -> &'static FormatSpec {
94 match self {
95 Format::Json => &FormatSpec {
96 canonical_name: "json",
97 display_name: "JSON",
98 aliases: &["json"],
99 extensions: &["json"],
100 family: FormatFamily::JsonValue,
101 split_payload_layout: SplitPayloadLayout::Direct,
102 },
103 Format::Json5 => &FormatSpec {
104 canonical_name: "json5",
105 display_name: "JSON5",
106 aliases: &["json5"],
107 extensions: &["json5"],
108 family: FormatFamily::JsonValue,
109 split_payload_layout: SplitPayloadLayout::Direct,
110 },
111 Format::Jsonc => &FormatSpec {
112 canonical_name: "jsonc",
113 display_name: "JSONC",
114 aliases: &["jsonc"],
115 extensions: &["jsonc"],
116 family: FormatFamily::JsonValue,
117 split_payload_layout: SplitPayloadLayout::Direct,
118 },
119 Format::Yaml => &FormatSpec {
120 canonical_name: "yaml",
121 display_name: "YAML",
122 aliases: &["yaml", "yml"],
123 extensions: &["yaml", "yml"],
124 family: FormatFamily::JsonValue,
125 split_payload_layout: SplitPayloadLayout::Direct,
126 },
127 Format::Toon => &FormatSpec {
128 canonical_name: "toon",
129 display_name: "TOON",
130 aliases: &["toon"],
131 extensions: &["toon"],
132 family: FormatFamily::JsonValue,
133 split_payload_layout: SplitPayloadLayout::Direct,
134 },
135 Format::Toml => &FormatSpec {
136 canonical_name: "toml",
137 display_name: "TOML",
138 aliases: &["toml"],
139 extensions: &["toml"],
140 family: FormatFamily::Toml,
141 split_payload_layout: SplitPayloadLayout::WrappedByParentKey,
142 },
143 Format::Ini => &FormatSpec {
144 canonical_name: "ini",
145 display_name: "INI",
146 aliases: &["ini"],
147 extensions: &["ini"],
148 family: FormatFamily::Ini,
149 split_payload_layout: SplitPayloadLayout::WrappedByParentKey,
150 },
151 }
152 }
153
154 pub fn extension(self) -> &'static str {
156 self.spec().canonical_name
157 }
158
159 pub fn canonical_name(self) -> &'static str {
161 self.spec().canonical_name
162 }
163
164 pub fn display_name(self) -> &'static str {
166 self.spec().display_name
167 }
168
169 pub fn aliases(self) -> &'static [&'static str] {
171 self.spec().aliases
172 }
173
174 pub fn extensions(self) -> &'static [&'static str] {
176 self.spec().extensions
177 }
178
179 pub fn family(self) -> FormatFamily {
181 self.spec().family
182 }
183
184 pub fn compatible_formats(self) -> &'static [Format] {
186 match self.family() {
187 FormatFamily::JsonValue => Self::JSON_VALUE_FAMILY,
188 FormatFamily::Toml => Self::TOML_FAMILY,
189 FormatFamily::Ini => Self::INI_FAMILY,
190 }
191 }
192
193 pub fn allows_format_overrides(self) -> bool {
196 self.compatible_formats().len() > 1
197 }
198
199 pub fn is_cross_format_compatible(self) -> bool {
201 self.allows_format_overrides()
202 }
203
204 pub fn can_convert_to(self, output: Format) -> bool {
206 self.family() == output.family()
207 }
208
209 pub fn ensure_can_convert_to(
211 self,
212 output: Format,
213 operation: ConversionOperation,
214 ) -> Result<()> {
215 if self.can_convert_to(output) {
216 return Ok(());
217 }
218
219 let name = self
223 .family()
224 .isolated_format_name()
225 .or_else(|| output.family().isolated_format_name())
226 .expect("cross-family conversion must involve at least one isolated format");
227
228 match operation {
229 ConversionOperation::Convert => Err(Error::Invalid(format!(
230 "{name} can only be converted to and from {name}; got input={self}, output={output}"
231 ))),
232 ConversionOperation::Reassemble => Err(Error::Invalid(format!(
233 "{name} can only be reassembled to and from {name}; the disassembled \
234 directory was written in {self} but reassembly target is {output}"
235 ))),
236 }
237 }
238
239 pub fn from_path(path: &Path) -> Result<Self> {
241 let ext = path
242 .extension()
243 .and_then(|e| e.to_str())
244 .map(|e| e.to_ascii_lowercase());
245 if let Some(ext) = ext.as_deref() {
246 for format in Self::ALL {
247 if format.extensions().contains(&ext) {
248 return Ok(*format);
249 }
250 }
251 }
252 Err(Error::UnknownFormat(path.to_path_buf()))
253 }
254
255 pub fn parse(self, input: &str) -> Result<Value> {
257 match self {
258 Format::Json => Ok(serde_json::from_str(input)?),
259 Format::Json5 => Ok(json5::from_str(input)?),
260 Format::Jsonc => parse_jsonc(input),
261 Format::Yaml => Ok(serde_yaml::from_str(input)?),
262 Format::Toon => toon_format::decode_default(input)
263 .map_err(|e| Error::Invalid(format!("toon parse error: {e}"))),
264 Format::Toml => Ok(toml::from_str(input)?),
265 Format::Ini => parse_ini(input),
266 }
267 }
268
269 pub fn serialize(self, value: &Value) -> Result<String> {
272 let mut out = match self {
273 Format::Json => serde_json::to_string_pretty(value)?,
274 Format::Json5 => json5::to_string(value)?,
275 Format::Jsonc => serde_json::to_string_pretty(value)?,
278 Format::Yaml => serde_yaml::to_string(value)?,
279 Format::Toon => toon_format::encode_default(value)
280 .map_err(|e| Error::Invalid(format!("toon serialize error: {e}")))?,
281 Format::Toml => serialize_toml(value)?,
282 Format::Ini => serialize_ini(value)?,
283 };
284 if !out.ends_with('\n') {
285 out.push('\n');
286 }
287 Ok(out)
288 }
289
290 pub fn load(self, path: &Path) -> Result<Value> {
292 let text = fs::read_to_string(path)?;
293 self.parse(&text)
294 }
295
296 pub fn wrap_split_payload(self, key: &str, value: &Value) -> Value {
302 match self.spec().split_payload_layout {
303 SplitPayloadLayout::Direct => value.clone(),
304 SplitPayloadLayout::WrappedByParentKey => {
305 let mut wrapper = Map::new();
306 wrapper.insert(key.to_string(), value.clone());
307 Value::Object(wrapper)
308 }
309 }
310 }
311
312 pub fn unwrap_split_payload(self, key: &str, filename: &str, loaded: Value) -> Result<Value> {
314 match self.spec().split_payload_layout {
315 SplitPayloadLayout::Direct => Ok(loaded),
316 SplitPayloadLayout::WrappedByParentKey => {
317 let Value::Object(mut map) = loaded else {
318 return Err(Error::Invalid(format!(
319 "{} file `{filename}` did not deserialize to a table",
320 self.display_name()
321 )));
322 };
323 map.remove(key).ok_or_else(|| {
324 Error::Invalid(format!(
325 "{} file `{filename}` does not contain expected wrapper key `{key}`",
326 self.display_name()
327 ))
328 })
329 }
330 }
331 }
332
333 pub fn supported_format_list() -> String {
335 Self::ALL
336 .iter()
337 .map(|f| f.canonical_name())
338 .collect::<Vec<_>>()
339 .join(", ")
340 }
341}
342
343impl FromStr for Format {
344 type Err = Error;
345
346 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
347 let s = s.to_ascii_lowercase();
348 for format in Format::ALL {
349 if format.aliases().contains(&s.as_str()) {
350 return Ok(*format);
351 }
352 }
353 Err(Error::Usage(format!(
354 "unknown format `{s}`; expected {}",
355 Format::supported_format_list()
356 )))
357 }
358}
359
360impl FormatFamily {
361 fn isolated_format_name(self) -> Option<&'static str> {
362 match self {
363 FormatFamily::JsonValue => None,
364 FormatFamily::Toml => Some("TOML"),
365 FormatFamily::Ini => Some("INI"),
366 }
367 }
368}
369
370const INI_DEFAULT_SECTION: &str = "__config_disassembler_root__";
371
372fn serialize_toml(value: &Value) -> Result<String> {
378 if !matches!(value, Value::Object(_)) {
379 return Err(Error::Invalid(
380 "TOML documents must have a table (object) root; got an array or scalar".into(),
381 ));
382 }
383 if let Some(path) = find_null_path(value, "") {
384 return Err(Error::Invalid(format!(
385 "TOML cannot represent null values (found at `{}`)",
386 if path.is_empty() { "<root>" } else { &path }
387 )));
388 }
389 toml::to_string_pretty(value).map_err(|e| Error::Invalid(format!("toml serialize error: {e}")))
395}
396
397fn parse_ini(input: &str) -> Result<Value> {
403 let mut ini = new_ini();
404 let parsed = ini
405 .read(input.to_string())
406 .map_err(|e| Error::Invalid(format!("ini parse error: {e}")))?;
407 let mut root = Map::new();
408
409 for (section, values) in parsed {
410 if section == INI_DEFAULT_SECTION {
411 for (key, value) in values {
412 root.insert(key, ini_value_to_json(value));
413 }
414 continue;
415 }
416
417 let mut section_object = Map::new();
418 for (key, value) in values {
419 section_object.insert(key, ini_value_to_json(value));
420 }
421 root.insert(section, Value::Object(section_object));
422 }
423
424 Ok(Value::Object(root))
425}
426
427fn serialize_ini(value: &Value) -> Result<String> {
428 let Value::Object(map) = value else {
429 return Err(Error::Invalid(
430 "INI documents must have an object root; got an array or scalar".into(),
431 ));
432 };
433
434 let mut ini = new_ini();
435 for (key, value) in map {
436 match value {
437 Value::Object(section) => {
438 ini.get_mut_map().entry(key.clone()).or_default();
441 for (section_key, section_value) in section {
442 ini.set(
443 key,
444 section_key,
445 ini_scalar_value(section_value, &format!("{key}.{section_key}"))?,
446 );
447 }
448 }
449 _ => {
450 ini.set(
451 INI_DEFAULT_SECTION,
452 key,
453 ini_scalar_value(value, key.as_str())?,
454 );
455 }
456 }
457 }
458
459 Ok(ini.pretty_writes(&ini_write_options()))
460}
461
462fn new_ini() -> Ini {
463 let mut ini = Ini::new_cs();
464 ini.set_default_section(INI_DEFAULT_SECTION);
465 ini.set_multiline(true);
466 ini
467}
468
469fn ini_write_options() -> WriteOptions {
474 let mut options = WriteOptions::new();
475 options.space_around_delimiters = true;
476 options.blank_lines_between_sections = 1;
477 options
478}
479
480fn ini_value_to_json(value: Option<String>) -> Value {
481 match value {
482 Some(value) => Value::String(value),
483 None => Value::Null,
484 }
485}
486
487fn ini_scalar_value(value: &Value, path: &str) -> Result<Option<String>> {
488 match value {
489 Value::Null => Ok(None),
490 Value::Bool(value) => Ok(Some(value.to_string())),
491 Value::Number(value) => Ok(Some(value.to_string())),
492 Value::String(value) => Ok(Some(value.clone())),
493 Value::Array(_) | Value::Object(_) => Err(Error::Invalid(format!(
494 "INI can only represent scalar values at the document root or one level of sections \
495 (found unsupported value at `{path}`)"
496 ))),
497 }
498}
499
500fn parse_jsonc(input: &str) -> Result<Value> {
505 jsonc_parser::parse_to_serde_value(input, &jsonc_parse_options())
506 .map_err(|e| Error::Invalid(format!("jsonc parse error: {e}")))
507}
508
509pub(crate) fn jsonc_parse_options() -> jsonc_parser::ParseOptions {
510 jsonc_parser::ParseOptions {
511 allow_comments: true,
512 allow_trailing_commas: true,
513 allow_loose_object_property_names: false,
514 allow_missing_commas: false,
515 allow_single_quoted_strings: false,
516 allow_hexadecimal_numbers: false,
517 allow_unary_plus_numbers: false,
518 }
519}
520
521fn find_null_path(value: &Value, prefix: &str) -> Option<String> {
523 match value {
524 Value::Null => Some(prefix.to_string()),
525 Value::Object(map) => {
526 for (k, v) in map {
527 let next = if prefix.is_empty() {
528 k.clone()
529 } else {
530 format!("{prefix}.{k}")
531 };
532 if let Some(p) = find_null_path(v, &next) {
533 return Some(p);
534 }
535 }
536 None
537 }
538 Value::Array(items) => {
539 for (i, v) in items.iter().enumerate() {
540 let next = format!("{prefix}[{i}]");
541 if let Some(p) = find_null_path(v, &next) {
542 return Some(p);
543 }
544 }
545 None
546 }
547 _ => None,
548 }
549}
550
551impl std::fmt::Display for Format {
552 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553 f.write_str(self.canonical_name())
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn from_str_accepts_canonical_and_aliases() {
563 assert_eq!("json".parse::<Format>().unwrap(), Format::Json);
564 assert_eq!("JSON5".parse::<Format>().unwrap(), Format::Json5);
565 assert_eq!("jsonc".parse::<Format>().unwrap(), Format::Jsonc);
566 assert_eq!("yaml".parse::<Format>().unwrap(), Format::Yaml);
567 assert_eq!("yml".parse::<Format>().unwrap(), Format::Yaml);
568 assert_eq!("toon".parse::<Format>().unwrap(), Format::Toon);
569 assert_eq!("toml".parse::<Format>().unwrap(), Format::Toml);
570 assert_eq!("ini".parse::<Format>().unwrap(), Format::Ini);
571 }
572
573 #[test]
574 fn from_str_rejects_unknown() {
575 let err = "xml".parse::<Format>().unwrap_err();
576 assert!(err.to_string().contains("unknown format"));
577 }
578
579 #[test]
580 fn from_path_detects_supported_extensions() {
581 assert_eq!(
582 Format::from_path(Path::new("a.json")).unwrap(),
583 Format::Json
584 );
585 assert_eq!(
586 Format::from_path(Path::new("a.JSON5")).unwrap(),
587 Format::Json5
588 );
589 assert_eq!(
590 Format::from_path(Path::new("a.JSONC")).unwrap(),
591 Format::Jsonc
592 );
593 assert_eq!(Format::from_path(Path::new("a.yml")).unwrap(), Format::Yaml);
594 assert_eq!(
595 Format::from_path(Path::new("a.toon")).unwrap(),
596 Format::Toon
597 );
598 assert_eq!(
599 Format::from_path(Path::new("a.toml")).unwrap(),
600 Format::Toml
601 );
602 assert_eq!(Format::from_path(Path::new("a.ini")).unwrap(), Format::Ini);
603 }
604
605 #[test]
606 fn from_path_rejects_missing_or_unknown_extension() {
607 assert!(Format::from_path(Path::new("a")).is_err());
608 assert!(Format::from_path(Path::new("a.txt")).is_err());
609 }
610
611 #[test]
612 fn display_matches_extension() {
613 assert_eq!(Format::Json.to_string(), "json");
614 assert_eq!(Format::Json5.to_string(), "json5");
615 assert_eq!(Format::Jsonc.to_string(), "jsonc");
616 assert_eq!(Format::Yaml.to_string(), "yaml");
617 assert_eq!(Format::Toon.to_string(), "toon");
618 assert_eq!(Format::Toml.to_string(), "toml");
619 assert_eq!(Format::Ini.to_string(), "ini");
620 }
621
622 #[test]
623 fn parse_and_serialize_round_trip_for_all_formats() {
624 for (fmt, text) in [
625 (Format::Json, r#"{"a":1}"#),
626 (Format::Json5, "{ a: 1 }"),
627 (Format::Jsonc, "{ \"a\": 1, } // kept as syntax only"),
628 (Format::Yaml, "a: 1\n"),
629 (Format::Toon, "a: 1\n"),
630 (Format::Toml, "a = 1\n"),
631 (Format::Ini, "a=1\n"),
632 ] {
633 let v = fmt.parse(text).unwrap();
634 let out = fmt.serialize(&v).unwrap();
635 assert!(out.ends_with('\n'));
636 assert_eq!(fmt.parse(&out).unwrap(), v);
637 }
638 }
639
640 #[test]
641 fn toml_rejects_array_root() {
642 let v: Value = serde_json::json!([1, 2, 3]);
643 let err = Format::Toml.serialize(&v).unwrap_err();
644 assert!(err.to_string().contains("table"), "got: {err}");
645 }
646
647 #[test]
648 fn toml_rejects_null_values() {
649 let v: Value = serde_json::json!({ "outer": { "inner": null } });
650 let err = Format::Toml.serialize(&v).unwrap_err();
651 assert!(err.to_string().contains("null"), "got: {err}");
652 assert!(err.to_string().contains("outer.inner"), "got: {err}");
653 }
654
655 #[test]
656 fn toml_rejects_null_inside_array() {
657 let v: Value = serde_json::json!({ "items": [1, null, 3] });
658 let err = Format::Toml.serialize(&v).unwrap_err();
659 assert!(err.to_string().contains("null"), "got: {err}");
660 assert!(err.to_string().contains("items[1]"), "got: {err}");
661 }
662
663 #[test]
664 fn toml_rejects_null_at_empty_string_key() {
665 let v: Value = serde_json::json!({ "": null });
668 let err = Format::Toml.serialize(&v).unwrap_err();
669 assert!(err.to_string().contains("<root>"), "got: {err}");
670 }
671
672 #[test]
673 fn cross_format_compatibility_excludes_toml() {
674 assert!(Format::Json.is_cross_format_compatible());
675 assert!(Format::Json5.is_cross_format_compatible());
676 assert!(Format::Jsonc.is_cross_format_compatible());
677 assert!(Format::Yaml.is_cross_format_compatible());
678 assert!(Format::Toon.is_cross_format_compatible());
679 assert!(!Format::Toml.is_cross_format_compatible());
680 assert!(!Format::Ini.is_cross_format_compatible());
681 }
682
683 #[test]
684 fn compatible_formats_are_grouped_by_conversion_family() {
685 assert_eq!(
686 Format::Json.compatible_formats(),
687 &[
688 Format::Json,
689 Format::Json5,
690 Format::Jsonc,
691 Format::Yaml,
692 Format::Toon
693 ]
694 );
695 assert_eq!(Format::Toml.compatible_formats(), &[Format::Toml]);
696 assert_eq!(Format::Ini.compatible_formats(), &[Format::Ini]);
697 }
698
699 #[test]
700 fn jsonc_accepts_comments_and_trailing_commas_only() {
701 let parsed = Format::Jsonc
702 .parse(
703 r#"{
704 // JSONC comment
705 "name": "demo",
706 "items": [1, 2,],
707}"#,
708 )
709 .unwrap();
710 assert_eq!(
711 parsed,
712 serde_json::json!({ "name": "demo", "items": [1, 2] })
713 );
714
715 let err = Format::Jsonc.parse("{ name: 'json5-only' }").unwrap_err();
716 assert!(err.to_string().contains("jsonc parse error"));
717 }
718
719 #[test]
720 fn ensure_can_convert_to_reassemble_variant_error_messages() {
721 let err = Format::Toml
724 .ensure_can_convert_to(Format::Json, ConversionOperation::Reassemble)
725 .unwrap_err();
726 assert!(
727 err.to_string().contains("TOML can only be reassembled"),
728 "got: {err}"
729 );
730
731 let err = Format::Ini
732 .ensure_can_convert_to(Format::Json, ConversionOperation::Reassemble)
733 .unwrap_err();
734 assert!(
735 err.to_string().contains("INI can only be reassembled"),
736 "got: {err}"
737 );
738
739 let err = Format::Json
741 .ensure_can_convert_to(Format::Toml, ConversionOperation::Reassemble)
742 .unwrap_err();
743 assert!(
744 err.to_string().contains("TOML can only be reassembled"),
745 "got: {err}"
746 );
747 }
748
749 #[test]
750 fn conversion_rules_reject_cross_family_edges() {
751 assert!(Format::Json
752 .ensure_can_convert_to(Format::Yaml, ConversionOperation::Convert)
753 .is_ok());
754 let err = Format::Json
755 .ensure_can_convert_to(Format::Toml, ConversionOperation::Convert)
756 .unwrap_err();
757 assert!(err.to_string().contains("TOML can only be converted"));
758
759 let err = Format::Json
760 .ensure_can_convert_to(Format::Ini, ConversionOperation::Convert)
761 .unwrap_err();
762 assert!(err.to_string().contains("INI can only be converted"));
763 }
764
765 #[test]
766 fn split_payload_wrapping_is_capability_driven() {
767 let value = serde_json::json!([{ "host": "a" }]);
768 assert_eq!(Format::Json.wrap_split_payload("servers", &value), value);
769
770 let wrapped = Format::Toml.wrap_split_payload("servers", &value);
771 assert_eq!(wrapped, serde_json::json!({ "servers": value }));
772 assert_eq!(
773 Format::Toml
774 .unwrap_split_payload("servers", "servers.toml", wrapped)
775 .unwrap(),
776 serde_json::json!([{ "host": "a" }])
777 );
778
779 let wrapped = Format::Ini.wrap_split_payload("servers", &value);
780 assert_eq!(wrapped, serde_json::json!({ "servers": value }));
781 assert_eq!(
782 Format::Ini
783 .unwrap_split_payload("servers", "servers.ini", wrapped)
784 .unwrap(),
785 serde_json::json!([{ "host": "a" }])
786 );
787 }
788
789 #[test]
790 fn ini_parse_maps_top_level_keys_and_sections() {
791 let parsed = Format::Ini
792 .parse(
793 r#"
794name = demo
795enabled
796
797[Database]
798host = db.example.com
799port = 5432
800"#,
801 )
802 .unwrap();
803
804 assert_eq!(
805 parsed,
806 serde_json::json!({
807 "name": "demo",
808 "enabled": null,
809 "Database": {
810 "host": "db.example.com",
811 "port": "5432"
812 }
813 })
814 );
815 }
816
817 #[test]
818 fn ini_serialize_rejects_arrays_and_deeply_nested_objects() {
819 let array = serde_json::json!({ "items": ["a", "b"] });
820 let err = Format::Ini.serialize(&array).unwrap_err();
821 assert!(err.to_string().contains("items"), "got: {err}");
822
823 let nested = serde_json::json!({ "section": { "child": { "x": "y" } } });
824 let err = Format::Ini.serialize(&nested).unwrap_err();
825 assert!(err.to_string().contains("section.child"), "got: {err}");
826 }
827
828 #[test]
829 fn ini_preserves_empty_sections() {
830 let value = serde_json::json!({ "empty": {} });
831 let out = Format::Ini.serialize(&value).unwrap();
832 assert!(out.contains("[empty]"), "got: {out}");
833 assert_eq!(Format::Ini.parse(&out).unwrap(), value);
834 }
835
836 #[test]
837 fn ini_serialize_rejects_non_object_roots() {
838 let array_root = serde_json::json!([1, 2, 3]);
839 let err = Format::Ini.serialize(&array_root).unwrap_err();
840 assert!(err.to_string().contains("object root"), "got: {err}");
841
842 let scalar_root = serde_json::json!(42);
843 let err = Format::Ini.serialize(&scalar_root).unwrap_err();
844 assert!(err.to_string().contains("object root"), "got: {err}");
845 }
846
847 #[test]
848 fn ini_serialize_uses_spaces_around_delimiter_and_blank_lines_between_sections() {
849 let value = serde_json::json!({
853 "name": "demo",
854 "settings": { "host": "db.example.com", "port": "5432" },
855 "empty": {}
856 });
857 let out = Format::Ini.serialize(&value).unwrap();
858 let normalized = out.replace("\r\n", "\n");
861 assert!(normalized.contains("name = demo"), "got: {out}");
862 assert!(normalized.contains("host = db.example.com"), "got: {out}");
863 assert!(normalized.contains("port = 5432"), "got: {out}");
864 assert!(normalized.contains("\n\n[settings]"), "got: {out}");
867 assert!(normalized.contains("\n\n[empty]"), "got: {out}");
868 }
869
870 #[test]
871 fn ini_serialize_renders_bool_and_number_scalars() {
872 let value = serde_json::json!({
875 "enabled": true,
876 "retries": 3,
877 "ratio": 1.5,
878 "section": {
879 "active": false,
880 "port": 5432
881 }
882 });
883 let out = Format::Ini.serialize(&value).unwrap();
884 assert!(out.contains("enabled = true"), "got: {out}");
885 assert!(out.contains("retries = 3"), "got: {out}");
886 assert!(out.contains("ratio = 1.5"), "got: {out}");
887 assert!(out.contains("active = false"), "got: {out}");
888 assert!(out.contains("port = 5432"), "got: {out}");
889 }
890}