Skip to main content

knf/
format.rs

1//! Format detection, parsing and emission.
2//!
3//! Both directions cross the IR boundary here and nowhere else: parse yields a
4//! [`Value`], emit takes one. The conversions themselves live in
5//! [`crate::value`].
6
7use std::fmt;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, bail};
11use knf_core::Value;
12
13use crate::value;
14
15/// v1 ships JSON and TOML only. Adding a format is one arm of these matches;
16/// removing one is a breaking change.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
18pub enum Format {
19    Json,
20    Toml,
21}
22
23impl Format {
24    /// Infers a format from a file extension. `None` means "no opinion" — the
25    /// caller decides whether that is an error or a cue to fall back.
26    pub fn from_path(path: &Path) -> Option<Self> {
27        let ext = path.extension()?.to_str()?;
28        if ext.eq_ignore_ascii_case("json") {
29            Some(Self::Json)
30        } else if ext.eq_ignore_ascii_case("toml") {
31            Some(Self::Toml)
32        } else {
33            None
34        }
35    }
36
37    /// The canonical file extension for this format.
38    pub fn extension(self) -> &'static str {
39        match self {
40            Self::Json => "json",
41            Self::Toml => "toml",
42        }
43    }
44}
45
46impl fmt::Display for Format {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        f.write_str(self.extension())
49    }
50}
51
52/// Which input a parse error came from. Names an input being *read*, so there
53/// is no variant for `--set`: a bad `--set` expression is rejected by
54/// `knf-dotted` during argument parsing, long before anything reaches here.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum SourceName {
57    File(PathBuf),
58    Stdin,
59}
60
61impl fmt::Display for SourceName {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::File(p) => write!(f, "{}", p.display()),
65            Self::Stdin => f.write_str("<stdin>"),
66        }
67    }
68}
69
70/// Parses one layer into the merge IR.
71///
72/// Enforces §2.3: every input must be an object at the top level. A bare array
73/// or string root is legal JSON but is not a config, cannot be emitted as TOML,
74/// and produces nonsense under last-wins.
75pub fn parse(format: Format, text: &str, source: &SourceName) -> anyhow::Result<Value> {
76    let value = match format {
77        Format::Json => {
78            let native: serde_json::Value =
79                serde_json::from_str(text).with_context(|| format!("{source}: invalid JSON"))?;
80            value::from_json(native)
81        }
82        Format::Toml => {
83            let native: toml::Value =
84                toml::from_str(text).with_context(|| format!("{source}: invalid TOML"))?;
85            value::from_toml(native)
86        }
87    };
88    if !matches!(value, Value::Object(_)) {
89        bail!(
90            "{source}: expected an object at the top level, found {}",
91            value.kind()
92        );
93    }
94    Ok(value)
95}
96
97/// Converts the merged IR into `format` and serializes it.
98///
99/// `null_as` substitutes a string for every null rather than failing on one.
100/// It is honoured in the TOML arm and nowhere else: JSON can hold a null
101/// perfectly well, so there is nothing there for it to rescue and substituting
102/// anyway would corrupt a document that was never in trouble.
103///
104/// The null pre-check inside [`value::to_toml`] is then the only thing that can
105/// fail here, and it reports key paths alone — nothing about the inputs
106/// survives the merge for it to name.
107pub fn emit(
108    value: Value,
109    format: Format,
110    pretty: bool,
111    null_as: Option<&str>,
112) -> anyhow::Result<String> {
113    let text = match format {
114        Format::Json => {
115            let native = value::to_json(value);
116            if pretty {
117                serde_json::to_string_pretty(&native)?
118            } else {
119                serde_json::to_string(&native)?
120            }
121        }
122        Format::Toml => {
123            let mut value = value;
124            if let Some(placeholder) = null_as {
125                value::replace_nulls(&mut value, placeholder);
126            }
127            let native = value::to_toml(value)?;
128            if pretty {
129                toml::to_string_pretty(&native)?
130            } else {
131                toml::to_string(&native)?
132            }
133        }
134    };
135    Ok(ensure_trailing_newline(text))
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(Format::Json, "[1,2]", &SourceName::Stdin).unwrap_err();
160        assert!(err.to_string().contains("found array"), "{err}");
161    }
162}