elasticctl 0.5.1

Operate Elastic Security as code with a safety-first CLI for security engineers.
//! CLI metadata: shell completion and a machine-readable command tree.

use crate::cli::Cli;
use crate::guard::MUTATING;
use clap::CommandFactory;
use clap_complete::Shell;
use elasticctl_core::Result;
use serde_json::{Value, json};

/// The name this process was invoked under; completions and usage follow it
/// so `elkctl completion zsh` completes `elkctl`. `file_stem` drops `.exe`.
fn invoked_name() -> String {
    std::env::args_os()
        .next()
        .map(std::path::PathBuf::from)
        .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned()))
        .unwrap_or_else(|| "elasticctl".to_string())
}

pub fn completion(shell: Shell) -> Result<()> {
    let mut cmd = Cli::command();
    clap_complete::generate(shell, &mut cmd, invoked_name(), &mut std::io::stdout());
    Ok(())
}

fn arg(a: &clap::Arg) -> Value {
    json!({
        "name": a.get_id().as_str(),
        "required": a.is_required_set(),
        "takes_value": a.get_num_args().map(|n| n.takes_values()).unwrap_or(false),
        "help": a.get_help().map(|h| h.to_string()),
    })
}

/// `path` is the full, space-separated command path (`rules`, `rules delete`,
/// ...). It keys `mutates` by command location rather than leaf name.
fn describe(cmd: &clap::Command, path: &str) -> Value {
    let args: Vec<Value> = cmd
        .get_arguments()
        .filter(|a| !a.is_hide_set())
        .map(arg)
        .collect();
    let subcommands: Vec<Value> = cmd
        .get_subcommands()
        .map(|sub| {
            let sub_path = format!("{path} {}", sub.get_name());
            describe(sub, &sub_path)
        })
        .collect();

    json!({
        "name": cmd.get_name(),
        "about": cmd.get_about().map(|a| a.to_string()),
        "mutates": MUTATING.contains(&path),
        "args": args,
        "subcommands": subcommands,
    })
}

pub fn command_tree() -> Result<Value> {
    let cmd = Cli::command();
    // Global flags propagate from the root to every subcommand. Read them from
    // clap metadata so a new flag cannot drift from this record.
    let global_args: Vec<Value> = cmd
        .get_arguments()
        .filter(|a| !a.is_hide_set())
        .map(arg)
        .collect();
    let commands: Vec<Value> = cmd
        .get_subcommands()
        .map(|sub| describe(sub, sub.get_name()))
        .collect();
    Ok(json!({
        "name": "elasticctl",
        "version": env!("CARGO_PKG_VERSION"),
        "global_args": global_args,
        "commands": commands,
    }))
}

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

    #[test]
    fn command_tree_records_the_global_flags_on_the_root() {
        let tree = command_tree().unwrap();
        let names: Vec<&str> = tree["global_args"]
            .as_array()
            .unwrap()
            .iter()
            .map(|a| a["name"].as_str().unwrap())
            .collect();
        for expected in [
            "profile", "config", "space", "json", "format", "fields", "out", "yes", "timeout",
            "debug",
        ] {
            assert!(
                names.contains(&expected),
                "global_args must list {expected}: {names:?}"
            );
        }
    }

    /// Collect full paths of tree nodes marked `mutates: true`.
    fn mutating_paths(value: &Value, path: &str) -> Vec<String> {
        let mut found = Vec::new();
        if value["mutates"] == true {
            found.push(path.to_string());
        }
        for sub in value["subcommands"]
            .as_array()
            .map(|a| a.as_slice())
            .unwrap_or(&[])
        {
            let name = sub["name"].as_str().unwrap();
            let sub_path = if path.is_empty() {
                name.to_string()
            } else {
                format!("{path} {name}")
            };
            found.extend(mutating_paths(sub, &sub_path));
        }
        found
    }

    /// Keep `MUTATING` exact and reviewable. This test pins `mutates: true` to
    /// the declared paths and fails on an unreviewed addition, rename, or
    /// removal. It declares the expected paths independently of `MUTATING`.
    #[test]
    fn the_mutating_set_is_exactly_the_declared_paths() {
        let tree = command_tree().unwrap();
        let mut actual: Vec<String> = tree["commands"]
            .as_array()
            .unwrap()
            .iter()
            .flat_map(|c| mutating_paths(c, c["name"].as_str().unwrap()))
            .collect();
        actual.sort();

        let mut expected = [
            "rules enable",
            "rules disable",
            "rules delete",
            "rules import",
            "rules prebuilt install",
            "exceptions delete",
            "exceptions import",
            "state push",
            "config init",
            "alerts ack",
            "alerts open",
            "alerts close",
            "alerts tag",
            "alerts assign",
            "cases create",
            "cases close",
            "cases open",
            "cases delete",
            "cases attach",
            "cases comment",
            "data-views import",
            "data-views delete",
            "data-views default set",
            "data-views default unset",
            "dashboards import",
            "dashboards delete",
            "dashboards bundle import",
        ];
        expected.sort();

        assert_eq!(actual, expected, "the set of mutating commands drifted");
    }
}