Skip to main content

knf/
lib.rs

1//! The `knf` pipeline: load layers, merge, emit.
2
3pub mod cli;
4pub mod format;
5pub mod interp;
6pub mod value;
7
8use std::io::{Read, Write};
9use std::path::Path;
10
11use anyhow::{Context, anyhow, bail};
12use knf_core::{
13    MergeError, MergeOptions, RuleError, RuleErrors, Rules, Strategy, Value, merge_with,
14};
15use knf_dotted::{PathError, PathLeaf};
16use knf_interp::{InterpError, Problem};
17
18use cli::Cli;
19use format::{Format, SourceName};
20use interp::ProcessEnv;
21
22/// The positional that means "read stdin".
23const STDIN: &str = "-";
24
25/// `knf <files...>` — merge layers left to right, print one document.
26///
27/// One pipeline regardless of the formats involved: every layer becomes a
28/// [`Value`], the fold runs once, and the output format is only consulted at
29/// emit. Nothing about JSON or TOML reaches the merge.
30pub fn run(cli: Cli) -> anyhow::Result<()> {
31    // Before anything is read: a broken rule set is a mistake in the command
32    // line, and saying so must not wait on the files existing or parsing.
33    let opts = merge_options(&cli)?;
34
35    // --set layers are terminal: appended after every file. The RHS parses as
36    // JSON with a string fallback, which is knf-dotted's job. The conversion
37    // is also where a bracketed path is rejected, so it runs with the rule
38    // set above: up front, not after the files exist or parse.
39    let mut set_layers: Vec<Value> = Vec::with_capacity(cli.set.len());
40    for path_leaf in &cli.set {
41        let typed = PathLeaf::<serde_json::Value>::from(path_leaf.clone());
42        let json = serde_json::Value::try_from(typed).map_err(name_the_set_flag)?;
43        set_layers.push(value::from_json(json));
44    }
45
46    let mut layers: Vec<Value> = Vec::new();
47    let mut input_formats: Vec<Format> = Vec::new();
48
49    for path in &cli.files {
50        let (name, format, text) = read_input(path, cli.input_format)?;
51        let value = format::parse(format, &text, &name)?;
52        input_formats.push(format);
53        layers.push(value);
54    }
55    layers.extend(set_layers);
56
57    let out_format = resolve_output_format(cli.format, &input_formats)?;
58
59    let merged = merge_with(layers, &opts).map_err(name_the_flag)?;
60    // After the merge, before the emit, and never per layer: a reference reads
61    // the document the user is actually going to get. `--set` layers therefore
62    // interpolate like any other layer, and `--strict` has already run — it
63    // compares the types values had when they were *written*, so a `"${port}"`
64    // was a string when it looked.
65    let merged = if cli.interpolate {
66        knf_interp::interpolate(merged, &ProcessEnv).map_err(explain_interp)?
67    } else {
68        merged
69    };
70    let text = format::emit(merged, out_format, !cli.compact, cli.null_as.as_deref())?;
71    write_stdout(&text)
72}
73
74/// Reads one positional, resolving its format.
75fn read_input(
76    path: &Path,
77    override_format: Option<Format>,
78) -> anyhow::Result<(SourceName, Format, String)> {
79    if path.as_os_str() == STDIN {
80        let format = override_format.context(
81            "`-` reads stdin, which has no extension: pass --input-format json or --input-format toml",
82        )?;
83        let mut text = String::new();
84        std::io::stdin()
85            .read_to_string(&mut text)
86            .context("reading stdin")?;
87        return Ok((SourceName::Stdin, format, text));
88    }
89
90    if path.is_dir() {
91        bail!(
92            "`{}` is a directory; knf takes files as layers\n\
93             help: `knf {}/*.toml` merges its files as layers",
94            path.display(),
95            path.display(),
96        );
97    }
98
99    let format = match override_format {
100        Some(format) => format,
101        None => Format::from_path(path).with_context(|| {
102            format!(
103                "cannot infer a format from `{}`: pass --input-format json or --input-format toml",
104                path.display()
105            )
106        })?,
107    };
108    let text =
109        std::fs::read_to_string(path).with_context(|| format!("reading `{}`", path.display()))?;
110    Ok((SourceName::File(path.to_path_buf()), format, text))
111}
112
113/// Decides the output format from `-f` and the inputs.
114///
115/// Following the first input's format would mean reordering arguments silently
116/// changes the output encoding, so mixed inputs demand an explicit choice.
117pub fn resolve_output_format(
118    explicit: Option<Format>,
119    inputs: &[Format],
120) -> anyhow::Result<Format> {
121    if let Some(format) = explicit {
122        return Ok(format);
123    }
124    let mut distinct: Vec<Format> = Vec::new();
125    for format in inputs {
126        if !distinct.contains(format) {
127            distinct.push(*format);
128        }
129    }
130    match distinct.as_slice() {
131        // No file inputs at all — `knf --set a.b=1`.
132        [] => Ok(Format::Json),
133        [only] => Ok(*only),
134        mixed => {
135            let names: Vec<String> = mixed.iter().map(Format::to_string).collect();
136            bail!(
137                "inputs mix {} formats; -f is required to choose the output format\n\
138                 help: pass -f json or -f toml",
139                names.join(" and "),
140            )
141        }
142    }
143}
144
145/// Builds the merge knobs, validating the whole rule set up front.
146///
147/// Fallible, and called before any input is read: the rules come from argv
148/// alone, so nothing about the files can change whether they are legal.
149pub fn merge_options(cli: &Cli) -> anyhow::Result<MergeOptions> {
150    let flags = [
151        ("--append", &cli.append, Strategy::Append),
152        ("--replace", &cli.replace, Strategy::Replace),
153        ("--fail", &cli.fail, Strategy::Fail),
154    ];
155    let mut rules: Vec<(Vec<String>, Strategy)> = Vec::new();
156    for (flag, paths, strategy) in flags {
157        for path in paths {
158            // The one write-side predicate, run per flag so the error can
159            // name it: rules name keys, never array elements.
160            let keys = path
161                .clone()
162                .try_into_keys()
163                .map_err(|err| name_the_rule_flag(err, flag))?;
164            rules.push((keys, strategy));
165        }
166    }
167
168    Ok(MergeOptions {
169        strict: cli.strict,
170        rules: Rules::build(rules).map_err(explain_rules)?,
171    })
172}
173
174/// The established division of labour: `knf-dotted` renders the path and
175/// stays provenance-free, the help line names the flag that carried it.
176fn name_the_rule_flag(err: PathError, flag: &str) -> anyhow::Error {
177    match err {
178        PathError::IndexInKeyPath { .. } => {
179            anyhow!("{err}\nhelp: {flag} takes a key path; a rule cannot name an array element")
180        }
181        other => other.into(),
182    }
183}
184
185/// Same division of labour for `--set`: its paths feed the same conversion.
186fn name_the_set_flag(err: PathError) -> anyhow::Error {
187    match err {
188        PathError::IndexInKeyPath { .. } => anyhow!(
189            "{err}\nhelp: --set takes KEY.PATH=VALUE; an index like servers[0] can be read\n      \
190             by a ${{...}} reference but never written — put the value in a file instead"
191        ),
192        other => other.into(),
193    }
194}
195
196/// Turns a rule-set rejection into the flags the user actually typed.
197///
198/// `knf-core` names strategies, never flags — it has no idea they are spelled
199/// `--append`, `--replace` and `--fail` — so the help lines belong here.
200fn explain_rules(errors: RuleErrors) -> anyhow::Error {
201    const FLAGS: &str = "--append, --replace and --fail";
202    let mut help = String::new();
203    if errors
204        .errors()
205        .iter()
206        .any(|e| matches!(e, RuleError::Conflict { .. }))
207    {
208        help.push_str(&format!(
209            "\nhelp: a path may be named by only one of {FLAGS}"
210        ));
211    }
212    if errors
213        .errors()
214        .iter()
215        .any(|e| matches!(e, RuleError::Unreachable { .. }))
216    {
217        help.push_str(&format!(
218            "\nhelp: {FLAGS} take the whole value at their path, so a rule below one can never fire"
219        ));
220    }
221    anyhow!("{errors}{help}")
222}
223
224/// Same division of labour for the errors a rule raises during the merge.
225fn name_the_flag(err: MergeError) -> anyhow::Error {
226    let help = match err {
227        MergeError::Locked { .. } => {
228            "help: --fail pins a path to the first layer that sets it; drop the flag or the later value"
229        }
230        MergeError::AppendKind { .. } => "help: --append needs an array on both sides",
231        MergeError::TypeConflict { .. } => return err.into(),
232    };
233    anyhow!("{err}\n{help}")
234}
235
236/// The same division of labour for interpolation.
237///
238/// `knf-interp` names key paths and reference spellings; it has never heard of
239/// `--interpolate`, so the flag only appears here.
240fn explain_interp(err: InterpError) -> anyhow::Error {
241    let mut help = String::new();
242    match &err {
243        InterpError::Cycle(_) => {
244            help.push_str("\nhelp: a reference may not resolve, directly or indirectly, to itself")
245        }
246        InterpError::Problems(problems) => {
247            // One help line per kind present, in the order the message lists
248            // them, then the escape that applies to a document whose `${...}`
249            // was never meant for knf in the first place.
250            let has = |f: fn(&Problem) -> bool| problems.iter().any(f);
251            let syntax = has(|p| matches!(p, Problem::Syntax { .. }));
252            let unresolved = has(|p| matches!(p, Problem::Unresolved { .. }));
253            if syntax {
254                help.push_str(
255                    "\nhelp: a reference is `${key.path}` (with `[n]` for array elements) or `${env:NAME}`; write `$$` for a literal `$`",
256                );
257            }
258            if unresolved {
259                help.push_str(
260                    "\nhelp: `${key.path}` names a key in the merged document, `${env:NAME}` an environment variable",
261                );
262            }
263            if has(|p| matches!(p, Problem::NotStringifiable { .. })) {
264                help.push_str(
265                    "\nhelp: an object or array reference must be the whole string, not embedded in one",
266                );
267            }
268            if syntax || unresolved {
269                help.push_str("\nhelp: drop --interpolate to pass `${...}` through untouched");
270            }
271        }
272    }
273    anyhow!("{err}{help}")
274}
275
276/// Writes to stdout, treating a closed pipe as success so `knf big.json | head`
277/// does not report an error the user cannot act on.
278pub fn write_stdout(text: &str) -> anyhow::Result<()> {
279    match std::io::stdout().write_all(text.as_bytes()) {
280        Ok(()) => Ok(()),
281        Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
282        Err(e) => Err(e).context("writing to stdout"),
283    }
284}