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 if let Some(name) = self
220 .family()
221 .isolated_format_name()
222 .or_else(|| output.family().isolated_format_name())
223 {
224 return match operation {
225 ConversionOperation::Convert => Err(Error::Invalid(format!(
226 "{name} can only be converted to and from {name}; got input={self}, output={output}"
227 ))),
228 ConversionOperation::Reassemble => Err(Error::Invalid(format!(
229 "{name} can only be reassembled to and from {name}; the disassembled \
230 directory was written in {self} but reassembly target is {output}"
231 ))),
232 };
233 }
234
235 Err(Error::Invalid(format!(
236 "conversion from {self} to {output} is not supported"
237 )))
238 }
239
240 pub fn from_path(path: &Path) -> Result<Self> {
242 let ext = path
243 .extension()
244 .and_then(|e| e.to_str())
245 .map(|e| e.to_ascii_lowercase());
246 if let Some(ext) = ext.as_deref() {
247 for format in Self::ALL {
248 if format.extensions().contains(&ext) {
249 return Ok(*format);
250 }
251 }
252 }
253 Err(Error::UnknownFormat(path.to_path_buf()))
254 }
255
256 pub fn parse(self, input: &str) -> Result<Value> {
258 match self {
259 Format::Json => Ok(serde_json::from_str(input)?),
260 Format::Json5 => Ok(json5::from_str(input)?),
261 Format::Jsonc => parse_jsonc(input),
262 Format::Yaml => Ok(serde_yaml::from_str(input)?),
263 Format::Toon => toon_format::decode_default(input)
264 .map_err(|e| Error::Invalid(format!("toon parse error: {e}"))),
265 Format::Toml => Ok(toml::from_str(input)?),
266 Format::Ini => parse_ini(input),
267 }
268 }
269
270 pub fn serialize(self, value: &Value) -> Result<String> {
273 let mut out = match self {
274 Format::Json => serde_json::to_string_pretty(value)?,
275 Format::Json5 => json5::to_string(value)?,
276 Format::Jsonc => serde_json::to_string_pretty(value)?,
279 Format::Yaml => serde_yaml::to_string(value)?,
280 Format::Toon => toon_format::encode_default(value)
281 .map_err(|e| Error::Invalid(format!("toon serialize error: {e}")))?,
282 Format::Toml => serialize_toml(value)?,
283 Format::Ini => serialize_ini(value)?,
284 };
285 if !out.ends_with('\n') {
286 out.push('\n');
287 }
288 Ok(out)
289 }
290
291 pub fn load(self, path: &Path) -> Result<Value> {
293 let text = fs::read_to_string(path)?;
294 self.parse(&text)
295 }
296
297 pub fn wrap_split_payload(self, key: &str, value: &Value) -> Value {
303 match self.spec().split_payload_layout {
304 SplitPayloadLayout::Direct => value.clone(),
305 SplitPayloadLayout::WrappedByParentKey => {
306 let mut wrapper = Map::new();
307 wrapper.insert(key.to_string(), value.clone());
308 Value::Object(wrapper)
309 }
310 }
311 }
312
313 pub fn unwrap_split_payload(self, key: &str, filename: &str, loaded: Value) -> Result<Value> {
315 match self.spec().split_payload_layout {
316 SplitPayloadLayout::Direct => Ok(loaded),
317 SplitPayloadLayout::WrappedByParentKey => {
318 let Value::Object(mut map) = loaded else {
319 return Err(Error::Invalid(format!(
320 "{} file `{filename}` did not deserialize to a table",
321 self.display_name()
322 )));
323 };
324 map.remove(key).ok_or_else(|| {
325 Error::Invalid(format!(
326 "{} file `{filename}` does not contain expected wrapper key `{key}`",
327 self.display_name()
328 ))
329 })
330 }
331 }
332 }
333
334 pub fn supported_format_list() -> String {
336 Self::ALL
337 .iter()
338 .map(|f| f.canonical_name())
339 .collect::<Vec<_>>()
340 .join(", ")
341 }
342}
343
344impl FromStr for Format {
345 type Err = Error;
346
347 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
348 let s = s.to_ascii_lowercase();
349 for format in Format::ALL {
350 if format.aliases().contains(&s.as_str()) {
351 return Ok(*format);
352 }
353 }
354 Err(Error::Usage(format!(
355 "unknown format `{s}`; expected {}",
356 Format::supported_format_list()
357 )))
358 }
359}
360
361impl FormatFamily {
362 fn isolated_format_name(self) -> Option<&'static str> {
363 match self {
364 FormatFamily::JsonValue => None,
365 FormatFamily::Toml => Some("TOML"),
366 FormatFamily::Ini => Some("INI"),
367 }
368 }
369}
370
371const INI_DEFAULT_SECTION: &str = "__config_disassembler_root__";
372
373fn serialize_toml(value: &Value) -> Result<String> {
379 if !matches!(value, Value::Object(_)) {
380 return Err(Error::Invalid(
381 "TOML documents must have a table (object) root; got an array or scalar".into(),
382 ));
383 }
384 if let Some(path) = find_null_path(value, "") {
385 return Err(Error::Invalid(format!(
386 "TOML cannot represent null values (found at `{}`)",
387 if path.is_empty() { "<root>" } else { &path }
388 )));
389 }
390 toml::to_string_pretty(value).map_err(|e| Error::Invalid(format!("toml serialize error: {e}")))
396}
397
398fn parse_ini(input: &str) -> Result<Value> {
404 let mut ini = new_ini();
405 let parsed = ini
406 .read(input.to_string())
407 .map_err(|e| Error::Invalid(format!("ini parse error: {e}")))?;
408 let mut root = Map::new();
409
410 for (section, values) in parsed {
411 if section == INI_DEFAULT_SECTION {
412 for (key, value) in values {
413 root.insert(key, ini_value_to_json(value));
414 }
415 continue;
416 }
417
418 let mut section_object = Map::new();
419 for (key, value) in values {
420 section_object.insert(key, ini_value_to_json(value));
421 }
422 root.insert(section, Value::Object(section_object));
423 }
424
425 Ok(Value::Object(root))
426}
427
428fn serialize_ini(value: &Value) -> Result<String> {
429 let Value::Object(map) = value else {
430 return Err(Error::Invalid(
431 "INI documents must have an object root; got an array or scalar".into(),
432 ));
433 };
434
435 let mut ini = new_ini();
436 for (key, value) in map {
437 match value {
438 Value::Object(section) => {
439 ini.get_mut_map().entry(key.clone()).or_default();
442 for (section_key, section_value) in section {
443 ini.set(
444 key,
445 section_key,
446 ini_scalar_value(section_value, &format!("{key}.{section_key}"))?,
447 );
448 }
449 }
450 _ => {
451 ini.set(
452 INI_DEFAULT_SECTION,
453 key,
454 ini_scalar_value(value, key.as_str())?,
455 );
456 }
457 }
458 }
459
460 Ok(ini.pretty_writes(&ini_write_options()))
461}
462
463fn new_ini() -> Ini {
464 let mut ini = Ini::new_cs();
465 ini.set_default_section(INI_DEFAULT_SECTION);
466 ini.set_multiline(true);
467 ini
468}
469
470fn ini_write_options() -> WriteOptions {
475 let mut options = WriteOptions::new();
476 options.space_around_delimiters = true;
477 options.blank_lines_between_sections = 1;
478 options
479}
480
481fn ini_value_to_json(value: Option<String>) -> Value {
482 match value {
483 Some(value) => Value::String(value),
484 None => Value::Null,
485 }
486}
487
488fn ini_scalar_value(value: &Value, path: &str) -> Result<Option<String>> {
489 match value {
490 Value::Null => Ok(None),
491 Value::Bool(value) => Ok(Some(value.to_string())),
492 Value::Number(value) => Ok(Some(value.to_string())),
493 Value::String(value) => Ok(Some(value.clone())),
494 Value::Array(_) | Value::Object(_) => Err(Error::Invalid(format!(
495 "INI can only represent scalar values at the document root or one level of sections \
496 (found unsupported value at `{path}`)"
497 ))),
498 }
499}
500
501fn parse_jsonc(input: &str) -> Result<Value> {
506 jsonc_parser::parse_to_serde_value(input, &jsonc_parse_options())
507 .map_err(|e| Error::Invalid(format!("jsonc parse error: {e}")))
508}
509
510pub(crate) fn jsonc_parse_options() -> jsonc_parser::ParseOptions {
511 jsonc_parser::ParseOptions {
512 allow_comments: true,
513 allow_trailing_commas: true,
514 allow_loose_object_property_names: false,
515 allow_missing_commas: false,
516 allow_single_quoted_strings: false,
517 allow_hexadecimal_numbers: false,
518 allow_unary_plus_numbers: false,
519 }
520}
521
522fn find_null_path(value: &Value, prefix: &str) -> Option<String> {
524 match value {
525 Value::Null => Some(prefix.to_string()),
526 Value::Object(map) => {
527 for (k, v) in map {
528 let next = if prefix.is_empty() {
529 k.clone()
530 } else {
531 format!("{prefix}.{k}")
532 };
533 if let Some(p) = find_null_path(v, &next) {
534 return Some(p);
535 }
536 }
537 None
538 }
539 Value::Array(items) => {
540 for (i, v) in items.iter().enumerate() {
541 let next = format!("{prefix}[{i}]");
542 if let Some(p) = find_null_path(v, &next) {
543 return Some(p);
544 }
545 }
546 None
547 }
548 _ => None,
549 }
550}
551
552impl std::fmt::Display for Format {
553 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554 f.write_str(self.canonical_name())
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 #[test]
563 fn from_str_accepts_canonical_and_aliases() {
564 assert_eq!("json".parse::<Format>().unwrap(), Format::Json);
565 assert_eq!("JSON5".parse::<Format>().unwrap(), Format::Json5);
566 assert_eq!("jsonc".parse::<Format>().unwrap(), Format::Jsonc);
567 assert_eq!("yaml".parse::<Format>().unwrap(), Format::Yaml);
568 assert_eq!("yml".parse::<Format>().unwrap(), Format::Yaml);
569 assert_eq!("toon".parse::<Format>().unwrap(), Format::Toon);
570 assert_eq!("toml".parse::<Format>().unwrap(), Format::Toml);
571 assert_eq!("ini".parse::<Format>().unwrap(), Format::Ini);
572 }
573
574 #[test]
575 fn from_str_rejects_unknown() {
576 let err = "xml".parse::<Format>().unwrap_err();
577 assert!(err.to_string().contains("unknown format"));
578 }
579
580 #[test]
581 fn from_path_detects_supported_extensions() {
582 assert_eq!(
583 Format::from_path(Path::new("a.json")).unwrap(),
584 Format::Json
585 );
586 assert_eq!(
587 Format::from_path(Path::new("a.JSON5")).unwrap(),
588 Format::Json5
589 );
590 assert_eq!(
591 Format::from_path(Path::new("a.JSONC")).unwrap(),
592 Format::Jsonc
593 );
594 assert_eq!(Format::from_path(Path::new("a.yml")).unwrap(), Format::Yaml);
595 assert_eq!(
596 Format::from_path(Path::new("a.toon")).unwrap(),
597 Format::Toon
598 );
599 assert_eq!(
600 Format::from_path(Path::new("a.toml")).unwrap(),
601 Format::Toml
602 );
603 assert_eq!(Format::from_path(Path::new("a.ini")).unwrap(), Format::Ini);
604 }
605
606 #[test]
607 fn from_path_rejects_missing_or_unknown_extension() {
608 assert!(Format::from_path(Path::new("a")).is_err());
609 assert!(Format::from_path(Path::new("a.txt")).is_err());
610 }
611
612 #[test]
613 fn display_matches_extension() {
614 assert_eq!(Format::Json.to_string(), "json");
615 assert_eq!(Format::Json5.to_string(), "json5");
616 assert_eq!(Format::Jsonc.to_string(), "jsonc");
617 assert_eq!(Format::Yaml.to_string(), "yaml");
618 assert_eq!(Format::Toon.to_string(), "toon");
619 assert_eq!(Format::Toml.to_string(), "toml");
620 assert_eq!(Format::Ini.to_string(), "ini");
621 }
622
623 #[test]
624 fn parse_and_serialize_round_trip_for_all_formats() {
625 for (fmt, text) in [
626 (Format::Json, r#"{"a":1}"#),
627 (Format::Json5, "{ a: 1 }"),
628 (Format::Jsonc, "{ \"a\": 1, } // kept as syntax only"),
629 (Format::Yaml, "a: 1\n"),
630 (Format::Toon, "a: 1\n"),
631 (Format::Toml, "a = 1\n"),
632 (Format::Ini, "a=1\n"),
633 ] {
634 let v = fmt.parse(text).unwrap();
635 let out = fmt.serialize(&v).unwrap();
636 assert!(out.ends_with('\n'));
637 assert_eq!(fmt.parse(&out).unwrap(), v);
638 }
639 }
640
641 #[test]
642 fn toml_rejects_array_root() {
643 let v: Value = serde_json::json!([1, 2, 3]);
644 let err = Format::Toml.serialize(&v).unwrap_err();
645 assert!(err.to_string().contains("table"), "got: {err}");
646 }
647
648 #[test]
649 fn toml_rejects_null_values() {
650 let v: Value = serde_json::json!({ "outer": { "inner": null } });
651 let err = Format::Toml.serialize(&v).unwrap_err();
652 assert!(err.to_string().contains("null"), "got: {err}");
653 assert!(err.to_string().contains("outer.inner"), "got: {err}");
654 }
655
656 #[test]
657 fn toml_rejects_null_inside_array() {
658 let v: Value = serde_json::json!({ "items": [1, null, 3] });
659 let err = Format::Toml.serialize(&v).unwrap_err();
660 assert!(err.to_string().contains("null"), "got: {err}");
661 assert!(err.to_string().contains("items[1]"), "got: {err}");
662 }
663
664 #[test]
665 fn cross_format_compatibility_excludes_toml() {
666 assert!(Format::Json.is_cross_format_compatible());
667 assert!(Format::Json5.is_cross_format_compatible());
668 assert!(Format::Jsonc.is_cross_format_compatible());
669 assert!(Format::Yaml.is_cross_format_compatible());
670 assert!(Format::Toon.is_cross_format_compatible());
671 assert!(!Format::Toml.is_cross_format_compatible());
672 assert!(!Format::Ini.is_cross_format_compatible());
673 }
674
675 #[test]
676 fn compatible_formats_are_grouped_by_conversion_family() {
677 assert_eq!(
678 Format::Json.compatible_formats(),
679 &[
680 Format::Json,
681 Format::Json5,
682 Format::Jsonc,
683 Format::Yaml,
684 Format::Toon
685 ]
686 );
687 assert_eq!(Format::Toml.compatible_formats(), &[Format::Toml]);
688 assert_eq!(Format::Ini.compatible_formats(), &[Format::Ini]);
689 }
690
691 #[test]
692 fn jsonc_accepts_comments_and_trailing_commas_only() {
693 let parsed = Format::Jsonc
694 .parse(
695 r#"{
696 // JSONC comment
697 "name": "demo",
698 "items": [1, 2,],
699}"#,
700 )
701 .unwrap();
702 assert_eq!(
703 parsed,
704 serde_json::json!({ "name": "demo", "items": [1, 2] })
705 );
706
707 let err = Format::Jsonc.parse("{ name: 'json5-only' }").unwrap_err();
708 assert!(err.to_string().contains("jsonc parse error"));
709 }
710
711 #[test]
712 fn conversion_rules_reject_cross_family_edges() {
713 assert!(Format::Json
714 .ensure_can_convert_to(Format::Yaml, ConversionOperation::Convert)
715 .is_ok());
716 let err = Format::Json
717 .ensure_can_convert_to(Format::Toml, ConversionOperation::Convert)
718 .unwrap_err();
719 assert!(err.to_string().contains("TOML can only be converted"));
720
721 let err = Format::Json
722 .ensure_can_convert_to(Format::Ini, ConversionOperation::Convert)
723 .unwrap_err();
724 assert!(err.to_string().contains("INI can only be converted"));
725 }
726
727 #[test]
728 fn split_payload_wrapping_is_capability_driven() {
729 let value = serde_json::json!([{ "host": "a" }]);
730 assert_eq!(Format::Json.wrap_split_payload("servers", &value), value);
731
732 let wrapped = Format::Toml.wrap_split_payload("servers", &value);
733 assert_eq!(wrapped, serde_json::json!({ "servers": value }));
734 assert_eq!(
735 Format::Toml
736 .unwrap_split_payload("servers", "servers.toml", wrapped)
737 .unwrap(),
738 serde_json::json!([{ "host": "a" }])
739 );
740
741 let wrapped = Format::Ini.wrap_split_payload("servers", &value);
742 assert_eq!(wrapped, serde_json::json!({ "servers": value }));
743 assert_eq!(
744 Format::Ini
745 .unwrap_split_payload("servers", "servers.ini", wrapped)
746 .unwrap(),
747 serde_json::json!([{ "host": "a" }])
748 );
749 }
750
751 #[test]
752 fn ini_parse_maps_top_level_keys_and_sections() {
753 let parsed = Format::Ini
754 .parse(
755 r#"
756name = demo
757enabled
758
759[Database]
760host = db.example.com
761port = 5432
762"#,
763 )
764 .unwrap();
765
766 assert_eq!(
767 parsed,
768 serde_json::json!({
769 "name": "demo",
770 "enabled": null,
771 "Database": {
772 "host": "db.example.com",
773 "port": "5432"
774 }
775 })
776 );
777 }
778
779 #[test]
780 fn ini_serialize_rejects_arrays_and_deeply_nested_objects() {
781 let array = serde_json::json!({ "items": ["a", "b"] });
782 let err = Format::Ini.serialize(&array).unwrap_err();
783 assert!(err.to_string().contains("items"), "got: {err}");
784
785 let nested = serde_json::json!({ "section": { "child": { "x": "y" } } });
786 let err = Format::Ini.serialize(&nested).unwrap_err();
787 assert!(err.to_string().contains("section.child"), "got: {err}");
788 }
789
790 #[test]
791 fn ini_preserves_empty_sections() {
792 let value = serde_json::json!({ "empty": {} });
793 let out = Format::Ini.serialize(&value).unwrap();
794 assert!(out.contains("[empty]"), "got: {out}");
795 assert_eq!(Format::Ini.parse(&out).unwrap(), value);
796 }
797
798 #[test]
799 fn ini_serialize_rejects_non_object_roots() {
800 let array_root = serde_json::json!([1, 2, 3]);
801 let err = Format::Ini.serialize(&array_root).unwrap_err();
802 assert!(err.to_string().contains("object root"), "got: {err}");
803
804 let scalar_root = serde_json::json!(42);
805 let err = Format::Ini.serialize(&scalar_root).unwrap_err();
806 assert!(err.to_string().contains("object root"), "got: {err}");
807 }
808
809 #[test]
810 fn ini_serialize_uses_spaces_around_delimiter_and_blank_lines_between_sections() {
811 let value = serde_json::json!({
815 "name": "demo",
816 "settings": { "host": "db.example.com", "port": "5432" },
817 "empty": {}
818 });
819 let out = Format::Ini.serialize(&value).unwrap();
820 let normalized = out.replace("\r\n", "\n");
823 assert!(normalized.contains("name = demo"), "got: {out}");
824 assert!(normalized.contains("host = db.example.com"), "got: {out}");
825 assert!(normalized.contains("port = 5432"), "got: {out}");
826 assert!(normalized.contains("\n\n[settings]"), "got: {out}");
829 assert!(normalized.contains("\n\n[empty]"), "got: {out}");
830 }
831
832 #[test]
833 fn ini_serialize_renders_bool_and_number_scalars() {
834 let value = serde_json::json!({
837 "enabled": true,
838 "retries": 3,
839 "ratio": 1.5,
840 "section": {
841 "active": false,
842 "port": 5432
843 }
844 });
845 let out = Format::Ini.serialize(&value).unwrap();
846 assert!(out.contains("enabled = true"), "got: {out}");
847 assert!(out.contains("retries = 3"), "got: {out}");
848 assert!(out.contains("ratio = 1.5"), "got: {out}");
849 assert!(out.contains("active = false"), "got: {out}");
850 assert!(out.contains("port = 5432"), "got: {out}");
851 }
852}