Skip to main content

config_disassembler/
format.rs

1//! Format detection and serialization for JSON, JSON5, and YAML.
2//!
3//! All three formats are loaded into a common [`serde_json::Value`] so that
4//! a file in one format can be re-emitted in any of the others.
5
6use std::fs;
7use std::path::Path;
8use std::str::FromStr;
9
10use serde_json::Value;
11
12use crate::error::{Error, Result};
13
14/// Supported textual formats for the JSON-family disassembler.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Format {
17    Json,
18    Json5,
19    Yaml,
20    /// TOML is intentionally isolated from the other formats: TOML's
21    /// syntactic constraints (no nulls, no array root, bare keys must
22    /// precede tables) mean conversions through TOML can reorder or
23    /// fail to represent values produced by JSON/JSON5/YAML. TOML files
24    /// can therefore only be split into TOML files and reassembled into
25    /// TOML.
26    Toml,
27}
28
29impl Format {
30    /// Canonical file extension (without the leading dot).
31    pub fn extension(self) -> &'static str {
32        match self {
33            Format::Json => "json",
34            Format::Json5 => "json5",
35            Format::Yaml => "yaml",
36            Format::Toml => "toml",
37        }
38    }
39
40    /// Whether this format participates in cross-format conversions.
41    /// TOML is the only format that does not.
42    pub fn is_cross_format_compatible(self) -> bool {
43        !matches!(self, Format::Toml)
44    }
45
46    /// Best-effort detection of a format from a file path's extension.
47    pub fn from_path(path: &Path) -> Result<Self> {
48        let ext = path
49            .extension()
50            .and_then(|e| e.to_str())
51            .map(|e| e.to_ascii_lowercase());
52        match ext.as_deref() {
53            Some("json") => Ok(Format::Json),
54            Some("json5") => Ok(Format::Json5),
55            Some("yaml" | "yml") => Ok(Format::Yaml),
56            Some("toml") => Ok(Format::Toml),
57            _ => Err(Error::UnknownFormat(path.to_path_buf())),
58        }
59    }
60
61    /// Parse a string in this format into a generic [`Value`].
62    pub fn parse(self, input: &str) -> Result<Value> {
63        match self {
64            Format::Json => Ok(serde_json::from_str(input)?),
65            Format::Json5 => Ok(json5::from_str(input)?),
66            Format::Yaml => Ok(serde_yaml::from_str(input)?),
67            Format::Toml => Ok(toml::from_str(input)?),
68        }
69    }
70
71    /// Serialize a [`Value`] in this format. The output is always
72    /// pretty-printed with newline-terminated content.
73    pub fn serialize(self, value: &Value) -> Result<String> {
74        let mut out = match self {
75            Format::Json => serde_json::to_string_pretty(value)?,
76            Format::Json5 => json5::to_string(value)?,
77            Format::Yaml => serde_yaml::to_string(value)?,
78            Format::Toml => serialize_toml(value)?,
79        };
80        if !out.ends_with('\n') {
81            out.push('\n');
82        }
83        Ok(out)
84    }
85
86    /// Read and parse a file in this format.
87    pub fn load(self, path: &Path) -> Result<Value> {
88        let text = fs::read_to_string(path)?;
89        self.parse(&text)
90    }
91}
92
93impl FromStr for Format {
94    type Err = Error;
95
96    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
97        match s.to_ascii_lowercase().as_str() {
98            "json" => Ok(Format::Json),
99            "json5" => Ok(Format::Json5),
100            "yaml" | "yml" => Ok(Format::Yaml),
101            "toml" => Ok(Format::Toml),
102            other => Err(Error::Usage(format!(
103                "unknown format `{other}`; expected json, json5, yaml, or toml"
104            ))),
105        }
106    }
107}
108
109/// Serialize a `Value` as TOML.
110///
111/// TOML cannot represent `null` and the document root must be a table,
112/// so this function pre-validates and returns a clear error before
113/// invoking the underlying TOML serializer.
114fn serialize_toml(value: &Value) -> Result<String> {
115    if !matches!(value, Value::Object(_)) {
116        return Err(Error::Invalid(
117            "TOML documents must have a table (object) root; got an array or scalar".into(),
118        ));
119    }
120    if let Some(path) = find_null_path(value, "") {
121        return Err(Error::Invalid(format!(
122            "TOML cannot represent null values (found at `{}`)",
123            if path.is_empty() { "<root>" } else { &path }
124        )));
125    }
126    // Pre-validation above (root must be a table, no null values) covers
127    // every case the `toml` crate would reject for a `serde_json::Value`
128    // constructed through the normal serde API, so a serialization error
129    // here would indicate an unexpected toml-crate behavior; surface it
130    // with a clear `Invalid` error rather than a dedicated variant.
131    toml::to_string_pretty(value).map_err(|e| Error::Invalid(format!("toml serialize error: {e}")))
132}
133
134/// Walks a `Value` and returns the first dotted path to a `Null`, if any.
135fn find_null_path(value: &Value, prefix: &str) -> Option<String> {
136    match value {
137        Value::Null => Some(prefix.to_string()),
138        Value::Object(map) => {
139            for (k, v) in map {
140                let next = if prefix.is_empty() {
141                    k.clone()
142                } else {
143                    format!("{prefix}.{k}")
144                };
145                if let Some(p) = find_null_path(v, &next) {
146                    return Some(p);
147                }
148            }
149            None
150        }
151        Value::Array(items) => {
152            for (i, v) in items.iter().enumerate() {
153                let next = format!("{prefix}[{i}]");
154                if let Some(p) = find_null_path(v, &next) {
155                    return Some(p);
156                }
157            }
158            None
159        }
160        _ => None,
161    }
162}
163
164impl std::fmt::Display for Format {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.write_str(self.extension())
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn from_str_accepts_canonical_and_aliases() {
176        assert_eq!("json".parse::<Format>().unwrap(), Format::Json);
177        assert_eq!("JSON5".parse::<Format>().unwrap(), Format::Json5);
178        assert_eq!("yaml".parse::<Format>().unwrap(), Format::Yaml);
179        assert_eq!("yml".parse::<Format>().unwrap(), Format::Yaml);
180        assert_eq!("toml".parse::<Format>().unwrap(), Format::Toml);
181    }
182
183    #[test]
184    fn from_str_rejects_unknown() {
185        let err = "xml".parse::<Format>().unwrap_err();
186        assert!(err.to_string().contains("unknown format"));
187    }
188
189    #[test]
190    fn from_path_detects_supported_extensions() {
191        assert_eq!(
192            Format::from_path(Path::new("a.json")).unwrap(),
193            Format::Json
194        );
195        assert_eq!(
196            Format::from_path(Path::new("a.JSON5")).unwrap(),
197            Format::Json5
198        );
199        assert_eq!(Format::from_path(Path::new("a.yml")).unwrap(), Format::Yaml);
200        assert_eq!(
201            Format::from_path(Path::new("a.toml")).unwrap(),
202            Format::Toml
203        );
204    }
205
206    #[test]
207    fn from_path_rejects_missing_or_unknown_extension() {
208        assert!(Format::from_path(Path::new("a")).is_err());
209        assert!(Format::from_path(Path::new("a.ini")).is_err());
210    }
211
212    #[test]
213    fn display_matches_extension() {
214        assert_eq!(Format::Json.to_string(), "json");
215        assert_eq!(Format::Json5.to_string(), "json5");
216        assert_eq!(Format::Yaml.to_string(), "yaml");
217        assert_eq!(Format::Toml.to_string(), "toml");
218    }
219
220    #[test]
221    fn parse_and_serialize_round_trip_for_all_formats() {
222        for (fmt, text) in [
223            (Format::Json, r#"{"a":1}"#),
224            (Format::Json5, "{ a: 1 }"),
225            (Format::Yaml, "a: 1\n"),
226            (Format::Toml, "a = 1\n"),
227        ] {
228            let v = fmt.parse(text).unwrap();
229            let out = fmt.serialize(&v).unwrap();
230            assert!(out.ends_with('\n'));
231            assert_eq!(fmt.parse(&out).unwrap(), v);
232        }
233    }
234
235    #[test]
236    fn toml_rejects_array_root() {
237        let v: Value = serde_json::json!([1, 2, 3]);
238        let err = Format::Toml.serialize(&v).unwrap_err();
239        assert!(err.to_string().contains("table"), "got: {err}");
240    }
241
242    #[test]
243    fn toml_rejects_null_values() {
244        let v: Value = serde_json::json!({ "outer": { "inner": null } });
245        let err = Format::Toml.serialize(&v).unwrap_err();
246        assert!(err.to_string().contains("null"), "got: {err}");
247        assert!(err.to_string().contains("outer.inner"), "got: {err}");
248    }
249
250    #[test]
251    fn toml_rejects_null_inside_array() {
252        let v: Value = serde_json::json!({ "items": [1, null, 3] });
253        let err = Format::Toml.serialize(&v).unwrap_err();
254        assert!(err.to_string().contains("null"), "got: {err}");
255        assert!(err.to_string().contains("items[1]"), "got: {err}");
256    }
257
258    #[test]
259    fn cross_format_compatibility_excludes_toml() {
260        assert!(Format::Json.is_cross_format_compatible());
261        assert!(Format::Json5.is_cross_format_compatible());
262        assert!(Format::Yaml.is_cross_format_compatible());
263        assert!(!Format::Toml.is_cross_format_compatible());
264    }
265}