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