knf-cli 0.5.0

Merge layered configuration files and print the result
//! Adds `help:` lines naming CLI flags to library errors. The only place flag
//! names appear in error messages.

use anyhow::anyhow;
use knf::fs::{AccumulateError, AccumulateTargetError};
use knf::{InterpError, LoadError, MergeError, PathError, Problem};

/// Preserve the command-line vocabulary for target validation.
pub fn explain_accumulate_target(err: AccumulateTargetError) -> String {
    match err {
        AccumulateTargetError::Stdin => "--accumulate does not accept stdin",
        AccumulateTargetError::InvalidPath => {
            "--accumulate requires a relative target path without .. components"
        }
        AccumulateTargetError::UnknownExtension => {
            "--accumulate requires a target with a JSON or TOML extension"
        }
    }
    .to_owned()
}

/// Renders discovery errors with their paths and I/O context.
pub fn explain_accumulate(err: AccumulateError) -> anyhow::Error {
    match err {
        AccumulateError::Inspect { path, source } => {
            anyhow::Error::new(source).context(format!("inspecting `{}`", path.display()))
        }
        AccumulateError::List { path, source } => {
            anyhow::Error::new(source).context(format!("listing `{}`", path.display()))
        }
        AccumulateError::Directory { path } | AccumulateError::NonRegular { path } => {
            anyhow!("`{}` is not a regular file", path.display())
        }
        AccumulateError::CurrentDirectory(source) => {
            anyhow::Error::new(source).context("determining the working directory")
        }
    }
}

/// Adds CLI help to a pipeline error by downcasting it.
///
/// If `knf-core` starts wrapping these errors, the downcasts silently miss; the
/// stderr snapshot tests catch that.
pub fn explain_pipeline(err: impl Into<anyhow::Error>) -> anyhow::Error {
    let err = err.into();
    let err = match err.downcast::<LoadError>() {
        Ok(err) => return explain_load(err),
        Err(err) => err,
    };
    let err = match err.downcast::<MergeError>() {
        // A type conflict is fixed in the documents, not on the command line.
        // Match variants exhaustively so new merge failures require a decision.
        Ok(err @ MergeError::TypeConflict { .. }) => return err.into(),
        Err(err) => err,
    };
    match err.downcast::<InterpError>() {
        Ok(err) => explain_interp(err),
        Err(err) => err,
    }
}

/// Names the flag that resolves a format-selection error.
fn explain_load(err: LoadError) -> anyhow::Error {
    match &err {
        LoadError::StdinNeedsFormat | LoadError::UnknownExtension { .. } => {
            anyhow!("{err}: pass -f json or -f toml")
        }
        LoadError::MixedFormats => {
            anyhow!("{err}\nhelp: merge JSON layers and TOML layers separately")
        }
        LoadError::Directory { path } => anyhow!(
            "{err}\nhelp: `knf {}/*.toml` merges its files as layers",
            path.display()
        ),
    }
}

/// Adds `-c` help to path errors.
pub fn name_the_inline_layer_flag(err: PathError) -> anyhow::Error {
    match err {
        PathError::IndexInKeyPath { .. } => anyhow!(
            "{err}\nhelp: -c takes KEY.PATH=VALUE; an index like servers[0] can be read\n      \
             by a ${{...}} reference but never written — put the value in a file instead"
        ),
        other => other.into(),
    }
}

/// Adds `--interpolate` help to interpolation errors.
fn explain_interp(err: InterpError) -> anyhow::Error {
    let mut help = String::new();
    match &err {
        InterpError::Cycle(_) => {
            help.push_str("\nhelp: a reference may not resolve, directly or indirectly, to itself")
        }
        InterpError::Problems(problems) => {
            // One help line per kind present, then the opt-out hint.
            let has = |f: fn(&Problem) -> bool| problems.iter().any(f);
            let syntax = has(|p| matches!(p, Problem::Syntax { .. }));
            let unresolved = has(|p| matches!(p, Problem::Unresolved { .. }));
            if syntax {
                help.push_str(
                    "\nhelp: a reference is `${key.path}` (with `[n]` for array elements) or `${env:NAME}`; write `$$` for a literal `$`",
                );
            }
            if unresolved {
                help.push_str(
                    "\nhelp: `${key.path}` names a key in the merged document, `${env:NAME}` an environment variable",
                );
            }
            if has(|p| matches!(p, Problem::NotStringifiable { .. })) {
                help.push_str(
                    "\nhelp: an object or array reference must be the whole string, not embedded in one",
                );
            }
            if syntax || unresolved {
                help.push_str("\nhelp: drop --interpolate to pass `${...}` through untouched");
            }
        }
    }
    anyhow!("{err}{help}")
}