Skip to main content

knf/
format.rs

1//! Format detection, parsing and emission.
2//!
3//! Values remain native throughout parsing, merging and emission.
4
5use std::fmt;
6use std::path::{Path, PathBuf};
7
8use crate::ConfigValue;
9use anyhow::{Context, bail};
10
11/// The two supported native formats. A pipeline selects one for every stage.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Format {
14    Json,
15    Toml,
16}
17
18impl Format {
19    /// Infers a format from a file extension. `None` means "no opinion" — the
20    /// caller decides whether that is an error or a cue to fall back.
21    pub fn from_path(path: &Path) -> Option<Self> {
22        let ext = path.extension()?.to_str()?;
23        if ext.eq_ignore_ascii_case("json") {
24            Some(Self::Json)
25        } else if ext.eq_ignore_ascii_case("toml") {
26            Some(Self::Toml)
27        } else {
28            None
29        }
30    }
31
32    /// The canonical file extension for this format.
33    pub fn extension(self) -> &'static str {
34        match self {
35            Self::Json => "json",
36            Self::Toml => "toml",
37        }
38    }
39}
40
41impl fmt::Display for Format {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        f.write_str(self.extension())
44    }
45}
46
47/// Which input a parse error came from.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum SourceName {
50    File(PathBuf),
51    Stdin,
52}
53
54impl fmt::Display for SourceName {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::File(p) => write!(f, "{}", p.display()),
58            Self::Stdin => f.write_str("<stdin>"),
59        }
60    }
61}
62
63mod private {
64    pub trait Sealed {}
65    impl Sealed for serde_json::Value {}
66    impl Sealed for toml::Value {}
67}
68
69/// Parsing and emission for the two supported native formats.
70///
71/// Sealed to JSON and TOML; structural algorithms use [`ConfigValue`].
72pub trait ConfigFormat: ConfigValue + private::Sealed {
73    /// The format represented by this native value.
74    const FORMAT: Format;
75    /// Parse a document. Top-level object validation happens in [`parse`].
76    fn parse_document(text: &str) -> anyhow::Result<Self>;
77    /// Parse one value, falling back to the original text as a string.
78    fn parse_inline(text: String) -> Self;
79    /// Serialize directly in this native format.
80    fn serialize(&self, pretty: bool) -> anyhow::Result<String>;
81}
82
83impl ConfigFormat for serde_json::Value {
84    const FORMAT: Format = Format::Json;
85    fn parse_document(text: &str) -> anyhow::Result<Self> {
86        Ok(serde_json::from_str(text)?)
87    }
88    fn parse_inline(text: String) -> Self {
89        crate::inline::json_or_string(text)
90    }
91    fn serialize(&self, pretty: bool) -> anyhow::Result<String> {
92        Ok(if pretty {
93            serde_json::to_string_pretty(self)?
94        } else {
95            serde_json::to_string(self)?
96        })
97    }
98}
99
100impl ConfigFormat for toml::Value {
101    const FORMAT: Format = Format::Toml;
102    fn parse_document(text: &str) -> anyhow::Result<Self> {
103        Ok(toml::from_str(text)?)
104    }
105    fn parse_inline(text: String) -> Self {
106        crate::inline::toml_or_string(text)
107    }
108    fn serialize(&self, pretty: bool) -> anyhow::Result<String> {
109        Ok(if pretty {
110            toml::to_string_pretty(self)?
111        } else {
112            toml::to_string(self)?
113        })
114    }
115}
116
117/// Parse a native document, requiring an object/table root.
118pub fn parse<V: ConfigFormat>(text: &str, source: &SourceName) -> anyhow::Result<V> {
119    let label = match V::FORMAT {
120        Format::Json => "JSON",
121        Format::Toml => "TOML",
122    };
123    let value = V::parse_document(text).with_context(|| format!("{source}: invalid {label}"))?;
124    if value.as_object().is_none() {
125        bail!(
126            "{source}: expected an object at the top level, found {}",
127            value.kind()
128        );
129    }
130    Ok(value)
131}
132
133/// Emit a native value in its own format, with a trailing newline.
134pub fn emit<V: ConfigFormat>(value: V, pretty: bool) -> anyhow::Result<String> {
135    Ok(ensure_trailing_newline(value.serialize(pretty)?))
136}
137
138fn ensure_trailing_newline(mut s: String) -> String {
139    if !s.ends_with('\n') {
140        s.push('\n');
141    }
142    s
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn extension_inference() {
151        assert_eq!(Format::from_path(Path::new("a.json")), Some(Format::Json));
152        assert_eq!(Format::from_path(Path::new("a.TOML")), Some(Format::Toml));
153        assert_eq!(Format::from_path(Path::new("a.yaml")), None);
154        assert_eq!(Format::from_path(Path::new("a")), None);
155    }
156
157    #[test]
158    fn top_level_must_be_an_object() {
159        let err = parse::<serde_json::Value>("[1,2]", &SourceName::Stdin).unwrap_err();
160        assert!(err.to_string().contains("found array"), "{err}");
161    }
162}