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/// Where a layer came from. Used only for error messages — provenance is never
53/// threaded through the merge itself.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum SourceName {
56    File(PathBuf),
57    Stdin,
58    Set(String),
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            Self::Set(expr) => write!(f, "--set {expr}"),
67        }
68    }
69}
70
71/// Parses one layer into the merge IR.
72///
73/// Enforces §2.3: every input must be an object at the top level. A bare array
74/// or string root is legal JSON but is not a config, cannot be emitted as TOML,
75/// and produces nonsense under last-wins.
76pub fn parse(format: Format, text: &str, source: &SourceName) -> anyhow::Result<Value> {
77    let value = match format {
78        Format::Json => {
79            let native: serde_json::Value =
80                serde_json::from_str(text).with_context(|| format!("{source}: invalid JSON"))?;
81            value::from_json(native)
82        }
83        Format::Toml => {
84            let native: toml::Value =
85                toml::from_str(text).with_context(|| format!("{source}: invalid TOML"))?;
86            value::from_toml(native)
87        }
88    };
89    if !matches!(value, Value::Object(_)) {
90        bail!(
91            "{source}: expected an object at the top level, found {}",
92            value.kind()
93        );
94    }
95    Ok(value)
96}
97
98/// Converts the merged IR into `format` and serializes it.
99///
100/// `sources` is the layer list, used only to name the file behind a null when
101/// TOML conversion rejects one. It is the caller's, not the merge's: provenance
102/// is never threaded through the merge itself. Pass `&[]` when there is nothing
103/// to attribute.
104pub fn emit(
105    value: Value,
106    format: Format,
107    pretty: bool,
108    sources: &[(SourceName, Value)],
109) -> anyhow::Result<String> {
110    let text = match format {
111        Format::Json => {
112            let native = value::to_json(value);
113            if pretty {
114                serde_json::to_string_pretty(&native)?
115            } else {
116                serde_json::to_string(&native)?
117            }
118        }
119        Format::Toml => {
120            let native = value::to_toml(value).map_err(|e| e.with_origins(sources))?;
121            if pretty {
122                toml::to_string_pretty(&native)?
123            } else {
124                toml::to_string(&native)?
125            }
126        }
127    };
128    Ok(ensure_trailing_newline(text))
129}
130
131fn ensure_trailing_newline(mut s: String) -> String {
132    if !s.ends_with('\n') {
133        s.push('\n');
134    }
135    s
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn extension_inference() {
144        assert_eq!(Format::from_path(Path::new("a.json")), Some(Format::Json));
145        assert_eq!(Format::from_path(Path::new("a.TOML")), Some(Format::Toml));
146        assert_eq!(Format::from_path(Path::new("a.yaml")), None);
147        assert_eq!(Format::from_path(Path::new("a")), None);
148    }
149
150    #[test]
151    fn top_level_must_be_an_object() {
152        let err = parse(Format::Json, "[1,2]", &SourceName::Stdin).unwrap_err();
153        assert!(err.to_string().contains("found array"), "{err}");
154    }
155}