knf-cli 0.3.2

Merge layered configuration files and print the result
//! Flag names, and where they are allowed to appear.
//!
//! The library crates raise errors that carry key paths, reference spellings
//! and file paths — never a command-line flag, because none of them has heard
//! of one. Every `help:` line in this file exists to close that gap on the way
//! out, and this is the only place in the workspace where `--set`,
//! `--input-format`, `--interpolate`, `-f` and `--null-as` appear in an error
//! message.

use anyhow::anyhow;
use knf::{
    IntegerOutOfRange, InterpError, LoadError, MergeError, NonFiniteFloat, NullInToml, PathError,
    Problem, TomlError,
};

/// Adds the command-line spelling to errors produced by the reusable pipeline.
///
/// Every stage passes through here — load, merge, interpolate and emit — so
/// there is one place a library error can pick up a flag name, rather than one
/// per call site. Downcasts rather than matching on a wrapper enum, because the
/// pipeline's errors arrive inside `anyhow` and each typed error is decorated by
/// a different rule. Note the hazard: if `knf-core` ever wraps these in one
/// error type of its own, every downcast below starts missing and nothing here
/// fails to compile — the tests that pin this stderr are what would catch it.
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.
        // Exhaustive for the reason given on the match below.
        Ok(err @ MergeError::TypeConflict { .. }) => return err.into(),
        Err(err) => err,
    };
    let err = match err.downcast::<InterpError>() {
        Ok(err) => return explain_interp(err),
        Err(err) => err,
    };
    let err = match err.downcast::<NonFiniteFloat>() {
        Ok(report) => return explain_non_finite(report),
        Err(err) => err,
    };
    // Exhaustive on purpose, rather than the catch-all this used to end with. The
    // dispatch above is by downcast, so a new error type slipping out of the
    // pipeline is already invisible to the compiler; the least this file can do is
    // make a new *variant* of one it already handles a build error instead of a
    // help line that quietly stops appearing.
    match err.downcast::<TomlError>() {
        Ok(TomlError::Null(report)) => explain_null(report),
        Ok(TomlError::Integer(report)) => explain_integer(report),
        // A datetime a caller spelled wrongly while building a `Value` by hand —
        // unreachable from argv, and no flag gets anyone out of it, so there is
        // nothing for this file to add.
        Ok(err @ TomlError::Datetime(_)) => err.into(),
        Err(err) => err,
    }
}

/// Names the two flags that get a document with a null out through TOML.
///
/// `knf-core` reports where the nulls are and stops: emitting JSON instead
/// and substituting a string are both things an *interface* offers, and it has
/// none. The report is deliberately newline-free at the end so this line lands
/// flush against its last `-->`.
fn explain_null(err: NullInToml) -> anyhow::Error {
    anyhow!("{err}\nhelp: emit JSON with -f json, substitute with --null-as, or remove the null")
}

/// Names the flag that emits an integer TOML has no spelling for.
///
/// The other half of [`explain_null`]'s bargain, and the same division of labour:
/// `knf-core` says which keys hold an integer past `i64::MAX`, and the one
/// remedy — emit JSON, where the digits survive exactly — is a flag, so it is
/// ours to name.
fn explain_integer(err: IntegerOutOfRange) -> anyhow::Error {
    anyhow!("{err}\nhelp: TOML integers are signed 64-bit; emit JSON with -f json")
}

/// Names the flag that emits a float JSON has no spelling for.
///
/// The escape runs the other way here: `inf` and `nan` are TOML literals and JSON
/// has no syntax for either, so the format that rescues a null is the one that
/// cannot hold this. Worth stating in the help, since a user who has met the null
/// error will reach for `-f json` by reflex.
fn explain_non_finite(err: NonFiniteFloat) -> anyhow::Error {
    anyhow!("{err}\nhelp: emit TOML with -f toml, which can represent inf and nan")
}

/// Names the flag that resolves an input whose format could not be settled.
///
/// `knf-core` states the problem — stdin has no extension, this file's
/// extension says nothing, this path is a directory — and stops there. The
/// remedy is always a flag or a different argv, so it is always ours.
fn explain_load(err: LoadError) -> anyhow::Error {
    match &err {
        LoadError::StdinNeedsFormat | LoadError::UnknownExtension { .. } => {
            anyhow!("{err}: pass --input-format json or --input-format toml")
        }
        LoadError::Directory { path } => anyhow!(
            "{err}\nhelp: `knf {}/*.toml` merges its files as layers",
            path.display()
        ),
    }
}

/// The established division of labour: `knf-core` renders the path and stays
/// provenance-free, the help line names the flag that carried it.
pub fn name_the_set_flag(err: PathError) -> anyhow::Error {
    match err {
        PathError::IndexInKeyPath { .. } => anyhow!(
            "{err}\nhelp: --set 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(),
    }
}

/// The same division of labour for interpolation.
///
/// Interpolation names key paths and reference spellings; it has never heard of
/// `--interpolate`, so the flag only appears here.
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, in the order the message lists
            // them, then the escape that applies to a document whose `${...}`
            // was never meant for knf in the first place.
            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}")
}