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}
21
22impl Format {
23    /// Canonical file extension (without the leading dot).
24    pub fn extension(self) -> &'static str {
25        match self {
26            Format::Json => "json",
27            Format::Json5 => "json5",
28            Format::Yaml => "yaml",
29        }
30    }
31
32    /// Best-effort detection of a format from a file path's extension.
33    pub fn from_path(path: &Path) -> Result<Self> {
34        let ext = path
35            .extension()
36            .and_then(|e| e.to_str())
37            .map(|e| e.to_ascii_lowercase());
38        match ext.as_deref() {
39            Some("json") => Ok(Format::Json),
40            Some("json5") => Ok(Format::Json5),
41            Some("yaml" | "yml") => Ok(Format::Yaml),
42            _ => Err(Error::UnknownFormat(path.to_path_buf())),
43        }
44    }
45
46    /// Parse a string in this format into a generic [`Value`].
47    pub fn parse(self, input: &str) -> Result<Value> {
48        match self {
49            Format::Json => Ok(serde_json::from_str(input)?),
50            Format::Json5 => Ok(json5::from_str(input)?),
51            Format::Yaml => Ok(serde_yaml::from_str(input)?),
52        }
53    }
54
55    /// Serialize a [`Value`] in this format. The output is always
56    /// pretty-printed with newline-terminated content.
57    pub fn serialize(self, value: &Value) -> Result<String> {
58        let mut out = match self {
59            Format::Json => serde_json::to_string_pretty(value)?,
60            Format::Json5 => json5::to_string(value)?,
61            Format::Yaml => serde_yaml::to_string(value)?,
62        };
63        if !out.ends_with('\n') {
64            out.push('\n');
65        }
66        Ok(out)
67    }
68
69    /// Read and parse a file in this format.
70    pub fn load(self, path: &Path) -> Result<Value> {
71        let text = fs::read_to_string(path)?;
72        self.parse(&text)
73    }
74}
75
76impl FromStr for Format {
77    type Err = Error;
78
79    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
80        match s.to_ascii_lowercase().as_str() {
81            "json" => Ok(Format::Json),
82            "json5" => Ok(Format::Json5),
83            "yaml" | "yml" => Ok(Format::Yaml),
84            other => Err(Error::Usage(format!(
85                "unknown format `{other}`; expected json, json5, or yaml"
86            ))),
87        }
88    }
89}
90
91impl std::fmt::Display for Format {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.write_str(self.extension())
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn from_str_accepts_canonical_and_aliases() {
103        assert_eq!("json".parse::<Format>().unwrap(), Format::Json);
104        assert_eq!("JSON5".parse::<Format>().unwrap(), Format::Json5);
105        assert_eq!("yaml".parse::<Format>().unwrap(), Format::Yaml);
106        assert_eq!("yml".parse::<Format>().unwrap(), Format::Yaml);
107    }
108
109    #[test]
110    fn from_str_rejects_unknown() {
111        let err = "xml".parse::<Format>().unwrap_err();
112        assert!(err.to_string().contains("unknown format"));
113    }
114
115    #[test]
116    fn from_path_detects_supported_extensions() {
117        assert_eq!(
118            Format::from_path(Path::new("a.json")).unwrap(),
119            Format::Json
120        );
121        assert_eq!(
122            Format::from_path(Path::new("a.JSON5")).unwrap(),
123            Format::Json5
124        );
125        assert_eq!(Format::from_path(Path::new("a.yml")).unwrap(), Format::Yaml);
126    }
127
128    #[test]
129    fn from_path_rejects_missing_or_unknown_extension() {
130        assert!(Format::from_path(Path::new("a")).is_err());
131        assert!(Format::from_path(Path::new("a.toml")).is_err());
132    }
133
134    #[test]
135    fn display_matches_extension() {
136        assert_eq!(Format::Json.to_string(), "json");
137        assert_eq!(Format::Json5.to_string(), "json5");
138        assert_eq!(Format::Yaml.to_string(), "yaml");
139    }
140
141    #[test]
142    fn parse_and_serialize_round_trip_for_all_formats() {
143        for (fmt, text) in [
144            (Format::Json, r#"{"a":1}"#),
145            (Format::Json5, "{ a: 1 }"),
146            (Format::Yaml, "a: 1\n"),
147        ] {
148            let v = fmt.parse(text).unwrap();
149            let out = fmt.serialize(&v).unwrap();
150            assert!(out.ends_with('\n'));
151            assert_eq!(fmt.parse(&out).unwrap(), v);
152        }
153    }
154}