elasticctl 0.10.0

Operate Elastic Security as code with a safety-first CLI for security engineers.
use crate::cli::Cli;
use crate::guard::MUTATING;
use clap::CommandFactory;
use clap_complete::Shell;
use elasticctl_core::Result;
use serde_json::{Value, json};

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()),
    })
}

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();
    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:?}"
            );
        }
    }

    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
    }

    #[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",
            "value-lists import",
            "value-lists delete",
            "value-lists init",
            "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",
            "fleet agent-policies import",
            "fleet agent-policies delete",
            "fleet integration-policies import",
            "fleet integration-policies delete",
        ];
        expected.sort();

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