config_disassembler/
format.rs1use std::fs;
7use std::path::Path;
8use std::str::FromStr;
9
10use serde_json::Value;
11
12use crate::error::{Error, Result};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Format {
17 Json,
18 Json5,
19 Yaml,
20 Toml,
27}
28
29impl Format {
30 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 pub fn is_cross_format_compatible(self) -> bool {
43 !matches!(self, Format::Toml)
44 }
45
46 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 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 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 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
109fn 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 toml::to_string_pretty(value).map_err(|e| Error::Invalid(format!("toml serialize error: {e}")))
132}
133
134fn 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}