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, bail};
11use knf_core::{MergeOptions, Value, merge_with};
12use knf_dotted::PathLeaf;
13
14use cli::Cli;
15use format::{Format, SourceName};
16
17/// The positional that means "read stdin".
18const STDIN: &str = "-";
19
20/// `knf <files...>` — merge layers left to right, print one document.
21///
22/// One pipeline regardless of the formats involved: every layer becomes a
23/// [`Value`], the fold runs once, and the output format is only consulted at
24/// emit. Nothing about JSON or TOML reaches the merge.
25pub fn run(cli: Cli) -> anyhow::Result<()> {
26    let mut layers: Vec<(SourceName, Value)> = Vec::new();
27    let mut input_formats: Vec<Format> = Vec::new();
28
29    for path in &cli.files {
30        let (name, format, text) = read_input(path, cli.input_format)?;
31        let value = format::parse(format, &text, &name)?;
32        input_formats.push(format);
33        layers.push((name, value));
34    }
35
36    // --set layers are terminal: appended after every file. The RHS parses as
37    // JSON with a string fallback, which is knf-dotted's job.
38    for path_leaf in &cli.set {
39        let name = SourceName::Set(path_leaf.to_string());
40        let json = serde_json::Value::from(PathLeaf::<serde_json::Value>::from(path_leaf.clone()));
41        layers.push((name, value::from_json(json)));
42    }
43
44    let out_format = resolve_output_format(cli.format, &input_formats)?;
45    let opts = merge_options(&cli);
46
47    // Provenance is only ever read by the null-in-TOML error, so JSON output
48    // does not pay for the clone.
49    let sources: Vec<(SourceName, Value)> = match out_format {
50        Format::Toml => layers.clone(),
51        Format::Json => Vec::new(),
52    };
53
54    let merged = merge_with(layers.into_iter().map(|(_, value)| value), &opts)?;
55    let text = format::emit(merged, out_format, !cli.compact, &sources)?;
56    write_stdout(&text)
57}
58
59/// Reads one positional, resolving its format.
60fn read_input(
61    path: &Path,
62    override_format: Option<Format>,
63) -> anyhow::Result<(SourceName, Format, String)> {
64    if path.as_os_str() == STDIN {
65        let format = override_format.context(
66            "`-` reads stdin, which has no extension: pass --input-format json or --input-format toml",
67        )?;
68        let mut text = String::new();
69        std::io::stdin()
70            .read_to_string(&mut text)
71            .context("reading stdin")?;
72        return Ok((SourceName::Stdin, format, text));
73    }
74
75    if path.is_dir() {
76        bail!(
77            "`{}` is a directory; knf takes files as layers\n\
78             help: `knf {}/*.toml` merges its files as layers",
79            path.display(),
80            path.display(),
81        );
82    }
83
84    let format = match override_format {
85        Some(format) => format,
86        None => Format::from_path(path).with_context(|| {
87            format!(
88                "cannot infer a format from `{}`: pass --input-format json or --input-format toml",
89                path.display()
90            )
91        })?,
92    };
93    let text =
94        std::fs::read_to_string(path).with_context(|| format!("reading `{}`", path.display()))?;
95    Ok((SourceName::File(path.to_path_buf()), format, text))
96}
97
98/// Decides the output format from `-f` and the inputs.
99///
100/// Following the first input's format would mean reordering arguments silently
101/// changes the output encoding, so mixed inputs demand an explicit choice.
102pub fn resolve_output_format(
103    explicit: Option<Format>,
104    inputs: &[Format],
105) -> anyhow::Result<Format> {
106    if let Some(format) = explicit {
107        return Ok(format);
108    }
109    let mut distinct: Vec<Format> = Vec::new();
110    for format in inputs {
111        if !distinct.contains(format) {
112            distinct.push(*format);
113        }
114    }
115    match distinct.as_slice() {
116        // No file inputs at all — `knf --set a.b=1`.
117        [] => Ok(Format::Json),
118        [only] => Ok(*only),
119        mixed => {
120            let names: Vec<String> = mixed.iter().map(Format::to_string).collect();
121            bail!(
122                "inputs mix {} formats; -f is required to choose the output format\n\
123                 help: pass -f json or -f toml",
124                names.join(" and "),
125            )
126        }
127    }
128}
129
130pub fn merge_options(cli: &Cli) -> MergeOptions {
131    MergeOptions { strict: cli.strict }
132}
133
134/// Writes to stdout, treating a closed pipe as success so `knf big.json | head`
135/// does not report an error the user cannot act on.
136pub fn write_stdout(text: &str) -> anyhow::Result<()> {
137    match std::io::stdout().write_all(text.as_bytes()) {
138        Ok(()) => Ok(()),
139        Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
140        Err(e) => Err(e).context("writing to stdout"),
141    }
142}