Skip to main content

config_disassembler/
format.rs

1//! Format detection, capabilities, and serialization for value-model formats.
2//!
3//! Each format in this module is loaded into a common [`serde_json::Value`].
4//! Conversion rules are expressed as format capabilities so adding another
5//! value-model format only requires registering its aliases, extensions,
6//! conversion family, and serializer/parser here.
7
8use 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/// Supported textual formats for the value-model disassembler.
20#[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 is intentionally isolated from the JSON-value formats: TOML's
29    /// syntactic constraints (no nulls, no array root, bare keys must
30    /// precede tables) mean conversions through TOML can reorder or
31    /// fail to represent values produced by JSON/JSON5/JSONC/YAML/TOON.
32    /// TOML files can therefore only be split into TOML files and
33    /// reassembled into TOML.
34    Toml,
35    /// INI uses the same table-document split layout as TOML, but is even
36    /// narrower: section values are strings (or valueless keys) and deeper
37    /// nesting/arrays cannot be represented without inventing an encoding.
38    Ini,
39}
40
41/// A family of formats that can safely convert among themselves.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum FormatFamily {
44    JsonValue,
45    Toml,
46    Ini,
47}
48
49/// Which operation is checking a conversion edge.
50#[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    /// All formats handled by the value-model disassembler.
73    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    /// Canonical file extension (without the leading dot).
155    pub fn extension(self) -> &'static str {
156        self.spec().canonical_name
157    }
158
159    /// Canonical lower-case name used in CLI and metadata.
160    pub fn canonical_name(self) -> &'static str {
161        self.spec().canonical_name
162    }
163
164    /// Human-facing display name.
165    pub fn display_name(self) -> &'static str {
166        self.spec().display_name
167    }
168
169    /// Accepted names for CLI parsing.
170    pub fn aliases(self) -> &'static [&'static str] {
171        self.spec().aliases
172    }
173
174    /// File extensions that identify this format.
175    pub fn extensions(self) -> &'static [&'static str] {
176        self.spec().extensions
177    }
178
179    /// The conversion family this format belongs to.
180    pub fn family(self) -> FormatFamily {
181        self.spec().family
182    }
183
184    /// Formats that can safely convert to/from this format.
185    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    /// Whether CLI `--input-format` / `--output-format` flags are useful
194    /// for this subcommand.
195    pub fn allows_format_overrides(self) -> bool {
196        self.compatible_formats().len() > 1
197    }
198
199    /// Whether this format participates in cross-format conversions.
200    pub fn is_cross_format_compatible(self) -> bool {
201        self.allows_format_overrides()
202    }
203
204    /// Whether this format can be converted into `output`.
205    pub fn can_convert_to(self, output: Format) -> bool {
206        self.family() == output.family()
207    }
208
209    /// Return a clear error if a conversion edge is not allowed.
210    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    /// Best-effort detection of a format from a file path's extension.
241    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    /// Parse a string in this format into a generic [`Value`].
257    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    /// Serialize a [`Value`] in this format. The output is always
271    /// pretty-printed with newline-terminated content.
272    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            // JSON is a valid JSONC document. Comments from input files are
277            // treated as syntax and are not preserved in the value model.
278            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    /// Read and parse a file in this format.
292    pub fn load(self, path: &Path) -> Result<Value> {
293        let text = fs::read_to_string(path)?;
294        self.parse(&text)
295    }
296
297    /// Prepare a per-key split payload for this format.
298    ///
299    /// Most formats can write the payload value directly. TOML wraps the
300    /// payload under its parent key so every split file remains a valid TOML
301    /// table document.
302    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    /// Reverse [`Format::wrap_split_payload`] while reassembling.
314    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    /// Canonical CLI names for all registered formats.
335    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
373/// Serialize a `Value` as TOML.
374///
375/// TOML cannot represent `null` and the document root must be a table,
376/// so this function pre-validates and returns a clear error before
377/// invoking the underlying TOML serializer.
378fn 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    // Pre-validation above (root must be a table, no null values) covers
391    // every case the `toml` crate would reject for a `serde_json::Value`
392    // constructed through the normal serde API, so a serialization error
393    // here would indicate an unexpected toml-crate behavior; surface it
394    // with a clear `Invalid` error rather than a dedicated variant.
395    toml::to_string_pretty(value).map_err(|e| Error::Invalid(format!("toml serialize error: {e}")))
396}
397
398/// Parse an INI document into the common value model.
399///
400/// Keys outside any section become top-level scalar keys. Section entries
401/// become one-level nested objects. INI values are strings; valueless keys are
402/// represented as `null` so they can round-trip through the same format.
403fn 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                // Preserve empty sections. Without this, a `[section]` with
440                // no keys would serialize as an empty file and fail unwrap.
441                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
470/// Pretty-print INI with the conventions human-authored INI files usually
471/// follow: spaces around `=` and a blank line between sections. This keeps
472/// reassembled output recognisable next to the original instead of collapsing
473/// it into the most compact form configparser can produce.
474fn 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
501/// Parse JSONC as JSON plus comments and trailing commas.
502///
503/// The upstream parser defaults are intentionally loose, so keep the accepted
504/// syntax close to JSONC rather than expanding this into JSON5.
505fn 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
522/// Walks a `Value` and returns the first dotted path to a `Null`, if any.
523fn 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        // Reassembled INI should look like a typical hand-authored file, not
812        // the most compact form configparser can produce. We check both the
813        // delimiter spacing and the blank line that separates sections.
814        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        // Normalize CRLF (emitted on Windows) so the structural assertions
821        // below stay platform-independent.
822        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        // configparser writes one blank line *before* every non-default
827        // section header when `blank_lines_between_sections` is 1.
828        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        // Top-level bool/number keys exercise the bool/number arms of the
835        // INI scalar serializer, which the string-only round trip skips.
836        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}