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        // Every cross-family edge involves at least one isolated format (TOML or INI);
220        // FormatFamily::JsonValue returns None, but two JsonValue formats share the
221        // same family and would have returned Ok above.
222        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    /// Best-effort detection of a format from a file path's extension.
240    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    /// Parse a string in this format into a generic [`Value`].
256    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    /// Serialize a [`Value`] in this format. The output is always
270    /// pretty-printed with newline-terminated content.
271    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            // JSON is a valid JSONC document. Comments from input files are
276            // treated as syntax and are not preserved in the value model.
277            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    /// Read and parse a file in this format.
291    pub fn load(self, path: &Path) -> Result<Value> {
292        let text = fs::read_to_string(path)?;
293        self.parse(&text)
294    }
295
296    /// Prepare a per-key split payload for this format.
297    ///
298    /// Most formats can write the payload value directly. TOML wraps the
299    /// payload under its parent key so every split file remains a valid TOML
300    /// table document.
301    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    /// Reverse [`Format::wrap_split_payload`] while reassembling.
313    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    /// Canonical CLI names for all registered formats.
334    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
372/// Serialize a `Value` as TOML.
373///
374/// TOML cannot represent `null` and the document root must be a table,
375/// so this function pre-validates and returns a clear error before
376/// invoking the underlying TOML serializer.
377fn 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    // Pre-validation above (root must be a table, no null values) covers
390    // every case the `toml` crate would reject for a `serde_json::Value`
391    // constructed through the normal serde API, so a serialization error
392    // here would indicate an unexpected toml-crate behavior; surface it
393    // with a clear `Invalid` error rather than a dedicated variant.
394    toml::to_string_pretty(value).map_err(|e| Error::Invalid(format!("toml serialize error: {e}")))
395}
396
397/// Parse an INI document into the common value model.
398///
399/// Keys outside any section become top-level scalar keys. Section entries
400/// become one-level nested objects. INI values are strings; valueless keys are
401/// represented as `null` so they can round-trip through the same format.
402fn 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                // Preserve empty sections. Without this, a `[section]` with
439                // no keys would serialize as an empty file and fail unwrap.
440                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
469/// Pretty-print INI with the conventions human-authored INI files usually
470/// follow: spaces around `=` and a blank line between sections. This keeps
471/// reassembled output recognisable next to the original instead of collapsing
472/// it into the most compact form configparser can produce.
473fn 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
500/// Parse JSONC as JSON plus comments and trailing commas.
501///
502/// The upstream parser defaults are intentionally loose, so keep the accepted
503/// syntax close to JSONC rather than expanding this into JSON5.
504fn 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
521/// Walks a `Value` and returns the first dotted path to a `Null`, if any.
522fn 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        // An empty-string key causes find_null_path to return Some(""),
666        // exercising the `if path.is_empty() { "<root>" }` branch.
667        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        // Reassemble errors mention "reassembled" in the message, distinguishing
722        // them from Convert errors. Both TOML and INI must use the Reassemble path.
723        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        // Cross-family with Json as self and Toml as output
740        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        // Reassembled INI should look like a typical hand-authored file, not
850        // the most compact form configparser can produce. We check both the
851        // delimiter spacing and the blank line that separates sections.
852        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        // Normalize CRLF (emitted on Windows) so the structural assertions
859        // below stay platform-independent.
860        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        // configparser writes one blank line *before* every non-default
865        // section header when `blank_lines_between_sections` is 1.
866        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        // Top-level bool/number keys exercise the bool/number arms of the
873        // INI scalar serializer, which the string-only round trip skips.
874        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}