Skip to main content

knf/
lib.rs

1//! Load, merge and interpolate homogeneous JSON or TOML configuration layers.
2//!
3//! [`load_layers`] returns native [`Layers`]. Match its variant, then call
4//! [`merge`], optionally [`interpolate`], and [`format::emit`] on that same
5//! value type. No stage converts JSON to TOML or TOML to JSON.
6
7pub mod format;
8pub mod fs;
9pub mod glob;
10mod inline;
11mod interp;
12mod merge;
13mod path;
14pub mod value;
15
16mod env;
17
18use std::io::Read;
19use std::path::{Path, PathBuf};
20
21use anyhow::Context;
22
23pub use env::ProcessEnv;
24pub use format::{ConfigFormat, Format};
25pub use inline::{PathLeaf, json_or_string, toml_or_string};
26pub use interp::{Cycle, Env, InterpError, Problem, Syntax, interpolate};
27pub use merge::{MergeError, MergeOptions, merge, merge_into};
28pub use path::{PathError, RefPath, Seg, render_path};
29pub use value::{ConfigObject, ConfigValue};
30
31use format::SourceName;
32
33/// The positional that means "read stdin".
34pub const STDIN: &str = "-";
35
36/// Why a positional could not be turned into a layer.
37///
38/// Reports source selection failures without frontend flag vocabulary.
39/// Format mismatches have no single source path; other input failures do.
40#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
41pub enum LoadError {
42    /// `-` was given without an explicit input format.
43    #[error("`-` reads stdin, which has no extension")]
44    StdinNeedsFormat,
45    /// Inferred input formats differ.
46    #[error("inputs mix JSON and TOML formats; layers must use one format")]
47    MixedFormats,
48    /// A positional named a directory.
49    #[error("`{}` is a directory; knf takes files as layers", path.display())]
50    Directory {
51        /// The directory that was named.
52        path: PathBuf,
53    },
54    /// A positional's extension is neither `json` nor `toml`.
55    #[error("cannot infer a format from `{}`", path.display())]
56    UnknownExtension {
57        /// The file whose extension said nothing.
58        path: PathBuf,
59    },
60}
61
62impl LoadError {
63    /// The path this failed on, or `None` for stdin and mixed formats.
64    pub fn path(&self) -> Option<&Path> {
65        match self {
66            Self::StdinNeedsFormat | Self::MixedFormats => None,
67            Self::Directory { path } | Self::UnknownExtension { path } => Some(path),
68        }
69    }
70}
71
72/// Native layers, all in one format and in the caller's original order.
73#[derive(Debug, Clone, PartialEq)]
74pub enum Layers {
75    /// JSON documents.
76    Json(Vec<serde_json::Value>),
77    /// TOML documents.
78    Toml(Vec<toml::Value>),
79}
80
81/// Resolve a single format without reading any document contents.
82///
83/// An explicit format overrides extensions. Stdin requires an explicit format;
84/// no inputs default to JSON. Directory inputs are rejected before inference.
85pub fn resolve_format<P: AsRef<Path>>(
86    paths: &[P],
87    explicit: Option<Format>,
88) -> Result<Format, LoadError> {
89    let mut selected = explicit;
90    for path in paths {
91        let path = path.as_ref();
92        if path.is_dir() {
93            return Err(LoadError::Directory {
94                path: path.to_path_buf(),
95            });
96        }
97        if explicit.is_some() {
98            continue;
99        }
100        let format = if path.as_os_str() == STDIN {
101            return Err(LoadError::StdinNeedsFormat);
102        } else {
103            Format::from_path(path).ok_or_else(|| LoadError::UnknownExtension {
104                path: path.to_path_buf(),
105            })?
106        };
107        if selected.is_some_and(|previous| previous != format) {
108            return Err(LoadError::MixedFormats);
109        }
110        selected = Some(format);
111    }
112    Ok(selected.unwrap_or(Format::Json))
113}
114
115/// Read homogeneous native layers after resolving one format for the entire list.
116///
117/// Stdin requires an explicit format. Mixed inferred formats fail before any
118/// document contents are read. Empty input returns JSON layers unless overridden.
119pub fn load_layers<P: AsRef<Path>>(
120    paths: &[P],
121    explicit_format: Option<Format>,
122) -> anyhow::Result<Layers> {
123    match resolve_format(paths, explicit_format)? {
124        Format::Json => Ok(Layers::Json(load_native(paths)?)),
125        Format::Toml => Ok(Layers::Toml(load_native(paths)?)),
126    }
127}
128
129fn load_native<P: AsRef<Path>, V: ConfigFormat>(paths: &[P]) -> anyhow::Result<Vec<V>> {
130    paths
131        .iter()
132        .map(|path| {
133            let path = path.as_ref();
134            let (name, text) = if path.as_os_str() == STDIN {
135                let mut text = String::new();
136                std::io::stdin()
137                    .read_to_string(&mut text)
138                    .context("reading stdin")?;
139                (SourceName::Stdin, text)
140            } else {
141                let text = std::fs::read_to_string(path)
142                    .with_context(|| format!("reading `{}`", path.display()))?;
143                (SourceName::File(path.to_path_buf()), text)
144            };
145            format::parse(&text, &name)
146        })
147        .collect()
148}