knf-cli 0.4.0

Merge layered configuration files and print the result
//! `knf <files...>` — merge layers left to right, print one document.
//!
//! Everything argv-shaped lives in this binary: the flag grammar ([`cli`]), the
//! `help:` lines that name a flag ([`explain`]), and the output-format decision
//! below. The pipeline itself is `knf-core`, which knows nothing about any of
//! it.

mod accumulate;
mod cli;
mod explain;

use std::io::Write;
use std::path::PathBuf;

use clap::Parser;
use knf::{
    ConfigFormat, Layers, MergeOptions, ProcessEnv, format, interpolate, load_layers, merge,
};

use cli::{Cli, ShallowAt};
use explain::{explain_pipeline, name_the_set_flag, name_the_shallow_flag};

// Entry point for the `knf-cli` binary. `knf-py` includes this file and calls
// `main_from` instead, so the function is unused in that compilation.
#[allow(dead_code)]
fn main() {
    // `std::env::args` is right for the `knf-cli` binary. The pyknf wheel's
    // command is a Python script, whose process argv starts with the interpreter,
    // so that caller passes `sys.argv` to `main_from` instead.
    main_from(std::env::args_os());
}

/// Runs the command line over `args`, whose first item is the program name.
pub fn main_from<I, T>(args: I)
where
    I: IntoIterator<Item = T>,
    T: Into<std::ffi::OsString> + Clone,
{
    // clap handles --help/--version and exits 2 on usage errors.
    let cli = Cli::parse_from(args);

    if let Err(err) = run(cli) {
        // Frontend help is added by `explain`; library errors remain flag-free.
        eprintln!("error: {err}");
        for cause in err.chain().skip(1) {
            eprintln!("  caused by: {cause}");
        }
        std::process::exit(1);
    }
}

/// Prepare explicit file inputs and terminal overlays for the shared pipeline.
fn run(cli: Cli) -> anyhow::Result<()> {
    // Before anything is read: a malformed --set is a mistake in the command
    // line, and saying so must not wait on the files existing or parsing.
    for leaf in &cli.set {
        leaf.validate_keys().map_err(name_the_set_flag)?;
    }
    let opts = merge_options(&cli)?;

    let mut files = if let Some(target) = &cli.accumulate {
        knf::fs::accumulate(target, None).map_err(explain::explain_accumulate)?
    } else {
        cli.files.clone()
    };
    if let Some(pattern) = &cli.glob {
        files = knf::fs::filter_paths(&files, pattern, false);
    } else if let Some(pattern) = &cli.glob_filename {
        files = knf::fs::filter_paths(&files, pattern, true);
    }
    if cli.list_files {
        let mut text = String::new();
        for path in &files {
            use std::fmt::Write;
            writeln!(text, "{}", path.display()).expect("writing to a String cannot fail");
        }
        return write_stdout(&text);
    }

    run_pipeline(&cli, &files, &opts)
}

/// Dispatch once to a native pipeline; every later stage retains its type.
fn run_pipeline(cli: &Cli, files: &[PathBuf], opts: &MergeOptions) -> anyhow::Result<()> {
    let layers = load_layers(files, cli.format.map(Into::into)).map_err(explain_pipeline)?;
    match layers {
        Layers::Json(layers) => run_native(cli, layers, opts),
        Layers::Toml(layers) => run_native(cli, layers, opts),
    }
}

fn run_native<V: ConfigFormat>(
    cli: &Cli,
    mut layers: Vec<V>,
    opts: &MergeOptions,
) -> anyhow::Result<()> {
    for leaf in &cli.set {
        layers.push(leaf.clone().into_layer().map_err(name_the_set_flag)?);
    }
    let merged = merge(layers, opts).map_err(explain_pipeline)?;
    let merged = if cli.interpolate {
        interpolate(merged, &ProcessEnv).map_err(explain_pipeline)?
    } else {
        merged
    };
    let text = format::emit(merged, !cli.compact).map_err(explain_pipeline)?;
    write_stdout(&text)
}

/// Builds the merge knobs, validating every `--shallow` path up front, for the
/// same reason as [`overlays`]: the paths come from argv alone.
fn merge_options(cli: &Cli) -> anyhow::Result<MergeOptions> {
    let mut shallow = Vec::with_capacity(cli.shallow.len());
    for occurrence in &cli.shallow {
        // The root is the empty key path.
        let keys = match occurrence {
            ShallowAt::Root => Vec::new(),
            ShallowAt::Path(path) => path
                .clone()
                .try_into_keys()
                .map_err(name_the_shallow_flag)?,
        };
        shallow.push(keys);
    }
    Ok(MergeOptions {
        strict: cli.strict,
        shallow,
    })
}

/// Writes to stdout, treating a closed pipe as success so `knf big.json | head`
/// does not report an error the user cannot act on.
fn write_stdout(text: &str) -> anyhow::Result<()> {
    use anyhow::Context;
    match std::io::stdout().write_all(text.as_bytes()) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
        Err(e) => Err(e).context("writing to stdout"),
    }
}