Skip to main content

knf/
lib.rs

1//! The `knf` pipeline: load layers, merge, emit.
2
3pub mod cli;
4pub mod format;
5pub mod value;
6
7use std::io::{Read, Write};
8use std::path::Path;
9
10use anyhow::{Context, anyhow, bail};
11use knf_core::{
12    MergeError, MergeOptions, RuleError, RuleErrors, Rules, Strategy, Value, merge_with,
13};
14use knf_dotted::PathLeaf;
15
16use cli::Cli;
17use format::{Format, SourceName};
18
19/// The positional that means "read stdin".
20const STDIN: &str = "-";
21
22/// `knf <files...>` — merge layers left to right, print one document.
23///
24/// One pipeline regardless of the formats involved: every layer becomes a
25/// [`Value`], the fold runs once, and the output format is only consulted at
26/// emit. Nothing about JSON or TOML reaches the merge.
27pub fn run(cli: Cli) -> anyhow::Result<()> {
28    // Before anything is read: a broken rule set is a mistake in the command
29    // line, and saying so must not wait on the files existing or parsing.
30    let opts = merge_options(&cli)?;
31
32    let mut layers: Vec<Value> = Vec::new();
33    let mut input_formats: Vec<Format> = Vec::new();
34
35    for path in &cli.files {
36        let (name, format, text) = read_input(path, cli.input_format)?;
37        let value = format::parse(format, &text, &name)?;
38        input_formats.push(format);
39        layers.push(value);
40    }
41
42    // --set layers are terminal: appended after every file. The RHS parses as
43    // JSON with a string fallback, which is knf-dotted's job.
44    for path_leaf in &cli.set {
45        let json = serde_json::Value::from(PathLeaf::<serde_json::Value>::from(path_leaf.clone()));
46        layers.push(value::from_json(json));
47    }
48
49    let out_format = resolve_output_format(cli.format, &input_formats)?;
50
51    let merged = merge_with(layers, &opts).map_err(name_the_flag)?;
52    let text = format::emit(
53        merged,
54        out_format,
55        !cli.compact,
56        cli.null_placeholder.as_deref(),
57    )?;
58    write_stdout(&text)
59}
60
61/// Reads one positional, resolving its format.
62fn read_input(
63    path: &Path,
64    override_format: Option<Format>,
65) -> anyhow::Result<(SourceName, Format, String)> {
66    if path.as_os_str() == STDIN {
67        let format = override_format.context(
68            "`-` reads stdin, which has no extension: pass --input-format json or --input-format toml",
69        )?;
70        let mut text = String::new();
71        std::io::stdin()
72            .read_to_string(&mut text)
73            .context("reading stdin")?;
74        return Ok((SourceName::Stdin, format, text));
75    }
76
77    if path.is_dir() {
78        bail!(
79            "`{}` is a directory; knf takes files as layers\n\
80             help: `knf {}/*.toml` merges its files as layers",
81            path.display(),
82            path.display(),
83        );
84    }
85
86    let format = match override_format {
87        Some(format) => format,
88        None => Format::from_path(path).with_context(|| {
89            format!(
90                "cannot infer a format from `{}`: pass --input-format json or --input-format toml",
91                path.display()
92            )
93        })?,
94    };
95    let text =
96        std::fs::read_to_string(path).with_context(|| format!("reading `{}`", path.display()))?;
97    Ok((SourceName::File(path.to_path_buf()), format, text))
98}
99
100/// Decides the output format from `-f` and the inputs.
101///
102/// Following the first input's format would mean reordering arguments silently
103/// changes the output encoding, so mixed inputs demand an explicit choice.
104pub fn resolve_output_format(
105    explicit: Option<Format>,
106    inputs: &[Format],
107) -> anyhow::Result<Format> {
108    if let Some(format) = explicit {
109        return Ok(format);
110    }
111    let mut distinct: Vec<Format> = Vec::new();
112    for format in inputs {
113        if !distinct.contains(format) {
114            distinct.push(*format);
115        }
116    }
117    match distinct.as_slice() {
118        // No file inputs at all — `knf --set a.b=1`.
119        [] => Ok(Format::Json),
120        [only] => Ok(*only),
121        mixed => {
122            let names: Vec<String> = mixed.iter().map(Format::to_string).collect();
123            bail!(
124                "inputs mix {} formats; -f is required to choose the output format\n\
125                 help: pass -f json or -f toml",
126                names.join(" and "),
127            )
128        }
129    }
130}
131
132/// Builds the merge knobs, validating the whole rule set up front.
133///
134/// Fallible, and called before any input is read: the rules come from argv
135/// alone, so nothing about the files can change whether they are legal.
136pub fn merge_options(cli: &Cli) -> anyhow::Result<MergeOptions> {
137    let flags = [
138        (&cli.append, Strategy::Append),
139        (&cli.replace, Strategy::Replace),
140        (&cli.fail, Strategy::Fail),
141    ];
142    let rules: Vec<(Vec<String>, Strategy)> = flags
143        .into_iter()
144        .flat_map(|(paths, strategy)| {
145            paths
146                .iter()
147                .map(move |path| (path.segments().to_vec(), strategy))
148        })
149        .collect();
150
151    Ok(MergeOptions {
152        strict: cli.strict,
153        rules: Rules::build(rules).map_err(explain_rules)?,
154    })
155}
156
157/// Turns a rule-set rejection into the flags the user actually typed.
158///
159/// `knf-core` names strategies, never flags — it has no idea they are spelled
160/// `--append`, `--replace` and `--fail` — so the help lines belong here.
161fn explain_rules(errors: RuleErrors) -> anyhow::Error {
162    const FLAGS: &str = "--append, --replace and --fail";
163    let mut help = String::new();
164    if errors
165        .errors()
166        .iter()
167        .any(|e| matches!(e, RuleError::Conflict { .. }))
168    {
169        help.push_str(&format!(
170            "\nhelp: a path may be named by only one of {FLAGS}"
171        ));
172    }
173    if errors
174        .errors()
175        .iter()
176        .any(|e| matches!(e, RuleError::Unreachable { .. }))
177    {
178        help.push_str(&format!(
179            "\nhelp: {FLAGS} take the whole value at their path, so a rule below one can never fire"
180        ));
181    }
182    anyhow!("{errors}{help}")
183}
184
185/// Same division of labour for the errors a rule raises during the merge.
186fn name_the_flag(err: MergeError) -> anyhow::Error {
187    let help = match err {
188        MergeError::Locked { .. } => {
189            "help: --fail pins a path to the first layer that sets it; drop the flag or the later value"
190        }
191        MergeError::AppendKind { .. } => "help: --append needs an array on both sides",
192        MergeError::TypeConflict { .. } => return err.into(),
193    };
194    anyhow!("{err}\n{help}")
195}
196
197/// Writes to stdout, treating a closed pipe as success so `knf big.json | head`
198/// does not report an error the user cannot act on.
199pub fn write_stdout(text: &str) -> anyhow::Result<()> {
200    match std::io::stdout().write_all(text.as_bytes()) {
201        Ok(()) => Ok(()),
202        Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
203        Err(e) => Err(e).context("writing to stdout"),
204    }
205}