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 crate::{Value, value};
11use anyhow::{Context, bail};
12
13/// v1 ships JSON and TOML only. Adding a format is one arm of these matches;
14/// removing one is a breaking change.
15///
16/// No `clap::ValueEnum` here โ€” this crate has no clap. `knf-cli` parses `-f`
17/// into a local enum and converts, which the orphan rule would force anyway.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Format {
20    Json,
21    Toml,
22}
23
24impl Format {
25    /// Infers a format from a file extension. `None` means "no opinion" โ€” the
26    /// caller decides whether that is an error or a cue to fall back.
27    pub fn from_path(path: &Path) -> Option<Self> {
28        let ext = path.extension()?.to_str()?;
29        if ext.eq_ignore_ascii_case("json") {
30            Some(Self::Json)
31        } else if ext.eq_ignore_ascii_case("toml") {
32            Some(Self::Toml)
33        } else {
34            None
35        }
36    }
37
38    /// The canonical file extension for this format.
39    pub fn extension(self) -> &'static str {
40        match self {
41            Self::Json => "json",
42            Self::Toml => "toml",
43        }
44    }
45}
46
47impl fmt::Display for Format {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.write_str(self.extension())
50    }
51}
52
53/// Which input a parse error came from. Names an input being *read*, so there
54/// is no variant for `--set`: a bad `--set` expression is rejected by
55/// [`PathLeaf`](crate::PathLeaf) during argument parsing, long before anything
56/// reaches here.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum SourceName {
59    File(PathBuf),
60    Stdin,
61}
62
63impl fmt::Display for SourceName {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::File(p) => write!(f, "{}", p.display()),
67            Self::Stdin => f.write_str("<stdin>"),
68        }
69    }
70}
71
72/// Parses one layer into the merge IR.
73///
74/// Enforces ยง2.3: every input must be an object at the top level. A bare array
75/// or string root is legal JSON but is not a config, cannot be emitted as TOML,
76/// and produces nonsense under last-wins.
77pub fn parse(format: Format, text: &str, source: &SourceName) -> anyhow::Result<Value> {
78    let value = match format {
79        Format::Json => {
80            let native: serde_json::Value =
81                serde_json::from_str(text).with_context(|| format!("{source}: invalid JSON"))?;
82            value::from_json(native)
83        }
84        Format::Toml => {
85            let native: toml::Value =
86                toml::from_str(text).with_context(|| format!("{source}: invalid TOML"))?;
87            value::from_toml(native)
88        }
89    };
90    if !matches!(value, Value::Object(_)) {
91        bail!(
92            "{source}: expected an object at the top level, found {}",
93            value.kind()
94        );
95    }
96    Ok(value)
97}
98
99/// Converts the merged IR into `format` and serializes it.
100///
101/// `null_as` substitutes a string for every null rather than failing on one.
102/// It is honoured in the TOML arm and nowhere else: JSON can hold a null
103/// perfectly well, so there is nothing there for it to rescue and substituting
104/// anyway would corrupt a document that was never in trouble.
105///
106/// Both arms can fail, and each fails on what its own format cannot spell:
107/// [`value::to_toml`] on nulls, integers past `i64::MAX` and malformed datetimes,
108/// [`value::to_json`] on infinities and NaNs. The two sets do not overlap, so each
109/// format is the escape from the other's rejection โ€” which is the help the CLI
110/// appends, and the reason neither library error names a flag. Both report key
111/// paths alone: nothing about the inputs survives the merge for them to name.
112pub fn emit(
113    value: Value,
114    format: Format,
115    pretty: bool,
116    null_as: Option<&str>,
117) -> anyhow::Result<String> {
118    let text = match format {
119        Format::Json => {
120            let native = value::to_json(value)?;
121            if pretty {
122                serde_json::to_string_pretty(&native)?
123            } else {
124                serde_json::to_string(&native)?
125            }
126        }
127        Format::Toml => {
128            let mut value = value;
129            if let Some(placeholder) = null_as {
130                value::replace_nulls(&mut value, placeholder);
131            }
132            let native = value::to_toml(value)?;
133            if pretty {
134                toml::to_string_pretty(&native)?
135            } else {
136                toml::to_string(&native)?
137            }
138        }
139    };
140    Ok(ensure_trailing_newline(text))
141}
142
143fn ensure_trailing_newline(mut s: String) -> String {
144    if !s.ends_with('\n') {
145        s.push('\n');
146    }
147    s
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn extension_inference() {
156        assert_eq!(Format::from_path(Path::new("a.json")), Some(Format::Json));
157        assert_eq!(Format::from_path(Path::new("a.TOML")), Some(Format::Toml));
158        assert_eq!(Format::from_path(Path::new("a.yaml")), None);
159        assert_eq!(Format::from_path(Path::new("a")), None);
160    }
161
162    #[test]
163    fn top_level_must_be_an_object() {
164        let err = parse(Format::Json, "[1,2]", &SourceName::Stdin).unwrap_err();
165        assert!(err.to_string().contains("found array"), "{err}");
166    }
167}