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