ebman 0.31.0

k9s-style TUI for AWS Elastic Beanstalk
# Architecture

A map of the codebase for anyone reading it for the first time — where things
live, how a keystroke becomes an AWS call, and the handful of rules that
aren't enforced by the compiler.

For build/test commands see [`docs/development.md`](docs/development.md); for
the AI-assisted-contributor rules see [`CLAUDE.md`](CLAUDE.md).

## Shape of the crate

`ebman` is lib + bin. `src/lib.rs` holds everything testable; `src/main.rs` is
a thin entry point that parses argv, sets up logging and the panic hook, and
either dispatches a headless subcommand or enters the TUI.

```
src/main.rs      argv, logging, panic hook, alt-screen lifecycle
src/lib.rs       module list + the `Tui` / `LogReloadHandle` aliases
├── app/         the TUI: state, event loop, everything it can do
├── ui/          rendering only — takes &App, returns nothing. One
│                module per surface (chrome / header / table / events /
│                footer / detail / overlays / action / dlq / shell /
│                help); `src/ui.rs` is the dispatcher and the map
├── aws/         every AWS SDK call, behind plain Rust types — one
│                module per service, so `aws/eb.rs` (the domain) is
│                separable from the twelve generic ones
├── cli/         headless subcommands (`ebman envs`, `action`, `ctl`, `mcp`)
├── lint.rs      the environment lint rules (see docs/lint-rules.md)
└── ...          config, state, audit, themes, plugins, LLM explain, ...
```

The dependency direction is one-way: `ui` reads `app`, `app` calls `aws`,
`aws` knows about neither. `cli` reuses `aws` and the pure helpers in `app`
without constructing an `App`.

## Where to start reading

1. **[`src/app.rs`]src/app.rs** — the `App` struct and the event loop. Its
   module doc lists the invariants and maps every submodule.
2. **[`src/app/input.rs`]src/app/input.rs** — the keymap. Follow any key you
   care about from here.
3. **[`src/app/dispatch.rs`]src/app/dispatch.rs** — the `:command` router.
   Pure one-liner routing; the bodies live in `app/cmd_*.rs`.
4. **[`src/commands.rs`]src/commands.rs** — the command registry. Adding a
   `:command` means one entry here plus one arm in `dispatch.rs`; a test pins
   the two together so help, palette and dispatch can't drift.
5. **[`src/ui.rs`]src/ui.rs** — the render dispatcher. Its module doc
   maps each surface to the module that owns it; `draw` picks the
   layout for the current `Mode` and hands each region on.
6. **[`src/aws.rs`]src/aws.rs** — the AWS boundary. Its module doc maps
   the per-service split; `aws/eb.rs` is the Elastic Beanstalk domain and
   the other twelve are generic AWS surface.

## How a keystroke becomes an AWS call

```
crossterm event
  └─ App::handle_event            app/input.rs
      └─ App::handle_key          per-mode keymap
          └─ App::execute_command app/dispatch.rs   (`:` commands)
              └─ App::deny_write  app/safety.rs     ← every mutation passes here
                  └─ App::spawn_* app/spawn_*.rs    tokio task, captures `generation`
                      └─ aws::…   src/aws.rs
                          └─ AppMsg → App::handle_msg   app/msg.rs
                              └─ App::rebuild_view      app/view.rs
                                  └─ ui::draw           src/ui.rs
```

The loop itself is `App::run`: it selects over terminal input, the `AppMsg`
channel, and timers, mutates `App`, and redraws. AWS work never blocks it —
every call is a spawned task that reports back as an `AppMsg`.

## The five rules

The compiler won't catch you breaking these. Rule 1 is the exception — the
type system now does most of the work there, and the story of how that came
about is in `src/app/view_state.rs`. Four of the five have bitten.

**1. Mutating view state means rebuilding the view.**
The table `ui` draws is a filtered, optionally grouped projection of
`environments`, plus two per-row lookup maps. It's cached, and
[`ViewState`](src/app/view_state.rs) is what keeps the cache honest: the
derived slices are private, changing `filter` or `grouped` marks them stale
automatically, and reading a stale one trips a `debug_assert` (and logs once
in release). Sort is private too: `App::set_sort` is the only way to change
it and it always re-sorts, so the header arrow can't disagree with the rows.
The inputs `ViewState` doesn't own — `environments`, `aliases`,
`latest_stacks`, the theme palette — still need an explicit
`view.invalidate()` before `rebuild_view()`.

One trap worth naming: `filter_mut()` marks the cache stale on the *borrow*,
not on an actual edit. If you only want to offer a key to the buffer, use
`filter_handle_key`, which marks it stale only when the key was consumed.

**2. Per-env work uses the row's region.**
`self.aws` is the *home* client — its region is `context.region`. Under a
multi-region fan-out the selected row is routinely somewhere else, so
anything about one environment goes through
[`App::client_for_env`](src/app.rs) (or `client_for_app` /
`current_env_client` / `detail_client` / `why_red_client` / `dlq_client`),
which resolves inside the spawned task. `spawn_aws_in` is the per-region
sibling of `spawn_aws`. Audit lines take the same region, so the journal
names where the write actually went — and a dispatch and its completion
have to agree. A test in `app/tests.rs` requires every remaining
`self.aws` spawn site to declare why account- or region-wide is right for
it.

**3. Async results check `generation`.**
Every spawned task captures the `generation` it launched at. If the operator
switches region, profile or account while it's in flight, `generation`
advances and the handler drops the result rather than applying data from the
old context to the new one. Every new `AppMsg` variant must do this.

**4. Guarded key arms come first.**
A `KeyCode::Char(c) if ctrl` arm must precede the unguarded `KeyCode::Char(c)`
arm for the same character. The compiler does not warn when the unguarded one
shadows it.

**5. Never print to stdout from the running app.**
The alternate screen swallows `println!`/`eprintln!` and they corrupt the
display. Use `tracing::*`; output goes to `~/.cache/ebman/ebman.log`. The same
reason is why a panic in the TUI is worse than a wrong frame — see the release
note in `ViewState::assert_fresh`.

## Writes and safety

Every mutating path — TUI, CLI and the MCP server alike — funnels through
`App::deny_write` / `deny_write_batch` in [`src/app/safety.rs`](src/app/safety.rs).
`--deny-write`, `safety.envs.NAME.read_only`, `safety.accounts.NAME.read_only`
and the freeze window are all resolved there, so there is exactly one place to
audit. Writes are journalled by [`src/audit.rs`](src/audit.rs).

Destructive actions go through a confirm modal and then sit in an undo window
(`app/action_flow.rs`) before they're dispatched — `tick_pending_dispatch` is
what finally fires them.

## Testing

Tests live beside the code in `#[cfg(test)] mod tests` blocks; `app`'s are in
[`src/app/tests.rs`](src/app/tests.rs). AWS is stubbed via
`AwsClient::stub()`, and `App::for_tests` builds an `App` without touching the
network or the filesystem. Pure logic — parsers, formatters, the sorting and
diffing helpers — is deliberately extracted out of UI and event handlers so it
can be tested directly; `app/render.rs`, `app/text.rs`, `app/deploy_math.rs`
and `app/config_diff.rs` are all `&str`-in, `String`-out.

`src/demo_fixture.rs` builds a synthetic fleet, which is what `ebman --demo`
runs against and what the render tests draw.