ebman 0.29.2

k9s-style TUI for AWS Elastic Beanstalk
Documentation
//! `ebman completions <bash|zsh|fish>` — emit a shell completion script.
//!
//! ebman's CLI is hand-rolled (no `clap`), so there's no derive to hang
//! `clap_complete` off. Instead the command surface is described once as
//! data ([`SUBS`] + [`GLOBALS`]) and rendered to each shell's script
//! format. One source of truth means the three scripts can't drift from
//! each other, and the unit tests pin every rendered script to that list.
//!
//! Scope is deliberately static: subcommands, global flags, and each
//! subcommand's flags / positional verbs. It does **not** complete
//! environment names in the shell — that would need a live
//! `ebman envs` (an AWS round-trip, credentials, latency) on every Tab.
//! Env-name completion lives in the TUI command bar instead, where the
//! fleet is already loaded. The subcommand *names* in [`SUBS`] are
//! pinned to [`crate::cli::SUBCOMMANDS`] by a test, so they can't drift
//! from the real CLI; the per-subcommand flags / verbs still track
//! `main.rs`'s dispatch and `print_help` by hand.

use color_eyre::eyre::Result;

/// One subcommand and the tokens (flags + positional verbs) that may
/// follow it. `desc` is shown by shells that annotate completions
/// (zsh, fish).
struct Sub {
    name: &'static str,
    desc: &'static str,
    /// Flags (`--json`) and positional verbs (`serve`, `replay`,
    /// `rebuild`) offered after the subcommand; order-insensitive.
    args: &'static [&'static str],
}

/// Top-level flags accepted before any subcommand (the bare-TUI path).
const GLOBALS: &[&str] = &[
    "-V",
    "--version",
    "-h",
    "--help",
    "--read-only",
    "--demo",
    "--control-socket",
];

/// The CLI subcommand surface. Mirror of `main.rs` dispatch + `print_help`.
const SUBS: &[Sub] = &[
    Sub {
        name: "envs",
        desc: "List environments in the current profile / region",
        args: &["--json"],
    },
    Sub {
        name: "lint",
        desc: "Run the diagnostic rule engine against one env or the fleet",
        args: &[
            "--env",
            "--regions",
            "--json",
            "--severity",
            "--rules",
            "--quiet",
            "--fix",
            "--yes",
            "--dry-run",
            "--watch",
            "--interval",
            "--webhook",
            "--probe-live",
            "--baseline",
            "--against-baseline",
        ],
    },
    Sub {
        name: "drift",
        desc: "Terraform drift report against live EB state",
        args: &[
            "--env",
            "--regions",
            "--tfstate",
            "--tfdir",
            "--json",
            "--quiet",
            "--no-redact",
        ],
    },
    Sub {
        name: "action",
        desc: "Run rebuild/restart/terminate/deploy/rollout on an env",
        args: &[
            "rebuild",
            "restart",
            "terminate",
            "deploy",
            "rollout",
            "--env",
            "--yes",
            "--version",
            "--wait-for-green",
            "--auto-rollback",
            "--regions",
            "--profile",
            "--parallel",
            "--max-concurrency",
            "--continue-on-fail",
            "--staggered",
            "--json",
        ],
    },
    Sub {
        name: "ctl",
        desc: "Talk to a running ebman via --control-socket",
        args: &["screen", "key", "cmd", "state", "reload", "--socket"],
    },
    Sub {
        name: "audit",
        desc: "Read the local audit log (or `replay` a line)",
        args: &[
            "replay", "--tail", "--since", "--env", "--rule", "--action", "--json", "--yes",
        ],
    },
    Sub {
        name: "mcp",
        desc: "Stdio MCP server exposing the fleet to coding agents",
        args: &["serve", "setup", "--demo", "--no-redact", "--allow-writes"],
    },
    Sub {
        name: "explain",
        desc: "LLM-backed explanation of a lint issue",
        args: &["--env", "--json", "--dry-run", "--no-cache"],
    },
    Sub {
        name: "versions",
        desc: "List application versions for an env's app",
        args: &["--env", "--json"],
    },
    Sub {
        name: "completions",
        desc: "Emit a shell completion script",
        args: &["bash", "zsh", "fish"],
    },
];

/// bash: a `_ebman` function registered with `complete -F`. First word
/// completes subcommands (or globals if it starts with `-`); later words
/// complete on the leading subcommand.
fn render_bash() -> String {
    let subnames: Vec<&str> = SUBS.iter().map(|c| c.name).collect();
    let mut s = String::new();
    s.push_str("# bash completion for ebman — generated by `ebman completions bash`.\n");
    s.push_str(
        "# Install: ebman completions bash > ~/.local/share/bash-completion/completions/ebman\n",
    );
    s.push_str("#     or:  ebman completions bash | sudo tee /etc/bash_completion.d/ebman\n");
    s.push_str("_ebman() {\n");
    s.push_str("    local cur\n");
    s.push_str("    cur=\"${COMP_WORDS[COMP_CWORD]}\"\n");
    s.push_str(&format!("    local subcmds=\"{}\"\n", subnames.join(" ")));
    s.push_str(&format!("    local globals=\"{}\"\n", GLOBALS.join(" ")));
    s.push_str("    if [[ $COMP_CWORD -eq 1 ]]; then\n");
    s.push_str("        if [[ \"$cur\" == -* ]]; then\n");
    s.push_str("            COMPREPLY=( $(compgen -W \"$globals\" -- \"$cur\") )\n");
    s.push_str("        else\n");
    s.push_str("            COMPREPLY=( $(compgen -W \"$subcmds\" -- \"$cur\") )\n");
    s.push_str("        fi\n");
    s.push_str("        return\n");
    s.push_str("    fi\n");
    s.push_str("    case \"${COMP_WORDS[1]}\" in\n");
    for c in SUBS {
        s.push_str(&format!("        {})\n", c.name));
        s.push_str(&format!(
            "            COMPREPLY=( $(compgen -W \"{}\" -- \"$cur\") ) ;;\n",
            c.args.join(" ")
        ));
    }
    s.push_str("    esac\n");
    s.push_str("}\n");
    s.push_str("complete -F _ebman ebman\n");
    s
}

/// zsh: an autoloadable `#compdef` function body. Install by dropping it
/// on `$fpath` as `_ebman`; the file body runs as the completion widget.
fn render_zsh() -> String {
    let subnames: Vec<&str> = SUBS.iter().map(|c| c.name).collect();
    let mut s = String::new();
    s.push_str("#compdef ebman\n");
    s.push_str("# zsh completion for ebman — generated by `ebman completions zsh`.\n");
    s.push_str("# Install: ebman completions zsh > \"${fpath[1]}/_ebman\"  (then restart zsh)\n");
    s.push_str("local -a subcmds globals args\n");
    s.push_str(&format!("subcmds=({})\n", subnames.join(" ")));
    s.push_str(&format!("globals=({})\n", GLOBALS.join(" ")));
    s.push_str("if (( CURRENT == 2 )); then\n");
    s.push_str("    if [[ ${words[CURRENT]} == -* ]]; then\n");
    s.push_str("        compadd -- $globals\n");
    s.push_str("    else\n");
    s.push_str("        compadd -- $subcmds\n");
    s.push_str("    fi\n");
    s.push_str("    return\n");
    s.push_str("fi\n");
    s.push_str("case ${words[2]} in\n");
    for c in SUBS {
        s.push_str(&format!("    {}) args=({}) ;;\n", c.name, c.args.join(" ")));
    }
    s.push_str("esac\n");
    s.push_str("compadd -- $args\n");
    s
}

/// Render one flag/verb token as its fish `complete` fragment:
/// `--long` → `-l long`, `-s` → `-s s`, a bare word → `-a word`.
fn fish_token(tok: &str) -> String {
    if let Some(long) = tok.strip_prefix("--") {
        format!("-l {long}")
    } else if let Some(short) = tok.strip_prefix('-') {
        format!("-s {short}")
    } else {
        format!("-a {tok}")
    }
}

/// Escape a description for a fish single-quoted string.
fn fish_esc(desc: &str) -> String {
    desc.replace('\\', "\\\\").replace('\'', "\\'")
}

/// fish: one `complete -c ebman` line per subcommand, global flag, and
/// per-subcommand token. Subcommands and globals are gated on
/// `__fish_use_subcommand`; a subcommand's own tokens on
/// `__fish_seen_subcommand_from <name>`.
fn render_fish() -> String {
    let mut s = String::new();
    s.push_str("# fish completion for ebman — generated by `ebman completions fish`.\n");
    s.push_str("# Install: ebman completions fish > ~/.config/fish/completions/ebman.fish\n");
    for c in SUBS {
        s.push_str(&format!(
            "complete -c ebman -n __fish_use_subcommand -a {} -d '{}'\n",
            c.name,
            fish_esc(c.desc)
        ));
    }
    for g in GLOBALS {
        s.push_str(&format!(
            "complete -c ebman -n __fish_use_subcommand {}\n",
            fish_token(g)
        ));
    }
    for c in SUBS {
        for a in c.args {
            s.push_str(&format!(
                "complete -c ebman -n '__fish_seen_subcommand_from {}' {}\n",
                c.name,
                fish_token(a)
            ));
        }
    }
    s
}

fn usage() -> ! {
    eprintln!(
        "ebman completions <bash|zsh|fish>\n\n\
         Emit a shell completion script to stdout. Examples:\n  \
         ebman completions zsh  > \"${{fpath[1]}}/_ebman\"\n  \
         ebman completions bash > ~/.local/share/bash-completion/completions/ebman\n  \
         ebman completions fish > ~/.config/fish/completions/ebman.fish"
    );
    std::process::exit(2);
}

/// Entry point. `args[0]` is `"completions"`, `args[1]` the shell name.
pub async fn run(args: &[String]) -> Result<()> {
    let script = match args.get(1).map(String::as_str) {
        Some("bash") => render_bash(),
        Some("zsh") => render_zsh(),
        Some("fish") => render_fish(),
        Some(other) => {
            eprintln!("ebman completions: unknown shell '{other}' (expected: bash | zsh | fish)");
            std::process::exit(2);
        }
        None => usage(),
    };
    print!("{script}");
    Ok(())
}

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

    #[test]
    fn subs_names_match_the_canonical_cli_subcommand_list() {
        // Pins the completion subcommand set to the single source of
        // truth (cli::SUBCOMMANDS) that main.rs dispatches — so a
        // subcommand added to the CLI but not to SUBS (or vice versa)
        // fails here instead of silently shipping incomplete completion.
        use std::collections::BTreeSet;
        let subs: BTreeSet<&str> = SUBS.iter().map(|c| c.name).collect();
        let canonical: BTreeSet<&str> = crate::cli::SUBCOMMANDS.iter().copied().collect();
        assert_eq!(
            subs, canonical,
            "completions SUBS drifted from cli::SUBCOMMANDS"
        );
    }

    #[test]
    fn every_shell_lists_all_subcommands() {
        for (shell, script) in [
            ("bash", render_bash()),
            ("zsh", render_zsh()),
            ("fish", render_fish()),
        ] {
            assert!(!script.is_empty(), "{shell} script must not be empty");
            for c in SUBS {
                assert!(
                    script.contains(c.name),
                    "{shell} script is missing subcommand '{}'",
                    c.name
                );
            }
        }
    }

    #[test]
    fn scripts_carry_their_registration_hook() {
        assert!(render_bash().contains("complete -F _ebman ebman"));
        assert!(render_zsh().starts_with("#compdef ebman"));
        assert!(render_fish().contains("complete -c ebman -n __fish_use_subcommand -a envs"));
    }

    #[test]
    fn per_subcommand_flags_render() {
        // A representative flag from each shape reaches the output.
        let bash = render_bash();
        assert!(bash.contains("--allow-writes")); // mcp
        assert!(bash.contains("--against-baseline")); // lint
        let zsh = render_zsh();
        assert!(zsh.contains("rollout")); // action positional verb
        assert!(zsh.contains("--tfstate")); // drift
    }

    #[test]
    fn fish_token_classifies_long_short_and_positional() {
        assert_eq!(fish_token("--json"), "-l json");
        assert_eq!(fish_token("-V"), "-s V");
        assert_eq!(fish_token("serve"), "-a serve");
    }

    #[test]
    fn globals_and_subcommands_are_disjoint_nonempty() {
        assert!(!GLOBALS.is_empty());
        assert!(!SUBS.is_empty());
        // A subcommand name must never also be a global flag token.
        for c in SUBS {
            assert!(
                !GLOBALS.contains(&c.name),
                "'{}' is both a subcommand and a global",
                c.name
            );
        }
    }
}