use std::fmt;
use std::path::Path;
use serde_json::Value;
use crate::error::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Format {
Json,
Toml,
Yaml,
}
impl fmt::Display for Format {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Format::Json => f.write_str("JSON"),
Format::Toml => f.write_str("TOML"),
Format::Yaml => f.write_str("YAML"),
}
}
}
impl Format {
pub fn from_path(path: &Path) -> Result<Self, Error> {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.map(str::to_ascii_lowercase);
match ext.as_deref() {
Some("json") => Ok(Format::Json),
Some("toml") => Ok(Format::Toml),
Some("yaml" | "yml") => Ok(Format::Yaml),
_ => Err(Error::UnknownExtension {
path: path.to_path_buf(),
}),
}
}
pub(crate) fn parse(self, text: &str, origin: Option<&Path>) -> Result<Value, Error> {
match self {
Format::Json => parse_json(text, origin),
Format::Toml => parse_toml(text, origin),
Format::Yaml => parse_yaml(text, origin),
}
}
}
pub(crate) fn merge(base: &mut Value, overlay: Value) {
match (base, overlay) {
(Value::Object(base_map), Value::Object(overlay_map)) => {
for (key, value) in overlay_map {
match base_map.get_mut(&key) {
Some(slot) => merge(slot, value),
None => {
base_map.insert(key, value);
}
}
}
}
(slot, value) => *slot = value,
}
}
fn parse_error(origin: Option<&Path>, format: Format, message: String) -> Error {
Error::Parse {
origin: origin.map(Path::to_path_buf),
format,
message,
}
}
fn parse_json(text: &str, origin: Option<&Path>) -> Result<Value, Error> {
serde_json::from_str(text).map_err(|err| parse_error(origin, Format::Json, err.to_string()))
}
#[cfg(feature = "toml")]
fn parse_toml(text: &str, origin: Option<&Path>) -> Result<Value, Error> {
let value: toml::Value = toml::from_str(text)
.map_err(|err: toml::de::Error| parse_error(origin, Format::Toml, err.to_string()))?;
toml_to_json(value).map_err(|reason| parse_error(origin, Format::Toml, reason))
}
#[cfg(not(feature = "toml"))]
fn parse_toml(_text: &str, _origin: Option<&Path>) -> Result<Value, Error> {
Err(Error::FeatureDisabled {
format: Format::Toml,
feature: "toml",
})
}
#[cfg(feature = "yaml")]
fn parse_yaml(text: &str, origin: Option<&Path>) -> Result<Value, Error> {
let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(text)
.map_err(|err| parse_error(origin, Format::Yaml, err.to_string()))?;
yaml_to_json(value).map_err(|reason| parse_error(origin, Format::Yaml, reason))
}
#[cfg(not(feature = "yaml"))]
fn parse_yaml(_text: &str, _origin: Option<&Path>) -> Result<Value, Error> {
Err(Error::FeatureDisabled {
format: Format::Yaml,
feature: "yaml",
})
}
#[cfg(feature = "toml")]
fn toml_to_json(value: toml::Value) -> Result<Value, String> {
Ok(match value {
toml::Value::String(s) => Value::String(s),
toml::Value::Integer(i) => Value::Number(i.into()),
toml::Value::Float(f) => Value::Number(
serde_json::Number::from_f64(f)
.ok_or_else(|| format!("non-finite float `{f}` is not supported"))?,
),
toml::Value::Boolean(b) => Value::Bool(b),
toml::Value::Datetime(dt) => Value::String(dt.to_string()),
toml::Value::Array(items) => Value::Array(
items
.into_iter()
.map(toml_to_json)
.collect::<Result<_, _>>()?,
),
toml::Value::Table(table) => {
let mut object = serde_json::Map::with_capacity(table.len());
for (key, value) in table {
object.insert(key, toml_to_json(value)?);
}
Value::Object(object)
}
})
}
#[cfg(feature = "yaml")]
fn yaml_to_json(value: serde_yaml_ng::Value) -> Result<Value, String> {
use serde_yaml_ng::Value as Yaml;
Ok(match value {
Yaml::Null => Value::Null,
Yaml::Bool(b) => Value::Bool(b),
Yaml::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Number(i.into())
} else if let Some(u) = n.as_u64() {
Value::Number(u.into())
} else if let Some(f) = n.as_f64() {
Value::Number(
serde_json::Number::from_f64(f)
.ok_or_else(|| format!("non-finite float `{f}` is not supported"))?,
)
} else {
return Err(format!("unrepresentable YAML number `{n}`"));
}
}
Yaml::String(s) => Value::String(s),
Yaml::Sequence(items) => Value::Array(
items
.into_iter()
.map(yaml_to_json)
.collect::<Result<_, _>>()?,
),
Yaml::Mapping(mapping) => {
let mut object = serde_json::Map::with_capacity(mapping.len());
for (key, value) in mapping {
let key = match key {
Yaml::String(s) => s,
Yaml::Bool(b) => b.to_string(),
Yaml::Number(n) => n.to_string(),
other => return Err(format!("unsupported YAML mapping key: {other:?}")),
};
object.insert(key, yaml_to_json(value)?);
}
Value::Object(object)
}
Yaml::Tagged(tagged) => yaml_to_json(tagged.value)?,
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn detect_format_from_extension() {
assert_eq!(
Format::from_path(Path::new("a/app.toml")).unwrap(),
Format::Toml
);
assert_eq!(
Format::from_path(Path::new("app.yaml")).unwrap(),
Format::Yaml
);
assert_eq!(
Format::from_path(Path::new("app.yml")).unwrap(),
Format::Yaml
);
assert_eq!(
Format::from_path(Path::new("app.JSON")).unwrap(),
Format::Json
);
}
#[test]
fn detect_format_unknown_extension() {
for path in ["app", "app.ini", "app.conf"] {
let err = Format::from_path(Path::new(path)).unwrap_err();
assert!(
matches!(err, Error::UnknownExtension { .. }),
"{path}: {err}"
);
}
}
#[test]
fn merge_semantics() {
let mut base = json!({
"server": { "host": "localhost", "port": 8080 },
"tags": ["a", "b"],
"debug": false,
});
let overlay = json!({
"server": { "port": 9090 },
"tags": ["c"],
"debug": null,
"extra": 1,
});
merge(&mut base, overlay);
assert_eq!(
base,
json!({
"server": { "host": "localhost", "port": 9090 },
"tags": ["c"],
"debug": null,
"extra": 1,
})
);
}
#[test]
fn merge_scalar_replaces_object() {
let mut base = json!({ "a": { "b": 1 } });
merge(&mut base, json!({ "a": 42 }));
assert_eq!(base, json!({ "a": 42 }));
}
#[test]
fn json_parse_error_has_origin() {
let err = Format::Json
.parse("{ bad", Some(Path::new("cfg.json")))
.unwrap_err();
assert!(matches!(
err,
Error::Parse {
format: Format::Json,
..
}
));
assert!(err.to_string().contains("cfg.json"), "{err}");
}
#[cfg(feature = "toml")]
#[test]
fn toml_datetime_becomes_string() {
let value = Format::Toml
.parse("ts = 2026-01-02T03:04:05Z", None)
.unwrap();
assert_eq!(value, json!({ "ts": "2026-01-02T03:04:05Z" }));
}
#[cfg(feature = "toml")]
#[test]
fn toml_non_finite_float_is_rejected() {
let err = Format::Toml.parse("f = nan", None).unwrap_err();
assert!(
matches!(
err,
Error::Parse {
format: Format::Toml,
..
}
),
"{err}"
);
}
#[cfg(feature = "yaml")]
#[test]
fn yaml_scalar_keys_are_stringified() {
let value = Format::Yaml
.parse("1: one\ntrue: yes_value\n", None)
.unwrap();
assert_eq!(value, json!({ "1": "one", "true": "yes_value" }));
}
#[cfg(feature = "yaml")]
#[test]
fn yaml_complex_key_is_rejected() {
let err = Format::Yaml.parse("? [a, b]\n: value\n", None).unwrap_err();
assert!(
matches!(
err,
Error::Parse {
format: Format::Yaml,
..
}
),
"{err}"
);
}
#[cfg(feature = "yaml")]
#[test]
fn yaml_tagged_value_is_unwrapped() {
let value = Format::Yaml.parse("v: !Custom 7\n", None).unwrap();
assert_eq!(value, json!({ "v": 7 }));
}
}