influxdb3-plugin-cli 0.5.0

InfluxDB 3 author-side CLI for templating, validating, and packaging InfluxDB 3 plugins.
Documentation
//! Shared human-mode renderer for error paths.
//!
//! One entry point — `render_human_error` — dispatches by `JsonError`
//! shape: multi-issue (`diagnostics[]` non-empty) renders the numbered
//! block; single-issue renders one line plus the optional `cause[]`
//! chain.

use crate::output::json::JsonError;
use crate::path_display::display_relative_to_cwd;
use crate::style::Palette;
use std::io;
use std::path::Path;

/// Top-level entry point for human-mode error rendering. Dispatches on
/// the `JsonError` shape: if `diagnostics[]` is non-empty we render the
/// numbered list block; otherwise we render one line with optional cause
/// chain.
pub fn render_human_error(
    err: &JsonError,
    palette: Palette,
    writer: &mut dyn io::Write,
) -> io::Result<()> {
    if err.diagnostics.is_empty() {
        render_single_issue(err, palette, writer)
    } else {
        render_multi_issue(err, palette, writer)
    }
}

fn render_single_issue(
    err: &JsonError,
    palette: Palette,
    writer: &mut dyn io::Write,
) -> io::Result<()> {
    let tag = palette.tag.render();
    let tag_reset = palette.tag.render_reset();
    let message = human_message(err);
    match err.field.as_deref() {
        Some(f) => {
            let field = human_field(f);
            writeln!(
                writer,
                "{tag}[{}]{tag_reset} {}: {}",
                err.code, field, message
            )?
        }
        None => writeln!(writer, "{tag}[{}]{tag_reset} {}", err.code, message)?,
    }
    let dim = palette.dim.render();
    let dim_reset = palette.dim.render_reset();
    for c in &err.cause {
        writeln!(writer, "  {dim}cause:{dim_reset} {c}")?;
    }
    Ok(())
}

fn render_multi_issue(
    err: &JsonError,
    palette: Palette,
    writer: &mut dyn io::Write,
) -> io::Result<()> {
    let header = palette.error.render();
    let header_reset = palette.error.render_reset();
    writeln!(
        writer,
        "{header}validation failed: {} diagnostic(s){header_reset}",
        err.diagnostics.len()
    )?;
    let dim = palette.dim.render();
    let dim_reset = palette.dim.render_reset();
    let tag = palette.tag.render();
    let tag_reset = palette.tag.render_reset();
    for (i, d) in err.diagnostics.iter().enumerate() {
        let message = human_message(d);
        match d.field.as_deref() {
            Some(f) => {
                let field = human_field(f);
                writeln!(
                    writer,
                    "  {dim}{}.{dim_reset} {tag}[{}]{tag_reset} {}: {}",
                    i + 1,
                    d.code,
                    field,
                    message,
                )?
            }
            None => writeln!(
                writer,
                "  {dim}{}.{dim_reset} {tag}[{}]{tag_reset} {}",
                i + 1,
                d.code,
                message,
            )?,
        }
    }
    Ok(())
}

fn human_field(field: &str) -> String {
    let path = Path::new(field);
    if path.is_absolute() {
        display_relative_to_cwd(path)
    } else {
        field.to_owned()
    }
}

fn human_message(err: &JsonError) -> String {
    let mut message = err.message.clone();
    for (absolute, display) in path_replacements(err) {
        message = message.replace(&absolute, &display);
    }
    message
}

fn path_replacements(err: &JsonError) -> Vec<(String, String)> {
    let mut replacements = Vec::new();
    if let Some(field) = err.field.as_deref() {
        push_path_replacement(field, &mut replacements);
    }
    if let Some(details) = err.details.as_ref().and_then(|v| v.as_object()) {
        for (key, value) in details {
            if path_detail_key(key)
                && let Some(value) = value.as_str()
            {
                push_path_replacement(value, &mut replacements);
            }
        }
    }
    replacements.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
    replacements.dedup_by(|a, b| a.0 == b.0);
    replacements
}

fn push_path_replacement(value: &str, replacements: &mut Vec<(String, String)>) {
    let path = Path::new(value);
    if !path.is_absolute() {
        return;
    }
    let display = display_relative_to_cwd(path);
    if display != value {
        replacements.push((value.to_owned(), display));
    }
}

fn path_detail_key(key: &str) -> bool {
    matches!(key, "path" | "index" | "out" | "target_dir")
        || key.ends_with("_path")
        || key.ends_with("_dir")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn plain() -> Palette {
        Palette::default()
    }

    fn je(code: &str, message: &str, field: Option<&str>) -> JsonError {
        JsonError {
            code: code.into(),
            message: message.into(),
            field: field.map(str::to_owned),
            details: None,
            diagnostics: vec![],
            cause: vec![],
        }
    }

    #[test]
    fn render_human_error_single_issue_emits_one_line_with_code_field_message() {
        let err = je(
            "package::canonical_collision",
            "name conflicts",
            Some("plugin.name"),
        );
        let mut buf = Vec::new();
        render_human_error(&err, plain(), &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert_eq!(
            s,
            "[package::canonical_collision] plugin.name: name conflicts\n",
        );
    }

    #[test]
    fn render_human_error_single_issue_omits_field_prefix_when_absent() {
        let err = je("usage::missing_subcommand", "subcommand required", None);
        let mut buf = Vec::new();
        render_human_error(&err, plain(), &mut buf).unwrap();
        assert_eq!(
            String::from_utf8(buf).unwrap(),
            "[usage::missing_subcommand] subcommand required\n",
        );
    }

    #[test]
    fn render_human_error_renders_cause_chain_when_present() {
        let err = JsonError {
            code: "io::read_failed".into(),
            message: "failed to read --index /tmp/idx.json".into(),
            field: Some("/tmp/idx.json".into()),
            details: None,
            diagnostics: vec![],
            cause: vec!["No such file or directory (os error 2)".into()],
        };
        let mut buf = Vec::new();
        render_human_error(&err, plain(), &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.starts_with(
            "[io::read_failed] /tmp/idx.json: failed to read --index /tmp/idx.json\n"
        ));
        assert!(s.contains("  cause: No such file or directory (os error 2)\n"));
    }

    #[test]
    fn render_human_error_multi_issue_renders_numbered_block() {
        let err = JsonError {
            code: "validate::failed".into(),
            message: "2 validation diagnostic(s)".into(),
            field: None,
            details: None,
            diagnostics: vec![
                je(
                    "validate::missing_required_file",
                    "required file \"x\" missing",
                    Some("x"),
                ),
                je("validate::python_parse", "syntax", Some("__init__.py")),
            ],
            cause: vec![],
        };
        let mut buf = Vec::new();
        render_human_error(&err, plain(), &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.starts_with("validation failed: 2 diagnostic(s)\n"));
        assert!(s.contains("  1. [validate::missing_required_file]"));
        assert!(s.contains("  2. [validate::python_parse]"));
    }

    #[test]
    fn render_human_error_schema_reported_does_not_double_prefix_field() {
        let err = JsonError {
            code: "validate::failed".into(),
            message: "1 validation diagnostic(s)".into(),
            field: None,
            details: None,
            diagnostics: vec![je(
                "validate::schema_reported",
                "plugin name \"X\" must match ...",
                Some("plugin.name"),
            )],
            cause: vec![],
        };
        let mut buf = Vec::new();
        render_human_error(&err, plain(), &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert_eq!(s.matches("plugin.name:").count(), 1);
    }

    #[test]
    fn render_human_error_replaces_path_like_detail_values() {
        let cwd = std::env::current_dir().unwrap();
        let target = cwd.join("target-dir-detail-test");
        let target = target.display().to_string();
        let err = JsonError {
            code: "new::scaffold_failed".into(),
            message: format!("failed to create {target}"),
            field: None,
            details: Some(serde_json::json!({ "target_dir": target })),
            diagnostics: vec![],
            cause: vec![],
        };

        let mut buf = Vec::new();
        render_human_error(&err, plain(), &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();

        assert!(s.contains("target-dir-detail-test"));
        assert!(
            !s.contains(&cwd.display().to_string()),
            "path-like detail value should be shortened, got: {s}"
        );
    }

    #[test]
    fn render_human_error_ignores_unknown_detail_keys() {
        let cwd = std::env::current_dir().unwrap();
        let value = cwd.join("not-a-path-detail").display().to_string();
        let err = JsonError {
            code: "usage::invalid_value".into(),
            message: format!("invalid value {value}"),
            field: Some("plugin.name".into()),
            details: Some(serde_json::json!({ "value": value })),
            diagnostics: vec![],
            cause: vec![],
        };

        let mut buf = Vec::new();
        render_human_error(&err, plain(), &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();

        assert!(s.contains(&cwd.display().to_string()));
        assert!(s.contains("plugin.name:"));
    }
}