ebman 0.30.2

k9s-style TUI for AWS Elastic Beanstalk
//! `ebman <verb>` non-interactive subcommands.
//!
//! Pre-0.15 every `run_*_cli` lived as an inline `async fn` in
//! `src/main.rs`, which ballooned to 2,600+ lines as the CLI surface
//! grew (audit/explain/lint --fix all landed in 0.14). The 0.14
//! architecture review's #1 finding was the resulting grab-bag.
//!
//! Each verb now lives in its own file under `src/cli/`, exposing
//! `pub async fn run(args: &[String]) -> Result<()>`. `main.rs`
//! dispatches by `argv[1]` and calls the matching `cli::<verb>::run`.
//! Shared CLI-only helpers (the `decide_poll` state machine, the
//! `--fix` dispatch-failure flag, the JSON-string escaper, the
//! cli-arg escaper) live here in `mod.rs`.
//!
//! Convention:
//! - Each module is named after the subcommand (`audit.rs`,
//!   `explain.rs`, ...) and exports exactly one public function:
//!   `pub async fn run(args: &[String]) -> Result<()>`. `args` is
//!   the full `std::env::args()` vector so callers can index from
//!   `args[1]` onwards uniformly.
//! - Exit codes follow the 0.13 CLI charter (locked in
//!   `BACKLOG.md`): 0 ok, 1 aws err, 2 usage err, 3 issues / drift,
//!   4 wait-for-green timeout, 5 auto-rollback fired.
//! - No `println!` inside the TUI alternate screen — these
//!   subcommands run before / outside TUI lifecycle, so plain
//!   stdout/stderr is fine.

pub mod action;
pub mod audit;
pub mod audit_replay;
pub mod completions;
pub mod ctl;
pub mod drift;
pub mod envs;
pub mod explain;
pub mod lint;
pub mod mcp;
pub mod versions;

/// The canonical list of top-level `ebman <subcommand>` names — the
/// single source of truth for the CLI-subcommand *name* axis. `main.rs`
/// dispatches these (and lists them on an unknown-subcommand error);
/// `cli::completions` renders them, and a test pins its `SUBS` to this
/// list so the shell-completion subcommand set can't drift from the real
/// CLI. Per-subcommand flags / sub-verbs aren't mechanically derivable
/// and stay hand-maintained in `completions::SUBS`.
pub const SUBCOMMANDS: &[&str] = &[
    "envs",
    "action",
    "ctl",
    "lint",
    "drift",
    "audit",
    "mcp",
    "explain",
    "versions",
    "completions",
];

/// Re-exports from the shared deploy-poll module. CLI subcommand
/// modules import via `crate::cli::{decide_poll, PollDecision}`;
/// the actual implementations live in `src/deploy_poll.rs` and are
/// shared with the TUI's `spawn_rollout_dispatch`.
pub(crate) use crate::deploy_poll::{decide_poll, PollDecision};

/// Re-exports of the canonical JSON helpers from `crate::util`. CLI
/// subcommand modules import these via `crate::cli::{json_string,
/// cli_esc}` so call-site rewrites are unnecessary; the actual
/// implementations live in `util.rs` and are shared across the
/// crate (lib + bin).
pub(crate) use crate::util::{json_escape as cli_esc, json_string};

/// Cross-process freeze gate for CLI write paths (0.28): refuse when
/// a live TUI session holds `:freeze-deploys` / `:incident START`
/// (pid-scoped marker — see `crate::freeze`). Exit 3, same class as
/// the pin refusal. These paths had the same blind spot the MCP
/// write tools would have had: a fleet frozen mid-incident could
/// still be written from a second terminal.
pub(crate) fn refuse_if_frozen(prog: &str) {
    if let Some(m) = crate::freeze::read_active() {
        let reason = if m.reason.is_empty() {
            "no reason given"
        } else {
            m.reason.as_str()
        };
        eprintln!(
            "{prog}: refusing — fleet freeze active ({reason}) — lift with `{}` in the owning TUI (pid {})",
            m.remedy(),
            m.pid
        );
        std::process::exit(3);
    }
}

/// Shared value-flag guard: reject a missing value or a following
/// flag consumed as one. Class fix from the 0.26 max-review — a
/// swallowed value silently changed semantics (`lint --fix --yes
/// --env` widened to the whole fleet; `--rules --json` disabled a CI
/// gate and ate the JSON flag).
pub(crate) fn take_value<'a, I: Iterator<Item = &'a String>>(
    iter: &mut I,
    prog: &str,
    flag: &str,
    what: &str,
) -> Result<String, String> {
    let Some(v) = iter.next() else {
        return Err(format!("{prog}: {flag} expects {what}"));
    };
    if v.starts_with("--") {
        return Err(format!("{prog}: {flag} expects {what}, got flag '{v}'"));
    }
    Ok(v.clone())
}

/// Exit a CLI command after draining in-flight audit-webhook POSTs —
/// `std::process::exit` (and returning from `#[tokio::main]`) cancels
/// spawned tasks, so a fire-and-forget outcome POST written just
/// before exit usually never left the machine. No-op when nothing is
/// in flight; bounded at slightly over the POST timeout.
pub(crate) async fn exit_after_drain(code: i32) -> ! {
    crate::audit::drain_webhooks(std::time::Duration::from_secs(12)).await;
    std::process::exit(code);
}

/// Drain in-flight webhook POSTs before a CLI command's Ok return —
/// same rationale as [`exit_after_drain`], for the success paths.
pub(crate) async fn drain_before_return() {
    crate::audit::drain_webhooks(std::time::Duration::from_secs(12)).await;
}

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

    // decide_poll matrix tests live in `src/deploy_poll.rs`
    // alongside the function itself (0.16 move).

    #[test]
    fn cli_esc_escapes_quotes_and_backslashes() {
        assert_eq!(cli_esc("hello"), "hello");
        assert_eq!(cli_esc("a\"b"), "a\\\"b");
        assert_eq!(cli_esc("a\\b"), "a\\\\b");
        // Newlines + tabs (added in 0.15) are also escaped so the
        // value can land in any JSON context safely.
        assert_eq!(cli_esc("a\nb"), "a\\nb");
        assert_eq!(cli_esc("a\tb"), "a\\tb");
    }

    #[test]
    fn json_string_wraps_in_quotes_and_escapes() {
        assert_eq!(json_string(""), "\"\"");
        assert_eq!(json_string("hello"), "\"hello\"");
        assert_eq!(json_string("a\"b"), "\"a\\\"b\"");
        // Round-trip via the YAML-superset parser.
        let s = "line1\nline2 \"with quotes\"";
        let escaped = json_string(s);
        let parsed: String =
            serde_yml::from_str(&escaped).expect("hand-rolled JSON should parse as YAML");
        assert_eq!(parsed, s);
    }
}