knf-core 0.3.2

Load, merge and emit layered JSON and TOML configuration files
Documentation
//! Format detection, parsing and emission.
//!
//! Both directions cross the IR boundary here and nowhere else: parse yields a
//! [`Value`], emit takes one. The conversions themselves live in
//! [`crate::value`].

use std::fmt;
use std::path::{Path, PathBuf};

use crate::{Value, value};
use anyhow::{Context, bail};

/// v1 ships JSON and TOML only. Adding a format is one arm of these matches;
/// removing one is a breaking change.
///
/// No `clap::ValueEnum` here โ€” this crate has no clap. `knf-cli` parses `-f`
/// into a local enum and converts, which the orphan rule would force anyway.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
    Json,
    Toml,
}

impl Format {
    /// Infers a format from a file extension. `None` means "no opinion" โ€” the
    /// caller decides whether that is an error or a cue to fall back.
    pub fn from_path(path: &Path) -> Option<Self> {
        let ext = path.extension()?.to_str()?;
        if ext.eq_ignore_ascii_case("json") {
            Some(Self::Json)
        } else if ext.eq_ignore_ascii_case("toml") {
            Some(Self::Toml)
        } else {
            None
        }
    }

    /// The canonical file extension for this format.
    pub fn extension(self) -> &'static str {
        match self {
            Self::Json => "json",
            Self::Toml => "toml",
        }
    }
}

impl fmt::Display for Format {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.extension())
    }
}

/// Which input a parse error came from. Names an input being *read*, so there
/// is no variant for `--set`: a bad `--set` expression is rejected by
/// [`PathLeaf`](crate::PathLeaf) during argument parsing, long before anything
/// reaches here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceName {
    File(PathBuf),
    Stdin,
}

impl fmt::Display for SourceName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::File(p) => write!(f, "{}", p.display()),
            Self::Stdin => f.write_str("<stdin>"),
        }
    }
}

/// Parses one layer into the merge IR.
///
/// Enforces ยง2.3: every input must be an object at the top level. A bare array
/// or string root is legal JSON but is not a config, cannot be emitted as TOML,
/// and produces nonsense under last-wins.
pub fn parse(format: Format, text: &str, source: &SourceName) -> anyhow::Result<Value> {
    let value = match format {
        Format::Json => {
            let native: serde_json::Value =
                serde_json::from_str(text).with_context(|| format!("{source}: invalid JSON"))?;
            value::from_json(native)
        }
        Format::Toml => {
            let native: toml::Value =
                toml::from_str(text).with_context(|| format!("{source}: invalid TOML"))?;
            value::from_toml(native)
        }
    };
    if !matches!(value, Value::Object(_)) {
        bail!(
            "{source}: expected an object at the top level, found {}",
            value.kind()
        );
    }
    Ok(value)
}

/// Converts the merged IR into `format` and serializes it.
///
/// `null_as` substitutes a string for every null rather than failing on one.
/// It is honoured in the TOML arm and nowhere else: JSON can hold a null
/// perfectly well, so there is nothing there for it to rescue and substituting
/// anyway would corrupt a document that was never in trouble.
///
/// Both arms can fail, and each fails on what its own format cannot spell:
/// [`value::to_toml`] on nulls, integers past `i64::MAX` and malformed datetimes,
/// [`value::to_json`] on infinities and NaNs. The two sets do not overlap, so each
/// format is the escape from the other's rejection โ€” which is the help the CLI
/// appends, and the reason neither library error names a flag. Both report key
/// paths alone: nothing about the inputs survives the merge for them to name.
pub fn emit(
    value: Value,
    format: Format,
    pretty: bool,
    null_as: Option<&str>,
) -> anyhow::Result<String> {
    let text = match format {
        Format::Json => {
            let native = value::to_json(value)?;
            if pretty {
                serde_json::to_string_pretty(&native)?
            } else {
                serde_json::to_string(&native)?
            }
        }
        Format::Toml => {
            let mut value = value;
            if let Some(placeholder) = null_as {
                value::replace_nulls(&mut value, placeholder);
            }
            let native = value::to_toml(value)?;
            if pretty {
                toml::to_string_pretty(&native)?
            } else {
                toml::to_string(&native)?
            }
        }
    };
    Ok(ensure_trailing_newline(text))
}

fn ensure_trailing_newline(mut s: String) -> String {
    if !s.ends_with('\n') {
        s.push('\n');
    }
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extension_inference() {
        assert_eq!(Format::from_path(Path::new("a.json")), Some(Format::Json));
        assert_eq!(Format::from_path(Path::new("a.TOML")), Some(Format::Toml));
        assert_eq!(Format::from_path(Path::new("a.yaml")), None);
        assert_eq!(Format::from_path(Path::new("a")), None);
    }

    #[test]
    fn top_level_must_be_an_object() {
        let err = parse(Format::Json, "[1,2]", &SourceName::Stdin).unwrap_err();
        assert!(err.to_string().contains("found array"), "{err}");
    }
}