Skip to main content

confy_rs/
source.rs

1//! Format detection, parsing and layered value merging.
2//!
3
4use std::fmt;
5use std::path::Path;
6
7use serde_json::Value;
8
9use crate::error::Error;
10
11/// Supported configuration file formats.
12///
13/// The format of a file is detected from its extension by default, see
14/// [`Format::from_path`].
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16#[non_exhaustive]
17pub enum Format {
18    /// JSON (`.json`). Always available.
19    Json,
20    /// TOML (`.toml`). Requires the `toml` feature (enabled by default).
21    Toml,
22    /// YAML (`.yaml` / `.yml`). Requires the `yaml` feature.
23    Yaml,
24}
25
26impl fmt::Display for Format {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            Format::Json => f.write_str("JSON"),
30            Format::Toml => f.write_str("TOML"),
31            Format::Yaml => f.write_str("YAML"),
32        }
33    }
34}
35
36impl Format {
37    /// Detects the format of a file from its extension (case-insensitive).
38    ///
39    /// # Errors
40    ///
41    /// Returns [`Error::UnknownExtension`] when the extension is missing or
42    /// not one of `.toml`, `.yaml`, `.yml`, `.json`.
43    pub fn from_path(path: &Path) -> Result<Self, Error> {
44        // Extract the extension and lowercase it before matching
45        let ext = path
46            .extension()
47            .and_then(|ext| ext.to_str())
48            .map(str::to_ascii_lowercase);
49        match ext.as_deref() {
50            Some("json") => Ok(Format::Json),
51            Some("toml") => Ok(Format::Toml),
52            Some("yaml" | "yml") => Ok(Format::Yaml),
53            _ => Err(Error::UnknownExtension {
54                path: path.to_path_buf(),
55            }),
56        }
57    }
58
59    /// Parses text in this format into the unified JSON intermediate
60    /// representation. `origin` is only used to point at the source in
61    /// error messages (`None` means the text came from a string).
62    pub(crate) fn parse(self, text: &str, origin: Option<&Path>) -> Result<Value, Error> {
63        match self {
64            Format::Json => parse_json(text, origin),
65            Format::Toml => parse_toml(text, origin),
66            Format::Yaml => parse_yaml(text, origin),
67        }
68    }
69}
70
71/// Deep-merges two config trees, applying `overlay` on top of `base`.
72///
73/// Merge semantics:
74/// - when both sides are objects, merge recursively per key;
75/// - otherwise (scalar / array / null) the overlay replaces the value.
76pub(crate) fn merge(base: &mut Value, overlay: Value) {
77    match (base, overlay) {
78        (Value::Object(base_map), Value::Object(overlay_map)) => {
79            for (key, value) in overlay_map {
80                match base_map.get_mut(&key) {
81                    Some(slot) => merge(slot, value),
82                    None => {
83                        base_map.insert(key, value);
84                    }
85                }
86            }
87        }
88        (slot, value) => *slot = value,
89    }
90}
91
92/// Builds a parse error carrying the origin and format context.
93fn parse_error(origin: Option<&Path>, format: Format, message: String) -> Error {
94    Error::Parse {
95        origin: origin.map(Path::to_path_buf),
96        format,
97        message,
98    }
99}
100
101/// Parses JSON text.
102fn parse_json(text: &str, origin: Option<&Path>) -> Result<Value, Error> {
103    serde_json::from_str(text).map_err(|err| parse_error(origin, Format::Json, err.to_string()))
104}
105
106/// Parses TOML text and converts it to the JSON intermediate representation.
107#[cfg(feature = "toml")]
108fn parse_toml(text: &str, origin: Option<&Path>) -> Result<Value, Error> {
109    let value: toml::Value = toml::from_str(text)
110        .map_err(|err: toml::de::Error| parse_error(origin, Format::Toml, err.to_string()))?;
111    toml_to_json(value).map_err(|reason| parse_error(origin, Format::Toml, reason))
112}
113
114/// Fallback used when the `toml` feature is disabled.
115#[cfg(not(feature = "toml"))]
116fn parse_toml(_text: &str, _origin: Option<&Path>) -> Result<Value, Error> {
117    Err(Error::FeatureDisabled {
118        format: Format::Toml,
119        feature: "toml",
120    })
121}
122
123/// Parses YAML text and converts it to the JSON intermediate representation.
124#[cfg(feature = "yaml")]
125fn parse_yaml(text: &str, origin: Option<&Path>) -> Result<Value, Error> {
126    let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(text)
127        .map_err(|err| parse_error(origin, Format::Yaml, err.to_string()))?;
128    yaml_to_json(value).map_err(|reason| parse_error(origin, Format::Yaml, reason))
129}
130
131/// Fallback used when the `yaml` feature is disabled.
132#[cfg(not(feature = "yaml"))]
133fn parse_yaml(_text: &str, _origin: Option<&Path>) -> Result<Value, Error> {
134    Err(Error::FeatureDisabled {
135        format: Format::Yaml,
136        feature: "yaml",
137    })
138}
139
140/// Converts a `toml::Value` into a `serde_json::Value`.
141///
142/// Bypassing the serde bridge is deliberate: the bridge encodes datetimes
143/// as an internal `$__toml_private_datetime` marker map, so the conversion
144/// is done by hand and datetimes become their string form (e.g. RFC 3339).
145#[cfg(feature = "toml")]
146fn toml_to_json(value: toml::Value) -> Result<Value, String> {
147    Ok(match value {
148        toml::Value::String(s) => Value::String(s),
149        toml::Value::Integer(i) => Value::Number(i.into()),
150        toml::Value::Float(f) => Value::Number(
151            // JSON numbers cannot represent nan / inf
152            serde_json::Number::from_f64(f)
153                .ok_or_else(|| format!("non-finite float `{f}` is not supported"))?,
154        ),
155        toml::Value::Boolean(b) => Value::Bool(b),
156        toml::Value::Datetime(dt) => Value::String(dt.to_string()),
157        toml::Value::Array(items) => Value::Array(
158            items
159                .into_iter()
160                .map(toml_to_json)
161                .collect::<Result<_, _>>()?,
162        ),
163        toml::Value::Table(table) => {
164            let mut object = serde_json::Map::with_capacity(table.len());
165            for (key, value) in table {
166                object.insert(key, toml_to_json(value)?);
167            }
168            Value::Object(object)
169        }
170    })
171}
172
173/// Converts a `serde_yaml_ng::Value` into a `serde_json::Value`.
174#[cfg(feature = "yaml")]
175fn yaml_to_json(value: serde_yaml_ng::Value) -> Result<Value, String> {
176    use serde_yaml_ng::Value as Yaml;
177
178    Ok(match value {
179        Yaml::Null => Value::Null,
180        Yaml::Bool(b) => Value::Bool(b),
181        Yaml::Number(n) => {
182            if let Some(i) = n.as_i64() {
183                Value::Number(i.into())
184            } else if let Some(u) = n.as_u64() {
185                Value::Number(u.into())
186            } else if let Some(f) = n.as_f64() {
187                // JSON numbers cannot represent .nan / .inf
188                Value::Number(
189                    serde_json::Number::from_f64(f)
190                        .ok_or_else(|| format!("non-finite float `{f}` is not supported"))?,
191                )
192            } else {
193                return Err(format!("unrepresentable YAML number `{n}`"));
194            }
195        }
196        Yaml::String(s) => Value::String(s),
197        Yaml::Sequence(items) => Value::Array(
198            items
199                .into_iter()
200                .map(yaml_to_json)
201                .collect::<Result<_, _>>()?,
202        ),
203        Yaml::Mapping(mapping) => {
204            let mut object = serde_json::Map::with_capacity(mapping.len());
205            for (key, value) in mapping {
206                // YAML allows non-string keys: numbers/bools are stringified,
207                // complex keys are rejected
208                let key = match key {
209                    Yaml::String(s) => s,
210                    Yaml::Bool(b) => b.to_string(),
211                    Yaml::Number(n) => n.to_string(),
212                    other => return Err(format!("unsupported YAML mapping key: {other:?}")),
213                };
214                object.insert(key, yaml_to_json(value)?);
215            }
216            Value::Object(object)
217        }
218        // Tagged values (e.g. `!Custom`): ignore the tag, take the inner value
219        Yaml::Tagged(tagged) => yaml_to_json(tagged.value)?,
220    })
221}
222
223#[cfg(test)]
224mod tests {
225    use serde_json::json;
226
227    use super::*;
228
229    /// Extension detection: case-insensitive, four supported extensions.
230    #[test]
231    fn detect_format_from_extension() {
232        assert_eq!(
233            Format::from_path(Path::new("a/app.toml")).unwrap(),
234            Format::Toml
235        );
236        assert_eq!(
237            Format::from_path(Path::new("app.yaml")).unwrap(),
238            Format::Yaml
239        );
240        assert_eq!(
241            Format::from_path(Path::new("app.yml")).unwrap(),
242            Format::Yaml
243        );
244        assert_eq!(
245            Format::from_path(Path::new("app.JSON")).unwrap(),
246            Format::Json
247        );
248    }
249
250    /// A missing or unknown extension should yield UnknownExtension.
251    #[test]
252    fn detect_format_unknown_extension() {
253        for path in ["app", "app.ini", "app.conf"] {
254            let err = Format::from_path(Path::new(path)).unwrap_err();
255            assert!(
256                matches!(err, Error::UnknownExtension { .. }),
257                "{path}: {err}"
258            );
259        }
260    }
261
262    /// Deep merge: objects recurse; scalars/arrays/null replace; new keys
263    /// are inserted.
264    #[test]
265    fn merge_semantics() {
266        let mut base = json!({
267            "server": { "host": "localhost", "port": 8080 },
268            "tags": ["a", "b"],
269            "debug": false,
270        });
271        let overlay = json!({
272            "server": { "port": 9090 },
273            "tags": ["c"],
274            "debug": null,
275            "extra": 1,
276        });
277        merge(&mut base, overlay);
278        assert_eq!(
279            base,
280            json!({
281                "server": { "host": "localhost", "port": 9090 },
282                "tags": ["c"],
283                "debug": null,
284                "extra": 1,
285            })
286        );
287    }
288
289    /// Merging non-objects: the overlay simply replaces the base.
290    #[test]
291    fn merge_scalar_replaces_object() {
292        let mut base = json!({ "a": { "b": 1 } });
293        merge(&mut base, json!({ "a": 42 }));
294        assert_eq!(base, json!({ "a": 42 }));
295    }
296
297    /// A JSON syntax error should yield Parse with origin info.
298    #[test]
299    fn json_parse_error_has_origin() {
300        let err = Format::Json
301            .parse("{ bad", Some(Path::new("cfg.json")))
302            .unwrap_err();
303        assert!(matches!(
304            err,
305            Error::Parse {
306                format: Format::Json,
307                ..
308            }
309        ));
310        assert!(err.to_string().contains("cfg.json"), "{err}");
311    }
312
313    /// TOML datetimes should become strings, not internal marker maps.
314    #[cfg(feature = "toml")]
315    #[test]
316    fn toml_datetime_becomes_string() {
317        let value = Format::Toml
318            .parse("ts = 2026-01-02T03:04:05Z", None)
319            .unwrap();
320        assert_eq!(value, json!({ "ts": "2026-01-02T03:04:05Z" }));
321    }
322
323    /// TOML nan / inf cannot be represented as JSON numbers and should
324    /// yield Parse.
325    #[cfg(feature = "toml")]
326    #[test]
327    fn toml_non_finite_float_is_rejected() {
328        let err = Format::Toml.parse("f = nan", None).unwrap_err();
329        assert!(
330            matches!(
331                err,
332                Error::Parse {
333                    format: Format::Toml,
334                    ..
335                }
336            ),
337            "{err}"
338        );
339    }
340
341    /// YAML number/bool keys are stringified.
342    #[cfg(feature = "yaml")]
343    #[test]
344    fn yaml_scalar_keys_are_stringified() {
345        let value = Format::Yaml
346            .parse("1: one\ntrue: yes_value\n", None)
347            .unwrap();
348        assert_eq!(value, json!({ "1": "one", "true": "yes_value" }));
349    }
350
351    /// YAML complex keys (e.g. sequences) should be rejected.
352    #[cfg(feature = "yaml")]
353    #[test]
354    fn yaml_complex_key_is_rejected() {
355        let err = Format::Yaml.parse("? [a, b]\n: value\n", None).unwrap_err();
356        assert!(
357            matches!(
358                err,
359                Error::Parse {
360                    format: Format::Yaml,
361                    ..
362                }
363            ),
364            "{err}"
365        );
366    }
367
368    /// YAML tagged values unwrap to the inner value.
369    #[cfg(feature = "yaml")]
370    #[test]
371    fn yaml_tagged_value_is_unwrapped() {
372        let value = Format::Yaml.parse("v: !Custom 7\n", None).unwrap();
373        assert_eq!(value, json!({ "v": 7 }));
374    }
375}