Skip to main content

ebman/
app.rs

1use std::{
2    collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
3    sync::Arc,
4    time::{Duration, Instant},
5};
6
7use color_eyre::eyre::{Result, WrapErr};
8use crossterm::event::{
9    Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
10    MouseEventKind,
11};
12use futures::StreamExt;
13use ratatui::{
14    layout::Rect,
15    widgets::{ListState, TableState},
16};
17use tokio::sync::mpsc;
18
19use crate::{
20    aws::{
21        AppVersion, Application, AwsClient, AwsContext, CwAlarm, Environment, Event as EbEvent,
22        Identity, Instance, MetricSeries, QueueMessage, WorkerQueues,
23    },
24    config::Config,
25    profiles,
26    state::{self, PersistedState},
27    theme::{IconStyle, Theme},
28    ui, Tui,
29};
30
31// Re-export action-cluster types so existing consumers (ui.rs, tests,
32// the `App` impl below) keep their `crate::app::Action` etc. paths
33// working after the move into `crate::mode_action`.
34pub use crate::mode_action::{
35    Action, ActionFlow, ConfirmKind, ConfirmModal, DryRunInfo, ParameterisedAction, ACTIONS,
36};
37pub use crate::mode_detail::{
38    config_editable_items, health_items, ConfigEdit, ConfigEditMode, ConfigItem, ConfigItemKind,
39    DetailState, DetailTab, EventLevel, EventWindow, HealthItem, LogTail, LogTailStage,
40};
41
42// Sub-modules: `execute_command` arms split by category. The
43// dispatch site below is now pure one-liner routing — every arm
44// body lives in one of these modules. Categories: lifecycle
45// actions (deploy/upgrade/clone/scale/...), alarm CRUD,
46// config-template CRUD, navigation (region/profile/sort/group/...),
47// option-settings setters, multi-account overlays
48// (accounts/org-health/find-env), per-env settings
49// (tag/env/capacity/...), view persistence (views/filters),
50// bulk-write commands (batch-action/batch-deploy/...), and the
51// remaining misc cluster (custom-platforms/versions/metric/...).
52mod cmd_action;
53mod cmd_alarms;
54mod cmd_config_template;
55mod cmd_misc;
56mod cmd_nav;
57mod cmd_option;
58mod cmd_overlay;
59mod cmd_settings;
60mod cmd_view;
61mod cmd_write;
62mod mode_keys;
63mod msg;
64pub use crate::mode_dlq::{DlqState, QueueView};
65
66/// Names of all built-in `:commands`. Used to detect collisions when loading
67/// user plugins from `commands.toml` — plugins that shadow a built-in are
68/// dropped with a warning rather than silently masking it.
69///
70/// Derived from [`crate::commands::COMMANDS`] so adding a command only
71/// requires one edit (`commands.rs`). The list is built lazily on first
72/// access; the registry is a `const` slice so the work is O(N) with N≈90.
73pub fn builtin_commands() -> Vec<&'static str> {
74    crate::commands::all_names()
75}
76
77/// Which on-screen panel is "focused" — i.e. which one j/k/Enter target. The
78/// main table is the default; the user can `Ctrl-]` over to the events panel
79/// (when visible) for cursor navigation + line yank.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Focus {
82    Table,
83    Events,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum ViewMode {
88    Default,
89    Compact,
90    Spacious,
91}
92
93impl ViewMode {
94    pub fn next(self) -> Self {
95        match self {
96            Self::Default => Self::Compact,
97            Self::Compact => Self::Spacious,
98            Self::Spacious => Self::Default,
99        }
100    }
101    pub fn label(self) -> &'static str {
102        match self {
103            Self::Default => "default",
104            Self::Compact => "compact",
105            Self::Spacious => "spacious",
106        }
107    }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum Scope {
112    Envs,
113    Apps,
114}
115
116impl Scope {
117    pub fn next(self) -> Self {
118        match self {
119            Self::Envs => Self::Apps,
120            Self::Apps => Self::Envs,
121        }
122    }
123    pub fn prev(self) -> Self {
124        // With two scopes, prev() and next() are equivalent, but expose both so
125        // a third scope can be added without changing call sites.
126        self.next()
127    }
128}
129
130// Note on match-arm ordering: guarded arms like `KeyCode::Char('r') if Ctrl`
131// must come BEFORE their unguarded counterparts (`KeyCode::Char('r') => …`),
132// otherwise the unguarded arm shadows them.
133
134pub const HISTORY_CAP: usize = 20;
135const MESSAGE_LOG_CAP: usize = 50;
136const TOAST_CAP: usize = 4;
137
138/// How long a refresh has to be in flight before the `loading…` indicator
139/// in the header appears. Faster round-trips complete invisibly so the user
140/// doesn't see a quick blip on every cycle.
141pub const LOADING_INDICATOR_THRESHOLD: Duration = Duration::from_millis(300);
142
143/// Once the loading indicator becomes visible, keep it visible for at
144/// least this long even if the load completes earlier. Smooths over the
145/// case where a round-trip is *just* slow enough to cross the threshold
146/// and then finishes ~100 ms later — without the linger, the indicator
147/// flashes on and off in a single visible frame which reads as flicker.
148pub const LOADING_INDICATOR_LINGER: Duration = Duration::from_millis(500);
149
150/// A single read-only popup that overlays the main UI. Only one can be open
151/// at once: opening another replaces it; `Esc` / `q` dismisses it. Replacing
152/// the previous six `Option<String>` fields with this enum eliminates the
153/// "did I forget one?" footgun every time a new overlay is added (separate
154/// dismiss path, separate draw conditional, separate dismiss-on-context-switch
155/// branch, …).
156#[derive(Debug, Clone)]
157pub enum Overlay {
158    /// Raw `DescribeEnvironment` dump shown as pretty JSON via `D`.
159    Describe(String),
160    /// Embedded changelog shown via `:whatsnew`.
161    Whatsnew(String),
162    /// Recent status/error message log shown via `:history`.
163    History(String),
164    /// CloudWatch alarms list shown via `:alarms`. `env_name` carries the env
165    /// the fetch was issued for, so a late `AppMsg::Alarms` for a different
166    /// env can be dropped instead of replacing the overlay's contents.
167    Alarms { env_name: String, body: String },
168    /// Side-by-side env comparison shown via `:diff NAME`.
169    Diff(String),
170    /// Fallback for the `:saved-configs` command when no templates exist.
171    /// Renders the styled `Application: foo / ▸ template` text; for the
172    /// generic-text-dump cases use `TextDump` instead.
173    SavedConfigs(String),
174    /// Generic scrollable text overlay with a custom title. Used by
175    /// `:pending`, `:resources`, `:find-env`, `:org-health`, `:versions`,
176    /// etc. — anywhere we want to show a multi-line result without
177    /// inventing a structured overlay.
178    TextDump { title: String, body: String },
179    /// Interactive variant of `:saved-configs` — cursor over (app, template)
180    /// pairs, with `a` (apply to selected env), `x` (delete), `c` (prefill
181    /// :config-save in the command bar). Distinct from `SavedConfigs(String)`
182    /// because the latter is used as a generic text-dump escape hatch.
183    /// `confirm_delete` armed when the user presses `x` — next y/Y/enter
184    /// dispatches; n/N/esc cancels back to navigation.
185    SavedConfigsInteractive {
186        items: Vec<(String, String)>,
187        cursor: usize,
188        confirm_delete: bool,
189    },
190    /// Unified diagnostic overlay opened by `:why` — aggregates the four
191    /// pieces of context an operator needs when an env goes Red: recent
192    /// events, current alarm states, per-instance health, and the most-
193    /// recent deploy. Each section is fetched in parallel; rendered with
194    /// a "loading…" placeholder until the result lands. `session_id`
195    /// drops late results for a prior `:why` invocation (e.g. when the
196    /// operator opens it on env A, closes it, opens on env B before A's
197    /// fetchers finished).
198    WhyRed {
199        env_name: String,
200        /// Captured at open time so the renderer knows whether to show
201        /// the worker-only sections (queues, DLQ peek).
202        tier: String,
203        events: Option<Result<Vec<crate::aws::Event>, String>>,
204        alarms: Option<Result<Vec<crate::aws::CwAlarm>, String>>,
205        instances: Option<Result<Vec<crate::aws::Instance>, String>>,
206        deploys: Option<Result<Vec<crate::aws::AppVersion>, String>>,
207        /// Worker-only: main + DLQ stats. `None` while loading; `Some(Err)`
208        /// surfaced as a red error line. Non-Worker envs leave this as
209        /// `None` forever and the renderer hides the section.
210        queues: Option<Result<crate::aws::WorkerQueues, String>>,
211        /// Worker-only: peek of the first few DLQ messages, fetched as a
212        /// second-stage spawn once the queue stats land + DLQ is non-empty.
213        /// `None` until either (a) the queue stats came back empty, or
214        /// (b) the peek result lands. `Some(Ok(empty))` means "DLQ has
215        /// messages but the peek returned no bodies in the visibility
216        /// window we asked for".
217        dlq_messages: Option<Result<Vec<crate::aws::QueueMessage>, String>>,
218        session_id: u64,
219        /// Cursor over the drillable items rendered in the overlay. The
220        /// renderer maintains the parallel `App.why_items` list in lockstep,
221        /// so the key handler can look up `why_items[cursor]` on `Enter`.
222        cursor: usize,
223    },
224    /// Scrubbed bug-report payload from `:report-bug`. The operator
225    /// chooses how to deliver: `y` copies to clipboard (paste into a
226    /// GitHub issue manually); `b` opens a pre-filled GitHub issue
227    /// in the browser; `esc` cancels. Ebman never sends the payload
228    /// itself — the operator is always the agent that emits data,
229    /// on their machine, after seeing the exact bytes that would
230    /// leave.
231    ReportBug { body: String },
232    /// Per-app action menu opened by Apps-scope `a`. Lists batch
233    /// operations that target every env in the application — the
234    /// operator picks one via j/k + Enter and the dispatcher fans
235    /// out through the existing `cmd_batch_*` helpers. Closing with
236    /// esc / q returns to the Apps table without doing anything.
237    AppsActionMenu {
238        app_name: String,
239        /// Cached at open time so the action labels can show "N envs"
240        /// without re-walking `app.environments` per frame.
241        env_names: Vec<String>,
242        cursor: usize,
243    },
244    /// Streaming CloudWatch Logs view opened by `:logs-tail`. Polling task
245    /// pushes new events via `AppMsg::LogTailEvents` every ~2s; the buffer
246    /// is capped at `LOG_TAIL_MAX_LINES` (oldest dropped when growing).
247    /// `following` snaps to the tail on new events; the user can pause it
248    /// by scrolling up.
249    LogTail {
250        log_group: String,
251        env_name: String,
252        events: std::collections::VecDeque<crate::aws::LogEvent>,
253        scroll: u16,
254        following: bool,
255        since_ms: i64,
256        filter_input: String,
257        filter_active: bool,
258        filter_pattern: Option<regex::Regex>,
259        last_err: Option<String>,
260        /// Unique-per-session id; the polling task carries the same id and
261        /// late events for stale sessions are dropped on arrival.
262        session_id: u64,
263    },
264    /// `:about` / `:credits` — the project card with the animated
265    /// 8-bit giant-grabs-the-beanstalk scene. The `Instant` is the
266    /// open time; the renderer derives the animation frame from its
267    /// elapsed time, and the `anim` ticker is woken while it's open.
268    About(std::time::Instant),
269}
270
271pub const LOG_TAIL_MAX_LINES: usize = 2000;
272
273/// One drillable row in the `:why` triage overlay. The renderer pushes
274/// these in lockstep with the lines it emits (events / alarms /
275/// instances / deploys / queues / dlq), and writes the list to
276/// `App.why_items` so the key handler can act on `items[cursor]` when
277/// the operator presses `Enter`.
278#[derive(Debug, Clone)]
279pub enum WhyItem {
280    /// Pop up `Overlay::Describe` with the formatted detail text. Used
281    /// for events / alarms / instances / deploys — read-only examination.
282    Describe(String),
283    /// Jump to the DLQ viewer (where the operator can examine / purge /
284    /// replay). Used for the worker-queues summary row + DLQ message
285    /// peek rows. The env name + queue URLs are read from the active
286    /// `Overlay::WhyRed` at drill time.
287    OpenDlq,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum ToastKind {
292    Info,
293    Success,
294    Error,
295}
296
297#[derive(Debug, Clone)]
298pub struct Toast {
299    pub text: String,
300    pub kind: ToastKind,
301    pub shown_at: Instant,
302}
303
304impl Toast {
305    pub fn ttl(&self) -> Duration {
306        match self.kind {
307            ToastKind::Error => Duration::from_secs(8),
308            _ => Duration::from_secs(4),
309        }
310    }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum MsgKind {
315    Info,
316    Error,
317}
318
319const WHATSNEW: &str = "\
320ebman — what's new
321==================
322
323Recent additions:
324  • --version / --help / --read-only CLI flags
325  • README and GitHub Actions CI
326  • Themes: dark, light, high-contrast (set in config.toml)
327  • Detail auto-refresh (R in Detail mode)
328  • Open env in console (b)
329  • Describe overlay (D — raw env JSON)
330  • Breadcrumb top-line, FROZEN pill, quick-jump 1-9
331  • Pin / star envs (*), persisted across runs
332  • Local env aliases (:alias NAME LABEL)
333  • Exports: TSV (^Y), JSON (:json), Markdown (:report)
334  • Read-only mode (--read-only or :readonly on)
335  • Local audit log (~/.cache/ebman/audit.log)
336  • Notification bell (notify_bell = true in config.toml)
337  • Crash report writer
338
339Press esc / q / w to close.";
340
341const WELCOME_OVERLAY: &str = "\
342Welcome to ebman
343================
344
345Looks like this is your first run — no AWS credentials or persisted ebman
346state were found on this machine. Here's what you'll need:
347
3481. AWS credentials. Either:
349     aws sso login --profile my-sso-profile     (recommended)
350   or set up ~/.aws/credentials with an access key, then
351     export AWS_PROFILE=my-profile
352
3532. The IAM identity needs at least these EB read permissions:
354     elasticbeanstalk:DescribeEnvironments
355     elasticbeanstalk:DescribeApplications
356     elasticbeanstalk:DescribeEvents
357   Destructive actions (rebuild / restart / swap / terminate) require their
358   matching write permission; you can stay safe with `--read-only` until then.
359
3603. Optional: drop a config at ~/.config/ebman/config.toml. See README.md for
361   the full schema (theme, refresh_interval_secs, extra_regions, …).
362
363Key bindings:
364  ?         this help screen
365  p / r     switch profile / region
366  :         command bar
367  Ctrl-K    fuzzy command palette
368  Ctrl-X    redact mode (good for screenshots / streaming)
369
370Press esc / q / w to close.";
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub enum SortKey {
374    App,
375    Name,
376    Status,
377    Health,
378    Age,
379    Version,
380}
381
382impl SortKey {
383    /// Cycle in the same order the columns appear in the UI:
384    /// NAME → APPLICATION → STATUS → HEALTH → VERSION → AGE → NAME.
385    pub fn next(self) -> Self {
386        match self {
387            Self::Name => Self::App,
388            Self::App => Self::Status,
389            Self::Status => Self::Health,
390            Self::Health => Self::Version,
391            Self::Version => Self::Age,
392            Self::Age => Self::Name,
393        }
394    }
395
396    pub fn label(self) -> &'static str {
397        match self {
398            Self::App => "app",
399            Self::Name => "name",
400            Self::Status => "status",
401            Self::Health => "health",
402            Self::Age => "age",
403            Self::Version => "version",
404        }
405    }
406
407    pub fn parse(s: &str) -> Option<Self> {
408        match s {
409            "app" => Some(Self::App),
410            "name" => Some(Self::Name),
411            "status" => Some(Self::Status),
412            "health" => Some(Self::Health),
413            "age" => Some(Self::Age),
414            "version" => Some(Self::Version),
415            _ => None,
416        }
417    }
418}
419
420/// How event timestamps render. Three-state cycle:
421/// `Utc` (default — matches EB / CloudWatch API output) →
422/// `Local` (operator's wall-clock for cross-referencing with
423/// other terminals / Slack threads) → `Age` (compact `5m` /
424/// `2h` / `3d` relative form). Persists in state.toml as
425/// `event_time_format = "utc"|"local"|"age"`.
426#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
427pub enum EventTimeFormat {
428    #[default]
429    Utc,
430    Local,
431    Age,
432}
433
434impl EventTimeFormat {
435    /// Cycle in the order documented above. Keeping UTC first means
436    /// the no-arg `:event-time` press most often lands the operator
437    /// back at the canonical form (the EB API uses UTC).
438    pub fn next(self) -> Self {
439        match self {
440            Self::Utc => Self::Local,
441            Self::Local => Self::Age,
442            Self::Age => Self::Utc,
443        }
444    }
445
446    pub fn label(self) -> &'static str {
447        match self {
448            Self::Utc => "utc",
449            Self::Local => "local",
450            Self::Age => "age",
451        }
452    }
453
454    pub fn parse(s: &str) -> Option<Self> {
455        match s.to_ascii_lowercase().as_str() {
456            "utc" => Some(Self::Utc),
457            "local" => Some(Self::Local),
458            "age" | "relative" => Some(Self::Age),
459            _ => None,
460        }
461    }
462}
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub enum Mode {
466    Normal,
467    Filter,
468    Help,
469    Picker,
470    Command,
471    Detail,
472    Action,
473    Dlq,
474    QuickJump,
475    Palette,
476    /// Embedded shell pane is foreground; keystrokes are forwarded to the
477    /// subprocess's PTY rather than dispatched as ebman key bindings.
478    /// F12 detaches back to `shell_return_mode`.
479    Shell,
480    /// Modal multi-field form (e.g. `:capacity`). Tab navigates fields,
481    /// per-field input handlers below; `Esc` cancels, `^S` submits.
482    Form,
483}
484
485#[derive(Debug, Clone)]
486pub enum PaletteAction {
487    /// Run a `:` command immediately with no further input.
488    RunCommand(String),
489    /// Switch to command mode with this prefix typed.
490    PrefillCommand(String),
491    /// Jump table cursor to this env.
492    JumpEnv(String),
493    /// Run `:view NAME`.
494    LoadView(String),
495}
496
497#[derive(Debug, Clone)]
498pub struct PaletteItem {
499    pub label: String,
500    pub detail: String,
501    pub kind_tag: &'static str, // "cmd" / "env" / "view" / "plugin"
502    pub action: PaletteAction,
503}
504
505// `DlqState` / `QueueView` moved to `crate::mode_dlq` — re-exported
506// from app.rs above.
507
508// `ActionFlow` / `ConfirmModal` / `ParameterisedAction` / `DryRunInfo`
509// / `ConfirmKind` / `Action` / `ACTIONS` moved to `crate::mode_action`
510// — re-exported from app.rs below so existing imports keep working.
511
512/// One in-flight or recently-completed action. `label` is the human-readable
513/// verb (e.g. "Rebuild env"), `target` the env or instance the
514/// action was dispatched against. `completed` lands when `AppMsg::ActionResult`
515/// arrives; until then the entry counts as in-flight and the user can see it
516/// in the `:pending` overlay + header chip.
517#[derive(Debug, Clone)]
518pub struct PendingAction {
519    pub label: String,
520    pub target: String,
521    pub started: Instant,
522    pub completed: Option<(Instant, Result<(), String>)>,
523}
524
525/// Help overlay scope. `Global` shows the full keymap; the per-mode topics
526/// surface only the keys relevant to where the user just pressed `?`,
527/// avoiding the "wall of help" problem when the user just needs a reminder
528/// about the screen they're on. Set when entering `Mode::Help`.
529///
530/// `Shell` is currently unreachable — `?` in the embedded shell is a
531/// legitimate character to forward to the subprocess (e.g. globbing) — but
532/// kept here for symmetry in case we later bind a separate detach-and-help
533/// combo (e.g. F11).
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535#[allow(dead_code)]
536pub enum HelpTopic {
537    Global,
538    Detail,
539    Dlq,
540    Action,
541    Shell,
542    /// Help for the interactive `:saved-configs` overlay (j/k cursor +
543    /// a/c/x dispatch keys).
544    SavedConfigs,
545}
546
547/// Cap on the in-flight + recently-completed list. Older entries fall off
548/// the front when this is reached.
549pub const PENDING_CAP: usize = 20;
550/// Completed entries linger for this long so the user has time to see the
551/// outcome before the panel clears.
552pub const PENDING_COMPLETED_TTL: Duration = Duration::from_secs(60);
553
554// `DetailTab` / `LogTail` / `LogTailStage` / `DetailState` (+ impl)
555// moved to `crate::mode_detail` — re-exported from app.rs above.
556
557/// Snapshot of an env's pre-deploy state, captured by `spawn_action`
558/// just before a Deploy fires. `previous_version_label` is what the
559/// env was running at capture time — the rollback target. `taken_at`
560/// is wall-clock; the watchdog uses it for status reporting ("armed
561/// 3m ago, 2m to deadline"). Persisted to state.toml so a cross-
562/// session `:rollback` still has a target.
563#[derive(Debug, Clone)]
564#[allow(dead_code)] // env_name + taken_at are diagnostic / future-render fields
565pub(crate) struct DeploySnapshot {
566    /// The env this snapshot was captured for. Redundant with the
567    /// `App.deploy_snapshots` map key, kept for log/debug output.
568    pub env_name: String,
569    pub previous_version_label: String,
570    /// Capture timestamp. Available for "snapshot taken Xs ago"
571    /// status messages on `:rollback` (already used by cmd_rollback)
572    /// + future UI surfacing of how stale a snapshot is.
573    pub taken_at: chrono::DateTime<chrono::Utc>,
574}
575
576/// In-flight auto-rollback watchdog state. Inserted at deploy
577/// dispatch when `--auto-rollback Nm` is set, drained on early
578/// disarm (env reached Green by the next refresh) or on the
579/// deadline firing. The `target_label` is the version we'd
580/// redeploy if the env still isn't healthy at the deadline —
581/// the same as the captured `DeploySnapshot.previous_version_label`
582/// at arm time, snapshotted here so we don't have to re-look-up.
583#[derive(Debug, Clone)]
584#[allow(dead_code)] // most fields are arm-time diagnostic / future-render
585pub(crate) struct ArmedWatchdog {
586    pub env_name: String,
587    /// Snapshot of the rollback target at arm time so the watchdog
588    /// doesn't have to re-look-up via deploy_snapshots when it fires.
589    /// Currently unused — `handle_auto_rollback_check` re-reads from
590    /// `deploy_snapshots` for consistency — but the duplicate is
591    /// load-bearing for a future "show armed countdown with target"
592    /// surface.
593    pub target_label: String,
594    pub armed_at: chrono::DateTime<chrono::Utc>,
595    pub deadline_at: chrono::DateTime<chrono::Utc>,
596}
597
598/// In-flight `--wait-for-green` tracker. Populated when the
599/// operator dispatches `:deploy LABEL --wait-for-green Nm` and
600/// drained by `apply_refresh` once the env either reaches Green
601/// (success) or the deadline elapses (timeout). Parallel to
602/// `ArmedWatchdog` but doesn't dispatch a follow-on action — its
603/// only outcome is a pinned status / error so the operator knows
604/// the deploy result without staring at the table.
605#[derive(Debug, Clone)]
606pub(crate) struct WatchingDeploy {
607    pub env_name: String,
608    pub target_label: String,
609    pub armed_at: chrono::DateTime<chrono::Utc>,
610    pub deadline_at: chrono::DateTime<chrono::Utc>,
611}
612
613/// A captured undo entry — the reverse-action of a single
614/// option-settings write, ready to be re-dispatched by `:undo`.
615/// Captured by `spawn_option_settings_update` right before the
616/// write (via an extra DescribeConfigurationSettings call) so
617/// the operator can reverse the most recent edit even after EB
618/// has committed it.
619///
620/// `to_set` reverses the original write's NEW values back to
621/// their PRIOR values; `to_remove` reverses what was previously
622/// unset (so the reverse drops the key rather than leaving it
623/// as an empty string).
624#[derive(Debug, Clone)]
625pub(crate) struct UndoEntry {
626    pub env_name: String,
627    pub to_set: Vec<(String, String, String)>,
628    pub to_remove: Vec<(String, String)>,
629    /// One-line summary of what the ORIGINAL action was, so the
630    /// undo toast can read "undoing: keypair foo" rather than the
631    /// generic "option-settings update".
632    pub original_summary: String,
633    pub captured_at: chrono::DateTime<chrono::Utc>,
634}
635
636/// Cap on the undo-history deque. Bounds memory while still
637/// covering most operator workflows — a long incident might
638/// run 4-5 config edits in a row; 10 is a generous ceiling.
639pub(crate) const UNDO_HISTORY_CAP: usize = 10;
640
641/// Session-scoped temporary write-lock set by `:freeze-deploys`.
642/// Layered above the per-env / per-account safety pins in
643/// `is_read_only_for` so destructive ops refuse fleet-wide
644/// during triage. Cleared by `:thaw-deploys` or by exiting
645/// ebman — not persisted to state.toml (intentional: the freeze
646/// is an in-session safety gesture, not a durable policy).
647#[derive(Debug, Clone)]
648pub(crate) struct DeployFreeze {
649    /// Operator-supplied reason (e.g. "incident #1234"). Empty
650    /// string when no reason was given. Surfaced in the refusal
651    /// toast so the operator (or a teammate sharing the terminal)
652    /// knows why the lock is on.
653    pub reason: String,
654    pub frozen_at: chrono::DateTime<chrono::Utc>,
655}
656
657impl DeploySnapshot {
658    /// On-disk shape — `"label|RFC3339-ts"`. Pipe separator keeps the
659    /// existing line-oriented state.toml parser happy. The pipe is
660    /// illegal inside an EB version label (EB rejects `|` per its
661    /// version-label validator), so there's no escaping needed.
662    pub fn to_persisted(&self) -> String {
663        format!(
664            "{}|{}",
665            self.previous_version_label,
666            self.taken_at.to_rfc3339()
667        )
668    }
669
670    /// Inverse of `to_persisted`. Returns `None` for malformed lines
671    /// so the loader can silently drop them — better to lose one
672    /// stale entry than to abort the App-init path.
673    pub fn parse_persisted(env_name: &str, raw: &str) -> Option<Self> {
674        let (label, ts_str) = raw.split_once('|')?;
675        let label = label.trim();
676        if label.is_empty() {
677            return None;
678        }
679        let taken_at = chrono::DateTime::parse_from_rfc3339(ts_str.trim())
680            .ok()?
681            .with_timezone(&chrono::Utc);
682        Some(Self {
683            env_name: env_name.to_string(),
684            previous_version_label: label.to_string(),
685            taken_at,
686        })
687    }
688}
689
690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
691pub enum PickerKind {
692    Profile,
693    Region,
694    /// Picker over the env's discovered CW log groups, opened from the
695    /// LogTail streaming overlay so the operator can switch the tailed
696    /// group without typing the full ARN.
697    LogGroup,
698    /// Picker over the env's instances when `:ssh` is invoked without
699    /// a target. Source is `Detail.instances` — the operator must
700    /// have the Detail view open + the Instances tab loaded, or pass
701    /// an explicit `:ssh i-abc` instance ID. Avoids adding a spawn
702    /// path just for `:ssh`-from-cold; the operator's already on
703    /// Detail/Instances when they reach for an SSM session.
704    SshInstance,
705}
706
707pub struct Picker {
708    pub kind: PickerKind,
709    pub items: Vec<String>,
710    pub filter: String,
711    pub list_state: ListState,
712}
713
714/// Payload for `AppMsg::FormMultiSelectLoaded`. Carries the full option
715/// list, parallel display annotations, and the current EB selection so
716/// the form's `MultiSelect` field can be populated in one update.
717#[derive(Clone, Debug)]
718pub struct MultiSelectOptions {
719    pub options: Vec<String>,
720    pub annotations: Vec<String>,
721    pub initial: Vec<String>,
722}
723
724impl Picker {
725    pub fn new(kind: PickerKind, items: Vec<String>, current: Option<&str>) -> Self {
726        let mut list_state = ListState::default();
727        let initial = current
728            .and_then(|c| items.iter().position(|i| i == c))
729            .unwrap_or(0);
730        if !items.is_empty() {
731            list_state.select(Some(initial));
732        }
733        Self {
734            kind,
735            items,
736            filter: String::new(),
737            list_state,
738        }
739    }
740
741    pub fn title(&self) -> &'static str {
742        match self.kind {
743            PickerKind::Profile => " select profile ",
744            PickerKind::Region => " select region ",
745            PickerKind::LogGroup => " select log group ",
746            PickerKind::SshInstance => " select instance for SSM session ",
747        }
748    }
749
750    pub fn filtered(&self) -> Vec<usize> {
751        if self.filter.is_empty() {
752            return (0..self.items.len()).collect();
753        }
754        let needle = self.filter.to_lowercase();
755        self.items
756            .iter()
757            .enumerate()
758            .filter(|(_, v)| v.to_lowercase().contains(&needle))
759            .map(|(i, _)| i)
760            .collect()
761    }
762
763    pub fn move_selection(&mut self, delta: i32) {
764        let filt = self.filtered();
765        if filt.is_empty() {
766            self.list_state.select(None);
767            return;
768        }
769        let cur_visible = self
770            .list_state
771            .selected()
772            .and_then(|s| filt.iter().position(|i| *i == s))
773            .unwrap_or(0) as i32;
774        let next = (cur_visible + delta).rem_euclid(filt.len() as i32) as usize;
775        self.list_state.select(Some(filt[next]));
776    }
777
778    pub fn selected_value(&self) -> Option<String> {
779        self.list_state
780            .selected()
781            .and_then(|i| self.items.get(i).cloned())
782    }
783}
784
785#[derive(Debug, Clone, Copy, PartialEq, Eq)]
786pub enum LoadState {
787    Idle,
788    Loading,
789    Error,
790}
791
792/// In-progress command-bar Tab-completion cycle.
793#[derive(Default)]
794pub struct CompletionState {
795    /// The text the operator had typed before they first pressed Tab to
796    /// start a completion cycle. Cycling forward / backward matches against
797    /// this prefix; typing a new character resets it (and the cycle).
798    /// `None` when no cycle is active.
799    pub origin: Option<String>,
800    /// Position within the candidate list for the active completion cycle.
801    /// Only meaningful when `origin` is `Some`. Zero before the first Tab
802    /// (so the first Tab lands on the first match).
803    pub index: usize,
804}
805
806/// State for the global help overlay.
807pub struct HelpState {
808    pub scroll: u16,
809    /// Last computed max scroll, written by `draw_help` each frame and read
810    /// by the j/k handler so an incremental scroll past the bottom doesn't
811    /// accumulate (which would otherwise require N matching scroll-ups to
812    /// bring content back into view).
813    pub max_scroll: u16,
814    /// Which keymap subset `draw_help` renders. Set whenever `?` opens Help.
815    pub topic: HelpTopic,
816    /// The mode the user was in before they opened help. Restored when help
817    /// closes so pressing `?` from Detail / Action / Dlq doesn't drop the
818    /// user back to Normal and lose the active screen.
819    pub pre_mode: Option<Mode>,
820    /// Overlay (if any) the user had open before pressing `?`. Help renders
821    /// before overlays in the z-order so it's stashed here and restored
822    /// around the help round-trip.
823    pub pre_overlay: Option<Overlay>,
824}
825
826/// State for the bottom Events panel (and the event-timestamp format it
827/// shares with the Detail/Events tab).
828pub struct EventPanel {
829    pub events: Vec<EbEvent>,
830    pub visible: bool,
831    /// How event timestamps render in the Events panel + Detail/Events tab.
832    /// Defaults to UTC so the column matches CloudWatch / EB API output.
833    /// Operator cycles `Utc → Local → Age` via `:event-time` or the `T` key
834    /// in scopes where events are visible. Persists.
835    pub time_format: EventTimeFormat,
836    /// Env the current `events` list was fetched for. `None` = global. Used
837    /// by `refresh_events_if_selection_changed` to detect when the user has
838    /// moved the table cursor to a different env and refetch.
839    pub for_env: Option<String>,
840    pub scroll: u16,
841    /// Inner Rect of the events panel — captured by the renderer so the
842    /// mouse handler can detect drags on the top edge (divider row) for
843    /// resize.
844    pub area: Option<ratatui::layout::Rect>,
845    /// Set when a divider drag is in progress; stores the panel height at
846    /// the moment the user pressed down so we can compute the delta against
847    /// the current mouse row.
848    pub drag_origin: Option<u16>,
849    /// When set, the user has "entered" the events panel for navigation:
850    /// J/K move the cursor within the events list, Y yanks the highlighted
851    /// line. `None` means events keys are inert and the main table responds
852    /// to J/K.
853    pub cursor: Option<usize>,
854    /// Rendered height of the events panel, in rows.
855    pub height: u16,
856}
857
858pub struct App {
859    pub context: AwsContext,
860    pub scope: Scope,
861    pub applications: Vec<Application>,
862    pub app_table_state: TableState,
863    pub environments: Vec<Environment>,
864    pub table_state: TableState,
865    pub table_area: Rect,
866    pub mode: Mode,
867    pub filter: String,
868    pub load_state: LoadState,
869    pub loading_since: Option<Instant>,
870    pub refresh_interval: Duration,
871    /// Once the loading indicator has been visible (i.e. `loading_since`
872    /// exceeded its display-threshold), keep showing it until this instant
873    /// even after the load actually finishes. Smooths over the case where
874    /// an AWS round-trip is *just* slow enough to trigger the indicator
875    /// and then completes ~100 ms later — without this, the status flashes
876    /// yellow → green for a single frame which reads as a flicker. Cleared
877    /// by the render path once `Instant::now() > t`.
878    pub loading_visible_until: Option<Instant>,
879    pub last_refresh: Option<chrono::DateTime<chrono::Utc>>,
880    pub status_message: Option<String>,
881    pub error_message: Option<String>,
882    pub picker: Option<Picker>,
883    pub override_profile: Option<String>,
884    pub override_region: Option<String>,
885    pub history: HashMap<String, VecDeque<String>>,
886    pub redact: bool,
887    pub grouped: bool,
888    pub sort_key: SortKey,
889    pub sort_desc: bool,
890    pub command_input: String,
891    pub completion: CompletionState,
892    pub quickjump_input: String,
893    pub extra_regions: Vec<String>,
894    pub event_panel: EventPanel,
895    /// Env names the user has marked for batch action via `space`. Cleared on
896    /// Esc, on context switch, and after a successful batch dispatch.
897    pub multi_selected: BTreeSet<String>,
898    /// Apps-scope multi-selection (parallel to `multi_selected`).
899    /// `space` in Apps scope toggles an app in/out. Doesn't persist
900    /// across sessions — selection is operator-intent for a single
901    /// task. Apps-scope batch ops (future expansion) will fan across
902    /// every env in every selected app.
903    pub apps_selected: BTreeSet<String>,
904    /// Currently-focused panel. Drives j/k routing and footer hints.
905    pub focus: Focus,
906    /// Regions to fan refreshes across. Empty = single-region mode (only the
907    /// AwsClient's region). Populated by `:region all`.
908    pub multi_regions: Vec<String>,
909    pub detail: Option<DetailState>,
910    pub action_flow: Option<ActionFlow>,
911    pub dlq: Option<DlqState>,
912    pub theme: Arc<Theme>,
913    pub view_mode: ViewMode,
914    pub help: HelpState,
915    pub hover_row: Option<usize>,
916    pub alerts: usize, // count of envs currently in Red, recomputed each refresh
917    /// Cached DLQ depth (`Visible` messages) for each Worker-tier env,
918    /// keyed by env name. Populated by a per-refresh fan-out of
919    /// `describe_worker_queues`. Used by the Red-alert calc + the table
920    /// render's `⚠ DLQ:N` chip on Worker rows. Missing entry = "not
921    /// checked yet" (don't fire an alert on cold state).
922    pub worker_dlq_depths: std::collections::HashMap<String, i64>,
923    /// Pre-deploy snapshots keyed by env name. Captured at deploy
924    /// dispatch time so `:rollback-deploy ENV` (and the watchdog
925    /// armed by `:deploy --auto-rollback Nm`) can redeploy whatever
926    /// version was running just before. In-memory only — lost on
927    /// app restart; the existing `:rollback` falls back to scanning
928    /// the env's event history. See `DeploySnapshot`.
929    pub(crate) deploy_snapshots: std::collections::HashMap<String, DeploySnapshot>,
930    /// Currently-armed auto-rollback watchdogs keyed by env name.
931    /// Populated by `:deploy --auto-rollback Nm`, drained on either
932    /// (a) the env reaching Green on a refresh tick (early disarm)
933    /// or (b) the deadline firing `AutoRollbackCheck`. Used both
934    /// for `apply_refresh`'s early-disarm check and for surfacing
935    /// "auto-rollback armed for X — Ys remaining" in the UI. The
936    /// tokio task that drives the deadline is fire-and-forget;
937    /// the in-flight visibility lives here.
938    pub(crate) armed_watchdogs: std::collections::HashMap<String, ArmedWatchdog>,
939    /// In-flight `--wait-for-green` trackers keyed by env name. Populated
940    /// by `:deploy --wait-for-green Nm`; drained on either (a) the env
941    /// reaching Green on a refresh tick (success outcome) or (b) the
942    /// deadline elapsing without Green (timeout outcome). Either way the
943    /// outcome is a pinned status — no follow-on action like
944    /// `armed_watchdogs`. Both maps can be populated for the same env
945    /// when the operator passes both flags.
946    pub(crate) watching_deploys: std::collections::HashMap<String, WatchingDeploy>,
947    /// Session-scoped freeze set by `:freeze-deploys`. `None` is
948    /// the common case (no freeze active); `Some(...)` makes
949    /// every destructive op refuse with the freeze's reason.
950    pub(crate) deploy_freeze: Option<DeployFreeze>,
951    /// Parsed terraform.tfstate from a walk-up of cwd at App
952    /// construction time, refreshed on `apply_rebuild` (context
953    /// switch) and on `:drift refresh`. `None` when no tfstate
954    /// was discovered — the badge / drift overlay surfaces are
955    /// no-ops in that case. The full `TfState` is held (rather
956    /// than just a derived set) so `:drift ENV` can pull the
957    /// declared option_settings + version_label for the report.
958    pub(crate) tf_state: Option<crate::terraform::TfState>,
959    /// Cached `HashSet` of tf-managed env names — derived from
960    /// `tf_state` and kept in sync with it. Used by the env-table
961    /// render path for the `ⓣ` badge: O(1) lookup per row,
962    /// which matters when an operator has 50+ envs and the
963    /// renderer fires every frame.
964    pub(crate) tf_managed_envs: std::collections::HashSet<String>,
965    /// Ring buffer of reversible option-settings writes captured
966    /// just before each `spawn_option_settings_update` dispatch.
967    /// `:undo` pops the most recent (back of the deque) and
968    /// dispatches its reverse-action. Capped at `UNDO_HISTORY_CAP`;
969    /// older entries fall off the front when the cap is hit.
970    /// Session-scoped — not persisted. Cross-context state is
971    /// cleared on `apply_rebuild` alongside the other env-keyed
972    /// state.
973    pub(crate) undo_history: std::collections::VecDeque<UndoEntry>,
974    /// `--demo` mode flag. Suppresses the periodic refresh (`spawn_refresh`
975    /// becomes a no-op) and the update-check (`spawn_update_check` likewise)
976    /// so hand-crafted fixture data from `demo_fixture::install` stays put.
977    /// All other paths run as normal — keybinds work, overlays render — so
978    /// VHS / asciinema captures show the genuine UI surface. Drill-into-
979    /// other-tabs (`:why`, Detail/Events, …) still fire against the stub
980    /// AwsClient and may return empty or errored data; closing that gap
981    /// is a separate piece of work (spawn-site gating).
982    pub demo_mode: bool,
983    /// Per-env `(healthy, total)` instance counts, populated by
984    /// `spawn_env_instance_counts` after each refresh tick. Drives the
985    /// `INST` column on the main env table. Missing entry = "not
986    /// checked yet"; rendered as `—`. `EnvInstanceCounts { 0, 0 }` is
987    /// a real value ("env reports no instances") and renders as `0/0`.
988    pub env_instance_counts: std::collections::HashMap<String, crate::aws::EnvInstanceCounts>,
989    /// Cost Explorer integration is opt-in via `:cost on`. Toggling
990    /// flips this + triggers a fetch (or a stale-cache load); the
991    /// envs-table COST column renders only while this is true.
992    /// Persisted to state.toml under `cost_enabled`.
993    pub cost_enabled: bool,
994    /// Per-env monthly USD spend, populated by `spawn_cost_fetch`
995    /// after a `:cost on` opt-in. Empty when costs haven't been
996    /// fetched yet or the cache file is missing. Cleared when the
997    /// operator toggles `:cost off` so the column stops rendering
998    /// stale numbers.
999    pub costs: std::collections::HashMap<String, f64>,
1000    pub costs_fetched_at: Option<chrono::DateTime<chrono::Utc>>,
1001    /// `family_key → newest available version` from `ListAvailableSolutionStacks`,
1002    /// built by `spawn_solution_stacks`. Drives the envs-table stale-platform
1003    /// tint. Empty until the first fetch lands; cleared on context switch so a
1004    /// new account/region rebuilds it.
1005    pub latest_stacks: std::collections::HashMap<String, String>,
1006    pub frozen: bool, // when true, auto-refresh ticker is no-op
1007    /// `true` when ebman launched without a `state.toml` on disk —
1008    /// i.e. first-ever run on this machine. Renderer surfaces a
1009    /// one-line "press ? for help, : for commands, Ctrl-K for
1010    /// fuzzy search" hint at the very bottom of the screen.
1011    /// Cleared on the operator's first input event so it never
1012    /// blocks; the persisted state.toml that every refresh writes
1013    /// also means subsequent launches won't re-trigger it.
1014    pub first_run_hint: bool,
1015    /// The currently visible overlay popup, if any. See [`Overlay`].
1016    pub current_overlay: Option<Overlay>,
1017    pub message_log: VecDeque<(chrono::DateTime<chrono::Utc>, MsgKind, String)>,
1018    pub toasts: VecDeque<Toast>,
1019    pub palette_input: String,
1020    pub palette_items: Vec<PaletteItem>,
1021    pub palette_filtered: Vec<usize>,
1022    pub palette_state: ListState,
1023    pub read_only: bool,
1024    pub pinned: BTreeSet<String>,
1025    /// Apps-scope pinned set — apps stay at the top of the Apps table
1026    /// regardless of sort. Persisted to state.toml's `pinned_apps`
1027    /// field. Parallel to `pinned` (which covers envs); the two
1028    /// surfaces have different cursor / sort behaviour so keeping
1029    /// them as separate sets is cleaner than a tagged union.
1030    pub pinned_apps: BTreeSet<String>,
1031    pub aliases: BTreeMap<String, String>,
1032    pub saved_views: BTreeMap<String, String>,
1033    pub hidden_cols: BTreeSet<String>,
1034    /// User-defined extra metric charts for the Metrics tab. Keyed by the
1035    /// operator-chosen display label so re-adding the same label updates
1036    /// in place. Persisted in `state.toml` under `metric.LABEL`.
1037    pub custom_metrics: BTreeMap<String, crate::state::CustomMetricSpec>,
1038    pub log_reload: Option<crate::LogReloadHandle>,
1039    pub log_directive: String,
1040    pub plugins: BTreeMap<String, crate::plugins::Plugin>,
1041    /// Snapshot of `(status_message, error_message)` captured when the current
1042    /// refresh was spawned. apply_refresh clears messages only if they still
1043    /// match this snapshot, so user-initiated status set between kickoff and
1044    /// apply (e.g. pressing `s` to sort during the round-trip) is preserved.
1045    pub status_snapshot_at_refresh: Option<(Option<String>, Option<String>)>,
1046    /// `true` when `status_message` was set by a user-facing command (e.g.
1047    /// `:pending`, `:metric add`) rather than a background spawn helper.
1048    /// Refresh-time auto-clear only touches non-pinned messages — without
1049    /// this, every 15s tick wipes out informational results the user just
1050    /// invoked.
1051    pub status_message_pinned: bool,
1052    /// When set, the next ticker firing skips `spawn_refresh` until this
1053    /// instant has passed. Driven by exponential backoff in response to
1054    /// AWS throttling responses; the user can still force a refresh with
1055    /// `Ctrl-R` / `:refresh`.
1056    pub throttle_until: Option<Instant>,
1057    /// How many consecutive refreshes have come back throttled. Each one
1058    /// roughly doubles the back-off; resets to zero on the next success.
1059    pub consecutive_throttles: u32,
1060    /// Latest still-valid `expiresAt` discovered in `~/.aws/sso/cache`.
1061    /// Recomputed on every ticker tick — the file is cheap to read and the
1062    /// user may `aws sso login` from another shell while ebman is open.
1063    pub sso_expiry: Option<chrono::DateTime<chrono::Utc>>,
1064    /// Rolling list of in-flight + recently-completed action dispatches.
1065    /// See `PendingAction`. Surfaced as a header chip + `:pending` overlay.
1066    pub pending_actions: std::collections::VecDeque<PendingAction>,
1067    /// Action queued for dispatch but inside the [`UNDO_WINDOW`] —
1068    /// see [`PendingDispatch`]. `tick_pending_dispatch` (called from
1069    /// the main loop) fires the AWS call when the deadline passes;
1070    /// `U` in Normal mode cancels.
1071    pub pending_dispatch: Option<PendingDispatch>,
1072    /// Active modal-form session (`:capacity`, future `:network`, etc.).
1073    /// Populated by `open_form`; cleared on cancel / submit completion.
1074    pub form: Option<crate::form::Form>,
1075    /// Handle to the `:logs-tail` polling task. Stored so we can `abort()`
1076    /// it when the overlay closes or the user switches context. None when
1077    /// no tail session is active.
1078    pub log_tail_task: Option<tokio::task::JoinHandle<()>>,
1079    /// Monotonically increasing id for `:logs-tail` sessions. Lets late
1080    /// `AppMsg::LogTailEvents` from a previous session be dropped on arrival.
1081    pub log_tail_session: u64,
1082    /// Same pattern for `:why` diagnostic overlays. Late
1083    /// `AppMsg::WhyRed{Events,Alarms,Instances,Deploys}` for a prior
1084    /// invocation get dropped when this counter has moved on.
1085    pub why_red_session: u64,
1086    /// Drillable items rendered in the active `:why` overlay, written by
1087    /// `draw_why_red_overlay` and read by the overlay's key handler on
1088    /// `Enter`. Empty whenever the overlay isn't a `WhyRed`.
1089    pub why_items: Vec<WhyItem>,
1090    /// Newer ebman release advertised by crates.io, if any. Populated by the
1091    /// fire-and-forget update-check task that runs once at startup.
1092    pub update_available: Option<crate::update_check::LatestRelease>,
1093    /// When `true`, `run()` exits and `main()` re-execs the binary so the
1094    /// user keeps their terminal session across a code change. Driven by
1095    /// `ControlOp::Reload` over the control socket.
1096    pub reload_requested: bool,
1097    /// When `Some`, the run loop spawns an embedded SSM shell session
1098    /// targeting this instance ID into `current_shell`. Keystrokes in
1099    /// `Mode::Shell` are forwarded to the PTY rather than dispatched as
1100    /// ebman key bindings.
1101    pub pending_shell_target: Option<String>,
1102    /// Set when `:env-edit` is mid-flight: the `fetch_env_vars`
1103    /// result arrived but the main loop hasn't yet shelled out to
1104    /// `$EDITOR` (which needs the `Tui` handle to leave + re-enter
1105    /// the alternate screen, only available in the main loop).
1106    /// Carries `(env_name, current_env_vars)` — the editor opens
1107    /// against these, diffs on save, dispatches the deltas.
1108    pub pending_env_edit: Option<(String, Vec<(String, String)>)>,
1109    /// The live embedded shell pane, if any. `None` outside Mode::Shell.
1110    pub current_shell: Option<Box<crate::shell::ShellSession>>,
1111    /// Mode to return to when the user detaches from a shell pane (F12).
1112    pub shell_return_mode: Mode,
1113    /// Snapshot of the last buffer we rendered, captured from inside the
1114    /// `terminal.draw` closure. ratatui swaps the front/back buffer after
1115    /// `draw()` returns, so a snapshot taken at SCREEN-request time via
1116    /// `current_buffer_mut()` would read the empty back-buffer; cloning
1117    /// during the render is the only reliable way to expose what's actually
1118    /// on screen to the control plane.
1119    pub last_rendered_buffer: Option<ratatui::buffer::Buffer>,
1120    pub notify_bell: bool,
1121    /// Mirror of `Config::notify_webhook`. The actual fan-out
1122    /// reads from the global `NOTIFY_WEBHOOK_URL` `OnceLock` so
1123    /// `write_audit_line` (a free fn called from 36 sites) doesn't
1124    /// need a `&self` borrow. We hold it on App too just so
1125    /// `:settings`-style round-trips can serialise the current
1126    /// value back to config.toml.
1127    pub notify_webhook: Option<String>,
1128    /// User-defined command aliases from `config.toml`'s
1129    /// `alias.NAME = "expansion"` entries. Looked up in
1130    /// `execute_command` before the dispatch match; an alias
1131    /// expansion + the rest of the typed args become the new
1132    /// command line, then re-parsed. Single-level expansion only
1133    /// (no transitive chaining) to keep cycle detection simple.
1134    /// Named `command_aliases` to disambiguate from the existing
1135    /// `App.aliases` (env-rename labels, state.toml-persisted).
1136    pub command_aliases: std::collections::HashMap<String, String>,
1137    /// Operator-disabled lint rule IDs from `config.toml`'s
1138    /// `lint.disable = "EBL001,EBL006"` line. Mirrored here so
1139    /// :settings round-trips preserve operator-set disables on
1140    /// save. The `ebman lint` CLI uses its own `config::load_lint_disables`
1141    /// loader so the disables apply to both surfaces.
1142    pub lint_disable: Vec<String>,
1143    /// `[explain]` block from `config.toml`. Round-tripped through
1144    /// the App so `:settings` save doesn't clobber them; not yet
1145    /// editable from the in-app settings form.
1146    pub explain_enabled: bool,
1147    pub explain_provider: String,
1148    pub explain_model: String,
1149    pub explain_api_key_env: String,
1150    pub explain_ollama_url: String,
1151    pub explain_max_tokens: u32,
1152    pub required_tags: Vec<String>,
1153    /// The raw `icons = …` string from `config.toml` (before resolution to
1154    /// [`crate::theme::IconStyle`]). Kept verbatim so `:settings` can round-trip
1155    /// values like `"auto"` without flattening them to the resolved style.
1156    pub cfg_icons_raw: String,
1157    /// Per-profile theme overrides loaded from `config.toml`'s
1158    /// `profile_themes` key. Empty when nothing is configured. Consulted
1159    /// by `maybe_apply_profile_theme` on initial setup + every profile
1160    /// switch through `apply_rebuild` so the visual cue follows the
1161    /// active profile without restart.
1162    pub profile_themes: std::collections::HashMap<String, String>,
1163    /// Per-environment runbook URLs from `config.toml`'s `runbooks.ENV`
1164    /// keys. Surfaced in the `:why` triage overlay; empty when unset.
1165    pub runbooks: std::collections::HashMap<String, String>,
1166    /// Per-env read-only locks (config.toml `safety.envs.NAME.read_only`).
1167    /// `Some(true)` blocks destructive actions against the named env
1168    /// even when the global `--read-only` toggle is off.
1169    pub safety_envs: std::collections::HashMap<String, bool>,
1170    /// Per-account read-only locks (config.toml
1171    /// `safety.accounts.NAME.read_only`). Matched against the active
1172    /// account name (the `:account NAME` key or the AWS profile name).
1173    pub safety_accounts: std::collections::HashMap<String, bool>,
1174    /// Named AssumeRole accounts loaded from `config.toml`'s
1175    /// `accounts.NAME.*` keys. `:account NAME` consults this map first;
1176    /// if the name matches, builds an `AwsClient` via STS AssumeRole.
1177    /// Otherwise falls back to the legacy `:profile NAME` aliasing.
1178    pub accounts: std::collections::HashMap<String, crate::config::AccountSpec>,
1179    /// Base theme name from `theme = …` — kept separate from the
1180    /// running `theme` so a profile-themed session reverts cleanly when
1181    /// the operator switches back to a profile with no override.
1182    pub base_theme_name: String,
1183    pub newly_red: HashSet<String>,
1184    /// Env names that appeared for the first time on the most recent
1185    /// refresh (weren't in `prev_health` last cycle). Used by the env
1186    /// table to render a transient `+` marker on the NAME cell so a new
1187    /// env doesn't scroll past unnoticed. Cleared on context switch +
1188    /// rotated each refresh.
1189    pub newly_added: HashSet<String>,
1190    /// Delta in counts vs. the previous refresh, e.g. {"Red" → +1, "Yellow" → -1}.
1191    pub health_delta: Vec<(String, i32)>,
1192    pub status_delta: Vec<(String, i32)>,
1193    prev_alerts: usize,
1194    prev_health: HashMap<String, String>,
1195    prev_status: HashMap<String, String>,
1196    cached_filtered: Vec<usize>,
1197    cached_display: Vec<DisplayRow>,
1198    /// Per-application palette colour, assigned by order of first appearance
1199    /// in the *filtered* view. Rebuilt in [`App::rebuild_view`] so that the
1200    /// render hot path can look up `app → Color` without allocating a fresh
1201    /// HashMap per frame (previously `draw_table` did this on every draw).
1202    pub cached_app_colors: HashMap<String, ratatui::style::Color>,
1203    /// `env_name → newest available platform version` for envs running a
1204    /// superseded solution stack. Rebuilt in [`App::rebuild_view`] so the
1205    /// render hot path does an O(1) lookup instead of re-parsing every
1206    /// env's stack string per row per frame. Empty until `latest_stacks`
1207    /// has been fetched.
1208    pub cached_stale_platforms: HashMap<String, String>,
1209    pending_select: Option<String>,
1210    aws: Arc<AwsClient>,
1211    generation: u64,
1212    msg_tx: mpsc::UnboundedSender<AppMsg>,
1213    msg_rx: mpsc::UnboundedReceiver<AppMsg>,
1214    quit: bool,
1215}
1216
1217enum AppMsg {
1218    Refresh {
1219        gen: u64,
1220        result: Result<Vec<Environment>, String>,
1221    },
1222    Applications {
1223        gen: u64,
1224        result: Result<Vec<Application>, String>,
1225    },
1226    /// Per-app newest version, fanned out after `Applications` lands. Each
1227    /// tuple is `(app_name, latest_version_label, latest_version_created)`;
1228    /// apps that failed to fetch are simply absent from the results vec so
1229    /// a transient error on one app doesn't blank the column for all.
1230    AppLatestVersions {
1231        gen: u64,
1232        results: Vec<(
1233            String,
1234            Option<String>,
1235            Option<chrono::DateTime<chrono::Utc>>,
1236        )>,
1237    },
1238    /// Per-Worker-env DLQ depth, fanned out after `Refresh` lands. Each
1239    /// tuple is `(env_name, dlq_visible_count)`; envs whose fetch failed
1240    /// are absent so a transient SQS error doesn't blank the column for
1241    /// all of them. Feeds into the Red-alert calc + the table render.
1242    WorkerQueueCheck {
1243        gen: u64,
1244        results: Vec<(String, i64)>,
1245    },
1246    /// Per-env `(healthy, total)` instance counts, fanned out after
1247    /// `Refresh` lands via `spawn_env_instance_counts`. Failed envs are
1248    /// absent. Feeds the `INST` column on the main table.
1249    EnvInstanceCountsCheck {
1250        gen: u64,
1251        results: Vec<(String, crate::aws::EnvInstanceCounts)>,
1252    },
1253    Rebuild(Result<Box<AwsClient>, String>),
1254    Identity {
1255        gen: u64,
1256        result: Result<Identity, String>,
1257    },
1258    Events {
1259        gen: u64,
1260        result: Result<Vec<EbEvent>, String>,
1261    },
1262    DetailEvents {
1263        gen: u64,
1264        env_name: String,
1265        result: Result<Vec<EbEvent>, String>,
1266    },
1267    DetailInstances {
1268        gen: u64,
1269        env_name: String,
1270        result: Result<Vec<Instance>, String>,
1271    },
1272    DetailQueues {
1273        gen: u64,
1274        env_name: String,
1275        result: Result<WorkerQueues, String>,
1276    },
1277    DetailMetrics {
1278        gen: u64,
1279        env_name: String,
1280        result: Result<Vec<MetricSeries>, String>,
1281    },
1282    DetailTags {
1283        gen: u64,
1284        env_name: String,
1285        result: Result<Vec<(String, String)>, String>,
1286    },
1287    /// Env vars for the Config tab — same shape as DetailTags but pulled
1288    /// from `DescribeConfigurationSettings` filtered to the app:environment
1289    /// namespace.
1290    DetailEnvVars {
1291        gen: u64,
1292        env_name: String,
1293        result: Result<Vec<(String, String)>, String>,
1294    },
1295    /// CloudWatch Logs groups discovered for an env. Sent once on Detail
1296    /// open; the Logs tab uses this to render an accurate "streaming
1297    /// available" hint.
1298    DetailLogGroups {
1299        gen: u64,
1300        env_name: String,
1301        groups: Vec<String>,
1302    },
1303    /// CW alarms attached to an env. Populates the Detail-Health-tab
1304    /// alarms section. Mirrors `AppMsg::WhyRedAlarms` but lands on the
1305    /// Detail view's `cw_alarms` field — single fetch path, two
1306    /// destinations.
1307    DetailAlarms {
1308        gen: u64,
1309        env_name: String,
1310        result: Result<Vec<crate::aws::CwAlarm>, String>,
1311    },
1312    /// Cost Explorer fetch result. Populates `App.costs` so the env
1313    /// table's COST column renders without waiting for the next
1314    /// refresh tick. Also written through to the on-disk cache so
1315    /// subsequent sessions render immediately.
1316    CostsFetched {
1317        gen: u64,
1318        account: Option<String>,
1319        region: String,
1320        result: Result<Vec<crate::aws::EnvCost>, String>,
1321    },
1322    /// Flat `ListAvailableSolutionStacks` result. The handler folds it into
1323    /// `App.latest_stacks` (family → newest version) so the envs table can
1324    /// flag platforms with a newer version available.
1325    SolutionStacks {
1326        gen: u64,
1327        result: Result<Vec<String>, String>,
1328    },
1329    /// Recently-registered application versions for an env's app.
1330    /// Populates the Detail-Health-tab "recent deploys" section.
1331    DetailRecentVersions {
1332        gen: u64,
1333        env_name: String,
1334        result: Result<Vec<crate::aws::AppVersion>, String>,
1335    },
1336    /// Pre-fill values for an open modal form. The handler walks the form's
1337    /// `(field_key, namespace, option_name)` mappings and populates each
1338    /// field's `value` from `settings`. Late messages (stale form / context
1339    /// switch) are dropped.
1340    FormPrefilled {
1341        gen: u64,
1342        env_name: String,
1343        settings: Result<Vec<(String, String, String)>, String>,
1344    },
1345    /// Load `MultiSelect` options for the named field of an open form.
1346    /// Used by the `:subnets` / `:security-groups` pickers — the option
1347    /// list comes from EC2 (DescribeSubnets / DescribeSecurityGroups),
1348    /// not from the env's option settings, so this lives on a separate
1349    /// AppMsg from FormPrefilled. Annotations are the per-row display
1350    /// suffixes (AZ + CIDR for subnets; group name + description for SGs).
1351    FormMultiSelectLoaded {
1352        gen: u64,
1353        env_name: String,
1354        field_key: String,
1355        result: Result<MultiSelectOptions, String>,
1356    },
1357    /// Result of a `:deploy --from PATH` chain (upload → create version →
1358    /// optional deploy). `summary` is the same label used in the pending
1359    /// row so `complete_pending` can match. On success we also surface the
1360    /// new version label in the toast.
1361    DeployFromLocal {
1362        gen: u64,
1363        env_name: String,
1364        label: String,
1365        summary: String,
1366        result: Result<(), String>,
1367    },
1368    /// Sent once at the start of a `:logs-tail` session after the log
1369    /// group is resolved (via discovery or user-supplied). Tells the App
1370    /// handler to install the `Overlay::LogTail` with the resolved group.
1371    LogTailOpened {
1372        gen: u64,
1373        session_id: u64,
1374        env_name: String,
1375        log_group: String,
1376        since_ms: i64,
1377    },
1378    /// New events pushed by the `:logs-tail` polling task. `session_id`
1379    /// must match the active `Overlay::LogTail` session or the message is
1380    /// dropped (stale session after the user closed and reopened).
1381    LogTailEvents {
1382        gen: u64,
1383        session_id: u64,
1384        next_since_ms: i64,
1385        result: Result<Vec<crate::aws::LogEvent>, String>,
1386    },
1387    /// One section's result for the `:why` diagnostic overlay. The session
1388    /// id matches the `Overlay::WhyRed { session_id, .. }` active when the
1389    /// fetcher was spawned; late results for stale sessions are dropped on
1390    /// arrival.
1391    WhyRedEvents {
1392        gen: u64,
1393        session_id: u64,
1394        result: Result<Vec<crate::aws::Event>, String>,
1395    },
1396    WhyRedAlarms {
1397        gen: u64,
1398        session_id: u64,
1399        result: Result<Vec<crate::aws::CwAlarm>, String>,
1400    },
1401    WhyRedInstances {
1402        gen: u64,
1403        session_id: u64,
1404        result: Result<Vec<crate::aws::Instance>, String>,
1405    },
1406    WhyRedDeploys {
1407        gen: u64,
1408        session_id: u64,
1409        result: Result<Vec<crate::aws::AppVersion>, String>,
1410    },
1411    /// Worker-only: main + DLQ queue stats for the `:why` overlay.
1412    WhyRedQueues {
1413        gen: u64,
1414        session_id: u64,
1415        result: Result<crate::aws::WorkerQueues, String>,
1416    },
1417    /// Worker-only: DLQ message peek (3 bodies). Fired by the queues
1418    /// handler once the DLQ stats indicate non-zero depth.
1419    WhyRedDlqMessages {
1420        gen: u64,
1421        session_id: u64,
1422        result: Result<Vec<crate::aws::QueueMessage>, String>,
1423    },
1424    DryRunResult {
1425        gen: u64,
1426        env_name: String,
1427        result: Result<Vec<Instance>, String>,
1428    },
1429    /// `fetch_env_vars` result for `:env-edit`. The handler stashes
1430    /// the env-name + KV pairs in `App.pending_env_edit`; the main
1431    /// loop tick takes them and shells out to `$EDITOR`. Two-step
1432    /// because the editor needs the `Tui` handle (alt-screen
1433    /// leave/enter), which is only available in the main loop.
1434    EnvVarsForEdit {
1435        gen: u64,
1436        env_name: String,
1437        result: Result<Vec<(String, String)>, String>,
1438    },
1439    PreflightEvents {
1440        gen: u64,
1441        env_name: String,
1442        result: Result<Vec<EbEvent>, String>,
1443    },
1444    /// Pre-deploy version preview for the confirm modal. Carries
1445    /// the pre-rendered `format_deploy_preview` body so the
1446    /// handler stays trivial — just stuff it into the modal slot.
1447    VersionPreview {
1448        gen: u64,
1449        env_name: String,
1450        result: Result<String, String>,
1451    },
1452    /// Pre-deploy health-check probe outcome. `Ok(())` means the
1453    /// probe was successful (2xx); `Err(reason)` means non-2xx /
1454    /// timeout / connect error and the modal should render a
1455    /// yellow warning so the operator can decide whether to
1456    /// continue. Doesn't block the deploy either way.
1457    HealthCheckProbe {
1458        gen: u64,
1459        env_name: String,
1460        result: Result<(), String>,
1461    },
1462    /// Pre-deploy unavailability estimate. `line` is the rendered
1463    /// modal text plus a caution flag for colouring. `None` if the
1464    /// option-settings fetch failed — the modal stays silent rather
1465    /// than rendering an error line (the impact is observability,
1466    /// not safety).
1467    UnavailabilityEstimate {
1468        gen: u64,
1469        env_name: String,
1470        line: Option<(String, bool)>,
1471    },
1472    /// Lint findings against the confirm-modal's target env,
1473    /// emitted by `spawn_confirm_lint`. Same `Issue` shape as
1474    /// the `:lint` TUI overlay + `ebman lint` CLI — designed
1475    /// for one engine, three surfaces.
1476    ConfirmModalLint {
1477        gen: u64,
1478        env_name: String,
1479        issues: Vec<crate::lint::Issue>,
1480    },
1481    /// Pre-flight result for one region of a `:rollout` flow.
1482    /// Carries the region's current version_label on success
1483    /// (so the plan overlay can show "currently build-820 →
1484    /// target build-900") or an error string on failure (STS,
1485    /// list_environments, or env-not-found). The handler
1486    /// populates the matching `RolloutRegion` row + advances
1487    /// the flow to AwaitingConfirm once all regions report.
1488    RolloutPreflight {
1489        gen: u64,
1490        region: String,
1491        result: Result<String, String>,
1492    },
1493    /// Dispatch outcome for one region of a `:rollout` flow.
1494    /// `Ok(())` after a successful `deploy_version` (and Green
1495    /// observation if --wait-for-green was set);
1496    /// `Err(reason)` on dispatch failure or wait timeout. The
1497    /// handler records the outcome, advances `next_index`, and
1498    /// either dispatches the next region OR halts (on first
1499    /// failure).
1500    RolloutDispatched {
1501        gen: u64,
1502        region: String,
1503        result: Result<(), String>,
1504    },
1505    /// `:undo` capture — emitted from the option-settings update
1506    /// spawn after a successful write, carrying the reverse-action
1507    /// so `App.undo_history` can push it for later `:undo`.
1508    UndoCaptured {
1509        gen: u64,
1510        entry: UndoEntry,
1511    },
1512    /// `:rollback` — the env's recent events came back; the handler
1513    /// scans them for the previously-deployed version label and opens
1514    /// the deploy-confirm modal for it.
1515    RollbackTarget {
1516        gen: u64,
1517        env_name: String,
1518        current_version: String,
1519        result: Result<Vec<EbEvent>, String>,
1520    },
1521    Alarms {
1522        gen: u64,
1523        env_name: String,
1524        result: Result<Vec<CwAlarm>, String>,
1525    },
1526    DlqMessages {
1527        gen: u64,
1528        env_name: String,
1529        result: Result<Vec<QueueMessage>, String>,
1530    },
1531    DlqActionResult {
1532        gen: u64,
1533        env_name: String,
1534        result: Result<DlqOp, String>,
1535    },
1536    ActionResult {
1537        gen: u64,
1538        action: Action,
1539        env_name: String,
1540        result: Result<(), String>,
1541    },
1542    /// Intermediate progress for the tail-logs pipeline (`Requesting` →
1543    /// `Polling` → `Fetching` → `Ready`). The UI consumes these so the user
1544    /// sees forward motion during the multi-second wait for EB to upload tail
1545    /// samples to S3.
1546    DetailLogsProgress {
1547        gen: u64,
1548        env_name: String,
1549        stage: LogTailStage,
1550        attempt: u32,
1551    },
1552    /// Final tail-logs payload — `Vec<(ec2_instance_id, log_text)>` on success.
1553    DetailLogs {
1554        gen: u64,
1555        env_name: String,
1556        result: Result<Vec<(String, String)>, String>,
1557    },
1558    /// Generic text overlay payload. Used by several commands that all
1559    /// finish on a background task and want to render the result as a
1560    /// scrollable text dump (`:find-env`, `:resources`, `:org-health`,
1561    /// `:upgrade`, `:custom-platforms`). `title` shows in the overlay block
1562    /// header; previous variants reused the SavedConfigs styling and
1563    /// inherited its title which lied about the content.
1564    TextOverlay {
1565        gen: u64,
1566        title: String,
1567        body: String,
1568    },
1569    /// Application versions listing for the env's app, fetched via `:versions`.
1570    /// `deployed_label` is the env's current version_label so the overlay
1571    /// can mark which row is "the live one" — common operator pain when
1572    /// rolling back.
1573    AppVersions {
1574        gen: u64,
1575        application: String,
1576        deployed_label: Option<String>,
1577        result: Result<Vec<AppVersion>, String>,
1578    },
1579    /// Result of the startup update-check. `None` means "no newer release"
1580    /// or the check couldn't reach crates.io; either way, the UI doesn't
1581    /// nag the user. We don't carry a generation — the message is anchored
1582    /// to the process, not a particular AWS context.
1583    UpdateCheck(Option<crate::update_check::LatestRelease>),
1584    /// Watchdog deadline for `:deploy --auto-rollback Nm`. Fires once
1585    /// `secs` after the deploy dispatched. Handler reads the env's
1586    /// current cached health: if Green, the watchdog disarms with a
1587    /// status toast; otherwise it dispatches a rollback deploy to
1588    /// the captured `DeploySnapshot.previous_version_label`.
1589    AutoRollbackCheck {
1590        gen: u64,
1591        env_name: String,
1592    },
1593    /// Result of an `UpdateTagsForResource` call from `:tag` / `:untag`.
1594    /// On success we re-issue the Config-tab tag fetch so the UI reflects
1595    /// the new state immediately.
1596    TagUpdate {
1597        gen: u64,
1598        env_name: String,
1599        summary: String,
1600        result: Result<(), String>,
1601    },
1602    /// Result of an `UpdateEnvironment(option_settings)` call from any of
1603    /// the small option-settings commands (`:logs-stream`, `:notify`,
1604    /// `:managed-window`). `summary` is the same human-readable label that
1605    /// went into the pending panel so `complete_pending` can match.
1606    OptionSettingsUpdate {
1607        gen: u64,
1608        env_name: String,
1609        summary: String,
1610        result: Result<(), String>,
1611    },
1612    /// Result of a CloudWatch alarm create / delete via `:alarm-create` /
1613    /// `:alarm-delete`. `verb` is "create" or "delete" so the toast can use
1614    /// the correct tense.
1615    AlarmOp {
1616        gen: u64,
1617        verb: &'static str,
1618        alarm_name: String,
1619        env_name: String,
1620        result: Result<(), String>,
1621    },
1622    /// Result of a `DeleteApplicationVersion` call from `:delete-version`.
1623    DeleteAppVersion {
1624        gen: u64,
1625        application: String,
1626        label: String,
1627        force: bool,
1628        result: Result<(), String>,
1629    },
1630}
1631
1632#[derive(Debug, Clone)]
1633pub enum DlqOp {
1634    Resent {
1635        message_id: String,
1636    },
1637    Purged,
1638    /// Outcome of a batch replay: `count` messages moved to the main queue
1639    /// (sent + deleted from the DLQ), `failures` that errored mid-way.
1640    Replayed {
1641        count: usize,
1642        failures: usize,
1643    },
1644}
1645
1646/// True when this looks like the user's very first run: no persisted ebman
1647/// state on disk *and* no AWS credentials or config to talk to. We use that as
1648/// the trigger for the welcome overlay rather than nagging on every cold
1649/// start.
1650fn is_first_run() -> bool {
1651    let no_state = !crate::util::config_file("state.toml").exists();
1652    let home = std::env::var_os("HOME")
1653        .map(std::path::PathBuf::from)
1654        .unwrap_or_default();
1655    let no_creds = !home.join(".aws").join("credentials").exists()
1656        && !home.join(".aws").join("config").exists();
1657    no_state && no_creds
1658}
1659
1660async fn init_client(
1661    profile: Option<String>,
1662    region: Option<String>,
1663) -> Result<(AwsClient, Option<String>, Option<String>, Option<String>)> {
1664    // Two-stage init:
1665    //   1. AwsClient::with must succeed (SDK config / region parsing). On
1666    //      failure we fall back from persisted profile/region to env defaults.
1667    //   2. verify_identity is *best-effort* — STS perms aren't required to use
1668    //      EB describe APIs. On failure we log + surface a startup warning but
1669    //      keep going with the client, leaving account/caller fields unset.
1670    let (mut client, used_profile, used_region) =
1671        match AwsClient::with(profile.clone(), region.clone()).await {
1672            Ok(c) => (c, profile, region),
1673            Err(e) if profile.is_some() || region.is_some() => {
1674                tracing::warn!(
1675                    error = %e,
1676                    profile = ?profile,
1677                    region = ?region,
1678                    "persisted profile/region failed to resolve — falling back to env defaults"
1679                );
1680                let c = AwsClient::with(None, None).await?;
1681                (c, None, None)
1682            }
1683            Err(e) => return Err(e),
1684        };
1685
1686    let warning = match client.verify_identity().await {
1687        Ok(id) => {
1688            client.context.account_id = id.account_id;
1689            client.context.caller_arn = id.caller_arn;
1690            None
1691        }
1692        Err(e) => {
1693            tracing::warn!(
1694                error = %e,
1695                "sts:GetCallerIdentity failed — proceeding without identity. EB describe perms may still be available."
1696            );
1697            Some(format!("identity unknown ({e}); EB calls may still work"))
1698        }
1699    };
1700    Ok((client, used_profile, used_region, warning))
1701}
1702
1703impl App {
1704    pub async fn new(config: Config) -> Result<Self> {
1705        // Stash the notify-webhook URL globally before any audit
1706        // line could be written. OnceLock::set is no-op on second
1707        // call so calling App::new twice in the same process (e.g.
1708        // a test harness) doesn't crash, but does mean the FIRST
1709        // App's webhook wins — fine for production where there's
1710        // only ever one App.
1711        let _ = NOTIFY_WEBHOOK_URL.set(config.notify_webhook.clone());
1712        let persisted = state::load();
1713        // Project config: optional `.ebman/ebman.toml` walked up from
1714        // cwd. Profile / region from the project win over persisted
1715        // state so a repo can pin its working context; everything
1716        // else (filter, application, runbooks) merges in further down
1717        // once `app` is constructed.
1718        let project = crate::project::load_from_cwd();
1719        let project_profile = project.as_ref().and_then(|p| p.profile.clone());
1720        let project_region = project.as_ref().and_then(|p| p.region.clone());
1721        // EB CLI config (`.elasticbeanstalk/config.yml`) is a
1722        // secondary source — fills in profile / region / application
1723        // only when the higher-precedence `.ebman/` file doesn't.
1724        // Most EB CLI users already maintain this file, so reading
1725        // it avoids forcing a duplicate `.ebman/` entry.
1726        let eb_cli = crate::eb_cli::load_from_cwd();
1727        let eb_cli_profile = eb_cli.as_ref().and_then(|c| c.profile.clone());
1728        let eb_cli_region = eb_cli.as_ref().and_then(|c| c.region.clone());
1729        tracing::info!(
1730            target: "ebman::state",
1731            persisted_profile = ?persisted.profile,
1732            persisted_region = ?persisted.region,
1733            project_profile = ?project_profile,
1734            project_region = ?project_region,
1735            eb_cli_profile = ?eb_cli_profile,
1736            eb_cli_region = ?eb_cli_region,
1737            "state::load"
1738        );
1739        let effective_profile = project_profile
1740            .or(eb_cli_profile)
1741            .or_else(|| persisted.profile.clone());
1742        let effective_region = project_region
1743            .or(eb_cli_region)
1744            .or_else(|| persisted.region.clone());
1745        let (aws, override_profile, override_region, identity_warning) =
1746            init_client(effective_profile, effective_region).await?;
1747        let aws = Arc::new(aws);
1748        let context = aws.context.clone();
1749        tracing::info!(
1750            target: "ebman::state",
1751            override_profile = ?override_profile,
1752            override_region = ?override_region,
1753            context_region = %context.region,
1754            context_profile = ?context.profile,
1755            "init_client returned"
1756        );
1757        let (msg_tx, msg_rx) = mpsc::unbounded_channel();
1758        let mut table_state = TableState::default();
1759        table_state.select(Some(0));
1760
1761        let (sort_key, sort_desc) = parse_sort(persisted.sort.as_deref());
1762        let redact = persisted.redact.or(config.redact_default).unwrap_or(false);
1763        let grouped = persisted
1764            .grouped
1765            .or(config.grouped_default)
1766            .unwrap_or(false);
1767        let events_visible = persisted.events_visible.unwrap_or(false);
1768        let event_time_format = persisted.event_time_format.unwrap_or_default();
1769        let refresh_interval = config.refresh_interval;
1770
1771        let mut app_table_state = TableState::default();
1772        app_table_state.select(Some(0));
1773
1774        let names = builtin_commands();
1775        let plugins_loaded = crate::plugins::load(&names);
1776        for w in &plugins_loaded.warnings {
1777            tracing::warn!(target: "ebman::plugins", "{}", w);
1778        }
1779        let plugin_startup_warning = if plugins_loaded.warnings.is_empty() {
1780            None
1781        } else {
1782            Some(format!("plugins: {}", plugins_loaded.warnings.join("; ")))
1783        };
1784
1785        let mut app = Self {
1786            context,
1787            scope: Scope::Envs,
1788            applications: Vec::new(),
1789            app_table_state,
1790            environments: Vec::new(),
1791            table_state,
1792            table_area: Rect::default(),
1793            mode: Mode::Normal,
1794            filter: persisted.filter.unwrap_or_default(),
1795            load_state: LoadState::Idle,
1796            loading_since: None,
1797            refresh_interval,
1798            loading_visible_until: None,
1799            last_refresh: None,
1800            status_message: None,
1801            error_message: None,
1802            picker: None,
1803            override_profile,
1804            override_region,
1805            history: HashMap::new(),
1806            redact,
1807            grouped,
1808            sort_key,
1809            sort_desc,
1810            command_input: String::new(),
1811            completion: CompletionState::default(),
1812            quickjump_input: String::new(),
1813            extra_regions: config.extra_regions,
1814            event_panel: EventPanel {
1815                events: Vec::new(),
1816                visible: events_visible,
1817                time_format: event_time_format,
1818                for_env: None,
1819                scroll: 0,
1820                area: None,
1821                drag_origin: None,
1822                cursor: None,
1823                height: 10,
1824            },
1825            multi_selected: BTreeSet::new(),
1826            apps_selected: BTreeSet::new(),
1827            focus: Focus::Table,
1828            multi_regions: Vec::new(),
1829            detail: None,
1830            action_flow: None,
1831            dlq: None,
1832            theme: {
1833                let (mut t, warning) = Theme::resolve(&config.theme);
1834                if let Some(w) = warning {
1835                    tracing::warn!("{w}");
1836                }
1837                match config.icons.trim().to_ascii_lowercase().as_str() {
1838                    "ascii" => t.icons = IconStyle::Ascii,
1839                    "powerline" | "nerd" | "nerdfont" => t.icons = IconStyle::Powerline,
1840                    _ => {}
1841                }
1842                Arc::new(t)
1843            },
1844            view_mode: ViewMode::Default,
1845            help: HelpState {
1846                scroll: 0,
1847                max_scroll: 0,
1848                topic: HelpTopic::Global,
1849                pre_mode: None,
1850                pre_overlay: None,
1851            },
1852            hover_row: None,
1853            alerts: 0,
1854            worker_dlq_depths: std::collections::HashMap::new(),
1855            // Restore persisted snapshots so a cross-session `:rollback`
1856            // / auto-rollback still has a target. Malformed lines are
1857            // silently skipped — better to drop one stale entry than
1858            // abort the App-init path.
1859            deploy_snapshots: persisted
1860                .deploy_snapshots
1861                .iter()
1862                .filter_map(
1863                    |(env, raw)| match DeploySnapshot::parse_persisted(env, raw) {
1864                        Some(snap) => Some((env.clone(), snap)),
1865                        None => {
1866                            // Log the malformed line so the operator can spot
1867                            // a corrupted state.toml entry. We still skip the
1868                            // entry — better to lose one stale snapshot than
1869                            // to abort App init.
1870                            tracing::warn!(
1871                                target: "ebman::state",
1872                                env = %env,
1873                                raw = %raw,
1874                                "malformed deploy_snapshot entry in state.toml — skipping"
1875                            );
1876                            None
1877                        }
1878                    },
1879                )
1880                .collect(),
1881            armed_watchdogs: std::collections::HashMap::new(),
1882            watching_deploys: std::collections::HashMap::new(),
1883            deploy_freeze: None,
1884            // Load tfstate from cwd at construction time. Failure
1885            // is silent (`None`) — operators not using terraform
1886            // shouldn't see any UI surface; operators with a
1887            // discoverable tfstate get the badge + drift overlay
1888            // immediately. Re-loaded on context switch (account /
1889            // region change) and on `:drift refresh`.
1890            tf_state: crate::terraform::load_from_cwd(),
1891            tf_managed_envs: std::collections::HashSet::new(),
1892            undo_history: std::collections::VecDeque::new(),
1893            demo_mode: false,
1894            env_instance_counts: std::collections::HashMap::new(),
1895            cost_enabled: persisted.cost_enabled.unwrap_or(false),
1896            costs: std::collections::HashMap::new(),
1897            costs_fetched_at: None,
1898            latest_stacks: std::collections::HashMap::new(),
1899            frozen: false,
1900            first_run_hint: !crate::state::file_exists(),
1901            current_overlay: None,
1902            message_log: VecDeque::with_capacity(MESSAGE_LOG_CAP),
1903            toasts: VecDeque::with_capacity(TOAST_CAP),
1904            palette_input: String::new(),
1905            palette_items: Vec::new(),
1906            palette_filtered: Vec::new(),
1907            palette_state: ListState::default(),
1908            read_only: false,
1909            pinned: persisted.pinned,
1910            pinned_apps: persisted.pinned_apps,
1911            aliases: persisted.aliases,
1912            saved_views: persisted.saved_views,
1913            hidden_cols: persisted.hidden_cols,
1914            custom_metrics: persisted.custom_metrics,
1915            log_reload: None,
1916            log_directive: std::env::var("RUST_LOG")
1917                .unwrap_or_else(|_| "info,aws=warn,hyper=warn".to_string()),
1918            plugins: plugins_loaded.plugins,
1919            status_snapshot_at_refresh: None,
1920            status_message_pinned: false,
1921            throttle_until: None,
1922            consecutive_throttles: 0,
1923            sso_expiry: crate::sso::latest_session_expiry(),
1924            pending_actions: std::collections::VecDeque::with_capacity(PENDING_CAP),
1925            pending_dispatch: None,
1926            form: None,
1927            log_tail_task: None,
1928            log_tail_session: 0,
1929            why_red_session: 0,
1930            why_items: Vec::new(),
1931            update_available: None,
1932            reload_requested: false,
1933            pending_shell_target: None,
1934            pending_env_edit: None,
1935            current_shell: None,
1936            shell_return_mode: Mode::Normal,
1937            last_rendered_buffer: None,
1938            notify_bell: config.notify_bell,
1939            notify_webhook: config.notify_webhook.clone(),
1940            command_aliases: config.command_aliases.clone(),
1941            lint_disable: config.lint_disable.clone(),
1942            explain_enabled: config.explain_enabled,
1943            explain_provider: config.explain_provider.clone(),
1944            explain_model: config.explain_model.clone(),
1945            explain_api_key_env: config.explain_api_key_env.clone(),
1946            explain_ollama_url: config.explain_ollama_url.clone(),
1947            explain_max_tokens: config.explain_max_tokens,
1948            required_tags: config.required_tags,
1949            cfg_icons_raw: config.icons.clone(),
1950            profile_themes: config.profile_themes.clone(),
1951            runbooks: config.runbooks.clone(),
1952            safety_envs: config.safety_envs.clone(),
1953            safety_accounts: config.safety_accounts.clone(),
1954            accounts: config.accounts.clone(),
1955            base_theme_name: config.theme.clone(),
1956            newly_red: HashSet::new(),
1957            newly_added: HashSet::new(),
1958            health_delta: Vec::new(),
1959            status_delta: Vec::new(),
1960            prev_alerts: 0,
1961            prev_health: HashMap::new(),
1962            prev_status: HashMap::new(),
1963            cached_filtered: Vec::new(),
1964            cached_display: Vec::new(),
1965            cached_app_colors: HashMap::new(),
1966            cached_stale_platforms: HashMap::new(),
1967            pending_select: persisted.selected_env,
1968            aws,
1969            generation: 0,
1970            msg_tx,
1971            msg_rx,
1972            quit: false,
1973        };
1974        app.rebuild_view();
1975        // Plugin warnings take priority over identity warnings — they're a user
1976        // misconfiguration the user can act on now; identity_warning is informational.
1977        if let Some(w) = plugin_startup_warning {
1978            app.error_message = Some(w);
1979        } else if let Some(w) = identity_warning {
1980            app.error_message = Some(w);
1981        }
1982        if is_first_run() {
1983            app.current_overlay = Some(Overlay::Whatsnew(WELCOME_OVERLAY.into()));
1984        }
1985        // Swap to the per-profile theme override if one is configured for
1986        // the resolved profile. Done here (after `context` is populated)
1987        // so the initial frame already shows the right palette.
1988        app.maybe_apply_profile_theme();
1989        // Apply the rest of the project config (filter / application
1990        // prefill, runbook merge) after the App is fully constructed.
1991        // Project entries win over user-level runbooks because the
1992        // repo is the more-specific source.
1993        if let Some(proj) = project {
1994            if let Some(filter) = proj.filter {
1995                app.filter = filter;
1996            } else if let Some(app_name) = proj.application {
1997                // Treat `application` as a filter prefill when no
1998                // explicit `filter` was set — pre-scopes the table to
1999                // a single-app repo's envs without a hard pin.
2000                app.filter = app_name;
2001            }
2002            app.runbooks.extend(proj.runbooks);
2003        }
2004        // EB CLI application name fills in as a filter prefill when
2005        // `.ebman/` hasn't already set one. Same "soft scope" intent
2006        // as the project-config path. `.ebman/` always wins because
2007        // it's the more explicit, ebman-native source.
2008        if app.filter.is_empty() {
2009            if let Some(eb) = eb_cli {
2010                if let Some(app_name) = eb.application {
2011                    app.filter = app_name;
2012                }
2013            }
2014        }
2015        // Derive the tf-managed name set from the loaded tfstate
2016        // so the env-table badge can do O(1) lookups per row.
2017        app.refresh_tf_managed_envs();
2018        Ok(app)
2019    }
2020
2021    /// Runtime constructor for `--demo` mode. Wraps `for_tests` with a
2022    /// stub `AwsClient` and an explicit `demo_mode = true` so the
2023    /// refresh / update-check spawns become no-ops. Then asks the
2024    /// hand-crafted fixture to populate `environments` / events /
2025    /// instance counts / cost data so the main table renders with
2026    /// believable content. Synchronous — no AWS calls, no disk I/O
2027    /// (state.load is skipped via the for_tests path).
2028    pub fn new_demo(config: Config) -> Self {
2029        let mut app = Self::for_tests(crate::aws::AwsClient::stub(), config);
2030        app.demo_mode = true;
2031        crate::demo_fixture::install(&mut app);
2032        app
2033    }
2034
2035    /// Synchronous AWS-free constructor. Skips `init_client` (no AWS
2036    /// round-trip), `state::load` (no disk read — caller passes a fresh
2037    /// empty state), and the spawn_identity / spawn_refresh kickoffs.
2038    /// The caller is responsible for providing a pre-built `AwsClient`
2039    /// (typically via `AwsClient::for_tests` or `AwsClient::stub()`).
2040    /// `msg_tx` / `msg_rx` are created here so `handle_event` can fire
2041    /// spawn helpers that send AppMsg variants without panicking;
2042    /// callers can drain `msg_rx` to inspect dispatched messages.
2043    ///
2044    /// Two consumers today: the unit-test harness (`#[cfg(test)]`
2045    /// builds) and the runtime `--demo` mode constructor (`new_demo`,
2046    /// which builds on top of this + a hand-crafted fixture). Kept
2047    /// `pub(crate)` — both callers are in this crate.
2048    pub(crate) fn for_tests(aws: crate::aws::AwsClient, config: Config) -> Self {
2049        let aws = Arc::new(aws);
2050        let context = aws.context.clone();
2051        let (msg_tx, msg_rx) = mpsc::unbounded_channel();
2052        let mut table_state = TableState::default();
2053        table_state.select(Some(0));
2054        let mut app_table_state = TableState::default();
2055        app_table_state.select(Some(0));
2056        let mut app = Self {
2057            context,
2058            scope: Scope::Envs,
2059            applications: Vec::new(),
2060            app_table_state,
2061            environments: Vec::new(),
2062            table_state,
2063            table_area: Rect::default(),
2064            mode: Mode::Normal,
2065            filter: String::new(),
2066            load_state: LoadState::Idle,
2067            loading_since: None,
2068            refresh_interval: config.refresh_interval,
2069            loading_visible_until: None,
2070            last_refresh: None,
2071            status_message: None,
2072            error_message: None,
2073            picker: None,
2074            override_profile: None,
2075            override_region: None,
2076            history: HashMap::new(),
2077            redact: config.redact_default.unwrap_or(false),
2078            grouped: config.grouped_default.unwrap_or(false),
2079            sort_key: SortKey::App,
2080            sort_desc: false,
2081            command_input: String::new(),
2082            completion: CompletionState::default(),
2083            quickjump_input: String::new(),
2084            extra_regions: config.extra_regions.clone(),
2085            event_panel: EventPanel {
2086                events: Vec::new(),
2087                visible: false,
2088                time_format: EventTimeFormat::default(),
2089                for_env: None,
2090                scroll: 0,
2091                area: None,
2092                drag_origin: None,
2093                cursor: None,
2094                height: 10,
2095            },
2096            multi_selected: BTreeSet::new(),
2097            apps_selected: BTreeSet::new(),
2098            focus: Focus::Table,
2099            multi_regions: Vec::new(),
2100            detail: None,
2101            action_flow: None,
2102            dlq: None,
2103            theme: {
2104                let (mut t, _w) = Theme::resolve(&config.theme);
2105                match config.icons.trim().to_ascii_lowercase().as_str() {
2106                    "ascii" => t.icons = IconStyle::Ascii,
2107                    "powerline" | "nerd" | "nerdfont" => t.icons = IconStyle::Powerline,
2108                    _ => {}
2109                }
2110                Arc::new(t)
2111            },
2112            view_mode: ViewMode::Default,
2113            help: HelpState {
2114                scroll: 0,
2115                max_scroll: 0,
2116                topic: HelpTopic::Global,
2117                pre_mode: None,
2118                pre_overlay: None,
2119            },
2120            hover_row: None,
2121            alerts: 0,
2122            worker_dlq_depths: std::collections::HashMap::new(),
2123            deploy_snapshots: std::collections::HashMap::new(),
2124            armed_watchdogs: std::collections::HashMap::new(),
2125            watching_deploys: std::collections::HashMap::new(),
2126            deploy_freeze: None,
2127            // Tests / demo mode don't probe the operator's cwd
2128            // for tfstate — keeps test runs deterministic and
2129            // prevents demo screencasts from leaking real fleet
2130            // detail. Tests that exercise drift behavior set
2131            // `app.tf_state` explicitly.
2132            tf_state: None,
2133            tf_managed_envs: std::collections::HashSet::new(),
2134            undo_history: std::collections::VecDeque::new(),
2135            demo_mode: false,
2136            env_instance_counts: std::collections::HashMap::new(),
2137            cost_enabled: false,
2138            costs: std::collections::HashMap::new(),
2139            costs_fetched_at: None,
2140            latest_stacks: std::collections::HashMap::new(),
2141            frozen: false,
2142            first_run_hint: false,
2143            current_overlay: None,
2144            message_log: VecDeque::with_capacity(MESSAGE_LOG_CAP),
2145            toasts: VecDeque::with_capacity(TOAST_CAP),
2146            palette_input: String::new(),
2147            palette_items: Vec::new(),
2148            palette_filtered: Vec::new(),
2149            palette_state: ListState::default(),
2150            read_only: false,
2151            pinned: BTreeSet::new(),
2152            pinned_apps: BTreeSet::new(),
2153            aliases: std::collections::BTreeMap::new(),
2154            saved_views: std::collections::BTreeMap::new(),
2155            hidden_cols: BTreeSet::new(),
2156            custom_metrics: std::collections::BTreeMap::new(),
2157            log_reload: None,
2158            log_directive: "info".to_string(),
2159            plugins: std::collections::BTreeMap::new(),
2160            status_snapshot_at_refresh: None,
2161            status_message_pinned: false,
2162            throttle_until: None,
2163            consecutive_throttles: 0,
2164            sso_expiry: None,
2165            pending_actions: std::collections::VecDeque::with_capacity(PENDING_CAP),
2166            pending_dispatch: None,
2167            form: None,
2168            log_tail_task: None,
2169            log_tail_session: 0,
2170            why_red_session: 0,
2171            why_items: Vec::new(),
2172            update_available: None,
2173            reload_requested: false,
2174            pending_shell_target: None,
2175            pending_env_edit: None,
2176            current_shell: None,
2177            shell_return_mode: Mode::Normal,
2178            last_rendered_buffer: None,
2179            notify_bell: config.notify_bell,
2180            notify_webhook: config.notify_webhook.clone(),
2181            command_aliases: config.command_aliases.clone(),
2182            lint_disable: config.lint_disable.clone(),
2183            explain_enabled: config.explain_enabled,
2184            explain_provider: config.explain_provider.clone(),
2185            explain_model: config.explain_model.clone(),
2186            explain_api_key_env: config.explain_api_key_env.clone(),
2187            explain_ollama_url: config.explain_ollama_url.clone(),
2188            explain_max_tokens: config.explain_max_tokens,
2189            required_tags: config.required_tags.clone(),
2190            cfg_icons_raw: config.icons.clone(),
2191            profile_themes: config.profile_themes.clone(),
2192            runbooks: config.runbooks.clone(),
2193            safety_envs: config.safety_envs.clone(),
2194            safety_accounts: config.safety_accounts.clone(),
2195            accounts: config.accounts.clone(),
2196            base_theme_name: config.theme.clone(),
2197            newly_red: HashSet::new(),
2198            newly_added: HashSet::new(),
2199            health_delta: Vec::new(),
2200            status_delta: Vec::new(),
2201            prev_alerts: 0,
2202            prev_health: HashMap::new(),
2203            prev_status: HashMap::new(),
2204            cached_filtered: Vec::new(),
2205            cached_display: Vec::new(),
2206            cached_app_colors: HashMap::new(),
2207            cached_stale_platforms: HashMap::new(),
2208            pending_select: None,
2209            aws,
2210            generation: 0,
2211            msg_tx,
2212            msg_rx,
2213            quit: false,
2214        };
2215        app.rebuild_view();
2216        app
2217    }
2218
2219    pub async fn run(
2220        &mut self,
2221        terminal: &mut Tui,
2222        mut control_rx: Option<mpsc::UnboundedReceiver<crate::control::ControlOp>>,
2223    ) -> Result<()> {
2224        let mut events = EventStream::new();
2225        let mut ticker = tokio::time::interval(self.refresh_interval);
2226        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2227        let mut anim = tokio::time::interval(Duration::from_millis(100));
2228        anim.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2229        // Higher-frequency ticker for the embedded shell pane (~30 fps) so
2230        // PTY output renders promptly. Idle-gated below.
2231        let mut shell_tick = tokio::time::interval(Duration::from_millis(30));
2232        shell_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2233        // Listen for OS termination signals (SIGINT from terminal Ctrl-C,
2234        // SIGTERM from cargo-watch / process supervisors). Default handlers
2235        // would kill us abruptly without running `leave_tui` — leaving the
2236        // terminal in raw mode and breaking the user's shell. Catching them
2237        // lets us set `quit = true` and break the loop, which the main
2238        // entrypoint follows with a proper terminal restore.
2239        let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
2240            .map_err(|e| color_eyre::eyre::eyre!("install SIGINT handler: {e}"))?;
2241        let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
2242            .map_err(|e| color_eyre::eyre::eyre!("install SIGTERM handler: {e}"))?;
2243        let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())
2244            .map_err(|e| color_eyre::eyre::eyre!("install SIGHUP handler: {e}"))?;
2245        // Track mode across iterations so we can clear the terminal when
2246        // entering or leaving Shell mode (avoids the prior view bleeding
2247        // around the new pane / shell content lingering after exit).
2248        let mut prev_mode = self.mode;
2249        self.spawn_refresh();
2250        self.spawn_update_check();
2251
2252        loop {
2253            // The closure both renders and clones the resulting buffer so the
2254            // control plane has a faithful snapshot — ratatui's terminal swaps
2255            // front/back after draw() so we can't grab it post-hoc.
2256            // Refetch the events panel when the cursor has moved to a
2257            // different env since the last fetch. Fires before draw so the
2258            // user sees "loading…" rather than the previous env's events.
2259            self.refresh_events_if_selection_changed();
2260
2261            // Clear the terminal on Shell-mode boundary crossings so cells
2262            // from the prior view don't bleed through (entering Shell) and
2263            // shell content doesn't linger when we exit (leaving Shell).
2264            if (self.mode == Mode::Shell) != (prev_mode == Mode::Shell) {
2265                let _ = terminal.clear();
2266            }
2267            prev_mode = self.mode;
2268
2269            let mut snapshot: Option<ratatui::buffer::Buffer> = None;
2270            terminal.draw(|f| {
2271                ui::draw(f, self);
2272                snapshot = Some(f.buffer_mut().clone());
2273            })?;
2274            self.last_rendered_buffer = snapshot;
2275            if self.quit {
2276                break;
2277            }
2278
2279            let prev_status = self.status_message.clone();
2280            let prev_error = self.error_message.clone();
2281
2282            tokio::select! {
2283                // Termination signals — set the quit flag and break so the
2284                // main entrypoint's `leave_tui` runs and the terminal is
2285                // restored. Without these the default OS handler kills the
2286                // process abruptly, leaving the terminal in raw mode + alt-
2287                // screen for the parent shell to deal with.
2288                _ = sigint.recv() => {
2289                    tracing::info!(target: "ebman", "received SIGINT, shutting down gracefully");
2290                    self.quit = true;
2291                }
2292                _ = sigterm.recv() => {
2293                    tracing::info!(target: "ebman", "received SIGTERM, shutting down gracefully");
2294                    self.quit = true;
2295                }
2296                _ = sighup.recv() => {
2297                    tracing::info!(target: "ebman", "received SIGHUP, shutting down gracefully");
2298                    self.quit = true;
2299                }
2300                maybe_event = events.next() => {
2301                    match maybe_event {
2302                        Some(Ok(event)) => self.handle_event(event),
2303                        Some(Err(e)) => {
2304                            self.error_message = Some(format!("input error: {e}"));
2305                        }
2306                        None => break,
2307                    }
2308                }
2309                _ = ticker.tick() => {
2310                    // Cheap and self-contained — re-read the SSO cache on every
2311                    // tick so the header countdown stays accurate even if the
2312                    // user `aws sso login`s in another shell mid-session.
2313                    self.sso_expiry = crate::sso::latest_session_expiry();
2314                    let now = Instant::now();
2315                    let backed_off = self
2316                        .throttle_until
2317                        .map(|t| now < t)
2318                        .unwrap_or(false);
2319                    if !self.frozen && !backed_off {
2320                        self.spawn_refresh();
2321                        if matches!(self.mode, Mode::Detail) {
2322                            if let Some(d) = self.detail.as_ref() {
2323                                if d.auto_refresh {
2324                                    self.detail_refresh_active_tab();
2325                                }
2326                            }
2327                        }
2328                    } else if backed_off && self.throttle_until.is_some_and(|t| now >= t) {
2329                        // Just crossed the back-off horizon — clear so the next
2330                        // tick proceeds normally even if no refresh fired here.
2331                        self.throttle_until = None;
2332                    }
2333                }
2334                _ = shell_tick.tick(), if self.current_shell.is_some() => {
2335                    // ~30 fps redraw while a shell pane is live so typed
2336                    // echo / backspace erase / vim frames render promptly.
2337                    // Demo sessions also use this beat to drain their
2338                    // canned bytes into the parser (typewriter animation).
2339                    if let Some(shell) = self.current_shell.as_ref() {
2340                        shell.tick_demo_typer();
2341                    }
2342                }
2343                _ = anim.tick(), if self.loading_since.is_some()
2344                    || !self.toasts.is_empty()
2345                    || self.pending_dispatch.is_some()
2346                    || !self.armed_watchdogs.is_empty()
2347                    || !self.watching_deploys.is_empty()
2348                    || matches!(self.current_overlay, Some(Overlay::About(_)))
2349                    || self.loading_visible_until.map(|t| Instant::now() < t).unwrap_or(false) => {
2350                    // Wake the draw loop so the spinner can advance, toasts
2351                    // expire promptly, the cancel-window countdown stays
2352                    // accurate, and the loading-indicator linger window can
2353                    // finish counting down. Gated to keep idle CPU at zero
2354                    // otherwise.
2355                }
2356                Some(msg) = self.msg_rx.recv() => {
2357                    self.handle_msg(msg);
2358                }
2359                Some(op) = async {
2360                    match control_rx.as_mut() {
2361                        Some(rx) => rx.recv().await,
2362                        None => std::future::pending().await,
2363                    }
2364                } => {
2365                    self.handle_control_op(op, terminal);
2366                }
2367            }
2368
2369            if self.status_message != prev_status {
2370                if let Some(s) = self.status_message.clone() {
2371                    self.log_message(MsgKind::Info, s.clone());
2372                    self.push_toast(ToastKind::Info, s);
2373                }
2374            }
2375            if self.error_message != prev_error {
2376                if let Some(s) = self.error_message.clone() {
2377                    self.log_message(MsgKind::Error, s.clone());
2378                    self.push_toast(ToastKind::Error, s);
2379                }
2380            }
2381            // Drop expired toasts so the screen clears even on idle ticks.
2382            let now = Instant::now();
2383            while self
2384                .toasts
2385                .front()
2386                .map(|t| now.duration_since(t.shown_at) > t.ttl())
2387                .unwrap_or(false)
2388            {
2389                self.toasts.pop_front();
2390            }
2391            // Drop pending-actions entries that completed > PENDING_COMPLETED_TTL ago.
2392            self.expire_pending();
2393            // Fire any pending dispatch whose cancel window has elapsed.
2394            // Cheap (a single Instant comparison when None); placed here
2395            // so the deadline is checked on every loop iteration, not
2396            // gated on user input.
2397            self.tick_pending_dispatch();
2398            // Pending embedded shell — allocate a PTY and switch mode.
2399            if let Some(target) = self.pending_shell_target.take() {
2400                self.open_embedded_shell(terminal, &target)?;
2401            }
2402            // Pending env-edit — shell out to `$EDITOR` against a
2403            // temp file holding the current env vars. Same
2404            // leave-altscreen / spawn / re-enter pattern as the
2405            // legacy inline-SSM path.
2406            if let Some((env_name, vars)) = self.pending_env_edit.take() {
2407                if let Err(e) = self.run_env_editor(terminal, &env_name, &vars) {
2408                    self.error_message = Some(format!("env-edit: {e}"));
2409                }
2410            }
2411
2412            // Auto-close the shell pane when the subprocess has exited.
2413            if matches!(self.mode, Mode::Shell)
2414                && self.current_shell.as_ref().is_some_and(|s| s.is_dead())
2415            {
2416                self.close_shell_session();
2417            }
2418        }
2419        // persist_state ALSO runs in main.rs after `run()` returns
2420        // (Ok or Err) so a draw / select error mid-shutdown can't drop
2421        // the operator's state. This call here is kept so the Ok path
2422        // still persists *before* `leave_tui()` (cheap, idempotent).
2423        self.persist_state();
2424        Ok(())
2425    }
2426
2427    /// Open an embedded SSM session into `instance_id`. Allocates a PTY,
2428    /// spawns `aws ssm start-session` inside it, and switches to
2429    /// `Mode::Shell` where keystrokes are forwarded to the subprocess
2430    /// instead of running ebman bindings. **F12** detaches back to the
2431    /// previous mode; the session keeps running and the user can re-open
2432    /// the pane (state preserved). The session ends when the subprocess
2433    /// exits — typically via the user typing `exit` or `^D`.
2434    fn open_embedded_shell(&mut self, terminal: &mut Tui, instance_id: &str) -> Result<()> {
2435        // Demo-mode short-circuit. The fixture's instance IDs are
2436        // synthetic, the AwsClient is a stub, and `aws ssm start-
2437        // session` would fail with "InstanceNotFound" (or hang
2438        // waiting for the session-manager-plugin handshake). Instead
2439        // spin up a fake `ShellSession` with a vt100::Parser
2440        // pre-loaded with canned content (session banner + a few
2441        // operator-realistic commands), and route into `Mode::Shell`
2442        // exactly like a real session. VHS captures show a real-
2443        // looking SSM pane; F12 detaches per the usual contract.
2444        if self.demo_mode {
2445            let size = terminal.size()?;
2446            let rows = size.height.saturating_sub(2).max(4);
2447            let cols = size.width.max(20);
2448            let content = crate::demo_fixture::canned_ssm_session(instance_id);
2449            let session =
2450                crate::shell::ShellSession::demo(instance_id.to_string(), &content, rows, cols);
2451            self.shell_return_mode = self.mode;
2452            self.current_shell = Some(Box::new(session));
2453            self.mode = Mode::Shell;
2454            return Ok(());
2455        }
2456        let region = self.context.region.clone();
2457        let profile = self
2458            .override_profile
2459            .clone()
2460            .or_else(|| self.context.profile.clone());
2461        write_audit_line(
2462            self.context.account_id.as_deref(),
2463            profile.as_deref(),
2464            &region,
2465            &format!("stage=dispatched action=SsmSession target={instance_id}"),
2466        );
2467
2468        let size = terminal.size()?;
2469        // Reserve 2 rows for a thin status bar so the pane title + detach
2470        // hint are always visible.
2471        let rows = size.height.saturating_sub(2).max(4);
2472        let cols = size.width.max(20);
2473
2474        let mut args = vec![
2475            "ssm",
2476            "start-session",
2477            "--target",
2478            instance_id,
2479            "--region",
2480            &region,
2481        ];
2482        let prof = profile.clone();
2483        if let Some(p) = prof.as_deref() {
2484            args.push("--profile");
2485            args.push(p);
2486        }
2487        match crate::shell::ShellSession::spawn(
2488            "aws",
2489            &args,
2490            rows,
2491            cols,
2492            format!("ssm: {instance_id}"),
2493        ) {
2494            Ok(session) => {
2495                self.current_shell = Some(Box::new(session));
2496                self.shell_return_mode = self.mode;
2497                self.mode = Mode::Shell;
2498                self.status_message = Some(format!(
2499                    "ssm session into {instance_id} — F12 detaches, ^D / exit closes"
2500                ));
2501            }
2502            Err(e) => {
2503                self.error_message = Some(format!(
2504                    "could not start SSM session ({e}). Install the AWS CLI + session-manager-plugin and check ssm:StartSession IAM"
2505                ));
2506            }
2507        }
2508        Ok(())
2509    }
2510
2511    /// Forward a key event to the running shell's PTY. Called only when
2512    /// `Mode::Shell` is active. F12 is consumed locally as the detach key.
2513    pub fn handle_shell_key(&mut self, key: KeyEvent) {
2514        // F12 detaches without killing the subprocess. Demo sessions
2515        // (no real PTY behind them) also accept Esc as a detach — VHS
2516        // can't emit F12 reliably, and there's no subprocess to
2517        // forward bytes to anyway. Real sessions keep Esc forwarded
2518        // to the PTY because vim / less / many TUIs need it.
2519        let is_demo_session = self
2520            .current_shell
2521            .as_ref()
2522            .is_some_and(|s| s.writer.is_none());
2523        let detach = matches!(key.code, KeyCode::F(12))
2524            || (is_demo_session && matches!(key.code, KeyCode::Esc));
2525        if detach {
2526            self.mode = self.shell_return_mode;
2527            self.status_message = Some(
2528                "detached from shell — F12 reattaches, or open shell again from Instances tab"
2529                    .into(),
2530            );
2531            return;
2532        }
2533        if let Some(shell) = self.current_shell.as_mut() {
2534            if let Some(bytes) = crate::shell::key_event_to_bytes(&key) {
2535                let _ = shell.send(&bytes);
2536            }
2537        }
2538    }
2539
2540    /// Tear down a finished shell session: the subprocess has exited, the
2541    /// reader thread returned. Surfaces a status message and routes the
2542    /// user back to where they came from.
2543    pub fn close_shell_session(&mut self) {
2544        if let Some(mut s) = self.current_shell.take() {
2545            s.kill();
2546            self.status_message = Some(format!("{} ended", s.label));
2547        }
2548        self.mode = self.shell_return_mode;
2549    }
2550
2551    /// Open the operator's `$EDITOR` against a temp file holding
2552    /// the current env vars in `KEY=VALUE` form. On save, parses
2553    /// the file, diffs against `original`, and dispatches the
2554    /// deltas via `spawn_option_settings_update`. Cancel paths
2555    /// (unchanged file / missing file / editor non-zero exit)
2556    /// are no-ops with a clear status message.
2557    ///
2558    /// Drops out of the alt-screen for the editor (vim / nano /
2559    /// VS Code's `code --wait` etc. all need the terminal directly)
2560    /// and re-enters when the editor exits — same pattern as
2561    /// `run_inline_ssm`.
2562    fn run_env_editor(
2563        &mut self,
2564        terminal: &mut Tui,
2565        env_name: &str,
2566        original: &[(String, String)],
2567    ) -> Result<()> {
2568        use crossterm::{
2569            event::{DisableMouseCapture, EnableMouseCapture},
2570            execute,
2571            terminal::{
2572                disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
2573            },
2574        };
2575
2576        let editor = std::env::var("VISUAL")
2577            .or_else(|_| std::env::var("EDITOR"))
2578            .unwrap_or_else(|_| "vi".to_string());
2579
2580        // Temp file path. Use the OS temp dir + a fingerprint
2581        // built from the env name + epoch nanos so concurrent
2582        // sessions can't collide. Format suffix `.env` so editor
2583        // syntax-highlighters give the operator a useful default.
2584        let now_ns = std::time::SystemTime::now()
2585            .duration_since(std::time::UNIX_EPOCH)
2586            .map(|d| d.as_nanos())
2587            .unwrap_or(0);
2588        let safe = env_name
2589            .chars()
2590            .map(|c| {
2591                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
2592                    c
2593                } else {
2594                    '_'
2595                }
2596            })
2597            .collect::<String>();
2598        let path = std::env::temp_dir().join(format!("ebman-env-{safe}-{now_ns}.env"));
2599
2600        let body = build_env_edit_body(env_name, original);
2601        std::fs::write(&path, body.as_bytes()).wrap_err("writing env-edit temp file")?;
2602
2603        // Leave the TUI for the editor.
2604        disable_raw_mode()?;
2605        execute!(
2606            terminal.backend_mut(),
2607            LeaveAlternateScreen,
2608            DisableMouseCapture
2609        )?;
2610        terminal.show_cursor()?;
2611
2612        let status = std::process::Command::new(&editor).arg(&path).status();
2613
2614        // Always re-enter, regardless of editor outcome.
2615        enable_raw_mode()?;
2616        execute!(
2617            terminal.backend_mut(),
2618            EnterAlternateScreen,
2619            EnableMouseCapture
2620        )?;
2621        terminal.hide_cursor()?;
2622        terminal.clear()?;
2623
2624        match status {
2625            Ok(s) if !s.success() => {
2626                self.error_message = Some(format!(
2627                    "$EDITOR ({editor}) exited {} — no changes dispatched",
2628                    s.code().unwrap_or(-1)
2629                ));
2630                let _ = std::fs::remove_file(&path);
2631                return Ok(());
2632            }
2633            Err(e) => {
2634                self.error_message = Some(format!(
2635                    "couldn't launch editor ({editor}): {e} — set $EDITOR / $VISUAL"
2636                ));
2637                let _ = std::fs::remove_file(&path);
2638                return Ok(());
2639            }
2640            _ => {}
2641        }
2642
2643        let edited = match std::fs::read_to_string(&path) {
2644            Ok(t) => t,
2645            Err(e) => {
2646                self.error_message = Some(format!(
2647                    "couldn't re-read temp file at {} — no changes dispatched ({e})",
2648                    path.display()
2649                ));
2650                return Ok(());
2651            }
2652        };
2653        let _ = std::fs::remove_file(&path);
2654
2655        let edited_map = parse_env_edit_body(&edited);
2656        let original_map: std::collections::BTreeMap<String, String> = original
2657            .iter()
2658            .map(|(k, v)| (k.clone(), v.clone()))
2659            .collect();
2660        let (to_set, to_remove) = diff_env_vars(
2661            "aws:elasticbeanstalk:application:environment",
2662            &original_map,
2663            &edited_map,
2664        );
2665
2666        if to_set.is_empty() && to_remove.is_empty() {
2667            self.status_message = Some("env-edit: no changes — nothing dispatched".into());
2668            return Ok(());
2669        }
2670
2671        let label = format!(
2672            "env-edit ({} set, {} removed)",
2673            to_set.len(),
2674            to_remove.len()
2675        );
2676        self.spawn_option_settings_update(label, to_set, to_remove);
2677        Ok(())
2678    }
2679
2680    /// Legacy inline-subprocess path: drops out of the TUI, runs
2681    /// `aws ssm start-session` against the terminal directly, and
2682    /// returns when the subprocess exits. **Not the active code path** —
2683    /// `open_embedded_shell` is the live SSM entry point and embeds the
2684    /// session inside a Mode::Shell pane (preserving the table behind
2685    /// it). Kept as a reference for any future "drop out fully" toggle;
2686    /// do not call from new code without confirming the embedded path
2687    /// genuinely can't serve the operator's use case.
2688    #[allow(dead_code)]
2689    fn run_inline_ssm(&mut self, terminal: &mut Tui, instance_id: &str) -> Result<()> {
2690        use crossterm::{
2691            event::{DisableMouseCapture, EnableMouseCapture},
2692            execute,
2693            terminal::{
2694                disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
2695            },
2696        };
2697        // 1. Leave the TUI cleanly.
2698        disable_raw_mode()?;
2699        execute!(
2700            terminal.backend_mut(),
2701            LeaveAlternateScreen,
2702            DisableMouseCapture
2703        )?;
2704        terminal.show_cursor()?;
2705
2706        let region = self.context.region.clone();
2707        let profile = self
2708            .override_profile
2709            .clone()
2710            .or_else(|| self.context.profile.clone());
2711        write_audit_line(
2712            self.context.account_id.as_deref(),
2713            profile.as_deref(),
2714            &region,
2715            &format!("stage=dispatched action=SsmSession target={instance_id}"),
2716        );
2717
2718        println!("→ aws ssm start-session --target {instance_id}");
2719        println!(
2720            "  region={region}{}",
2721            match &profile {
2722                Some(p) => format!("  profile={p}"),
2723                None => String::new(),
2724            }
2725        );
2726        println!("  ^D or `exit` to return to ebman");
2727        println!();
2728
2729        let mut cmd = std::process::Command::new("aws");
2730        cmd.arg("ssm")
2731            .arg("start-session")
2732            .arg("--target")
2733            .arg(instance_id)
2734            .arg("--region")
2735            .arg(&region);
2736        if let Some(p) = &profile {
2737            cmd.arg("--profile").arg(p);
2738        }
2739        let status = cmd.status();
2740
2741        // 3. Re-enter the TUI regardless of the subprocess outcome.
2742        enable_raw_mode()?;
2743        execute!(
2744            terminal.backend_mut(),
2745            EnterAlternateScreen,
2746            EnableMouseCapture
2747        )?;
2748        terminal.hide_cursor()?;
2749        terminal.clear()?;
2750
2751        match status {
2752            Ok(s) if s.success() => {
2753                self.status_message = Some(format!("ssm session to {instance_id} ended"));
2754            }
2755            Ok(s) => {
2756                self.error_message = Some(format!(
2757                    "aws ssm start-session exited {} — check that the AWS CLI + session-manager-plugin are installed and you have ssm:StartSession",
2758                    s.code().unwrap_or(-1)
2759                ));
2760            }
2761            Err(e) => {
2762                self.error_message = Some(format!(
2763                    "could not invoke `aws`: {e} — install the AWS CLI + session-manager-plugin"
2764                ));
2765            }
2766        }
2767        Ok(())
2768    }
2769
2770    /// Set a status message that survives the next refresh tick. Use this
2771    /// for one-shot informational results the operator just asked for
2772    /// (e.g. `:pending` outcome, `:metric add` ack); plain
2773    /// `self.status_message = Some(...)` writes are still ephemeral and
2774    /// get auto-cleared by `apply_refresh`.
2775    pub fn pin_status(&mut self, msg: impl Into<String>) {
2776        self.status_message = Some(msg.into());
2777        self.status_message_pinned = true;
2778    }
2779
2780    /// Error-message counterpart to `pin_status`. Sets
2781    /// `error_message` AND raises `status_message_pinned` so the
2782    /// next `apply_refresh` doesn't wipe it (the "no-snapshot"
2783    /// branch of the refresh clear path gates BOTH status and error
2784    /// behind the pinned flag). Used by paths that surface
2785    /// permanent-until-acknowledged conditions — e.g. dispatch_auto_rollback's
2786    /// "no pre-deploy snapshot" branch, which can fire from inside
2787    /// apply_refresh and would otherwise be cleared in the same tick.
2788    pub fn pin_error(&mut self, msg: impl Into<String>) {
2789        self.error_message = Some(msg.into());
2790        self.status_message_pinned = true;
2791    }
2792
2793    fn push_toast(&mut self, kind: ToastKind, text: String) {
2794        // Dedupe: if an identical toast (same kind + text) is already on
2795        // screen, refresh its timestamp instead of stacking a duplicate.
2796        // Without this, a flurry of identical status updates (e.g. repeated
2797        // "no env selected" key presses, or a rebuilt-context message
2798        // arriving twice) would push the same card N times.
2799        if let Some(existing) = self
2800            .toasts
2801            .iter_mut()
2802            .find(|t| t.text == text && t.kind == kind)
2803        {
2804            existing.shown_at = Instant::now();
2805            return;
2806        }
2807        // Bucket-aware dedupe: status-diff toasts like "▲2 Red", "▲3 Red"
2808        // would otherwise stack as the deltas churn. Collapse to the latest
2809        // value when the new text shares the same delta-bucket key as an
2810        // existing toast.
2811        if let Some(new_key) = delta_toast_key(&text) {
2812            if let Some(existing) = self.toasts.iter_mut().find(|t| {
2813                t.kind == kind
2814                    && delta_toast_key(&t.text)
2815                        .map(|k| k == new_key)
2816                        .unwrap_or(false)
2817            }) {
2818                existing.text = text;
2819                existing.shown_at = Instant::now();
2820                return;
2821            }
2822        }
2823        while self.toasts.len() >= TOAST_CAP {
2824            self.toasts.pop_front();
2825        }
2826        self.toasts.push_back(Toast {
2827            text,
2828            kind,
2829            shown_at: Instant::now(),
2830        });
2831    }
2832
2833    fn log_message(&mut self, kind: MsgKind, text: String) {
2834        if self.message_log.len() >= MESSAGE_LOG_CAP {
2835            self.message_log.pop_front();
2836        }
2837        self.message_log.push_back((chrono::Utc::now(), kind, text));
2838    }
2839
2840    fn format_message_log(&self) -> String {
2841        let mut out = String::new();
2842        // Active-context header — useful when scanning recent messages
2843        // across an `:account` / `:profile` / `:region` switch so the
2844        // operator can see which account a given action targeted.
2845        // Audit log on disk (`~/.cache/ebman/audit.log`) carries the
2846        // full per-action `account=…` field; this header is the in-app
2847        // shorthand reminder.
2848        let account = self
2849            .context
2850            .account_id
2851            .as_deref()
2852            .map(|a| redact_for_log(a, self.redact))
2853            .unwrap_or_else(|| "—".into());
2854        let profile = self.context.profile.as_deref().unwrap_or("default");
2855        out.push_str(&format!(
2856            "context: account={account} · profile={profile} · region={}\n",
2857            self.context.region
2858        ));
2859        if self.message_log.is_empty() {
2860            out.push_str("─────────────────────────────────\n\n");
2861            out.push_str("no messages yet\n");
2862            return out;
2863        }
2864        out.push_str("recent messages (most recent last)\n");
2865        out.push_str("─────────────────────────────────\n\n");
2866        for (when, kind, text) in &self.message_log {
2867            let when = when.with_timezone(&chrono::Local).format("%H:%M:%S");
2868            let tag = match kind {
2869                MsgKind::Info => "INFO",
2870                MsgKind::Error => "ERR ",
2871            };
2872            out.push_str(&format!("{when}  {tag}  {text}\n"));
2873        }
2874        out
2875    }
2876
2877    fn handle_event(&mut self, event: Event) {
2878        // First-run hint dismisses on any input. The renderer
2879        // checks the flag every frame, so this is enough to make
2880        // the footer line vanish on the operator's first real
2881        // interaction — typed key, mouse click, anything.
2882        if self.first_run_hint && matches!(event, Event::Key(_) | Event::Mouse(_) | Event::Paste(_))
2883        {
2884            self.first_run_hint = false;
2885        }
2886        match event {
2887            // Press AND Repeat — the latter fires when the user holds a
2888            // key (Backspace to delete a line, arrow to scroll). Repeat
2889            // events were previously dropped, which felt like "the key
2890            // isn't working" inside the embedded shell pane.
2891            Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
2892                self.handle_key(key)
2893            }
2894            Event::Mouse(m) => self.handle_mouse(m),
2895            _ => {}
2896        }
2897    }
2898
2899    fn handle_mouse(&mut self, m: MouseEvent) {
2900        // Drag-to-resize on the events-panel divider. The divider is the top
2901        // row of the events area (one row above the panel body, conceptually).
2902        // We bracket the row with a 1-cell tolerance so clicks land easily.
2903        if self.event_panel.visible {
2904            if let Some(area) = self.event_panel.area {
2905                let divider_row = area.y;
2906                let in_drag = self.event_panel.drag_origin.is_some();
2907                match m.kind {
2908                    MouseEventKind::Down(MouseButton::Left)
2909                        if (m.row as i32 - divider_row as i32).abs() <= 0 =>
2910                    {
2911                        self.event_panel.drag_origin = Some(self.event_panel.height);
2912                        return;
2913                    }
2914                    MouseEventKind::Drag(MouseButton::Left) if in_drag => {
2915                        // The mouse row is now where the divider should sit;
2916                        // events panel height = footer_bottom - mouse_row.
2917                        let footer_bottom = area.y.saturating_add(area.height).saturating_add(2);
2918                        let new_height = footer_bottom.saturating_sub(m.row);
2919                        self.event_panel.height = new_height.clamp(4, 30);
2920                        return;
2921                    }
2922                    MouseEventKind::Up(MouseButton::Left) if in_drag => {
2923                        self.event_panel.drag_origin = None;
2924                        return;
2925                    }
2926                    _ => {}
2927                }
2928            }
2929        }
2930
2931        // Metrics-tab hover capture: in Detail mode, track the mouse column
2932        // when it's over the metrics body so the renderer can surface the
2933        // value at that point.
2934        if matches!(self.mode, Mode::Detail) {
2935            if let Some(d) = self.detail.as_mut() {
2936                if d.tab() == DetailTab::Metrics {
2937                    if let MouseEventKind::Moved = m.kind {
2938                        let in_body = d
2939                            .metrics_body_rect
2940                            .map(|r| {
2941                                m.column >= r.x
2942                                    && m.column < r.x.saturating_add(r.width)
2943                                    && m.row >= r.y
2944                                    && m.row < r.y.saturating_add(r.height)
2945                            })
2946                            .unwrap_or(false);
2947                        d.metrics_hover_col = if in_body { Some(m.column) } else { None };
2948                    }
2949                }
2950            }
2951            return;
2952        }
2953
2954        // Mouse events steer the main table — wheel scroll moves selection,
2955        // left click selects a row, hover tints. None of those make sense
2956        // outside Normal mode: in Detail / Dlq / Action / Palette / QuickJump
2957        // the table is hidden, and a wheel scroll would silently change which
2958        // env you'd land on when you popped back out. Pickers / overlays /
2959        // command-mode are also handled by the keyboard.
2960        //
2961        // Apps scope shares the table area but uses a different selection
2962        // state; mouse routing for that is out of scope for now (movement
2963        // would land on env rows even when Apps is the active scope).
2964        let mouse_active = matches!(self.mode, Mode::Normal)
2965            && self.scope == Scope::Envs
2966            && self.current_overlay.is_none();
2967        if !mouse_active {
2968            self.hover_row = None;
2969            return;
2970        }
2971        match m.kind {
2972            MouseEventKind::ScrollDown => self.move_selection(1),
2973            MouseEventKind::ScrollUp => self.move_selection(-1),
2974            MouseEventKind::Down(MouseButton::Left) => self.select_row_at(m.column, m.row),
2975            MouseEventKind::Moved => self.update_hover(m.row),
2976            _ => {}
2977        }
2978    }
2979
2980    fn update_hover(&mut self, row: u16) {
2981        let area = self.table_area;
2982        if area.width == 0 || area.height == 0 {
2983            self.hover_row = None;
2984            return;
2985        }
2986        let data_top = area.y.saturating_add(2);
2987        let data_bottom = area.y.saturating_add(area.height).saturating_sub(1);
2988        if row < data_top || row >= data_bottom {
2989            self.hover_row = None;
2990            return;
2991        }
2992        let offset = self.table_state.offset();
2993        let target = offset + (row - data_top) as usize;
2994        self.hover_row = Some(target);
2995    }
2996
2997    fn select_row_at(&mut self, _col: u16, row: u16) {
2998        let area = self.table_area;
2999        if area.width == 0 || area.height == 0 {
3000            return;
3001        }
3002        // Table block: 1-row border on top, then 1-row header, then data rows.
3003        let data_top = area.y.saturating_add(2);
3004        let data_bottom = area.y.saturating_add(area.height).saturating_sub(1);
3005        if row < data_top || row >= data_bottom {
3006            return;
3007        }
3008        let rows = self.display_rows();
3009        if rows.is_empty() {
3010            return;
3011        }
3012        let offset = self.table_state.offset();
3013        let target = offset + (row - data_top) as usize;
3014        if target < rows.len() && matches!(rows[target], DisplayRow::Env(_)) {
3015            self.table_state.select(Some(target));
3016        }
3017    }
3018
3019    fn handle_key(&mut self, key: KeyEvent) {
3020        if matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(KeyModifiers::CONTROL) {
3021            self.quit = true;
3022            return;
3023        }
3024
3025        // Read-only popups overlay any mode and absorb all keys until dismissed.
3026        // Variant-specific extra dismiss keys (e.g. `D` re-toggles describe, `w`
3027        // re-toggles whatsnew) are honoured in addition to the universal Esc/q.
3028        // The SavedConfigsInteractive variant is its own mini-mode — j/k cursor
3029        // plus a/c/x dispatch — handled before the universal dismiss.
3030        // Mode::Picker short-circuits the overlay key handlers: when a
3031        // picker is open on top of an overlay (e.g. LogTail's group switcher
3032        // opened via Tab), the picker needs the keys, not the overlay.
3033        // Falls through to the `match self.mode` block below where
3034        // Mode::Picker has its own arm.
3035        if !matches!(self.mode, Mode::Picker) {
3036            if matches!(
3037                self.current_overlay.as_ref(),
3038                Some(Overlay::SavedConfigsInteractive { .. })
3039            ) {
3040                self.handle_saved_configs_interactive_key(key);
3041                return;
3042            }
3043            if matches!(self.current_overlay.as_ref(), Some(Overlay::LogTail { .. })) {
3044                self.handle_log_tail_key(key);
3045                return;
3046            }
3047            if matches!(
3048                self.current_overlay.as_ref(),
3049                Some(Overlay::AppsActionMenu { .. })
3050            ) {
3051                self.handle_apps_action_menu_key(key);
3052                return;
3053            }
3054            if matches!(
3055                self.current_overlay.as_ref(),
3056                Some(Overlay::ReportBug { .. })
3057            ) {
3058                self.handle_report_bug_key(key);
3059                return;
3060            }
3061            // `:why` cursor navigation — handled before the generic overlay
3062            // close logic so j/k/↑/↓ in the overlay scroll its items
3063            // instead of being ignored. The cursor lives on the overlay;
3064            // `App.why_items` (written by the renderer) sets the bound.
3065            if let Some(Overlay::WhyRed { cursor, .. }) = self.current_overlay.as_mut() {
3066                let item_count = self.why_items.len();
3067                let moved = match key.code {
3068                    KeyCode::Char('j') | KeyCode::Down if item_count > 0 => {
3069                        *cursor = cursor.saturating_add(1).min(item_count - 1);
3070                        true
3071                    }
3072                    KeyCode::Char('k') | KeyCode::Up if *cursor > 0 => {
3073                        *cursor -= 1;
3074                        true
3075                    }
3076                    _ => false,
3077                };
3078                if moved {
3079                    return;
3080                }
3081            }
3082            // `:why` Enter drill — extract the action under an immutable
3083            // borrow, then release it before mutating the overlay/mode.
3084            if matches!(key.code, KeyCode::Enter) {
3085                let drill: Option<(WhyItem, String, Option<String>, Option<String>)> =
3086                    if let Some(Overlay::WhyRed {
3087                        cursor,
3088                        queues,
3089                        env_name,
3090                        ..
3091                    }) = self.current_overlay.as_ref()
3092                    {
3093                        self.why_items.get(*cursor).cloned().map(|item| {
3094                            let qs = queues.as_ref().and_then(|r| r.as_ref().ok());
3095                            (
3096                                item,
3097                                env_name.clone(),
3098                                qs.and_then(|q| q.main_url.clone()),
3099                                qs.and_then(|q| q.dlq_url.clone()),
3100                            )
3101                        })
3102                    } else {
3103                        None
3104                    };
3105                if let Some((item, env_name, main_url_opt, dlq_url_opt)) = drill {
3106                    match item {
3107                        WhyItem::Describe(text) => {
3108                            self.current_overlay = Some(Overlay::Describe(text));
3109                        }
3110                        WhyItem::OpenDlq => {
3111                            if let Some(dlq_url) = dlq_url_opt {
3112                                self.current_overlay = None;
3113                                self.open_dlq_from_why(
3114                                    env_name,
3115                                    main_url_opt.unwrap_or_default(),
3116                                    dlq_url,
3117                                );
3118                            }
3119                        }
3120                    }
3121                    return;
3122                }
3123            }
3124            if let Some(overlay) = self.current_overlay.as_ref() {
3125                // Drill-in actions transition out of the overlay into
3126                // another mode. Evaluated first so the overlay's q/esc
3127                // close semantics still apply on the fallback path.
3128                let drill_dlq: Option<(String, String, String)> = match overlay {
3129                    Overlay::WhyRed {
3130                        env_name,
3131                        tier,
3132                        queues,
3133                        ..
3134                    } if matches!(key.code, KeyCode::Char('d'))
3135                        && tier.eq_ignore_ascii_case("Worker") =>
3136                    {
3137                        queues
3138                            .as_ref()
3139                            .and_then(|r| r.as_ref().ok())
3140                            .and_then(|qs| {
3141                                qs.dlq_url.clone().map(|du| {
3142                                    (
3143                                        env_name.clone(),
3144                                        qs.main_url.clone().unwrap_or_default(),
3145                                        du,
3146                                    )
3147                                })
3148                            })
3149                    }
3150                    _ => None,
3151                };
3152                if let Some((env_name, main_url, dlq_url)) = drill_dlq {
3153                    self.current_overlay = None;
3154                    self.open_dlq_from_why(env_name, main_url, dlq_url);
3155                    return;
3156                }
3157                let universal = matches!(key.code, KeyCode::Esc | KeyCode::Char('q'));
3158                let variant_extra = match overlay {
3159                    Overlay::Describe(_) => {
3160                        matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D'))
3161                    }
3162                    Overlay::Whatsnew(_) => matches!(key.code, KeyCode::Char('w')),
3163                    _ => false,
3164                };
3165                if universal || variant_extra {
3166                    self.current_overlay = None;
3167                }
3168                return;
3169            }
3170        }
3171
3172        match self.mode {
3173            Mode::Filter => self.handle_filter_key(key),
3174            Mode::Help => self.handle_help_key(key),
3175            Mode::Command => self.handle_command_key(key),
3176            Mode::Shell => self.handle_shell_key(key),
3177            Mode::Palette => self.handle_palette_key(key),
3178            Mode::QuickJump => self.handle_quickjump_key(key),
3179            Mode::Picker => self.handle_picker_key(key),
3180            Mode::Detail => {
3181                // If a search is being typed (events or logs tab), capture keys there first.
3182                if self
3183                    .detail
3184                    .as_ref()
3185                    .is_some_and(|d| d.search_active || d.log_tail.search_active)
3186                {
3187                    self.handle_detail_search_key(key);
3188                    return;
3189                }
3190                // In-place Config-tab value editor intercepts ALL keys
3191                // while open — same pattern as the search input.
3192                if self
3193                    .detail
3194                    .as_ref()
3195                    .is_some_and(|d| d.config_edit.is_some())
3196                {
3197                    self.handle_config_edit_key(key);
3198                    return;
3199                }
3200                // Instance-terminate confirm intercepts ALL keys until resolved.
3201                if let Some(idx) = self
3202                    .detail
3203                    .as_ref()
3204                    .and_then(|d| d.instance_terminate_confirm)
3205                {
3206                    match key.code {
3207                        KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
3208                            if let Some(d) = self.detail.as_mut() {
3209                                d.instance_terminate_confirm = None;
3210                            }
3211                            self.spawn_terminate_instance(idx);
3212                        }
3213                        _ => {
3214                            if let Some(d) = self.detail.as_mut() {
3215                                d.instance_terminate_confirm = None;
3216                            }
3217                            self.status_message = Some("terminate cancelled".into());
3218                        }
3219                    }
3220                    return;
3221                }
3222                // Config-row delete confirm intercepts ALL keys until resolved.
3223                if self
3224                    .detail
3225                    .as_ref()
3226                    .and_then(|d| d.config_delete_confirm)
3227                    .is_some()
3228                {
3229                    match key.code {
3230                        KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
3231                            self.commit_config_delete();
3232                        }
3233                        _ => {
3234                            if let Some(d) = self.detail.as_mut() {
3235                                d.config_delete_confirm = None;
3236                            }
3237                            self.status_message = Some("delete cancelled".into());
3238                        }
3239                    }
3240                    return;
3241                }
3242                match key.code {
3243                    KeyCode::Esc | KeyCode::Char('q') => {
3244                        self.detail = None;
3245                        self.mode = Mode::Normal;
3246                    }
3247                    KeyCode::Tab | KeyCode::Char('l') => self.detail_cycle_tab(1),
3248                    KeyCode::BackTab | KeyCode::Char('h') => self.detail_cycle_tab(-1),
3249                    KeyCode::Char('j') | KeyCode::Down => self.detail_scroll(1),
3250                    KeyCode::Char('k') | KeyCode::Up => self.detail_scroll(-1),
3251                    KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3252                        self.detail_refresh_active_tab();
3253                    }
3254                    KeyCode::Char('R') => {
3255                        if let Some(d) = self.detail.as_mut() {
3256                            d.auto_refresh = !d.auto_refresh;
3257                            let msg = if d.auto_refresh {
3258                                "detail auto-refresh ON"
3259                            } else {
3260                                "detail auto-refresh off"
3261                            };
3262                            self.status_message = Some(msg.into());
3263                        }
3264                    }
3265                    KeyCode::Char('T') => {
3266                        self.cmd_event_time(&[]);
3267                    }
3268                    // Events-tab severity / time-window filters. Guarded
3269                    // to the Events tab so `L` / `w` stay free elsewhere.
3270                    KeyCode::Char('L')
3271                        if matches!(
3272                            self.detail.as_ref().map(|d| d.tab()),
3273                            Some(DetailTab::Events)
3274                        ) =>
3275                    {
3276                        if let Some(d) = self.detail.as_mut() {
3277                            d.events_level = d.events_level.next();
3278                            d.events_scroll = 0;
3279                            let label = d.events_level.label();
3280                            self.status_message = Some(format!("events: severity ≥ {label}"));
3281                        }
3282                    }
3283                    KeyCode::Char('w')
3284                        if matches!(
3285                            self.detail.as_ref().map(|d| d.tab()),
3286                            Some(DetailTab::Events)
3287                        ) =>
3288                    {
3289                        if let Some(d) = self.detail.as_mut() {
3290                            d.events_window = d.events_window.next();
3291                            d.events_scroll = 0;
3292                            let label = d.events_window.label();
3293                            self.status_message = Some(format!("events: window {label}"));
3294                        }
3295                    }
3296                    KeyCode::Char('?') => {
3297                        self.help.topic = HelpTopic::Detail;
3298                        self.help.pre_mode = Some(Mode::Detail);
3299                        self.mode = Mode::Help;
3300                    }
3301                    KeyCode::Char('a') => self.open_action_menu(),
3302                    // Guarded `b` on Instances tab opens the EC2 console for
3303                    // the selected instance; must come before the unguarded
3304                    // `b` (which opens the env console) per the match-arm
3305                    // order rule documented in CLAUDE.md.
3306                    KeyCode::Char('b')
3307                        if matches!(
3308                            self.detail.as_ref().map(|d| d.tab()),
3309                            Some(DetailTab::Instances)
3310                        ) =>
3311                    {
3312                        self.open_instance_in_console();
3313                    }
3314                    KeyCode::Char('b') => self.open_in_console(),
3315                    KeyCode::Char('*') => self.toggle_pin_selected(),
3316                    KeyCode::Enter
3317                        if matches!(
3318                            self.detail.as_ref().map(|d| d.tab()),
3319                            Some(DetailTab::Health)
3320                        ) =>
3321                    {
3322                        self.drill_health_item();
3323                    }
3324                    KeyCode::Enter
3325                        if matches!(
3326                            self.detail.as_ref().map(|d| d.tab()),
3327                            Some(DetailTab::Queue)
3328                        ) =>
3329                    {
3330                        // On the Queue tab, Enter opens whichever queue the
3331                        // cursor is on. 0 = Main, 1 = DLQ.
3332                        let want_main = self
3333                            .detail
3334                            .as_ref()
3335                            .map(|d| d.queue_cursor == 0)
3336                            .unwrap_or(false);
3337                        if want_main {
3338                            self.open_queue_viewer(crate::app::QueueView::Main);
3339                        } else {
3340                            self.open_queue_viewer(crate::app::QueueView::Dlq);
3341                        }
3342                    }
3343                    KeyCode::Enter
3344                        if matches!(
3345                            self.detail.as_ref().map(|d| d.tab()),
3346                            Some(DetailTab::Instances)
3347                        ) =>
3348                    {
3349                        // Enter now opens an info overlay (non-intrusive).
3350                        // For the AWS EC2 console deeplink — which used to
3351                        // be Enter — use `b` from the Instances tab.
3352                        self.open_instance_info_overlay();
3353                    }
3354                    KeyCode::Char('i')
3355                        if matches!(
3356                            self.detail.as_ref().map(|d| d.tab()),
3357                            Some(DetailTab::Instances)
3358                        ) =>
3359                    {
3360                        // `i` is an alias for Enter on the Instances tab —
3361                        // open the info overlay.
3362                        self.open_instance_info_overlay();
3363                    }
3364                    KeyCode::Enter
3365                        if matches!(
3366                            self.detail.as_ref().map(|d| d.tab()),
3367                            Some(DetailTab::Config)
3368                        ) =>
3369                    {
3370                        // On the Config tab, Enter opens the in-place
3371                        // value editor for the row under the cursor.
3372                        self.start_config_edit();
3373                    }
3374                    KeyCode::Char('n')
3375                        if matches!(
3376                            self.detail.as_ref().map(|d| d.tab()),
3377                            Some(DetailTab::Config)
3378                        ) =>
3379                    {
3380                        // `n` on the Config tab — add a new row (tag or
3381                        // env var, kind taken from the cursor's section).
3382                        self.start_config_add();
3383                    }
3384                    KeyCode::Char('x')
3385                        if matches!(
3386                            self.detail.as_ref().map(|d| d.tab()),
3387                            Some(DetailTab::Config)
3388                        ) =>
3389                    {
3390                        // `x` on the Config tab — arm delete of the row
3391                        // under the cursor (y confirms).
3392                        self.arm_config_delete();
3393                    }
3394                    KeyCode::Char('r')
3395                        if matches!(
3396                            self.detail.as_ref().map(|d| d.tab()),
3397                            Some(DetailTab::Config)
3398                        ) =>
3399                    {
3400                        // `r` on the Config tab — rename the key of the
3401                        // row under the cursor.
3402                        self.start_config_rename();
3403                    }
3404                    KeyCode::Char('y')
3405                        if matches!(
3406                            self.detail.as_ref().map(|d| d.tab()),
3407                            Some(DetailTab::Instances)
3408                        ) =>
3409                    {
3410                        self.yank_instance_id();
3411                    }
3412                    KeyCode::Char('s')
3413                        if matches!(
3414                            self.detail.as_ref().map(|d| d.tab()),
3415                            Some(DetailTab::Instances)
3416                        ) =>
3417                    {
3418                        // Queue an SSM session into the selected instance.
3419                        // The run loop handles the TUI suspend/resume.
3420                        if let Some(d) = self.detail.as_ref() {
3421                            if let Some(inst) = d.instances.get(d.instances_cursor) {
3422                                self.pending_shell_target = Some(inst.id.clone());
3423                            }
3424                        }
3425                    }
3426                    KeyCode::Char('s')
3427                        if matches!(
3428                            self.detail.as_ref().map(|d| d.tab()),
3429                            Some(DetailTab::Logs)
3430                        ) =>
3431                    {
3432                        // Open the CW Logs streaming overlay over the
3433                        // existing snapshot view. spawn_logs_tail handles
3434                        // group discovery + auto-pick. The snapshot path
3435                        // stays untouched so esc returns to it.
3436                        if let Some(d) = self.detail.as_ref() {
3437                            let env_name = d.env_name.clone();
3438                            self.spawn_logs_tail(env_name, None);
3439                        }
3440                    }
3441                    KeyCode::Char('x')
3442                        if matches!(
3443                            self.detail.as_ref().map(|d| d.tab()),
3444                            Some(DetailTab::Instances)
3445                        ) =>
3446                    {
3447                        // Start delete-confirm flow. Y/N resolved in the
3448                        // same handler the next time a key arrives.
3449                        if let Some(d) = self.detail.as_mut() {
3450                            if d.instances.get(d.instances_cursor).is_some() {
3451                                d.instance_terminate_confirm = Some(d.instances_cursor);
3452                            }
3453                        }
3454                    }
3455                    KeyCode::Char('d') => self.open_dlq(),
3456                    KeyCode::Char('D') => self.open_describe_overlay(),
3457                    KeyCode::Char(']')
3458                        if matches!(
3459                            self.detail.as_ref().map(|d| d.tab()),
3460                            Some(DetailTab::Metrics)
3461                        ) =>
3462                    {
3463                        self.cycle_metrics_range(1);
3464                    }
3465                    KeyCode::Char('[')
3466                        if matches!(
3467                            self.detail.as_ref().map(|d| d.tab()),
3468                            Some(DetailTab::Metrics)
3469                        ) =>
3470                    {
3471                        self.cycle_metrics_range(-1);
3472                    }
3473                    // ] / [ on the main env table cycle through the saved-
3474                    // view chips above the table. Operators with saved
3475                    // views get a one-key flip between them instead of
3476                    // typing `:view NAME` (or `:filter NAME` for legacy
3477                    // filter-only views) each time. Guard on
3478                    // `detail.is_none()` so Detail-pane bindings (which
3479                    // also use ] / [) keep working.
3480                    KeyCode::Char(']') if self.detail.is_none() && !self.saved_views.is_empty() => {
3481                        self.cycle_saved_view(1);
3482                    }
3483                    KeyCode::Char('[') if self.detail.is_none() && !self.saved_views.is_empty() => {
3484                        self.cycle_saved_view(-1);
3485                    }
3486                    KeyCode::Char('/')
3487                        if matches!(
3488                            self.detail.as_ref().map(|d| d.tab()),
3489                            Some(DetailTab::Events)
3490                        ) =>
3491                    {
3492                        if let Some(d) = self.detail.as_mut() {
3493                            d.search_active = true;
3494                            d.search_input.clear();
3495                            d.search_error = None;
3496                        }
3497                    }
3498                    KeyCode::Char('/')
3499                        if matches!(
3500                            self.detail.as_ref().map(|d| d.tab()),
3501                            Some(DetailTab::Logs)
3502                        ) =>
3503                    {
3504                        if let Some(d) = self.detail.as_mut() {
3505                            d.log_tail.search_active = true;
3506                            d.log_tail.search_input.clear();
3507                            d.log_tail.search_error = None;
3508                        }
3509                    }
3510                    KeyCode::Char('n')
3511                        if matches!(
3512                            self.detail.as_ref().map(|d| d.tab()),
3513                            Some(DetailTab::Events)
3514                        ) =>
3515                    {
3516                        self.detail_search_jump(1);
3517                    }
3518                    KeyCode::Char('N')
3519                        if matches!(
3520                            self.detail.as_ref().map(|d| d.tab()),
3521                            Some(DetailTab::Events)
3522                        ) =>
3523                    {
3524                        self.detail_search_jump(-1);
3525                    }
3526                    _ => {}
3527                }
3528            }
3529            Mode::Action => {
3530                if key.code == KeyCode::Char('?') {
3531                    self.help.topic = HelpTopic::Action;
3532                    self.help.pre_mode = Some(Mode::Action);
3533                    self.mode = Mode::Help;
3534                } else {
3535                    self.handle_action_key(key);
3536                }
3537            }
3538            Mode::Dlq => {
3539                if key.code == KeyCode::Char('?') {
3540                    self.help.topic = HelpTopic::Dlq;
3541                    self.help.pre_mode = Some(Mode::Dlq);
3542                    self.mode = Mode::Help;
3543                } else {
3544                    self.handle_dlq_key(key);
3545                }
3546            }
3547            Mode::Form => self.handle_form_key(key),
3548            Mode::Normal => {
3549                match key.code {
3550                    KeyCode::Char('q') => self.quit = true,
3551                    // `U` undoes a pending action dispatch during the
3552                    // 5s cancel window — last-ditch "oh god no" rescue
3553                    // after a Y / typed-name confirm. Uppercase so it
3554                    // can't be mistaken for a regular keystroke.
3555                    KeyCode::Char('U') if self.pending_dispatch.is_some() => {
3556                        self.cancel_pending_dispatch();
3557                    }
3558                    // Esc clears multi-select when active. Honours the
3559                    // "esc = clear" hint the multi-select status message
3560                    // advertises; previously a no-op (silent footgun).
3561                    KeyCode::Esc if !self.multi_selected.is_empty() => {
3562                        let n = self.multi_selected.len();
3563                        self.multi_selected.clear();
3564                        self.status_message = Some(format!("multi-select cleared ({n} env(s))"));
3565                    }
3566                    KeyCode::Esc if !self.apps_selected.is_empty() => {
3567                        let n = self.apps_selected.len();
3568                        self.apps_selected.clear();
3569                        self.status_message =
3570                            Some(format!("apps multi-select cleared ({n} app(s))"));
3571                    }
3572                    KeyCode::Tab => self.set_scope(self.scope.next()),
3573                    KeyCode::BackTab => self.set_scope(self.scope.prev()),
3574                    KeyCode::Enter if self.scope == Scope::Apps => self.drill_into_app(),
3575                    KeyCode::Enter => self.open_detail(),
3576                    KeyCode::Char('a') if self.scope == Scope::Apps => {
3577                        self.open_apps_action_menu();
3578                    }
3579                    KeyCode::Char('a') if self.scope == Scope::Envs => self.open_action_menu(),
3580                    KeyCode::Char('b') if self.scope == Scope::Apps => {
3581                        self.open_app_in_console();
3582                    }
3583                    KeyCode::F(5) => self.manual_refresh(),
3584                    KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3585                        self.manual_refresh();
3586                    }
3587                    KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3588                        self.redact = !self.redact;
3589                        self.status_message = Some(if self.redact {
3590                            "redact mode ON".into()
3591                        } else {
3592                            "redact mode off".into()
3593                        });
3594                    }
3595                    KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3596                        self.grouped = !self.grouped;
3597                        self.rebuild_view();
3598                        self.status_message = Some(if self.grouped {
3599                            "grouped by application".into()
3600                        } else {
3601                            "ungrouped".into()
3602                        });
3603                    }
3604                    KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3605                        self.event_panel.visible = !self.event_panel.visible;
3606                        if self.event_panel.visible {
3607                            self.event_panel.scroll = 0;
3608                            // events were fetched on each refresh; if we have none yet, prompt one.
3609                            if self.event_panel.events.is_empty() {
3610                                self.spawn_events();
3611                            }
3612                        }
3613                    }
3614                    KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3615                        self.view_mode = self.view_mode.next();
3616                        self.status_message = Some(format!("view: {}", self.view_mode.label()));
3617                    }
3618                    KeyCode::Up
3619                        if key.modifiers.contains(KeyModifiers::CONTROL)
3620                            && self.event_panel.visible =>
3621                    {
3622                        self.event_panel.height = (self.event_panel.height + 1).min(30);
3623                    }
3624                    KeyCode::Down
3625                        if key.modifiers.contains(KeyModifiers::CONTROL)
3626                            && self.event_panel.visible =>
3627                    {
3628                        self.event_panel.height = self.event_panel.height.saturating_sub(1).max(4);
3629                    }
3630                    KeyCode::Char('s') => {
3631                        self.sort_key = self.sort_key.next();
3632                        self.resort_envs();
3633                        self.status_message = Some(format!(
3634                            "sort: {} ({})",
3635                            self.sort_key.label(),
3636                            if self.sort_desc { "desc" } else { "asc" }
3637                        ));
3638                    }
3639                    KeyCode::Char('S') => {
3640                        self.sort_desc = !self.sort_desc;
3641                        self.resort_envs();
3642                        self.status_message = Some(format!(
3643                            "sort: {} ({})",
3644                            self.sort_key.label(),
3645                            if self.sort_desc { "desc" } else { "asc" }
3646                        ));
3647                    }
3648                    KeyCode::Char('T') => {
3649                        self.cmd_event_time(&[]);
3650                    }
3651                    KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3652                        self.export_tsv();
3653                    }
3654                    KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3655                        self.yank_cli();
3656                    }
3657                    KeyCode::Char(']') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3658                        self.focus = match self.focus {
3659                            Focus::Table => {
3660                                if self.event_panel.visible {
3661                                    Focus::Events
3662                                } else {
3663                                    Focus::Table
3664                                }
3665                            }
3666                            Focus::Events => Focus::Table,
3667                        };
3668                        if matches!(self.focus, Focus::Events) && self.event_panel.cursor.is_none()
3669                        {
3670                            self.event_panel.cursor = Some(0);
3671                        }
3672                        if matches!(self.focus, Focus::Table) {
3673                            self.event_panel.cursor = None;
3674                        }
3675                        self.status_message = Some(format!(
3676                            "focus: {}",
3677                            if matches!(self.focus, Focus::Table) {
3678                                "table"
3679                            } else {
3680                                "events"
3681                            }
3682                        ));
3683                    }
3684                    KeyCode::Char('[') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3685                        self.focus = match self.focus {
3686                            Focus::Events => Focus::Table,
3687                            Focus::Table => {
3688                                if self.event_panel.visible {
3689                                    Focus::Events
3690                                } else {
3691                                    Focus::Table
3692                                }
3693                            }
3694                        };
3695                    }
3696                    KeyCode::Char(' ') if self.scope == Scope::Envs => {
3697                        if let Some(env) = self.selected_env().cloned() {
3698                            if !self.multi_selected.remove(&env.name) {
3699                                self.multi_selected.insert(env.name);
3700                            }
3701                            let n = self.multi_selected.len();
3702                            self.status_message = if n == 0 {
3703                                Some("multi-select cleared".into())
3704                            } else {
3705                                Some(format!(
3706                                    "{n} env(s) selected (a = batch action, esc = clear)"
3707                                ))
3708                            };
3709                        }
3710                    }
3711                    KeyCode::Char(' ') if self.scope == Scope::Apps => {
3712                        // Apps-scope multi-select — toggles the
3713                        // selected app in/out of `apps_selected`.
3714                        // Selection is render-only today; future
3715                        // Apps-scope batch ops will fan across every
3716                        // env in every selected app.
3717                        if let Some(idx) = self.app_table_state.selected() {
3718                            if let Some(name) = self.applications.get(idx).map(|a| a.name.clone()) {
3719                                if !self.apps_selected.remove(&name) {
3720                                    self.apps_selected.insert(name);
3721                                }
3722                                let n = self.apps_selected.len();
3723                                self.status_message = if n == 0 {
3724                                    Some("apps multi-select cleared".into())
3725                                } else {
3726                                    Some(format!("{n} app(s) selected (esc = clear)"))
3727                                };
3728                            }
3729                        }
3730                    }
3731                    KeyCode::Char('y') => {
3732                        if let Some(i) = self.event_panel.cursor {
3733                            self.yank_event_at(i);
3734                        } else {
3735                            self.yank_selected(YankKind::Cname);
3736                        }
3737                    }
3738                    KeyCode::Char('Y') => self.yank_selected(YankKind::Name),
3739                    KeyCode::Char('J')
3740                        if self.event_panel.visible && !self.event_panel.events.is_empty() =>
3741                    {
3742                        let next = self
3743                            .event_panel
3744                            .cursor
3745                            .map(|c| (c + 1).min(self.event_panel.events.len().saturating_sub(1)))
3746                            .unwrap_or(0);
3747                        self.event_panel.cursor = Some(next);
3748                    }
3749                    KeyCode::Char('K')
3750                        if self.event_panel.visible && !self.event_panel.events.is_empty() =>
3751                    {
3752                        self.event_panel.cursor =
3753                            self.event_panel.cursor.and_then(|c| c.checked_sub(1));
3754                    }
3755                    KeyCode::Char('b') if self.scope == Scope::Envs => self.open_in_console(),
3756                    KeyCode::Char('D') if self.scope == Scope::Envs => self.open_describe_overlay(),
3757                    KeyCode::Char('*') if self.scope == Scope::Envs => self.toggle_pin_selected(),
3758                    KeyCode::Char('*') if self.scope == Scope::Apps => {
3759                        self.toggle_pin_selected_app()
3760                    }
3761                    KeyCode::Char('!') if self.scope == Scope::Envs => {
3762                        // Diagnostic shortcut — opens `:why` for the
3763                        // selected env. Works on any health (not just
3764                        // Red) so the operator can pull up the same
3765                        // four-section context any time, but the
3766                        // mnemonic targets the Red-row triage case.
3767                        if let Some(env) = self.selected_env() {
3768                            let env_name = env.name.clone();
3769                            let app_name = env.application.clone();
3770                            self.open_why_red(env_name, app_name);
3771                        } else {
3772                            self.error_message = Some("no env selected".into());
3773                        }
3774                    }
3775                    KeyCode::Char('f') if self.scope == Scope::Envs => {
3776                        self.frozen = !self.frozen;
3777                        self.status_message = Some(if self.frozen {
3778                            "frozen — auto-refresh paused".into()
3779                        } else {
3780                            "unfrozen".into()
3781                        });
3782                    }
3783                    KeyCode::Char(c @ '1'..='9') => self.quick_jump((c as u8 - b'0') as usize),
3784                    KeyCode::Char('?') => {
3785                        self.help.topic = HelpTopic::Global;
3786                        self.help.pre_mode = Some(Mode::Normal);
3787                        self.mode = Mode::Help;
3788                    }
3789                    KeyCode::Char(':') => {
3790                        self.command_input.clear();
3791                        self.mode = Mode::Command;
3792                    }
3793                    KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3794                        self.open_palette();
3795                    }
3796                    KeyCode::Char('\'') => {
3797                        self.quickjump_input.clear();
3798                        self.mode = Mode::QuickJump;
3799                    }
3800                    KeyCode::Char('/') => {
3801                        self.filter.clear();
3802                        self.mode = Mode::Filter;
3803                    }
3804                    KeyCode::Char('p') => self.open_profile_picker(),
3805                    KeyCode::Char('r') => self.open_region_picker(),
3806                    KeyCode::Char('j') | KeyCode::Down => match self.focus {
3807                        Focus::Events if self.event_panel.visible => {
3808                            let next = self
3809                                .event_panel
3810                                .cursor
3811                                .map(|c| {
3812                                    (c + 1).min(self.event_panel.events.len().saturating_sub(1))
3813                                })
3814                                .unwrap_or(0);
3815                            self.event_panel.cursor = Some(next);
3816                        }
3817                        _ => self.move_scope_selection(1),
3818                    },
3819                    KeyCode::Char('k') | KeyCode::Up => match self.focus {
3820                        Focus::Events if self.event_panel.visible => {
3821                            self.event_panel.cursor =
3822                                self.event_panel.cursor.and_then(|c| c.checked_sub(1));
3823                        }
3824                        _ => self.move_scope_selection(-1),
3825                    },
3826                    KeyCode::Char('g') | KeyCode::Home => self.scope_select_first(),
3827                    KeyCode::Char('G') | KeyCode::End => self.scope_select_last(),
3828                    _ => {}
3829                }
3830            }
3831        }
3832    }
3833
3834    /// Apply a `ControlOp` received over the control socket. Snapshot ops
3835    /// read the terminal's current back-buffer; key/command ops dispatch
3836    /// through the normal handlers so all existing bindings still apply.
3837    fn handle_control_op(&mut self, op: crate::control::ControlOp, _terminal: &mut Tui) {
3838        use crate::control::ControlOp;
3839        match op {
3840            ControlOp::Screen(reply) => {
3841                let text = self
3842                    .last_rendered_buffer
3843                    .as_ref()
3844                    .map(crate::control::render_buffer_as_text)
3845                    .unwrap_or_else(|| "(no frame rendered yet)".to_string());
3846                let _ = reply.send(text);
3847            }
3848            ControlOp::Key(ke) => {
3849                self.handle_event(Event::Key(ke));
3850            }
3851            ControlOp::Command(text) => {
3852                self.execute_command(&text);
3853            }
3854            ControlOp::Reload => {
3855                self.reload_requested = true;
3856                self.quit = true;
3857                self.status_message = Some("reloading (exec self)…".into());
3858            }
3859            ControlOp::State(reply) => {
3860                let selected = self
3861                    .selected_env()
3862                    .map(|e| e.name.clone())
3863                    .unwrap_or_default();
3864                let env_count = self.environments.len();
3865                let load = match self.load_state {
3866                    LoadState::Idle => "idle",
3867                    LoadState::Loading => "loading",
3868                    LoadState::Error => "error",
3869                };
3870                let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
3871                let json = format!(
3872                    "{{\"mode\":\"{:?}\",\"profile\":\"{}\",\"region\":\"{}\",\"account\":\"{}\",\"envs\":{},\"selected\":\"{}\",\"filter\":\"{}\",\"load\":\"{}\",\"sort\":\"{}\",\"grouped\":{},\"redact\":{},\"focus\":\"{:?}\"}}",
3873                    self.mode,
3874                    esc(self.context.profile.as_deref().unwrap_or("")),
3875                    esc(&self.context.region),
3876                    esc(self.context.account_id.as_deref().unwrap_or("")),
3877                    env_count,
3878                    esc(&selected),
3879                    esc(&self.filter),
3880                    load,
3881                    self.sort_key.label(),
3882                    self.grouped,
3883                    self.redact,
3884                    self.focus,
3885                );
3886                let _ = reply.send(json);
3887            }
3888        }
3889    }
3890
3891    fn manual_refresh(&mut self) {
3892        self.spawn_refresh();
3893        self.status_message = Some("refresh requested".into());
3894    }
3895
3896    /// Toggle the COST column. `state` = None flips the current
3897    /// value; Some(true)/Some(false) sets explicitly. Persists to
3898    /// state.toml so the toggle survives restarts. Opting in triggers
3899    /// a fetch immediately (with stale-cache rendered while it runs);
3900    /// opting out clears the costs map so the column stops showing
3901    /// numbers that no longer represent reality.
3902    pub(crate) fn cmd_cost(&mut self, rest: &[&str]) {
3903        let next = match rest.first().copied() {
3904            Some("on") | Some("true") | Some("enable") => true,
3905            Some("off") | Some("false") | Some("disable") => false,
3906            Some("status") | None => {
3907                let pretty = match (self.cost_enabled, self.costs_fetched_at) {
3908                    (false, _) => "off".to_string(),
3909                    (true, None) => "on (no data yet)".into(),
3910                    (true, Some(t)) => {
3911                        let age = chrono::Utc::now()
3912                            .signed_duration_since(t)
3913                            .to_std()
3914                            .unwrap_or_default();
3915                        format!(
3916                            "on (refreshed {} ago, {} env(s) cached)",
3917                            humanize_short_age(age),
3918                            self.costs.len()
3919                        )
3920                    }
3921                };
3922                self.status_message = Some(format!("cost: {pretty}"));
3923                return;
3924            }
3925            Some(other) => {
3926                self.error_message =
3927                    Some(format!("usage: :cost on | off | status  (got '{other}')"));
3928                return;
3929            }
3930        };
3931        if next == self.cost_enabled {
3932            self.status_message =
3933                Some(format!("cost: already {}", if next { "on" } else { "off" }));
3934            return;
3935        }
3936        self.cost_enabled = next;
3937        if next {
3938            // Load whatever the cache has so the column renders
3939            // immediately with stale data; spawn a fresh fetch in
3940            // the background. The CostsFetched handler will refresh
3941            // and persist when the result lands.
3942            let account = self
3943                .context
3944                .account_id
3945                .clone()
3946                .unwrap_or_else(|| "unknown".into());
3947            let cache = crate::cost_cache::load(&account, &self.context.region);
3948            let now = chrono::Utc::now();
3949            let stale = cache.is_stale(now);
3950            self.costs = cache.costs;
3951            self.costs_fetched_at = cache.fetched_at;
3952            if stale {
3953                // Cache stale (>24h) or absent. Fetch in background;
3954                // operator sees stale numbers (or "—") immediately
3955                // and the column refreshes when CostsFetched lands.
3956                self.spawn_cost_fetch();
3957                self.status_message =
3958                    Some("cost: on — fetching latest from Cost Explorer (1-3s; cached 24h)".into());
3959            } else {
3960                // Fresh cache hit — Cost Explorer data only refreshes
3961                // ~24h on AWS's side anyway, so an extra fetch buys
3962                // nothing but rate-limit pressure. Tell the operator
3963                // what they're seeing.
3964                let age = now
3965                    .signed_duration_since(cache.fetched_at.unwrap_or(now))
3966                    .to_std()
3967                    .unwrap_or_default();
3968                self.status_message = Some(format!(
3969                    "cost: on — cached ({} ago; AWS refreshes ~24h)",
3970                    humanize_short_age(age)
3971                ));
3972            }
3973        } else {
3974            self.costs.clear();
3975            self.costs_fetched_at = None;
3976            self.status_message = Some("cost: off — column hidden, cache preserved".into());
3977        }
3978        self.persist_state();
3979    }
3980
3981    /// Spawn a Cost Explorer fetch in the background. Result lands
3982    /// via `AppMsg::CostsFetched`; on success the costs map updates
3983    /// AND the cache file is rewritten. Idempotent — multiple
3984    /// fetches in flight overwrite each other harmlessly (last
3985    /// write wins; the tag-grouped result is stable across calls).
3986    /// Spawn a background AWS call off the UI thread.
3987    ///
3988    /// `op` runs against a cloned `AwsClient`; on failure its `eyre::Report`
3989    /// is flattened to a user-facing string tagged with `op_name`. The
3990    /// `Result<T, String>` plus the generation captured at spawn time are
3991    /// handed to `into_msg`, whose `AppMsg` is sent back to the event loop.
3992    /// This is the boilerplate every simple single-call `spawn_*` helper
3993    /// shares; multi-call fan-outs (`spawn_worker_queue_check`,
3994    /// `spawn_app_latest_versions`) still build their tasks directly.
3995    fn spawn_aws<T, Fut, Op, Build>(&self, op_name: &'static str, op: Op, into_msg: Build)
3996    where
3997        T: Send + 'static,
3998        Fut: std::future::Future<Output = Result<T, color_eyre::eyre::Report>> + Send + 'static,
3999        Op: FnOnce(Arc<AwsClient>) -> Fut + Send + 'static,
4000        Build: FnOnce(u64, Result<T, String>) -> AppMsg + Send + 'static,
4001    {
4002        let aws = self.aws.clone();
4003        let tx = self.msg_tx.clone();
4004        let gen = self.generation;
4005        tokio::spawn(async move {
4006            let result = op(aws).await.map_err(|e| flatten_err(op_name, e));
4007            let _ = tx.send(into_msg(gen, result));
4008        });
4009    }
4010
4011    fn spawn_cost_fetch(&mut self) {
4012        let account = self.context.account_id.clone();
4013        let region = self.context.region.clone();
4014        self.spawn_aws(
4015            "fetch_env_costs",
4016            move |aws| async move { aws.fetch_env_costs().await },
4017            move |gen, result| AppMsg::CostsFetched {
4018                gen,
4019                account,
4020                region,
4021                result,
4022            },
4023        );
4024    }
4025
4026    fn spawn_alarms_fetch(&mut self, env_name: String) {
4027        // The fetch's env name lives on the Overlay::Alarms variant so a late
4028        // result for a different env can be dropped at the handler. The body
4029        // is initially a placeholder until the result arrives.
4030        self.current_overlay = Some(Overlay::Alarms {
4031            env_name: env_name.clone(),
4032            body: format!("fetching alarms for {env_name}…"),
4033        });
4034        let name_for_msg = env_name.clone();
4035        self.spawn_aws(
4036            "list_alarms_for_env",
4037            move |aws| async move { aws.list_alarms_for_env(&env_name).await },
4038            move |gen, result| AppMsg::Alarms {
4039                gen,
4040                env_name: name_for_msg,
4041                result,
4042            },
4043        );
4044    }
4045
4046    /// `:why` / `:diagnose` — open the unified diagnostic overlay for the
4047    /// given env. Installs an empty `Overlay::WhyRed` immediately so the
4048    /// user sees "fetching…" placeholders, then fans out four parallel
4049    /// fetchers (events, alarms, instances, deploys). Each lands as its
4050    /// own `AppMsg::WhyRed*` variant gated on `session_id`.
4051    fn open_why_red(&mut self, env_name: String, app_name: String) {
4052        self.why_red_session = self.why_red_session.wrapping_add(1);
4053        let session_id = self.why_red_session;
4054        // Tier captured up front so the renderer can hide the queue
4055        // section for Web envs without consulting `self.environments`
4056        // (which may have refreshed under us by the time the overlay
4057        // renders).
4058        let tier = self
4059            .environments
4060            .iter()
4061            .find(|e| e.name == env_name)
4062            .map(|e| e.tier.clone())
4063            .unwrap_or_default();
4064        let is_worker = tier.eq_ignore_ascii_case("Worker");
4065        self.current_overlay = Some(Overlay::WhyRed {
4066            env_name: env_name.clone(),
4067            tier,
4068            events: None,
4069            alarms: None,
4070            instances: None,
4071            deploys: None,
4072            // Web envs never get a queues entry — keep it None so the
4073            // renderer omits the section entirely. Worker envs start at
4074            // None and fill in via WhyRedQueues.
4075            queues: None,
4076            dlq_messages: None,
4077            session_id,
4078            cursor: 0,
4079        });
4080        self.spawn_why_red_events(env_name.clone(), session_id);
4081        self.spawn_why_red_alarms(env_name.clone(), session_id);
4082        self.spawn_why_red_instances(env_name.clone(), session_id);
4083        self.spawn_why_red_deploys(app_name.clone(), session_id);
4084        if is_worker {
4085            self.spawn_why_red_queues(app_name, env_name, session_id);
4086        }
4087    }
4088
4089    fn spawn_why_red_queues(&self, app_name: String, env_name: String, session_id: u64) {
4090        if self.demo_mode {
4091            let result = Ok(crate::demo_fixture::worker_queues_for_env(&env_name));
4092            let gen = self.generation;
4093            let _ = self.msg_tx.send(AppMsg::WhyRedQueues {
4094                gen,
4095                session_id,
4096                result,
4097            });
4098            return;
4099        }
4100        self.spawn_aws(
4101            "describe_worker_queues",
4102            move |aws| async move { aws.describe_worker_queues(&app_name, &env_name).await },
4103            move |gen, result| AppMsg::WhyRedQueues {
4104                gen,
4105                session_id,
4106                result,
4107            },
4108        );
4109    }
4110
4111    /// Second-stage worker-queues fetch: once the queue stats land and
4112    /// the DLQ has visible messages, peek a few bodies so the operator
4113    /// sees what's failing without leaving the overlay. Uses the same
4114    /// `peek_messages` (visibility_timeout=5s) as the DLQ overlay — the
4115    /// brief invisibility is acceptable since the DLQ isn't being
4116    /// consumed by anyone in normal operation.
4117    fn spawn_why_red_dlq_peek(&self, dlq_url: String, session_id: u64) {
4118        self.spawn_aws(
4119            "peek_messages",
4120            move |aws| async move { aws.peek_messages(&dlq_url, 3).await },
4121            move |gen, result| AppMsg::WhyRedDlqMessages {
4122                gen,
4123                session_id,
4124                result,
4125            },
4126        );
4127    }
4128
4129    fn spawn_why_red_events(&self, env_name: String, session_id: u64) {
4130        if self.demo_mode {
4131            let result = Ok(crate::demo_fixture::events_for_env(&env_name));
4132            let gen = self.generation;
4133            let _ = self.msg_tx.send(AppMsg::WhyRedEvents {
4134                gen,
4135                session_id,
4136                result,
4137            });
4138            return;
4139        }
4140        self.spawn_aws(
4141            "list_events_for_env",
4142            move |aws| async move { aws.list_events_for_env(&env_name, 50).await },
4143            move |gen, result| AppMsg::WhyRedEvents {
4144                gen,
4145                session_id,
4146                result,
4147            },
4148        );
4149    }
4150
4151    fn spawn_why_red_alarms(&self, env_name: String, session_id: u64) {
4152        if self.demo_mode {
4153            let result = Ok(crate::demo_fixture::alarms_for_env(&env_name));
4154            let gen = self.generation;
4155            let _ = self.msg_tx.send(AppMsg::WhyRedAlarms {
4156                gen,
4157                session_id,
4158                result,
4159            });
4160            return;
4161        }
4162        self.spawn_aws(
4163            "list_alarms_for_env",
4164            move |aws| async move { aws.list_alarms_for_env(&env_name).await },
4165            move |gen, result| AppMsg::WhyRedAlarms {
4166                gen,
4167                session_id,
4168                result,
4169            },
4170        );
4171    }
4172
4173    fn spawn_why_red_instances(&self, env_name: String, session_id: u64) {
4174        if self.demo_mode {
4175            let result = Ok(crate::demo_fixture::instances_for(&env_name));
4176            let gen = self.generation;
4177            let _ = self.msg_tx.send(AppMsg::WhyRedInstances {
4178                gen,
4179                session_id,
4180                result,
4181            });
4182            return;
4183        }
4184        self.spawn_aws(
4185            "list_instances",
4186            move |aws| async move { aws.list_instances(&env_name).await },
4187            move |gen, result| AppMsg::WhyRedInstances {
4188                gen,
4189                session_id,
4190                result,
4191            },
4192        );
4193    }
4194
4195    fn spawn_why_red_deploys(&self, app_name: String, session_id: u64) {
4196        if self.demo_mode {
4197            let result = Ok(crate::demo_fixture::deploys_for_app(&app_name));
4198            let gen = self.generation;
4199            let _ = self.msg_tx.send(AppMsg::WhyRedDeploys {
4200                gen,
4201                session_id,
4202                result,
4203            });
4204            return;
4205        }
4206        self.spawn_aws(
4207            "list_application_versions",
4208            move |aws| async move { aws.list_application_versions(&app_name).await },
4209            move |gen, result| AppMsg::WhyRedDeploys {
4210                gen,
4211                session_id,
4212                result,
4213            },
4214        );
4215    }
4216
4217    /// Detail-Health-tab alarms fetch. Mirrors `spawn_why_red_alarms`
4218    /// but lands on `AppMsg::DetailAlarms` so the result populates the
4219    /// Detail view's `cw_alarms` field instead of the `:why` overlay
4220    /// state. The Health tab + `:why` now share the *same* underlying
4221    /// AWS call shape but each lands on its own typed result so a stale
4222    /// fetch from a closed overlay can't clobber the Detail view.
4223    fn spawn_detail_alarms(&mut self, env_name: String) {
4224        if let Some(d) = self.detail.as_mut() {
4225            d.loading_cw_alarms = true;
4226        }
4227        // Demo-mode short-circuit (same pattern as
4228        // spawn_detail_instances / spawn_detail_events). Inject
4229        // fixture alarms so Detail/Health doesn't show an
4230        // ugly "error: DescribeAlarms failed" row.
4231        if self.demo_mode {
4232            let result = Ok(crate::demo_fixture::alarms_for_env(&env_name));
4233            let gen = self.generation;
4234            let _ = self.msg_tx.send(AppMsg::DetailAlarms {
4235                gen,
4236                env_name,
4237                result,
4238            });
4239            return;
4240        }
4241        let env_for_msg = env_name.clone();
4242        self.spawn_aws(
4243            "list_alarms_for_env",
4244            move |aws| async move { aws.list_alarms_for_env(&env_name).await },
4245            move |gen, result| AppMsg::DetailAlarms {
4246                gen,
4247                env_name: env_for_msg,
4248                result,
4249            },
4250        );
4251    }
4252
4253    /// Detail-Health-tab recent-versions fetch. Same shape as
4254    /// `spawn_why_red_deploys` but lands on `AppMsg::DetailRecentVersions`.
4255    fn spawn_detail_recent_versions(&mut self, app_name: String, env_name: String) {
4256        if let Some(d) = self.detail.as_mut() {
4257            d.loading_recent_versions = true;
4258        }
4259        if self.demo_mode {
4260            let result = Ok(crate::demo_fixture::deploys_for_app(&app_name));
4261            let gen = self.generation;
4262            let _ = self.msg_tx.send(AppMsg::DetailRecentVersions {
4263                gen,
4264                env_name,
4265                result,
4266            });
4267            return;
4268        }
4269        self.spawn_aws(
4270            "list_application_versions",
4271            move |aws| async move { aws.list_application_versions(&app_name).await },
4272            move |gen, result| AppMsg::DetailRecentVersions {
4273                gen,
4274                env_name,
4275                result,
4276            },
4277        );
4278    }
4279
4280    fn set_log_level(&mut self, level: &str) {
4281        // Treat a bare level as a directive applied to the root, but keep the
4282        // AWS/hyper crates capped at warn unless the user explicitly opts in.
4283        let directive = match level.to_lowercase().as_str() {
4284            "trace" | "debug" | "info" | "warn" | "error" => {
4285                format!("{level},aws=warn,hyper=warn")
4286            }
4287            other => other.to_string(),
4288        };
4289        let new_filter = match tracing_subscriber::EnvFilter::try_new(&directive) {
4290            Ok(f) => f,
4291            Err(e) => {
4292                self.error_message = Some(format!("invalid log directive '{level}': {e}"));
4293                return;
4294            }
4295        };
4296        let Some(handle) = self.log_reload.as_ref() else {
4297            self.error_message = Some("log reload handle missing".into());
4298            return;
4299        };
4300        match handle.modify(|f| *f = new_filter) {
4301            Ok(()) => {
4302                self.log_directive = directive.clone();
4303                self.status_message = Some(format!("log level → {directive}"));
4304            }
4305            Err(e) => self.error_message = Some(format!("log reload failed: {e}")),
4306        }
4307    }
4308
4309    fn open_whatsnew(&mut self) {
4310        // Embedded changelog text. Keep this short — full release notes live in
4311        // git history / GitHub releases. Update on every release.
4312        self.current_overlay = Some(Overlay::Whatsnew(WHATSNEW.into()));
4313    }
4314
4315    /// `:about` / `:credits` — author + license + repo info. Discoverable
4316    /// via the command palette but never pushed at the operator;
4317    /// existence justifies removing the splash byline if anyone ever
4318    /// objects to the 3-second introduction.
4319    /// `:report-bug` — build a scrubbed bug-report payload from
4320    /// current app state + ~/.cache/ebman/ebman.log tail + latest
4321    /// crash log (if any), and show it in the `Overlay::ReportBug`.
4322    /// Operator chooses `y` (copy to clipboard) or `b` (open
4323    /// GitHub issue in browser). See `report_bug` module for the
4324    /// scrubbing rules.
4325    pub(crate) fn open_report_bug_overlay(&mut self) {
4326        let cnames: std::collections::BTreeSet<String> = self
4327            .environments
4328            .iter()
4329            .filter(|e| !e.cname.is_empty())
4330            .map(|e| e.cname.clone())
4331            .collect();
4332        let env_names: std::collections::BTreeSet<String> =
4333            self.environments.iter().map(|e| e.name.clone()).collect();
4334        let app_names: std::collections::BTreeSet<String> =
4335            self.applications.iter().map(|a| a.name.clone()).collect();
4336        // message_log entries are (timestamp, kind, text) tuples;
4337        // pull the text + a single-char severity prefix so the
4338        // operator can see whether each line was a status or an
4339        // error without the structured tracing noise.
4340        let recent_messages: Vec<String> = self
4341            .message_log
4342            .iter()
4343            .rev()
4344            .take(10)
4345            .map(|(ts, kind, text)| {
4346                let sev = match kind {
4347                    MsgKind::Info => "[i]",
4348                    MsgKind::Error => "[!]",
4349                };
4350                let when = ts.format("%H:%M:%S");
4351                format!("{when}  {sev}  {text}")
4352            })
4353            .collect::<Vec<_>>()
4354            .into_iter()
4355            .rev()
4356            .collect();
4357        let icons = format!("{:?}", self.theme.icons).to_lowercase();
4358        let input = crate::report_bug::ReportInput {
4359            ebman_version: env!("CARGO_PKG_VERSION"),
4360            os: std::env::consts::OS,
4361            os_release: std::env::consts::ARCH,
4362            icons: &icons,
4363            theme: self.theme.name,
4364            refresh_interval_secs: self.refresh_interval.as_secs(),
4365            recent_log_lines: crate::report_bug::tail_ebman_log(30),
4366            recent_messages,
4367            recent_crash: crate::report_bug::latest_crash_log(),
4368            env_count: self.environments.len(),
4369            app_count: self.applications.len(),
4370            multi_regions_count: self.multi_regions.len(),
4371            multi_account_enabled: !self.accounts.is_empty(),
4372        };
4373        let ctx = crate::report_bug::ScrubContext {
4374            account_id: self.context.account_id.clone(),
4375            profile: self.context.profile.clone(),
4376            region: Some(self.context.region.clone()),
4377            env_names,
4378            app_names,
4379            cnames,
4380        };
4381        let body = crate::report_bug::build_report(&input, &ctx);
4382        self.current_overlay = Some(Overlay::ReportBug { body });
4383    }
4384
4385    /// Key handler for the `:report-bug` overlay. `y` copies the
4386    /// scrubbed payload to clipboard; `b` opens a pre-filled
4387    /// GitHub issue in the browser; `esc` / `q` closes. Same shape
4388    /// as the other interactive overlays.
4389    fn handle_report_bug_key(&mut self, key: KeyEvent) {
4390        let body = match self.current_overlay.as_ref() {
4391            Some(Overlay::ReportBug { body }) => body.clone(),
4392            _ => return,
4393        };
4394        match key.code {
4395            KeyCode::Esc | KeyCode::Char('q') => {
4396                self.current_overlay = None;
4397            }
4398            KeyCode::Char('y') | KeyCode::Char('Y') => {
4399                match yank(&body) {
4400                    Ok(()) => {
4401                        self.status_message = Some(format!(
4402                            "bug report copied to clipboard ({} chars) — paste at https://github.com/tombaldwin/ebman/issues/new",
4403                            body.chars().count()
4404                        ));
4405                    }
4406                    Err(e) => {
4407                        self.error_message = Some(format!("clipboard error: {e}"));
4408                    }
4409                }
4410                self.current_overlay = None;
4411            }
4412            KeyCode::Char('b') | KeyCode::Char('B') => {
4413                let url = crate::report_bug::github_issue_url(
4414                    "tombaldwin/ebman",
4415                    "Bug report from ebman",
4416                    &body,
4417                );
4418                match open_url(&url) {
4419                    Ok(()) => {
4420                        self.status_message = Some("opened GitHub issue draft in browser".into());
4421                    }
4422                    Err(e) => {
4423                        self.error_message = Some(format!("couldn't open browser: {e}"));
4424                    }
4425                }
4426                self.current_overlay = None;
4427            }
4428            _ => {}
4429        }
4430    }
4431
4432    /// `:rds` — fetch the env's RDS dbinstance option settings and
4433    /// Advance / rewind the command-mode completion cycle by
4434    /// `delta` (+1 = Tab, -1 = Shift-Tab). Captures the operator's
4435    /// typed prefix on the first Tab; subsequent Tabs cycle
4436    /// through matches without losing the original prefix (so
4437    /// they can pop out by typing).
4438    ///
4439    /// Args after the first whitespace pass through untouched —
4440    /// only the command-name fragment gets matched. Means `:set-
4441    /// option aws` still completes `set-option` if the operator
4442    /// goes back and Tabs at the start.
4443    fn command_completion_step(&mut self, delta: i32) {
4444        // First Tab: snapshot what the operator had typed so a
4445        // subsequent reverse-Tab (or text input) can restore.
4446        if self.completion.origin.is_none() {
4447            self.completion.origin = Some(self.command_input.clone());
4448            self.completion.index = 0;
4449        }
4450        let origin = self.completion.origin.clone().unwrap_or_default();
4451        // Split origin into (name_fragment, rest). Only the
4452        // pre-whitespace fragment is completed; anything after
4453        // (args) is preserved as-is. Take ownership of the
4454        // fragments so we can move `origin` if we hit the
4455        // empty-candidates restore path below.
4456        let (prefix, rest): (String, String) = match origin.find(char::is_whitespace) {
4457            Some(i) => (origin[..i].to_string(), origin[i..].to_string()),
4458            None => (origin.clone(), String::new()),
4459        };
4460        let candidates = completion_candidates(&prefix);
4461        if candidates.is_empty() {
4462            // Restore the operator's typed prefix and surface a
4463            // hint so the silent-no-op doesn't feel broken.
4464            self.command_input = origin;
4465            self.status_message = Some(format!(
4466                "no command matches '{prefix}' (Tab cycles command names)"
4467            ));
4468            return;
4469        }
4470        let n = candidates.len() as i32;
4471        let cur = self.completion.index as i32;
4472        let next = (cur + delta).rem_euclid(n) as usize;
4473        self.completion.index = next;
4474        self.command_input = format!("{}{rest}", candidates[next]);
4475        self.status_message = Some(format!(
4476            "completion {}/{} — Tab cycles, Esc cancels",
4477            next + 1,
4478            n
4479        ));
4480    }
4481
4482    /// `:secrets [FILTER]` — list Secrets Manager secrets in the
4483    /// active region. Optional substring filter matches against
4484    /// secret name. Output: one section per secret with name +
4485    /// ARN + description + last-changed / last-rotated dates.
4486    /// Operator yanks the ARN to paste into `:env-edit` /
4487    /// `:env set ENV_VAR ARN` for downstream consumption.
4488    ///
4489    /// No secret *values* shown here — that's a separate explicit
4490    /// `:secret NAME` call so an accidentally-typed `:secrets`
4491    /// doesn't dump credentials to the screen.
4492    pub(crate) fn cmd_secrets(&mut self, rest: &[&str]) {
4493        let filter = rest.first().map(|s| s.to_string());
4494        let aws = self.aws.clone();
4495        let tx = self.msg_tx.clone();
4496        let gen = self.generation;
4497        let title_filter = filter.clone();
4498        self.status_message = Some(match filter.as_deref() {
4499            Some(f) => format!("listing secrets matching '{f}'…"),
4500            None => "listing secrets…".into(),
4501        });
4502        tokio::spawn(async move {
4503            let result = aws
4504                .list_secrets(filter.as_deref())
4505                .await
4506                .map_err(|e| flatten_err("list_secrets", e));
4507            let body = match result {
4508                Ok(rows) => render_secrets_overlay(&rows, title_filter.as_deref()),
4509                Err(e) => format!("secrets: {e}\n\nesc / q to close"),
4510            };
4511            let _ = tx.send(AppMsg::TextOverlay {
4512                gen,
4513                title: "secrets".into(),
4514                body,
4515            });
4516        });
4517    }
4518
4519    /// `:secret NAME` — fetch and reveal a single Secrets Manager
4520    /// secret's value. Requires an explicit name to make this an
4521    /// opt-in action (accidental `:secret` with no arg is an
4522    /// error, not a "dump every secret"). Audit-logs the read so
4523    /// the operator's CloudTrail-equivalent has a record.
4524    ///
4525    /// Output respects `app.redact` — when redact mode is on, the
4526    /// value is hashed instead of shown. The operator can flip
4527    /// `:redact off` first if they need to see it.
4528    pub(crate) fn cmd_secret_view(&mut self, rest: &[&str]) {
4529        let Some(name) = rest.first().map(|s| s.to_string()) else {
4530            self.error_message =
4531                Some("usage: :secret NAME  (NAME or full ARN; see :secrets to list)".into());
4532            return;
4533        };
4534        let aws = self.aws.clone();
4535        let tx = self.msg_tx.clone();
4536        let gen = self.generation;
4537        let redact = self.redact;
4538        write_audit_line(
4539            self.context.account_id.as_deref(),
4540            self.context.profile.as_deref(),
4541            &self.context.region,
4542            &format!("stage=dispatched action=GetSecretValue target={name}"),
4543        );
4544        // Captured for the completion audit line written from the task.
4545        let account = self.context.account_id.clone();
4546        let profile = self.context.profile.clone();
4547        let region = self.context.region.clone();
4548        self.status_message = Some(format!("fetching secret '{name}'…"));
4549        tokio::spawn(async move {
4550            let result = aws
4551                .fetch_secret_value(&name)
4552                .await
4553                .map_err(|e| flatten_err("fetch_secret_value", e));
4554            // Audit the completion — `stage=completed`, matching the
4555            // dispatched/completed pairing of the write paths. (The
4556            // AWS-side CloudTrail event is the canonical record; this
4557            // is ebman's own breadcrumb.)
4558            let outcome = match &result {
4559                Ok(_) => {
4560                    format!("stage=completed action=GetSecretValue target={name} outcome=ok")
4561                }
4562                Err(e) => format!(
4563                    "stage=completed action=GetSecretValue target={name} outcome=err err=\"{}\"",
4564                    crate::audit::escape_value(e)
4565                ),
4566            };
4567            write_audit_line(account.as_deref(), profile.as_deref(), &region, &outcome);
4568            let body = match result {
4569                Ok(value) => render_secret_value_overlay(&name, &value, redact),
4570                Err(e) => format!("secret: {e}\n\nesc / q to close"),
4571            };
4572            let _ = tx.send(AppMsg::TextOverlay {
4573                gen,
4574                title: format!("secret — {name}"),
4575                body,
4576            });
4577        });
4578    }
4579
4580    /// `:rollback` — redeploy the env's previously-deployed version.
4581    /// Fetches the env's recent events, scans them for the version
4582    /// label that was current before this one (see
4583    /// [`previous_version_label`]), and opens the standard deploy
4584    /// confirm modal for it — so the operator sees + confirms the
4585    /// target, and the 5s undo window still applies.
4586    pub(crate) fn cmd_rollback(&mut self, rest: &[&str]) {
4587        let Some(env) = self.selected_env().cloned() else {
4588            self.error_message = Some("no env selected".into());
4589            return;
4590        };
4591        if self.deny_write(&env.name, "rollback") {
4592            return;
4593        }
4594        // `--auto-rollback Nm` arms the same watchdog as
4595        // `:deploy LABEL --auto-rollback`. Composes with `--to LABEL`
4596        // so the operator can dispatch "roll back to build-820,
4597        // auto-roll-forward to build-823 if Green doesn't land
4598        // within Nm". Same duration grammar (`parse_window_ms`).
4599        let auto_rollback_secs = parse_named_arg::<String>(rest, "--auto-rollback").and_then(|s| {
4600            let ms = crate::aws::parse_window_ms(&s)?;
4601            Some((ms / 1000) as u64)
4602        });
4603        if rest.contains(&"--auto-rollback") && auto_rollback_secs.is_none() {
4604            self.error_message =
4605                Some("--auto-rollback expects a duration like `5m` / `30m` / `1h`".into());
4606            return;
4607        }
4608
4609        // `:rollback --to LABEL` — operator picked the target
4610        // themselves. Skip snapshot detection + event-scan and
4611        // route straight to the deploy confirm with the named
4612        // label. EB will reject an unknown label downstream
4613        // with a clear error, so no pre-validation is needed.
4614        if let Some(target) = parse_named_arg::<String>(rest, "--to") {
4615            if target.is_empty() {
4616                self.error_message = Some("--to expects a version label".into());
4617                return;
4618            }
4619            if target == env.version_label {
4620                self.error_message = Some(format!("{target} is already the deployed version"));
4621                return;
4622            }
4623            self.open_parameterised_action(
4624                Action::Deploy,
4625                ParameterisedAction {
4626                    deploy_version: Some(target.clone()),
4627                    auto_rollback_secs,
4628                    ..Default::default()
4629                },
4630            );
4631            self.status_message = Some(format!("rollback target: {target} (operator-specified)"));
4632            return;
4633        }
4634
4635        let env_name = env.name.clone();
4636        let current_version = env.version_label.clone();
4637        // Prefer the captured pre-deploy snapshot if one exists —
4638        // more reliable than scanning events (which can hit the
4639        // 100-event window cap on chatty envs and miss the actual
4640        // previous version). The snapshot was taken right before
4641        // the deploy we'd be rolling back from, so it's exactly
4642        // what the operator means.
4643        if let Some(snapshot) = self.deploy_snapshots.get(&env_name).cloned() {
4644            if snapshot.previous_version_label != current_version {
4645                self.open_parameterised_action(
4646                    Action::Deploy,
4647                    ParameterisedAction {
4648                        deploy_version: Some(snapshot.previous_version_label.clone()),
4649                        auto_rollback_secs,
4650                        ..Default::default()
4651                    },
4652                );
4653                let age = (chrono::Utc::now() - snapshot.taken_at).num_seconds();
4654                self.status_message = Some(format!(
4655                    "rollback target: {} (from snapshot taken {}s ago)",
4656                    snapshot.previous_version_label, age
4657                ));
4658                return;
4659            }
4660        }
4661        // Fallback: scan the env's recent event history for the
4662        // most-recent version_label that differs from current. The
4663        // RollbackTarget message handler opens the confirm modal.
4664        // The event-scan path doesn't currently thread `auto_rollback_secs`
4665        // through `AppMsg::RollbackTarget` — surface a friendly
4666        // refusal when the operator asked for it but we had to fall
4667        // back to the scan, so they don't think their flag was
4668        // honoured silently.
4669        if auto_rollback_secs.is_some() {
4670            self.error_message = Some(format!(
4671                "--auto-rollback needs an in-memory snapshot for {env_name} — none captured. \
4672                 Try `:rollback --to LABEL --auto-rollback Nm` to name the target explicitly."
4673            ));
4674            return;
4675        }
4676        let aws = self.aws.clone();
4677        let tx = self.msg_tx.clone();
4678        let gen = self.generation;
4679        self.status_message = Some(format!("rollback: finding {env_name}'s previous version…"));
4680        tokio::spawn(async move {
4681            let result = aws
4682                .list_events_for_env(&env_name, 100)
4683                .await
4684                .map_err(|e| flatten_err("list_events_for_env", e));
4685            let _ = tx.send(AppMsg::RollbackTarget {
4686                gen,
4687                env_name,
4688                current_version,
4689                result,
4690            });
4691        });
4692    }
4693
4694    /// `:changes` — config-change timeline for the selected env: the
4695    /// deploy + configuration-update events from `DescribeEvents`,
4696    /// newest-first, with routine health/scaling noise filtered out.
4697    /// `:ssm-run "<shell-command>"` — fan a shell command out across
4698    /// the selected env's instances via SSM Run Command, poll the
4699    /// per-instance results, and land them in a TextOverlay. Sources
4700    /// the target list from cached `Detail.instances` (same as `:ssh`'s
4701    /// no-arg form) — if Detail isn't open with the Instances tab
4702    /// loaded, surfaces a clear error. The command runs as the SSM
4703    /// agent's default user (root on most EB AMIs); operators should
4704    /// treat this as a write operation and prefer read-only probes
4705    /// (e.g. `:ssm-run "uptime"`, `:ssm-run "ls /var/log"`) over
4706    /// state-mutating shells. Hard-capped at 60s wall-clock per
4707    /// command to keep the overlay from hanging on a stuck instance.
4708    pub(crate) fn cmd_ssm_run(&mut self, rest: &[&str]) {
4709        // The shell command is everything after `:ssm-run`. Rejoin
4710        // tokens with single spaces — the operator can quote-wrap to
4711        // preserve internal whitespace if needed. EB CLI's
4712        // `eb ssh -c '...'` uses the same shape.
4713        if rest.is_empty() {
4714            self.error_message = Some(
4715                "usage: :ssm-run \"<shell-command>\"  (fans the command out across the env's instances; quotes preserve whitespace)".into(),
4716            );
4717            return;
4718        }
4719        let command_str = rest.join(" ");
4720        let trimmed = command_str
4721            .trim_matches(|c: char| c == '"' || c == '\'')
4722            .to_string();
4723        if trimmed.is_empty() {
4724            self.error_message = Some("empty command — nothing to run".into());
4725            return;
4726        }
4727        let instances: Vec<String> = self
4728            .detail
4729            .as_ref()
4730            .map(|d| d.instances.iter().map(|i| i.id.clone()).collect())
4731            .unwrap_or_default();
4732        if instances.is_empty() {
4733            self.error_message = Some(
4734                "no cached instances — open the env's Detail/Instances tab first so :ssm-run knows what to target".into(),
4735            );
4736            return;
4737        }
4738        // Treat as a write so the global read-only / per-env safety
4739        // pin gates it. The selected env name is the natural owner.
4740        let env_name = self
4741            .detail
4742            .as_ref()
4743            .map(|d| d.env_name.clone())
4744            .unwrap_or_default();
4745        if self.deny_write(&env_name, "ssm-run") {
4746            return;
4747        }
4748        // Audit-log the dispatch + the completion outcome. SSM
4749        // commands can mutate state; treating them as write-class
4750        // operations means an after-the-fact incident review can pin
4751        // down "who ran what, when, on which env" by tailing
4752        // ~/.cache/ebman/audit.log. The command string is escaped so
4753        // quotes don't break the line shape.
4754        let audit_cmd = trimmed.replace('"', "'");
4755        write_audit_line(
4756            self.context.account_id.as_deref(),
4757            self.context.profile.as_deref(),
4758            &self.context.region,
4759            &format!(
4760                "stage=dispatched action=SsmRunCommand target={env_name} instances={n} cmd=\"{cmd}\"",
4761                n = instances.len(),
4762                cmd = audit_cmd,
4763            ),
4764        );
4765        let aws = self.aws.clone();
4766        let tx = self.msg_tx.clone();
4767        let gen = self.generation;
4768        let command_for_render = trimmed.clone();
4769        let n = instances.len();
4770        // Snapshot context for the completion-stage audit line. The
4771        // tokio task outlives `self`'s borrow.
4772        let audit_account = self.context.account_id.clone();
4773        let audit_profile = self.context.profile.clone();
4774        let audit_region = self.context.region.clone();
4775        let audit_env = env_name.clone();
4776        let audit_cmd_for_outcome = audit_cmd.clone();
4777        self.status_message = Some(format!("running `{trimmed}` on {n} instance(s)…"));
4778        tokio::spawn(async move {
4779            let result = aws
4780                .run_shell_command(&instances, &trimmed, 60)
4781                .await
4782                .map_err(|e| flatten_err("run_shell_command", e));
4783            let outcome = match &result {
4784                Ok(rows) => {
4785                    let oks = rows.iter().filter(|r| r.status == "Success").count();
4786                    format!(
4787                        "stage=completed action=SsmRunCommand target={audit_env} outcome=ok ok_count={oks}/{n} cmd=\"{audit_cmd_for_outcome}\""
4788                    )
4789                }
4790                Err(e) => format!(
4791                    "stage=completed action=SsmRunCommand target={audit_env} outcome=err err=\"{}\" cmd=\"{audit_cmd_for_outcome}\"",
4792                    crate::audit::escape_value(e)
4793                ),
4794            };
4795            write_audit_line(
4796                audit_account.as_deref(),
4797                audit_profile.as_deref(),
4798                &audit_region,
4799                &outcome,
4800            );
4801            let body = match result {
4802                Ok(rows) => format_ssm_results(&command_for_render, &rows),
4803                Err(e) => format!("ssm-run: {e}\n\nesc / q to close"),
4804            };
4805            let _ = tx.send(AppMsg::TextOverlay {
4806                gen,
4807                title: "ssm-run".into(),
4808                body,
4809            });
4810        });
4811    }
4812
4813    /// `:ssh [INSTANCE-ID]` — open an SSM Session Manager session into
4814    /// one of the selected env's instances. With an arg (`:ssh i-abc`)
4815    /// the target is taken verbatim; with no arg, a picker opens over
4816    /// `Detail.instances` if the operator has the env's Detail view
4817    /// open with the Instances tab loaded (otherwise a clear error
4818    /// points them at the missing precondition). Either path routes
4819    /// to the existing `pending_shell_target → open_embedded_shell`
4820    /// machinery — same TUI-suspend/resume + alt-screen dance as
4821    /// pressing `s` on Detail/Instances. Requires the AWS CLI +
4822    /// `session-manager-plugin` on PATH (the SDK can't substitute —
4823    /// SSM start-session uses a binary side-channel).
4824    pub(crate) fn cmd_ssh(&mut self, rest: &[&str]) {
4825        match rest.first().copied() {
4826            Some(id) => {
4827                if !id.starts_with("i-") {
4828                    self.error_message =
4829                        Some(format!("expected an EC2 instance ID (`i-…`), got '{id}'"));
4830                    return;
4831                }
4832                // Log the dispatch alongside the existing
4833                // `run_inline_ssm` audit entry — both end up in the
4834                // same `ssm:start-session` shell-out, just driven from
4835                // different paths (typed command vs Detail/Instances
4836                // `s` keybind).
4837                write_audit_line(
4838                    self.context.account_id.as_deref(),
4839                    self.context.profile.as_deref(),
4840                    &self.context.region,
4841                    &format!("stage=dispatched action=SsmSession target={id} via=cmd_ssh"),
4842                );
4843                self.pending_shell_target = Some(id.to_string());
4844                self.status_message = Some(format!("opening SSM session to {id}…"));
4845            }
4846            None => {
4847                // Picker path. Source from Detail.instances rather than
4848                // spawning a fresh DescribeInstancesHealth — keeps the
4849                // command boundary-free of new async machinery, and the
4850                // operator's typical journey already passes through
4851                // Detail/Instances on the way to a session.
4852                let instances: Vec<String> = self
4853                    .detail
4854                    .as_ref()
4855                    .map(|d| d.instances.iter().map(|i| i.id.clone()).collect())
4856                    .unwrap_or_default();
4857                if instances.is_empty() {
4858                    self.error_message = Some(
4859                        "no cached instances — open the env's Detail/Instances tab first, or pass an ID (`:ssh i-abc`)".into(),
4860                    );
4861                    return;
4862                }
4863                self.picker = Some(Picker::new(PickerKind::SshInstance, instances, None));
4864                self.mode = Mode::Picker;
4865            }
4866        }
4867    }
4868
4869    /// `:lineage` — chronological deploy timeline for the selected env.
4870    /// Where `:changes` mixes deploy events with config-change events,
4871    /// `:lineage` filters to deploys only (events that carry a
4872    /// `version_label`), collapses consecutive same-label events into
4873    /// one row, and shows the inter-deploy gap (`Δ`) plus deploy span
4874    /// (`took`). Answers "what was deployed at HH:MM" during incident
4875    /// review — the cut that's currently a manual scan through
4876    /// `:changes` mixed output.
4877    pub(crate) fn cmd_lineage(&mut self) {
4878        let env_opt = if let Some(d) = self.detail.as_ref() {
4879            Some(d.env_name.clone())
4880        } else {
4881            self.selected_env().map(|e| e.name.clone())
4882        };
4883        let Some(env_name) = env_opt else {
4884            self.error_message = Some("no env selected".into());
4885            return;
4886        };
4887        let aws = self.aws.clone();
4888        let tx = self.msg_tx.clone();
4889        let gen = self.generation;
4890        self.status_message = Some(format!("fetching deploy lineage for {env_name}…"));
4891        tokio::spawn(async move {
4892            let result = aws
4893                .list_events_for_env(&env_name, 100)
4894                .await
4895                .map_err(|e| flatten_err("list_events_for_env", e));
4896            let body = match result {
4897                Ok(events) => format_lineage(&env_name, &events),
4898                Err(e) => format!("lineage: {e}\n\nesc / q to close"),
4899            };
4900            let _ = tx.send(AppMsg::TextOverlay {
4901                gen,
4902                title: format!("lineage — {env_name}"),
4903                body,
4904            });
4905        });
4906    }
4907
4908    pub(crate) fn cmd_changes(&mut self) {
4909        let env_opt = if let Some(d) = self.detail.as_ref() {
4910            Some(d.env_name.clone())
4911        } else {
4912            self.selected_env().map(|e| e.name.clone())
4913        };
4914        let Some(env_name) = env_opt else {
4915            self.error_message = Some("no env selected".into());
4916            return;
4917        };
4918        let aws = self.aws.clone();
4919        let tx = self.msg_tx.clone();
4920        let gen = self.generation;
4921        self.status_message = Some(format!("fetching change history for {env_name}…"));
4922        tokio::spawn(async move {
4923            let result = aws
4924                .list_events_for_env(&env_name, 100)
4925                .await
4926                .map_err(|e| flatten_err("list_events_for_env", e));
4927            let body = match result {
4928                Ok(events) => render_changes_overlay(&env_name, &events),
4929                Err(e) => format!("changes: {e}\n\nesc / q to close"),
4930            };
4931            let _ = tx.send(AppMsg::TextOverlay {
4932                gen,
4933                title: format!("changes — {env_name}"),
4934                body,
4935            });
4936        });
4937    }
4938
4939    /// `:event-time [utc|local|age]` — set how event timestamps render
4940    /// in the Events panel + Detail/Events tab. No argument cycles
4941    /// `Utc → Local → Age`. Persists to state.toml. UTC is the
4942    /// default because it matches the EB / CloudWatch API output the
4943    /// operator cross-references against.
4944    pub(crate) fn cmd_event_time(&mut self, rest: &[&str]) {
4945        let next = match rest.first().copied() {
4946            None => self.event_panel.time_format.next(),
4947            Some(arg) => match EventTimeFormat::parse(arg) {
4948                Some(f) => f,
4949                None => {
4950                    self.error_message = Some(format!(
4951                        "unknown event-time format '{arg}'  (use: utc | local | age)"
4952                    ));
4953                    return;
4954                }
4955            },
4956        };
4957        self.event_panel.time_format = next;
4958        self.persist_state();
4959        self.status_message = Some(match next {
4960            EventTimeFormat::Utc => "event timestamps: UTC (YYYY-MM-DD HH:MM:SSZ)".into(),
4961            EventTimeFormat::Local => "event timestamps: local time".into(),
4962            EventTimeFormat::Age => "event timestamps: relative age".into(),
4963        });
4964    }
4965
4966    /// `:env-edit` — bulk env-var editor via `$EDITOR`. Two-stage:
4967    ///
4968    ///   1. Async fetch of the env's current env vars
4969    ///      (`spawn_env_vars_for_edit`).
4970    ///   2. Main-loop tick takes the result + shells out to
4971    ///      `$EDITOR` against a temp file, parses the result on
4972    ///      save, dispatches the diff via `spawn_option_settings_update`.
4973    ///
4974    /// Closes the bulk-edit gap that single-key `:env set` /
4975    /// `:env unset` doesn't. Operator can add / remove / rename
4976    /// multiple env vars in one update — and saving an unchanged
4977    /// file is a clean no-op.
4978    pub(crate) fn cmd_env_edit(&mut self) {
4979        let Some(env) = self.selected_env().cloned() else {
4980            self.error_message = Some("no env selected".into());
4981            return;
4982        };
4983        if self.deny_write(&env.name, ":env-edit") {
4984            return;
4985        }
4986        if self.pending_env_edit.is_some() {
4987            self.error_message =
4988                Some("another :env-edit is mid-flight — wait for the editor to close".into());
4989            return;
4990        }
4991        let aws = self.aws.clone();
4992        let tx = self.msg_tx.clone();
4993        let gen = self.generation;
4994        let app_name = env.application.clone();
4995        let env_name = env.name.clone();
4996        let env_name_for_msg = env_name.clone();
4997        self.status_message = Some(format!("fetching env vars for {env_name}…"));
4998        tokio::spawn(async move {
4999            let result = aws
5000                .fetch_env_vars(&app_name, &env_name)
5001                .await
5002                .map_err(|e| flatten_err("fetch_env_vars", e));
5003            let _ = tx.send(AppMsg::EnvVarsForEdit {
5004                gen,
5005                env_name: env_name_for_msg,
5006                result,
5007            });
5008        });
5009    }
5010
5011    /// `:explain` — diagnose an IAM `AccessDenied` by calling
5012    /// `iam:SimulatePrincipalPolicy` against the principal + action
5013    /// the failed request named. Surfaces the policy decision
5014    /// (allowed / explicitDeny / implicitDeny), the matched
5015    /// statements, SCP / permission-boundary blockers, and a
5016    /// concrete JSON snippet the operator can paste into a policy.
5017    ///
5018    /// Two shapes:
5019    ///   - `:explain` (no args) walks the most recent error message
5020    ///     looking for the standard AWS AccessDenied shape; uses
5021    ///     [`parse_access_denied`] to extract principal + action.
5022    ///   - `:explain ARN ACTION [ACTION ...]` evaluates explicit
5023    ///     pairs. Useful for pre-flight ("can this role rebuild
5024    ///     this env?") even when no error has happened yet.
5025    ///
5026    /// Caller needs `iam:SimulatePrincipalPolicy` on the target
5027    /// principal — common gap on assumed-role sessions. We surface
5028    /// that as a clear error rather than a silent no-op.
5029    pub(crate) fn cmd_explain(&mut self, rest: &[&str]) {
5030        // New in 0.14: `:explain EBL###` routes to the LLM-backed
5031        // explainer for lint issues. Backward-compatible with the
5032        // existing IAM AccessDenied flow because rule IDs don't
5033        // start with `arn:aws:` and don't take a second positional.
5034        if let Some(first) = rest.first().copied() {
5035            if first.starts_with("EBL") {
5036                self.cmd_explain_issue(first);
5037                return;
5038            }
5039        }
5040        let (principal, actions): (String, Vec<String>) = match rest.first().copied() {
5041            // Args form: ARN + 1..N action names.
5042            Some(arn) if arn.starts_with("arn:aws:") && rest.len() >= 2 => {
5043                let actions: Vec<String> = rest[1..].iter().map(|s| s.to_string()).collect();
5044                (arn.to_string(), actions)
5045            }
5046            Some(_) => {
5047                self.error_message = Some(
5048                    "usage: :explain (IAM AccessDenied) | :explain ARN ACTION [...] | :explain EBL###"
5049                        .into(),
5050                );
5051                return;
5052            }
5053            None => {
5054                // Walk message_log for the latest error containing
5055                // "is not authorized to perform" — that's the
5056                // AWS AccessDenied shape `parse_access_denied`
5057                // understands.
5058                let latest = self.message_log.iter().rev().find(|(_, kind, text)| {
5059                    matches!(kind, MsgKind::Error) && text.contains("is not authorized to perform")
5060                });
5061                let Some((_, _, text)) = latest else {
5062                    self.error_message = Some(
5063                        "no recent AccessDenied to explain — :explain ARN ACTION to evaluate explicitly".into(),
5064                    );
5065                    return;
5066                };
5067                match parse_access_denied(text) {
5068                    Some((arn, action)) => (arn, vec![action]),
5069                    None => {
5070                        self.error_message = Some(format!(
5071                            "couldn't parse principal + action from last error: {text}"
5072                        ));
5073                        return;
5074                    }
5075                }
5076            }
5077        };
5078        let aws = self.aws.clone();
5079        let tx = self.msg_tx.clone();
5080        let gen = self.generation;
5081        let principal_for_title = principal.clone();
5082        self.status_message = Some(format!(
5083            "diagnosing IAM perms for {} action(s) on {principal}…",
5084            actions.len()
5085        ));
5086        tokio::spawn(async move {
5087            let result = aws
5088                .simulate_principal_policy(&principal, &actions, &[])
5089                .await
5090                .map_err(|e| flatten_err("simulate_principal_policy", e));
5091            let body = match result {
5092                Ok(rows) => render_explain_overlay(&principal, &rows),
5093                Err(e) => format!(
5094                    "explain: {e}\n\n\
5095                     This usually means the caller lacks `iam:SimulatePrincipalPolicy`\n\
5096                     on the target role — common with assumed-role sessions that don't\n\
5097                     have IAM perms. Try from a profile with IAM access.\n\n\
5098                     esc / q to close"
5099                ),
5100            };
5101            let _ = tx.send(AppMsg::TextOverlay {
5102                gen,
5103                title: format!("explain — {principal_for_title}"),
5104                body,
5105            });
5106        });
5107    }
5108
5109    /// `:explain EBL###` — LLM-backed explanation of a lint issue.
5110    /// Runs the lint engine against the currently-selected env,
5111    /// finds the matching issue, builds the standard explain prompt
5112    /// via [`crate::llm::build_prompt`], and dispatches to the
5113    /// configured Provider. Result lands in a TextOverlay (same
5114    /// surface as the IAM AccessDenied explainer).
5115    ///
5116    /// Opt-in via `[explain] enabled = true` in `config.toml` plus
5117    /// the env-var holding the provider API key. Without consent
5118    /// the overlay just says so with a config-file pointer.
5119    fn cmd_explain_issue(&mut self, issue_id: &str) {
5120        let Some(env) = self.selected_env().cloned() else {
5121            self.error_message = Some("no env selected".into());
5122            return;
5123        };
5124        let aws = self.aws.clone();
5125        let tx = self.msg_tx.clone();
5126        let gen = self.generation;
5127        let mut disabled = self.lint_disable.clone();
5128        disabled.extend(crate::project::load_lint_disables_from_cwd());
5129        let app_name = env.application.clone();
5130        let env_name_for_fetch = env.name.clone();
5131        // Resolve LLM settings directly from the App's mirrored
5132        // explain_* fields (round-tripped through
5133        // current_config_snapshot), without round-tripping through
5134        // Config::default + field assignment.
5135        let settings = crate::llm::Settings {
5136            enabled: self.explain_enabled,
5137            provider: if self.explain_provider.is_empty() {
5138                "anthropic".into()
5139            } else {
5140                self.explain_provider.clone()
5141            },
5142            model: if self.explain_model.is_empty() {
5143                "claude-haiku-4-5".into()
5144            } else {
5145                self.explain_model.clone()
5146            },
5147            api_key_env: if self.explain_api_key_env.is_empty() {
5148                "ANTHROPIC_API_KEY".into()
5149            } else {
5150                self.explain_api_key_env.clone()
5151            },
5152            ollama_url: if self.explain_ollama_url.is_empty() {
5153                "http://localhost:11434".into()
5154            } else {
5155                self.explain_ollama_url.clone()
5156            },
5157            max_tokens: if self.explain_max_tokens == 0 {
5158                1024
5159            } else {
5160                self.explain_max_tokens
5161            },
5162        };
5163        let issue_id_owned = issue_id.to_string();
5164        let issue_id_title = issue_id.to_string();
5165        self.status_message = Some(format!("explain: building prompt for {issue_id}…"));
5166        tokio::spawn(async move {
5167            let body = match aws
5168                .fetch_env_option_settings(&app_name, &env_name_for_fetch)
5169                .await
5170            {
5171                Ok(opts) => {
5172                    let ctx = crate::lint::LintContext {
5173                        env: &env,
5174                        options: &opts,
5175                        events: &[],
5176                        cost_usd_per_month: None,
5177                        latest_stack_version: None,
5178                    };
5179                    let rules = crate::lint::default_rules(&disabled);
5180                    let issues = crate::lint::run_rules(&rules, &ctx);
5181                    match issues.iter().find(|i| i.rule_id == issue_id_owned) {
5182                        None => format!(
5183                            "explain: rule {issue_id_owned} doesn't fire on env {} — nothing to explain.\n\
5184                             Run :lint to see which issues do fire here.\n\nesc / q to close",
5185                            env.name
5186                        ),
5187                        Some(issue) => {
5188                            let prompt = crate::llm::build_prompt(issue);
5189                            // Cache first — operators running the
5190                            // same explain multiple times in a
5191                            // session don't burn API calls.
5192                            match crate::llm::read_cache(issue) {
5193                                Some(cached) => cached,
5194                                None => match crate::llm::dispatch(&settings, &prompt).await {
5195                                    Ok(r) => {
5196                                        crate::llm::write_cache(issue, &r);
5197                                        r
5198                                    }
5199                                    Err(e) => format!(
5200                                        "explain: {e}\n\n\
5201                                         Configure [explain] in {} or run from CLI with `ebman explain {issue_id_owned} --env {}`.\n\n\
5202                                         esc / q to close",
5203                                        crate::util::config_file("config.toml").display(),
5204                                        env.name,
5205                                    ),
5206                                },
5207                            }
5208                        }
5209                    }
5210                }
5211                Err(e) => format!("explain: fetch_env_option_settings: {e}\n\nesc / q to close"),
5212            };
5213            let _ = tx.send(AppMsg::TextOverlay {
5214                gen,
5215                title: format!("explain — {issue_id_title}"),
5216                body,
5217            });
5218        });
5219    }
5220
5221    /// `:options [NAMESPACE]` — full settable-option vocabulary for
5222    /// the selected env's platform. Closes the biggest console-parity
5223    /// gap (config discoverability): the console has the canonical
5224    /// list of every settable EB option with metadata; ebman's
5225    /// `:set-option NAMESPACE NAME VALUE` requires the operator to
5226    /// already know the vocabulary.
5227    ///
5228    /// `:options` lists everything. `:options NAMESPACE` filters
5229    /// to one family (e.g. `:options aws:elbv2:listener`,
5230    /// `:options aws:autoscaling:asg`).
5231    pub(crate) fn cmd_options(&mut self, rest: &[&str]) {
5232        let Some(env) = self.selected_env().cloned() else {
5233            self.error_message = Some("no env selected".into());
5234            return;
5235        };
5236        let filter_ns = rest.first().map(|s| s.to_string());
5237        let aws = self.aws.clone();
5238        let tx = self.msg_tx.clone();
5239        let gen = self.generation;
5240        let app_name = env.application.clone();
5241        let env_name = env.name.clone();
5242        self.status_message = Some(format!(
5243            "fetching config vocabulary for {env_name}… (this can take a few seconds)"
5244        ));
5245        tokio::spawn(async move {
5246            let result = aws
5247                .fetch_env_configuration_options(&app_name, &env_name)
5248                .await
5249                .map_err(|e| flatten_err("fetch_env_configuration_options", e));
5250            let body = match result {
5251                Ok(rows) => render_options_overlay(&rows, filter_ns.as_deref(), &env_name),
5252                Err(e) => format!("options: {e}\n\nesc / q to close"),
5253            };
5254            let _ = tx.send(AppMsg::TextOverlay {
5255                gen,
5256                title: format!("options — {env_name}"),
5257                body,
5258            });
5259        });
5260    }
5261
5262    /// `:config-diff-local [NAME]` — diff the deployed env's current
5263    /// option settings against a local EB CLI saved config (the YAML
5264    /// under `.elasticbeanstalk/saved_configs/<NAME>.cfg.yml`). With
5265    /// no arg, auto-picks the lone config if there's exactly one;
5266    /// with multiple, errors and lists names so the operator can
5267    /// pick. Bridges EB CLI users into ebman: answers "is what I
5268    /// committed still what's deployed" without rerunning
5269    /// `eb config get` and eyeballing the diff.
5270    pub(crate) fn cmd_config_diff_local(&mut self, rest: &[&str]) {
5271        let Some(env) = self.selected_env().cloned() else {
5272            self.error_message = Some("no env selected".into());
5273            return;
5274        };
5275        let cwd = match std::env::current_dir() {
5276            Ok(p) => p,
5277            Err(e) => {
5278                self.error_message = Some(format!("can't read cwd: {e}"));
5279                return;
5280            }
5281        };
5282        let path = match rest.first().copied() {
5283            Some(name) => match crate::saved_config::resolve_saved_config(&cwd, name) {
5284                Ok(p) => p,
5285                Err(e) => {
5286                    self.error_message = Some(format!("config-diff-local: {e}"));
5287                    return;
5288                }
5289            },
5290            None => {
5291                let configs = match crate::saved_config::discover_saved_configs(&cwd) {
5292                    Ok(c) => c,
5293                    Err(e) => {
5294                        self.error_message = Some(format!("config-diff-local: {e}"));
5295                        return;
5296                    }
5297                };
5298                match configs.len() {
5299                    0 => {
5300                        self.error_message = Some(format!(
5301                            "no .elasticbeanstalk/saved_configs/*.cfg.yml under {}",
5302                            cwd.display()
5303                        ));
5304                        return;
5305                    }
5306                    1 => configs.into_iter().next().unwrap(),
5307                    _ => {
5308                        let names: Vec<String> = configs
5309                            .iter()
5310                            .map(|p| crate::saved_config::saved_config_name(p))
5311                            .collect();
5312                        self.error_message = Some(format!(
5313                            "multiple saved configs — pick one: :config-diff-local <{}>",
5314                            names.join(" | ")
5315                        ));
5316                        return;
5317                    }
5318                }
5319            }
5320        };
5321        let yaml = match std::fs::read_to_string(&path) {
5322            Ok(s) => s,
5323            Err(e) => {
5324                self.error_message = Some(format!("reading {}: {e}", path.display()));
5325                return;
5326            }
5327        };
5328        let local_opts = match crate::saved_config::parse_saved_config(&yaml) {
5329            Ok(o) => o,
5330            Err(e) => {
5331                self.error_message = Some(format!("parsing {}: {e}", path.display()));
5332                return;
5333            }
5334        };
5335        let local_name = crate::saved_config::saved_config_name(&path);
5336        let aws = self.aws.clone();
5337        let tx = self.msg_tx.clone();
5338        let gen = self.generation;
5339        let (app_name, env_name) = (env.application.clone(), env.name.clone());
5340        let env_name_for_title = env_name.clone();
5341        let local_name_for_title = local_name.clone();
5342        self.status_message = Some(format!(
5343            "comparing {env_name} ↔ saved config '{local_name}'…"
5344        ));
5345        tokio::spawn(async move {
5346            let result = aws
5347                .fetch_env_configuration_options(&app_name, &env_name)
5348                .await
5349                .map_err(|e| flatten_err("fetch_env_configuration_options", e));
5350            let body = match result {
5351                Ok(deployed) => {
5352                    let diffs = diff_config_options(&local_opts, &deployed);
5353                    let left_label = format!("local:{local_name}");
5354                    render_config_diff_overlay(&left_label, &env_name, &diffs)
5355                }
5356                Err(e) => format!("config-diff-local: {e}\n\nesc / q to close"),
5357            };
5358            let _ = tx.send(AppMsg::TextOverlay {
5359                gen,
5360                title: format!("config-diff-local — {env_name_for_title} ↔ {local_name_for_title}"),
5361                body,
5362            });
5363        });
5364    }
5365
5366    /// `:config-diff ENV` — compare the selected env's option-settings
5367    /// against `ENV`'s, showing every setting that differs. Answers
5368    /// "why does staging differ from prod?". Fetches both envs'
5369    /// configuration options in parallel and renders the diff.
5370    pub(crate) fn cmd_config_diff(&mut self, rest: &[&str]) {
5371        let Some(target) = rest.first().map(|s| s.to_string()) else {
5372            self.error_message = Some(
5373                "usage: :config-diff ENV  (compare the selected env's option-settings against ENV)"
5374                    .into(),
5375            );
5376            return;
5377        };
5378        let left = if let Some(d) = self.detail.as_ref() {
5379            Some(d.env_snapshot.clone())
5380        } else {
5381            self.selected_env().cloned()
5382        };
5383        let Some(left) = left else {
5384            self.error_message = Some("no env selected".into());
5385            return;
5386        };
5387        let Some(right) = self.environments.iter().find(|e| e.name == target).cloned() else {
5388            self.error_message = Some(format!("no env named '{target}' in the current view"));
5389            return;
5390        };
5391        if left.name == right.name {
5392            self.error_message = Some("pick a different env to compare against".into());
5393            return;
5394        }
5395        let aws = self.aws.clone();
5396        let tx = self.msg_tx.clone();
5397        let gen = self.generation;
5398        let (la, ln) = (left.application.clone(), left.name.clone());
5399        let (ra, rn) = (right.application.clone(), right.name.clone());
5400        self.status_message = Some(format!("comparing config: {ln} ↔ {rn}…"));
5401        tokio::spawn(async move {
5402            let body = match tokio::try_join!(
5403                aws.fetch_env_configuration_options(&la, &ln),
5404                aws.fetch_env_configuration_options(&ra, &rn),
5405            ) {
5406                Ok((lopts, ropts)) => {
5407                    let diffs = diff_config_options(&lopts, &ropts);
5408                    render_config_diff_overlay(&ln, &rn, &diffs)
5409                }
5410                Err(e) => format!(
5411                    "config-diff: {}\n\nesc / q to close",
5412                    flatten_err("fetch_env_configuration_options", e)
5413                ),
5414            };
5415            let _ = tx.send(AppMsg::TextOverlay {
5416                gen,
5417                title: format!("config diff — {ln} ↔ {rn}"),
5418                body,
5419            });
5420        });
5421    }
5422
5423    /// `:rds` — fetch the env's RDS dbinstance option settings and
5424    /// render them. Visibility-only first cut: attach (via
5425    /// `UpdateEnvironment(aws:rds:dbinstance.*)`) and detach (the
5426    /// decouple-via-snapshot workflow) are follow-ups — both need
5427    /// careful operator confirmation flows and the detach path is
5428    /// genuinely destructive.
5429    ///
5430    /// Empty result = no RDS attached. We surface that as an
5431    /// explicit message rather than "no config" so the operator
5432    /// isn't left wondering whether the fetch failed silently.
5433    pub(crate) fn cmd_rds(&mut self) {
5434        let Some(env) = self.selected_env().cloned() else {
5435            self.error_message = Some("no env selected".into());
5436            return;
5437        };
5438        let aws = self.aws.clone();
5439        let tx = self.msg_tx.clone();
5440        let gen = self.generation;
5441        let app_name = env.application.clone();
5442        let env_name = env.name.clone();
5443        self.status_message = Some(format!("fetching RDS config for {env_name}…"));
5444        tokio::spawn(async move {
5445            let result = aws
5446                .fetch_env_rds_config(&app_name, &env_name)
5447                .await
5448                .map_err(|e| flatten_err("fetch_env_rds_config", e));
5449            let body = match result {
5450                Ok(rows) if rows.is_empty() => "No RDS instance attached to this env.\n\n\
5451                     EB-managed RDS is configured via `aws:rds:dbinstance.*`\n\
5452                     option settings. To attach a new one:\n\n  \
5453                     :set-option aws:rds:dbinstance DBEngine postgres\n  \
5454                     :set-option aws:rds:dbinstance DBInstanceClass db.t3.micro\n  \
5455                     :set-option aws:rds:dbinstance DBPassword <secret>\n\n\
5456                     (See the EB docs — there are 10+ required fields. A\n\
5457                     dedicated `:rds-attach` form is a planned follow-up.)\n\n\
5458                     esc / q to close"
5459                    .to_string(),
5460                Ok(rows) => {
5461                    let mut body = String::from("RDS dbinstance configuration:\n\n");
5462                    for (opt, value) in &rows {
5463                        // Redact the password field even when the
5464                        // operator hasn't toggled global redact mode —
5465                        // surfacing a DB password into an overlay is a
5466                        // worse default than hiding it.
5467                        let safe_value = if opt.eq_ignore_ascii_case("DBPassword") {
5468                            "(redacted)".to_string()
5469                        } else {
5470                            value.clone()
5471                        };
5472                        body.push_str(&format!("  {opt:<28}  {safe_value}\n"));
5473                    }
5474                    body.push_str(
5475                        "\nUse `:set-option aws:rds:dbinstance <KEY> <VALUE>` to change a setting.\n\
5476                         Note: most RDS option changes trigger instance modification (downtime risk).\n\
5477                         esc / q to close",
5478                    );
5479                    body
5480                }
5481                Err(e) => format!("rds: {e}\n\nesc / q to close"),
5482            };
5483            let _ = tx.send(AppMsg::TextOverlay {
5484                gen,
5485                title: format!("rds — {env_name}"),
5486                body,
5487            });
5488        });
5489    }
5490
5491    /// `:listeners` — fetch the env's ALB listener config (per-port:
5492    /// protocol, attached cert ARN, SSL policy, default rule) and
5493    /// render it as a text overlay. Web-tier only — Worker envs
5494    /// don't have an ALB. Edit support (cert rotation, listener
5495    /// add/remove) is a follow-up; the generic
5496    /// `:set-option aws:elbv2:listener:<PORT> KEY VAL` already
5497    /// works for one-off updates.
5498    pub(crate) fn cmd_listeners(&mut self) {
5499        let Some(env) = self.selected_env().cloned() else {
5500            self.error_message = Some("no env selected".into());
5501            return;
5502        };
5503        if env.tier.eq_ignore_ascii_case("Worker") {
5504            self.error_message = Some(format!(
5505                "env '{}' is Worker tier — no ALB to configure",
5506                env.name
5507            ));
5508            return;
5509        }
5510        let aws = self.aws.clone();
5511        let tx = self.msg_tx.clone();
5512        let gen = self.generation;
5513        let app_name = env.application.clone();
5514        let env_name = env.name.clone();
5515        self.status_message = Some(format!("fetching listeners for {env_name}…"));
5516        tokio::spawn(async move {
5517            let result = aws
5518                .fetch_env_listeners(&app_name, &env_name)
5519                .await
5520                .map_err(|e| flatten_err("fetch_env_listeners", e));
5521            let body = match result {
5522                Ok(rows) if rows.is_empty() => "No listener config found.\n\n\
5523                     The env may use a Classic ELB instead of an ALB, or no\n\
5524                     listener overrides have been set (EB uses account defaults).\n\
5525                     `:set-option aws:elbv2:listener:443 SSLCertificateArns ARN`\n\
5526                     to configure a listener from scratch.\n\nesc / q to close"
5527                    .to_string(),
5528                Ok(rows) => {
5529                    let mut body = String::from("Listener configuration:\n");
5530                    body.push_str("(one block per port; `default` = HTTP/80)\n\n");
5531                    let mut current_port: Option<String> = None;
5532                    for (port, opt, value) in &rows {
5533                        if current_port.as_deref() != Some(port.as_str()) {
5534                            if current_port.is_some() {
5535                                body.push('\n');
5536                            }
5537                            body.push_str(&format!("── aws:elbv2:listener:{port} ──\n"));
5538                            current_port = Some(port.clone());
5539                        }
5540                        body.push_str(&format!("  {opt:<32}  {value}\n"));
5541                    }
5542                    body.push_str(
5543                        "\n`:set-option aws:elbv2:listener:<PORT> <KEY> <VALUE>` to change a setting.\n\
5544                         esc / q to close",
5545                    );
5546                    body
5547                }
5548                Err(e) => format!("listeners: {e}\n\nesc / q to close"),
5549            };
5550            let _ = tx.send(AppMsg::TextOverlay {
5551                gen,
5552                title: format!("listeners — {env_name}"),
5553                body,
5554            });
5555        });
5556    }
5557
5558    /// `:listener-edit PORT` — modal cert-rotation form for an ALB
5559    /// listener. Opens a single MultiSelect field whose options are the
5560    /// region's ISSUED ACM certificates (loaded async), pre-selected with
5561    /// the listener's current `SSLCertificateArns`. Submit writes the new
5562    /// cert set to `aws:elbv2:listener:<PORT>` via the option-settings
5563    /// path. `PORT` is `443` / a numeric port / `default` (HTTP/80).
5564    pub(crate) fn cmd_listener_edit(&mut self, rest: &[&str]) {
5565        use crate::form::{Form, FormField, FormSubmit};
5566        let Some(env) = self.selected_env().cloned() else {
5567            self.error_message = Some("no env selected".into());
5568            return;
5569        };
5570        if env.tier.eq_ignore_ascii_case("Worker") {
5571            self.error_message = Some(format!(
5572                "env '{}' is Worker tier — no ALB to configure",
5573                env.name
5574            ));
5575            return;
5576        }
5577        let Some(port) = rest.first().copied() else {
5578            self.error_message = Some(
5579                "usage: :listener-edit PORT  (e.g. :listener-edit 443; `default` = HTTP/80)".into(),
5580            );
5581            return;
5582        };
5583        let port = port.to_string();
5584        let ns = format!("aws:elbv2:listener:{port}");
5585        let placeholder = FormField::multi_select(
5586            "cert",
5587            "SSL certificate(s)",
5588            Vec::new(),
5589            Vec::new(),
5590            Some::<String>("space toggle · ↑↓ option cursor · loaded from ACM".into()),
5591        );
5592        let form = Form::loading(
5593            format!("listener {port} — {}", env.name),
5594            env.name.clone(),
5595            format!("listener {port} cert update"),
5596            vec![placeholder],
5597            FormSubmit::OptionSettings {
5598                mappings: vec![("cert".into(), ns, "SSLCertificateArns".into())],
5599            },
5600        );
5601        // Bypass open_form's DescribeConfigurationSettings pre-fill (it
5602        // wouldn't load ACM inventory) — stash the form and spawn the
5603        // cert-specific loader, mirroring the subnet / SG pickers.
5604        self.form = Some(form);
5605        self.mode = Mode::Form;
5606        let aws = self.aws.clone();
5607        let tx = self.msg_tx.clone();
5608        let gen = self.generation;
5609        let env_for_msg = env.name.clone();
5610        let app_name = env.application.clone();
5611        tokio::spawn(async move {
5612            let result = load_listener_certs(aws, &app_name, &env_for_msg, &port).await;
5613            let _ = tx.send(AppMsg::FormMultiSelectLoaded {
5614                gen,
5615                env_name: env_for_msg,
5616                field_key: "cert".to_string(),
5617                result,
5618            });
5619        });
5620    }
5621
5622    /// `:apps-info` — surface application metadata that doesn't fit
5623    /// in the apps-table columns: full description, creation date,
5624    /// last-updated date, saved-config templates, env count.
5625    /// Resolves the target via cursor position in either scope:
5626    /// Apps scope uses `app_table_state`; Envs scope walks
5627    /// `selected_env().application`.
5628    pub(crate) fn open_apps_info_overlay(&mut self) {
5629        let app_name_opt = match self.scope {
5630            Scope::Apps => self
5631                .app_table_state
5632                .selected()
5633                .and_then(|i| self.applications.get(i).map(|a| a.name.clone())),
5634            Scope::Envs => self.selected_env().map(|e| e.application.clone()),
5635        };
5636        let Some(app_name) = app_name_opt else {
5637            self.error_message = Some("no application selected".into());
5638            return;
5639        };
5640        let Some(app) = self.applications.iter().find(|a| a.name == app_name) else {
5641            self.error_message = Some(format!(
5642                "application '{app_name}' not in cache yet — refresh and retry"
5643            ));
5644            return;
5645        };
5646        // Walk env list for the rollup figures; mirrors the apps-table
5647        // columns so the operator can compare without bouncing.
5648        let rollup = app_rollup(&self.environments, &app.name, &self.worker_dlq_depths);
5649        let env_names: Vec<&str> = self
5650            .environments
5651            .iter()
5652            .filter(|e| e.application == app.name)
5653            .map(|e| e.name.as_str())
5654            .collect();
5655        let date_fmt = |dt: Option<chrono::DateTime<chrono::Utc>>| -> String {
5656            dt.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
5657                .unwrap_or_else(|| "—".into())
5658        };
5659        let templates_block = if app.templates.is_empty() {
5660            "  (none)".to_string()
5661        } else {
5662            app.templates
5663                .iter()
5664                .map(|t| format!("  ▸ {t}"))
5665                .collect::<Vec<_>>()
5666                .join("\n")
5667        };
5668        let envs_block = if env_names.is_empty() {
5669            "  (none)".to_string()
5670        } else {
5671            env_names
5672                .iter()
5673                .map(|n| format!("  ▸ {n}"))
5674                .collect::<Vec<_>>()
5675                .join("\n")
5676        };
5677        let description = if app.description.is_empty() {
5678            "(no description)".to_string()
5679        } else {
5680            app.description.clone()
5681        };
5682        let latest_line = match (
5683            app.latest_version_label.as_deref(),
5684            app.latest_version_created,
5685        ) {
5686            (Some(label), Some(created)) => format!("{label}  ({})", date_fmt(Some(created))),
5687            (Some(label), None) => label.to_string(),
5688            _ => "—".into(),
5689        };
5690        let body = format!(
5691            "Application: {}\n\
5692             Description: {description}\n\n\
5693             Created:     {created}\n\
5694             Updated:     {updated}\n\n\
5695             Versions:    {version_count} registered · latest: {latest_line}\n\
5696             Envs:        {env_count} total · {red_count} alerting · {updating_count} updating\n\n\
5697             Environments:\n{envs_block}\n\n\
5698             Saved configuration templates:\n{templates_block}\n\n\
5699             esc / q to close",
5700            app.name,
5701            created = date_fmt(app.date_created),
5702            updated = date_fmt(app.date_updated),
5703            version_count = app.version_count,
5704            env_count = rollup.env_count,
5705            red_count = rollup.red_count + rollup.worker_dlq_alerts,
5706            updating_count = rollup.updating_count,
5707        );
5708        self.current_overlay = Some(Overlay::TextDump {
5709            title: format!("info — {}", app.name),
5710            body,
5711        });
5712    }
5713
5714    fn open_about_overlay(&mut self) {
5715        // The card content is built by `draw_about`; the overlay just
5716        // carries the open time so the giant scene can animate.
5717        self.current_overlay = Some(Overlay::About(Instant::now()));
5718    }
5719
5720    fn toggle_pin_selected(&mut self) {
5721        let name_opt = if let Some(d) = self.detail.as_ref() {
5722            Some(d.env_name.clone())
5723        } else {
5724            self.selected_env().map(|e| e.name.clone())
5725        };
5726        let Some(name) = name_opt else {
5727            self.status_message = Some("no env selected".into());
5728            return;
5729        };
5730        if self.pinned.remove(&name) {
5731            self.status_message = Some(format!("unpinned {name}"));
5732        } else {
5733            self.pinned.insert(name.clone());
5734            self.status_message = Some(format!("pinned {name}"));
5735        }
5736        self.resort_envs();
5737        self.persist_state();
5738    }
5739
5740    /// Apps-scope counterpart to `toggle_pin_selected`. Pins / unpins
5741    /// the application under the apps-table cursor. Pinned apps sort
5742    /// to the top of the Apps table regardless of the sort key (the
5743    /// `applications` Vec gets re-sorted on every refresh; see
5744    /// `resort_applications`).
5745    fn toggle_pin_selected_app(&mut self) {
5746        let Some(idx) = self.app_table_state.selected() else {
5747            self.status_message = Some("no app selected".into());
5748            return;
5749        };
5750        let Some(name) = self.applications.get(idx).map(|a| a.name.clone()) else {
5751            return;
5752        };
5753        if self.pinned_apps.remove(&name) {
5754            self.status_message = Some(format!("unpinned app {name}"));
5755        } else {
5756            self.pinned_apps.insert(name.clone());
5757            self.status_message = Some(format!("pinned app {name}"));
5758        }
5759        self.resort_applications();
5760        self.persist_state();
5761    }
5762
5763    /// Sort `self.applications` so pinned apps float to the top.
5764    /// Within each pinned / unpinned bucket, alphabetical by name to
5765    /// keep ordering stable.
5766    fn resort_applications(&mut self) {
5767        let pinned = self.pinned_apps.clone();
5768        self.applications.sort_by(|a, b| {
5769            let a_pin = pinned.contains(&a.name);
5770            let b_pin = pinned.contains(&b.name);
5771            if a_pin != b_pin {
5772                return if a_pin {
5773                    std::cmp::Ordering::Less
5774                } else {
5775                    std::cmp::Ordering::Greater
5776                };
5777            }
5778            a.name.cmp(&b.name)
5779        });
5780    }
5781
5782    fn yank_cli(&mut self) {
5783        let env_opt = if let Some(d) = self.detail.as_ref() {
5784            Some(d.env_snapshot.clone())
5785        } else {
5786            self.selected_env().cloned()
5787        };
5788        let Some(env) = env_opt else {
5789            self.status_message = Some("no env selected".into());
5790            return;
5791        };
5792        let cmd = build_describe_cli(
5793            &env.name,
5794            &self.context.region,
5795            self.override_profile
5796                .as_deref()
5797                .or(self.context.profile.as_deref()),
5798        );
5799        match yank(&cmd) {
5800            Ok(()) => {
5801                self.status_message = Some("equivalent AWS CLI command copied".into());
5802            }
5803            Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
5804        }
5805    }
5806
5807    fn export_json(&mut self) {
5808        let count = self.cached_filtered.len();
5809        let mut out = String::from("[\n");
5810        for (idx, &i) in self.cached_filtered.iter().enumerate() {
5811            let e = &self.environments[i];
5812            let cname = if self.redact {
5813                redact_block(&e.cname)
5814            } else {
5815                e.cname.clone()
5816            };
5817            let updated = e
5818                .updated
5819                .map(|u| format!("\"{}\"", u.to_rfc3339()))
5820                .unwrap_or_else(|| "null".into());
5821            out.push_str(&format!(
5822                "  {{\"name\":\"{}\",\"application\":\"{}\",\"tier\":\"{}\",\"status\":\"{}\",\"health\":\"{}\",\"platform\":\"{}\",\"version\":\"{}\",\"cname\":\"{}\",\"updated\":{}}}",
5823                json_escape(&e.name),
5824                json_escape(&e.application),
5825                json_escape(&e.tier),
5826                json_escape(&e.status),
5827                json_escape(&e.health),
5828                json_escape(&e.platform),
5829                json_escape(&e.version_label),
5830                json_escape(&cname),
5831                updated,
5832            ));
5833            if idx + 1 < count {
5834                out.push(',');
5835            }
5836            out.push('\n');
5837        }
5838        out.push(']');
5839        match yank(&out) {
5840            Ok(()) => {
5841                self.status_message = Some(format!("exported {count} rows (JSON) to clipboard"));
5842            }
5843            Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
5844        }
5845    }
5846
5847    fn export_markdown(&mut self) {
5848        let count = self.cached_filtered.len();
5849        let mut out = String::new();
5850        out.push_str("| NAME | APPLICATION | TIER | STATUS | HEALTH | PLATFORM | VERSION | CNAME | UPDATED |\n");
5851        out.push_str("| ---- | ----------- | ---- | ------ | ------ | -------- | ------- | ----- | ------- |\n");
5852        for &i in &self.cached_filtered {
5853            let e = &self.environments[i];
5854            let cname = if self.redact {
5855                redact_block(&e.cname)
5856            } else {
5857                e.cname.clone()
5858            };
5859            let updated = e.updated.map(|u| u.to_rfc3339()).unwrap_or_default();
5860            out.push_str(&format!(
5861                "| {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
5862                md_escape(&e.name),
5863                md_escape(&e.application),
5864                e.tier,
5865                e.status,
5866                e.health,
5867                md_escape(&e.platform),
5868                md_escape(&e.version_label),
5869                md_escape(&cname),
5870                updated,
5871            ));
5872        }
5873        match yank(&out) {
5874            Ok(()) => {
5875                self.status_message =
5876                    Some(format!("exported {count} rows (Markdown) to clipboard"));
5877            }
5878            Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
5879        }
5880    }
5881
5882    fn open_describe_overlay(&mut self) {
5883        let env = if let Some(d) = self.detail.as_ref() {
5884            Some(d.env_snapshot.clone())
5885        } else {
5886            self.selected_env().cloned()
5887        };
5888        let Some(env) = env else {
5889            self.status_message = Some("no env selected".into());
5890            return;
5891        };
5892        self.current_overlay = Some(Overlay::Describe(describe_env(&env)));
5893    }
5894
5895    fn open_in_console(&mut self) {
5896        let env_opt = if let Some(d) = self.detail.as_ref() {
5897            Some(d.env_snapshot.clone())
5898        } else {
5899            self.selected_env().cloned()
5900        };
5901        let Some(env) = env_opt else {
5902            self.status_message = Some("no env selected".into());
5903            return;
5904        };
5905        let url = console_url(&self.context.region, &env.application, &env.name);
5906        match open_url(&url) {
5907            Ok(()) => {
5908                self.status_message = Some(format!("opened {} in browser", env.name));
5909            }
5910            Err(e) => {
5911                self.error_message = Some(format!("couldn't open browser: {e}"));
5912            }
5913        }
5914    }
5915
5916    fn open_palette(&mut self) {
5917        self.palette_input.clear();
5918        self.palette_items = build_palette_items(self);
5919        self.palette_refilter();
5920        self.mode = Mode::Palette;
5921    }
5922
5923    fn palette_refilter(&mut self) {
5924        let needle = self.palette_input.to_lowercase();
5925        let mut scored: Vec<(usize, isize)> = self
5926            .palette_items
5927            .iter()
5928            .enumerate()
5929            .filter_map(|(i, it)| {
5930                let s = palette_score(&needle, &it.label, &it.detail)?;
5931                Some((i, s))
5932            })
5933            .collect();
5934        scored.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
5935        self.palette_filtered = scored.into_iter().map(|(i, _)| i).collect();
5936        self.palette_state
5937            .select(if self.palette_filtered.is_empty() {
5938                None
5939            } else {
5940                Some(0)
5941            });
5942    }
5943
5944    fn palette_move(&mut self, delta: i32) {
5945        let n = self.palette_filtered.len();
5946        if n == 0 {
5947            self.palette_state.select(None);
5948            return;
5949        }
5950        let cur = self.palette_state.selected().unwrap_or(0) as i32;
5951        let next = (cur + delta).rem_euclid(n as i32) as usize;
5952        self.palette_state.select(Some(next));
5953    }
5954
5955    fn palette_execute(&mut self) {
5956        let Some(pos) = self.palette_state.selected() else {
5957            return;
5958        };
5959        let Some(&idx) = self.palette_filtered.get(pos) else {
5960            return;
5961        };
5962        let Some(item) = self.palette_items.get(idx).cloned() else {
5963            return;
5964        };
5965        self.mode = Mode::Normal;
5966        self.palette_input.clear();
5967        match item.action {
5968            PaletteAction::RunCommand(cmd) => self.execute_command(&cmd),
5969            PaletteAction::PrefillCommand(prefix) => {
5970                self.command_input = prefix;
5971                self.mode = Mode::Command;
5972            }
5973            PaletteAction::JumpEnv(name) => {
5974                if let Some(pos) = self.cached_display.iter().position(|r| match r {
5975                    DisplayRow::Env(i) => self.environments[*i].name == name,
5976                    DisplayRow::Separator => false,
5977                }) {
5978                    self.table_state.select(Some(pos));
5979                    self.status_message = Some(format!("jumped to {name}"));
5980                }
5981            }
5982            PaletteAction::LoadView(name) => {
5983                self.execute_command(&format!("view {name}"));
5984            }
5985        }
5986    }
5987
5988    fn quickjump_apply(&mut self) {
5989        if self.quickjump_input.is_empty() {
5990            return;
5991        }
5992        let needle = self.quickjump_input.to_lowercase();
5993        for (pos, row) in self.cached_display.iter().enumerate() {
5994            if let DisplayRow::Env(i) = row {
5995                let e = &self.environments[*i];
5996                let alias = self
5997                    .aliases
5998                    .get(&e.name)
5999                    .map(|a| a.to_lowercase())
6000                    .unwrap_or_default();
6001                if e.name.to_lowercase().starts_with(&needle) || alias.starts_with(&needle) {
6002                    self.table_state.select(Some(pos));
6003                    return;
6004                }
6005            }
6006        }
6007    }
6008
6009    fn quick_jump(&mut self, n: usize) {
6010        // 1..=9 maps to position n-1 in the visible env rows.
6011        let Some(target_env) = self
6012            .cached_display
6013            .iter()
6014            .filter(|r| matches!(r, DisplayRow::Env(_)))
6015            .nth(n.saturating_sub(1))
6016        else {
6017            return;
6018        };
6019        if let Some(pos) = self
6020            .cached_display
6021            .iter()
6022            .position(|r| std::ptr::eq(r, target_env))
6023        {
6024            self.table_state.select(Some(pos));
6025        }
6026    }
6027
6028    fn open_detail(&mut self) {
6029        let Some(env) = self.selected_env().cloned() else {
6030            self.status_message = Some("no env selected".into());
6031            return;
6032        };
6033        let mut tabs = vec![
6034            DetailTab::Health,
6035            DetailTab::Events,
6036            DetailTab::Instances,
6037            DetailTab::Metrics,
6038        ];
6039        if env.tier == "Worker" {
6040            tabs.push(DetailTab::Queue);
6041        }
6042        tabs.push(DetailTab::Logs);
6043        tabs.push(DetailTab::Config);
6044        let detail = DetailState {
6045            env_name: env.name.clone(),
6046            env_snapshot: env,
6047            tabs,
6048            tab_idx: 0,
6049            events: Vec::new(),
6050            instances: Vec::new(),
6051            queues: WorkerQueues::default(),
6052            metrics: Vec::new(),
6053            metrics_range_secs: 3600, // 1h default
6054            auto_refresh: false,
6055            search_input: String::new(),
6056            search_active: false,
6057            search_pattern: None,
6058            search_error: None,
6059            events_scroll: 0,
6060            events_max_scroll: 0,
6061            events_level: EventLevel::default(),
6062            events_window: EventWindow::default(),
6063            instances_scroll: 0,
6064            tags: Vec::new(),
6065            env_vars: Vec::new(),
6066            cw_log_groups: None,
6067            loading_events: false,
6068            loading_instances: false,
6069            loading_queues: false,
6070            loading_metrics: false,
6071            loading_tags: false,
6072            loading_env_vars: false,
6073            error: None,
6074            log_tail: LogTail::default(),
6075            queue_cursor: 0,
6076            instances_cursor: 0,
6077            instance_terminate_confirm: None,
6078            health_cursor: 0,
6079            metrics_hover_col: None,
6080            metrics_body_rect: None,
6081            cw_alarms: None,
6082            loading_cw_alarms: false,
6083            recent_versions: None,
6084            loading_recent_versions: false,
6085            config_cursor: 0,
6086            config_edit: None,
6087            config_scroll: 0,
6088            config_delete_confirm: None,
6089        };
6090        self.detail = Some(detail);
6091        self.mode = Mode::Detail;
6092        self.detail_refresh_active_tab();
6093        // Tags & instances load eagerly so the Config tab (tags + cost
6094        // annotation) is populated without the user having to switch tabs.
6095        self.spawn_detail_tags();
6096        self.spawn_detail_env_vars();
6097        self.spawn_detail_log_groups();
6098        if let Some(d) = self.detail.as_ref() {
6099            let env_name = d.env_name.clone();
6100            self.spawn_detail_instances(env_name);
6101        }
6102    }
6103
6104    fn spawn_detail_log_groups(&mut self) {
6105        let Some(d) = self.detail.as_ref() else {
6106            return;
6107        };
6108        let env_name = d.env_name.clone();
6109        let aws = self.aws.clone();
6110        let tx = self.msg_tx.clone();
6111        let gen = self.generation;
6112        tokio::spawn(async move {
6113            // We don't surface fetch errors here — failure just means we
6114            // can't tell whether CW Logs are configured, in which case the
6115            // Logs tab falls back to the generic "press ^R or s" hint.
6116            let groups = aws
6117                .discover_env_log_groups(&env_name)
6118                .await
6119                .unwrap_or_default();
6120            let _ = tx.send(AppMsg::DetailLogGroups {
6121                gen,
6122                env_name,
6123                groups,
6124            });
6125        });
6126    }
6127
6128    fn spawn_detail_env_vars(&mut self) {
6129        let Some(d) = self.detail.as_ref() else {
6130            return;
6131        };
6132        let app_name = d.env_snapshot.application.clone();
6133        let env_name = d.env_name.clone();
6134        if let Some(d) = self.detail.as_mut() {
6135            d.loading_env_vars = true;
6136        }
6137        let env_for_msg = env_name.clone();
6138        self.spawn_aws(
6139            "fetch_env_vars",
6140            move |aws| async move { aws.fetch_env_vars(&app_name, &env_name).await },
6141            move |gen, result| AppMsg::DetailEnvVars {
6142                gen,
6143                env_name: env_for_msg,
6144                result,
6145            },
6146        );
6147    }
6148
6149    fn spawn_detail_tags(&mut self) {
6150        let Some(d) = self.detail.as_ref() else {
6151            return;
6152        };
6153        let Some(arn) = d.env_snapshot.arn.clone() else {
6154            return;
6155        };
6156        let env_name = d.env_name.clone();
6157        if let Some(d) = self.detail.as_mut() {
6158            d.loading_tags = true;
6159        }
6160        self.spawn_aws(
6161            "list_tags",
6162            move |aws| async move { aws.list_tags(&arn).await },
6163            move |gen, result| AppMsg::DetailTags {
6164                gen,
6165                env_name,
6166                result,
6167            },
6168        );
6169    }
6170
6171    /// Enter handler for the Health tab — drills into whichever
6172    /// `HealthItem` the `health_cursor` is currently on. Event → opens
6173    /// the full message in a TextDump overlay (some EB events are
6174    /// multi-line); Instance → switches to the Instances tab and
6175    /// positions the cursor on that instance; Main/DLQ queue → switches
6176    /// to the Queue tab and positions the queue cursor on the
6177    /// corresponding row (operator then presses Enter again to open the
6178    /// queue viewer).
6179    fn drill_health_item(&mut self) {
6180        let Some(detail) = self.detail.as_ref() else {
6181            return;
6182        };
6183        let now = chrono::Utc::now();
6184        let items = crate::app::health_items(detail, now);
6185        let Some(item) = items.get(detail.health_cursor).copied() else {
6186            return;
6187        };
6188        match item {
6189            HealthItem::Event { event_idx } => {
6190                let Some(ev) = detail.events.get(event_idx) else {
6191                    return;
6192                };
6193                let when = ev
6194                    .at
6195                    .map(|t| t.with_timezone(&chrono::Local).to_string())
6196                    .unwrap_or_else(|| "?".into());
6197                let body = format!(
6198                    "{when}\n[{}]  {}\n\n{}\n\nesc / q to close",
6199                    ev.severity, ev.env, ev.message
6200                );
6201                self.current_overlay = Some(Overlay::TextDump {
6202                    title: "event detail".into(),
6203                    body,
6204                });
6205            }
6206            HealthItem::Instance { instance_idx } => {
6207                // Switch to the Instances tab and seat the cursor on
6208                // the chosen instance. Then the operator can Enter
6209                // again for the info overlay, `s` for SSM, etc.
6210                let Some(d) = self.detail.as_mut() else {
6211                    return;
6212                };
6213                if let Some(pos) = d.tabs.iter().position(|t| *t == DetailTab::Instances) {
6214                    d.tab_idx = pos;
6215                }
6216                d.instances_cursor = instance_idx.min(d.instances.len().saturating_sub(1));
6217                d.instances_scroll = (d.instances_cursor as u16).saturating_sub(3);
6218                self.detail_refresh_active_tab();
6219            }
6220            HealthItem::MainQueue | HealthItem::Dlq => {
6221                let Some(d) = self.detail.as_mut() else {
6222                    return;
6223                };
6224                if let Some(pos) = d.tabs.iter().position(|t| *t == DetailTab::Queue) {
6225                    d.tab_idx = pos;
6226                }
6227                d.queue_cursor = match item {
6228                    HealthItem::MainQueue => 0,
6229                    HealthItem::Dlq => 1,
6230                    _ => 0,
6231                };
6232                self.detail_refresh_active_tab();
6233            }
6234        }
6235    }
6236
6237    fn detail_cycle_tab(&mut self, delta: i32) {
6238        let Some(detail) = self.detail.as_mut() else {
6239            return;
6240        };
6241        let n = detail.tabs.len() as i32;
6242        let next = (detail.tab_idx as i32 + delta).rem_euclid(n) as usize;
6243        detail.tab_idx = next;
6244        self.detail_refresh_active_tab();
6245        // NB: an earlier iteration auto-spawned the CW Logs streaming
6246        // overlay here when groups were discovered. Reverted because
6247        // jumping into a popup obscures the Logs tab's own snapshot path
6248        // (`^R`) and removes the explicit opt-in that `s` represents.
6249        // Pressing `s` on the Logs tab is the way to open the stream;
6250        // the in-overlay `g` keybind switches between discovered groups.
6251    }
6252
6253    fn detail_scroll(&mut self, delta: i32) {
6254        let Some(detail) = self.detail.as_mut() else {
6255            return;
6256        };
6257        match detail.tab() {
6258            DetailTab::Events => {
6259                // Clamp to the ceiling the renderer published last frame
6260                // so j/k can't scroll the list off into blank space.
6261                detail.events_scroll =
6262                    scroll_apply(detail.events_scroll, delta).min(detail.events_max_scroll);
6263            }
6264            DetailTab::Instances => {
6265                let n = detail.instances.len();
6266                if n == 0 {
6267                    return;
6268                }
6269                let cur = detail.instances_cursor as i32;
6270                let next = (cur + delta).rem_euclid(n as i32) as usize;
6271                detail.instances_cursor = next;
6272                // Keep the scroll offset roughly aligned with the cursor so
6273                // the active row stays visible when navigating with j/k.
6274                detail.instances_scroll = (next as u16).saturating_sub(3);
6275            }
6276            DetailTab::Logs => {
6277                detail.log_tail.scroll = scroll_apply(detail.log_tail.scroll, delta);
6278            }
6279            DetailTab::Queue => {
6280                // Cursor wraps between the two queue rows (Main / DLQ).
6281                let n: i32 = 2;
6282                let cur = detail.queue_cursor as i32;
6283                detail.queue_cursor = (cur + delta).rem_euclid(n) as usize;
6284            }
6285            DetailTab::Health => {
6286                // Cursor wraps over the interactive items list; see
6287                // `health_items` for the enumeration order.
6288                let now = chrono::Utc::now();
6289                let n = crate::app::health_items(detail, now).len() as i32;
6290                if n == 0 {
6291                    return;
6292                }
6293                let cur = detail.health_cursor as i32;
6294                detail.health_cursor = (cur + delta).rem_euclid(n) as usize;
6295            }
6296            DetailTab::Config => {
6297                // Cursor moves over the editable rows (tags + env vars).
6298                // Clamped at the ends — no wrap — since the list can be
6299                // long and wrapping past the bottom is disorienting.
6300                let n = crate::app::config_editable_items(detail).len();
6301                if n == 0 {
6302                    return;
6303                }
6304                let cur = detail.config_cursor as i32;
6305                detail.config_cursor = (cur + delta).clamp(0, n as i32 - 1) as usize;
6306            }
6307            // Metrics tab has no scrollable cursor — the chart body
6308            // handles its own keyboard interactions.
6309            DetailTab::Metrics => {}
6310        }
6311    }
6312
6313    fn detail_refresh_active_tab(&mut self) {
6314        let Some(detail) = self.detail.as_ref() else {
6315            return;
6316        };
6317        let env_name = detail.env_name.clone();
6318        let app_name = detail.env_snapshot.application.clone();
6319        let is_worker = detail.env_snapshot.tier.eq_ignore_ascii_case("Worker");
6320        let tab = detail.tab();
6321        // Release the immutable borrow of `detail` before calling
6322        // spawn_* methods which take `&mut self`.
6323        let _ = detail;
6324        match tab {
6325            // Health tab is a rollup — refresh events (for the recent-
6326            // events list) and queues (for worker DLQ depth shown
6327            // inline). Instances were eagerly fetched in `open_detail`
6328            // and don't change often, so we don't refetch them here on
6329            // every Health-tab visit; the eager fetch + periodic
6330            // background refresh keeps the count fresh enough.
6331            DetailTab::Health => {
6332                self.spawn_detail_events(env_name.clone());
6333                self.spawn_detail_alarms(env_name.clone());
6334                self.spawn_detail_recent_versions(app_name.clone(), env_name.clone());
6335                if is_worker {
6336                    self.spawn_detail_queues(app_name, env_name);
6337                }
6338            }
6339            DetailTab::Events => self.spawn_detail_events(env_name),
6340            DetailTab::Instances => self.spawn_detail_instances(env_name),
6341            DetailTab::Queue => self.spawn_detail_queues(app_name, env_name),
6342            DetailTab::Metrics => self.spawn_detail_metrics(env_name),
6343            DetailTab::Logs => self.spawn_detail_logs(env_name),
6344            DetailTab::Config => {}
6345        }
6346    }
6347
6348    fn handle_detail_search_key(&mut self, key: KeyEvent) {
6349        let Some(detail) = self.detail.as_mut() else {
6350            return;
6351        };
6352        // Pick the search target based on which tab's search is currently active.
6353        // The Logs tab carries its own search state on `log_tail` so its filter
6354        // is independent of the Events tab's filter.
6355        let on_logs = detail.log_tail.search_active;
6356        match key.code {
6357            KeyCode::Esc => {
6358                if on_logs {
6359                    detail.log_tail.search_active = false;
6360                    detail.log_tail.search_input.clear();
6361                    detail.log_tail.search_error = None;
6362                } else {
6363                    detail.search_active = false;
6364                    detail.search_input.clear();
6365                    detail.search_error = None;
6366                }
6367            }
6368            KeyCode::Enter => {
6369                if on_logs {
6370                    detail.log_tail.search_active = false;
6371                    if detail.log_tail.search_input.is_empty() {
6372                        detail.log_tail.search_pattern = None;
6373                        detail.log_tail.search_error = None;
6374                        return;
6375                    }
6376                    match regex::RegexBuilder::new(&detail.log_tail.search_input)
6377                        .case_insensitive(true)
6378                        .build()
6379                    {
6380                        Ok(r) => {
6381                            detail.log_tail.search_pattern = Some(r);
6382                            detail.log_tail.search_error = None;
6383                        }
6384                        Err(e) => {
6385                            detail.log_tail.search_pattern = None;
6386                            detail.log_tail.search_error = Some(format!("invalid regex: {e}"));
6387                        }
6388                    }
6389                    return;
6390                }
6391                detail.search_active = false;
6392                if detail.search_input.is_empty() {
6393                    detail.search_pattern = None;
6394                    detail.search_error = None;
6395                    return;
6396                }
6397                match regex::RegexBuilder::new(&detail.search_input)
6398                    .case_insensitive(true)
6399                    .build()
6400                {
6401                    Ok(r) => {
6402                        detail.search_pattern = Some(r);
6403                        detail.search_error = None;
6404                    }
6405                    Err(e) => {
6406                        detail.search_pattern = None;
6407                        detail.search_error = Some(format!("invalid regex: {e}"));
6408                    }
6409                }
6410            }
6411            KeyCode::Backspace => {
6412                if on_logs {
6413                    detail.log_tail.search_input.pop();
6414                } else {
6415                    detail.search_input.pop();
6416                }
6417            }
6418            KeyCode::Char(c) if is_text_input(&key) => {
6419                if on_logs {
6420                    detail.log_tail.search_input.push(c);
6421                } else {
6422                    detail.search_input.push(c);
6423                }
6424            }
6425            _ => {}
6426        }
6427    }
6428
6429    /// Open the in-place value editor for the Config-tab row under the
6430    /// cursor. No-op if the cursor isn't on an editable row (empty
6431    /// list). Refuses in read-only mode so the operator isn't left
6432    /// typing a value that can't be dispatched.
6433    fn start_config_edit(&mut self) {
6434        let env_name = match self.detail.as_ref() {
6435            Some(d) => d.env_name.clone(),
6436            None => return,
6437        };
6438        if self.deny_write(&env_name, "config editing") {
6439            return;
6440        }
6441        let Some(detail) = self.detail.as_mut() else {
6442            return;
6443        };
6444        let items = crate::app::config_editable_items(detail);
6445        let Some(item) = items.get(detail.config_cursor) else {
6446            self.error_message = Some("no editable config rows".into());
6447            return;
6448        };
6449        let key = item.key.clone();
6450        // Caret starts at the end of the value so the operator can
6451        // append immediately, or arrow left to edit mid-string.
6452        let caret = item.value.chars().count();
6453        detail.config_edit = Some(ConfigEdit {
6454            kind: item.kind,
6455            key: item.key.clone(),
6456            original: item.value.clone(),
6457            input: item.value.clone(),
6458            caret,
6459            mode: ConfigEditMode::Value,
6460        });
6461        self.status_message = Some(format!("editing {key} — enter saves, esc cancels"));
6462    }
6463
6464    /// Key handling while the Config-tab in-place editor is open.
6465    /// Esc cancels, Enter commits, Backspace / printable chars edit
6466    /// the value buffer. Mirrors `handle_detail_search_key`.
6467    fn handle_config_edit_key(&mut self, key: KeyEvent) {
6468        match key.code {
6469            KeyCode::Esc => {
6470                if let Some(d) = self.detail.as_mut() {
6471                    d.config_edit = None;
6472                }
6473                self.status_message = Some("config edit cancelled".into());
6474            }
6475            KeyCode::Enter => self.commit_config_edit(),
6476            KeyCode::Backspace => {
6477                if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6478                    e.backspace();
6479                }
6480            }
6481            KeyCode::Delete => {
6482                if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6483                    e.delete();
6484                }
6485            }
6486            KeyCode::Left => {
6487                if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6488                    e.move_left();
6489                }
6490            }
6491            KeyCode::Right => {
6492                if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6493                    e.move_right();
6494                }
6495            }
6496            KeyCode::Home => {
6497                if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6498                    e.move_home();
6499                }
6500            }
6501            KeyCode::End => {
6502                if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6503                    e.move_end();
6504                }
6505            }
6506            KeyCode::Char(c) if is_text_input(&key) => {
6507                if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6508                    e.insert(c);
6509                }
6510            }
6511            _ => {}
6512        }
6513    }
6514
6515    /// Commit the open Config-tab edit. All three modes dispatch via
6516    /// the same `UpdateOptionSettings` (env var) / `UpdateTags` (tag)
6517    /// paths `:env set` / `:tag` use. `Value` sets the row's new
6518    /// value (unchanged → no-op); `NewRow` parses the `KEY=VALUE`
6519    /// buffer and sets the new row; `RenameKey` sets the new key +
6520    /// removes the old in one call, carrying the row's value across.
6521    /// Clears the editor either way.
6522    fn commit_config_edit(&mut self) {
6523        let Some(edit) = self.detail.as_mut().and_then(|d| d.config_edit.take()) else {
6524            return;
6525        };
6526        let ns = "aws:elasticbeanstalk:application:environment";
6527        match edit.mode {
6528            ConfigEditMode::Value => {
6529                if edit.input == edit.original {
6530                    self.status_message = Some(format!("{} unchanged", edit.key));
6531                    return;
6532                }
6533                match edit.kind {
6534                    ConfigItemKind::EnvVar => self.spawn_option_settings_update(
6535                        format!("env set {}", edit.key),
6536                        vec![(ns.into(), edit.key.clone(), edit.input.clone())],
6537                        vec![],
6538                    ),
6539                    ConfigItemKind::Tag => {
6540                        self.spawn_tag_update(vec![(edit.key.clone(), edit.input.clone())], vec![])
6541                    }
6542                }
6543            }
6544            ConfigEditMode::NewRow => {
6545                let Some((k, v)) = crate::mode_detail::parse_new_config_row(&edit.input) else {
6546                    self.error_message = Some("new row needs KEY=VALUE (non-empty key)".into());
6547                    return;
6548                };
6549                match edit.kind {
6550                    ConfigItemKind::EnvVar => self.spawn_option_settings_update(
6551                        format!("env set {k}"),
6552                        vec![(ns.into(), k, v)],
6553                        vec![],
6554                    ),
6555                    ConfigItemKind::Tag => self.spawn_tag_update(vec![(k, v)], vec![]),
6556                }
6557            }
6558            ConfigEditMode::RenameKey => {
6559                let new_key = edit.input.trim().to_string();
6560                if new_key.is_empty() {
6561                    self.error_message = Some("rename: the new key can't be empty".into());
6562                    return;
6563                }
6564                if new_key == edit.original {
6565                    self.status_message = Some(format!("{} unchanged", edit.key));
6566                    return;
6567                }
6568                // Carry the row's current value across to the new key.
6569                let value = self.detail.as_ref().and_then(|d| {
6570                    config_editable_items(d)
6571                        .into_iter()
6572                        .find(|it| it.kind == edit.kind && it.key == edit.key)
6573                        .map(|it| it.value)
6574                });
6575                let Some(value) = value else {
6576                    self.error_message = Some("rename: the row no longer exists".into());
6577                    return;
6578                };
6579                let old = edit.key.clone();
6580                match edit.kind {
6581                    ConfigItemKind::EnvVar => self.spawn_option_settings_update(
6582                        format!("env rename {old} -> {new_key}"),
6583                        vec![(ns.into(), new_key, value)],
6584                        vec![(ns.into(), old)],
6585                    ),
6586                    ConfigItemKind::Tag => self.spawn_tag_update(vec![(new_key, value)], vec![old]),
6587                }
6588            }
6589        }
6590    }
6591
6592    /// `n` on the Config tab — open the add-a-new-row editor. The new
6593    /// row's kind (tag vs env var) is taken from the section the
6594    /// cursor currently sits in; an empty editable list defaults to
6595    /// an env var (the more common edit target). The buffer is typed
6596    /// as `KEY=VALUE`.
6597    fn start_config_add(&mut self) {
6598        let env_name = match self.detail.as_ref() {
6599            Some(d) => d.env_name.clone(),
6600            None => return,
6601        };
6602        if self.deny_write(&env_name, "config editing") {
6603            return;
6604        }
6605        let Some(detail) = self.detail.as_mut() else {
6606            return;
6607        };
6608        let items = crate::app::config_editable_items(detail);
6609        let kind = items
6610            .get(detail.config_cursor)
6611            .map(|i| i.kind)
6612            .unwrap_or(ConfigItemKind::EnvVar);
6613        detail.config_edit = Some(ConfigEdit {
6614            kind,
6615            key: String::new(),
6616            original: String::new(),
6617            input: String::new(),
6618            caret: 0,
6619            mode: ConfigEditMode::NewRow,
6620        });
6621        let what = match kind {
6622            ConfigItemKind::EnvVar => "env var",
6623            ConfigItemKind::Tag => "tag",
6624        };
6625        self.status_message = Some(format!(
6626            "new {what} — type KEY=VALUE, enter saves, esc cancels"
6627        ));
6628    }
6629
6630    /// `r` on the Config tab — open the key-rename editor for the row
6631    /// under the cursor. `input` is seeded with the current key;
6632    /// commit dispatches a remove-old + set-new (keeping the value)
6633    /// as one `UpdateOptionSettings` / `UpdateTags` call.
6634    fn start_config_rename(&mut self) {
6635        let env_name = match self.detail.as_ref() {
6636            Some(d) => d.env_name.clone(),
6637            None => return,
6638        };
6639        if self.deny_write(&env_name, "config editing") {
6640            return;
6641        }
6642        let Some(detail) = self.detail.as_mut() else {
6643            return;
6644        };
6645        let items = crate::app::config_editable_items(detail);
6646        let Some(item) = items.get(detail.config_cursor) else {
6647            self.error_message = Some("no editable config rows".into());
6648            return;
6649        };
6650        let key = item.key.clone();
6651        let caret = key.chars().count();
6652        detail.config_edit = Some(ConfigEdit {
6653            kind: item.kind,
6654            key: item.key.clone(),
6655            original: item.key.clone(),
6656            input: item.key.clone(),
6657            caret,
6658            mode: ConfigEditMode::RenameKey,
6659        });
6660        self.status_message = Some(format!(
6661            "renaming {key} — type the new key, enter saves, esc cancels"
6662        ));
6663    }
6664
6665    /// `x` on the Config tab — arm a delete of the row under the
6666    /// cursor. The actual `UpdateTags` / `UpdateOptionSettings`
6667    /// removal waits for the `y` confirmation (see the
6668    /// `config_delete_confirm` interception in the key handler).
6669    fn arm_config_delete(&mut self) {
6670        let env_name = match self.detail.as_ref() {
6671            Some(d) => d.env_name.clone(),
6672            None => return,
6673        };
6674        if self.deny_write(&env_name, "config editing") {
6675            return;
6676        }
6677        let Some(detail) = self.detail.as_mut() else {
6678            return;
6679        };
6680        let items = crate::app::config_editable_items(detail);
6681        let Some(item) = items.get(detail.config_cursor) else {
6682            self.error_message = Some("no editable config rows".into());
6683            return;
6684        };
6685        let key = item.key.clone();
6686        detail.config_delete_confirm = Some(detail.config_cursor);
6687        self.status_message = Some(format!("delete {key}? — y confirms, any other key cancels"));
6688    }
6689
6690    /// Confirmed delete of the armed Config-tab row — dispatches the
6691    /// removal (`UpdateTags` remove / `UpdateOptionSettings` remove).
6692    fn commit_config_delete(&mut self) {
6693        let Some(idx) = self
6694            .detail
6695            .as_mut()
6696            .and_then(|d| d.config_delete_confirm.take())
6697        else {
6698            return;
6699        };
6700        let Some(detail) = self.detail.as_ref() else {
6701            return;
6702        };
6703        let items = crate::app::config_editable_items(detail);
6704        let Some(item) = items.get(idx) else {
6705            self.error_message = Some("config row no longer exists".into());
6706            return;
6707        };
6708        let kind = item.kind;
6709        let key = item.key.clone();
6710        match kind {
6711            ConfigItemKind::EnvVar => {
6712                let ns = "aws:elasticbeanstalk:application:environment";
6713                self.spawn_option_settings_update(
6714                    format!("env unset {key}"),
6715                    vec![],
6716                    vec![(ns.into(), key)],
6717                );
6718            }
6719            ConfigItemKind::Tag => {
6720                self.spawn_tag_update(vec![], vec![key]);
6721            }
6722        }
6723    }
6724
6725    fn detail_search_jump(&mut self, delta: i32) {
6726        let Some(detail) = self.detail.as_mut() else {
6727            return;
6728        };
6729        let Some(re) = detail.search_pattern.as_ref() else {
6730            return;
6731        };
6732        // Search only within the *filtered* event set — `events_scroll`
6733        // is a line offset into the rendered (filtered) list, so the
6734        // jump target must be a position in that same list, not a raw
6735        // index into `detail.events`.
6736        let visible = crate::mode_detail::filter_event_indices(
6737            &detail.events,
6738            detail.events_level,
6739            detail.events_window,
6740            chrono::Utc::now(),
6741        );
6742        let n = visible.len();
6743        if n == 0 {
6744            return;
6745        }
6746        let cur = (detail.events_scroll as usize).min(n - 1);
6747        let order: Vec<usize> = if delta >= 0 {
6748            (1..=n).map(|off| (cur + off) % n).collect()
6749        } else {
6750            (1..=n).map(|off| (cur + n - off) % n).collect()
6751        };
6752        for pos in order {
6753            if re.is_match(&detail.events[visible[pos]].message) {
6754                detail.events_scroll = pos as u16;
6755                return;
6756            }
6757        }
6758    }
6759
6760    /// Cycle through saved-view chips above the env table.
6761    /// `delta = +1` → next chip; `-1` → previous; both wrap.
6762    /// "Active" is derived from comparing the current filter to
6763    /// each view's encoded `filter=` portion — matches the chip
6764    /// bar's own active-test, so cycling lands on the chip
6765    /// immediately to the right/left of whichever is currently
6766    /// applied. If no chip is active (operator typed a freeform
6767    /// filter or none at all), starts at index 0 / -1 depending
6768    /// on direction.
6769    ///
6770    /// Replaced the earlier `cycle_named_filter` (0.11 and prior)
6771    /// when saved-views unified into a single store in 0.12.
6772    /// Loading a view applies the full encoded snapshot via
6773    /// `apply_view`, so cycling can change sort / group / scope
6774    /// alongside the filter — the BACKLOG-promised "tab"
6775    /// behavior. Filter-only views (the legacy migration case)
6776    /// only change the filter, leaving sort/group/scope alone.
6777    fn cycle_saved_view(&mut self, delta: i32) {
6778        if self.saved_views.is_empty() {
6779            return;
6780        }
6781        // BTreeMap iteration is sorted by key, so the cycle order
6782        // matches the chip-bar render order. Keep them in sync.
6783        let names: Vec<String> = self.saved_views.keys().cloned().collect();
6784        let cur_idx = if self.filter.is_empty() {
6785            None
6786        } else {
6787            names.iter().position(|n| {
6788                self.saved_views
6789                    .get(n)
6790                    .map(|encoded| view_filter_value(encoded) == self.filter)
6791                    .unwrap_or(false)
6792            })
6793        };
6794        let next = match cur_idx {
6795            Some(i) => (i as i32 + delta).rem_euclid(names.len() as i32) as usize,
6796            None if delta >= 0 => 0,
6797            None => names.len() - 1,
6798        };
6799        let chosen = names[next].clone();
6800        if let Some(snap) = self.saved_views.get(&chosen).cloned() {
6801            apply_view(self, &snap);
6802            self.status_message = Some(format!("view: {chosen}"));
6803        }
6804    }
6805
6806    /// Dispatch an auto-rollback redeploy for `env_name`. Single
6807    /// source of truth for the rollback dispatch — `apply_refresh`
6808    /// calls this when an armed watchdog's deadline has passed and
6809    /// the freshly-applied env is still non-Green. Earlier shape
6810    /// had this inline in `handle_auto_rollback_check`, which read
6811    /// possibly-stale cached health; making `apply_refresh` the
6812    /// decision point eliminates that race.
6813    ///
6814    /// Caller contracts: env is in the cached fleet, env is non-
6815    /// Green, watchdog slot exists. The "no snapshot" + read-only
6816    /// gating paths are handled here (drain the watchdog + surface
6817    /// an error / status) so caller logic stays simple.
6818    pub(crate) fn dispatch_auto_rollback(&mut self, env_name: String, health: String) {
6819        // Always drain a parallel wait-for-green watcher when the
6820        // rollback fires — otherwise the subsequent Green from the
6821        // rolled-back version would pin "✓ deploy reached Green:
6822        // ENV (build-900)" even though build-900 is the version we
6823        // just rolled away from. The auto-rollback's own pin is
6824        // the signal the operator should see.
6825        self.watching_deploys.remove(&env_name);
6826        let Some(snapshot) = self.deploy_snapshots.get(&env_name).cloned() else {
6827            // pin_error so the warning survives apply_refresh's auto-
6828            // clear — when this fires *from* apply_refresh (the
6829            // common case), the unpinned error_message would
6830            // otherwise be wiped on the same tick.
6831            self.pin_error(format!(
6832                "auto-rollback for {env_name}: no pre-deploy snapshot; manual rollback required"
6833            ));
6834            self.armed_watchdogs.remove(&env_name);
6835            return;
6836        };
6837        if self.deny_write(&env_name, "auto-rollback") {
6838            self.armed_watchdogs.remove(&env_name);
6839            return;
6840        }
6841        self.armed_watchdogs.remove(&env_name);
6842        let label = snapshot.previous_version_label.clone();
6843        let aws = self.aws.clone();
6844        let tx = self.msg_tx.clone();
6845        let gen = self.generation;
6846        let account = self.context.account_id.clone();
6847        let profile = self.context.profile.clone();
6848        let region = self.context.region.clone();
6849        write_audit_line(
6850            account.as_deref(),
6851            profile.as_deref(),
6852            &region,
6853            &format!(
6854                "stage=dispatched action=AutoRollback target={env_name} version={label} health={health}"
6855            ),
6856        );
6857        self.push_pending("Auto-rollback", env_name.clone());
6858        self.pin_status(format!(
6859            "auto-rollback for {env_name}: redeploying {label} (env was {health})"
6860        ));
6861        let env_for_msg = env_name.clone();
6862        tokio::spawn(async move {
6863            let result = aws
6864                .deploy_version(&env_name, &label)
6865                .await
6866                .map_err(|e| flatten_err("deploy_version", e));
6867            let _ = tx.send(AppMsg::ActionResult {
6868                gen,
6869                action: Action::Deploy,
6870                env_name: env_for_msg,
6871                result,
6872            });
6873        });
6874    }
6875
6876    fn cycle_metrics_range(&mut self, delta: i32) {
6877        const RANGES: &[i64] = &[900, 3600, 21_600, 86_400]; // 15m / 1h / 6h / 24h
6878        let Some(d) = self.detail.as_mut() else {
6879            return;
6880        };
6881        let cur = RANGES
6882            .iter()
6883            .position(|r| *r == d.metrics_range_secs)
6884            .unwrap_or(1) as i32;
6885        let next = (cur + delta).rem_euclid(RANGES.len() as i32) as usize;
6886        d.metrics_range_secs = RANGES[next];
6887        let env_name = d.env_name.clone();
6888        self.spawn_detail_metrics(env_name);
6889    }
6890
6891    fn spawn_detail_logs(&mut self, env_name: String) {
6892        if let Some(d) = self.detail.as_mut() {
6893            // Re-entering an in-flight tail is a refresh; reset state. Existing
6894            // content is retained until the new fetch lands so the user keeps
6895            // seeing the previous tail rather than a blank screen.
6896            d.log_tail.stage = LogTailStage::Requesting;
6897            d.log_tail.poll_attempt = 0;
6898            d.log_tail.error = None;
6899        }
6900        let aws = self.aws.clone();
6901        let tx = self.msg_tx.clone();
6902        let gen = self.generation;
6903        let env_for_msg = env_name.clone();
6904        tokio::spawn(async move {
6905            let result = collect_tail_logs(aws, env_name.clone(), tx.clone(), gen).await;
6906            let _ = tx.send(AppMsg::DetailLogs {
6907                gen,
6908                env_name: env_for_msg,
6909                result,
6910            });
6911        });
6912    }
6913
6914    fn spawn_detail_metrics(&mut self, env_name: String) {
6915        let range = self
6916            .detail
6917            .as_ref()
6918            .map(|d| d.metrics_range_secs)
6919            .unwrap_or(3600);
6920        if let Some(d) = self.detail.as_mut() {
6921            d.loading_metrics = true;
6922            d.error = None;
6923        }
6924        // Snapshot the custom-metrics spec list at spawn time so concurrent
6925        // `:metric add`s don't race with the in-flight fetch.
6926        let custom: Vec<crate::aws::CustomMetricQuery> = self
6927            .custom_metrics
6928            .iter()
6929            .map(|(label, spec)| {
6930                (
6931                    label.clone(),
6932                    spec.namespace.clone(),
6933                    spec.name.clone(),
6934                    spec.stat.clone(),
6935                    spec.dimensions.clone(),
6936                )
6937            })
6938            .collect();
6939        let aws = self.aws.clone();
6940        let tx = self.msg_tx.clone();
6941        let gen = self.generation;
6942        let name = env_name.clone();
6943        tokio::spawn(async move {
6944            // Fire both queries concurrently; combine into one ordered series
6945            // list. Built-ins come first, then user metrics in add-order so
6946            // the operator sees their additions appended to the familiar
6947            // four.
6948            let (builtin, user) = tokio::join!(
6949                aws.fetch_env_metrics(&name, range),
6950                aws.fetch_custom_env_metrics(&name, range, &custom),
6951            );
6952            let result = match builtin {
6953                Ok(mut series) => {
6954                    if let Ok(extra) = user {
6955                        series.extend(extra);
6956                    }
6957                    Ok(series)
6958                }
6959                Err(e) => Err(flatten_err("fetch_env_metrics", e)),
6960            };
6961            let _ = tx.send(AppMsg::DetailMetrics {
6962                gen,
6963                env_name,
6964                result,
6965            });
6966        });
6967    }
6968
6969    /// Open the worker-queue viewer for the env in Detail mode, defaulting
6970    /// to whichever queue the caller asked for. `open_dlq` is the legacy
6971    /// shortcut that always opens the DLQ.
6972    fn open_queue_viewer(&mut self, viewing: QueueView) {
6973        let Some(detail) = self.detail.as_ref() else {
6974            return;
6975        };
6976        if detail.tab() != DetailTab::Queue {
6977            return;
6978        }
6979        let main_url = detail.queues.main_url.clone().unwrap_or_default();
6980        let dlq_url = detail.queues.dlq_url.clone().unwrap_or_default();
6981        let target_url = match viewing {
6982            QueueView::Main => main_url.clone(),
6983            QueueView::Dlq => dlq_url.clone(),
6984        };
6985        if target_url.is_empty() {
6986            self.status_message = Some(match viewing {
6987                QueueView::Main => "no main queue URL known".into(),
6988                QueueView::Dlq => "no DLQ for this env".into(),
6989            });
6990            return;
6991        }
6992        let dlq = DlqState {
6993            env_name: detail.env_name.clone(),
6994            main_queue_url: main_url,
6995            dlq_url,
6996            messages: Vec::new(),
6997            list_state: ListState::default(),
6998            loading: false,
6999            error: None,
7000            confirm_purge: false,
7001            purge_typed: String::new(),
7002            viewing,
7003            confirm_delete_idx: None,
7004            replay_input: None,
7005        };
7006        self.dlq = Some(dlq);
7007        self.mode = Mode::Dlq;
7008        self.spawn_dlq_fetch();
7009    }
7010
7011    fn open_dlq(&mut self) {
7012        let Some(detail) = self.detail.as_ref() else {
7013            return;
7014        };
7015        if detail.tab() != DetailTab::Queue {
7016            return;
7017        }
7018        let Some(dlq_url) = detail.queues.dlq_url.clone() else {
7019            self.status_message = Some("no DLQ for this env".into());
7020            return;
7021        };
7022        let main_url = detail.queues.main_url.clone().unwrap_or_default();
7023        let dlq = DlqState {
7024            env_name: detail.env_name.clone(),
7025            main_queue_url: main_url,
7026            dlq_url,
7027            messages: Vec::new(),
7028            list_state: ListState::default(),
7029            loading: false,
7030            error: None,
7031            confirm_purge: false,
7032            purge_typed: String::new(),
7033            viewing: QueueView::Dlq,
7034            confirm_delete_idx: None,
7035            replay_input: None,
7036        };
7037        self.dlq = Some(dlq);
7038        self.mode = Mode::Dlq;
7039        self.spawn_dlq_fetch();
7040    }
7041
7042    /// Open the DLQ viewer for an env outside the Detail flow — used
7043    /// when drilling in from the `:why` overlay, which already has the
7044    /// env's queue URLs from its `WhyRedQueues` fetch and shouldn't make
7045    /// the operator detour through Detail's Queue tab first.
7046    fn open_dlq_from_why(&mut self, env_name: String, main_queue_url: String, dlq_url: String) {
7047        let dlq = DlqState {
7048            env_name,
7049            main_queue_url,
7050            dlq_url,
7051            messages: Vec::new(),
7052            list_state: ListState::default(),
7053            loading: false,
7054            error: None,
7055            confirm_purge: false,
7056            purge_typed: String::new(),
7057            viewing: QueueView::Dlq,
7058            confirm_delete_idx: None,
7059            replay_input: None,
7060        };
7061        self.dlq = Some(dlq);
7062        self.mode = Mode::Dlq;
7063        self.spawn_dlq_fetch();
7064    }
7065
7066    fn close_dlq(&mut self) {
7067        self.dlq = None;
7068        self.mode = if self.detail.is_some() {
7069            Mode::Detail
7070        } else {
7071            Mode::Normal
7072        };
7073    }
7074
7075    fn spawn_dlq_fetch(&mut self) {
7076        let Some(dlq) = self.dlq.as_mut() else { return };
7077        dlq.loading = true;
7078        dlq.error = None;
7079        let env_name = dlq.env_name.clone();
7080        let queue_url = match dlq.viewing {
7081            QueueView::Dlq => dlq.dlq_url.clone(),
7082            QueueView::Main => dlq.main_queue_url.clone(),
7083        };
7084        self.spawn_aws(
7085            "peek_messages",
7086            move |aws| async move { aws.peek_messages(&queue_url, 50).await },
7087            move |gen, result| AppMsg::DlqMessages {
7088                gen,
7089                env_name,
7090                result,
7091            },
7092        );
7093    }
7094
7095    /// Delete a single message from whichever queue is currently loaded
7096    /// (`dlq.viewing`). The message's `receipt_handle` keeps it deletable
7097    /// even though our visibility timeout window is short — SQS treats the
7098    /// receipt handle as the canonical authorisation token for delete.
7099    fn spawn_dlq_delete_one(&mut self, idx: usize) {
7100        let Some(dlq) = self.dlq.as_mut() else { return };
7101        let Some(msg) = dlq.messages.get(idx).cloned() else {
7102            return;
7103        };
7104        let queue_url = match dlq.viewing {
7105            QueueView::Dlq => dlq.dlq_url.clone(),
7106            QueueView::Main => dlq.main_queue_url.clone(),
7107        };
7108        if queue_url.is_empty() {
7109            self.error_message = Some("queue URL missing — cannot delete".into());
7110            return;
7111        }
7112        let env_name = dlq.env_name.clone();
7113        let aws = self.aws.clone();
7114        let tx = self.msg_tx.clone();
7115        let gen = self.generation;
7116        write_audit_line(
7117            self.context.account_id.as_deref(),
7118            self.context.profile.as_deref(),
7119            &self.context.region,
7120            &format!(
7121                "sqs-delete env={env_name} queue={} msg_id={}",
7122                if matches!(dlq.viewing, QueueView::Main) {
7123                    "MAIN"
7124                } else {
7125                    "DLQ"
7126                },
7127                msg.id
7128            ),
7129        );
7130        tokio::spawn(async move {
7131            let result = aws
7132                .delete_message(&queue_url, &msg.receipt_handle)
7133                .await
7134                .map(|_| DlqOp::Resent {
7135                    // Reuse the existing "Resent" variant — the handler
7136                    // already drops the message by id, which is exactly what
7137                    // delete should do.
7138                    message_id: msg.id.clone(),
7139                })
7140                .map_err(|e| flatten_err("delete_message", e));
7141            let _ = tx.send(AppMsg::DlqActionResult {
7142                gen,
7143                env_name,
7144                result,
7145            });
7146        });
7147    }
7148
7149    fn handle_dlq_key(&mut self, key: KeyEvent) {
7150        let Some(dlq) = self.dlq.as_mut() else { return };
7151        // Single-message delete confirmation: Y/N inline. Anything else cancels.
7152        if let Some(idx) = dlq.confirm_delete_idx {
7153            match key.code {
7154                KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
7155                    dlq.confirm_delete_idx = None;
7156                    self.spawn_dlq_delete_one(idx);
7157                }
7158                _ => {
7159                    dlq.confirm_delete_idx = None;
7160                }
7161            }
7162            return;
7163        }
7164        // Strict-confirm mode for purge: capture text input until match.
7165        if dlq.confirm_purge {
7166            match key.code {
7167                KeyCode::Esc => {
7168                    dlq.confirm_purge = false;
7169                    dlq.purge_typed.clear();
7170                }
7171                KeyCode::Enter if dlq.purge_typed == dlq.env_name => {
7172                    let dlq_url = dlq.dlq_url.clone();
7173                    let env_name = dlq.env_name.clone();
7174                    dlq.confirm_purge = false;
7175                    dlq.purge_typed.clear();
7176                    self.spawn_dlq_purge(env_name, dlq_url);
7177                }
7178                KeyCode::Backspace => {
7179                    dlq.purge_typed.pop();
7180                }
7181                KeyCode::Char(c) if is_text_input(&key) => dlq.purge_typed.push(c),
7182                _ => {}
7183            }
7184            return;
7185        }
7186        // Time-windowed replay prompt: type a spec, Enter resolves + dispatches.
7187        if let Some(input) = dlq.replay_input.as_mut() {
7188            match key.code {
7189                KeyCode::Esc => dlq.replay_input = None,
7190                KeyCode::Enter => match crate::mode_dlq::parse_replay_spec(input) {
7191                    None => {
7192                        dlq.error = Some(
7193                            "replay: type `all`, a count (e.g. 20), or a window (1h / 24h / 7d)"
7194                                .into(),
7195                        );
7196                    }
7197                    Some(spec) => {
7198                        let idxs = crate::mode_dlq::select_replay_indices(
7199                            &dlq.messages,
7200                            &spec,
7201                            chrono::Utc::now(),
7202                        );
7203                        let msgs: Vec<_> = idxs
7204                            .iter()
7205                            .filter_map(|&i| dlq.messages.get(i).cloned())
7206                            .collect();
7207                        dlq.replay_input = None;
7208                        if msgs.is_empty() {
7209                            self.error_message = Some("replay: no messages match".into());
7210                        } else {
7211                            self.spawn_dlq_replay_batch(msgs);
7212                        }
7213                    }
7214                },
7215                KeyCode::Backspace => {
7216                    input.pop();
7217                }
7218                KeyCode::Char(c) if is_text_input(&key) => input.push(c),
7219                _ => {}
7220            }
7221            return;
7222        }
7223
7224        match key.code {
7225            KeyCode::Esc | KeyCode::Char('q') => self.close_dlq(),
7226            KeyCode::Enter => {
7227                let Some(idx) = dlq.list_state.selected() else {
7228                    return;
7229                };
7230                let Some(msg) = dlq.messages.get(idx).cloned() else {
7231                    return;
7232                };
7233                let when = msg
7234                    .sent_at
7235                    .map(|t| {
7236                        t.with_timezone(&chrono::Local)
7237                            .format("%Y-%m-%d %H:%M:%S %Z")
7238                            .to_string()
7239                    })
7240                    .unwrap_or_else(|| "—".into());
7241                let view_label = match dlq.viewing {
7242                    QueueView::Main => "Main queue",
7243                    QueueView::Dlq => "DLQ",
7244                };
7245                let body = format!(
7246                    "{view_label} message\n\
7247                     ─────────────────────────────\n\
7248                     id:           {}\n\
7249                     receive-count:{}\n\
7250                     sent:         {when}\n\
7251                     bytes:        {}\n\n\
7252                     ─ body ─\n{}\n\nesc / q to close",
7253                    msg.id,
7254                    msg.receive_count,
7255                    msg.body.len(),
7256                    msg.body
7257                );
7258                self.current_overlay = Some(Overlay::Describe(body));
7259            }
7260            KeyCode::Char('j') | KeyCode::Down => {
7261                let n = dlq.messages.len();
7262                if n == 0 {
7263                    return;
7264                }
7265                let cur = dlq.list_state.selected().unwrap_or(0);
7266                dlq.list_state.select(Some((cur + 1) % n));
7267            }
7268            KeyCode::Char('k') | KeyCode::Up => {
7269                let n = dlq.messages.len();
7270                if n == 0 {
7271                    return;
7272                }
7273                let cur = dlq.list_state.selected().unwrap_or(0);
7274                dlq.list_state.select(Some((cur + n - 1) % n));
7275            }
7276            KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
7277                self.spawn_dlq_fetch();
7278            }
7279            KeyCode::Char('r') => {
7280                if matches!(dlq.viewing, QueueView::Main) {
7281                    self.error_message = Some("resend is only available in DLQ view".into());
7282                } else {
7283                    self.spawn_dlq_resend_selected();
7284                }
7285            }
7286            KeyCode::Char('R') => {
7287                if matches!(dlq.viewing, QueueView::Main) {
7288                    self.error_message = Some("replay is only available in DLQ view".into());
7289                } else if dlq.messages.is_empty() {
7290                    self.error_message = Some("replay: DLQ is empty".into());
7291                } else {
7292                    dlq.replay_input = Some(String::new());
7293                    dlq.error = None;
7294                }
7295            }
7296            KeyCode::Char('m') => {
7297                // Toggle which queue is loaded. Main-queue view disables
7298                // resend/purge (too dangerous on a live queue). Refetch on switch.
7299                if dlq.main_queue_url.is_empty() {
7300                    self.error_message = Some("no main queue URL known".into());
7301                } else {
7302                    dlq.viewing = match dlq.viewing {
7303                        QueueView::Dlq => QueueView::Main,
7304                        QueueView::Main => QueueView::Dlq,
7305                    };
7306                    dlq.messages.clear();
7307                    dlq.list_state.select(None);
7308                    self.spawn_dlq_fetch();
7309                }
7310            }
7311            KeyCode::Char('x') => {
7312                // Single-message delete. The dispatch loop catches y/n in the
7313                // next iteration via `confirm_delete_idx`.
7314                if let Some(idx) = dlq.list_state.selected() {
7315                    if dlq.messages.get(idx).is_some() {
7316                        dlq.confirm_delete_idx = Some(idx);
7317                    }
7318                }
7319            }
7320            KeyCode::Char('p') => {
7321                if let Some(dlq) = self.dlq.as_mut() {
7322                    dlq.confirm_purge = true;
7323                    dlq.purge_typed.clear();
7324                }
7325            }
7326            _ => {}
7327        }
7328    }
7329
7330    fn spawn_dlq_resend_selected(&mut self) {
7331        let env_name = match self.dlq.as_ref() {
7332            Some(d) => d.env_name.clone(),
7333            None => return,
7334        };
7335        if self.deny_write(&env_name, "resend") {
7336            return;
7337        }
7338        let Some(dlq) = self.dlq.as_mut() else { return };
7339        let Some(idx) = dlq.list_state.selected() else {
7340            return;
7341        };
7342        let Some(msg) = dlq.messages.get(idx).cloned() else {
7343            return;
7344        };
7345        if dlq.main_queue_url.is_empty() {
7346            dlq.error = Some("main queue URL unknown — cannot resend".into());
7347            return;
7348        }
7349        let aws = self.aws.clone();
7350        let tx = self.msg_tx.clone();
7351        let gen = self.generation;
7352        let env_name = dlq.env_name.clone();
7353        let main_url = dlq.main_queue_url.clone();
7354        let dlq_url = dlq.dlq_url.clone();
7355        write_audit_line(
7356            self.context.account_id.as_deref(),
7357            self.context.profile.as_deref(),
7358            &self.context.region,
7359            &format!("dlq-resend env={env_name} msg_id={}", msg.id),
7360        );
7361        tokio::spawn(async move {
7362            let result = match aws.send_message(&main_url, &msg.body).await {
7363                Ok(()) => match aws.delete_message(&dlq_url, &msg.receipt_handle).await {
7364                    Ok(()) => Ok(DlqOp::Resent {
7365                        message_id: msg.id.clone(),
7366                    }),
7367                    Err(e) => {
7368                        tracing::error!(target: "ebman::aws", op = "dlq_delete_after_send", error = ?e, "aws call failed");
7369                        Err(format!("sent to main queue, but DLQ delete failed: {e}"))
7370                    }
7371                },
7372                Err(e) => {
7373                    tracing::error!(target: "ebman::aws", op = "dlq_send", error = ?e, "aws call failed");
7374                    Err(format!("send to main queue failed: {e}"))
7375                }
7376            };
7377            let _ = tx.send(AppMsg::DlqActionResult {
7378                gen,
7379                env_name,
7380                result,
7381            });
7382        });
7383    }
7384
7385    fn spawn_dlq_purge(&mut self, env_name: String, dlq_url: String) {
7386        if self.deny_write(&env_name, "purge") {
7387            return;
7388        }
7389        write_audit_line(
7390            self.context.account_id.as_deref(),
7391            self.context.profile.as_deref(),
7392            &self.context.region,
7393            &format!("dlq-purge env={env_name}"),
7394        );
7395        let aws = self.aws.clone();
7396        let tx = self.msg_tx.clone();
7397        let gen = self.generation;
7398        tokio::spawn(async move {
7399            let result = aws
7400                .purge_queue(&dlq_url)
7401                .await
7402                .map(|_| DlqOp::Purged)
7403                .map_err(|e| flatten_err("purge_queue", e));
7404            let _ = tx.send(AppMsg::DlqActionResult {
7405                gen,
7406                env_name,
7407                result,
7408            });
7409        });
7410    }
7411
7412    /// Batch DLQ replay: for each message, send the body to the main queue
7413    /// then delete it from the DLQ. A send failure (or a delete failure
7414    /// after a successful send) counts toward `failures` and is logged;
7415    /// the batch keeps going. Result lands as `DlqOp::Replayed`.
7416    fn spawn_dlq_replay_batch(&mut self, messages: Vec<crate::aws::QueueMessage>) {
7417        let env_name = match self.dlq.as_ref() {
7418            Some(d) => d.env_name.clone(),
7419            None => return,
7420        };
7421        if self.deny_write(&env_name, "replay") {
7422            return;
7423        }
7424        let Some(dlq) = self.dlq.as_ref() else { return };
7425        if matches!(dlq.viewing, QueueView::Main) {
7426            self.error_message = Some("replay is only available in DLQ view".into());
7427            return;
7428        }
7429        if dlq.main_queue_url.is_empty() {
7430            self.error_message = Some("main queue URL unknown — cannot replay".into());
7431            return;
7432        }
7433        let main_url = dlq.main_queue_url.clone();
7434        let dlq_url = dlq.dlq_url.clone();
7435        let env_name = dlq.env_name.clone();
7436        let aws = self.aws.clone();
7437        let tx = self.msg_tx.clone();
7438        let gen = self.generation;
7439        let count = messages.len();
7440        write_audit_line(
7441            self.context.account_id.as_deref(),
7442            self.context.profile.as_deref(),
7443            &self.context.region,
7444            &format!("dlq-replay env={env_name} count={count}"),
7445        );
7446        self.status_message = Some(format!("replaying {count} message(s) to the main queue…"));
7447        tokio::spawn(async move {
7448            let mut failures = 0usize;
7449            for msg in &messages {
7450                match aws.send_message(&main_url, &msg.body).await {
7451                    Ok(()) => {
7452                        if let Err(e) = aws.delete_message(&dlq_url, &msg.receipt_handle).await {
7453                            tracing::error!(target: "ebman::aws", op = "dlq_replay_delete", error = ?e, msg_id = %msg.id, "DLQ delete after send failed");
7454                            failures += 1;
7455                        }
7456                    }
7457                    Err(e) => {
7458                        tracing::error!(target: "ebman::aws", op = "dlq_replay_send", error = ?e, msg_id = %msg.id, "send to main queue failed");
7459                        failures += 1;
7460                    }
7461                }
7462            }
7463            let result = Ok(DlqOp::Replayed {
7464                count: count - failures,
7465                failures,
7466            });
7467            let _ = tx.send(AppMsg::DlqActionResult {
7468                gen,
7469                env_name,
7470                result,
7471            });
7472        });
7473    }
7474
7475    fn spawn_detail_queues(&mut self, application_name: String, env_name: String) {
7476        if let Some(d) = self.detail.as_mut() {
7477            d.loading_queues = true;
7478            d.error = None;
7479        }
7480        if self.demo_mode {
7481            let result = Ok(crate::demo_fixture::worker_queues_for_env(&env_name));
7482            let gen = self.generation;
7483            let _ = self.msg_tx.send(AppMsg::DetailQueues {
7484                gen,
7485                env_name,
7486                result,
7487            });
7488            return;
7489        }
7490        let env_for_msg = env_name.clone();
7491        self.spawn_aws(
7492            "describe_worker_queues",
7493            move |aws| async move {
7494                aws.describe_worker_queues(&application_name, &env_name)
7495                    .await
7496            },
7497            move |gen, result| AppMsg::DetailQueues {
7498                gen,
7499                env_name: env_for_msg,
7500                result,
7501            },
7502        );
7503    }
7504
7505    fn spawn_detail_events(&mut self, env_name: String) {
7506        if let Some(d) = self.detail.as_mut() {
7507            d.loading_events = true;
7508            d.error = None;
7509        }
7510        // Demo-mode short-circuit: filter the fixture's fleet-wide
7511        // events down to this env, mirror what list_events_for_env
7512        // would have returned. Same channel + msg variant the live
7513        // path uses, so the rest of the rendering pipeline is
7514        // untouched.
7515        if self.demo_mode {
7516            let result = Ok(crate::demo_fixture::events_for_env(&env_name));
7517            let gen = self.generation;
7518            let _ = self.msg_tx.send(AppMsg::DetailEvents {
7519                gen,
7520                env_name,
7521                result,
7522            });
7523            return;
7524        }
7525        let env_for_msg = env_name.clone();
7526        self.spawn_aws(
7527            "list_events_for_env",
7528            move |aws| async move { aws.list_events_for_env(&env_name, 50).await },
7529            move |gen, result| AppMsg::DetailEvents {
7530                gen,
7531                env_name: env_for_msg,
7532                result,
7533            },
7534        );
7535    }
7536
7537    fn target_env_for_action(&self) -> Option<Environment> {
7538        // Detail view targets the env it was opened on; Normal view targets selection.
7539        if let Some(d) = self.detail.as_ref() {
7540            return Some(d.env_snapshot.clone());
7541        }
7542        self.selected_env().cloned()
7543    }
7544
7545    fn open_action_menu(&mut self) {
7546        let Some(target) = self.target_env_for_action() else {
7547            self.status_message = Some("no env selected".into());
7548            return;
7549        };
7550        if self.deny_write(&target.name, "action menu") {
7551            return;
7552        }
7553        let mut list_state = ListState::default();
7554        list_state.select(Some(0));
7555        self.action_flow = Some(ActionFlow::Menu { list_state });
7556        self.mode = Mode::Action;
7557    }
7558
7559    fn close_action_flow(&mut self) {
7560        self.action_flow = None;
7561        if self.detail.is_some() {
7562            self.mode = Mode::Detail;
7563        } else {
7564            self.mode = Mode::Normal;
7565        }
7566    }
7567
7568    /// Open a modal form. Captures the env at open-time (so later main-table
7569    /// cursor moves don't redirect the submit), spawns a
7570    /// `DescribeConfigurationSettings` fetch to pre-fill values, and flips
7571    /// to `Mode::Form`. The form stays in `FormState::Loading` until the
7572    /// `FormPrefilled` AppMsg lands.
7573    fn open_form(&mut self, mut form: crate::form::Form) {
7574        // LocalConfig forms don't need an AWS pre-fill — the caller has
7575        // already populated the field values from the live `App` state.
7576        // Skip the DescribeConfigurationSettings round-trip and go straight
7577        // to Ready so the user can type immediately.
7578        if matches!(form.submit, crate::form::FormSubmit::LocalConfig) {
7579            form.state = crate::form::FormState::Ready;
7580            self.form = Some(form);
7581            self.mode = Mode::Form;
7582            return;
7583        }
7584        let env_name = form.env_name.clone();
7585        // Look up the env's application from the live env list. We need it
7586        // for DescribeConfigurationSettings; the form itself only knows the
7587        // env name.
7588        let app_name = match self.environments.iter().find(|e| e.name == env_name) {
7589            Some(e) => e.application.clone(),
7590            None => {
7591                self.error_message = Some(format!("env '{env_name}' not in current list"));
7592                return;
7593            }
7594        };
7595        self.form = Some(form);
7596        self.mode = Mode::Form;
7597        let aws = self.aws.clone();
7598        let tx = self.msg_tx.clone();
7599        let gen = self.generation;
7600        let env_for_msg = env_name.clone();
7601        tokio::spawn(async move {
7602            let settings = aws
7603                .fetch_env_option_settings(&app_name, &env_for_msg)
7604                .await
7605                .map_err(|e| flatten_err("fetch_env_option_settings", e));
7606            let _ = tx.send(AppMsg::FormPrefilled {
7607                gen,
7608                env_name: env_for_msg,
7609                settings,
7610            });
7611        });
7612    }
7613
7614    /// Key handler for `Mode::Form`. Loading-state forms ignore input
7615    /// (operator waits for the pre-fill); Ready forms route through Tab /
7616    /// arrow nav + per-field input; Submitting forms ignore input (waiting
7617    /// for the AppMsg::OptionSettingsUpdate that lands the result).
7618    fn handle_form_key(&mut self, key: KeyEvent) {
7619        use crate::form::{FieldKind, FormState};
7620        // Resolve current state before borrowing the form mutably so the
7621        // submit branch can dispatch through self.
7622        let state = self.form.as_ref().map(|f| f.state.clone());
7623        let cursor_kind = self
7624            .form
7625            .as_ref()
7626            .and_then(|f| f.current_field().map(|fld| fld.kind.clone()));
7627        match state {
7628            None => return,
7629            Some(FormState::Loading) | Some(FormState::Submitting) => {
7630                if matches!(key.code, KeyCode::Esc) {
7631                    self.form = None;
7632                    self.mode = Mode::Normal;
7633                }
7634                return;
7635            }
7636            Some(FormState::Ready) => {}
7637        }
7638        // Submit shortcut works regardless of focused-field kind.
7639        if matches!(key.code, KeyCode::Char('s')) && key.modifiers.contains(KeyModifiers::CONTROL) {
7640            self.submit_form();
7641            return;
7642        }
7643        if matches!(key.code, KeyCode::Esc) {
7644            self.form = None;
7645            self.mode = Mode::Normal;
7646            return;
7647        }
7648        // Field navigation that's always available: Tab, Shift-Tab, Up, Down.
7649        // Up/Down would conflict with vim-style j/k inside text input — we
7650        // don't bind j/k for nav inside the form. Exception: when the
7651        // focused field is a MultiSelect, Up/Down (and j/k) move the
7652        // *option cursor* within the field rather than between fields;
7653        // Tab/Shift-Tab still leave the field.
7654        let is_multi = matches!(cursor_kind.as_ref(), Some(FieldKind::MultiSelect { .. }));
7655        let between_fields = match key.code {
7656            KeyCode::Tab => Some(1),
7657            KeyCode::BackTab => Some(-1),
7658            KeyCode::Up | KeyCode::Down if !is_multi => {
7659                if matches!(key.code, KeyCode::Up) {
7660                    Some(-1)
7661                } else {
7662                    Some(1)
7663                }
7664            }
7665            _ => None,
7666        };
7667        if let Some(delta) = between_fields {
7668            if let Some(form) = self.form.as_mut() {
7669                form.move_cursor(delta);
7670            }
7671            return;
7672        }
7673        // In-field option-cursor movement for MultiSelect fields. Wraps
7674        // around the option list both ways.
7675        if is_multi
7676            && matches!(
7677                key.code,
7678                KeyCode::Up | KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('k')
7679            )
7680        {
7681            if let Some(form) = self.form.as_mut() {
7682                if let Some(field) = form.current_field_mut() {
7683                    if let FieldKind::MultiSelect { options } = &field.kind {
7684                        let n = options.len();
7685                        if n > 0 {
7686                            let delta: isize =
7687                                matches!(key.code, KeyCode::Down | KeyCode::Char('j')) as isize * 2
7688                                    - 1;
7689                            let cur = field.option_cursor as isize;
7690                            let next = ((cur + delta) % n as isize + n as isize) % n as isize;
7691                            field.option_cursor = next as usize;
7692                        }
7693                    }
7694                }
7695            }
7696            return;
7697        }
7698        // Per-kind editing on the focused field.
7699        let Some(form) = self.form.as_mut() else {
7700            return;
7701        };
7702        let Some(field) = form.current_field_mut() else {
7703            return;
7704        };
7705        // Live-revalidate after every edit so the inline error clears as the
7706        // operator fixes it.
7707        match (cursor_kind.unwrap_or(FieldKind::Text), key.code) {
7708            (FieldKind::Text, KeyCode::Backspace) => {
7709                field.value.pop();
7710            }
7711            (FieldKind::Text, KeyCode::Char(c)) if is_text_input(&key) => {
7712                field.value.push(c);
7713            }
7714            (FieldKind::Integer { .. }, KeyCode::Backspace) => {
7715                field.value.pop();
7716            }
7717            (FieldKind::Integer { .. }, KeyCode::Char(c))
7718                if c.is_ascii_digit() || (c == '-' && field.value.is_empty()) =>
7719            {
7720                field.value.push(c);
7721            }
7722            (FieldKind::Boolean, KeyCode::Char(' ')) => {
7723                field.value = if field.value == "true" {
7724                    "false".into()
7725                } else {
7726                    "true".into()
7727                };
7728            }
7729            (FieldKind::Boolean, KeyCode::Char('t')) => {
7730                field.value = "true".into();
7731            }
7732            (FieldKind::Boolean, KeyCode::Char('f')) => {
7733                field.value = "false".into();
7734            }
7735            (FieldKind::Select { options }, KeyCode::Left)
7736            | (FieldKind::Select { options }, KeyCode::Char('h')) => {
7737                let i = options.iter().position(|o| o == &field.value).unwrap_or(0);
7738                let next = (i + options.len() - 1) % options.len();
7739                field.value = options[next].clone();
7740            }
7741            (FieldKind::Select { options }, KeyCode::Right)
7742            | (FieldKind::Select { options }, KeyCode::Char('l')) => {
7743                let i = options.iter().position(|o| o == &field.value).unwrap_or(0);
7744                let next = (i + 1) % options.len();
7745                field.value = options[next].clone();
7746            }
7747            (FieldKind::MultiSelect { options }, KeyCode::Char(' ')) => {
7748                if let Some(opt) = options.get(field.option_cursor) {
7749                    field.value = crate::form::toggle_multi(&field.value, opt);
7750                }
7751            }
7752            _ => {}
7753        }
7754        // Clear stale error on this field after any edit.
7755        let _ = crate::form::validate_field(&field.value, &field.kind).map(|_| field.error = None);
7756    }
7757
7758    /// Validate the form; if good, dispatch via the existing option-settings
7759    /// helper and switch to Submitting. Failures keep the form open with
7760    /// per-field error messages.
7761    fn submit_form(&mut self) {
7762        let Some(form) = self.form.as_mut() else {
7763            return;
7764        };
7765        if let Err(failing) = form.validate() {
7766            form.cursor = failing[0];
7767            return;
7768        }
7769        // LocalConfig submits write `config.toml` and apply changes live to
7770        // the running App. No AWS round-trip, so close out immediately.
7771        if matches!(form.submit, crate::form::FormSubmit::LocalConfig) {
7772            self.submit_local_config();
7773            return;
7774        }
7775        let env_name = form.env_name.clone();
7776        let summary = form.summary.clone();
7777        let (to_set, to_remove) = form.to_option_settings();
7778        form.state = crate::form::FormState::Submitting;
7779        // We can't reuse spawn_option_settings_update directly because it
7780        // reads self.selected_env() for the env_name; the form captured its
7781        // env at open time so we dispatch by-value here. Inlining keeps the
7782        // form's env binding authoritative.
7783        if self.deny_write(&env_name, "form submit") {
7784            self.form = None;
7785            self.mode = Mode::Normal;
7786            return;
7787        }
7788        if to_set.is_empty() && to_remove.is_empty() {
7789            self.status_message = Some("no changes to apply".into());
7790            self.form = None;
7791            self.mode = Mode::Normal;
7792            return;
7793        }
7794        write_audit_line(
7795            self.context.account_id.as_deref(),
7796            self.context.profile.as_deref(),
7797            &self.context.region,
7798            &format!(
7799                "stage=dispatched action=UpdateOptionSettings target={env_name} summary=\"{summary}\""
7800            ),
7801        );
7802        self.push_pending(summary.clone(), env_name.clone());
7803        // No status_message ack here — the pending-actions pill in the
7804        // header (`⏳ N`) is the truth-source for in-flight work, and a
7805        // status_message ack would just race with whatever the operator
7806        // last set there. Completion fires a Success / Error toast.
7807        let aws = self.aws.clone();
7808        let tx = self.msg_tx.clone();
7809        let gen = self.generation;
7810        let env_for_msg = env_name.clone();
7811        let summary_for_msg = summary.clone();
7812        let account = self.context.account_id.clone();
7813        let profile = self.context.profile.clone();
7814        let region = self.context.region.clone();
7815        // Undo capture — same shape as `spawn_option_settings_update`.
7816        // The form path lost the env's application name when it
7817        // stashed only `env_name`; recover it by looking up the
7818        // env in the cached fleet. Race with context switch leaves
7819        // `app_for_undo` as None and we silently skip capture.
7820        let app_for_undo = self
7821            .environments
7822            .iter()
7823            .find(|e| e.name == env_name)
7824            .map(|e| e.application.clone());
7825        let env_for_undo = env_name.clone();
7826        let summary_for_undo = summary.clone();
7827        let to_set_for_undo = to_set.clone();
7828        let to_remove_for_undo = to_remove.clone();
7829        tokio::spawn(async move {
7830            let undo_entry = if let Some(app_name) = app_for_undo {
7831                match aws
7832                    .fetch_env_option_settings(&app_name, &env_for_undo)
7833                    .await
7834                {
7835                    Ok(opts) => Some(build_undo_entry(
7836                        &env_for_undo,
7837                        &summary_for_undo,
7838                        &to_set_for_undo,
7839                        &to_remove_for_undo,
7840                        &opts,
7841                    )),
7842                    Err(_) => None,
7843                }
7844            } else {
7845                None
7846            };
7847            let result = aws
7848                .update_env_option_settings(&env_for_msg, &to_set, &to_remove)
7849                .await
7850                .map_err(|e| flatten_err("update_env_option_settings", e));
7851            let outcome = match &result {
7852                Ok(()) => format!(
7853                    "stage=completed action=UpdateOptionSettings target={env_for_msg} summary=\"{summary_for_msg}\" outcome=ok"
7854                ),
7855                Err(e) => format!(
7856                    "stage=completed action=UpdateOptionSettings target={env_for_msg} summary=\"{summary_for_msg}\" outcome=err err=\"{}\"",
7857                    crate::audit::escape_value(e)
7858                ),
7859            };
7860            write_audit_line(account.as_deref(), profile.as_deref(), &region, &outcome);
7861            if result.is_ok() {
7862                if let Some(entry) = undo_entry {
7863                    let _ = tx.send(AppMsg::UndoCaptured { gen, entry });
7864                }
7865            }
7866            let _ = tx.send(AppMsg::OptionSettingsUpdate {
7867                gen,
7868                env_name: env_for_msg,
7869                summary: summary_for_msg,
7870                result,
7871            });
7872        });
7873        // Close the form so the user returns to wherever they were.
7874        // OptionSettingsUpdate handler will fire a toast on completion.
7875        self.form = None;
7876        self.mode = Mode::Normal;
7877    }
7878
7879    /// Apply a [`crate::form::FormSubmit::LocalConfig`] submit: render the
7880    /// form values back into a [`Config`], write it to disk, and update the
7881    /// live `App` state so theme / icons / refresh interval changes take
7882    /// effect immediately. Other fields (notify_bell, required_tags,
7883    /// redact, grouped, extra_regions) are updated in place but
7884    /// only take effect on the next refresh / restart depending on what
7885    /// reads them — see the field docs.
7886    fn submit_local_config(&mut self) {
7887        let Some(form) = self.form.as_ref() else {
7888            return;
7889        };
7890        let snapshot = self.current_config_snapshot();
7891        let updated = form.apply_to_config(&snapshot);
7892        match crate::config::save(&updated) {
7893            Ok(()) => {
7894                let path = crate::config::config_path();
7895                self.apply_config_live(&updated);
7896                self.pin_status(format!("settings saved → {}", path.display()));
7897            }
7898            Err(e) => {
7899                self.error_message = Some(format!("settings save failed: {e}"));
7900            }
7901        }
7902        self.form = None;
7903        self.mode = Mode::Normal;
7904    }
7905
7906    /// Build the `:settings` form pre-filled from the live App state and
7907    /// Open the `:subnets` MultiSelect form: lists subnets in the env's
7908    /// VPC via `DescribeSubnets`, pre-fills with the env's current
7909    /// `aws:ec2:vpc.Subnets` selection, submits via the shared
7910    /// option-settings update path. Bound to the env table cursor —
7911    /// reports an error and bails if no env is selected.
7912    fn open_subnets_form(&mut self) {
7913        self.open_multi_select_form(MultiSelectFlavour::Subnets);
7914    }
7915
7916    /// Open the `:elb-subnets` MultiSelect form. Same EC2 list call as
7917    /// `:subnets` but targets `aws:ec2:vpc.ELBSubnets` — the option
7918    /// setting that controls which subnets the env's ELB attaches to.
7919    /// Web-tier only; worker-tier envs leave this empty.
7920    fn open_elb_subnets_form(&mut self) {
7921        self.open_multi_select_form(MultiSelectFlavour::ElbSubnets);
7922    }
7923
7924    /// Open the `:security-groups` MultiSelect form. Same shape as
7925    /// `:subnets` but lists security groups in the env's VPC and
7926    /// targets `aws:autoscaling:launchconfiguration.SecurityGroups`.
7927    fn open_security_groups_form(&mut self) {
7928        self.open_multi_select_form(MultiSelectFlavour::SecurityGroups);
7929    }
7930
7931    /// Shared open + async-load path for the two MultiSelect pickers.
7932    /// Opens the form in `Loading` state with an empty option list,
7933    /// then spawns a tokio task that fans out to fetch the VPC context
7934    /// (via DescribeConfigurationSettings) and the EC2 listing
7935    /// (DescribeSubnets / DescribeSecurityGroups). The result lands as
7936    /// `AppMsg::FormMultiSelectLoaded` which the handler matches by
7937    /// `field_key` to populate the form.
7938    fn open_multi_select_form(&mut self, flavour: MultiSelectFlavour) {
7939        use crate::form::{Form, FormField, FormSubmit};
7940        let Some(env) = self.selected_env().cloned() else {
7941            self.error_message = Some("no env selected".into());
7942            return;
7943        };
7944        let (title_prefix, summary, field_key, label, ns, opt_name) = match flavour {
7945            MultiSelectFlavour::Subnets => (
7946                "subnets",
7947                "subnets update",
7948                "subnets",
7949                "Subnets",
7950                "aws:ec2:vpc",
7951                "Subnets",
7952            ),
7953            MultiSelectFlavour::ElbSubnets => (
7954                "elb-subnets",
7955                "elb-subnets update",
7956                "elb_subnets",
7957                "ELB subnets",
7958                "aws:ec2:vpc",
7959                "ELBSubnets",
7960            ),
7961            MultiSelectFlavour::SecurityGroups => (
7962                "security-groups",
7963                "security-groups update",
7964                "security_groups",
7965                "Security groups",
7966                "aws:autoscaling:launchconfiguration",
7967                "SecurityGroups",
7968            ),
7969        };
7970        let placeholder = FormField::multi_select(
7971            field_key,
7972            label,
7973            Vec::new(),
7974            Vec::new(),
7975            Some::<String>("space toggle · ↑↓ option cursor · tab field".into()),
7976        );
7977        let form = Form::loading(
7978            format!("{title_prefix} — {}", env.name),
7979            env.name.clone(),
7980            summary.to_string(),
7981            vec![placeholder],
7982            FormSubmit::OptionSettings {
7983                mappings: vec![(field_key.into(), ns.into(), opt_name.into())],
7984            },
7985        );
7986        // open_form would dispatch the default DescribeConfigurationSettings
7987        // pre-fill, which doesn't load EC2 inventory. Bypass it: stash the
7988        // form ourselves and spawn the multi-select-specific loader.
7989        self.form = Some(form);
7990        self.mode = Mode::Form;
7991        let aws = self.aws.clone();
7992        let tx = self.msg_tx.clone();
7993        let gen = self.generation;
7994        let env_for_msg = env.name.clone();
7995        let app_name = env.application.clone();
7996        let field_key_for_msg = field_key.to_string();
7997        tokio::spawn(async move {
7998            let result = load_multi_select(aws, &app_name, &env_for_msg, flavour).await;
7999            let _ = tx.send(AppMsg::FormMultiSelectLoaded {
8000                gen,
8001                env_name: env_for_msg,
8002                field_key: field_key_for_msg,
8003                result,
8004            });
8005        });
8006    }
8007
8008    /// Open the `:settings` form pre-filled from the live App state and
8009    /// open it. Submit writes `config.toml` and live-applies any field
8010    /// that can change at runtime (see [`App::apply_config_live`]).
8011    fn open_settings_form(&mut self) {
8012        use crate::form::{Form, FormField, FormSubmit};
8013        let snapshot = self.current_config_snapshot();
8014        let bool_select = vec!["true".to_string(), "false".to_string()];
8015        let triple_select = vec!["auto".to_string(), "true".to_string(), "false".to_string()];
8016        let mut fields: Vec<FormField> = Vec::new();
8017        // Theme — present the known names as a select; user can still
8018        // type-edit via the value field if they prefer a wider list later.
8019        let theme_options = vec![
8020            "dark".to_string(),
8021            "light".to_string(),
8022            "high-contrast".to_string(),
8023        ];
8024        let mut theme_field = FormField::select(
8025            "theme",
8026            "Theme",
8027            theme_options.clone(),
8028            Some::<String>("dark / light / high-contrast".into()),
8029        );
8030        // Pre-fill from current Config. Theme name is always one of the
8031        // known options at this point — App::new normalises unknown names
8032        // back to `dark`. Fall back to the first option defensively in
8033        // case a future theme is added without updating this list.
8034        theme_field.value = if theme_options.iter().any(|o| o == &snapshot.theme) {
8035            snapshot.theme.clone()
8036        } else {
8037            theme_options[0].clone()
8038        };
8039        fields.push(theme_field);
8040
8041        let icons_options = vec![
8042            "unicode".to_string(),
8043            "ascii".to_string(),
8044            "powerline".to_string(),
8045            "auto".to_string(),
8046        ];
8047        let mut icons_field = FormField::select(
8048            "icons",
8049            "Icons",
8050            icons_options.clone(),
8051            Some::<String>("auto = probe the terminal at startup".into()),
8052        );
8053        icons_field.value = if icons_options
8054            .iter()
8055            .any(|o| o.eq_ignore_ascii_case(&snapshot.icons))
8056        {
8057            snapshot.icons.to_ascii_lowercase()
8058        } else {
8059            "unicode".to_string()
8060        };
8061        fields.push(icons_field);
8062
8063        let mut refresh_field = FormField::integer(
8064            "refresh_interval_secs",
8065            "Refresh interval (s)",
8066            Some("How often the env list reloads from AWS"),
8067            Some(5),
8068            Some(600),
8069            false,
8070        );
8071        refresh_field.value = snapshot.refresh_interval.as_secs().to_string();
8072        fields.push(refresh_field);
8073
8074        // redact_default and grouped_default are Option<bool> → use a
8075        // three-way select.
8076        let mut redact_field = FormField::select(
8077            "redact_default",
8078            "Redact by default",
8079            triple_select.clone(),
8080            Some::<String>("auto leaves the toggle to per-session state".into()),
8081        );
8082        redact_field.value = match snapshot.redact_default {
8083            None => "auto".into(),
8084            Some(true) => "true".into(),
8085            Some(false) => "false".into(),
8086        };
8087        fields.push(redact_field);
8088
8089        let mut grouped_field = FormField::select(
8090            "grouped_default",
8091            "Group by app by default",
8092            triple_select,
8093            Some::<String>("auto leaves the toggle to per-session state".into()),
8094        );
8095        grouped_field.value = match snapshot.grouped_default {
8096            None => "auto".into(),
8097            Some(true) => "true".into(),
8098            Some(false) => "false".into(),
8099        };
8100        fields.push(grouped_field);
8101
8102        let mut notify_field = FormField::select(
8103            "notify_bell",
8104            "Bell on new Red",
8105            bool_select,
8106            Some::<String>("ring BEL when an env transitions into Red".into()),
8107        );
8108        notify_field.value = if snapshot.notify_bell {
8109            "true".into()
8110        } else {
8111            "false".into()
8112        };
8113        fields.push(notify_field);
8114
8115        let mut tags_field = FormField::text(
8116            "required_tags",
8117            "Required tags",
8118            Some::<String>("comma-separated; surfaced in :report".into()),
8119        );
8120        tags_field.value = snapshot.required_tags.join(",");
8121        fields.push(tags_field);
8122
8123        let mut regions_field = FormField::text(
8124            "extra_regions",
8125            "Extra regions",
8126            Some::<String>("comma-separated; appended to :region picker".into()),
8127        );
8128        regions_field.value = snapshot.extra_regions.join(",");
8129        fields.push(regions_field);
8130
8131        let form = Form::loading(
8132            "settings",
8133            String::new(),
8134            "settings".to_string(),
8135            fields,
8136            FormSubmit::LocalConfig,
8137        );
8138        self.open_form(form);
8139    }
8140
8141    /// Build a [`Config`] from the App's current state. Used by the
8142    /// `:settings` form for pre-fill and as the base the form's edited
8143    /// fields are merged onto before writing back to disk.
8144    fn current_config_snapshot(&self) -> Config {
8145        Config {
8146            refresh_interval: self.refresh_interval,
8147            extra_regions: self.extra_regions.clone(),
8148            redact_default: Some(self.redact),
8149            grouped_default: Some(self.grouped),
8150            // Snapshot the BASE theme name, not the currently-applied one;
8151            // otherwise a profile-overridden theme would persist as the
8152            // new default and erase the operator's per-profile mapping.
8153            theme: self.base_theme_name.clone(),
8154            icons: self.cfg_icons_raw.clone(),
8155            notify_bell: self.notify_bell,
8156            required_tags: self.required_tags.clone(),
8157            profile_themes: self.profile_themes.clone(),
8158            // Accounts live in config.toml only — :settings doesn't
8159            // surface them in the form (the assume-role schema would
8160            // need its own editor), so the snapshot just preserves
8161            // whatever was loaded.
8162            accounts: self.accounts.clone(),
8163            runbooks: self.runbooks.clone(),
8164            safety_envs: self.safety_envs.clone(),
8165            safety_accounts: self.safety_accounts.clone(),
8166            notify_webhook: self.notify_webhook.clone(),
8167            command_aliases: self.command_aliases.clone(),
8168            lint_disable: self.lint_disable.clone(),
8169            // `lint.fix_disable` is a CLI-only knob (no TUI surface
8170            // consumes it; `ebman lint --fix` reads via
8171            // `config::load_lint_fix_disables` directly). We re-read
8172            // from disk on snapshot so `:settings save` doesn't
8173            // silently drop the existing line.
8174            lint_fix_disable: crate::config::load_lint_fix_disables(),
8175            explain_enabled: self.explain_enabled,
8176            explain_provider: self.explain_provider.clone(),
8177            explain_model: self.explain_model.clone(),
8178            explain_api_key_env: self.explain_api_key_env.clone(),
8179            explain_ollama_url: self.explain_ollama_url.clone(),
8180            explain_max_tokens: self.explain_max_tokens,
8181        }
8182    }
8183
8184    /// Resolve the effective read-only lock for a destructive action
8185    /// against `env_name`. Layered:
8186    ///
8187    /// 1. Global `--read-only` flag / `:readonly on` (master switch).
8188    /// 2. Per-env safety pin (`safety.envs.NAME.read_only = true` in
8189    ///    config.toml).
8190    /// 3. Per-account safety pin (`safety.accounts.NAME.read_only = true`)
8191    ///    matched against the active profile name.
8192    ///
8193    /// Any of these returning `true` blocks the action; the operator-
8194    /// facing error message can differentiate via `read_only_reason`.
8195    pub fn is_read_only_for(&self, env_name: &str) -> bool {
8196        if self.read_only {
8197            return true;
8198        }
8199        // Session-scoped freeze (`:freeze-deploys`) is fleet-wide —
8200        // doesn't care about env_name. Layered above the per-env /
8201        // per-account pins because it's the most-recent operator
8202        // gesture: if they froze deploys, they meant for nothing to
8203        // dispatch regardless of what the persisted pins say.
8204        if self.deploy_freeze.is_some() {
8205            return true;
8206        }
8207        if self.safety_envs.get(env_name).copied().unwrap_or(false) {
8208            return true;
8209        }
8210        if let Some(profile) = self.context.profile.as_deref() {
8211            if self.safety_accounts.get(profile).copied().unwrap_or(false) {
8212                return true;
8213            }
8214        }
8215        false
8216    }
8217
8218    /// Enforce the read-only gate for a destructive action against
8219    /// `env_name`. Returns `true` (and sets `self.error_message` to a
8220    /// "<reason> — <verb> disabled" toast) when the env is locked;
8221    /// `false` (no side effects) otherwise. Designed to be the single
8222    /// guard at the top of every `spawn_*`-style destructive helper:
8223    ///
8224    /// ```ignore
8225    /// if self.deny_write(&env.name, "rollback") { return; }
8226    /// ```
8227    ///
8228    /// Saves duplicating the `is_read_only_for` + `read_only_reason`
8229    /// + `error_message` triplet at every call site (~25 of them).
8230    pub fn deny_write(&mut self, env_name: &str, verb: &str) -> bool {
8231        if !self.is_read_only_for(env_name) {
8232            return false;
8233        }
8234        let reason = self
8235            .read_only_reason(env_name)
8236            .unwrap_or_else(|| "read-only mode".into());
8237        self.error_message = Some(format!("{reason} — {verb} disabled"));
8238        true
8239    }
8240
8241    /// Human-readable explanation of *why* an env is read-only, used
8242    /// in the toast / footer when a destructive action is blocked.
8243    /// Returns `None` when the env isn't locked (caller shouldn't have
8244    /// called this; defensive return). The three reasons are ordered
8245    /// to match `is_read_only_for`'s precedence.
8246    pub fn read_only_reason(&self, env_name: &str) -> Option<String> {
8247        if self.read_only {
8248            return Some("read-only mode (global toggle)".into());
8249        }
8250        if let Some(freeze) = self.deploy_freeze.as_ref() {
8251            let age = (chrono::Utc::now() - freeze.frozen_at).num_seconds().max(0);
8252            let age = crate::app::humanize_short_age(std::time::Duration::from_secs(age as u64));
8253            return Some(if freeze.reason.is_empty() {
8254                format!("deploys frozen ({age} ago) — :thaw-deploys to unfreeze")
8255            } else {
8256                format!(
8257                    "deploys frozen ({age} ago): {} — :thaw-deploys to unfreeze",
8258                    freeze.reason
8259                )
8260            });
8261        }
8262        if self.safety_envs.get(env_name).copied().unwrap_or(false) {
8263            return Some(format!(
8264                "read-only mode (env pinned via safety.envs.{env_name})"
8265            ));
8266        }
8267        if let Some(profile) = self.context.profile.as_deref() {
8268            if self.safety_accounts.get(profile).copied().unwrap_or(false) {
8269                return Some(format!(
8270                    "read-only mode (account pinned via safety.accounts.{profile})"
8271                ));
8272            }
8273        }
8274        None
8275    }
8276
8277    /// Per-profile theme override. Looks at the active profile (from
8278    /// `self.context.profile`) and the configured `profile_themes` map;
8279    /// swaps `self.theme` to the override if one exists, or back to the
8280    /// base theme otherwise. Idempotent — calling repeatedly with the
8281    /// same profile is a no-op.
8282    fn maybe_apply_profile_theme(&mut self) {
8283        let profile = self.context.profile.as_deref().unwrap_or("default");
8284        let target_name = self
8285            .profile_themes
8286            .get(profile)
8287            .cloned()
8288            .unwrap_or_else(|| self.base_theme_name.clone());
8289        // Avoid rebuilding the Arc<Theme> when nothing changed.
8290        if self.theme.name == target_name {
8291            return;
8292        }
8293        let (mut t, warning) = Theme::resolve(&target_name);
8294        if let Some(w) = warning {
8295            tracing::warn!("{w}");
8296        }
8297        // Preserve the live-resolved icon style across the swap — icons
8298        // are a font-capability fact, not a theme preference, and the
8299        // `auto` probe only runs once at startup.
8300        t.icons = self.theme.icons;
8301        self.theme = Arc::new(t);
8302        // Theme swap invalidates the cached per-app colour assignments —
8303        // same reason as `apply_config_live`.
8304        self.cached_app_colors.clear();
8305    }
8306
8307    /// Apply a saved [`Config`] to the running App. Mirrors the assignments
8308    /// in [`App::new`] for the slots that can change at runtime; fields not
8309    /// listed here only take effect on restart.
8310    fn apply_config_live(&mut self, cfg: &Config) {
8311        // Theme + icons are stored on an `Arc<Theme>`; rebuild it from the
8312        // resolved values so renderers pick up the new palette/icon style
8313        // on the next draw.
8314        let (mut t, warning) = Theme::resolve(&cfg.theme);
8315        if let Some(w) = warning {
8316            tracing::warn!("{w}");
8317        }
8318        // Resolve `icons = "auto"` again — the form may have set it. We
8319        // can't run the probe from inside the TUI (alt-screen swallows the
8320        // cursor query), so "auto" falls back to whatever the previous
8321        // resolution chose. Operators who want a fresh probe should restart.
8322        let icons_raw = cfg.icons.clone();
8323        let resolved_icons = if icons_raw.eq_ignore_ascii_case("auto") {
8324            // Keep the previous resolved style on the running theme;
8325            // restart picks up a fresh probe.
8326            self.theme.icons
8327        } else {
8328            match icons_raw.trim().to_ascii_lowercase().as_str() {
8329                "ascii" => IconStyle::Ascii,
8330                "powerline" | "nerd" | "nerdfont" => IconStyle::Powerline,
8331                _ => IconStyle::Unicode,
8332            }
8333        };
8334        t.icons = resolved_icons;
8335        self.theme = Arc::new(t);
8336        self.cfg_icons_raw = icons_raw;
8337        // Refresh interval — the ticker reads `self.refresh_interval` on
8338        // each tick boundary, so the new value applies on the next cycle.
8339        self.refresh_interval = cfg.refresh_interval;
8340        // Defaults that flow through the persisted-state overlay: don't
8341        // overwrite the live toggles (the user may have flipped them with
8342        // `:redact` / `:group`), only the *_default fields in cfg get
8343        // written back. Reflecting those onto the running view would
8344        // surprise the operator.
8345        self.extra_regions = cfg.extra_regions.clone();
8346        self.notify_bell = cfg.notify_bell;
8347        self.required_tags = cfg.required_tags.clone();
8348        // Theme swap invalidates the cached per-app colour assignments —
8349        // those store final `Color` values, not palette indices, so they'd
8350        // otherwise carry the old palette into the new theme's rendering.
8351        self.rebuild_view();
8352    }
8353
8354    fn handle_action_key(&mut self, key: KeyEvent) {
8355        let Some(flow) = self.action_flow.as_mut() else {
8356            self.mode = Mode::Normal;
8357            return;
8358        };
8359        match flow {
8360            ActionFlow::Menu { list_state } => match key.code {
8361                // Menu has j/k cursor + Enter to pick — no text input, so
8362                // `q` as close is unambiguous and matches every other
8363                // overlay's pattern.
8364                KeyCode::Esc | KeyCode::Char('q') => self.close_action_flow(),
8365                KeyCode::Char('j') | KeyCode::Down => {
8366                    let cur = list_state.selected().unwrap_or(0);
8367                    let next = (cur + 1) % ACTIONS.len();
8368                    list_state.select(Some(next));
8369                }
8370                KeyCode::Char('k') | KeyCode::Up => {
8371                    let cur = list_state.selected().unwrap_or(0);
8372                    let next = (cur + ACTIONS.len() - 1) % ACTIONS.len();
8373                    list_state.select(Some(next));
8374                }
8375                KeyCode::Enter => {
8376                    let Some(idx) = list_state.selected() else {
8377                        return;
8378                    };
8379                    let action = ACTIONS[idx];
8380                    self.advance_action_flow(action);
8381                }
8382                _ => {}
8383            },
8384            ActionFlow::SwapTarget { picker, .. } => match key.code {
8385                KeyCode::Esc => self.close_action_flow(),
8386                KeyCode::Down | KeyCode::Char('j')
8387                    if !key.modifiers.contains(KeyModifiers::CONTROL) =>
8388                {
8389                    picker.move_selection(1);
8390                }
8391                KeyCode::Up | KeyCode::Char('k')
8392                    if !key.modifiers.contains(KeyModifiers::CONTROL) =>
8393                {
8394                    picker.move_selection(-1);
8395                }
8396                KeyCode::Backspace => {
8397                    picker.filter.pop();
8398                }
8399                KeyCode::Enter => {
8400                    let Some(target) = picker.selected_value() else {
8401                        return;
8402                    };
8403                    let source = match flow {
8404                        ActionFlow::SwapTarget { source, .. } => source.clone(),
8405                        _ => return,
8406                    };
8407                    let warning = self
8408                        .environments
8409                        .iter()
8410                        .find(|e| e.name == source)
8411                        .map(compute_traffic_warning)
8412                        .unwrap_or(None);
8413                    self.action_flow = Some(ActionFlow::Confirm(ConfirmModal {
8414                        action: Action::SwapCnames,
8415                        target_env: source,
8416                        swap_with: Some(target),
8417                        typed: String::new(),
8418                        kind: ConfirmKind::YesNo,
8419                        dryrun: None,
8420                        loading_dryrun: false,
8421                        recent_events: None,
8422                        loading_events: false,
8423                        traffic_warning: warning,
8424                        deploy_version: None,
8425                        upgrade_platform_arn: None,
8426                        upgrade_platform_label: None,
8427                        clone_target: None,
8428                        scale_min: None,
8429                        scale_max: None,
8430                        auto_rollback_secs: None,
8431                        wait_for_green_secs: None,
8432                        version_preview: None,
8433                        loading_version_preview: false,
8434                        health_check_probe: None,
8435                        loading_health_check: false,
8436                        unavailability_line: None,
8437                        loading_unavailability: false,
8438                        lint_issues: None,
8439                        loading_lint: false,
8440                    }));
8441                }
8442                KeyCode::Char(c) if is_text_input(&key) => {
8443                    picker.filter.push(c);
8444                    let filt = picker.filtered();
8445                    if !filt
8446                        .iter()
8447                        .any(|i| Some(*i) == picker.list_state.selected())
8448                    {
8449                        picker.list_state.select(filt.first().copied());
8450                    }
8451                }
8452                _ => {}
8453            },
8454            ActionFlow::Confirm(modal) => match (key.code, modal.kind) {
8455                (KeyCode::Esc, _) => self.close_action_flow(),
8456                // `q` cancels Y/N confirms (n / esc are the others). TypeName
8457                // confirms intentionally don't bind q since the user is
8458                // typing the env name and `q` might be part of it.
8459                (KeyCode::Char('q'), ConfirmKind::YesNo) => self.close_action_flow(),
8460                (KeyCode::Char('y'), ConfirmKind::YesNo) | (KeyCode::Enter, ConfirmKind::YesNo) => {
8461                    // Queue with a 5s cancel window instead of dispatching
8462                    // immediately. The action flow closes (modal gone)
8463                    // and a countdown lands in `status_message`; `U` in
8464                    // Normal mode undoes it before the deadline.
8465                    let m = modal.clone();
8466                    self.close_action_flow();
8467                    self.queue_action_dispatch(m);
8468                }
8469                (KeyCode::Char('n'), ConfirmKind::YesNo) => self.close_action_flow(),
8470                (KeyCode::Enter, ConfirmKind::TypeName) if modal.typed == modal.target_env => {
8471                    // Same cancel-window treatment as Y-confirms. Terminate
8472                    // is the loudest example — the typed-name guard already
8473                    // prevents accidental dispatch, but the 5s window is a
8474                    // last-ditch "oh god no" rescue.
8475                    let m = modal.clone();
8476                    self.close_action_flow();
8477                    self.queue_action_dispatch(m);
8478                }
8479                (KeyCode::Backspace, ConfirmKind::TypeName) => {
8480                    modal.typed.pop();
8481                }
8482                (KeyCode::Char(c), ConfirmKind::TypeName) if is_text_input(&key) => {
8483                    modal.typed.push(c);
8484                }
8485                _ => {}
8486            },
8487            ActionFlow::Rollout(flow) => match (key.code, &flow.state) {
8488                // Esc / q close the rollout at any state — even
8489                // during Dispatching the operator can abort
8490                // further regions. The dispatched ones have
8491                // already fired (each region's UpdateEnvironment
8492                // is a non-reversible AWS write), but the loop
8493                // halts so regions queued behind the current
8494                // one don't fire.
8495                (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => self.close_action_flow(),
8496                // `y` confirms a fully-pre-flighted plan. Only
8497                // valid in AwaitingConfirm. Switches state to
8498                // Dispatching and fires the first region.
8499                (KeyCode::Char('y'), crate::mode_action::RolloutState::AwaitingConfirm)
8500                | (KeyCode::Enter, crate::mode_action::RolloutState::AwaitingConfirm) => {
8501                    // Refuse to dispatch if no regions passed
8502                    // pre-flight — there's nothing safe to send.
8503                    let any_ok = flow.regions.iter().any(|r| r.env_found == Some(true));
8504                    if !any_ok {
8505                        self.error_message = Some(
8506                            "rollout: no regions passed pre-flight — fix or `esc` to abort".into(),
8507                        );
8508                        return;
8509                    }
8510                    // Find the first region that passed pre-
8511                    // flight. Failed regions are skipped (their
8512                    // outcome stays None — surfaced as
8513                    // "skipped" in the final report).
8514                    let Some((first_idx, _)) = flow
8515                        .regions
8516                        .iter()
8517                        .enumerate()
8518                        .find(|(_, r)| r.env_found == Some(true))
8519                    else {
8520                        return;
8521                    };
8522                    flow.state = crate::mode_action::RolloutState::Dispatching {
8523                        next_index: first_idx,
8524                    };
8525                    let region = flow.regions[first_idx].region.clone();
8526                    let env_name = flow.env_name.clone();
8527                    let version_label = flow.version_label.clone();
8528                    let wait_for_green_secs = flow.wait_for_green_secs;
8529                    let profile = self.context.profile.clone();
8530                    self.spawn_rollout_dispatch(
8531                        profile,
8532                        region,
8533                        env_name,
8534                        version_label,
8535                        wait_for_green_secs,
8536                    );
8537                }
8538                // `n` aborts before any dispatch fires. Same as
8539                // esc but matches the y/n posture of the
8540                // standard ConfirmModal.
8541                (KeyCode::Char('n'), crate::mode_action::RolloutState::AwaitingConfirm) => {
8542                    self.close_action_flow();
8543                }
8544                _ => {}
8545            },
8546        }
8547    }
8548
8549    fn advance_action_flow(&mut self, action: Action) {
8550        let Some(env) = self.target_env_for_action() else {
8551            self.close_action_flow();
8552            return;
8553        };
8554        match action {
8555            Action::SwapCnames => {
8556                // Build a list of envs in the same application (excluding the source).
8557                let candidates: Vec<String> = self
8558                    .environments
8559                    .iter()
8560                    .filter(|e| e.application == env.application && e.name != env.name)
8561                    .map(|e| e.name.clone())
8562                    .collect();
8563                if candidates.is_empty() {
8564                    self.action_flow = None;
8565                    self.mode = if self.detail.is_some() {
8566                        Mode::Detail
8567                    } else {
8568                        Mode::Normal
8569                    };
8570                    self.error_message = Some(format!(
8571                        "no swap candidates: app '{}' has only one env",
8572                        env.application
8573                    ));
8574                    return;
8575                }
8576                let picker = Picker::new(PickerKind::Region, candidates, None); // kind unused here
8577                self.action_flow = Some(ActionFlow::SwapTarget {
8578                    source: env.name.clone(),
8579                    picker,
8580                });
8581            }
8582            Action::Terminate => {
8583                // Terminate is the only Action that uses TypeName confirm;
8584                // every other entry routes through `open_parameterised_action`.
8585                // Preflight gating still flows from `Action::wants_preflight()`
8586                // so the rule lives in exactly one place.
8587                let wants_preflight = action.wants_preflight();
8588                self.action_flow = Some(ActionFlow::Confirm(ConfirmModal {
8589                    action,
8590                    target_env: env.name.clone(),
8591                    swap_with: None,
8592                    typed: String::new(),
8593                    kind: ConfirmKind::TypeName,
8594                    dryrun: None,
8595                    loading_dryrun: wants_preflight,
8596                    recent_events: None,
8597                    loading_events: wants_preflight,
8598                    traffic_warning: compute_traffic_warning(&env),
8599                    deploy_version: None,
8600                    upgrade_platform_arn: None,
8601                    upgrade_platform_label: None,
8602                    clone_target: None,
8603                    scale_min: None,
8604                    scale_max: None,
8605                    auto_rollback_secs: None,
8606                    wait_for_green_secs: None,
8607                    version_preview: None,
8608                    loading_version_preview: false,
8609                    health_check_probe: None,
8610                    loading_health_check: false,
8611                    unavailability_line: None,
8612                    loading_unavailability: false,
8613                    lint_issues: None,
8614                    loading_lint: false,
8615                }));
8616                if wants_preflight {
8617                    self.spawn_dry_run(env.name.clone());
8618                    self.spawn_preflight_events(env.name.clone());
8619                }
8620            }
8621            Action::Rebuild => {
8622                self.open_parameterised_action(action, ParameterisedAction::default());
8623            }
8624            // Parameterised actions need user input before the confirm can
8625            // be built. The menu closes itself and pre-fills the command
8626            // bar so the user types `<arg>` and Enter, which routes through
8627            // the existing `:deploy` / `:upgrade` / `:clone` / `:scale`
8628            // handlers (all of which open a confirm modal).
8629            Action::Deploy => {
8630                self.close_action_flow();
8631                self.mode = Mode::Command;
8632                self.command_input = "deploy ".into();
8633                self.status_message = Some("type a version label and press enter".into());
8634            }
8635            Action::UpgradePlatform => {
8636                self.close_action_flow();
8637                self.spawn_list_compatible_platforms(env.name.clone());
8638                self.mode = Mode::Command;
8639                self.command_input = "upgrade ".into();
8640                self.status_message =
8641                    Some("listing platforms in overlay; paste an ARN and press enter".into());
8642            }
8643            Action::Clone => {
8644                self.close_action_flow();
8645                self.mode = Mode::Command;
8646                self.command_input = "clone ".into();
8647                self.status_message = Some("type a new env name and press enter".into());
8648            }
8649            Action::Scale => {
8650                self.close_action_flow();
8651                self.mode = Mode::Command;
8652                self.command_input = "scale ".into();
8653                self.status_message = Some(
8654                    "scale N (instances), or `scale min N` / `scale max N`; enter to apply".into(),
8655                );
8656            }
8657            Action::Capacity => {
8658                // `:capacity` opens a modal form pre-filled from
8659                // DescribeConfigurationSettings — no command-bar args
8660                // needed, so we close the menu and dispatch straight
8661                // to the form opener.
8662                self.close_action_flow();
8663                self.cmd_capacity();
8664            }
8665            Action::AbortUpdate => {
8666                self.action_flow = Some(ActionFlow::Confirm(ConfirmModal {
8667                    action,
8668                    target_env: env.name.clone(),
8669                    swap_with: None,
8670                    typed: String::new(),
8671                    kind: ConfirmKind::YesNo,
8672                    dryrun: None,
8673                    loading_dryrun: false,
8674                    recent_events: None,
8675                    loading_events: false,
8676                    traffic_warning: compute_traffic_warning(&env),
8677                    deploy_version: None,
8678                    upgrade_platform_arn: None,
8679                    upgrade_platform_label: None,
8680                    clone_target: None,
8681                    scale_min: None,
8682                    scale_max: None,
8683                    auto_rollback_secs: None,
8684                    wait_for_green_secs: None,
8685                    version_preview: None,
8686                    loading_version_preview: false,
8687                    health_check_probe: None,
8688                    loading_health_check: false,
8689                    unavailability_line: None,
8690                    loading_unavailability: false,
8691                    lint_issues: None,
8692                    loading_lint: false,
8693                }));
8694            }
8695            _ => {
8696                self.action_flow = Some(ActionFlow::Confirm(ConfirmModal {
8697                    action,
8698                    target_env: env.name.clone(),
8699                    swap_with: None,
8700                    typed: String::new(),
8701                    kind: ConfirmKind::YesNo,
8702                    dryrun: None,
8703                    loading_dryrun: false,
8704                    recent_events: None,
8705                    loading_events: false,
8706                    traffic_warning: compute_traffic_warning(&env),
8707                    deploy_version: None,
8708                    upgrade_platform_arn: None,
8709                    upgrade_platform_label: None,
8710                    clone_target: None,
8711                    scale_min: None,
8712                    scale_max: None,
8713                    auto_rollback_secs: None,
8714                    wait_for_green_secs: None,
8715                    version_preview: None,
8716                    loading_version_preview: false,
8717                    health_check_probe: None,
8718                    loading_health_check: false,
8719                    unavailability_line: None,
8720                    loading_unavailability: false,
8721                    lint_issues: None,
8722                    loading_lint: false,
8723                }));
8724            }
8725        }
8726    }
8727
8728    /// Key handler for the `:logs-tail` streaming overlay. j/k scroll, G
8729    /// snaps back to follow-mode (auto-tail), g jumps to top (and pauses
8730    /// follow), / opens a regex filter, n clears it, esc/q closes the
8731    /// overlay and tears down the polling task.
8732    fn handle_log_tail_key(&mut self, key: KeyEvent) {
8733        // Group-switcher: Tab opens a Picker over the env's discovered CW
8734        // log groups. Handled up-front before the destructured borrow of
8735        // `current_overlay` below so the picker open can re-borrow `self`.
8736        if matches!(key.code, KeyCode::Tab)
8737            && !matches!(
8738                self.current_overlay.as_ref(),
8739                Some(Overlay::LogTail {
8740                    filter_active: true,
8741                    ..
8742                })
8743            )
8744        {
8745            self.open_log_group_picker();
8746            return;
8747        }
8748        // Filter input mode swallows printable keys.
8749        {
8750            let Some(Overlay::LogTail {
8751                filter_active,
8752                filter_input,
8753                filter_pattern,
8754                ..
8755            }) = self.current_overlay.as_mut()
8756            else {
8757                return;
8758            };
8759            if *filter_active {
8760                match key.code {
8761                    KeyCode::Esc => {
8762                        *filter_active = false;
8763                        filter_input.clear();
8764                        *filter_pattern = None;
8765                        return;
8766                    }
8767                    KeyCode::Enter => {
8768                        *filter_active = false;
8769                        if filter_input.is_empty() {
8770                            *filter_pattern = None;
8771                        } else {
8772                            match regex::RegexBuilder::new(filter_input)
8773                                .case_insensitive(true)
8774                                .build()
8775                            {
8776                                Ok(re) => *filter_pattern = Some(re),
8777                                Err(_) => *filter_pattern = None,
8778                            }
8779                        }
8780                        return;
8781                    }
8782                    KeyCode::Backspace => {
8783                        filter_input.pop();
8784                        return;
8785                    }
8786                    KeyCode::Char(c) if is_text_input(&key) => {
8787                        filter_input.push(c);
8788                        return;
8789                    }
8790                    _ => return,
8791                }
8792            }
8793        }
8794        let Some(Overlay::LogTail {
8795            scroll,
8796            following,
8797            filter_active,
8798            filter_input,
8799            filter_pattern,
8800            ..
8801        }) = self.current_overlay.as_mut()
8802        else {
8803            return;
8804        };
8805        match key.code {
8806            KeyCode::Esc | KeyCode::Char('q') => {
8807                if let Some(handle) = self.log_tail_task.take() {
8808                    handle.abort();
8809                }
8810                // Bump session id so a late `LogTailOpened` from the
8811                // aborted task can't re-open the overlay after the user
8812                // dismissed it (abort + channel-send race).
8813                self.log_tail_session = self.log_tail_session.wrapping_add(1);
8814                self.current_overlay = None;
8815            }
8816            KeyCode::Char('j') | KeyCode::Down => {
8817                if *scroll > 0 {
8818                    *scroll -= 1;
8819                }
8820                if *scroll == 0 {
8821                    *following = true;
8822                }
8823            }
8824            KeyCode::Char('k') | KeyCode::Up => {
8825                *scroll = scroll.saturating_add(1);
8826                *following = false;
8827            }
8828            KeyCode::Char('G') | KeyCode::End => {
8829                *scroll = 0;
8830                *following = true;
8831            }
8832            KeyCode::Char('g') | KeyCode::Home => {
8833                *scroll = u16::MAX;
8834                *following = false;
8835            }
8836            KeyCode::Char('/') => {
8837                *filter_active = true;
8838                filter_input.clear();
8839                *filter_pattern = None;
8840            }
8841            KeyCode::Char('n') => {
8842                filter_input.clear();
8843                *filter_pattern = None;
8844            }
8845            _ => {}
8846        }
8847    }
8848
8849    /// Open a Picker over the env's discovered CW log groups so the operator
8850    /// can switch the tailed group from inside the streaming overlay.
8851    /// Pre-selects the currently-tailed group; no-op (with a status hint) if
8852    /// no groups have been discovered for this env.
8853    fn open_log_group_picker(&mut self) {
8854        let Some(Overlay::LogTail { log_group, .. }) = self.current_overlay.as_ref() else {
8855            return;
8856        };
8857        let current_group = log_group.clone();
8858        let groups: Vec<String> = self
8859            .detail
8860            .as_ref()
8861            .and_then(|d| d.cw_log_groups.clone())
8862            .unwrap_or_default();
8863        if groups.is_empty() {
8864            self.status_message = Some(
8865                "no CW log groups discovered for this env — try `:logs-tail <full-group-name>`"
8866                    .into(),
8867            );
8868            return;
8869        }
8870        self.picker = Some(Picker::new(
8871            PickerKind::LogGroup,
8872            groups,
8873            Some(current_group.as_str()),
8874        ));
8875        self.mode = Mode::Picker;
8876    }
8877
8878    /// Dispatch `UpdateEnvironment(template_name)`. Used by both the typed
8879    /// `:config-apply TEMPLATE` command and the `a`/enter key in the
8880    /// interactive saved-configs overlay. Reads template + env directly
8881    /// so callers can pass strings with embedded spaces (the typed-command
8882    /// parser joins rest with single spaces; the overlay passes the raw
8883    /// template name).
8884    fn spawn_config_apply_template(&mut self, env_name: String, template: String) {
8885        if self.deny_write(&env_name, "config-apply") {
8886            return;
8887        }
8888        let aws = self.aws.clone();
8889        let tx = self.msg_tx.clone();
8890        let gen = self.generation;
8891        // In-flight ack lives on the pending pill; completion toasts.
8892        write_audit_line(
8893            self.context.account_id.as_deref(),
8894            self.context.profile.as_deref(),
8895            &self.context.region,
8896            &format!("stage=dispatched action=ConfigApply target={env_name} template={template}"),
8897        );
8898        self.push_pending(Action::ConfigApply.label(), env_name.clone());
8899        let env_for_msg = env_name.clone();
8900        tokio::spawn(async move {
8901            let result = aws
8902                .apply_config_template(&env_for_msg, &template)
8903                .await
8904                .map_err(|e| flatten_err("apply_config_template", e));
8905            let _ = tx.send(AppMsg::ActionResult {
8906                gen,
8907                action: Action::ConfigApply,
8908                env_name: env_for_msg,
8909                result,
8910            });
8911        });
8912    }
8913
8914    /// Dispatch `DeleteConfigurationTemplate`. Same shape as
8915    /// `spawn_config_apply_template`; bypasses the typed-command parser so
8916    /// the overlay can pass template names with embedded spaces.
8917    fn spawn_config_delete_template(&mut self, app_name: String, template: String) {
8918        // config-delete is app-scoped, not env-scoped — the template
8919        // lives at the application level. Per-account safety still
8920        // applies; per-env doesn't. The global / account-pin gate fires
8921        // via `deny_write` with an empty env name (which never matches
8922        // any `safety_envs` key).
8923        if self.deny_write("", "config-delete") {
8924            return;
8925        }
8926        let aws = self.aws.clone();
8927        let tx = self.msg_tx.clone();
8928        let gen = self.generation;
8929        let target = format!("{app_name}/{template}");
8930        self.status_message = Some(format!(
8931            "deleting template '{template}' from app '{app_name}'…"
8932        ));
8933        write_audit_line(
8934            self.context.account_id.as_deref(),
8935            self.context.profile.as_deref(),
8936            &self.context.region,
8937            &format!("stage=dispatched action=ConfigDelete target={target}"),
8938        );
8939        self.push_pending(Action::ConfigDelete.label(), target.clone());
8940        let template_for_msg = template.clone();
8941        tokio::spawn(async move {
8942            let result = aws
8943                .delete_config_template(&app_name, &template)
8944                .await
8945                .map_err(|e| flatten_err("delete_config_template", e))
8946                .map_err(|e| format!("config-delete '{template_for_msg}': {e}"));
8947            let _ = tx.send(AppMsg::ActionResult {
8948                gen,
8949                action: Action::ConfigDelete,
8950                env_name: target,
8951                result,
8952            });
8953        });
8954    }
8955
8956    /// Fetch a template's option settings and surface them as a TextOverlay.
8957    /// Read-only — no read-only-mode gate. Called by `:config-inspect` and
8958    /// by the `i` keybind in the interactive saved-configs overlay.
8959    fn spawn_config_inspect_template(&mut self, app_name: String, template: String) {
8960        let aws = self.aws.clone();
8961        let tx = self.msg_tx.clone();
8962        let gen = self.generation;
8963        let title = format!("template — {app_name}/{template}");
8964        // In-flight ack: pending pill. Inspect result lands as a TextOverlay.
8965        tokio::spawn(async move {
8966            let body = match aws.describe_template_settings(&app_name, &template).await {
8967                Ok(settings) if settings.is_empty() => {
8968                    "(template has no option settings)".to_string()
8969                }
8970                Ok(settings) => format_template_settings(&settings),
8971                Err(e) => format!("error: {}", flatten_err("describe_template_settings", e)),
8972            };
8973            let _ = tx.send(AppMsg::TextOverlay { gen, title, body });
8974        });
8975    }
8976
8977    /// Open a streaming CW Logs view for `env_name`. If `explicit_group` is
8978    /// `None`, discovers the env's log groups and picks the most useful one
8979    /// via `pick_default_log_group`. Aborts any active log-tail task before
8980    /// starting the new one, then spawns a polling loop that sends
8981    /// `AppMsg::LogTailEvents` every ~2s. The overlay opens immediately in
8982    /// a "discovering" state and gets replaced with the LogTail variant
8983    /// once the group is known.
8984    fn spawn_logs_tail(&mut self, env_name: String, explicit_group: Option<String>) {
8985        // Tear down any prior session so we don't have two pollers racing.
8986        if let Some(handle) = self.log_tail_task.take() {
8987            handle.abort();
8988        }
8989        self.log_tail_session = self.log_tail_session.wrapping_add(1);
8990        let session_id = self.log_tail_session;
8991        let aws = self.aws.clone();
8992        let tx = self.msg_tx.clone();
8993        let gen = self.generation;
8994        let env_for_msg = env_name.clone();
8995        // In-flight ack: the LogTail overlay opens itself when data lands.
8996        let handle = tokio::spawn(async move {
8997            // Resolve the log group up front. If the user supplied one,
8998            // trust it (no DescribeLogGroups round-trip); otherwise discover.
8999            let group = match explicit_group {
9000                Some(g) => g,
9001                None => match aws.discover_env_log_groups(&env_for_msg).await {
9002                    Ok(groups) => match pick_default_log_group(&groups) {
9003                        Some(g) => g,
9004                        None => {
9005                            let _ = tx.send(AppMsg::LogTailEvents {
9006                                gen,
9007                                session_id,
9008                                next_since_ms: 0,
9009                                result: Err(format!(
9010                                    "no CW log groups under /aws/elasticbeanstalk/{env_for_msg}/ — enable streaming with `:logs-stream on`"
9011                                )),
9012                            });
9013                            return;
9014                        }
9015                    },
9016                    Err(e) => {
9017                        let _ = tx.send(AppMsg::LogTailEvents {
9018                            gen,
9019                            session_id,
9020                            next_since_ms: 0,
9021                            result: Err(format!("discover log groups: {e}")),
9022                        });
9023                        return;
9024                    }
9025                },
9026            };
9027            // First batch: fetch the last 5 minutes so the overlay isn't
9028            // empty on open.
9029            let mut since_ms = chrono::Utc::now().timestamp_millis() - 5 * 60 * 1000;
9030            // Send an "opening" message that tells the App handler what log
9031            // group resolved + replaces the overlay with a real LogTail.
9032            let _ = tx.send(AppMsg::LogTailOpened {
9033                gen,
9034                session_id,
9035                env_name: env_for_msg.clone(),
9036                log_group: group.clone(),
9037                since_ms,
9038            });
9039            loop {
9040                match aws.fetch_recent_log_events(&group, since_ms, 1000).await {
9041                    Ok((events, next_since)) => {
9042                        let next_since_ms = next_since;
9043                        let _ = tx.send(AppMsg::LogTailEvents {
9044                            gen,
9045                            session_id,
9046                            next_since_ms,
9047                            result: Ok(events),
9048                        });
9049                        since_ms = next_since;
9050                    }
9051                    Err(e) => {
9052                        let _ = tx.send(AppMsg::LogTailEvents {
9053                            gen,
9054                            session_id,
9055                            next_since_ms: since_ms,
9056                            result: Err(format!("{e}")),
9057                        });
9058                        // Keep going on errors — transient throttling
9059                        // shouldn't kill the session.
9060                    }
9061                }
9062                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
9063            }
9064        });
9065        self.log_tail_task = Some(handle);
9066    }
9067
9068    /// Dispatch an `UpdateEnvironment(option_settings)` call. Used by the
9069    /// three "tweak one or two settings" commands (`:logs-stream`, `:notify`,
9070    /// `:managed-window`); each pushes its own pending row + audit entry
9071    /// then funnels through here. `summary` is the human-readable label
9072    /// that ends up in the toast and the pending panel.
9073    pub(crate) fn spawn_option_settings_update(
9074        &mut self,
9075        summary: String,
9076        to_set: Vec<(String, String, String)>,
9077        to_remove: Vec<(String, String)>,
9078    ) {
9079        let Some(env) = self.selected_env().cloned() else {
9080            self.error_message = Some("no env selected".into());
9081            return;
9082        };
9083        if self.deny_write(&env.name, &summary) {
9084            return;
9085        }
9086        if to_set.is_empty() && to_remove.is_empty() {
9087            self.error_message = Some(format!(
9088                "{summary}: nothing to do (no options to set or remove)"
9089            ));
9090            return;
9091        }
9092        let env_name = env.name.clone();
9093        write_audit_line(
9094            self.context.account_id.as_deref(),
9095            self.context.profile.as_deref(),
9096            &self.context.region,
9097            &format!(
9098                "stage=dispatched action=UpdateOptionSettings target={env_name} summary=\"{summary}\""
9099            ),
9100        );
9101        self.push_pending(summary.clone(), env_name.clone());
9102        // No status_message ack here — the pending-actions pill in the
9103        // header (`⏳ N`) is the truth-source for in-flight work, and a
9104        // status_message ack would just race with whatever the operator
9105        // last set there. Completion fires a Success / Error toast.
9106        let aws = self.aws.clone();
9107        let tx = self.msg_tx.clone();
9108        let gen = self.generation;
9109        let env_for_msg = env_name.clone();
9110        let summary_for_msg = summary.clone();
9111        let account = self.context.account_id.clone();
9112        let profile = self.context.profile.clone();
9113        let region = self.context.region.clone();
9114        // Capture inputs for the optional :undo round-trip — the
9115        // option-settings fetch happens BEFORE the write so we can
9116        // record the prior state and offer a clean reverse-action.
9117        let app_for_undo = env.application.clone();
9118        let env_for_undo = env_name.clone();
9119        let summary_for_undo = summary.clone();
9120        let to_set_for_undo = to_set.clone();
9121        let to_remove_for_undo = to_remove.clone();
9122        tokio::spawn(async move {
9123            // Fetch current option-settings for the affected keys
9124            // BEFORE the write so we can build the reverse-action.
9125            // Read failure doesn't block the write — undo is a
9126            // safety net, not a correctness invariant.
9127            let undo_entry = match aws
9128                .fetch_env_option_settings(&app_for_undo, &env_for_undo)
9129                .await
9130            {
9131                Ok(opts) => Some(build_undo_entry(
9132                    &env_for_undo,
9133                    &summary_for_undo,
9134                    &to_set_for_undo,
9135                    &to_remove_for_undo,
9136                    &opts,
9137                )),
9138                Err(_) => None,
9139            };
9140            let result = aws
9141                .update_env_option_settings(&env_for_msg, &to_set, &to_remove)
9142                .await
9143                .map_err(|e| flatten_err("update_env_option_settings", e));
9144            let outcome = match &result {
9145                Ok(()) => format!(
9146                    "stage=completed action=UpdateOptionSettings target={env_for_msg} summary=\"{summary_for_msg}\" outcome=ok"
9147                ),
9148                Err(e) => format!(
9149                    "stage=completed action=UpdateOptionSettings target={env_for_msg} summary=\"{summary_for_msg}\" outcome=err err=\"{}\"",
9150                    crate::audit::escape_value(e)
9151                ),
9152            };
9153            write_audit_line(account.as_deref(), profile.as_deref(), &region, &outcome);
9154            // Only record undo on a successful write — otherwise
9155            // `:undo` would "revert" a write that never landed.
9156            if result.is_ok() {
9157                if let Some(entry) = undo_entry {
9158                    let _ = tx.send(AppMsg::UndoCaptured { gen, entry });
9159                }
9160            }
9161            let _ = tx.send(AppMsg::OptionSettingsUpdate {
9162                gen,
9163                env_name: env_for_msg,
9164                summary: summary_for_msg,
9165                result,
9166            });
9167        });
9168    }
9169
9170    /// Register a new application version pointing at an existing S3
9171    /// object, and optionally deploy it. Skips the local-read +
9172    /// storage-location + put_object steps that `spawn_deploy_from_local`
9173    /// does. Useful when the bundle is already in S3 — most CI pipelines
9174    /// upload artifacts to S3 themselves.
9175    fn spawn_deploy_from_s3(
9176        &mut self,
9177        bucket: String,
9178        key: String,
9179        explicit_label: Option<String>,
9180        description: Option<String>,
9181        and_deploy: bool,
9182    ) {
9183        let Some(env) = self.selected_env().cloned() else {
9184            self.error_message = Some("no env selected".into());
9185            return;
9186        };
9187        if self.deny_write(&env.name, "deploy-from-s3") {
9188            return;
9189        }
9190        // Derive label from the S3 key's basename if not pinned. Same
9191        // convention as the local-path flow so the audit log + version list
9192        // are consistent across the two sources.
9193        let label = explicit_label
9194            .unwrap_or_else(|| derive_version_label(&key, chrono::Utc::now().timestamp()));
9195        let env_name = env.name.clone();
9196        let app_name = env.application.clone();
9197        let summary = if and_deploy {
9198            format!("deploy-from-s3 {label}")
9199        } else {
9200            format!("create-version-from-s3 {label}")
9201        };
9202        write_audit_line(
9203            self.context.account_id.as_deref(),
9204            self.context.profile.as_deref(),
9205            &self.context.region,
9206            &format!(
9207                "stage=dispatched action=DeployFromS3 target={env_name} label={label} source=s3://{bucket}/{key} and_deploy={and_deploy}"
9208            ),
9209        );
9210        self.push_pending(summary.clone(), env_name.clone());
9211        // In-flight ack lives on the pending pill; completion toasts.
9212        let aws = self.aws.clone();
9213        let tx = self.msg_tx.clone();
9214        let gen = self.generation;
9215        let env_for_msg = env_name.clone();
9216        let label_for_msg = label.clone();
9217        let summary_for_msg = summary.clone();
9218        let account = self.context.account_id.clone();
9219        let profile = self.context.profile.clone();
9220        let region = self.context.region.clone();
9221        let description_owned = description;
9222        tokio::spawn(async move {
9223            if let Err(e) = aws
9224                .create_app_version(
9225                    &app_name,
9226                    &label_for_msg,
9227                    description_owned.as_deref(),
9228                    &bucket,
9229                    &key,
9230                )
9231                .await
9232            {
9233                let err = format!("create-version: {}", flatten_err("create_app_version", e));
9234                finish_deploy_from_local(
9235                    &tx,
9236                    gen,
9237                    env_for_msg,
9238                    label_for_msg,
9239                    summary_for_msg,
9240                    account.as_deref(),
9241                    profile.as_deref(),
9242                    &region,
9243                    Err(err),
9244                );
9245                return;
9246            }
9247            if and_deploy {
9248                if let Err(e) = aws.deploy_version(&env_for_msg, &label_for_msg).await {
9249                    let err = format!("deploy: {}", flatten_err("deploy_version", e));
9250                    finish_deploy_from_local(
9251                        &tx,
9252                        gen,
9253                        env_for_msg,
9254                        label_for_msg,
9255                        summary_for_msg,
9256                        account.as_deref(),
9257                        profile.as_deref(),
9258                        &region,
9259                        Err(err),
9260                    );
9261                    return;
9262                }
9263            }
9264            finish_deploy_from_local(
9265                &tx,
9266                gen,
9267                env_for_msg,
9268                label_for_msg,
9269                summary_for_msg,
9270                account.as_deref(),
9271                profile.as_deref(),
9272                &region,
9273                Ok(()),
9274            );
9275        });
9276    }
9277
9278    /// Upload a local bundle to EB's managed S3 storage, register a new
9279    /// application version pointing at it, and optionally deploy it to the
9280    /// selected env. The chain runs serially in one spawned task; failures
9281    /// at any stage surface as a single error toast with the stage name.
9282    /// Fetch the candidate version's metadata + the currently-deployed
9283    /// version's metadata for the env's app, render a preview text, and
9284    /// land it as a TextOverlay. EB application versions carry only a
9285    /// label + description + source-bundle S3 pointer + created date;
9286    /// there's no option-settings diff to surface (settings live on the
9287    /// env, not the version). So the preview is "informed deploy" —
9288    /// label, age, description, plus a warning when the candidate is
9289    /// older than what's currently deployed.
9290    fn spawn_deploy_preview(&self, env: crate::aws::Environment, label: String) {
9291        let aws = self.aws.clone();
9292        let tx = self.msg_tx.clone();
9293        let gen = self.generation;
9294        let app_name = env.application.clone();
9295        let env_name = env.name.clone();
9296        let current_label = env.version_label.clone();
9297        tokio::spawn(async move {
9298            let body = match aws.list_application_versions(&app_name).await {
9299                Ok(versions) => format_deploy_preview(&env_name, &current_label, &label, &versions),
9300                Err(e) => format!(
9301                    "deploy preview — failed to fetch application versions:\n  {}\n",
9302                    flatten_err_to_string(&e)
9303                ),
9304            };
9305            let _ = tx.send(AppMsg::TextOverlay {
9306                gen,
9307                title: format!("deploy preview — {env_name} ← {label}"),
9308                body,
9309            });
9310        });
9311    }
9312
9313    fn spawn_deploy_from_local(
9314        &mut self,
9315        path: String,
9316        explicit_label: Option<String>,
9317        description: Option<String>,
9318        and_deploy: bool,
9319    ) {
9320        let Some(env) = self.selected_env().cloned() else {
9321            self.error_message = Some("no env selected".into());
9322            return;
9323        };
9324        if self.is_read_only_for(&env.name) {
9325            let reason = self
9326                .read_only_reason(&env.name)
9327                .unwrap_or_else(|| "read-only mode".into());
9328            self.error_message = Some(format!("{reason} — deploy-from-local disabled"));
9329            return;
9330        }
9331        // Path resolution: ~ expansion + check file exists + size.
9332        // The bundle is streamed from disk by the AWS layer, not slurped
9333        // here — keeps RAM flat regardless of bundle size and lets the
9334        // multipart path handle anything above MULTIPART_THRESHOLD.
9335        let resolved = expand_tilde(&path);
9336        let resolved_path = std::path::PathBuf::from(&resolved);
9337        let size = match std::fs::metadata(&resolved_path) {
9338            Ok(m) => m.len(),
9339            Err(e) => {
9340                self.error_message = Some(format!("can't read {resolved}: {e}"));
9341                return;
9342            }
9343        };
9344        if size == 0 {
9345            self.error_message = Some(format!("{resolved} is empty"));
9346            return;
9347        }
9348        // Derive label if the operator didn't pin one. We use the filename
9349        // basename + a unix timestamp so re-deploys don't collide.
9350        let label = explicit_label
9351            .unwrap_or_else(|| derive_version_label(&resolved, chrono::Utc::now().timestamp()));
9352        let env_name = env.name.clone();
9353        let app_name = env.application.clone();
9354        let summary = if and_deploy {
9355            format!("deploy-from-local {label}")
9356        } else {
9357            format!("upload-version {label}")
9358        };
9359        write_audit_line(
9360            self.context.account_id.as_deref(),
9361            self.context.profile.as_deref(),
9362            &self.context.region,
9363            &format!(
9364                "stage=dispatched action=DeployFromLocal target={env_name} label={label} bytes={size} and_deploy={and_deploy}"
9365            ),
9366        );
9367        self.push_pending(summary.clone(), env_name.clone());
9368        let aws = self.aws.clone();
9369        let tx = self.msg_tx.clone();
9370        let gen = self.generation;
9371        let env_for_msg = env_name.clone();
9372        let label_for_msg = label.clone();
9373        let summary_for_msg = summary.clone();
9374        let account = self.context.account_id.clone();
9375        let profile = self.context.profile.clone();
9376        let region = self.context.region.clone();
9377        let description_owned = description;
9378        tokio::spawn(async move {
9379            // Three (or four) stages: bucket → put → create version → (deploy).
9380            // We surface the stage name in any error so the operator knows
9381            // where it failed.
9382            let bucket = match aws.create_storage_location().await {
9383                Ok(b) => b,
9384                Err(e) => {
9385                    let err = format!(
9386                        "storage-location: {}",
9387                        flatten_err("create_storage_location", e)
9388                    );
9389                    finish_deploy_from_local(
9390                        &tx,
9391                        gen,
9392                        env_for_msg,
9393                        label_for_msg,
9394                        summary_for_msg,
9395                        account.as_deref(),
9396                        profile.as_deref(),
9397                        &region,
9398                        Err(err),
9399                    );
9400                    return;
9401                }
9402            };
9403            // Key: `applications/<app>/<label>` mirrors EB's own layout.
9404            let key = format!("applications/{app_name}/{label_for_msg}");
9405            if let Err(e) = aws.upload_bundle(&bucket, &key, &resolved_path).await {
9406                let err = format!("s3-put: {}", flatten_err("upload_bundle", e));
9407                finish_deploy_from_local(
9408                    &tx,
9409                    gen,
9410                    env_for_msg,
9411                    label_for_msg,
9412                    summary_for_msg,
9413                    account.as_deref(),
9414                    profile.as_deref(),
9415                    &region,
9416                    Err(err),
9417                );
9418                return;
9419            }
9420            if let Err(e) = aws
9421                .create_app_version(
9422                    &app_name,
9423                    &label_for_msg,
9424                    description_owned.as_deref(),
9425                    &bucket,
9426                    &key,
9427                )
9428                .await
9429            {
9430                let err = format!("create-version: {}", flatten_err("create_app_version", e));
9431                finish_deploy_from_local(
9432                    &tx,
9433                    gen,
9434                    env_for_msg,
9435                    label_for_msg,
9436                    summary_for_msg,
9437                    account.as_deref(),
9438                    profile.as_deref(),
9439                    &region,
9440                    Err(err),
9441                );
9442                return;
9443            }
9444            if and_deploy {
9445                if let Err(e) = aws.deploy_version(&env_for_msg, &label_for_msg).await {
9446                    let err = format!("deploy: {}", flatten_err("deploy_version", e));
9447                    finish_deploy_from_local(
9448                        &tx,
9449                        gen,
9450                        env_for_msg,
9451                        label_for_msg,
9452                        summary_for_msg,
9453                        account.as_deref(),
9454                        profile.as_deref(),
9455                        &region,
9456                        Err(err),
9457                    );
9458                    return;
9459                }
9460            }
9461            finish_deploy_from_local(
9462                &tx,
9463                gen,
9464                env_for_msg,
9465                label_for_msg,
9466                summary_for_msg,
9467                account.as_deref(),
9468                profile.as_deref(),
9469                &region,
9470                Ok(()),
9471            );
9472        });
9473    }
9474
9475    /// Dispatch a `DeleteApplicationVersion` for the selected env's app.
9476    /// `force` also requests `DeleteSourceBundle=true` so the underlying
9477    /// `.zip` is removed from the env's storage bucket.
9478    fn spawn_delete_app_version(&mut self, label: String, force: bool) {
9479        let Some(env) = self.selected_env().cloned() else {
9480            self.error_message = Some("no env selected".into());
9481            return;
9482        };
9483        if self.deny_write(&env.name, "delete-version") {
9484            return;
9485        }
9486        let application = env.application.clone();
9487        let force_str = if force { " (+source bundle)" } else { "" };
9488        let detail = format!(
9489            "stage=dispatched action=DeleteAppVersion target={application}/{label}{force_str}"
9490        );
9491        write_audit_line(
9492            self.context.account_id.as_deref(),
9493            self.context.profile.as_deref(),
9494            &self.context.region,
9495            &detail,
9496        );
9497        // In-flight ack lives on the pending pill; completion toasts.
9498        let _ = force_str;
9499        let pending_label = if force {
9500            "Delete app version (+source)"
9501        } else {
9502            "Delete app version"
9503        };
9504        let pending_target = format!("{application}/{label}");
9505        self.push_pending(pending_label, pending_target);
9506        let aws = self.aws.clone();
9507        let tx = self.msg_tx.clone();
9508        let gen = self.generation;
9509        let account = self.context.account_id.clone();
9510        let profile = self.context.profile.clone();
9511        let region = self.context.region.clone();
9512        let app_for_msg = application.clone();
9513        let label_for_msg = label.clone();
9514        tokio::spawn(async move {
9515            let result = aws
9516                .delete_application_version(&application, &label, force)
9517                .await
9518                .map_err(|e| flatten_err("delete_application_version", e));
9519            let outcome = match &result {
9520                Ok(()) => format!(
9521                    "stage=completed action=DeleteAppVersion target={application}/{label}{force_str} outcome=ok"
9522                ),
9523                Err(e) => format!(
9524                    "stage=completed action=DeleteAppVersion target={application}/{label}{force_str} outcome=err err=\"{}\"",
9525                    crate::audit::escape_value(e)
9526                ),
9527            };
9528            write_audit_line(account.as_deref(), profile.as_deref(), &region, &outcome);
9529            let _ = tx.send(AppMsg::DeleteAppVersion {
9530                gen,
9531                application: app_for_msg,
9532                label: label_for_msg,
9533                force,
9534                result,
9535            });
9536        });
9537    }
9538
9539    /// Key handler for the interactive saved-configs overlay. Cursor moves
9540    /// with j/k/arrows/g/G; `a` applies the selected template to the current
9541    /// env (via `apply_config_template`); `x` deletes it; `c` closes the
9542    /// overlay and prefills `:config-save ` so the user can type a name; `?`
9543    /// stashes the overlay and surfaces the SavedConfigs help topic — closing
9544    /// help restores the overlay.
9545    fn handle_saved_configs_interactive_key(&mut self, key: KeyEvent) {
9546        // Mutate cursor in-place for navigation keys, then return early; for
9547        // dispatch keys (a/x/c) extract the selected pair, clear the overlay,
9548        // and re-enter the existing command path so we inherit read-only
9549        // gating + audit trail + ActionResult plumbing.
9550        {
9551            let Some(Overlay::SavedConfigsInteractive {
9552                items,
9553                cursor,
9554                confirm_delete,
9555            }) = self.current_overlay.as_mut()
9556            else {
9557                return;
9558            };
9559            if items.is_empty() {
9560                self.current_overlay = None;
9561                return;
9562            }
9563            let len = items.len();
9564            // When the delete confirm is armed, only y/Y/enter and n/N/esc do
9565            // anything — navigation keys are inert so a stray j/k doesn't
9566            // discard the confirm state and reset the cursor.
9567            if *confirm_delete {
9568                match key.code {
9569                    KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
9570                        *confirm_delete = false;
9571                        return;
9572                    }
9573                    KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
9574                        // Fall through to the dispatch block below.
9575                    }
9576                    _ => return,
9577                }
9578            } else {
9579                match key.code {
9580                    KeyCode::Char('j') | KeyCode::Down => {
9581                        *cursor = (*cursor + 1).min(len.saturating_sub(1));
9582                        return;
9583                    }
9584                    KeyCode::Char('k') | KeyCode::Up => {
9585                        *cursor = cursor.saturating_sub(1);
9586                        return;
9587                    }
9588                    KeyCode::Char('g') | KeyCode::Home => {
9589                        *cursor = 0;
9590                        return;
9591                    }
9592                    KeyCode::Char('G') | KeyCode::End => {
9593                        *cursor = len.saturating_sub(1);
9594                        return;
9595                    }
9596                    KeyCode::Char('x') => {
9597                        *confirm_delete = true;
9598                        return;
9599                    }
9600                    _ => {}
9601                }
9602            }
9603        }
9604        let Some(Overlay::SavedConfigsInteractive {
9605            items,
9606            cursor,
9607            confirm_delete,
9608        }) = self.current_overlay.as_ref()
9609        else {
9610            return;
9611        };
9612        let cursor = *cursor;
9613        let confirm_delete = *confirm_delete;
9614        let selected = items.get(cursor).cloned();
9615        match key.code {
9616            KeyCode::Esc | KeyCode::Char('q') => {
9617                self.current_overlay = None;
9618            }
9619            KeyCode::Char('a') | KeyCode::Enter if !confirm_delete => {
9620                if let Some((_app, template)) = selected {
9621                    self.current_overlay = None;
9622                    let Some(env) = self.selected_env().cloned() else {
9623                        self.error_message = Some("no env selected".into());
9624                        return;
9625                    };
9626                    // Direct call bypasses execute_command's whitespace
9627                    // split so template names with spaces work.
9628                    self.spawn_config_apply_template(env.name, template);
9629                }
9630            }
9631            // y/Y/enter under armed-confirm dispatches the delete.
9632            KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter if confirm_delete => {
9633                if let Some((app_name, template)) = selected {
9634                    self.current_overlay = None;
9635                    self.spawn_config_delete_template(app_name, template);
9636                }
9637            }
9638            KeyCode::Char('c') => {
9639                self.current_overlay = None;
9640                self.command_input = "config-save ".into();
9641                self.mode = Mode::Command;
9642            }
9643            KeyCode::Char('i') => {
9644                // Inspect: close the interactive overlay and dispatch
9645                // config-inspect directly. Template name may contain spaces
9646                // (e.g. "Dev config pre-redis") — direct method call avoids
9647                // execute_command's whitespace-split parser.
9648                if let Some((app_name, template)) = selected {
9649                    self.current_overlay = None;
9650                    self.spawn_config_inspect_template(app_name, template);
9651                }
9652            }
9653            KeyCode::Char('?') => {
9654                self.help.pre_overlay = self.current_overlay.take();
9655                self.help.pre_mode = Some(self.mode);
9656                self.help.topic = HelpTopic::SavedConfigs;
9657                self.mode = Mode::Help;
9658            }
9659            _ => {}
9660        }
9661    }
9662
9663    /// Dispatch an `UpdateTagsForResource` for the selected env. `to_add`
9664    /// and `to_remove` follow EB semantics: the API allows both in a single
9665    /// call; we surface a summary toast either way.
9666    fn spawn_tag_update(&mut self, to_add: Vec<(String, String)>, to_remove: Vec<String>) {
9667        let Some(env) = self.selected_env().cloned() else {
9668            self.error_message = Some("no env selected".into());
9669            return;
9670        };
9671        if self.deny_write(&env.name, "tag edits") {
9672            return;
9673        }
9674        let Some(arn) = env.arn.clone() else {
9675            self.error_message = Some(format!("env {} has no ARN — re-fetch and retry", env.name));
9676            return;
9677        };
9678        if to_add.is_empty() && to_remove.is_empty() {
9679            self.error_message =
9680                Some("nothing to do — provide tags to add or keys to remove".into());
9681            return;
9682        }
9683        let summary = if !to_add.is_empty() {
9684            let keys: Vec<String> = to_add.iter().map(|(k, _)| k.clone()).collect();
9685            format!("tag {}", keys.join(","))
9686        } else {
9687            format!("untag {}", to_remove.join(","))
9688        };
9689        let detail = format!(
9690            "stage=dispatched action=UpdateTags target={} {}",
9691            env.name, summary
9692        );
9693        write_audit_line(
9694            self.context.account_id.as_deref(),
9695            self.context.profile.as_deref(),
9696            &self.context.region,
9697            &detail,
9698        );
9699        // Label intentionally carries the operation (`tag …` / `untag …`) so
9700        // the pending panel distinguishes simultaneous edits. The pending
9701        // pill in the header is the in-flight truth-source; no
9702        // status_message ack here (would race with the next operation).
9703        self.push_pending(summary.clone(), env.name.clone());
9704        let aws = self.aws.clone();
9705        let tx = self.msg_tx.clone();
9706        let gen = self.generation;
9707        let env_name = env.name.clone();
9708        let summary_for_msg = summary.clone();
9709        let account = self.context.account_id.clone();
9710        let profile = self.context.profile.clone();
9711        let region = self.context.region.clone();
9712        tokio::spawn(async move {
9713            let result = aws
9714                .update_tags(&arn, &to_add, &to_remove)
9715                .await
9716                .map_err(|e| flatten_err("update_tags", e));
9717            let outcome_detail = match &result {
9718                Ok(()) => format!(
9719                    "stage=completed action=UpdateTags target={env_name} {summary} outcome=ok"
9720                ),
9721                Err(e) => format!(
9722                    "stage=completed action=UpdateTags target={env_name} {summary} outcome=err err=\"{}\"",
9723                    crate::audit::escape_value(e),
9724                ),
9725            };
9726            write_audit_line(
9727                account.as_deref(),
9728                profile.as_deref(),
9729                &region,
9730                &outcome_detail,
9731            );
9732            let _ = tx.send(AppMsg::TagUpdate {
9733                gen,
9734                env_name,
9735                summary: summary_for_msg,
9736                result,
9737            });
9738        });
9739    }
9740
9741    fn spawn_preflight_events(&mut self, env_name: String) {
9742        let env_for_msg = env_name.clone();
9743        self.spawn_aws(
9744            "preflight_events",
9745            move |aws| async move { aws.list_events_for_env(&env_name, 3).await },
9746            move |gen, result| AppMsg::PreflightEvents {
9747                gen,
9748                env_name: env_for_msg,
9749                result,
9750            },
9751        );
9752    }
9753
9754    fn spawn_dry_run(&mut self, env_name: String) {
9755        let env_for_msg = env_name.clone();
9756        self.spawn_aws(
9757            "dry_run_list_instances",
9758            move |aws| async move { aws.list_instances(&env_name).await },
9759            move |gen, result| AppMsg::DryRunResult {
9760                gen,
9761                env_name: env_for_msg,
9762                result,
9763            },
9764        );
9765    }
9766
9767    /// Kick off the version-list fetch for the Deploy confirm
9768    /// modal's inline preview. Builds the `format_deploy_preview`
9769    /// body off-thread so the spawn handler doesn't allocate; the
9770    /// handler just stuffs the rendered string into the modal.
9771    /// `current_label` may be empty for a brand-new env (first
9772    /// deploy) — the formatter handles that gracefully.
9773    fn spawn_version_preview(
9774        &mut self,
9775        app_name: String,
9776        env_name: String,
9777        current_label: String,
9778        candidate_label: String,
9779    ) {
9780        let env_for_msg = env_name.clone();
9781        let env_for_render = env_name.clone();
9782        let candidate_for_render = candidate_label.clone();
9783        self.spawn_aws(
9784            "version_preview",
9785            move |aws| async move { aws.list_application_versions(&app_name).await },
9786            move |gen, result| {
9787                let body = match result {
9788                    Ok(versions) => Ok(format_deploy_preview(
9789                        &env_for_render,
9790                        &current_label,
9791                        &candidate_for_render,
9792                        &versions,
9793                    )),
9794                    Err(e) => Err(e),
9795                };
9796                AppMsg::VersionPreview {
9797                    gen,
9798                    env_name: env_for_msg,
9799                    result: body,
9800                }
9801            },
9802        );
9803    }
9804
9805    /// Pre-deploy health-check probe for the confirm modal. Reads
9806    /// the env's current `Application Healthcheck URL` option
9807    /// (defaults to `/` if unset), composes a probe URL against
9808    /// the env's CNAME, and HEADs it via curl with a 2s cap. The
9809    /// outcome (success / non-2xx / timeout / refusal) is just a
9810    /// warning surface; it doesn't block the deploy.
9811    ///
9812    /// Shells out to `curl` for the same reason `fetch_url_text`
9813    /// and `fire_audit_webhook` do — keeps ebman HTTP-client-dep
9814    /// free. Output is parsed to an HTTP status code (or classified
9815    /// Pre-flight one region of a rollout: construct an
9816    /// AwsClient with the region override, list the region's
9817    /// envs, check whether the target env exists, and emit
9818    /// `AppMsg::RolloutPreflight`. Failure modes (STS error,
9819    /// list_environments failure, env not found) all land as
9820    /// per-region row state — the operator sees which regions
9821    /// passed pre-flight and which need investigation before
9822    /// pressing `y` to dispatch.
9823    pub(crate) fn spawn_rollout_preflight(
9824        &self,
9825        profile: Option<String>,
9826        region: String,
9827        env_name: String,
9828    ) {
9829        let tx = self.msg_tx.clone();
9830        let gen = self.generation;
9831        tokio::spawn(async move {
9832            let result = match crate::aws::AwsClient::with(profile, Some(region.clone())).await {
9833                Ok(client) => match client.list_environments().await {
9834                    Ok(envs) => match envs.iter().find(|e| e.name == env_name) {
9835                        Some(e) => Ok(e.version_label.clone()),
9836                        None => Err(format!("env '{env_name}' not found in region '{region}'")),
9837                    },
9838                    Err(e) => Err(format!("list_environments: {e}")),
9839                },
9840                Err(e) => Err(format!("AwsClient::with({region}): {e}")),
9841            };
9842            let _ = tx.send(AppMsg::RolloutPreflight {
9843                gen,
9844                region,
9845                result,
9846            });
9847        });
9848    }
9849
9850    /// Dispatch a single region of a rollout: construct an
9851    /// AwsClient with that region's override, fire
9852    /// `UpdateEnvironment(env, version_label)`, optionally poll
9853    /// for Green if `wait_for_green_secs` is set. Emits
9854    /// `AppMsg::RolloutDispatched` with the outcome. The handler
9855    /// advances the state machine (next region, or halt on
9856    /// failure).
9857    ///
9858    /// Reuses `deploy_settled_green` for the wait-for-green
9859    /// predicate. Polling cadence 5s; deadline `wait_for_green_secs`
9860    /// from the dispatch's start.
9861    pub(crate) fn spawn_rollout_dispatch(
9862        &self,
9863        profile: Option<String>,
9864        region: String,
9865        env_name: String,
9866        version_label: String,
9867        wait_for_green_secs: Option<u64>,
9868    ) {
9869        let tx = self.msg_tx.clone();
9870        let gen = self.generation;
9871        tokio::spawn(async move {
9872            let client = match crate::aws::AwsClient::with(profile, Some(region.clone())).await {
9873                Ok(c) => c,
9874                Err(e) => {
9875                    let _ = tx.send(AppMsg::RolloutDispatched {
9876                        gen,
9877                        region,
9878                        result: Err(format!("client: {e}")),
9879                    });
9880                    return;
9881                }
9882            };
9883            if let Err(e) = client.deploy_version(&env_name, &version_label).await {
9884                let _ = tx.send(AppMsg::RolloutDispatched {
9885                    gen,
9886                    region,
9887                    result: Err(format!("deploy_version: {e}")),
9888                });
9889                return;
9890            }
9891            if let Some(secs) = wait_for_green_secs {
9892                let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(secs);
9893                loop {
9894                    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
9895                    if tokio::time::Instant::now() >= deadline {
9896                        let _ = tx.send(AppMsg::RolloutDispatched {
9897                            gen,
9898                            region,
9899                            result: Err(format!("did not reach Green within {secs}s")),
9900                        });
9901                        return;
9902                    }
9903                    match client.list_environments().await {
9904                        Ok(envs) => {
9905                            let (status, health) = envs
9906                                .iter()
9907                                .find(|e| e.name == env_name)
9908                                .map(|e| (e.status.clone(), e.health.clone()))
9909                                .unwrap_or_default();
9910                            if deploy_settled_green(&status, &health) {
9911                                break;
9912                            }
9913                        }
9914                        Err(e) => {
9915                            let _ = tx.send(AppMsg::RolloutDispatched {
9916                                gen,
9917                                region,
9918                                result: Err(format!("poll list_environments: {e}")),
9919                            });
9920                            return;
9921                        }
9922                    }
9923                }
9924            }
9925            let _ = tx.send(AppMsg::RolloutDispatched {
9926                gen,
9927                region,
9928                result: Ok(()),
9929            });
9930        });
9931    }
9932
9933    /// as a transport error) so the warning text can surface what
9934    /// specifically failed.
9935    fn spawn_health_check_probe(&mut self, app_name: String, env_name: String, cname: String) {
9936        let env_for_msg = env_name.clone();
9937        let aws = self.aws.clone();
9938        let tx = self.msg_tx.clone();
9939        let gen = self.generation;
9940        tokio::spawn(async move {
9941            // Look up the configured health-check path. Missing or
9942            // empty setting means EB defaults to `/`, so we probe
9943            // the env root.
9944            let path = match aws.fetch_env_option_settings(&app_name, &env_name).await {
9945                Ok(opts) => opts
9946                    .into_iter()
9947                    .find(|(ns, name, _)| {
9948                        ns == "aws:elasticbeanstalk:application"
9949                            && name == "Application Healthcheck URL"
9950                    })
9951                    .map(|(_, _, v)| v)
9952                    .filter(|s| !s.is_empty())
9953                    .unwrap_or_else(|| "/".into()),
9954                Err(_) => "/".into(),
9955            };
9956            let url = build_health_check_probe_url(&cname, &path);
9957            let result = run_health_check_probe(&url).await;
9958            let _ = tx.send(AppMsg::HealthCheckProbe {
9959                gen,
9960                env_name: env_for_msg,
9961                result,
9962            });
9963        });
9964    }
9965
9966    /// Spawn the pre-deploy unavailability estimator. Fetches the
9967    /// env's option-settings, extracts deployment policy + batch +
9968    /// ASG max, formats a one-line summary, and emits
9969    /// `AppMsg::UnavailabilityEstimate`. Uses its own fetch rather
9970    /// than piggy-backing on the health-check probe — two parallel
9971    /// DescribeConfigurationSettings calls is fine for a one-shot
9972    /// modal open, and keeps the two features isolated so failure
9973    /// of one doesn't taint the other.
9974    fn spawn_unavailability_estimate(&mut self, app_name: String, env_name: String) {
9975        let env_for_msg = env_name.clone();
9976        self.spawn_aws(
9977            "unavailability_estimate",
9978            move |aws| async move { aws.fetch_env_option_settings(&app_name, &env_name).await },
9979            move |gen, result| {
9980                let line = result.ok().map(|opts| {
9981                    let (policy, batch, btype, asg_max) = extract_unavailability_inputs(&opts);
9982                    let count = compute_unavailability_count(&policy, batch, &btype, asg_max);
9983                    format_unavailability_line(&policy, count, asg_max)
9984                });
9985                AppMsg::UnavailabilityEstimate {
9986                    gen,
9987                    env_name: env_for_msg,
9988                    line,
9989                }
9990            },
9991        );
9992    }
9993
9994    /// Run the lint engine against the env at confirm-modal-open
9995    /// time. Same rules `:lint` and `ebman lint` use; same
9996    /// operator-tunable disables. Issues at `>= Warn` render as
9997    /// modal warning lines so the operator sees rule-keyed risk
9998    /// before authorising the action.
9999    ///
10000    /// Uses the same option-settings fetch path as the
10001    /// unavailability estimate + health-check probe, but issues
10002    /// its own DescribeConfigurationSettings call to keep the
10003    /// three features isolated. Failure of the read is non-
10004    /// blocking — modal renders without lint when the fetch
10005    /// fails (the operator can still see the rule-output via
10006    /// `:lint` once the modal closes).
10007    fn spawn_confirm_lint(&mut self, env: crate::aws::Environment) {
10008        let aws = self.aws.clone();
10009        let tx = self.msg_tx.clone();
10010        let gen = self.generation;
10011        // Snapshot operator-tunable disables — user-level (already
10012        // mirrored on App) + project-local (read fresh from cwd).
10013        let mut disabled = self.lint_disable.clone();
10014        disabled.extend(crate::project::load_lint_disables_from_cwd());
10015        let env_for_msg = env.name.clone();
10016        let app_name = env.application.clone();
10017        let env_name = env.name.clone();
10018        tokio::spawn(async move {
10019            let issues = match aws.fetch_env_option_settings(&app_name, &env_name).await {
10020                Ok(opts) => {
10021                    let ctx = crate::lint::LintContext {
10022                        env: &env,
10023                        options: &opts,
10024                        events: &[],
10025                        cost_usd_per_month: None,
10026                        latest_stack_version: None,
10027                    };
10028                    let rules = crate::lint::default_rules(&disabled);
10029                    crate::lint::run_rules(&rules, &ctx)
10030                }
10031                Err(_) => Vec::new(),
10032            };
10033            let _ = tx.send(AppMsg::ConfirmModalLint {
10034                gen,
10035                env_name: env_for_msg,
10036                issues,
10037            });
10038        });
10039    }
10040
10041    /// Fire a single non-destructive action for batch mode. Unlike
10042    /// `spawn_action` this doesn't need a `ConfirmModal` — the user already
10043    /// opted in by typing `:batch-…`. Only Rebuild and RestartAppServer are
10044    /// allowed; destructive actions still require per-env strict confirm.
10045    fn spawn_batch_action(&mut self, action: Action, env: String) {
10046        write_audit_entry(
10047            self.context.account_id.as_deref(),
10048            self.context.profile.as_deref(),
10049            &self.context.region,
10050            action,
10051            &env,
10052            None,
10053        );
10054        self.push_pending(action.label(), env.clone());
10055        let env_for_msg = env.clone();
10056        self.spawn_aws(
10057            "batch_action",
10058            move |aws| async move {
10059                match action {
10060                    Action::Rebuild => aws.rebuild_env(&env).await,
10061                    Action::RestartAppServer => aws.restart_app_server(&env).await,
10062                    _ => Err(color_eyre::eyre::eyre!(
10063                        "batch-mode only supports Rebuild / Restart"
10064                    )),
10065                }
10066            },
10067            move |gen, result| AppMsg::ActionResult {
10068                gen,
10069                action,
10070                env_name: env_for_msg,
10071                result,
10072            },
10073        );
10074    }
10075
10076    /// Per-env deploy dispatch for `:batch-deploy`. Shares the pending
10077    /// pill + audit + `ActionResult` plumbing with the existing single-env
10078    /// `:deploy` path via `Action::Deploy`.
10079    fn spawn_batch_deploy(&mut self, env: String, label: String) {
10080        let aws = self.aws.clone();
10081        let tx = self.msg_tx.clone();
10082        let gen = self.generation;
10083        write_audit_line(
10084            self.context.account_id.as_deref(),
10085            self.context.profile.as_deref(),
10086            &self.context.region,
10087            &format!("stage=dispatched action=Deploy target={env} version={label}"),
10088        );
10089        self.push_pending(Action::Deploy.label(), env.clone());
10090        let env_for_msg = env.clone();
10091        tokio::spawn(async move {
10092            let result = aws
10093                .deploy_version(&env, &label)
10094                .await
10095                .map_err(|e| flatten_err("deploy_version", e));
10096            let _ = tx.send(AppMsg::ActionResult {
10097                gen,
10098                action: Action::Deploy,
10099                env_name: env_for_msg,
10100                result,
10101            });
10102        });
10103    }
10104
10105    /// Per-env tag / untag dispatch for `:batch-tag` / `:batch-untag`.
10106    /// `value = Some(v)` adds the tag; `value = None` removes the key.
10107    /// Audit + pending entries label the op so a query against the audit
10108    /// log can distinguish tag from untag.
10109    fn spawn_batch_tag(&mut self, env: String, arn: String, key: String, value: Option<String>) {
10110        let aws = self.aws.clone();
10111        let tx = self.msg_tx.clone();
10112        let gen = self.generation;
10113        let is_add = value.is_some();
10114        let op_label = if is_add { "tag" } else { "untag" };
10115        let detail = match &value {
10116            Some(v) => {
10117                format!("stage=dispatched action=Tag target={env} key={key} value={v}")
10118            }
10119            None => format!("stage=dispatched action=Untag target={env} key={key}"),
10120        };
10121        write_audit_line(
10122            self.context.account_id.as_deref(),
10123            self.context.profile.as_deref(),
10124            &self.context.region,
10125            &detail,
10126        );
10127        let pending_label = format!("{op_label} {key}");
10128        self.push_pending(pending_label.clone(), env.clone());
10129        let env_for_msg = env.clone();
10130        tokio::spawn(async move {
10131            let to_add: Vec<(String, String)> = match &value {
10132                Some(v) => vec![(key.clone(), v.clone())],
10133                None => Vec::new(),
10134            };
10135            let to_remove: Vec<String> = if value.is_none() {
10136                vec![key.clone()]
10137            } else {
10138                Vec::new()
10139            };
10140            let result = aws
10141                .update_tags(&arn, &to_add, &to_remove)
10142                .await
10143                .map_err(|e| flatten_err("update_tags", e));
10144            let _ = tx.send(AppMsg::TagUpdate {
10145                gen,
10146                env_name: env_for_msg,
10147                summary: pending_label,
10148                result,
10149            });
10150        });
10151    }
10152
10153    /// Per-env option-settings dispatch for `:batch-set-option`. Each env
10154    /// is its own `UpdateEnvironment(option_settings)` call; the existing
10155    /// `spawn_option_settings_update` is selected-env-only so this is a
10156    /// parameterised parallel.
10157    fn spawn_batch_set_option(
10158        &mut self,
10159        env: String,
10160        namespace: String,
10161        name: String,
10162        value: String,
10163    ) {
10164        // Resolve the env's application name from the cached fleet
10165        // — needed for the option-settings read that backs undo
10166        // capture. If the env vanished mid-batch (context switch /
10167        // termination race), we skip the dispatch with an audit
10168        // line rather than firing a write against a stale name.
10169        let Some(app_name) = self
10170            .environments
10171            .iter()
10172            .find(|e| e.name == env)
10173            .map(|e| e.application.clone())
10174        else {
10175            write_audit_line(
10176                self.context.account_id.as_deref(),
10177                self.context.profile.as_deref(),
10178                &self.context.region,
10179                &format!(
10180                    "stage=skipped action=SetOption target={env} reason=\"env not in current view\""
10181                ),
10182            );
10183            return;
10184        };
10185        let aws = self.aws.clone();
10186        let tx = self.msg_tx.clone();
10187        let gen = self.generation;
10188        let detail = format!(
10189            "stage=dispatched action=SetOption target={env} ns={namespace} name={name} value={value}"
10190        );
10191        write_audit_line(
10192            self.context.account_id.as_deref(),
10193            self.context.profile.as_deref(),
10194            &self.context.region,
10195            &detail,
10196        );
10197        let pending_label = format!("set-option {namespace}.{name}");
10198        self.push_pending(pending_label.clone(), env.clone());
10199        let env_for_msg = env.clone();
10200        let env_for_undo = env.clone();
10201        let pending_label_for_undo = pending_label.clone();
10202        let to_set_for_undo: Vec<(String, String, String)> =
10203            vec![(namespace.clone(), name.clone(), value.clone())];
10204        tokio::spawn(async move {
10205            // Undo capture: read the env's current option-settings
10206            // BEFORE the write so :undo can reverse this batch entry
10207            // alongside any per-env writes. Read failure is non-
10208            // blocking — the write still proceeds, undo just isn't
10209            // captured for the affected env. Mirrors the safety-net
10210            // semantics of `spawn_option_settings_update`.
10211            let undo_entry = match aws
10212                .fetch_env_option_settings(&app_name, &env_for_undo)
10213                .await
10214            {
10215                Ok(opts) => Some(build_undo_entry(
10216                    &env_for_undo,
10217                    &pending_label_for_undo,
10218                    &to_set_for_undo,
10219                    &[],
10220                    &opts,
10221                )),
10222                Err(_) => None,
10223            };
10224            let settings = vec![(namespace, name, value)];
10225            let result = aws
10226                .update_env_option_settings(&env, &settings, &[])
10227                .await
10228                .map_err(|e| flatten_err("update_env_option_settings", e));
10229            // Only record undo on a successful write — otherwise
10230            // :undo would "revert" a write that never landed. Same
10231            // contract as the single-env spawn.
10232            if result.is_ok() {
10233                if let Some(entry) = undo_entry {
10234                    let _ = tx.send(AppMsg::UndoCaptured { gen, entry });
10235                }
10236            }
10237            let _ = tx.send(AppMsg::OptionSettingsUpdate {
10238                gen,
10239                env_name: env_for_msg,
10240                summary: pending_label,
10241                result,
10242            });
10243        });
10244    }
10245
10246    /// Open a confirm modal for an action that carries parameters (deploy
10247    /// version, clone target, scale min/max, …). Uses the same Y/N path as
10248    /// the existing Rebuild / Restart / Swap confirms so the operator sees
10249    /// the impact summary before authorising.
10250    /// Surface the selected instance's details as an `Overlay::TextDump`.
10251    /// Non-intrusive alternative to opening the EC2 console — operators
10252    /// can scan id / type / AZ / health / causes / launch age without
10253    /// leaving the TUI. `b` still opens the browser when needed.
10254    fn open_instance_info_overlay(&mut self) {
10255        let Some(d) = self.detail.as_ref() else {
10256            return;
10257        };
10258        let Some(inst) = d.instances.get(d.instances_cursor) else {
10259            self.status_message = Some("no instance selected".into());
10260            return;
10261        };
10262        let mut body = String::new();
10263        body.push_str(&format!("Instance ID       {}\n", inst.id));
10264        body.push_str(&format!("Type              {}\n", inst.instance_type));
10265        body.push_str(&format!("Availability zone {}\n", inst.availability_zone));
10266        body.push_str(&format!(
10267            "Health            {} ({})\n",
10268            inst.health, inst.color
10269        ));
10270        if let Some(t) = inst.launched_at {
10271            let age = chrono::Utc::now().signed_duration_since(t);
10272            body.push_str(&format!(
10273                "Launched          {}  (up {})\n",
10274                t.format("%Y-%m-%d %H:%M UTC"),
10275                humanize_short_age(age.to_std().unwrap_or_default())
10276            ));
10277        }
10278        if !inst.causes.is_empty() {
10279            body.push_str("\nCauses:\n");
10280            for c in &inst.causes {
10281                body.push_str(&format!("  • {c}\n"));
10282            }
10283        }
10284        body.push_str(
10285            "\nKeys: b → open in EC2 console · s → SSM shell · y → yank id · x → terminate",
10286        );
10287        self.current_overlay = Some(Overlay::TextDump {
10288            title: format!("instance — {}", inst.id),
10289            body,
10290        });
10291    }
10292
10293    /// Open the currently-selected instance (in the Instances tab) in the
10294    /// EC2 console. No-op when no instance is selected.
10295    fn open_instance_in_console(&mut self) {
10296        let Some(d) = self.detail.as_ref() else {
10297            return;
10298        };
10299        let Some(inst) = d.instances.get(d.instances_cursor) else {
10300            return;
10301        };
10302        let region = self.context.region.clone();
10303        let id = inst.id.clone();
10304        let url = format!(
10305            "https://{region}.console.aws.amazon.com/ec2/home?region={region}#InstanceDetails:instanceId={id}"
10306        );
10307        let display = id.clone();
10308        let result = std::process::Command::new(if cfg!(target_os = "macos") {
10309            "open"
10310        } else {
10311            "xdg-open"
10312        })
10313        .arg(&url)
10314        .stdout(std::process::Stdio::null())
10315        .stderr(std::process::Stdio::null())
10316        .spawn();
10317        match result {
10318            Ok(_) => {
10319                self.status_message = Some(format!("opened {display} in EC2 console"));
10320            }
10321            Err(e) => {
10322                self.error_message = Some(format!("could not open browser: {e}"));
10323            }
10324        }
10325    }
10326
10327    /// Copy the currently-selected instance ID to the clipboard.
10328    fn yank_instance_id(&mut self) {
10329        let Some(d) = self.detail.as_ref() else {
10330            return;
10331        };
10332        let Some(inst) = d.instances.get(d.instances_cursor) else {
10333            return;
10334        };
10335        let id = inst.id.clone();
10336        match yank(&id) {
10337            Ok(()) => self.status_message = Some(format!("yanked instance id: {id}")),
10338            Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
10339        }
10340    }
10341
10342    /// Fire `ec2:TerminateInstances` for the selected instance. ASG will
10343    /// re-launch a replacement automatically. Goes through the same
10344    /// `AppMsg::ActionResult` path so the status surface stays consistent.
10345    fn spawn_terminate_instance(&mut self, idx: usize) {
10346        let Some(d) = self.detail.as_ref() else {
10347            return;
10348        };
10349        let Some(inst) = d.instances.get(idx).cloned() else {
10350            return;
10351        };
10352        let env_name = d.env_name.clone();
10353        if self.is_read_only_for(&env_name) {
10354            let reason = self
10355                .read_only_reason(&env_name)
10356                .unwrap_or_else(|| "read-only mode".into());
10357            self.error_message = Some(format!("{reason} — terminate-instance disabled"));
10358            return;
10359        }
10360        let id = inst.id.clone();
10361        let aws = self.aws.clone();
10362        let tx = self.msg_tx.clone();
10363        let gen = self.generation;
10364        write_audit_line(
10365            self.context.account_id.as_deref(),
10366            self.context.profile.as_deref(),
10367            &self.context.region,
10368            &format!("stage=dispatched action=TerminateInstance target={env_name} instance={id}"),
10369        );
10370        // Pending target carries env + instance id so the operator can tell
10371        // simultaneous terminations apart. Label must match
10372        // `Action::TerminateInstance.label()` exactly so the AppMsg handler's
10373        // `complete_pending` finds the row.
10374        let target = format!("{env_name}/{id}");
10375        self.push_pending(Action::TerminateInstance.label(), target.clone());
10376        // In-flight ack lives on the pending pill; completion toasts.
10377        let _ = id;
10378        tokio::spawn(async move {
10379            let result = aws
10380                .terminate_instance(&id)
10381                .await
10382                .map_err(|e| flatten_err("terminate_instance", e));
10383            let _ = tx.send(AppMsg::ActionResult {
10384                gen,
10385                action: Action::TerminateInstance,
10386                env_name: target,
10387                result,
10388            });
10389        });
10390    }
10391
10392    /// Add a row to the pending-actions panel before dispatching. Callers
10393    /// follow with a `tokio::spawn` that sends an `AppMsg::ActionResult`;
10394    /// the result handler finds the first matching unfinished row and
10395    /// stamps it with the outcome. Caps the list at `PENDING_CAP`.
10396    pub fn push_pending(&mut self, label: impl Into<String>, target: impl Into<String>) {
10397        if self.pending_actions.len() >= PENDING_CAP {
10398            self.pending_actions.pop_front();
10399        }
10400        self.pending_actions.push_back(PendingAction {
10401            label: label.into(),
10402            target: target.into(),
10403            started: Instant::now(),
10404            completed: None,
10405        });
10406    }
10407
10408    /// Resolve a pending entry against an arriving `ActionResult`. Picks
10409    /// the oldest unfinished entry whose `(label, target)` match — the
10410    /// dispatch order is preserved so this is correct without IDs as long
10411    /// as we don't have two concurrent dispatches of the same action to the
10412    /// same target (a deliberate operator wouldn't do that).
10413    pub fn complete_pending(&mut self, label: &str, target: &str, result: Result<(), String>) {
10414        if let Some(entry) = self
10415            .pending_actions
10416            .iter_mut()
10417            .find(|e| e.completed.is_none() && e.label == label && e.target == target)
10418        {
10419            entry.completed = Some((Instant::now(), result));
10420        }
10421    }
10422
10423    /// Drop completed entries older than `PENDING_COMPLETED_TTL`. Called
10424    /// from the run loop's per-frame housekeeping so the panel quietens
10425    /// after a minute of inactivity.
10426    pub fn expire_pending(&mut self) {
10427        let now = Instant::now();
10428        self.pending_actions.retain(|e| match e.completed {
10429            Some((c, _)) => now.duration_since(c) < PENDING_COMPLETED_TTL,
10430            None => true,
10431        });
10432    }
10433
10434    fn open_parameterised_action(&mut self, action: Action, params: ParameterisedAction) {
10435        let Some(env) = self.selected_env().cloned() else {
10436            self.error_message = Some("no env selected".into());
10437            return;
10438        };
10439        self.open_parameterised_action_on(env, action, params);
10440    }
10441
10442    /// Variant of `open_parameterised_action` that targets an
10443    /// explicit env rather than the currently-selected one. Used by
10444    /// commands like `:promote-env SOURCE TARGET` where the
10445    /// destination is named in the command itself, not implied by
10446    /// the table cursor. The `selected_env()`-based wrapper is the
10447    /// common path; this is the cursor-independent escape hatch.
10448    pub(crate) fn open_parameterised_action_on(
10449        &mut self,
10450        env: crate::aws::Environment,
10451        action: Action,
10452        params: ParameterisedAction,
10453    ) {
10454        if self.deny_write(&env.name, action.label()) {
10455            return;
10456        }
10457        // The preflight (impact preview + last-3 events) is gated by
10458        // `Action::wants_preflight()` — single source of truth, see
10459        // `mode_action.rs`. Every ConfirmModal construction site must
10460        // route through here so the rule can't drift.
10461        let wants_preflight = action.wants_preflight();
10462        // For Deploy with a candidate label, pull the version
10463        // metadata (label / age / description) and inline the
10464        // existing `:deploy --preview` body in the modal. Saves
10465        // the operator the separate `:deploy LABEL --preview`
10466        // round-trip.
10467        let wants_version_preview = action == Action::Deploy && params.deploy_version.is_some();
10468        let modal = ConfirmModal {
10469            action,
10470            target_env: env.name.clone(),
10471            swap_with: params.swap_with,
10472            typed: String::new(),
10473            kind: ConfirmKind::YesNo,
10474            dryrun: None,
10475            loading_dryrun: wants_preflight,
10476            recent_events: None,
10477            loading_events: wants_preflight,
10478            traffic_warning: compute_traffic_warning(&env),
10479            deploy_version: params.deploy_version.clone(),
10480            upgrade_platform_arn: params.upgrade_platform_arn,
10481            upgrade_platform_label: params.upgrade_platform_label,
10482            clone_target: params.clone_target,
10483            scale_min: params.scale_min,
10484            scale_max: params.scale_max,
10485            auto_rollback_secs: params.auto_rollback_secs,
10486            wait_for_green_secs: params.wait_for_green_secs,
10487            version_preview: None,
10488            loading_version_preview: wants_version_preview,
10489            health_check_probe: None,
10490            // Pre-deploy health-check probe only runs for Deploy
10491            // confirms — we want to warn the operator if the env's
10492            // current health-check-url is dead BEFORE they ship a
10493            // new build over it. For non-Deploy actions, the probe
10494            // is meaningless. Skipped in `--demo` mode because the
10495            // synthetic CNAMEs would always fail DNS and pollute
10496            // screencasts with a fake-warning red herring.
10497            loading_health_check: wants_version_preview && !env.cname.is_empty() && !self.demo_mode,
10498            unavailability_line: None,
10499            // Same gate as the health-check probe — only useful for
10500            // Deploy confirms; --demo mode skips the AWS call.
10501            loading_unavailability: wants_version_preview && !self.demo_mode,
10502            lint_issues: None,
10503            // Lint at confirm time runs against every confirm modal,
10504            // not just Deploy. The health-check probe + unavailability
10505            // pill specialise on deploys; lint is universal — operator
10506            // sees AllAtOnce / health-check-empty / cooldown-low etc.
10507            // before confirming any destructive action. Skipped in
10508            // demo mode (same gate as the other probes — no AWS
10509            // round-trip in demo).
10510            loading_lint: !self.demo_mode,
10511        };
10512        let needs_health_check_probe = modal.loading_health_check;
10513        let needs_unavailability = modal.loading_unavailability;
10514        let needs_lint = modal.loading_lint;
10515        self.action_flow = Some(ActionFlow::Confirm(modal));
10516        self.mode = Mode::Action;
10517        if wants_preflight {
10518            self.spawn_dry_run(env.name.clone());
10519            self.spawn_preflight_events(env.name.clone());
10520        }
10521        if wants_version_preview {
10522            if let Some(label) = params.deploy_version {
10523                self.spawn_version_preview(
10524                    env.application.clone(),
10525                    env.name.clone(),
10526                    env.version_label.clone(),
10527                    label,
10528                );
10529            }
10530        }
10531        if needs_health_check_probe {
10532            self.spawn_health_check_probe(
10533                env.application.clone(),
10534                env.name.clone(),
10535                env.cname.clone(),
10536            );
10537        }
10538        if needs_unavailability {
10539            self.spawn_unavailability_estimate(env.application.clone(), env.name.clone());
10540        }
10541        if needs_lint {
10542            self.spawn_confirm_lint(env.clone());
10543        }
10544    }
10545
10546    /// Fetch `list_compatible_platforms` for `env` and surface them in an
10547    /// overlay so the user can copy the desired ARN into `:upgrade <arn>`.
10548    fn spawn_list_compatible_platforms(&mut self, env_name: String) {
10549        let aws = self.aws.clone();
10550        let tx = self.msg_tx.clone();
10551        let gen = self.generation;
10552        self.status_message = Some(format!(
10553            "fetching compatible platform versions for {env_name}…"
10554        ));
10555        let env_for_msg = env_name.clone();
10556        tokio::spawn(async move {
10557            let result = aws
10558                .list_compatible_platforms(&env_name)
10559                .await
10560                .map_err(|e| flatten_err("list_compatible_platforms", e));
10561            let body = match result {
10562                Ok(p) if p.is_empty() => {
10563                    format!("No compatible platform versions found for {env_for_msg}.\n\nesc / q to close")
10564                }
10565                Ok(platforms) => {
10566                    let mut lines: Vec<String> = vec![
10567                        format!("Compatible platform versions for {env_for_msg}"),
10568                        "─────────────────────────────────────────────".into(),
10569                        String::new(),
10570                    ];
10571                    for p in platforms.iter().take(20) {
10572                        lines.push(format!(
10573                            "  v{}  {}  ({}, {})",
10574                            p.version, p.branch, p.status, p.lifecycle
10575                        ));
10576                        lines.push(format!("      {}", p.arn));
10577                    }
10578                    lines.push(String::new());
10579                    lines.push(
10580                        "Copy an ARN and run `:upgrade <ARN>` to migrate. esc / q to close".into(),
10581                    );
10582                    lines.join("\n")
10583                }
10584                Err(e) => format!("upgrade list failed: {e}\n\nesc / q to close"),
10585            };
10586            let _ = tx.send(AppMsg::TextOverlay {
10587                gen,
10588                title: format!("compatible platforms — {env_for_msg}"),
10589                body,
10590            });
10591        });
10592    }
10593
10594    /// Queue a single-env action with the cancel window. Called from
10595    /// the Y / TypeName-confirm paths in `handle_action_key`.
10596    fn queue_action_dispatch(&mut self, modal: ConfirmModal) {
10597        if self.pending_dispatch.is_some() {
10598            self.error_message = Some(
10599                "another action is mid-dispatch — wait for it to land or press U to undo".into(),
10600            );
10601            return;
10602        }
10603        let label = modal.action.label().to_string();
10604        let target = modal.target_env.clone();
10605        let deadline = Instant::now() + UNDO_WINDOW;
10606        self.pending_dispatch = Some(PendingDispatch {
10607            deadline,
10608            label: label.clone(),
10609            target: target.clone(),
10610            kind: PendingDispatchKind::Single { modal },
10611        });
10612        self.status_message = Some(format!(
10613            "{} → {} dispatches in {}s — press U to undo",
10614            label,
10615            target,
10616            UNDO_WINDOW.as_secs()
10617        ));
10618    }
10619
10620    /// Queue a batch dispatch with the same cancel window. Caller
10621    /// resolves the kind + display labels (e.g. `"Batch rebuild"` /
10622    /// `"5 envs"`) before invoking. One-at-a-time rule shared with
10623    /// `queue_action_dispatch`.
10624    pub(crate) fn queue_batch_dispatch(
10625        &mut self,
10626        label: String,
10627        target: String,
10628        kind: PendingDispatchKind,
10629    ) {
10630        if self.pending_dispatch.is_some() {
10631            self.error_message = Some(
10632                "another dispatch is mid-window — wait for it to land or press U to undo".into(),
10633            );
10634            return;
10635        }
10636        let deadline = Instant::now() + UNDO_WINDOW;
10637        let status = format!(
10638            "{} → {} dispatches in {}s — press U to undo",
10639            label,
10640            target,
10641            UNDO_WINDOW.as_secs()
10642        );
10643        self.pending_dispatch = Some(PendingDispatch {
10644            deadline,
10645            label,
10646            target,
10647            kind,
10648        });
10649        self.status_message = Some(status);
10650    }
10651
10652    /// Per-tick check called from the main loop. Fires whatever
10653    /// dispatch is queued when its cancel window expires. The
10654    /// per-variant dispatch re-uses the same helpers the immediate
10655    /// path used to call (`spawn_action`, `spawn_batch_*`), so audit
10656    /// log + pending pill + toast plumbing carry over unchanged.
10657    fn tick_pending_dispatch(&mut self) {
10658        let now = Instant::now();
10659        let Some(pd) = self.pending_dispatch.as_ref() else {
10660            return;
10661        };
10662        if now < pd.deadline {
10663            return;
10664        }
10665        let kind = pd.kind.clone();
10666        self.pending_dispatch = None;
10667        match kind {
10668            PendingDispatchKind::Single { modal } => self.spawn_action(modal),
10669            PendingDispatchKind::BatchAction { action, env_names } => {
10670                for env in env_names {
10671                    self.spawn_batch_action(action, env);
10672                }
10673            }
10674            PendingDispatchKind::BatchDeploy {
10675                env_names,
10676                version_label,
10677            } => {
10678                for env in env_names {
10679                    self.spawn_batch_deploy(env, version_label.clone());
10680                }
10681            }
10682            PendingDispatchKind::BatchTag {
10683                envs_with_arns,
10684                key,
10685                value,
10686            } => {
10687                for (env, arn) in envs_with_arns {
10688                    self.spawn_batch_tag(env, arn, key.clone(), value.clone());
10689                }
10690            }
10691            PendingDispatchKind::BatchSetOption {
10692                env_names,
10693                namespace,
10694                option_name,
10695                value,
10696            } => {
10697                for env in env_names {
10698                    self.spawn_batch_set_option(
10699                        env,
10700                        namespace.clone(),
10701                        option_name.clone(),
10702                        value.clone(),
10703                    );
10704                }
10705            }
10706        }
10707    }
10708
10709    /// Cancel the pending dispatch (bound to `U` in Normal mode).
10710    /// Audit-logs the cancel + emits a status toast. Silent abort
10711    /// would feel like a missed keypress.
10712    fn cancel_pending_dispatch(&mut self) {
10713        let Some(pd) = self.pending_dispatch.take() else {
10714            return;
10715        };
10716        let msg = format!("undone — {} → {} not dispatched", pd.label, pd.target);
10717        let action_for_audit = match &pd.kind {
10718            PendingDispatchKind::Single { modal } => format!("{:?}", modal.action),
10719            PendingDispatchKind::BatchAction { action, .. } => format!("Batch{action:?}"),
10720            PendingDispatchKind::BatchDeploy { .. } => "BatchDeploy".into(),
10721            PendingDispatchKind::BatchTag { value, .. } => {
10722                if value.is_some() {
10723                    "BatchTag".into()
10724                } else {
10725                    "BatchUntag".into()
10726                }
10727            }
10728            PendingDispatchKind::BatchSetOption { .. } => "BatchSetOption".into(),
10729        };
10730        write_audit_line(
10731            self.context.account_id.as_deref(),
10732            self.context.profile.as_deref(),
10733            &self.context.region,
10734            &format!(
10735                "stage=undone action={action_for_audit} target={}",
10736                pd.target
10737            ),
10738        );
10739        self.status_message = Some(msg);
10740    }
10741
10742    fn spawn_action(&mut self, modal: ConfirmModal) {
10743        // Per-env / per-account read-only locks short-circuit the
10744        // dispatch before any AWS call. `read_only_reason` returns
10745        // the specific cause (global toggle vs. config-pinned env vs.
10746        // pinned account) so the toast tells the operator exactly
10747        // which knob is keeping them safe.
10748        if self.is_read_only_for(&modal.target_env) {
10749            let reason = self
10750                .read_only_reason(&modal.target_env)
10751                .unwrap_or_else(|| "read-only mode".into());
10752            self.error_message = Some(format!("{reason} — {} disabled", modal.action.label()));
10753            return;
10754        }
10755        // For Deploy actions: snapshot the env's pre-deploy version
10756        // label before dispatching, so :rollback-deploy and the
10757        // optional `--auto-rollback Nm` watchdog know what to roll
10758        // back TO. Skip if we don't have the env in our cached
10759        // fleet (e.g. assume-role race where the modal opened
10760        // before the refresh landed) — the existing :rollback can
10761        // scan events as a fallback.
10762        if modal.action == Action::Deploy {
10763            if let Some(env) = self
10764                .environments
10765                .iter()
10766                .find(|e| e.name == modal.target_env)
10767            {
10768                if !env.version_label.is_empty() {
10769                    self.deploy_snapshots.insert(
10770                        env.name.clone(),
10771                        DeploySnapshot {
10772                            env_name: env.name.clone(),
10773                            previous_version_label: env.version_label.clone(),
10774                            taken_at: chrono::Utc::now(),
10775                        },
10776                    );
10777                }
10778            }
10779            // Arm the auto-rollback watchdog if requested. Two signals
10780            // can fire: the env reaching Green on the next refresh
10781            // tick (early disarm via apply_refresh — most common
10782            // outcome) or the deadline timer firing AutoRollbackCheck.
10783            // `armed_watchdogs` carries the in-flight state for both
10784            // surfaces.
10785            if let Some(secs) = modal.auto_rollback_secs {
10786                let tx = self.msg_tx.clone();
10787                let env_name = modal.target_env.clone();
10788                let gen = self.generation;
10789                // Snapshot the rollback target now so the watchdog
10790                // doesn't have to re-look it up later. The pre-deploy
10791                // snapshot we just inserted is the source of truth.
10792                let target_label = self
10793                    .deploy_snapshots
10794                    .get(&modal.target_env)
10795                    .map(|s| s.previous_version_label.clone())
10796                    .unwrap_or_default();
10797                let armed_at = chrono::Utc::now();
10798                let deadline_at = armed_at + chrono::Duration::seconds(secs as i64);
10799                self.armed_watchdogs.insert(
10800                    modal.target_env.clone(),
10801                    ArmedWatchdog {
10802                        env_name: modal.target_env.clone(),
10803                        target_label,
10804                        armed_at,
10805                        deadline_at,
10806                    },
10807                );
10808                self.status_message = Some(format!(
10809                    "auto-rollback armed: {secs}s to reach Green or revert"
10810                ));
10811                tokio::spawn(async move {
10812                    tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
10813                    let _ = tx.send(AppMsg::AutoRollbackCheck { gen, env_name });
10814                });
10815            }
10816            // Arm the wait-for-green tracker if requested. Pure
10817            // observability — `apply_refresh` watches `watching_deploys`
10818            // and pins the outcome (success on Green, error on timeout).
10819            // No tokio task needed: apply_refresh runs on every refresh
10820            // tick anyway and checks deadlines there. Orthogonal to
10821            // auto-rollback so both flags can coexist.
10822            if let Some(secs) = modal.wait_for_green_secs {
10823                let target_label = modal.deploy_version.clone().unwrap_or_default();
10824                let armed_at = chrono::Utc::now();
10825                let deadline_at = armed_at + chrono::Duration::seconds(secs as i64);
10826                self.watching_deploys.insert(
10827                    modal.target_env.clone(),
10828                    WatchingDeploy {
10829                        env_name: modal.target_env.clone(),
10830                        target_label,
10831                        armed_at,
10832                        deadline_at,
10833                    },
10834                );
10835                // Don't clobber a "auto-rollback armed" message if
10836                // both flags were set — append instead.
10837                if let Some(existing) = self.status_message.as_mut() {
10838                    existing.push_str(&format!("; watching for Green ({secs}s)"));
10839                } else {
10840                    self.status_message = Some(format!(
10841                        "watching deploy: {secs}s to reach Green or report timeout"
10842                    ));
10843                }
10844            }
10845        }
10846        let aws = self.aws.clone();
10847        let tx = self.msg_tx.clone();
10848        let gen = self.generation;
10849        let action = modal.action;
10850        let env = modal.target_env.clone();
10851        let swap_with = modal.swap_with.clone();
10852        let deploy_version = modal.deploy_version.clone();
10853        let upgrade_arn = modal.upgrade_platform_arn.clone();
10854        let clone_target = modal.clone_target.clone();
10855        let scale_min = modal.scale_min;
10856        let scale_max = modal.scale_max;
10857        write_audit_entry(
10858            self.context.account_id.as_deref(),
10859            self.context.profile.as_deref(),
10860            &self.context.region,
10861            action,
10862            &env,
10863            swap_with.as_deref(),
10864        );
10865        self.push_pending(action.label(), env.clone());
10866        tokio::spawn(async move {
10867            let result = match action {
10868                Action::Rebuild => aws.rebuild_env(&env).await,
10869                Action::RestartAppServer => aws.restart_app_server(&env).await,
10870                Action::Terminate => aws.terminate_env(&env).await,
10871                Action::SwapCnames => match swap_with {
10872                    Some(dest) => aws.swap_cnames(&env, &dest).await,
10873                    None => Err(color_eyre::eyre::eyre!("swap target missing")),
10874                },
10875                Action::Deploy => match deploy_version {
10876                    Some(ver) => aws.deploy_version(&env, &ver).await,
10877                    None => Err(color_eyre::eyre::eyre!("deploy version missing")),
10878                },
10879                Action::UpgradePlatform => match upgrade_arn {
10880                    Some(arn) => aws.upgrade_platform(&env, &arn).await,
10881                    None => Err(color_eyre::eyre::eyre!("upgrade platform ARN missing")),
10882                },
10883                Action::Clone => match clone_target {
10884                    Some(target) => aws.clone_env(&env, &target).await,
10885                    None => Err(color_eyre::eyre::eyre!("clone target name missing")),
10886                },
10887                Action::Scale => match (scale_min, scale_max) {
10888                    (Some(mn), Some(mx)) => aws.scale_env(&env, mn, mx).await,
10889                    _ => Err(color_eyre::eyre::eyre!("scale min/max missing")),
10890                },
10891                Action::AbortUpdate => aws.abort_environment_update(&env).await,
10892                // Capacity opens a modal form (cmd_capacity) and dispatches
10893                // via spawn_option_settings_update — it never reaches
10894                // spawn_action's ConfirmModal path. Same for Config* and
10895                // TerminateInstance which have dedicated spawn paths.
10896                Action::Capacity
10897                | Action::ConfigSave
10898                | Action::ConfigDelete
10899                | Action::ConfigApply
10900                | Action::TerminateInstance => Err(color_eyre::eyre::eyre!(
10901                    "internal: {} dispatched through spawn_action path",
10902                    action.label()
10903                )),
10904            }
10905            .map_err(|e| flatten_err("action", e));
10906            let _ = tx.send(AppMsg::ActionResult {
10907                gen,
10908                action,
10909                env_name: env,
10910                result,
10911            });
10912        });
10913    }
10914
10915    fn spawn_detail_instances(&mut self, env_name: String) {
10916        if let Some(d) = self.detail.as_mut() {
10917            d.loading_instances = true;
10918            d.error = None;
10919        }
10920        // Demo-mode short-circuit: synthesise the fixture's per-env
10921        // instance list and send the result message directly. Avoids
10922        // firing list_instances against the stub AwsClient, which
10923        // would error and leave Detail/Instances stuck on "loading…".
10924        if self.demo_mode {
10925            let result = Ok(crate::demo_fixture::instances_for(&env_name));
10926            let gen = self.generation;
10927            let _ = self.msg_tx.send(AppMsg::DetailInstances {
10928                gen,
10929                env_name,
10930                result,
10931            });
10932            return;
10933        }
10934        let env_for_msg = env_name.clone();
10935        self.spawn_aws(
10936            "list_instances",
10937            move |aws| async move { aws.list_instances(&env_name).await },
10938            move |gen, result| AppMsg::DetailInstances {
10939                gen,
10940                env_name: env_for_msg,
10941                result,
10942            },
10943        );
10944    }
10945
10946    fn execute_command(&mut self, raw: &str) {
10947        let line = raw.trim();
10948        if line.is_empty() {
10949            return;
10950        }
10951        // Expand user-defined aliases first — `alias.dp = "deploy
10952        // --auto-rollback 5m"` + `:dp build-900` becomes the line
10953        // `deploy --auto-rollback 5m build-900`. Single-level
10954        // expansion only so `alias.x = "x"` can't loop. The
10955        // expansion is owned (String) because the borrowed `raw`
10956        // doesn't outlive this scope.
10957        let expanded = expand_command_alias(line, &self.command_aliases);
10958        let line = expanded.as_str();
10959        let mut parts = line.split_whitespace();
10960        let Some(cmd) = parts.next() else { return };
10961        let rest: Vec<&str> = parts.collect();
10962        match cmd {
10963            "q" | "quit" => self.quit = true,
10964            "refresh" => self.manual_refresh(),
10965            "help" | "?" => {
10966                // Mirror the `?` keybind: scope help to the screen the user
10967                // was on before opening the command bar. The Command-mode
10968                // transition doesn't leave a breadcrumb, so we infer from
10969                // what's currently set (Detail view live, action flow open,
10970                // DLQ open, interactive overlay open).
10971                self.help.topic = if self.detail.is_some() {
10972                    HelpTopic::Detail
10973                } else if self.action_flow.is_some() {
10974                    HelpTopic::Action
10975                } else if self.dlq.is_some() {
10976                    HelpTopic::Dlq
10977                } else if matches!(
10978                    self.current_overlay,
10979                    Some(Overlay::SavedConfigsInteractive { .. })
10980                ) {
10981                    HelpTopic::SavedConfigs
10982                } else {
10983                    HelpTopic::Global
10984                };
10985                self.help.pre_mode = Some(self.mode);
10986                self.mode = Mode::Help;
10987            }
10988            "region" | "r" => self.cmd_region(&rest),
10989            "custom-platforms" | "platforms" => self.cmd_custom_platforms(),
10990            "accounts" => self.cmd_accounts(),
10991            "org-health" => self.cmd_org_health(),
10992            "find-env" => match rest.first().copied() {
10993                None => {
10994                    self.error_message = Some(
10995                        "usage: :find-env <name-substring>  (scans every AWS profile + AssumeRole account)"
10996                            .into(),
10997                    );
10998                }
10999                Some(needle) => self.cmd_find_env(needle),
11000            },
11001            "envs-by-version" => match rest.first().copied() {
11002                None => {
11003                    self.error_message = Some(
11004                        "usage: :envs-by-version <label>  (scans every AWS profile + AssumeRole account for envs running that exact version label)"
11005                            .into(),
11006                    );
11007                }
11008                Some(label) => self.cmd_envs_by_version(label),
11009            },
11010            "logs-insights" => {
11011                // Pass the whole remainder verbatim. `cmd_logs_insights`
11012                // parses the optional `--window WINDOW` prefix; everything
11013                // after is the Insights query (spacing + punctuation kept
11014                // exactly as the operator typed it).
11015                let args = rest.join(" ");
11016                self.cmd_logs_insights(&args);
11017            }
11018            "account" => self.cmd_account(&rest),
11019            "profile" | "p" => self.cmd_profile(&rest),
11020            "sort" => self.cmd_sort(&rest),
11021            "group" => self.cmd_group(&rest),
11022            "redact" => self.cmd_redact(&rest),
11023            "events" => {
11024                self.event_panel.visible =
11025                    parse_toggle(rest.first().copied(), self.event_panel.visible);
11026                if self.event_panel.visible && self.event_panel.events.is_empty() {
11027                    self.spawn_events();
11028                }
11029                self.status_message = Some(if self.event_panel.visible {
11030                    "events panel ON".into()
11031                } else {
11032                    "events panel off".into()
11033                });
11034            }
11035            "event-time" => self.cmd_event_time(&rest),
11036            "export" => self.export_tsv(),
11037            "json" => self.export_json(),
11038            "report" | "markdown" => self.export_markdown(),
11039            "readonly" => {
11040                self.read_only = parse_toggle(rest.first().copied(), self.read_only);
11041                self.status_message = Some(if self.read_only {
11042                    "read-only ON — destructive actions disabled".into()
11043                } else {
11044                    "read-only off".into()
11045                });
11046            }
11047            "pin" => self.toggle_pin_selected(),
11048            "alias" => match rest.first().copied() {
11049                Some(name) => {
11050                    let label = rest[1..].join(" ");
11051                    if label.is_empty() {
11052                        self.error_message = Some(
11053                            "usage: :alias <env-name> <label>  (label cannot be empty)".to_string(),
11054                        );
11055                    } else {
11056                        self.aliases.insert(name.to_string(), label.clone());
11057                        self.status_message = Some(format!("alias '{name}' → \"{label}\""));
11058                        self.persist_state();
11059                    }
11060                }
11061                None => {
11062                    if self.aliases.is_empty() {
11063                        self.status_message = Some("no aliases set".into());
11064                    } else {
11065                        let list: Vec<String> = self
11066                            .aliases
11067                            .iter()
11068                            .map(|(k, v)| format!("{k} → \"{v}\""))
11069                            .collect();
11070                        self.status_message = Some(format!("aliases: {}", list.join("  ")));
11071                    }
11072                }
11073            },
11074            "alias-drop" | "alias-rm" => match rest.first() {
11075                Some(name) => {
11076                    if self.aliases.remove(*name).is_some() {
11077                        self.status_message = Some(format!("alias '{name}' removed"));
11078                        self.persist_state();
11079                    } else {
11080                        self.error_message = Some(format!("no alias for '{name}'"));
11081                    }
11082                }
11083                None => self.error_message = Some("usage: :alias-drop <env-name>".into()),
11084            },
11085            "whatsnew" => self.open_whatsnew(),
11086            "about" | "credits" => self.open_about_overlay(),
11087            "apps-info" => self.open_apps_info_overlay(),
11088            "cost" => self.cmd_cost(&rest),
11089            "listeners" => self.cmd_listeners(),
11090            "listener-edit" => self.cmd_listener_edit(&rest),
11091            "rds" => self.cmd_rds(),
11092            "rds-attach" => self.cmd_rds_attach(),
11093            "rds-detach" => self.cmd_rds_detach(&rest),
11094            "options" => self.cmd_options(&rest),
11095            "config-diff" => self.cmd_config_diff(&rest),
11096            "config-diff-local" => self.cmd_config_diff_local(&rest),
11097            "explain" => self.cmd_explain(&rest),
11098            "env-edit" => self.cmd_env_edit(),
11099            "secrets" => self.cmd_secrets(&rest),
11100            "secret" => self.cmd_secret_view(&rest),
11101            "report-bug" => self.open_report_bug_overlay(),
11102            "settings" => {
11103                self.open_settings_form();
11104            }
11105            "capacity" => self.cmd_capacity(),
11106            "scaling-triggers" => self.cmd_scaling_triggers(),
11107            "subnets" => self.open_subnets_form(),
11108            "elb-subnets" => self.open_elb_subnets_form(),
11109            "security-groups" => self.open_security_groups_form(),
11110            "update" => {
11111                // Surface the upgrade command for whichever install channel
11112                // looks live. Doesn't actually upgrade — operators on
11113                // AWS-touching tools prefer conscious upgrades, and
11114                // self-replacing the binary across Cellar / cargo-bin /
11115                // tarball layouts has too many platform footguns.
11116                let channel = crate::update_check::detect_install_channel();
11117                let cmd = channel.upgrade_command();
11118                let current = env!("CARGO_PKG_VERSION");
11119                let msg = match self.update_available.as_ref() {
11120                    Some(release) => format!(
11121                        "update available: {current} → {}.  run: {cmd}",
11122                        release.version
11123                    ),
11124                    None => {
11125                        format!("already on the latest ({current}).  to force-reinstall: {cmd}")
11126                    }
11127                };
11128                // Best-effort yank to the clipboard so the operator can
11129                // paste the upgrade command directly. Silent if the
11130                // clipboard isn't reachable.
11131                if let Ok(mut cb) = arboard::Clipboard::new() {
11132                    let _ = cb.set_text(cmd.to_string());
11133                }
11134                self.pin_status(msg);
11135            }
11136            "history" => {
11137                self.current_overlay = Some(Overlay::History(self.format_message_log()));
11138            }
11139            "saved-configs" | "configs" => {
11140                let items = collect_saved_configs(&self.applications);
11141                if items.is_empty() {
11142                    self.current_overlay = Some(Overlay::SavedConfigs(format_saved_configs(
11143                        &self.applications,
11144                    )));
11145                } else {
11146                    self.current_overlay = Some(Overlay::SavedConfigsInteractive {
11147                        items,
11148                        cursor: 0,
11149                        confirm_delete: false,
11150                    });
11151                }
11152            }
11153            "plugins" => {
11154                if self.plugins.is_empty() {
11155                    self.status_message =
11156                        Some("no plugins — add ~/.config/ebman/commands.toml".into());
11157                } else {
11158                    let names: Vec<&str> = self.plugins.keys().map(String::as_str).collect();
11159                    self.status_message = Some(format!(":<plugin>  {}", names.join(", ")));
11160                }
11161            }
11162            "diff" => match (rest.first(), rest.get(1)) {
11163                (None, _) => {
11164                    self.error_message = Some(
11165                        "usage: :diff ENV  (selected ↔ ENV)  |  :diff ENV-A ENV-B  (name both)"
11166                            .into(),
11167                    );
11168                }
11169                // Two-arg form: both envs named explicitly, so no
11170                // implicit selected-env side. Useful for picking
11171                // env-A ↔ env-B from a different scope than what's
11172                // currently selected.
11173                (Some(a), Some(b)) => {
11174                    if a == b {
11175                        self.error_message = Some("pick two different envs to compare".into());
11176                        return;
11177                    }
11178                    let Some(left) = self.environments.iter().find(|e| e.name == **a).cloned()
11179                    else {
11180                        self.error_message = Some(format!("no env named '{a}' in current view"));
11181                        return;
11182                    };
11183                    let Some(right) = self.environments.iter().find(|e| e.name == **b).cloned()
11184                    else {
11185                        self.error_message = Some(format!("no env named '{b}' in current view"));
11186                        return;
11187                    };
11188                    self.current_overlay =
11189                        Some(Overlay::Diff(diff_envs(&left, &right, self.redact)));
11190                }
11191                // Legacy single-arg form: selected (or detail-pane) env
11192                // compared against the named arg. Preserves the
11193                // existing behaviour every operator already knows.
11194                (Some(target), None) => {
11195                    let left_opt = if let Some(d) = self.detail.as_ref() {
11196                        Some(d.env_snapshot.clone())
11197                    } else {
11198                        self.selected_env().cloned()
11199                    };
11200                    let Some(left) = left_opt else {
11201                        self.error_message = Some("no env selected".into());
11202                        return;
11203                    };
11204                    if left.name == **target {
11205                        self.error_message = Some("pick a different env to compare against".into());
11206                        return;
11207                    }
11208                    let right = self
11209                        .environments
11210                        .iter()
11211                        .find(|e| e.name == **target)
11212                        .cloned();
11213                    match right {
11214                        None => {
11215                            self.error_message =
11216                                Some(format!("no env named '{target}' in current view"));
11217                        }
11218                        Some(right) => {
11219                            self.current_overlay =
11220                                Some(Overlay::Diff(diff_envs(&left, &right, self.redact)));
11221                        }
11222                    }
11223                }
11224            },
11225            "alarms" => {
11226                let env_opt = if let Some(d) = self.detail.as_ref() {
11227                    Some(d.env_name.clone())
11228                } else {
11229                    self.selected_env().map(|e| e.name.clone())
11230                };
11231                match env_opt {
11232                    Some(env_name) => self.spawn_alarms_fetch(env_name),
11233                    None => self.error_message = Some("no env selected".into()),
11234                }
11235            }
11236            "why" | "diagnose" => {
11237                let env_opt = if let Some(d) = self.detail.as_ref() {
11238                    Some((d.env_name.clone(), d.env_snapshot.application.clone()))
11239                } else {
11240                    self.selected_env()
11241                        .map(|e| (e.name.clone(), e.application.clone()))
11242                };
11243                match env_opt {
11244                    Some((env_name, app_name)) => self.open_why_red(env_name, app_name),
11245                    None => self.error_message = Some("no env selected".into()),
11246                }
11247            }
11248            "loglevel" => match rest.first() {
11249                None => {
11250                    self.status_message =
11251                        Some(format!("current log directive: {}", self.log_directive));
11252                }
11253                Some(level) => {
11254                    self.set_log_level(level);
11255                }
11256            },
11257            "cols" => self.cmd_cols(&rest),
11258            "save-view" => self.cmd_save_view(&rest),
11259            "view" => self.cmd_view(&rest),
11260            "views" => self.cmd_views(),
11261            "view-drop" => self.cmd_view_drop(&rest),
11262            "filter" | "f" => self.cmd_filter_load(&rest),
11263            "save" => self.cmd_save_filter(&rest),
11264            "drop" => self.cmd_drop_filter(&rest),
11265            "filters" => self.cmd_filters(),
11266            "batch-rebuild" => self.cmd_batch_action(Action::Rebuild),
11267            "batch-restart" => self.cmd_batch_action(Action::RestartAppServer),
11268            "batch-deploy" => self.cmd_batch_deploy(&rest),
11269            "batch-tag" => self.cmd_batch_tag_or_untag(true, &rest),
11270            "batch-untag" => self.cmd_batch_tag_or_untag(false, &rest),
11271            "batch-set-option" => self.cmd_batch_set_option(&rest),
11272            "versions" => self.cmd_versions(),
11273            "deploy" => self.cmd_deploy(&rest),
11274            "rollback" => self.cmd_rollback(&rest),
11275            "changes" => self.cmd_changes(),
11276            "lineage" => self.cmd_lineage(),
11277            "ssh" => self.cmd_ssh(&rest),
11278            "ssm-run" => self.cmd_ssm_run(&rest),
11279            "delete-version" => self.cmd_delete_version(&rest),
11280            "upgrade" => self.cmd_upgrade(&rest),
11281            "clone" => self.cmd_clone(&rest),
11282            "promote-env" => self.cmd_promote_env(&rest),
11283            "rollout" => self.cmd_rollout(&rest),
11284            "scale" => self.cmd_scale(&rest),
11285            "stop" => self.cmd_stop(),
11286            "start" => self.cmd_start(),
11287            "abort" => self.cmd_abort(),
11288            "pending" | "in-flight" | "inflight" => self.cmd_pending(),
11289            "rollbacks-armed" | "rb-armed" => self.cmd_rollbacks_armed(),
11290            "abort-rollback" => self.cmd_abort_rollback(&rest),
11291            "freeze-deploys" => self.cmd_freeze_deploys(&rest),
11292            "thaw-deploys" => self.cmd_thaw_deploys(),
11293            "undo" => self.cmd_undo(),
11294            "lint" => self.cmd_lint(&rest),
11295            "drift" => self.cmd_drift(&rest),
11296            "tag" => self.cmd_tag(&rest),
11297            "untag" => self.cmd_untag(&rest),
11298            "resources" | "res" => self.cmd_resources(),
11299            "rebuild" => self.cmd_rebuild(),
11300            "restart" => self.cmd_restart(),
11301            "terminate" => self.cmd_terminate(),
11302            "swap" => self.cmd_swap(&rest),
11303            "config-save" => self.cmd_config_save(&rest),
11304            "config-delete" => self.cmd_config_delete(&rest),
11305            "config-apply" => self.cmd_config_apply(&rest),
11306            "deployment-policy" => self.cmd_deployment_policy(&rest),
11307            "rolling-update" => self.cmd_rolling_update(&rest),
11308            "health-check-url" => self.cmd_health_check_url(&rest),
11309            "keypair" => self.cmd_keypair(&rest),
11310            "service-role" => self.cmd_service_role(&rest),
11311            "instance-profile" => self.cmd_instance_profile(&rest),
11312            "public-ip" => self.cmd_public_ip(&rest),
11313            "elb-scheme" => self.cmd_elb_scheme(&rest),
11314            "set-option" => self.cmd_set_option(&rest),
11315            "unset-option" => self.cmd_unset_option(&rest),
11316            "instance-type" => self.cmd_instance_type(&rest),
11317            "custom-platform-delete" => self.cmd_custom_platform_delete(&rest),
11318            "env" => self.cmd_env(&rest),
11319            "metric" => self.cmd_metric(&rest),
11320            "logs-tail" => {
11321                // `:logs-tail [LOG_GROUP]` — stream a CW Logs group for the
11322                // selected env. If no group given, discover groups for the
11323                // env and pick the most useful one (web.stdout.log if
11324                // present, else the first by name). The polling task is
11325                // tracked on App.log_tail_task so subsequent calls / close
11326                // can abort cleanly.
11327                let Some(env) = self.selected_env().cloned() else {
11328                    self.error_message = Some("no env selected".into());
11329                    return;
11330                };
11331                let explicit_group = rest.first().map(|s| s.to_string());
11332                self.spawn_logs_tail(env.name.clone(), explicit_group);
11333            }
11334            "logs-stream" => self.cmd_logs_stream(&rest),
11335            "notify" => self.cmd_notify(&rest),
11336            "managed-window" => self.cmd_managed_window(&rest),
11337            "alarm-create" => self.cmd_alarm_create(&rest),
11338            "alarm-delete" => self.cmd_alarm_delete(&rest),
11339            "alarm-history" => self.cmd_alarm_history(&rest),
11340            "config-inspect" => self.cmd_config_inspect(&rest),
11341            "deselect" | "select-clear" => {
11342                let n = self.multi_selected.len();
11343                self.multi_selected.clear();
11344                self.status_message = Some(format!("cleared {n} env selection(s)"));
11345            }
11346            other => {
11347                if let Some(plugin) = self.plugins.get(other).cloned() {
11348                    self.run_plugin_command(other, &plugin);
11349                    return;
11350                }
11351                // Did-you-mean: surface the closest registry name
11352                // within edit-distance 2. Catches everyday typos
11353                // like `:restrt` → `:restart`. Skips the suggestion
11354                // entirely when nothing's close enough — a wild
11355                // guess would mislead rather than help.
11356                let suggestion = suggest_command(other);
11357                let msg = match suggestion {
11358                    Some(name) => {
11359                        format!("unknown command: :{other} — did you mean :{name}? (try :help)")
11360                    }
11361                    None => format!("unknown command: :{other}  (try :help)"),
11362                };
11363                self.error_message = Some(msg);
11364            }
11365        }
11366    }
11367
11368    fn run_plugin_command(&mut self, name: &str, plugin: &crate::plugins::Plugin) {
11369        let env_opt = if let Some(d) = self.detail.as_ref() {
11370            Some(d.env_snapshot.clone())
11371        } else {
11372            self.selected_env().cloned()
11373        };
11374        let Some(env) = env_opt else {
11375            self.error_message = Some(format!(":{name} — no env selected"));
11376            return;
11377        };
11378        let rendered = crate::plugins::render(
11379            &plugin.template,
11380            &env.name,
11381            &env.cname,
11382            &env.application,
11383            &env.tier,
11384            &self.context.region,
11385            self.override_profile
11386                .as_deref()
11387                .or(self.context.profile.as_deref()),
11388        );
11389        match yank(&rendered) {
11390            Ok(()) => {
11391                self.status_message = Some(format!(
11392                    "plugin :{name} → clipboard ({} chars)",
11393                    rendered.chars().count()
11394                ));
11395            }
11396            Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
11397        }
11398    }
11399
11400    fn open_profile_picker(&mut self) {
11401        let items = profiles::load_profiles();
11402        let current = self.context.profile.as_deref();
11403        self.picker = Some(Picker::new(PickerKind::Profile, items, current));
11404        self.mode = Mode::Picker;
11405    }
11406
11407    fn open_region_picker(&mut self) {
11408        let mut items: Vec<String> = profiles::REGIONS.iter().map(|s| (*s).to_string()).collect();
11409        for r in &self.extra_regions {
11410            if !items.iter().any(|i| i == r) {
11411                items.push(r.clone());
11412            }
11413        }
11414        let current = Some(self.context.region.as_str());
11415        self.picker = Some(Picker::new(PickerKind::Region, items, current));
11416        self.mode = Mode::Picker;
11417    }
11418
11419    pub fn persist_state(&self) {
11420        // `--demo` mode runs against a synthetic fleet on a fake
11421        // profile/region with cost tracking flipped on by the fixture.
11422        // Writing that to ~/.config/ebman/state.toml would clobber the
11423        // operator's real saved state (selected env, sort, named
11424        // filters, cost-enabled, …) on every demo session exit. Bail
11425        // before touching disk.
11426        if self.demo_mode {
11427            return;
11428        }
11429        let selected = self.selected_env().map(|e| e.name.clone());
11430        // Persist the operator's *intent* first, then fall back to the
11431        // effective state. Override-wins matters when the user has
11432        // dispatched `:region X` (so `override_region` is `Some(X)`) but
11433        // the rebuild hasn't landed yet (so `context.region` is still the
11434        // *previous* region). Quitting in that gap would otherwise
11435        // persist the stale context and restore the user to the old
11436        // region on next launch. Falling back to `context` when override
11437        // is `None` covers the env-default case so we still remember
11438        // where the user was even if they never explicitly switched.
11439        let region = self.override_region.clone().or_else(|| {
11440            if !self.context.region.is_empty() && self.context.region != "unknown" {
11441                Some(self.context.region.clone())
11442            } else {
11443                None
11444            }
11445        });
11446        let profile = self
11447            .override_profile
11448            .clone()
11449            .or_else(|| self.context.profile.clone());
11450        tracing::debug!(
11451            target: "ebman::state",
11452            override_region = ?self.override_region,
11453            context_region = %self.context.region,
11454            persisted_region = ?region,
11455            override_profile = ?self.override_profile,
11456            context_profile = ?self.context.profile,
11457            persisted_profile = ?profile,
11458            "persist_state"
11459        );
11460        state::save(&PersistedState {
11461            profile,
11462            region,
11463            filter: if self.filter.is_empty() {
11464                None
11465            } else {
11466                Some(self.filter.clone())
11467            },
11468            sort: Some(format!(
11469                "{}:{}",
11470                self.sort_key.label(),
11471                if self.sort_desc { "desc" } else { "asc" }
11472            )),
11473            grouped: Some(self.grouped),
11474            redact: Some(self.redact),
11475            events_visible: Some(self.event_panel.visible),
11476            event_time_format: Some(self.event_panel.time_format),
11477            selected_env: selected,
11478            pinned: self.pinned.clone(),
11479            pinned_apps: self.pinned_apps.clone(),
11480            cost_enabled: Some(self.cost_enabled),
11481            aliases: self.aliases.clone(),
11482            saved_views: self.saved_views.clone(),
11483            deploy_snapshots: self
11484                .deploy_snapshots
11485                .iter()
11486                .map(|(env, snap)| (env.clone(), snap.to_persisted()))
11487                .collect(),
11488            hidden_cols: self.hidden_cols.clone(),
11489            custom_metrics: self.custom_metrics.clone(),
11490        });
11491    }
11492
11493    fn resort_envs(&mut self) {
11494        let key = self.sort_key;
11495        let desc = self.sort_desc;
11496        let pinned = self.pinned.clone();
11497        self.environments.sort_by(|a, b| {
11498            // Pinned envs always sort to the top regardless of key/direction.
11499            let a_pin = pinned.contains(&a.name);
11500            let b_pin = pinned.contains(&b.name);
11501            if a_pin != b_pin {
11502                return if a_pin {
11503                    std::cmp::Ordering::Less
11504                } else {
11505                    std::cmp::Ordering::Greater
11506                };
11507            }
11508            let ord = match key {
11509                SortKey::App => a
11510                    .application
11511                    .to_lowercase()
11512                    .cmp(&b.application.to_lowercase())
11513                    .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())),
11514                SortKey::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
11515                SortKey::Status => a
11516                    .status
11517                    .to_lowercase()
11518                    .cmp(&b.status.to_lowercase())
11519                    .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())),
11520                SortKey::Health => health_rank(&a.health)
11521                    .cmp(&health_rank(&b.health))
11522                    .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())),
11523                SortKey::Age => a.updated.cmp(&b.updated),
11524                SortKey::Version => a
11525                    .version_label
11526                    .to_lowercase()
11527                    .cmp(&b.version_label.to_lowercase()),
11528            };
11529            if desc {
11530                ord.reverse()
11531            } else {
11532                ord
11533            }
11534        });
11535        self.rebuild_view();
11536    }
11537
11538    fn yank_selected(&mut self, kind: YankKind) {
11539        let Some(env) = self.selected_env() else {
11540            self.status_message = Some("nothing to yank".into());
11541            return;
11542        };
11543        let value = match kind {
11544            YankKind::Cname => env.cname.clone(),
11545            YankKind::Name => env.name.clone(),
11546        };
11547        if value.is_empty() {
11548            self.status_message = Some("selected env has no value to yank".into());
11549            return;
11550        }
11551        match yank(&value) {
11552            Ok(()) => {
11553                self.status_message = Some(format!(
11554                    "copied {} to clipboard",
11555                    match kind {
11556                        YankKind::Cname => "CNAME",
11557                        YankKind::Name => "name",
11558                    }
11559                ));
11560            }
11561            Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
11562        }
11563    }
11564
11565    fn export_tsv(&mut self) {
11566        let count = self.cached_filtered.len();
11567        let mut out = String::new();
11568        out.push_str(
11569            "NAME\tAPPLICATION\tTIER\tSTATUS\tHEALTH\tPLATFORM\tVERSION\tCNAME\tUPDATED\n",
11570        );
11571        for &i in &self.cached_filtered {
11572            let e = &self.environments[i];
11573            let cname = if self.redact {
11574                redact_block(&e.cname)
11575            } else {
11576                e.cname.clone()
11577            };
11578            let updated = e.updated.map(|u| u.to_rfc3339()).unwrap_or_default();
11579            out.push_str(&format!(
11580                "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
11581                e.name,
11582                e.application,
11583                e.tier,
11584                e.status,
11585                e.health,
11586                e.platform,
11587                e.version_label,
11588                cname,
11589                updated
11590            ));
11591        }
11592        match yank(&out) {
11593            Ok(()) => {
11594                self.status_message = Some(format!("exported {count} rows (TSV) to clipboard"));
11595            }
11596            Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
11597        }
11598    }
11599
11600    /// Recompute `tf_managed_envs` from the current `tf_state`.
11601    /// Called at startup (after `App::new`'s tfstate load), on
11602    /// `apply_rebuild` (context switch), and on the `:drift
11603    /// refresh` operator gesture. Cheap — set construction is
11604    /// O(n) over tf-managed env names; typically < 50.
11605    pub(crate) fn refresh_tf_managed_envs(&mut self) {
11606        self.tf_managed_envs = self
11607            .tf_state
11608            .as_ref()
11609            .map(|s| s.managed_names())
11610            .unwrap_or_default();
11611    }
11612
11613    pub fn selected_env(&self) -> Option<&Environment> {
11614        let sel = self.table_state.selected()?;
11615        match self.display_rows().get(sel)? {
11616            DisplayRow::Env(i) => self.environments.get(*i),
11617            DisplayRow::Separator => None,
11618        }
11619    }
11620
11621    fn apply_picker_choice(&mut self, kind: PickerKind, value: String) {
11622        match kind {
11623            PickerKind::Profile => {
11624                tracing::info!(
11625                    target: "ebman::state",
11626                    new_profile = %value,
11627                    cleared_override_region = ?self.override_region,
11628                    "apply_picker_choice(Profile) clears override_region so SDK re-resolves from new profile config"
11629                );
11630                self.override_profile = Some(value.clone());
11631                self.override_region = None;
11632                self.status_message = Some(format!("switching to profile {value}…"));
11633                self.spawn_rebuild();
11634            }
11635            PickerKind::Region => {
11636                tracing::info!(
11637                    target: "ebman::state",
11638                    new_region = %value,
11639                    prior_override = ?self.override_region,
11640                    "apply_picker_choice(Region) sets override_region"
11641                );
11642                self.override_region = Some(value.clone());
11643                self.status_message = Some(format!("switching to region {value}…"));
11644                self.spawn_rebuild();
11645            }
11646            PickerKind::LogGroup => {
11647                // Swap the streaming overlay's tailed group. Read the env
11648                // from the currently-open LogTail overlay; `spawn_logs_tail`
11649                // aborts the existing poller and opens a fresh one against
11650                // the chosen group, replacing `current_overlay` via the
11651                // resulting `AppMsg::LogTailOpened`.
11652                let env = match self.current_overlay.as_ref() {
11653                    Some(Overlay::LogTail { env_name, .. }) => env_name.clone(),
11654                    _ => return,
11655                };
11656                self.spawn_logs_tail(env, Some(value));
11657            }
11658            PickerKind::SshInstance => {
11659                // Same flow as pressing `s` on Detail/Instances — the
11660                // main loop tick consumes `pending_shell_target` and
11661                // handles the TUI suspend/resume + alt-screen dance.
11662                write_audit_line(
11663                    self.context.account_id.as_deref(),
11664                    self.context.profile.as_deref(),
11665                    &self.context.region,
11666                    &format!(
11667                        "stage=dispatched action=SsmSession target={value} via=cmd_ssh_picker"
11668                    ),
11669                );
11670                self.pending_shell_target = Some(value.clone());
11671                self.status_message = Some(format!("opening SSM session to {value}…"));
11672            }
11673        }
11674    }
11675
11676    fn spawn_rebuild(&mut self) {
11677        self.load_state = LoadState::Loading;
11678        self.loading_since = Some(Instant::now());
11679        let profile = self.override_profile.clone();
11680        let region = self.override_region.clone();
11681        let tx = self.msg_tx.clone();
11682        tokio::spawn(async move {
11683            let result = match AwsClient::with(profile, region).await {
11684                Ok(c) => Ok(Box::new(c)),
11685                Err(e) => Err(flatten_err("aws_client_with", e)),
11686            };
11687            let _ = tx.send(AppMsg::Rebuild(result));
11688        });
11689    }
11690
11691    /// Background task variant of `spawn_rebuild` for the AssumeRole
11692    /// path. Calls `AwsClient::assume_role` with the operator's named
11693    /// account spec; same `AppMsg::Rebuild` arrival point so the rest
11694    /// of the swap (overlay tear-down, throttle reset, identity refresh)
11695    /// flows through the existing `apply_rebuild` handler.
11696    fn spawn_assume_role_switch(&mut self, account_name: String) {
11697        let Some(spec) = self.accounts.get(&account_name).cloned() else {
11698            self.error_message = Some(format!(
11699                "no `accounts.{account_name}` in config.toml — add `accounts.{account_name}.role_arn = …`"
11700            ));
11701            return;
11702        };
11703        self.load_state = LoadState::Loading;
11704        self.loading_since = Some(Instant::now());
11705        self.status_message = Some(format!("assuming role for account '{account_name}'…"));
11706        let tx = self.msg_tx.clone();
11707        tokio::spawn(async move {
11708            let result = match AwsClient::assume_role(&account_name, &spec).await {
11709                Ok(c) => Ok(Box::new(c)),
11710                Err(e) => Err(flatten_err("aws_client_assume_role", e)),
11711            };
11712            let _ = tx.send(AppMsg::Rebuild(result));
11713        });
11714    }
11715
11716    fn spawn_identity(&mut self) {
11717        self.spawn_aws(
11718            "verify_identity",
11719            move |aws| async move { aws.verify_identity().await },
11720            |gen, result| AppMsg::Identity { gen, result },
11721        );
11722    }
11723
11724    fn spawn_update_check(&mut self) {
11725        // No outbound network in `--demo` mode — VHS captures shouldn't
11726        // pulse a "latest version available" toast partway through.
11727        if self.demo_mode {
11728            return;
11729        }
11730        let tx = self.msg_tx.clone();
11731        tokio::spawn(async move {
11732            let result = crate::update_check::check_async().await;
11733            let _ = tx.send(AppMsg::UpdateCheck(result));
11734        });
11735    }
11736
11737    /// If the `loading…` indicator was visible during the current load (i.e.
11738    /// `loading_since` was set and crossed the display threshold), arm a
11739    /// linger window so the indicator stays on for at least
11740    /// [`LOADING_INDICATOR_LINGER`] after the load completes. Call this
11741    /// *before* clearing `loading_since` and flipping `load_state` back to
11742    /// Idle/Error in the AppMsg handler.
11743    fn arm_loading_linger(&mut self) {
11744        let now = Instant::now();
11745        if let Some(until) = compute_loading_linger_target(
11746            self.loading_since,
11747            LOADING_INDICATOR_THRESHOLD,
11748            LOADING_INDICATOR_LINGER,
11749            now,
11750        ) {
11751            self.loading_visible_until = Some(until);
11752        }
11753    }
11754
11755    fn spawn_refresh(&mut self) {
11756        // `--demo` mode pins the fixture data in place — refresh would
11757        // call into the stub AwsClient, get empty results, and blank
11758        // the table. Skip entirely.
11759        if self.demo_mode {
11760            return;
11761        }
11762        if matches!(self.load_state, LoadState::Loading) {
11763            return;
11764        }
11765        self.load_state = LoadState::Loading;
11766        self.loading_since = Some(Instant::now());
11767        self.status_snapshot_at_refresh =
11768            Some((self.status_message.clone(), self.error_message.clone()));
11769        let tx = self.msg_tx.clone();
11770        let gen = self.generation;
11771        if self.multi_regions.is_empty() {
11772            let aws = self.aws.clone();
11773            tokio::spawn(async move {
11774                let result = aws
11775                    .list_environments()
11776                    .await
11777                    .map_err(|e| flatten_err("list_environments", e));
11778                let _ = tx.send(AppMsg::Refresh { gen, result });
11779            });
11780        } else {
11781            let regions = self.multi_regions.clone();
11782            let profile = self
11783                .override_profile
11784                .clone()
11785                .or_else(|| self.context.profile.clone());
11786            tokio::spawn(async move {
11787                use futures::future::join_all;
11788                let tasks = regions.into_iter().map(|r| {
11789                    let p = profile.clone();
11790                    async move { crate::aws::list_environments_in_region(p, r).await }
11791                });
11792                let results = join_all(tasks).await;
11793                let mut envs = Vec::new();
11794                let mut errs = Vec::new();
11795                for r in results {
11796                    match r {
11797                        Ok(v) => envs.extend(v),
11798                        Err(e) => errs.push(format!("{e}")),
11799                    }
11800                }
11801                let result = if envs.is_empty() && !errs.is_empty() {
11802                    Err(errs.join("; "))
11803                } else {
11804                    Ok(envs)
11805                };
11806                let _ = tx.send(AppMsg::Refresh { gen, result });
11807            });
11808        }
11809        if self.event_panel.visible {
11810            self.spawn_events();
11811        }
11812        self.spawn_applications();
11813        // Solution stacks change rarely (AWS releases platform versions
11814        // roughly monthly); fetch once per context and reuse. Cleared on a
11815        // context switch so a new account/region rebuilds it.
11816        if self.latest_stacks.is_empty() {
11817            self.spawn_solution_stacks();
11818        }
11819    }
11820
11821    /// Fetch the region's solution-stack catalogue so the envs table can
11822    /// flag platforms with a newer version available. Best-effort: a failed
11823    /// fetch just leaves `latest_stacks` empty and no env is flagged.
11824    fn spawn_solution_stacks(&self) {
11825        self.spawn_aws(
11826            "list_solution_stacks",
11827            move |aws| async move { aws.list_solution_stacks().await },
11828            |gen, result| AppMsg::SolutionStacks { gen, result },
11829        );
11830    }
11831
11832    fn spawn_applications(&self) {
11833        self.spawn_aws(
11834            "list_applications",
11835            move |aws| async move { aws.list_applications().await },
11836            |gen, result| AppMsg::Applications { gen, result },
11837        );
11838    }
11839
11840    /// Set the active scope. Triggers the lazy `spawn_app_latest_versions`
11841    /// fetch when transitioning to `Apps`, so the LATEST column populates
11842    /// on entry rather than waiting for the next periodic refresh tick.
11843    /// Idempotent — re-entering the same scope is a no-op.
11844    fn set_scope(&mut self, new: Scope) {
11845        let changed = self.scope != new;
11846        self.scope = new;
11847        if changed && new == Scope::Apps {
11848            self.spawn_app_latest_versions();
11849        }
11850    }
11851
11852    /// Fan out `DescribeApplicationVersions` per app to compute the LATEST
11853    /// column in the apps view. The AWS application-level `date_updated`
11854    /// only changes on metadata edits (description / templates / lifecycle),
11855    /// not on new version pushes — so operators expect this column to track
11856    /// version `date_created` instead. Errors on individual apps drop that
11857    /// row from the result rather than failing the batch.
11858    fn spawn_app_latest_versions(&self) {
11859        let aws = self.aws.clone();
11860        let tx = self.msg_tx.clone();
11861        let gen = self.generation;
11862        let names: Vec<String> = self.applications.iter().map(|a| a.name.clone()).collect();
11863        if names.is_empty() {
11864            return;
11865        }
11866        tokio::spawn(async move {
11867            use futures::future::join_all;
11868            let futs = names.into_iter().map(|name| {
11869                let aws = aws.clone();
11870                async move {
11871                    let res = aws.list_application_versions(&name).await;
11872                    let head = res.ok().and_then(|mut v| v.drain(..).next());
11873                    (
11874                        name,
11875                        head.as_ref().map(|h| h.label.clone()),
11876                        head.and_then(|h| h.created),
11877                    )
11878                }
11879            });
11880            let results: Vec<(
11881                String,
11882                Option<String>,
11883                Option<chrono::DateTime<chrono::Utc>>,
11884            )> = join_all(futs).await;
11885            let _ = tx.send(AppMsg::AppLatestVersions { gen, results });
11886        });
11887    }
11888
11889    /// Per-Worker-env DLQ depth fan-out. Fires once per refresh after
11890    /// `list_environments` lands. Skips Web envs (no DLQ). Each env's
11891    /// fetch is independent — a failure on one drops that entry from
11892    /// the result rather than failing the batch.
11893    fn spawn_worker_queue_check(&self) {
11894        let aws = self.aws.clone();
11895        let tx = self.msg_tx.clone();
11896        let gen = self.generation;
11897        let workers: Vec<(String, String)> = self
11898            .environments
11899            .iter()
11900            .filter(|e| e.tier.eq_ignore_ascii_case("Worker"))
11901            .map(|e| (e.name.clone(), e.application.clone()))
11902            .collect();
11903        if workers.is_empty() {
11904            return;
11905        }
11906        tokio::spawn(async move {
11907            use futures::future::join_all;
11908            let futs = workers.into_iter().map(|(env, app)| {
11909                let aws = aws.clone();
11910                async move {
11911                    aws.describe_worker_queues(&app, &env)
11912                        .await
11913                        .ok()
11914                        .and_then(|q| q.dlq_stats.map(|s| (env, s.visible)))
11915                }
11916            });
11917            let results: Vec<(String, i64)> = join_all(futs).await.into_iter().flatten().collect();
11918            let _ = tx.send(AppMsg::WorkerQueueCheck { gen, results });
11919        });
11920    }
11921
11922    /// Fan `DescribeEnvironmentHealth` across every env on each refresh
11923    /// tick to populate the `INST` column. Skips Terminated / Terminating
11924    /// envs (EB returns AccessDenied-ish errors for them) and silently
11925    /// drops failures so a single env's API blip doesn't poison the
11926    /// whole batch. Same shape as `spawn_worker_queue_check`.
11927    fn spawn_env_instance_counts(&self) {
11928        let aws = self.aws.clone();
11929        let tx = self.msg_tx.clone();
11930        let gen = self.generation;
11931        let targets: Vec<String> = self
11932            .environments
11933            .iter()
11934            .filter(|e| {
11935                // EB rejects DescribeEnvironmentHealth for envs in
11936                // terminal lifecycle states — no instances to count.
11937                !matches!(
11938                    e.status.as_str(),
11939                    "Terminated" | "Terminating" | "Launching"
11940                )
11941            })
11942            .map(|e| e.name.clone())
11943            .collect();
11944        if targets.is_empty() {
11945            return;
11946        }
11947        tokio::spawn(async move {
11948            use futures::future::join_all;
11949            let futs = targets.into_iter().map(|env| {
11950                let aws = aws.clone();
11951                async move {
11952                    aws.fetch_env_instance_counts(&env)
11953                        .await
11954                        .ok()
11955                        .map(|counts| (env, counts))
11956                }
11957            });
11958            let results: Vec<(String, crate::aws::EnvInstanceCounts)> =
11959                join_all(futs).await.into_iter().flatten().collect();
11960            let _ = tx.send(AppMsg::EnvInstanceCountsCheck { gen, results });
11961        });
11962    }
11963
11964    fn spawn_events(&mut self) {
11965        // Scope the events panel to the currently-selected env so it tells
11966        // the user about *this* env, not the entire account. Falls back to
11967        // the global event stream when no env is selected. The previously-
11968        // fetched env name is recorded so we can detect selection changes
11969        // and refetch without firing a request on every j/k.
11970        let selected = self.selected_env().map(|e| e.name.clone());
11971        self.event_panel.for_env = selected.clone();
11972        self.spawn_aws(
11973            "list_events",
11974            move |aws| async move {
11975                match selected {
11976                    Some(name) => aws.list_events_for_env(&name, 50).await,
11977                    None => aws.list_events(50).await,
11978                }
11979            },
11980            |gen, result| AppMsg::Events { gen, result },
11981        );
11982    }
11983
11984    /// Refetch the events panel if the cursor has moved to a different env
11985    /// since the last fetch. Called from the main loop just before draw, so
11986    /// any keystroke / mouse click that changed selection picks up the new
11987    /// env's events on the next frame.
11988    fn refresh_events_if_selection_changed(&mut self) {
11989        if !self.event_panel.visible {
11990            return;
11991        }
11992        let selected = self.selected_env().map(|e| e.name.clone());
11993        if selected != self.event_panel.for_env {
11994            self.spawn_events();
11995        }
11996    }
11997
11998    /// Apply a Detail-tab AppMsg payload. Handles the boilerplate every
11999    /// `Detail*` variant shares: drop when no Detail view is open, drop
12000    /// when the user switched to a different env mid-fetch. The stale-
12001    /// generation drop is handled upstream by `handle_msg`'s central guard.
12002    ///
12003    /// The closure runs against `&mut DetailState` + the raw
12004    /// `Result<T, String>` so the caller picks its own success / error
12005    /// behaviour — most clear `detail.error` on the Ok branch, but tags /
12006    /// env-vars use `tracing::warn!` instead since their failures
12007    /// shouldn't tint the whole tab red.
12008    fn apply_detail_msg<T, F>(&mut self, env_name: &str, result: Result<T, String>, apply: F)
12009    where
12010        F: FnOnce(&mut DetailState, Result<T, String>),
12011    {
12012        let Some(detail) = self.detail.as_mut() else {
12013            return;
12014        };
12015        if detail.env_name != env_name {
12016            return;
12017        }
12018        apply(detail, result);
12019    }
12020
12021    fn apply_rebuild(&mut self, result: Result<Box<AwsClient>, String>) {
12022        match result {
12023            Ok(client) => {
12024                self.generation = self.generation.wrapping_add(1);
12025                self.context = client.context.clone();
12026                self.aws = Arc::new(*client);
12027                self.maybe_apply_profile_theme();
12028                self.environments.clear();
12029                self.event_panel.events.clear();
12030                self.event_panel.scroll = 0;
12031                self.history.clear();
12032                // Solution-stack catalogue is region-specific; drop it so the
12033                // new context's `spawn_refresh` rebuilds it.
12034                self.latest_stacks.clear();
12035                // Overlays show data from the previous context (describe dump,
12036                // alarms list, …); close them so the user doesn't act on stale info.
12037                self.current_overlay = None;
12038                // Tear down any long-running CW Logs poll that's mid-flight;
12039                // it would otherwise keep hitting the previous account's CW.
12040                // Also bump session id so any in-flight LogTailOpened from
12041                // the aborted task is dropped on arrival.
12042                if let Some(handle) = self.log_tail_task.take() {
12043                    handle.abort();
12044                }
12045                self.log_tail_session = self.log_tail_session.wrapping_add(1);
12046                // Reset throttle back-off across context switches — the new
12047                // account/region has its own rate limits.
12048                self.throttle_until = None;
12049                self.consecutive_throttles = 0;
12050                // Diff state is keyed by env name. Switching accounts/regions may
12051                // surface envs with overlapping names but unrelated history;
12052                // clearing here prevents spurious "newly red" / status-delta noise
12053                // on the first refresh in the new context.
12054                self.prev_health.clear();
12055                self.prev_status.clear();
12056                self.prev_alerts = 0;
12057                self.newly_red.clear();
12058                self.newly_added.clear();
12059                self.health_delta.clear();
12060                self.status_delta.clear();
12061                // Drop any auto-rollback watchdogs armed against the
12062                // previous context. The deadline tokio tasks survive
12063                // (no JoinHandle for cancellation), but their late
12064                // `AutoRollbackCheck` messages get dropped by the
12065                // generation-guard at msg.rs's entry. Clearing here
12066                // also prevents a same-name env in the new context
12067                // from being seen as "still armed" by apply_refresh.
12068                self.armed_watchdogs.clear();
12069                // Same reasoning applies to wait-for-green trackers:
12070                // env-name keyed, context-scoped — drop on rebuild.
12071                self.watching_deploys.clear();
12072                // Pre-deploy snapshots are env-name keyed; clearing on
12073                // context switch avoids :rollback in the new context
12074                // picking up a label that doesn't exist there.
12075                self.deploy_snapshots.clear();
12076                // Undo entries reference env names from the previous
12077                // context — meaningless after a switch. Drop them
12078                // alongside the other env-keyed state.
12079                self.undo_history.clear();
12080                // Re-read tfstate from cwd. The new context might
12081                // be a different repo (operator cd'd between
12082                // sessions of `ebman` left running, or switched
12083                // account / region within the same shell);
12084                // re-discovery ensures the tf-managed badge reflects
12085                // the current project.
12086                self.tf_state = crate::terraform::load_from_cwd();
12087                self.refresh_tf_managed_envs();
12088                self.rebuild_view();
12089                self.table_state.select(None);
12090                self.status_message = Some(format!(
12091                    "context: {} / {}",
12092                    self.context.profile.as_deref().unwrap_or("default"),
12093                    self.context.region
12094                ));
12095                self.error_message = None;
12096                self.arm_loading_linger();
12097                self.load_state = LoadState::Idle;
12098                self.persist_state();
12099                self.spawn_identity();
12100                self.spawn_refresh();
12101            }
12102            Err(msg) => {
12103                tracing::error!(error = %msg, "rebuild failed");
12104                self.arm_loading_linger();
12105                self.load_state = LoadState::Error;
12106                self.loading_since = None;
12107                self.error_message = Some(self.format_aws_error("context switch", &msg));
12108            }
12109        }
12110    }
12111
12112    fn move_scope_selection(&mut self, delta: i32) {
12113        match self.scope {
12114            Scope::Envs => self.move_selection(delta),
12115            Scope::Apps => {
12116                let n = self.applications.len();
12117                if n == 0 {
12118                    self.app_table_state.select(None);
12119                    return;
12120                }
12121                let cur = self.app_table_state.selected().unwrap_or(0) as i32;
12122                let next = (cur + delta).rem_euclid(n as i32) as usize;
12123                self.app_table_state.select(Some(next));
12124            }
12125        }
12126    }
12127
12128    fn scope_select_first(&mut self) {
12129        match self.scope {
12130            Scope::Envs => self.select_first(),
12131            Scope::Apps => {
12132                if !self.applications.is_empty() {
12133                    self.app_table_state.select(Some(0));
12134                }
12135            }
12136        }
12137    }
12138
12139    fn scope_select_last(&mut self) {
12140        match self.scope {
12141            Scope::Envs => self.select_last(),
12142            Scope::Apps => {
12143                if !self.applications.is_empty() {
12144                    self.app_table_state
12145                        .select(Some(self.applications.len() - 1));
12146                }
12147            }
12148        }
12149    }
12150
12151    /// Open the apps-scope action overlay for the selected application.
12152    /// Captures the env list at open time so later refreshes (e.g. an
12153    /// env terminating mid-action) can't shift which envs the operator
12154    /// thought they were targeting. Closes silently when no app is
12155    /// selected or the application has no envs.
12156    pub(crate) fn open_apps_action_menu(&mut self) {
12157        let Some(idx) = self.app_table_state.selected() else {
12158            return;
12159        };
12160        let Some(app_name) = self.applications.get(idx).map(|a| a.name.clone()) else {
12161            return;
12162        };
12163        let env_names: Vec<String> = self
12164            .environments
12165            .iter()
12166            .filter(|e| e.application == app_name)
12167            .map(|e| e.name.clone())
12168            .collect();
12169        if env_names.is_empty() {
12170            self.status_message = Some(format!(
12171                "application '{app_name}' has no envs — nothing to act on"
12172            ));
12173            return;
12174        }
12175        self.current_overlay = Some(Overlay::AppsActionMenu {
12176            app_name,
12177            env_names,
12178            cursor: 0,
12179        });
12180    }
12181
12182    /// Key handler for the apps-scope action overlay. j/k cycles the
12183    /// cursor; Enter dispatches the selected item; esc / q closes.
12184    /// Five items, dispatched via the matching `cmd_batch_*` helpers
12185    /// after seeding `multi_selected` with the captured env list.
12186    fn handle_apps_action_menu_key(&mut self, key: KeyEvent) {
12187        let n_items = APPS_ACTION_ITEMS.len() as i32;
12188        match key.code {
12189            KeyCode::Esc | KeyCode::Char('q') => {
12190                self.current_overlay = None;
12191            }
12192            KeyCode::Down | KeyCode::Char('j') => {
12193                if let Some(Overlay::AppsActionMenu { cursor, .. }) = self.current_overlay.as_mut()
12194                {
12195                    let cur = *cursor as i32;
12196                    *cursor = (cur + 1).rem_euclid(n_items) as usize;
12197                }
12198            }
12199            KeyCode::Up | KeyCode::Char('k') => {
12200                if let Some(Overlay::AppsActionMenu { cursor, .. }) = self.current_overlay.as_mut()
12201                {
12202                    let cur = *cursor as i32;
12203                    *cursor = (cur - 1).rem_euclid(n_items) as usize;
12204                }
12205            }
12206            KeyCode::Enter => self.dispatch_apps_action_menu(),
12207            _ => {}
12208        }
12209    }
12210
12211    fn dispatch_apps_action_menu(&mut self) {
12212        let Some(Overlay::AppsActionMenu {
12213            app_name,
12214            env_names,
12215            cursor,
12216        }) = self.current_overlay.as_ref().cloned()
12217        else {
12218            return;
12219        };
12220        // Close the overlay before dispatching so the resulting toast /
12221        // confirm modal renders on the bare apps table, not on top of
12222        // the menu.
12223        self.current_overlay = None;
12224        let item = match APPS_ACTION_ITEMS.get(cursor) {
12225            Some(it) => *it,
12226            None => return,
12227        };
12228        match item {
12229            AppsActionItem::Drill => {
12230                self.filter = app_name.clone();
12231                self.set_scope(Scope::Envs);
12232                self.rebuild_view();
12233                self.status_message = Some(format!("filtered envs to application '{app_name}'"));
12234            }
12235            AppsActionItem::BatchRebuild => {
12236                self.multi_selected = env_names.into_iter().collect();
12237                self.cmd_batch_action(Action::Rebuild);
12238            }
12239            AppsActionItem::BatchRestart => {
12240                self.multi_selected = env_names.into_iter().collect();
12241                self.cmd_batch_action(Action::RestartAppServer);
12242            }
12243            AppsActionItem::BatchDeploy => {
12244                // Seed the multi-select then drop into command mode
12245                // with `:batch-deploy ` so the operator types the
12246                // version label and Enter dispatches.
12247                self.multi_selected = env_names.into_iter().collect();
12248                self.mode = Mode::Command;
12249                self.command_input = "batch-deploy ".into();
12250                self.status_message = Some("type a version label and press enter".into());
12251            }
12252            AppsActionItem::OpenInConsole => {
12253                self.open_app_in_console();
12254            }
12255        }
12256    }
12257
12258    /// Open the EB applications-page console URL for the selected
12259    /// application in the browser. Mirrors `open_in_console`'s
12260    /// `arboard`-clipboard-on-failure shape so the operator still has
12261    /// the URL available when the browser launch fails (SSH session,
12262    /// no DISPLAY, etc.).
12263    pub(crate) fn open_app_in_console(&mut self) {
12264        let Some(idx) = self.app_table_state.selected() else {
12265            self.status_message = Some("no application selected".into());
12266            return;
12267        };
12268        let Some(name) = self.applications.get(idx).map(|a| a.name.clone()) else {
12269            return;
12270        };
12271        let region = &self.context.region;
12272        let app_enc = urlencode(&name);
12273        let url = format!(
12274            "https://{region}.console.aws.amazon.com/elasticbeanstalk/home?region={region}#/application/overview?applicationName={app_enc}"
12275        );
12276        match open_url(&url) {
12277            Ok(()) => {
12278                self.status_message = Some(format!("opened {name} in browser"));
12279            }
12280            Err(e) => {
12281                self.error_message = Some(format!("couldn't open browser: {e}"));
12282            }
12283        }
12284    }
12285
12286    fn drill_into_app(&mut self) {
12287        let Some(idx) = self.app_table_state.selected() else {
12288            return;
12289        };
12290        let Some(name) = self.applications.get(idx).map(|a| a.name.clone()) else {
12291            return;
12292        };
12293        self.filter = name.clone();
12294        self.set_scope(Scope::Envs);
12295        self.rebuild_view();
12296        self.status_message = Some(format!("filtered envs to application '{name}'"));
12297    }
12298
12299    fn select_first(&mut self) {
12300        let rows = self.display_rows();
12301        if let Some(pos) = rows.iter().position(|r| matches!(r, DisplayRow::Env(_))) {
12302            self.table_state.select(Some(pos));
12303        }
12304    }
12305
12306    fn select_last(&mut self) {
12307        let rows = self.display_rows();
12308        if let Some(pos) = rows.iter().rposition(|r| matches!(r, DisplayRow::Env(_))) {
12309            self.table_state.select(Some(pos));
12310        }
12311    }
12312
12313    fn move_selection(&mut self, delta: i32) {
12314        let rows = self.display_rows();
12315        if rows.is_empty() {
12316            self.table_state.select(None);
12317            return;
12318        }
12319        // Build a list of indexes that are selectable (Env rows only).
12320        let selectable: Vec<usize> = rows
12321            .iter()
12322            .enumerate()
12323            .filter_map(|(i, r)| matches!(r, DisplayRow::Env(_)).then_some(i))
12324            .collect();
12325        if selectable.is_empty() {
12326            self.table_state.select(None);
12327            return;
12328        }
12329        let current = self.table_state.selected().unwrap_or(selectable[0]);
12330        let pos_in_selectable = selectable.iter().position(|i| *i == current).unwrap_or(0) as i32;
12331        let next = (pos_in_selectable + delta).rem_euclid(selectable.len() as i32) as usize;
12332        self.table_state.select(Some(selectable[next]));
12333    }
12334
12335    pub fn display_rows(&self) -> &[DisplayRow] {
12336        &self.cached_display
12337    }
12338
12339    pub fn filtered_indexes(&self) -> &[usize] {
12340        &self.cached_filtered
12341    }
12342
12343    /// Recompute the cached filtered/display slices. Call after any change to
12344    /// filter, sort, grouping, or the env list.
12345    pub fn rebuild_view(&mut self) {
12346        // Filtered indexes.
12347        self.cached_filtered.clear();
12348        if self.filter.is_empty() {
12349            self.cached_filtered.extend(0..self.environments.len());
12350        } else {
12351            let needle = self.filter.to_lowercase();
12352            for (i, e) in self.environments.iter().enumerate() {
12353                let alias_hit = self
12354                    .aliases
12355                    .get(&e.name)
12356                    .map(|a| a.to_lowercase().contains(&needle))
12357                    .unwrap_or(false);
12358                if e.name.to_lowercase().contains(&needle)
12359                    || alias_hit
12360                    || e.application.to_lowercase().contains(&needle)
12361                    || e.health.to_lowercase().contains(&needle)
12362                    || e.status.to_lowercase().contains(&needle)
12363                {
12364                    self.cached_filtered.push(i);
12365                }
12366            }
12367        }
12368
12369        // Display rows (with optional group separators).
12370        self.cached_display.clear();
12371        let mut prev_app: Option<&str> = None;
12372        for i in &self.cached_filtered {
12373            let e = &self.environments[*i];
12374            if self.grouped && prev_app.is_some() && prev_app != Some(e.application.as_str()) {
12375                self.cached_display.push(DisplayRow::Separator);
12376            }
12377            self.cached_display.push(DisplayRow::Env(*i));
12378            prev_app = Some(e.application.as_str());
12379        }
12380
12381        // Per-application palette colour cache. Assigned by order of first
12382        // appearance in the filtered view; rebuilt here so the render path
12383        // can do an O(1) lookup instead of building this map per frame.
12384        self.cached_app_colors = assign_app_colors(
12385            self.cached_filtered
12386                .iter()
12387                .map(|i| self.environments[*i].application.as_str()),
12388            &self.theme.app_palette,
12389        );
12390
12391        // Stale-platform cache: parse each env's solution stack against the
12392        // available-versions catalogue once here, so the render path looks
12393        // up `env_name → newer version` instead of re-parsing per row per
12394        // frame. Empty while `latest_stacks` hasn't loaded yet.
12395        self.cached_stale_platforms.clear();
12396        if !self.latest_stacks.is_empty() {
12397            for e in &self.environments {
12398                if let Some(newer) =
12399                    crate::aws::newer_stack_version(&e.solution_stack, &self.latest_stacks)
12400                {
12401                    self.cached_stale_platforms.insert(e.name.clone(), newer);
12402                }
12403            }
12404        }
12405    }
12406
12407    fn apply_refresh(&mut self, result: Result<Vec<Environment>, String>) {
12408        match result {
12409            Ok(envs) => {
12410                // Track newly-Red transitions for the anomaly highlight.
12411                let is_red =
12412                    |h: &str| h.eq_ignore_ascii_case("Red") || h.eq_ignore_ascii_case("Severe");
12413                self.newly_red.clear();
12414                // Compute newly-added envs *before* swapping prev_health
12415                // below — once we overwrite it, "previously unseen" is no
12416                // longer derivable. Skip the first refresh (prev_health is
12417                // empty then) so every env doesn't get flagged on startup.
12418                self.newly_added.clear();
12419                if !self.prev_health.is_empty() {
12420                    for e in &envs {
12421                        if !self.prev_health.contains_key(&e.name) {
12422                            self.newly_added.insert(e.name.clone());
12423                        }
12424                    }
12425                }
12426                for e in &envs {
12427                    let prev_red = self
12428                        .prev_health
12429                        .get(&e.name)
12430                        .map(|h| is_red(h))
12431                        .unwrap_or(false);
12432                    if is_red(&e.health) && !prev_red {
12433                        self.newly_red.insert(e.name.clone());
12434                        // Surface the transition via tracing + the audit log
12435                        // so operators can wire their own notifier (Slack,
12436                        // pager, etc.) off the audit stream. The previous
12437                        // built-in `webhook_url` POST was trimmed — too rigid
12438                        // for real ops workflows.
12439                        tracing::warn!(
12440                            env = %e.name,
12441                            application = %e.application,
12442                            health = %e.health,
12443                            region = %self.context.region,
12444                            "env transitioned into Red",
12445                        );
12446                        write_audit_line(
12447                            self.context.account_id.as_deref(),
12448                            self.context.profile.as_deref(),
12449                            &self.context.region,
12450                            &format!(
12451                                "stage=event kind=red_transition env={} application={} health={}",
12452                                e.name, e.application, e.health
12453                            ),
12454                        );
12455                    }
12456                }
12457                // Compute health + status deltas before swapping prev maps.
12458                self.health_delta = bucket_delta(&self.prev_health, &envs, |e| e.health.clone());
12459                self.status_delta = bucket_delta(&self.prev_status, &envs, |e| e.status.clone());
12460
12461                self.prev_health = envs
12462                    .iter()
12463                    .map(|e| (e.name.clone(), e.health.clone()))
12464                    .collect();
12465                self.prev_status = envs
12466                    .iter()
12467                    .map(|e| (e.name.clone(), e.status.clone()))
12468                    .collect();
12469
12470                let new_alerts = compute_red_alerts(&envs, &self.worker_dlq_depths);
12471                if self.notify_bell && new_alerts > self.prev_alerts {
12472                    // BEL — write to stderr and flush so the terminal rings
12473                    // immediately even though we're in the alt screen.
12474                    use std::io::Write;
12475                    let mut err = std::io::stderr().lock();
12476                    let _ = err.write_all(b"\x07");
12477                    let _ = err.flush();
12478                }
12479                self.prev_alerts = new_alerts;
12480                self.alerts = new_alerts;
12481
12482                self.environments = envs;
12483                self.resort_envs();
12484
12485                // Watchdog decision pass — single source of truth for
12486                // auto-rollback outcomes. Every armed watchdog gets
12487                // evaluated against the *freshly-applied* env list
12488                // (line above), eliminating the stale-cache race the
12489                // earlier "deadline handler dispatches inline" design
12490                // had: the deadline `tokio::spawn` now just sends an
12491                // `AutoRollbackCheck` message whose handler kicks a
12492                // manual refresh, so by the time we reach here the
12493                // health field is current.
12494                //
12495                // Three outcomes per armed env:
12496                //   1. Env is Green/Ok → drain, pin status.
12497                //   2. Env still non-Green AND deadline passed →
12498                //      dispatch the rollback redeploy.
12499                //   3. Else → keep armed; check again next refresh.
12500                let armed: Vec<(String, chrono::DateTime<chrono::Utc>)> = self
12501                    .armed_watchdogs
12502                    .iter()
12503                    .map(|(env, w)| (env.clone(), w.deadline_at))
12504                    .collect();
12505                let now = chrono::Utc::now();
12506                for (env_name, deadline_at) in armed {
12507                    let (status, health) = self
12508                        .environments
12509                        .iter()
12510                        .find(|e| e.name == env_name)
12511                        .map(|e| (e.status.clone(), e.health.clone()))
12512                        .unwrap_or_default();
12513                    let healthy = deploy_settled_green(&status, &health);
12514                    if healthy {
12515                        self.armed_watchdogs.remove(&env_name);
12516                        // pin_status survives the same-tick auto-clear
12517                        // at the bottom of apply_refresh — without it
12518                        // the disarm message gets wiped before the
12519                        // operator ever sees it.
12520                        self.pin_status(format!(
12521                            "auto-rollback for {env_name}: env reached Green, watchdog disarmed"
12522                        ));
12523                    } else if now >= deadline_at {
12524                        // Deadline reached, env still bad. Dispatch
12525                        // the redeploy using the just-refreshed health
12526                        // so the audit line is accurate.
12527                        self.dispatch_auto_rollback(env_name, health);
12528                    }
12529                    // else: still armed, next refresh re-evaluates.
12530                }
12531
12532                // Wait-for-green watcher decision pass. Same shape as
12533                // armed_watchdogs, but the resolution is purely
12534                // observational — no follow-on dispatch. Three outcomes:
12535                //   1. Env is Green/Ok → drain, pin success.
12536                //   2. Deadline passed and env still non-Green → drain,
12537                //      pin timeout error (operator decides next move).
12538                //   3. Else → keep watching, re-check next refresh.
12539                let watching: Vec<(String, chrono::DateTime<chrono::Utc>, String, u64)> = self
12540                    .watching_deploys
12541                    .iter()
12542                    .map(|(env, w)| {
12543                        let secs = (w.deadline_at - w.armed_at).num_seconds().max(0) as u64;
12544                        (env.clone(), w.deadline_at, w.target_label.clone(), secs)
12545                    })
12546                    .collect();
12547                for (env_name, deadline_at, target_label, total_secs) in watching {
12548                    let (status, health) = self
12549                        .environments
12550                        .iter()
12551                        .find(|e| e.name == env_name)
12552                        .map(|e| (e.status.clone(), e.health.clone()))
12553                        .unwrap_or_default();
12554                    let healthy = deploy_settled_green(&status, &health);
12555                    if healthy {
12556                        self.watching_deploys.remove(&env_name);
12557                        let label_hint = if target_label.is_empty() {
12558                            String::new()
12559                        } else {
12560                            format!(" ({target_label})")
12561                        };
12562                        self.pin_status(format!("✓ deploy reached Green: {env_name}{label_hint}"));
12563                    } else if now >= deadline_at {
12564                        self.watching_deploys.remove(&env_name);
12565                        let label_hint = if target_label.is_empty() {
12566                            String::new()
12567                        } else {
12568                            format!(" ({target_label})")
12569                        };
12570                        self.pin_error(format!(
12571                            "deploy did not reach Green within {total_secs}s: {env_name}{label_hint} — status={status} health={health}"
12572                        ));
12573                    }
12574                }
12575
12576                let live: HashSet<String> =
12577                    self.environments.iter().map(|e| e.name.clone()).collect();
12578                for e in &self.environments {
12579                    let buf = self.history.entry(e.name.clone()).or_default();
12580                    buf.push_back(e.health.clone());
12581                    while buf.len() > HISTORY_CAP {
12582                        buf.pop_front();
12583                    }
12584                }
12585                self.history.retain(|k, _| live.contains(k));
12586
12587                self.arm_loading_linger();
12588                self.load_state = LoadState::Idle;
12589                self.loading_since = None;
12590                self.last_refresh = Some(chrono::Utc::now());
12591                // A successful refresh resets the throttle back-off so the
12592                // next throttle (if any) starts again from the base interval.
12593                self.consecutive_throttles = 0;
12594                self.throttle_until = None;
12595                // Clear status/error only if the user hasn't replaced them
12596                // during the refresh round-trip. Otherwise their action message
12597                // (sort change, alias set, …) would get clobbered here.
12598                if let Some((prev_status, prev_error)) = self.status_snapshot_at_refresh.take() {
12599                    // Don't auto-clear user-pinned messages — those are
12600                    // results the operator just asked for and would lose
12601                    // every 15s otherwise.
12602                    if !self.status_message_pinned && self.status_message == prev_status {
12603                        self.status_message = None;
12604                    }
12605                    if self.error_message == prev_error {
12606                        self.error_message = None;
12607                    }
12608                } else if !self.status_message_pinned {
12609                    self.status_message = None;
12610                    self.error_message = None;
12611                }
12612                // Pin lasts one refresh cycle. After that the message
12613                // survives in the slot but the next ephemeral write (e.g.
12614                // a spawn helper's "fetching…") gets normal auto-clear
12615                // semantics again.
12616                self.status_message_pinned = false;
12617                self.restore_or_clamp_selection();
12618                // Fan out DLQ depth checks for Worker-tier envs. Result
12619                // lands as `AppMsg::WorkerQueueCheck` and updates the
12620                // alert count + the in-row `⚠ DLQ:N` chip on the next
12621                // draw.
12622                self.spawn_worker_queue_check();
12623                // Same fan-out shape for the INST column: per-env
12624                // `DescribeEnvironmentHealth` summarised down to
12625                // `(healthy, total)`. Cache rebuilt from results in
12626                // `handle_env_instance_counts`.
12627                self.spawn_env_instance_counts();
12628            }
12629            Err(msg) => {
12630                tracing::error!(error = %msg, "refresh failed");
12631                self.arm_loading_linger();
12632                self.load_state = LoadState::Error;
12633                self.loading_since = None;
12634                self.status_snapshot_at_refresh = None;
12635                if is_throttling_error(&msg) {
12636                    let backoff =
12637                        throttle_backoff(self.refresh_interval, self.consecutive_throttles);
12638                    self.consecutive_throttles = self.consecutive_throttles.saturating_add(1);
12639                    self.throttle_until = Some(Instant::now() + backoff);
12640                    self.error_message = Some(format!(
12641                        "rate-limited by AWS — backing off {}s (^R to force)",
12642                        backoff.as_secs().max(1)
12643                    ));
12644                } else {
12645                    self.error_message = Some(self.format_aws_error("refresh", &msg));
12646                }
12647            }
12648        }
12649    }
12650
12651    fn restore_or_clamp_selection(&mut self) {
12652        if self.cached_display.is_empty() {
12653            self.table_state.select(None);
12654            return;
12655        }
12656        let first_env_idx = self
12657            .cached_display
12658            .iter()
12659            .position(|r| matches!(r, DisplayRow::Env(_)))
12660            .unwrap_or(0);
12661        let pending = self.pending_select.take();
12662        if let Some(name) = pending {
12663            let pos = self.cached_display.iter().position(|r| match r {
12664                DisplayRow::Env(i) => self.environments[*i].name == name,
12665                DisplayRow::Separator => false,
12666            });
12667            if let Some(p) = pos {
12668                self.table_state.select(Some(p));
12669                return;
12670            }
12671        }
12672        let valid = self
12673            .table_state
12674            .selected()
12675            .is_some_and(|s| matches!(self.cached_display.get(s), Some(DisplayRow::Env(_))));
12676        if !valid {
12677            self.table_state.select(Some(first_env_idx));
12678        }
12679    }
12680
12681    fn format_aws_error(&self, op: &str, msg: &str) -> String {
12682        let lower = msg.to_lowercase();
12683        let sso_signals = [
12684            "expiredtoken",
12685            "expired token",
12686            "token has expired",
12687            "the security token included in the request is expired",
12688            "unable to load credentials",
12689            "no credentials in the property bag",
12690            "sso session has expired",
12691        ];
12692        if sso_signals.iter().any(|s| lower.contains(s)) {
12693            let profile = self
12694                .override_profile
12695                .clone()
12696                .or_else(|| self.context.profile.clone())
12697                .unwrap_or_else(|| "default".into());
12698            return format!(
12699                "credentials expired — run: aws sso login --profile {profile}  (or refresh your creds, then press Ctrl-R)"
12700            );
12701        }
12702        format!("{op} failed: {msg}")
12703    }
12704}
12705
12706fn is_text_input(key: &KeyEvent) -> bool {
12707    // Allow plain text and shifted text (capital letters); block Ctrl/Alt/Super.
12708    let m = key.modifiers;
12709    !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
12710}
12711
12712#[derive(Debug, Clone, Copy)]
12713pub enum YankKind {
12714    Cname,
12715    Name,
12716}
12717
12718#[derive(Debug, Clone, Copy)]
12719pub enum DisplayRow {
12720    Env(usize),
12721    Separator,
12722}
12723
12724/// Drive the tail-log capture pipeline end-to-end:
12725/// 1. `RequestEnvironmentInfo` to kick EB into producing samples.
12726/// 2. Poll `RetrieveEnvironmentInfo` until pre-signed S3 URLs appear or we
12727///    hit the attempt cap.
12728/// 3. Fetch each URL (sequentially — typically only 1-3 instances; serial
12729///    keeps error handling simple and avoids hammering S3).
12730///
12731/// Progress messages are emitted via `tx` so the UI advances through the
12732/// Requesting → Polling → Fetching → Ready states while this future runs.
12733async fn collect_tail_logs(
12734    aws: Arc<AwsClient>,
12735    env_name: String,
12736    tx: mpsc::UnboundedSender<AppMsg>,
12737    gen: u64,
12738) -> std::result::Result<Vec<(String, String)>, String> {
12739    const POLL_ATTEMPTS: u32 = 12;
12740    const POLL_INTERVAL: Duration = Duration::from_secs(2);
12741
12742    aws.request_env_info_tail(&env_name)
12743        .await
12744        .map_err(|e| flatten_err("request_env_info_tail", e))?;
12745    let _ = tx.send(AppMsg::DetailLogsProgress {
12746        gen,
12747        env_name: env_name.clone(),
12748        stage: LogTailStage::Polling,
12749        attempt: 0,
12750    });
12751
12752    let mut urls: Vec<(String, String)> = Vec::new();
12753    for attempt in 1..=POLL_ATTEMPTS {
12754        tokio::time::sleep(POLL_INTERVAL).await;
12755        urls = aws
12756            .retrieve_env_info_tail(&env_name)
12757            .await
12758            .map_err(|e| flatten_err("retrieve_env_info_tail", e))?;
12759        if !urls.is_empty() {
12760            break;
12761        }
12762        let _ = tx.send(AppMsg::DetailLogsProgress {
12763            gen,
12764            env_name: env_name.clone(),
12765            stage: LogTailStage::Polling,
12766            attempt,
12767        });
12768    }
12769    if urls.is_empty() {
12770        return Err(format!(
12771            "no tail samples uploaded after {}s — instance role may lack s3:PutObject on the EB info bucket",
12772            POLL_ATTEMPTS as u64 * POLL_INTERVAL.as_secs()
12773        ));
12774    }
12775    let _ = tx.send(AppMsg::DetailLogsProgress {
12776        gen,
12777        env_name: env_name.clone(),
12778        stage: LogTailStage::Fetching,
12779        attempt: 0,
12780    });
12781
12782    let mut out = Vec::with_capacity(urls.len());
12783    for (instance_id, url) in urls {
12784        match AwsClient::fetch_url_text(&url).await {
12785            Ok(text) => out.push((instance_id, text)),
12786            Err(e) => out.push((instance_id, format!("(fetch failed: {e})"))),
12787        }
12788    }
12789    Ok(out)
12790}
12791
12792/// Pre-flight signal for the confirm modal: looks at the env's current state
12793/// at action-open time and returns a one-line warning when something
12794/// noteworthy is in progress (mid-deploy, recently updated, currently in
12795/// Updating / Terminating). `None` for envs that look quiet. Pure function so
12796/// the rule set can be pinned down with unit tests.
12797pub fn compute_traffic_warning(env: &Environment) -> Option<String> {
12798    let status_lower = env.status.to_lowercase();
12799    if status_lower.contains("updating") || status_lower.contains("launching") {
12800        return Some(format!("ACTIVE DEPLOY: status={}", env.status));
12801    }
12802    if status_lower.contains("terminating") {
12803        return Some(format!("env is {} already", env.status));
12804    }
12805    if let Some(updated) = env.updated {
12806        let dur = chrono::Utc::now().signed_duration_since(updated);
12807        if dur >= chrono::Duration::zero() && dur < chrono::Duration::minutes(5) {
12808            return Some(format!(
12809                "RECENT CHANGE: updated {}s ago",
12810                dur.num_seconds().max(0)
12811            ));
12812        }
12813    }
12814    if env.health.eq_ignore_ascii_case("Red") || env.health.eq_ignore_ascii_case("Severe") {
12815        return Some(format!("env is currently {}", env.health));
12816    }
12817    None
12818}
12819
12820/// Recognise AWS throttling error messages. The SDK surfaces these via the
12821/// `ThrottlingException` code (EB, STS) or `RequestLimitExceeded` (older
12822/// services). Match case-insensitively against the flattened error string so
12823/// that exact framing of the message doesn't matter.
12824/// Pure: count "Red-equivalent" alerts across the env list. An env counts
12825/// as alert-worthy when either (a) EB reports its health as Red / Severe,
12826/// or (b) it's a Worker-tier env with `worker_dlq_depths.get(name) > 0`.
12827/// The two predicates are disjoint per env, so a worker that's both
12828/// EB-Red and DLQ-loaded is counted once.
12829pub(crate) fn compute_red_alerts(
12830    envs: &[crate::aws::Environment],
12831    worker_dlq_depths: &std::collections::HashMap<String, i64>,
12832) -> usize {
12833    envs.iter()
12834        .filter(|e| {
12835            let eb_red =
12836                e.health.eq_ignore_ascii_case("Red") || e.health.eq_ignore_ascii_case("Severe");
12837            let dlq_red = e.tier.eq_ignore_ascii_case("Worker")
12838                && worker_dlq_depths.get(&e.name).copied().unwrap_or(0) > 0;
12839            eb_red || dlq_red
12840        })
12841        .count()
12842}
12843
12844pub(crate) fn is_throttling_error(msg: &str) -> bool {
12845    let lower = msg.to_lowercase();
12846    [
12847        "throttling",
12848        "throttlingexception",
12849        "requestlimitexceeded",
12850        "too many requests",
12851        "rate exceeded",
12852    ]
12853    .iter()
12854    .any(|needle| lower.contains(needle))
12855}
12856
12857/// Exponential back-off horizon: 2× base on the first throttle, doubling each
12858/// consecutive failure, capped at 5 minutes. The 5 min cap keeps the app
12859/// responsive when the throttle clears — the user shouldn't have to wait
12860/// arbitrarily long after rate limits ease.
12861/// Pure: given the moment a load started and the display constants, return
12862/// the instant the loading indicator should remain visible until (if it
12863/// was visible at all). Returns `None` when the load completed before the
12864/// indicator's display threshold, signalling "no linger needed".
12865pub fn compute_loading_linger_target(
12866    loading_since: Option<Instant>,
12867    threshold: Duration,
12868    linger: Duration,
12869    now: Instant,
12870) -> Option<Instant> {
12871    let elapsed = loading_since.map(|t| now.duration_since(t))?;
12872    if elapsed >= threshold {
12873        Some(now + linger)
12874    } else {
12875        None
12876    }
12877}
12878
12879fn throttle_backoff(base: Duration, consecutive: u32) -> Duration {
12880    const MAX_BACKOFF: Duration = Duration::from_secs(300);
12881    let factor: u32 = 2u32.saturating_pow(consecutive.min(6).saturating_add(1));
12882    let scaled = base.saturating_mul(factor);
12883    scaled.min(MAX_BACKOFF)
12884}
12885
12886/// Assign palette colours to application names in order of first appearance.
12887/// Once the palette is exhausted, colours wrap around (so the 17th distinct app
12888/// reuses the first colour, etc.). With an empty palette the result is empty —
12889/// callers should fall back to a default text colour.
12890fn assign_app_colors<'a>(
12891    names: impl IntoIterator<Item = &'a str>,
12892    palette: &[ratatui::style::Color],
12893) -> HashMap<String, ratatui::style::Color> {
12894    let mut out: HashMap<String, ratatui::style::Color> = HashMap::new();
12895    if palette.is_empty() {
12896        return out;
12897    }
12898    for name in names {
12899        if !out.contains_key(name) {
12900            let idx = out.len() % palette.len();
12901            out.insert(name.to_string(), palette[idx]);
12902        }
12903    }
12904    out
12905}
12906
12907impl App {
12908    fn yank_event_at(&mut self, idx: usize) {
12909        let Some(ev) = self.event_panel.events.get(idx) else {
12910            self.event_panel.cursor = None;
12911            return;
12912        };
12913        let when = ev
12914            .at
12915            .map(|t| {
12916                t.with_timezone(&chrono::Local)
12917                    .format("%Y-%m-%d %H:%M:%S")
12918                    .to_string()
12919            })
12920            .unwrap_or_else(|| "—".into());
12921        let line = format!("{when}  [{}]  {}  {}", ev.severity, ev.env, ev.message);
12922        match yank(&line) {
12923            Ok(()) => {
12924                self.status_message = Some(format!(
12925                    "yanked event line ({} chars)",
12926                    line.chars().count()
12927                ));
12928            }
12929            Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
12930        }
12931    }
12932}
12933
12934/// Compact age formatter — "3s", "12s", "2m", "1h", "4d". Used for the
12935/// pending-actions overlay so ages stay short and uniform.
12936/// Pure: the version label deployed *before* `current`, found by
12937/// scanning `events` (newest-first, as `DescribeEvents` returns)
12938/// for the first `version_label` that differs from `current`. EB
12939/// tags each event with the version current at the time, so walking
12940/// back, the first label ≠ `current` is the one the env ran before
12941/// this deploy. `None` when no prior version appears in the window.
12942pub fn previous_version_label(events: &[EbEvent], current: &str) -> Option<String> {
12943    events
12944        .iter()
12945        .filter_map(|e| e.version_label.as_deref())
12946        .filter(|v| !v.is_empty())
12947        .find(|v| *v != current)
12948        .map(|v| v.to_string())
12949}
12950
12951/// Pure: whether an event message looks like a deploy or a
12952/// configuration change — the rows the `:changes` timeline keeps,
12953/// filtering out routine health / scaling / launch noise.
12954pub fn is_config_event(message: &str) -> bool {
12955    let m = message.to_ascii_lowercase();
12956    m.contains("version label")
12957        || m.contains("deploying")
12958        || m.contains("configuration")
12959        || m.contains("config setting")
12960}
12961
12962/// Render the `:changes` overlay — the env's deploy / config-change
12963/// events as a newest-first timeline. Routine health + scaling
12964/// events are filtered out by [`is_config_event`].
12965pub(crate) fn render_changes_overlay(env: &str, events: &[EbEvent]) -> String {
12966    let rows: Vec<&EbEvent> = events
12967        .iter()
12968        .filter(|e| is_config_event(&e.message))
12969        .collect();
12970    if rows.is_empty() {
12971        return format!(
12972            "Config change timeline — {env}\n\n\
12973             No deploy / config-change events in the recent window.\n\n\
12974             esc / q to close"
12975        );
12976    }
12977    let mut body = format!(
12978        "Config change timeline — {env}\n\
12979         {} change event(s), newest first.\n\n",
12980        rows.len()
12981    );
12982    for e in rows {
12983        let ts =
12984            e.at.map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string())
12985                .unwrap_or_else(|| "—".into());
12986        let ver = e
12987            .version_label
12988            .as_deref()
12989            .map(|v| format!("  [{v}]"))
12990            .unwrap_or_default();
12991        body.push_str(&format!("{ts}{ver}\n    {}\n\n", e.message));
12992    }
12993    body.push_str("esc / q to close");
12994    body
12995}
12996
12997/// One row in the `:lineage` overlay — a single deploy, identified
12998/// by its version label. The two timestamps bracket the deploy's
12999/// event group (earliest event = "deploy started", latest = "deploy
13000/// completed"); the gap to the *next-older* deploy is computed at
13001/// render time so the row stays cheap to compare.
13002#[derive(Debug, Clone, PartialEq)]
13003pub(crate) struct LineageRow {
13004    pub label: String,
13005    pub first_at: Option<chrono::DateTime<chrono::Utc>>,
13006    pub last_at: Option<chrono::DateTime<chrono::Utc>>,
13007}
13008
13009/// Pure: collapse the env's recent events into one row per distinct
13010/// deploy. Events come in newest-first; this walks them oldest-first
13011/// so consecutive same-label events fold into one row carrying the
13012/// span (first → last) of that deploy's event group, then reverses
13013/// the result so callers see newest-first. Events without a
13014/// `version_label` are dropped — `:lineage` is the deploy-only cut
13015/// of the event history.
13016pub(crate) fn build_lineage(events: &[EbEvent]) -> Vec<LineageRow> {
13017    let mut oldest_first: Vec<&EbEvent> = events
13018        .iter()
13019        .filter(|e| {
13020            e.version_label
13021                .as_deref()
13022                .map(|v| !v.is_empty())
13023                .unwrap_or(false)
13024        })
13025        .collect();
13026    oldest_first.reverse();
13027    let mut rows: Vec<LineageRow> = Vec::new();
13028    for e in oldest_first {
13029        // Safe to unwrap: the filter above guarantees a non-empty label.
13030        let label = e.version_label.clone().unwrap();
13031        match rows.last_mut() {
13032            Some(last) if last.label == label => {
13033                if let Some(t) = e.at {
13034                    last.last_at = Some(t);
13035                }
13036            }
13037            _ => rows.push(LineageRow {
13038                label,
13039                first_at: e.at,
13040                last_at: e.at,
13041            }),
13042        }
13043    }
13044    rows.into_iter().rev().collect()
13045}
13046
13047/// Render the `:lineage` overlay — one row per deploy, newest first,
13048/// with the deploy's span (`took`) and the gap to the next-older
13049/// deploy (`Δ since previous`). Empty event window produces a stub
13050/// matching the `:changes` style so the operator isn't left wondering
13051/// whether the fetch silently failed.
13052pub(crate) fn format_lineage(env: &str, events: &[EbEvent]) -> String {
13053    let rows = build_lineage(events);
13054    if rows.is_empty() {
13055        return format!(
13056            "Deploy lineage — {env}\n\n\
13057             No deploys in the recent event window.\n\n\
13058             esc / q to close"
13059        );
13060    }
13061    let mut body = format!(
13062        "Deploy lineage — {env}\n\
13063         {} deploy(s), newest first.  Δ = gap between deploy starts.\n\n",
13064        rows.len()
13065    );
13066    for (i, row) in rows.iter().enumerate() {
13067        let ts = row
13068            .first_at
13069            .map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string())
13070            .unwrap_or_else(|| "—".into());
13071        body.push_str(&format!("  ▸ {ts}  {}\n", row.label));
13072        if let (Some(f), Some(l)) = (row.first_at, row.last_at) {
13073            let span = l - f;
13074            if span.num_seconds() > 0 {
13075                body.push_str(&format!(
13076                    "       took {}\n",
13077                    humanize_short_age(Duration::from_secs(span.num_seconds() as u64))
13078                ));
13079            }
13080        }
13081        if let Some(next) = rows.get(i + 1) {
13082            if let (Some(this), Some(prev)) = (row.first_at, next.first_at) {
13083                let gap = this - prev;
13084                if gap.num_seconds() > 0 {
13085                    body.push_str(&format!(
13086                        "       Δ {} since previous deploy\n",
13087                        humanize_short_age(Duration::from_secs(gap.num_seconds() as u64))
13088                    ));
13089                }
13090            }
13091        }
13092        body.push('\n');
13093    }
13094    body.push_str("esc / q to close");
13095    body
13096}
13097
13098/// Pure: render the `:rollbacks-armed` overlay body — one row per
13099/// armed watchdog with env / target_label / armed_at age / time
13100/// remaining until deadline. Sorted by deadline so the soonest-
13101/// firing watchdog reads first.
13102pub(crate) fn format_armed_rollbacks(
13103    armed: &std::collections::HashMap<String, ArmedWatchdog>,
13104    now: chrono::DateTime<chrono::Utc>,
13105) -> String {
13106    if armed.is_empty() {
13107        return "(no auto-rollbacks armed)\n\nesc / q to close".to_string();
13108    }
13109    let mut rows: Vec<&ArmedWatchdog> = armed.values().collect();
13110    rows.sort_by_key(|w| w.deadline_at);
13111    let mut body = String::new();
13112    body.push_str("ENV                              TARGET            ARMED      DEADLINE IN\n");
13113    body.push_str("─────────────────────────────────────────────────────────────────────────\n");
13114    for w in rows {
13115        let armed_ago = (now - w.armed_at).num_seconds().max(0) as u64;
13116        let remaining_secs = (w.deadline_at - now).num_seconds();
13117        let armed_str = humanize_short_age(Duration::from_secs(armed_ago));
13118        let remaining_str = if remaining_secs <= 0 {
13119            "fired / expired".to_string()
13120        } else {
13121            humanize_short_age(Duration::from_secs(remaining_secs as u64))
13122        };
13123        body.push_str(&format!(
13124            "{:<32} {:<17} {:>5} ago  {}\n",
13125            truncate_armed_cell(&w.env_name, 32),
13126            truncate_armed_cell(&w.target_label, 17),
13127            armed_str,
13128            remaining_str,
13129        ));
13130    }
13131    body.push_str("\nesc / q to close");
13132    body
13133}
13134
13135/// Soonest-firing armed watchdog's countdown — used by the header
13136/// pill chain to show "⏱ rollback prod-api in 4m22s". Returns
13137/// `None` when nothing is armed.
13138pub(crate) fn soonest_armed_rollback(
13139    armed: &std::collections::HashMap<String, ArmedWatchdog>,
13140    now: chrono::DateTime<chrono::Utc>,
13141) -> Option<(String, String)> {
13142    let next = armed.values().min_by_key(|w| w.deadline_at)?;
13143    let remaining_secs = (next.deadline_at - now).num_seconds();
13144    let remaining_str = if remaining_secs <= 0 {
13145        "now".to_string()
13146    } else {
13147        humanize_short_age(Duration::from_secs(remaining_secs as u64))
13148    };
13149    Some((next.env_name.clone(), remaining_str))
13150}
13151
13152/// Soonest-resolving watching-deploy tracker's countdown — used by
13153/// the header pill so the operator sees "👁 watching prod-api in
13154/// 4m22s" for `:deploy --wait-for-green Nm`. Returns `None` when
13155/// nothing is being watched. Parallel to `soonest_armed_rollback`.
13156pub(crate) fn soonest_watching_deploy(
13157    watching: &std::collections::HashMap<String, WatchingDeploy>,
13158    now: chrono::DateTime<chrono::Utc>,
13159) -> Option<(String, String)> {
13160    let next = watching.values().min_by_key(|w| w.deadline_at)?;
13161    let remaining_secs = (next.deadline_at - now).num_seconds();
13162    let remaining_str = if remaining_secs <= 0 {
13163        "now".to_string()
13164    } else {
13165        humanize_short_age(Duration::from_secs(remaining_secs as u64))
13166    };
13167    Some((next.env_name.clone(), remaining_str))
13168}
13169
13170/// Cell truncator local to `format_armed_rollbacks`. Trailing `…`
13171/// keeps the column alignment stable on long env names / version
13172/// labels.
13173fn truncate_armed_cell(s: &str, n: usize) -> String {
13174    if s.chars().count() <= n {
13175        return s.to_string();
13176    }
13177    let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
13178    out.push('…');
13179    out
13180}
13181
13182/// Pure: build the reverse-action of an option-settings write by
13183/// looking up the affected (namespace, name) pairs in the
13184/// pre-write snapshot. Keys that previously had a value get
13185/// reversed via `to_set` (restore old value); keys that were
13186/// previously unset get reversed via `to_remove` (drop the key).
13187///
13188/// EB's option-settings API doesn't distinguish "unset" from
13189/// "set to empty string" — we treat empty-string-prior as unset
13190/// (the common case) so the reverse cleanly removes the key
13191/// rather than leaving it as a literal empty string.
13192pub(crate) fn build_undo_entry(
13193    env_name: &str,
13194    original_summary: &str,
13195    to_set: &[(String, String, String)],
13196    to_remove: &[(String, String)],
13197    pre_write: &[(String, String, String)],
13198) -> UndoEntry {
13199    let lookup = |ns: &str, name: &str| -> Option<&String> {
13200        pre_write
13201            .iter()
13202            .find(|(n, k, _)| n == ns && k == name)
13203            .map(|(_, _, v)| v)
13204    };
13205    let mut reverse_set: Vec<(String, String, String)> = Vec::new();
13206    let mut reverse_remove: Vec<(String, String)> = Vec::new();
13207    // For each key the original write SET, the reverse is either
13208    // (a) restore the prior value, or (b) remove the key if it
13209    // was previously unset / empty.
13210    for (ns, name, _) in to_set {
13211        match lookup(ns, name) {
13212            Some(prev) if !prev.is_empty() => {
13213                reverse_set.push((ns.clone(), name.clone(), prev.clone()));
13214            }
13215            _ => {
13216                reverse_remove.push((ns.clone(), name.clone()));
13217            }
13218        }
13219    }
13220    // For each key the original write REMOVED, the reverse is to
13221    // restore the prior value — but only if there was one. If the
13222    // key was already absent, the remove was a no-op and the
13223    // reverse is nothing.
13224    for (ns, name) in to_remove {
13225        if let Some(prev) = lookup(ns, name) {
13226            if !prev.is_empty() {
13227                reverse_set.push((ns.clone(), name.clone(), prev.clone()));
13228            }
13229        }
13230    }
13231    UndoEntry {
13232        env_name: env_name.to_string(),
13233        to_set: reverse_set,
13234        to_remove: reverse_remove,
13235        original_summary: original_summary.to_string(),
13236        captured_at: chrono::Utc::now(),
13237    }
13238}
13239
13240/// Pure: expand a typed command line through the operator's
13241/// alias map. If the first whitespace-separated token matches a
13242/// key, swap it for the alias's expansion and keep any remaining
13243/// args (appended after the expansion). Single-level only — the
13244/// expanded line is NOT re-checked for further aliases, so
13245/// `alias.x = "x ..."` is safe (degenerates to "x ..." dispatched
13246/// once). Non-alias lines pass through unchanged.
13247///
13248/// Owned `String` return so the caller can borrow `.as_str()`
13249/// without lifetime gymnastics around the input slice.
13250pub fn expand_command_alias(
13251    line: &str,
13252    aliases: &std::collections::HashMap<String, String>,
13253) -> String {
13254    let line = line.trim();
13255    if aliases.is_empty() || line.is_empty() {
13256        return line.to_string();
13257    }
13258    let mut parts = line.splitn(2, char::is_whitespace);
13259    let first = match parts.next() {
13260        Some(s) => s,
13261        None => return line.to_string(),
13262    };
13263    let Some(expansion) = aliases.get(first) else {
13264        return line.to_string();
13265    };
13266    match parts.next() {
13267        Some(rest) => format!("{expansion} {rest}"),
13268        None => expansion.clone(),
13269    }
13270}
13271
13272/// Pure: how many instances will be simultaneously unavailable
13273/// during a deploy with the given EB deployment policy + batch
13274/// settings + ASG max-size. Returns the worst-case planning
13275/// number — what the operator sees on the EB dashboard during
13276/// the rollout. `asg_max` clamps at 1 to avoid divide-by-zero
13277/// nonsense on misconfigured envs.
13278///
13279/// Numbers per policy (EB docs):
13280/// - `AllAtOnce` — every instance restarts simultaneously
13281/// - `Rolling` — one batch at a time, no extra capacity
13282/// - `RollingWithAdditionalBatch` — extra batch launched first
13283/// - `Immutable` — new instances alongside (no current-fleet impact)
13284/// - `TrafficSplitting` — new ASG receives % traffic (no impact)
13285pub fn compute_unavailability_count(
13286    policy: &str,
13287    batch_size: i32,
13288    batch_size_type: &str,
13289    asg_max: i32,
13290) -> i32 {
13291    let asg_max = asg_max.max(1);
13292    match policy {
13293        p if p.eq_ignore_ascii_case("AllAtOnce") => asg_max,
13294        p if p.eq_ignore_ascii_case("Rolling") => {
13295            compute_batch_count(batch_size, batch_size_type, asg_max)
13296        }
13297        // The "additional batch" launches before rotating, so no
13298        // capacity dip from the perspective of in-service requests.
13299        p if p.eq_ignore_ascii_case("RollingWithAdditionalBatch") => 0,
13300        p if p.eq_ignore_ascii_case("Immutable") => 0,
13301        p if p.eq_ignore_ascii_case("TrafficSplitting") => 0,
13302        // Unknown policy — be honest about the lack of signal.
13303        // Return the worst case rather than 0 so an operator with
13304        // a custom policy isn't lulled by a false-zero.
13305        _ => asg_max,
13306    }
13307}
13308
13309/// Pure helper: resolve `BatchSize` + `BatchSizeType` into a
13310/// concrete instance count, clamped to [1, asg_max]. `Percentage`
13311/// rounds UP (a 33% batch on a 4-instance ASG is 2 instances; EB
13312/// rounds up internally too, per the docs).
13313pub fn compute_batch_count(batch_size: i32, batch_size_type: &str, asg_max: i32) -> i32 {
13314    if batch_size_type.eq_ignore_ascii_case("Percentage") {
13315        let pct = batch_size.clamp(1, 100);
13316        // Manual ceiling-divide: `i32::div_ceil` is still unstable
13317        // on this MSRV (1.91). Both operands are positive after the
13318        // clamps above, so `(a + b - 1) / b` is safe.
13319        let count = (asg_max * pct + 99) / 100;
13320        count.max(1).min(asg_max)
13321    } else {
13322        // Fixed — `BatchSizeType=Fixed` or any non-Percentage value.
13323        batch_size.max(1).min(asg_max)
13324    }
13325}
13326
13327/// Pure: render the modal's unavailability line. Returns the
13328/// human-readable text plus a severity flag for colouring (true
13329/// = caution, false = green/no impact).
13330pub fn format_unavailability_line(policy: &str, unavailable: i32, asg_max: i32) -> (String, bool) {
13331    let asg_max = asg_max.max(1);
13332    let caution = unavailable > 0;
13333    let plural = if unavailable == 1 {
13334        "instance"
13335    } else {
13336        "instances"
13337    };
13338    let body = if unavailable == 0 {
13339        format!("deploy plan: {policy} → no in-service unavailability")
13340    } else {
13341        format!("deploy plan: {policy} → max {unavailable}/{asg_max} {plural} unavailable")
13342    };
13343    (body, caution)
13344}
13345
13346/// Pure: extract the four option-settings the unavailability
13347/// estimate needs from the flat `(namespace, name, value)` shape
13348/// `fetch_env_option_settings` returns. Defaults match EB's own
13349/// defaults so the math degrades gracefully on partial reads.
13350pub fn extract_unavailability_inputs(
13351    opts: &[(String, String, String)],
13352) -> (String, i32, String, i32) {
13353    let get = |ns: &str, name: &str| -> Option<&String> {
13354        opts.iter()
13355            .find(|(n, k, _)| n == ns && k == name)
13356            .map(|(_, _, v)| v)
13357    };
13358    let policy = get("aws:elasticbeanstalk:command", "DeploymentPolicy")
13359        .cloned()
13360        .filter(|s| !s.is_empty())
13361        .unwrap_or_else(|| "AllAtOnce".to_string());
13362    let batch_size = get("aws:elasticbeanstalk:command", "BatchSize")
13363        .and_then(|s| s.parse::<i32>().ok())
13364        .unwrap_or(1);
13365    let batch_size_type = get("aws:elasticbeanstalk:command", "BatchSizeType")
13366        .cloned()
13367        .filter(|s| !s.is_empty())
13368        .unwrap_or_else(|| "Fixed".to_string());
13369    let asg_max = get("aws:autoscaling:asg", "MaxSize")
13370        .and_then(|s| s.parse::<i32>().ok())
13371        .unwrap_or(1);
13372    (policy, batch_size, batch_size_type, asg_max)
13373}
13374
13375/// Pure: compose a probe URL from a CNAME + a health-check path.
13376/// EB CNAMEs are bare hostnames (`api-prod.eba.amazonaws.com`);
13377/// the path may or may not start with a slash. We always emit a
13378/// `http://` URL because EB envs aren't HTTPS by default and a
13379/// missing TLS cert is a separate operator concern (the probe
13380/// shouldn't false-positive on that). Operators with custom TLS
13381/// can put their HTTPS CNAME directly into their LB listener
13382/// config; the probe is a development-mode best-effort signal.
13383pub fn build_health_check_probe_url(cname: &str, path: &str) -> String {
13384    let path = if path.starts_with('/') {
13385        path.to_string()
13386    } else if path.is_empty() {
13387        "/".to_string()
13388    } else {
13389        format!("/{path}")
13390    };
13391    format!("http://{cname}{path}")
13392}
13393
13394/// Run the pre-deploy probe via curl: HEAD + 2s timeout + follow
13395/// redirects (operators sometimes set the health-check-url to a
13396/// path that 301s to the real handler). Returns `Ok(())` for any
13397/// 2xx; `Err(<short reason>)` for non-2xx, timeout, or transport
13398/// errors. The reason string is surfaced in the modal so the
13399/// operator can decide whether the warning matters.
13400pub(crate) async fn run_health_check_probe(url: &str) -> Result<(), String> {
13401    use tokio::process::Command;
13402    let out = Command::new("curl")
13403        .args([
13404            "-s",
13405            "-o",
13406            "/dev/null",
13407            "-L",
13408            "--max-time",
13409            "2",
13410            "-w",
13411            "%{http_code}",
13412            "-I",
13413        ])
13414        .arg(url)
13415        .output()
13416        .await
13417        .map_err(|e| format!("could not invoke curl: {e}"))?;
13418    if !out.status.success() {
13419        // curl exit code 28 is the timeout; everything else is some
13420        // form of connect/resolve/protocol error. Surface the
13421        // stderr message when present so the operator gets a hint.
13422        let stderr = String::from_utf8_lossy(&out.stderr);
13423        let stderr = stderr.trim();
13424        if !stderr.is_empty() {
13425            return Err(stderr
13426                .lines()
13427                .next()
13428                .unwrap_or("transport error")
13429                .to_string());
13430        }
13431        return Err(format!("curl exit {}", out.status.code().unwrap_or(-1)));
13432    }
13433    let code_str = String::from_utf8_lossy(&out.stdout);
13434    let code: u16 = code_str
13435        .trim()
13436        .parse()
13437        .map_err(|_| format!("unparseable status `{}`", code_str.trim()))?;
13438    classify_health_check_status(code)
13439}
13440
13441/// Pure status-code classifier — surfaced as a separate helper so
13442/// the matrix is unit-testable without invoking curl.
13443pub(crate) fn classify_health_check_status(code: u16) -> Result<(), String> {
13444    match code {
13445        200..=299 => Ok(()),
13446        0 => Err("no response (transport error)".into()),
13447        300..=399 => Err(format!("HTTP {code} (redirect — curl was told to follow)")),
13448        // 4xx / 5xx — the live URL responded but with an error.
13449        // The most common offender is 404 (path not configured on
13450        // the new app version) which is exactly the auto-rollback
13451        // footgun we're trying to warn about.
13452        400..=599 => Err(format!("HTTP {code}")),
13453        // Forward-compat: any other range is a server doing
13454        // something unusual. Surface it verbatim.
13455        _ => Err(format!("HTTP {code}")),
13456    }
13457}
13458
13459/// Pure: "deploy has fully succeeded for this env" predicate.
13460/// Both conditions matter — `UpdateEnvironment` momentarily
13461/// leaves `health=Green` while `status` flips to `Updating`,
13462/// so a watcher that only checks health would false-positive
13463/// during that window and disarm a rollback (or report
13464/// success) before the deploy has actually settled. Single
13465/// source of truth shared by the rollback-watchdog pass, the
13466/// wait-for-green pass, and the non-interactive CLI's
13467/// `decide_poll`.
13468///
13469/// Truthy when:
13470/// - `status` is `Ready` (case-insensitive, EB's settled state)
13471/// - `health` is `Green` or `Ok` (case-insensitive, EB's two
13472///   "all-clear" terms across enhanced vs legacy reporting)
13473pub fn deploy_settled_green(status: &str, health: &str) -> bool {
13474    status.eq_ignore_ascii_case("Ready")
13475        && (health.eq_ignore_ascii_case("Green") || health.eq_ignore_ascii_case("Ok"))
13476}
13477
13478pub fn humanize_short_age(d: Duration) -> String {
13479    let secs = d.as_secs();
13480    if secs < 60 {
13481        format!("{secs}s")
13482    } else if secs < 3600 {
13483        format!("{}m", secs / 60)
13484    } else if secs < 86_400 {
13485        format!("{}h", secs / 3600)
13486    } else {
13487        format!("{}d", secs / 86_400)
13488    }
13489}
13490
13491/// Parse a `:tag KEY [value tokens…]` argument list. Returns `Some((key,
13492/// value))` when there's at least a key and one value token. Value tokens
13493/// are joined with a single space — there's no shell-style quoting, since
13494/// we trust the operator and want the command bar to stay typeable.
13495pub fn parse_tag_args(rest: &[&str]) -> Option<(String, String)> {
13496    let key = (*rest.first()?).to_string();
13497    if rest.len() < 2 {
13498        return None;
13499    }
13500    let value = rest[1..].join(" ");
13501    if key.is_empty() || value.is_empty() {
13502        return None;
13503    }
13504    Some((key, value))
13505}
13506
13507/// Extract a "delta toast key" from text shaped like `▲2 Red` / `▼1 Yellow`.
13508/// Returns `Some(bucket_name)` when the text is a status-delta toast and we
13509/// want subsequent updates for the same bucket to replace rather than stack.
13510/// Pure function so it's easy to pin down in tests.
13511pub fn delta_toast_key(text: &str) -> Option<String> {
13512    let trimmed = text.trim_start();
13513    let mut chars = trimmed.chars();
13514    let first = chars.next()?;
13515    if first != '▲' && first != '▼' {
13516        return None;
13517    }
13518    let rest: String = chars.collect();
13519    // Require at least one digit immediately after the arrow.
13520    let first_rest = rest.chars().next()?;
13521    if !first_rest.is_ascii_digit() {
13522        return None;
13523    }
13524    let bucket_start = rest.find(|c: char| !c.is_ascii_digit())?;
13525    let after_digits = &rest[bucket_start..];
13526    let bucket = after_digits.trim_start();
13527    if bucket.is_empty() || !bucket.starts_with(|c: char| c.is_ascii_alphabetic()) {
13528        return None;
13529    }
13530    let word: String = bucket
13531        .chars()
13532        .take_while(|c| c.is_ascii_alphabetic())
13533        .collect();
13534    Some(word)
13535}
13536
13537fn yank(text: &str) -> std::result::Result<(), String> {
13538    let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
13539    cb.set_text(text.to_string()).map_err(|e| e.to_string())
13540}
13541
13542/// Pair every async AWS error with a full-chain log entry. The returned string
13543/// is the SDK's top-level `Display` (concise, suitable for the toast/footer);
13544/// the chain — including the underlying `dyn Error` causes that color-eyre
13545/// records on `Report` — goes to `ebman.log` via `tracing::error!`. Without
13546/// this the chain was lost both from the UI and the log.
13547/// Which EC2 surface a MultiSelect form is pulling its option list from.
13548/// Drives both the EC2 API call and the option-setting target so the
13549/// pickers share `open_multi_select_form` without conditional branches.
13550#[derive(Copy, Clone, Debug)]
13551enum MultiSelectFlavour {
13552    Subnets,
13553    /// Subnets attached to the env's ELB (web tier). Same EC2 list call
13554    /// as `Subnets` but writes to a different option setting and
13555    /// pre-fills from a different field on the env's VPC context.
13556    ElbSubnets,
13557    SecurityGroups,
13558}
13559
13560/// Fetch VPC context + EC2 inventory + current selection for a MultiSelect
13561/// picker, in parallel. Returns the data the form's field needs to flip
13562/// from Loading → Ready.
13563async fn load_multi_select(
13564    aws: Arc<crate::aws::AwsClient>,
13565    app_name: &str,
13566    env_name: &str,
13567    flavour: MultiSelectFlavour,
13568) -> Result<MultiSelectOptions, String> {
13569    let ctx = aws
13570        .fetch_env_vpc_context(app_name, env_name)
13571        .await
13572        .map_err(|e| flatten_err("fetch_env_vpc_context", e))?;
13573    let Some(vpc_id) = ctx.vpc_id.as_deref() else {
13574        return Err("env has no VPC id in its option settings — using account-default VPC?".into());
13575    };
13576    match flavour {
13577        MultiSelectFlavour::Subnets | MultiSelectFlavour::ElbSubnets => {
13578            let subnets = aws
13579                .list_subnets_in_vpc(vpc_id)
13580                .await
13581                .map_err(|e| flatten_err("list_subnets_in_vpc", e))?;
13582            let mut options = Vec::with_capacity(subnets.len());
13583            let mut annotations = Vec::with_capacity(subnets.len());
13584            for s in subnets {
13585                options.push(s.id.clone());
13586                let mut annot = format!("({} · {}", s.availability_zone, s.cidr_block);
13587                if let Some(name) = s.name_tag.as_ref().filter(|n| !n.is_empty()) {
13588                    annot.push_str(" · ");
13589                    annot.push_str(name);
13590                }
13591                annot.push(')');
13592                annotations.push(annot);
13593            }
13594            let initial = match flavour {
13595                MultiSelectFlavour::ElbSubnets => ctx.elb_subnets,
13596                _ => ctx.subnets,
13597            };
13598            Ok(MultiSelectOptions {
13599                options,
13600                annotations,
13601                initial,
13602            })
13603        }
13604        MultiSelectFlavour::SecurityGroups => {
13605            let groups = aws
13606                .list_security_groups_in_vpc(vpc_id)
13607                .await
13608                .map_err(|e| flatten_err("list_security_groups_in_vpc", e))?;
13609            let mut options = Vec::with_capacity(groups.len());
13610            let mut annotations = Vec::with_capacity(groups.len());
13611            for g in groups {
13612                options.push(g.id.clone());
13613                let desc_suffix = if g.description.is_empty() {
13614                    String::new()
13615                } else {
13616                    format!(" — {}", g.description)
13617                };
13618                annotations.push(format!("({}{desc_suffix})", g.group_name));
13619            }
13620            Ok(MultiSelectOptions {
13621                options,
13622                annotations,
13623                initial: ctx.security_groups,
13624            })
13625        }
13626    }
13627}
13628
13629/// Load the cert picker for `:listener-edit`: the region's ACM
13630/// certificates as options, plus the listener's current
13631/// `SSLCertificateArns` as the pre-selected `initial` set.
13632async fn load_listener_certs(
13633    aws: Arc<crate::aws::AwsClient>,
13634    app_name: &str,
13635    env_name: &str,
13636    port: &str,
13637) -> Result<MultiSelectOptions, String> {
13638    let certs = aws
13639        .list_certificates()
13640        .await
13641        .map_err(|e| flatten_err("list_certificates", e))?;
13642    let listeners = aws
13643        .fetch_env_listeners(app_name, env_name)
13644        .await
13645        .map_err(|e| flatten_err("fetch_env_listeners", e))?;
13646    let initial: Vec<String> = listeners
13647        .iter()
13648        .find(|(p, opt, _)| p == port && opt == "SSLCertificateArns")
13649        .map(|(_, _, v)| {
13650            v.split(',')
13651                .map(|s| s.trim().to_string())
13652                .filter(|s| !s.is_empty())
13653                .collect()
13654        })
13655        .unwrap_or_default();
13656    let mut options = Vec::with_capacity(certs.len());
13657    let mut annotations = Vec::with_capacity(certs.len());
13658    for c in certs {
13659        options.push(c.arn);
13660        annotations.push(if c.domain.is_empty() {
13661            String::new()
13662        } else {
13663            format!("({})", c.domain)
13664        });
13665    }
13666    Ok(MultiSelectOptions {
13667        options,
13668        annotations,
13669        initial,
13670    })
13671}
13672
13673/// Pure: copy `latest_version_label` / `latest_version_created` from a
13674/// previous `applications` snapshot onto the new one (matched by name) so
13675/// the apps-view LATEST column doesn't flicker to "—" while the follow-up
13676/// `DescribeApplicationVersions` fan-out is in flight after each refresh.
13677///
13678/// Only fills slots that are currently `None`. Today `list_applications`
13679/// never populates those fields itself so the conditional is a no-op
13680/// safety net — but it means a future caller that *does* pre-populate
13681/// won't get silently overwritten with stale data.
13682fn merge_app_latest_versions(prev: &[Application], next: &mut [Application]) {
13683    let by_name: std::collections::HashMap<
13684        &str,
13685        (&Option<String>, &Option<chrono::DateTime<chrono::Utc>>),
13686    > = prev
13687        .iter()
13688        .map(|a| {
13689            (
13690                a.name.as_str(),
13691                (&a.latest_version_label, &a.latest_version_created),
13692            )
13693        })
13694        .collect();
13695    for app in next.iter_mut() {
13696        let Some((label, created)) = by_name.get(app.name.as_str()) else {
13697            continue;
13698        };
13699        if app.latest_version_label.is_none() {
13700            app.latest_version_label = (*label).clone();
13701        }
13702        if app.latest_version_created.is_none() {
13703            app.latest_version_created = **created;
13704        }
13705    }
13706}
13707
13708/// Pure: redact a free-form string for display in the `:history` overlay
13709/// context header. Matches the `redact` helper in `ui.rs` (full-block
13710/// shaded chars preserving length) so the look is consistent — duplicated
13711/// rather than imported because the ui module's `redact` is private.
13712pub(crate) fn redact_for_log(value: &str, on: bool) -> String {
13713    if !on || value.is_empty() || value == "—" {
13714        return value.to_string();
13715    }
13716    "▓".repeat(value.chars().count())
13717}
13718
13719/// Inferred kind of an `Updating` env's in-flight operation. EB's
13720/// `status` field is generic ("Updating") regardless of cause, but the
13721/// recent events expose what's actually happening. The Health tab uses
13722/// this to render `Updating: deploying build-142` (or similar) instead
13723/// of just the generic pill.
13724#[derive(Debug, Clone, PartialEq, Eq)]
13725pub enum UpdateKind {
13726    /// `UpdateEnvironment(version_label)` — a version deploy in flight.
13727    /// `version_label` is extracted from the event message when present.
13728    Deploy { version_label: Option<String> },
13729    /// `UpdateEnvironment(option_settings)` — configuration change in flight.
13730    Config,
13731    /// Auto-scaling activity — instances being added or removed.
13732    Scale,
13733    /// `UpdateEnvironment(platform_arn)` / managed platform update.
13734    Platform,
13735    /// Status is Updating but no recent event matches a known pattern.
13736    /// Falls back to a generic "updating" label.
13737    Generic,
13738}
13739
13740/// Pure: classify an `Updating` env's in-flight op by looking at the
13741/// most recent event whose message matches a known pattern. Events are
13742/// expected newest-first (as the EB API returns them); returns the kind
13743/// from the first matching event. Returns `Generic` when nothing
13744/// matches.
13745pub fn classify_update_kind(events: &[crate::aws::Event]) -> UpdateKind {
13746    for e in events {
13747        let lower = e.message.to_lowercase();
13748        // Deploy comes first — "version label" is the unambiguous signal.
13749        // The "version label" check catches both the dispatch event
13750        // (`Updating environment to use version label 'X'`) and the
13751        // completion event (`Environment update completed successfully
13752        // … version 'X'`).
13753        if lower.contains("version label") {
13754            return UpdateKind::Deploy {
13755                version_label: extract_quoted_after(&e.message, "version label"),
13756            };
13757        }
13758        if lower.contains("deploying") && lower.contains("version") {
13759            return UpdateKind::Deploy {
13760                version_label: extract_quoted_after(&e.message, "version"),
13761            };
13762        }
13763        // Platform updates have a distinctive "platform" + "updat" stem.
13764        if lower.contains("platform") && (lower.contains("updat") || lower.contains("upgrad")) {
13765            return UpdateKind::Platform;
13766        }
13767        // Config changes — option settings.
13768        if lower.contains("configuration") && lower.contains("updat") {
13769            return UpdateKind::Config;
13770        }
13771        // Auto-scaling — instances coming or going.
13772        if (lower.contains("adding") || lower.contains("removing")) && lower.contains("instance") {
13773            return UpdateKind::Scale;
13774        }
13775    }
13776    UpdateKind::Generic
13777}
13778
13779/// Pure: extract the first single-quoted string that appears after
13780/// `needle` in `msg` (case-insensitive needle match). Returns None if
13781/// the needle isn't found or there's no quoted substring after it. Used
13782/// to pull `'build-142'` out of "Updating environment to use version
13783/// label 'build-142'.".
13784fn extract_quoted_after(msg: &str, needle: &str) -> Option<String> {
13785    let lower = msg.to_lowercase();
13786    let needle_lower = needle.to_lowercase();
13787    let after = lower.find(&needle_lower)? + needle_lower.len();
13788    let tail = msg.get(after..)?;
13789    let start = tail.find('\'')?;
13790    let body = &tail[start + 1..];
13791    let end = body.find('\'')?;
13792    Some(body[..end].to_string())
13793}
13794
13795fn flatten_err(op: &str, e: color_eyre::eyre::Report) -> String {
13796    tracing::error!(target: "ebman::aws", op = op, error = ?e, "aws call failed");
13797    flatten_err_to_string(&e)
13798}
13799
13800/// Pure: convert an `eyre::Report` into the user-facing string we route into
13801/// toasts and the refresh-error path. The SDK's `Display` impl returns
13802/// generic strings like `"service error"` for throttling — the structured
13803/// AWS error codes (`ThrottlingException`, `AccessDenied`, etc.) live in
13804/// the `Debug` form. To keep toasts clean *and* let downstream predicates
13805/// like [`is_throttling_error`] do their job, we peek at the Debug dump
13806/// for known error codes and surface a clean `"<CodeName>: ..."` prefix.
13807/// All other errors pass through with Display unchanged.
13808pub(crate) fn flatten_err_to_string(e: &color_eyre::eyre::Report) -> String {
13809    let display = e.to_string();
13810    let dbg_lower = format!("{e:?}").to_lowercase();
13811    // Throttling tokens — kept in sync with `is_throttling_error` so the
13812    // predicate and the surfaced prefix can't drift.
13813    const THROTTLING_TOKENS: &[&str] = &[
13814        "throttling",
13815        "throttlingexception",
13816        "requestlimitexceeded",
13817        "too many requests",
13818        "rate exceeded",
13819    ];
13820    if THROTTLING_TOKENS.iter().any(|t| dbg_lower.contains(t)) {
13821        return format!("ThrottlingException: {display}");
13822    }
13823    // IAM / authorisation failures — operators hit these constantly when
13824    // bouncing between profiles. A clean prefix points them at the policy
13825    // gap rather than burying it in the SDK chain dump.
13826    const ACCESS_TOKENS: &[&str] = &[
13827        "accessdenied",
13828        "accessdeniedexception",
13829        "unauthorizedoperation",
13830        "not authorized to perform",
13831    ];
13832    if ACCESS_TOKENS.iter().any(|t| dbg_lower.contains(t)) {
13833        return format!("AccessDenied: {display}");
13834    }
13835    // Missing-resource errors. EB / S3 / SQS each have their own variant
13836    // names — surface a uniform NotFound prefix so operators don't have
13837    // to learn the per-service vocabulary.
13838    const NOTFOUND_TOKENS: &[&str] = &[
13839        "resourcenotfoundexception",
13840        "nosuchentity",
13841        "nosuchbucket",
13842        "nosuchkey",
13843        "queuedoesnotexist",
13844        "environmentnotfound",
13845        "applicationversionnotfound",
13846    ];
13847    if NOTFOUND_TOKENS.iter().any(|t| dbg_lower.contains(t)) {
13848        return format!("NotFound: {display}");
13849    }
13850    // Dependency conflicts — usually "can't delete X, Y still references it".
13851    const DEPENDENCY_TOKENS: &[&str] = &[
13852        "dependencyviolation",
13853        "resourceinuse",
13854        "operationinprogressexception",
13855        "invalidrequestexception",
13856    ];
13857    if DEPENDENCY_TOKENS.iter().any(|t| dbg_lower.contains(t)) {
13858        return format!("Conflict: {display}");
13859    }
13860    // Expired SSO / STS credentials — surface the rewrite the
13861    // ExpiredToken handler already does, in case the error reaches
13862    // this path via a different route.
13863    if dbg_lower.contains("expiredtoken") || dbg_lower.contains("tokenexpired") {
13864        return format!("ExpiredToken: {display}");
13865    }
13866    display
13867}
13868
13869fn parse_sort(raw: Option<&str>) -> (SortKey, bool) {
13870    let Some(s) = raw else {
13871        return (SortKey::App, false);
13872    };
13873    let (k, dir) = s.split_once(':').unwrap_or((s, "asc"));
13874    let key = SortKey::parse(k.trim()).unwrap_or(SortKey::App);
13875    let desc = dir.trim().eq_ignore_ascii_case("desc");
13876    (key, desc)
13877}
13878
13879fn health_rank(h: &str) -> u8 {
13880    match h.to_lowercase().as_str() {
13881        "green" | "ok" => 0,
13882        "grey" | "gray" | "info" | "no data" | "pending" => 1,
13883        "yellow" | "warning" => 2,
13884        "red" | "severe" | "degraded" => 3,
13885        _ => 4,
13886    }
13887}
13888
13889fn parse_toggle(arg: Option<&str>, current: bool) -> bool {
13890    match arg.map(str::to_ascii_lowercase).as_deref() {
13891        Some("on") | Some("true") | Some("yes") | Some("1") => true,
13892        Some("off") | Some("false") | Some("no") | Some("0") => false,
13893        _ => !current,
13894    }
13895}
13896
13897fn scroll_apply(current: u16, delta: i32) -> u16 {
13898    let next = current as i32 + delta;
13899    next.max(0) as u16
13900}
13901
13902/// Bucketed delta between two snapshots. `prev` is a per-env-name → bucket
13903/// snapshot from the previous refresh; `next` is the new env list. The accessor
13904/// extracts the bucket label (e.g. health or status). The result is sorted with
13905/// non-zero changes only, bucket-alphabetical.
13906/// Build the palette item list from current app state. Items are returned in a
13907/// stable order (commands first, then envs, then views, then plugins); ranking
13908/// happens at filter time.
13909fn build_palette_items(app: &App) -> Vec<PaletteItem> {
13910    let mut out: Vec<PaletteItem> = Vec::new();
13911
13912    // Built-in commands — generated from `crate::commands::COMMANDS` so
13913    // the registry, the palette, and the help screen can't drift apart.
13914    // ZeroArg → Enter executes; Prefill → Enter switches to command-bar
13915    // mode with the prefix typed in; Hidden → skipped here.
13916    for c in crate::commands::COMMANDS {
13917        match c.kind {
13918            crate::commands::CommandKind::ZeroArg => {
13919                out.push(PaletteItem {
13920                    label: format!(":{}", c.name),
13921                    detail: c.help.to_string(),
13922                    kind_tag: "cmd",
13923                    action: PaletteAction::RunCommand(c.name.to_string()),
13924                });
13925            }
13926            crate::commands::CommandKind::Prefill(prefix) => {
13927                out.push(PaletteItem {
13928                    label: format!(":{}", prefix.trim_end()),
13929                    detail: c.help.to_string(),
13930                    kind_tag: "cmd",
13931                    action: PaletteAction::PrefillCommand(prefix.to_string()),
13932                });
13933            }
13934        }
13935    }
13936
13937    // Envs — jump cursor.
13938    for e in &app.environments {
13939        let alias = app
13940            .aliases
13941            .get(&e.name)
13942            .map(|a| format!("  ({a})"))
13943            .unwrap_or_default();
13944        out.push(PaletteItem {
13945            label: e.name.clone(),
13946            detail: format!("env in {}{alias}  ·  {}", e.application, e.health),
13947            kind_tag: "env",
13948            action: PaletteAction::JumpEnv(e.name.clone()),
13949        });
13950    }
13951
13952    // Saved views.
13953    for name in app.saved_views.keys() {
13954        out.push(PaletteItem {
13955            label: format!("view: {name}"),
13956            detail: "load saved view".into(),
13957            kind_tag: "view",
13958            action: PaletteAction::LoadView(name.clone()),
13959        });
13960    }
13961
13962    // Plugins.
13963    for (name, plugin) in &app.plugins {
13964        out.push(PaletteItem {
13965            label: format!(":{name}"),
13966            detail: plugin
13967                .description
13968                .clone()
13969                .unwrap_or_else(|| format!("plugin: {}", plugin.template)),
13970            kind_tag: "plugin",
13971            action: PaletteAction::RunCommand(name.clone()),
13972        });
13973    }
13974
13975    out
13976}
13977
13978/// Score a palette item against the needle. Lower is better; `None` means no
13979/// match. Score is: prefix match → 0; substring → byte index of first match.
13980/// Detail string is also searched, with a penalty so label matches rank higher.
13981fn palette_score(needle: &str, label: &str, detail: &str) -> Option<isize> {
13982    if needle.is_empty() {
13983        return Some(0);
13984    }
13985    let l = label.to_lowercase();
13986    let d = detail.to_lowercase();
13987    if let Some(i) = l.find(needle) {
13988        return Some(i as isize);
13989    }
13990    if let Some(i) = d.find(needle) {
13991        return Some(1_000 + i as isize);
13992    }
13993    None
13994}
13995
13996fn bucket_delta<F>(
13997    prev: &HashMap<String, String>,
13998    next: &[Environment],
13999    accessor: F,
14000) -> Vec<(String, i32)>
14001where
14002    F: Fn(&Environment) -> String,
14003{
14004    // Only count envs present in *both* sides. Disappearing envs aren't a
14005    // transition (they just left), and new envs aren't a transition either
14006    // (no previous state to compare). This also makes a cleared `prev`
14007    // (e.g. after a context switch) produce zero deltas, instead of spamming
14008    // +N for every bucket the first time the new context loads.
14009    let mut prev_counts: BTreeMap<String, i32> = BTreeMap::new();
14010    let mut next_counts: BTreeMap<String, i32> = BTreeMap::new();
14011    for e in next {
14012        if let Some(prev_bucket) = prev.get(&e.name) {
14013            *prev_counts.entry(prev_bucket.clone()).or_insert(0) += 1;
14014            *next_counts.entry(accessor(e)).or_insert(0) += 1;
14015        }
14016    }
14017    let mut keys: BTreeMap<String, ()> = BTreeMap::new();
14018    for k in prev_counts.keys().chain(next_counts.keys()) {
14019        keys.insert(k.clone(), ());
14020    }
14021    keys.into_keys()
14022        .filter_map(|k| {
14023            let p = *prev_counts.get(&k).unwrap_or(&0);
14024            let n = *next_counts.get(&k).unwrap_or(&0);
14025            let d = n - p;
14026            if d != 0 {
14027                Some((k, d))
14028            } else {
14029                None
14030            }
14031        })
14032        .collect()
14033}
14034
14035/// Render env vars as `KEY=VALUE` lines, aligned on the `=` for easy scan.
14036/// Empty values render as `""` so operators can distinguish "explicitly
14037/// empty" from "not set". Pure.
14038pub fn format_env_vars(vars: &[(String, String)]) -> String {
14039    if vars.is_empty() {
14040        return "(no env vars set)".into();
14041    }
14042    let key_width = vars
14043        .iter()
14044        .map(|(k, _)| k.chars().count())
14045        .max()
14046        .unwrap_or(0)
14047        .clamp(8, 40);
14048    let mut out = String::new();
14049    for (k, v) in vars {
14050        let rendered = if v.is_empty() {
14051            "\"\"".to_string()
14052        } else {
14053            v.clone()
14054        };
14055        out.push_str(&format!("{k:<key_width$} = {rendered}\n"));
14056    }
14057    out
14058}
14059
14060/// Parse the optional trailing args of `:metric add LABEL NS NAME ...`.
14061/// Args after `NAME` are either a stat name (`Average`, `Sum`, ...) or a
14062/// dimension list (`InstanceId=i-abc,Foo=bar`). Any token containing `=`
14063/// is treated as dims; the other is stat. Returns `(stat, dims)` with
14064/// `stat` defaulting to `Average` and `dims` to empty when absent. Pure.
14065pub fn parse_metric_extra_args(args: &[&str]) -> (String, Vec<(String, String)>) {
14066    let mut stat: Option<String> = None;
14067    let mut dims: Vec<(String, String)> = Vec::new();
14068    for tok in args {
14069        if tok.contains('=') {
14070            for kv in tok.split(',') {
14071                if let Some((k, v)) = kv.split_once('=') {
14072                    let k = k.trim();
14073                    let v = v.trim();
14074                    if !k.is_empty() && !v.is_empty() {
14075                        dims.push((k.to_string(), v.to_string()));
14076                    }
14077                }
14078            }
14079        } else if stat.is_none() {
14080            stat = Some(tok.to_string());
14081        }
14082    }
14083    (stat.unwrap_or_else(|| "Average".into()), dims)
14084}
14085
14086/// Parse an `s3://bucket/key/with/slashes` URL into a `(bucket, key)`
14087/// tuple. Returns `None` if the input isn't an `s3://` URL or the bucket
14088/// or key is empty. Pure.
14089pub fn parse_s3_url(raw: &str) -> Option<(String, String)> {
14090    let rest = raw.strip_prefix("s3://")?;
14091    let (bucket, key) = rest.split_once('/')?;
14092    if bucket.is_empty() || key.is_empty() {
14093        return None;
14094    }
14095    Some((bucket.to_string(), key.to_string()))
14096}
14097
14098/// Expand a leading `~/` to `$HOME/`. Other tilde forms (e.g. `~user`) are
14099/// left as-is; the operator gets a clear "can't read" error if they pass
14100/// something obscure. Pure for ease of testing.
14101pub fn expand_tilde(path: &str) -> String {
14102    if let Some(rest) = path.strip_prefix("~/") {
14103        if let Some(home) = std::env::var_os("HOME") {
14104            let mut p = std::path::PathBuf::from(home);
14105            p.push(rest);
14106            return p.display().to_string();
14107        }
14108    }
14109    path.to_string()
14110}
14111
14112/// Derive a version label from a file path + a timestamp. Uses the
14113/// filename stem (everything before the last `.`) so `./build.zip` becomes
14114/// `build_1684512345`. Sanitises any chars EB rejects in version labels
14115/// (anything outside `[A-Za-z0-9_.-]`). Pure for testability.
14116pub fn derive_version_label(path: &str, unix_ts: i64) -> String {
14117    let stem = std::path::Path::new(path)
14118        .file_stem()
14119        .and_then(|s| s.to_str())
14120        .unwrap_or("bundle");
14121    let sanitised: String = stem
14122        .chars()
14123        .map(|c| {
14124            if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
14125                c
14126            } else {
14127                '_'
14128            }
14129        })
14130        .collect();
14131    format!("{sanitised}_{unix_ts}")
14132}
14133
14134/// Helper: write the outcome audit line and send the AppMsg in one place
14135/// so each of the four early-return paths in `spawn_deploy_from_local`
14136/// stays one line. Free function (not a method) so it can be called from
14137/// the async closure without borrowing `self`.
14138#[allow(clippy::too_many_arguments)]
14139fn finish_deploy_from_local(
14140    tx: &tokio::sync::mpsc::UnboundedSender<AppMsg>,
14141    gen: u64,
14142    env_name: String,
14143    label: String,
14144    summary: String,
14145    account: Option<&str>,
14146    profile: Option<&str>,
14147    region: &str,
14148    result: Result<(), String>,
14149) {
14150    let outcome = match &result {
14151        Ok(()) => format!(
14152            "stage=completed action=DeployFromLocal target={env_name} label={label} outcome=ok"
14153        ),
14154        Err(e) => format!(
14155            "stage=completed action=DeployFromLocal target={env_name} label={label} outcome=err err=\"{}\"",
14156            crate::audit::escape_value(e)
14157        ),
14158    };
14159    write_audit_line(account, profile, region, &outcome);
14160    let _ = tx.send(AppMsg::DeployFromLocal {
14161        gen,
14162        env_name,
14163        label,
14164        summary,
14165        result,
14166    });
14167}
14168
14169/// Pick the most useful CloudWatch Logs group for an env's `:logs-tail`
14170/// default. EB streams to a handful of groups per env (web.stdout.log,
14171/// nginx access, eb-engine.log, …); we prefer the app stdout because that's
14172/// where deploy / runtime output lives. Falls back to the first by name.
14173/// Pure for testability.
14174pub fn pick_default_log_group(groups: &[String]) -> Option<String> {
14175    const PRIORITIES: &[&str] = &[
14176        "/var/log/web.stdout.log",
14177        "/var/log/eb-engine.log",
14178        "/var/log/eb-hooks.log",
14179        "/var/log/nginx/access.log",
14180    ];
14181    for needle in PRIORITIES {
14182        if let Some(g) = groups.iter().find(|g| g.ends_with(needle)) {
14183            return Some(g.clone());
14184        }
14185    }
14186    groups.first().cloned()
14187}
14188
14189/// Pull a `--flag VALUE` style named argument out of a `:command` `rest`
14190/// slice and parse it. Returns `None` if the flag is absent, the value is
14191/// missing, or parsing fails. Used by commands like `:logs-stream` that
14192/// take optional flags alongside their positional args. Pure.
14193pub fn parse_named_arg<T: std::str::FromStr>(rest: &[&str], flag: &str) -> Option<T> {
14194    let pos = rest.iter().position(|s| *s == flag)?;
14195    rest.get(pos + 1).and_then(|v| v.parse().ok())
14196}
14197
14198/// Render the `:versions` overlay body. Marks the currently-deployed
14199/// version with `◀ deployed`; trims the redundant
14200/// "Application version created from " prefix that every CI-pipeline
14201/// description tends to carry; shows "showing N of M (newest first)"
14202/// when the list was truncated. `limit` caps the visible rows.
14203/// Pure: render the `:deploy LABEL --preview` body. Highlights the
14204/// candidate version (label / age / description), the currently-deployed
14205/// version's age for context, and warns if the candidate predates the
14206/// current one (rolling back is intentional but worth flagging).
14207///
14208/// `versions` is the result of `list_application_versions` (already
14209/// sorted newest-first by the aws layer). Missing labels surface as
14210/// human-readable "not found" hints rather than blanks.
14211/// Pure: render the `:accounts` overlay body. Rows are sorted ACTIVE-first
14212/// then by name (the `list_org_accounts` helper does the sort on the AWS
14213/// side); this just formats one row per account with a `(:account NAME)`
14214/// hint when a matching `accounts.NAME` entry is configured in
14215/// config.toml. Without that entry, the row is informational only — the
14216/// operator must still configure a role_arn before AssumeRole works.
14217///
14218/// `configured` is the set of friendly names from `config.toml`'s
14219/// `accounts.*` section; matching is name-or-id-suffix so an operator
14220/// who names their entries by account-id still gets the hint.
14221pub fn format_org_accounts(
14222    accounts: &[crate::aws::OrgAccount],
14223    configured: &std::collections::HashMap<String, String>,
14224) -> String {
14225    if accounts.is_empty() {
14226        return "no accounts returned by organizations:ListAccounts\n\nesc / q to close".into();
14227    }
14228    let mut out = String::new();
14229    out.push_str(&format!(
14230        "Org accounts ({})\n────────────────────\n\n",
14231        accounts.len()
14232    ));
14233    let max_name = accounts
14234        .iter()
14235        .map(|a| a.name.len())
14236        .max()
14237        .unwrap_or(0)
14238        .min(28);
14239    for a in accounts {
14240        let switchable = configured
14241            .keys()
14242            .find(|n| {
14243                n.eq_ignore_ascii_case(&a.name)
14244                    || n.eq_ignore_ascii_case(&a.id)
14245                    || n.eq_ignore_ascii_case(&format!("acct-{}", a.id))
14246            })
14247            .cloned();
14248        let switch_hint = match switchable {
14249            Some(n) => format!(" :account {n}"),
14250            None => String::new(),
14251        };
14252        let status_marker = match a.status.as_str() {
14253            "ACTIVE" => "●",
14254            "SUSPENDED" => "⊘",
14255            _ => "○",
14256        };
14257        out.push_str(&format!(
14258            "  {status_marker} {name:<width$}  {id}  [{status}]{switch_hint}\n",
14259            name = a.name,
14260            width = max_name,
14261            id = a.id,
14262            status = a.status,
14263        ));
14264        if let Some(email) = a.email.as_ref() {
14265            out.push_str(&format!(
14266                "    {pad:<width$}  ↳ {email}\n",
14267                pad = "",
14268                width = max_name,
14269            ));
14270        }
14271    }
14272    out.push('\n');
14273    out.push_str(
14274        "To switch into an account, add `accounts.NAME.role_arn = …` to config.toml\n\
14275         then use `:account NAME`. esc / q to close.",
14276    );
14277    out
14278}
14279
14280pub fn format_deploy_preview(
14281    env_name: &str,
14282    current_label: &str,
14283    candidate_label: &str,
14284    versions: &[crate::aws::AppVersion],
14285) -> String {
14286    let now = chrono::Utc::now();
14287    let humanize = |d: Option<chrono::DateTime<chrono::Utc>>| -> String {
14288        d.map(|t| {
14289            let dur = now.signed_duration_since(t);
14290            let secs = dur.num_seconds().max(0);
14291            if secs < 3600 {
14292                format!("{}m ago", secs / 60)
14293            } else if secs < 86_400 {
14294                format!("{}h ago", secs / 3600)
14295            } else {
14296                format!("{}d ago", secs / 86_400)
14297            }
14298        })
14299        .unwrap_or_else(|| "—".into())
14300    };
14301    let candidate = versions.iter().find(|v| v.label == candidate_label);
14302    let current = if current_label.is_empty() {
14303        None
14304    } else {
14305        versions.iter().find(|v| v.label == current_label)
14306    };
14307    let mut out = String::new();
14308    out.push_str(&format!("env:        {env_name}\n"));
14309    out.push_str(&format!(
14310        "current:    {}{}\n",
14311        if current_label.is_empty() {
14312            "(none deployed)".to_string()
14313        } else {
14314            current_label.to_string()
14315        },
14316        match current.and_then(|v| v.created) {
14317            Some(t) => format!("  ({})", humanize(Some(t))),
14318            None => String::new(),
14319        }
14320    ));
14321    out.push_str(&format!("candidate:  {candidate_label}"));
14322    match candidate {
14323        Some(v) => {
14324            out.push_str(&format!("  ({})\n", humanize(v.created)));
14325            if !v.description.is_empty() {
14326                out.push_str(&format!("description: {}\n", v.description));
14327            }
14328        }
14329        None => {
14330            out.push_str("\n\n");
14331            out.push_str(&format!(
14332                "⚠ candidate label '{candidate_label}' not found in this app's version list — \
14333                 deploy will fail. Run :versions to see available labels.\n"
14334            ));
14335            return out;
14336        }
14337    }
14338    // Rollback warning — only fires when both timestamps are known and
14339    // the candidate is older than current. Rolling back IS legitimate;
14340    // the warning just gives the operator a beat to confirm intent.
14341    if let (Some(cand), Some(curr)) = (
14342        candidate.and_then(|v| v.created),
14343        current.and_then(|v| v.created),
14344    ) {
14345        if cand < curr {
14346            let secs = curr.signed_duration_since(cand).num_seconds().max(0) as u32;
14347            let diff = if secs < 3600 {
14348                format!("{}m", secs / 60)
14349            } else if secs < 86_400 {
14350                format!("{}h", secs / 3600)
14351            } else {
14352                format!("{}d", secs / 86_400)
14353            };
14354            out.push('\n');
14355            out.push_str(&format!(
14356                "⚠ candidate is {diff} older than the currently-deployed version — \
14357                 looks like a rollback. Confirm intent.\n"
14358            ));
14359        }
14360    }
14361    out.push_str("\nrun :deploy without --preview to dispatch, or :versions for the full list.\n");
14362    out
14363}
14364
14365pub fn format_app_versions(
14366    versions: &[crate::aws::AppVersion],
14367    deployed_label: Option<&str>,
14368    limit: usize,
14369) -> String {
14370    let mut out = String::new();
14371    let total = versions.len();
14372    let shown = total.min(limit);
14373    if total > limit {
14374        out.push_str(&format!(
14375            "showing {shown} of {total} (newest first; deploy older with `:deploy LABEL`)\n\n",
14376        ));
14377    }
14378    for v in versions.iter().take(limit) {
14379        // Drop the standard EB CI-pipeline prefix. The rest (usually a
14380        // pipeline URL) still distinguishes versions but consumes much less
14381        // horizontal width.
14382        let desc = v
14383            .description
14384            .strip_prefix("Application version created from ")
14385            .unwrap_or(&v.description);
14386        let marker = if deployed_label == Some(v.label.as_str()) {
14387            "▶ "
14388        } else {
14389            "  "
14390        };
14391        let suffix = if deployed_label == Some(v.label.as_str()) {
14392            "  ◀ deployed"
14393        } else {
14394            ""
14395        };
14396        if desc.is_empty() {
14397            out.push_str(&format!("{marker}{}{}\n", v.label, suffix));
14398        } else {
14399            out.push_str(&format!("{marker}{}  {desc}{}\n", v.label, suffix));
14400        }
14401    }
14402    out.push('\n');
14403    out.push_str("Use `:deploy <label>` to ship one to the selected env.");
14404    out
14405}
14406
14407/// Map a friendly env-metric "kind" to a `(metric_name, default_op, default_stat)`
14408/// triple. The user can override the operator on the CLI but the defaults
14409/// reflect "what you'd reasonably alarm on for this metric" — e.g. drop in
14410/// health (LE) vs spike in 5xx (GT). Pure so the unit tests don't need
14411/// AWS.
14412pub fn alarm_kind_to_metric(kind: &str) -> Option<(&'static str, &'static str, &'static str)> {
14413    match kind {
14414        "health" => Some(("EnvironmentHealth", "LessThanOrEqualToThreshold", "Maximum")),
14415        "4xx" | "req4xx" => Some(("ApplicationRequests4xx", "GreaterThanThreshold", "Sum")),
14416        "5xx" | "req5xx" => Some(("ApplicationRequests5xx", "GreaterThanThreshold", "Sum")),
14417        "latency" | "p90" => Some(("ApplicationLatencyP90", "GreaterThanThreshold", "Average")),
14418        _ => None,
14419    }
14420}
14421
14422/// Render a sorted `(namespace, option_name, value)` list as an aligned
14423/// text block grouped by namespace. Empty values render as `""` so the
14424/// reader can distinguish "explicitly empty" from "not present".
14425pub fn format_template_settings(settings: &[(String, String, String)]) -> String {
14426    if settings.is_empty() {
14427        return "(no option settings)".into();
14428    }
14429    let key_width = settings
14430        .iter()
14431        .map(|(_, name, _)| name.chars().count())
14432        .max()
14433        .unwrap_or(0)
14434        .clamp(16, 40);
14435    let mut out = String::new();
14436    let mut prev_ns: Option<&str> = None;
14437    for (ns, name, value) in settings {
14438        if Some(ns.as_str()) != prev_ns {
14439            if prev_ns.is_some() {
14440                out.push('\n');
14441            }
14442            out.push_str(&format!("[{ns}]\n"));
14443            prev_ns = Some(ns.as_str());
14444        }
14445        let rendered = if value.is_empty() {
14446            "\"\"".to_string()
14447        } else {
14448            value.clone()
14449        };
14450        out.push_str(&format!("  {name:<key_width$} = {rendered}\n"));
14451    }
14452    out
14453}
14454
14455/// Flatten the per-application configuration_templates lists into a single
14456/// `(application, template)` vector, sorted by app then by template name so
14457/// the overlay's cursor order is stable across refreshes. Pure so the unit
14458/// tests don't need an AWS client.
14459pub fn collect_saved_configs(apps: &[Application]) -> Vec<(String, String)> {
14460    let mut out: Vec<(String, String)> = apps
14461        .iter()
14462        .flat_map(|a| a.templates.iter().map(|t| (a.name.clone(), t.clone())))
14463        .collect();
14464    out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
14465    out
14466}
14467
14468fn format_saved_configs(apps: &[Application]) -> String {
14469    if apps.is_empty() {
14470        return "no applications loaded — wait for first refresh or :region NAME".into();
14471    }
14472    let mut out = String::new();
14473    out.push_str("EB saved configurations (templates per application)\n");
14474    out.push_str("──────────────────────────────────────────────────\n\n");
14475    let mut any = false;
14476    for a in apps {
14477        if a.templates.is_empty() {
14478            continue;
14479        }
14480        any = true;
14481        out.push_str(&format!("Application: {}\n", a.name));
14482        for t in &a.templates {
14483            out.push_str(&format!("  ▸ {t}\n"));
14484        }
14485        out.push('\n');
14486    }
14487    if !any {
14488        out.push_str("no saved configuration templates in any application\n");
14489    }
14490    out
14491}
14492
14493fn diff_envs(left: &Environment, right: &Environment, redact_on: bool) -> String {
14494    let cn = |s: &str| {
14495        if redact_on {
14496            redact_block(s)
14497        } else {
14498            s.to_string()
14499        }
14500    };
14501    let updated = |e: &Environment| {
14502        e.updated
14503            .map(|u| u.to_rfc3339())
14504            .unwrap_or_else(|| "—".into())
14505    };
14506    let rows: Vec<(&str, String, String)> = vec![
14507        ("Name", left.name.clone(), right.name.clone()),
14508        (
14509            "Application",
14510            left.application.clone(),
14511            right.application.clone(),
14512        ),
14513        ("Tier", left.tier.clone(), right.tier.clone()),
14514        ("Status", left.status.clone(), right.status.clone()),
14515        ("Health", left.health.clone(), right.health.clone()),
14516        ("Platform", left.platform.clone(), right.platform.clone()),
14517        (
14518            "Version",
14519            left.version_label.clone(),
14520            right.version_label.clone(),
14521        ),
14522        ("CNAME", cn(&left.cname), cn(&right.cname)),
14523        ("Updated", updated(left), updated(right)),
14524    ];
14525
14526    // Width-aware truncation so long values don't blow out the popup.
14527    let width: usize = 28;
14528    let truncate = |s: &str| -> String {
14529        if s.chars().count() > width {
14530            let mut t: String = s.chars().take(width.saturating_sub(1)).collect();
14531            t.push('…');
14532            t
14533        } else {
14534            s.to_string()
14535        }
14536    };
14537
14538    let left_label = truncate(&format!("◄ {}", left.name));
14539    let right_label = truncate(&format!("{} ►", right.name));
14540    let mut out = String::new();
14541    out.push_str(&format!(
14542        "{:<14}    {:<width$}    {}\n",
14543        "", left_label, right_label,
14544    ));
14545    out.push_str(&"─".repeat(14 + 4 + width + 4 + width));
14546    out.push('\n');
14547    for (field, l, r) in rows {
14548        let differs = l != r;
14549        let marker = if differs { "≠" } else { " " };
14550        out.push_str(&format!(
14551            "{marker} {:<12}  {:<width$}    {}\n",
14552            field,
14553            truncate(&l),
14554            truncate(&r),
14555        ));
14556    }
14557    out
14558}
14559
14560/// Render the `:ssm-run` overlay — one section per instance with
14561/// status / exit-code header, then stdout, then stderr if present.
14562/// Long buffers are line-truncated at 50 lines per stream (operator
14563/// can rerun with `tail -n 200 logfile` or similar if they need
14564/// more); per-line truncation at 200 chars keeps the overlay legible
14565/// when a single line is huge. Empty rows produce a stub.
14566pub(crate) fn format_ssm_results(command: &str, rows: &[crate::aws::SsmRunResult]) -> String {
14567    if rows.is_empty() {
14568        return format!("ssm-run — `{command}`\n\nNo instances targeted.\n\nesc / q to close");
14569    }
14570    const MAX_LINES_PER_STREAM: usize = 50;
14571    const MAX_LINE_CHARS: usize = 200;
14572    let truncate_line = |line: &str| -> String {
14573        if line.chars().count() <= MAX_LINE_CHARS {
14574            line.to_string()
14575        } else {
14576            let mut out: String = line.chars().take(MAX_LINE_CHARS - 1).collect();
14577            out.push('…');
14578            out
14579        }
14580    };
14581    let truncate_block = |block: &str| -> String {
14582        let lines: Vec<&str> = block.lines().collect();
14583        if lines.len() <= MAX_LINES_PER_STREAM {
14584            return lines
14585                .iter()
14586                .map(|l| truncate_line(l))
14587                .collect::<Vec<_>>()
14588                .join("\n");
14589        }
14590        let head = lines
14591            .iter()
14592            .take(MAX_LINES_PER_STREAM)
14593            .map(|l| truncate_line(l))
14594            .collect::<Vec<_>>()
14595            .join("\n");
14596        format!(
14597            "{head}\n… ({} more lines truncated)",
14598            lines.len() - MAX_LINES_PER_STREAM
14599        )
14600    };
14601    let mut body = format!(
14602        "ssm-run — `{command}`\n\
14603         {} instance(s)\n\n",
14604        rows.len()
14605    );
14606    for r in rows {
14607        body.push_str(&format!(
14608            "─── {} [{}, exit={}] ───\n",
14609            r.instance_id, r.status, r.exit_code
14610        ));
14611        if r.stdout.is_empty() && r.stderr.is_empty() {
14612            body.push_str("  (no output)\n");
14613        }
14614        if !r.stdout.is_empty() {
14615            body.push_str("stdout:\n");
14616            body.push_str(&truncate_block(&r.stdout));
14617            body.push('\n');
14618        }
14619        if !r.stderr.is_empty() {
14620            body.push_str("stderr:\n");
14621            body.push_str(&truncate_block(&r.stderr));
14622            body.push('\n');
14623        }
14624        body.push('\n');
14625    }
14626    body.push_str("esc / q to close");
14627    body
14628}
14629
14630/// Render the `:alarm-history` overlay — one row per history entry,
14631/// newest first, with timestamp + kind + summary. Kind is the API's
14632/// HistoryItemType (StateUpdate / ConfigurationUpdate / Action) and is
14633/// shown verbatim so an operator scanning the timeline can spot e.g.
14634/// `ConfigurationUpdate` entries that explain a state change. Empty
14635/// result yields a stub body so the operator isn't left wondering
14636/// whether the fetch silently failed.
14637pub(crate) fn format_alarm_history(
14638    alarm_name: &str,
14639    entries: &[crate::aws::AlarmHistoryEntry],
14640) -> String {
14641    if entries.is_empty() {
14642        return format!(
14643            "Alarm history — {alarm_name}\n\n\
14644             No history items in the recent window.\n\
14645             (CloudWatch retains alarm history for 90 days.)\n\n\
14646             esc / q to close"
14647        );
14648    }
14649    let mut body = format!(
14650        "Alarm history — {alarm_name}\n\
14651         {} entries, newest first.\n\n",
14652        entries.len()
14653    );
14654    for e in entries {
14655        let ts =
14656            e.at.map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string())
14657                .unwrap_or_else(|| "—".into());
14658        body.push_str(&format!("{ts}  [{}]\n    {}\n\n", e.kind, e.summary));
14659    }
14660    body.push_str("esc / q to close");
14661    body
14662}
14663
14664fn format_alarms(result: Result<Vec<CwAlarm>, String>) -> String {
14665    match result {
14666        Err(e) => format!("error fetching alarms: {e}"),
14667        Ok(alarms) if alarms.is_empty() => "no CloudWatch alarms reference this env".into(),
14668        Ok(alarms) => {
14669            let mut out = String::new();
14670            out.push_str(&format!("CloudWatch alarms ({})\n", alarms.len()));
14671            out.push_str("──────────────────────────────────────────\n\n");
14672            for a in alarms {
14673                out.push_str(&format!(
14674                    "{:<10} {} ({}/{})\n",
14675                    a.state, a.name, a.namespace, a.metric_name,
14676                ));
14677                if !a.state_reason.is_empty() {
14678                    // Pre-wrap the reason at a conservative column width
14679                    // with a hanging indent so continuation lines stay
14680                    // aligned. Avoids ratatui's auto-wrap dropping to
14681                    // column 0 which looks broken.
14682                    let lead = "           ↳ ";
14683                    let cont = "             ";
14684                    out.push_str(&wrap_with_hanging_indent(&a.state_reason, 100, lead, cont));
14685                    out.push('\n');
14686                }
14687                out.push('\n');
14688            }
14689            out
14690        }
14691    }
14692}
14693
14694/// Wrap `text` at `width` columns, prefixing the first line with `lead` and
14695/// subsequent lines with `cont` so continuation visually flows under the
14696/// leader (e.g. `"↳ "` followed by aligned continuation). Greedy
14697/// word-wrap; falls back to hard-break inside a word that won't fit on its
14698/// own line. Pure for testability.
14699pub fn wrap_with_hanging_indent(text: &str, width: usize, lead: &str, cont: &str) -> String {
14700    if text.is_empty() {
14701        return lead.to_string();
14702    }
14703    let body_width = width.saturating_sub(lead.chars().count()).max(1);
14704    let mut out = String::new();
14705    let mut first = true;
14706    let mut current = String::new();
14707    let prefix = |first: bool| if first { lead } else { cont };
14708    for word in text.split_whitespace() {
14709        // If a single word is longer than the body width, hard-break it.
14710        if word.chars().count() > body_width {
14711            if !current.is_empty() {
14712                out.push_str(prefix(first));
14713                out.push_str(&current);
14714                out.push('\n');
14715                first = false;
14716                current.clear();
14717            }
14718            let mut chars = word.chars();
14719            loop {
14720                let chunk: String = (&mut chars).take(body_width).collect();
14721                if chunk.is_empty() {
14722                    break;
14723                }
14724                out.push_str(prefix(first));
14725                out.push_str(&chunk);
14726                out.push('\n');
14727                first = false;
14728            }
14729            continue;
14730        }
14731        let candidate_len = if current.is_empty() {
14732            word.chars().count()
14733        } else {
14734            current.chars().count() + 1 + word.chars().count()
14735        };
14736        if candidate_len > body_width {
14737            out.push_str(prefix(first));
14738            out.push_str(&current);
14739            out.push('\n');
14740            first = false;
14741            current.clear();
14742        }
14743        if !current.is_empty() {
14744            current.push(' ');
14745        }
14746        current.push_str(word);
14747    }
14748    if !current.is_empty() {
14749        out.push_str(prefix(first));
14750        out.push_str(&current);
14751        out.push('\n');
14752    }
14753    out.pop(); // remove trailing newline (caller adds its own)
14754    out
14755}
14756
14757fn encode_view(app: &App) -> String {
14758    let mut parts: Vec<String> = Vec::new();
14759    if !app.filter.is_empty() {
14760        parts.push(format!("filter={}", app.filter));
14761    }
14762    parts.push(format!(
14763        "sort={}:{}",
14764        app.sort_key.label(),
14765        if app.sort_desc { "desc" } else { "asc" }
14766    ));
14767    parts.push(format!("grouped={}", app.grouped));
14768    let scope = match app.scope {
14769        Scope::Envs => "envs",
14770        Scope::Apps => "apps",
14771    };
14772    parts.push(format!("scope={scope}"));
14773    parts.join(";")
14774}
14775
14776/// Encode a filter-only saved view — the value `:save NAME` writes
14777/// to `saved_views`. Omits `sort=`, `grouped=`, `scope=` so loading
14778/// the view doesn't perturb the operator's current sort / group /
14779/// scope state. `apply_view` ignores missing fields, so a
14780/// filter-only view is a safe no-touch-other-state operation.
14781///
14782/// Used by the legacy `:save` command (filter-only save) and by
14783/// the state.toml backward-compat path that promotes old
14784/// `filter.NAME = "..."` lines into saved_views.
14785pub fn encode_filter_only_view(filter: &str) -> String {
14786    format!("filter={filter}")
14787}
14788
14789/// Pure: extract the filter portion of an encoded saved view.
14790/// Returns the empty string when the view doesn't include a
14791/// `filter=` part (which means "no filter" — operator wanted the
14792/// view to clear whatever filter was set). Used by the chip-bar
14793/// active-check + the cycle keybind.
14794pub fn view_filter_value(encoded: &str) -> &str {
14795    for part in encoded.split(';') {
14796        if let Some(rest) = part.trim().strip_prefix("filter=") {
14797            return rest;
14798        }
14799    }
14800    ""
14801}
14802
14803fn apply_view(app: &mut App, snap: &str) {
14804    let mut new_filter = String::new();
14805    for part in snap.split(';') {
14806        let Some((k, v)) = part.split_once('=') else {
14807            continue;
14808        };
14809        match k.trim() {
14810            "filter" => new_filter = v.trim().to_string(),
14811            "sort" => {
14812                let (key, desc) = parse_sort(Some(v.trim()));
14813                app.sort_key = key;
14814                app.sort_desc = desc;
14815            }
14816            "grouped" => app.grouped = v.trim().eq_ignore_ascii_case("true"),
14817            "scope" => {
14818                app.scope = match v.trim() {
14819                    "apps" => Scope::Apps,
14820                    _ => Scope::Envs,
14821                };
14822            }
14823            _ => {}
14824        }
14825    }
14826    app.filter = new_filter;
14827    app.resort_envs(); // also rebuilds the view.
14828}
14829
14830/// Best-effort hourly USD price for an EC2 instance type, on-demand Linux,
14831/// us-east-1 as the baseline. Returned in USD/hour. Returns None for unknown
14832/// types — caller should label the estimate as "approximate (us-east-1)".
14833pub fn instance_hourly_usd(instance_type: &str) -> Option<f64> {
14834    // Hand-curated subset covering the families EB typically runs.
14835    // Prices are public list (on-demand Linux, us-east-1) as a baseline.
14836    match instance_type {
14837        // T-family burstable
14838        "t2.nano" => Some(0.0058),
14839        "t2.micro" => Some(0.0116),
14840        "t2.small" => Some(0.023),
14841        "t2.medium" => Some(0.0464),
14842        "t2.large" => Some(0.0928),
14843        "t3.nano" => Some(0.0052),
14844        "t3.micro" => Some(0.0104),
14845        "t3.small" => Some(0.0208),
14846        "t3.medium" => Some(0.0416),
14847        "t3.large" => Some(0.0832),
14848        "t3.xlarge" => Some(0.1664),
14849        "t3.2xlarge" => Some(0.3328),
14850        "t3a.nano" => Some(0.0047),
14851        "t3a.micro" => Some(0.0094),
14852        "t3a.small" => Some(0.0188),
14853        "t3a.medium" => Some(0.0376),
14854        "t3a.large" => Some(0.0752),
14855        "t4g.nano" => Some(0.0042),
14856        "t4g.micro" => Some(0.0084),
14857        "t4g.small" => Some(0.0168),
14858        "t4g.medium" => Some(0.0336),
14859        "t4g.large" => Some(0.0672),
14860        // General purpose
14861        "m5.large" => Some(0.096),
14862        "m5.xlarge" => Some(0.192),
14863        "m5.2xlarge" => Some(0.384),
14864        "m5.4xlarge" => Some(0.768),
14865        "m6i.large" => Some(0.096),
14866        "m6i.xlarge" => Some(0.192),
14867        "m6i.2xlarge" => Some(0.384),
14868        "m6g.large" => Some(0.077),
14869        "m6g.xlarge" => Some(0.154),
14870        // Compute optimized
14871        "c5.large" => Some(0.085),
14872        "c5.xlarge" => Some(0.17),
14873        "c5.2xlarge" => Some(0.34),
14874        "c6i.large" => Some(0.085),
14875        "c6i.xlarge" => Some(0.17),
14876        // Memory optimized
14877        "r5.large" => Some(0.126),
14878        "r5.xlarge" => Some(0.252),
14879        "r6i.large" => Some(0.126),
14880        _ => None,
14881    }
14882}
14883
14884/// Sum of hourly prices for a list of instance types, with a "missing" count
14885/// of instances whose type wasn't in the table.
14886pub fn estimate_cost(instances: &[Instance]) -> (f64, usize) {
14887    let mut total = 0.0;
14888    let mut missing = 0;
14889    for i in instances {
14890        match instance_hourly_usd(&i.instance_type) {
14891            Some(p) => total += p,
14892            None => missing += 1,
14893        }
14894    }
14895    (total, missing)
14896}
14897
14898fn build_describe_cli(env_name: &str, region: &str, profile: Option<&str>) -> String {
14899    let env_q = shell_quote(env_name);
14900    let mut out = format!(
14901        "aws elasticbeanstalk describe-environments --environment-names {env_q} --region {region}"
14902    );
14903    if let Some(p) = profile {
14904        out.push_str(&format!(" --profile {}", shell_quote(p)));
14905    }
14906    out
14907}
14908
14909fn shell_quote(s: &str) -> String {
14910    if s.chars()
14911        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
14912    {
14913        s.to_string()
14914    } else {
14915        // POSIX-safe single-quote: replace ' with '\'' and wrap.
14916        let escaped = s.replace('\'', "'\\''");
14917        format!("'{escaped}'")
14918    }
14919}
14920
14921fn md_escape(s: &str) -> String {
14922    // Escape '|' (table separator) and backslash. Other Markdown specials are
14923    // safe inside a table cell.
14924    s.replace('\\', "\\\\").replace('|', "\\|")
14925}
14926
14927fn write_audit_entry(
14928    account: Option<&str>,
14929    profile: Option<&str>,
14930    region: &str,
14931    action: Action,
14932    env: &str,
14933    swap_with: Option<&str>,
14934) {
14935    let target = match swap_with {
14936        Some(other) => format!("{env} ↔ {other}"),
14937        None => env.to_string(),
14938    };
14939    let detail = format!("stage=dispatched action={action:?} target={target}");
14940    write_audit_line(account, profile, region, &detail);
14941}
14942
14943/// Log the outcome of a dispatched action. Called once the SDK response lands
14944/// so that the audit trail reflects what AWS actually did, not just what we
14945/// asked it to do.
14946fn write_audit_outcome(
14947    account: Option<&str>,
14948    profile: Option<&str>,
14949    region: &str,
14950    action: Action,
14951    env: &str,
14952    result: Result<(), &str>,
14953) {
14954    // Outcome is always emitted as a `key=value` pair so the parser
14955    // in `src/audit.rs` can promote it into the typed `outcome` /
14956    // `err` field. Pre-0.14 entries used a bare `ok` trailing the
14957    // detail string; the parser tolerates those as outcome=None but
14958    // can't surface the success state cleanly. New writes use the
14959    // explicit shape. Error strings go through `audit::escape_value`
14960    // so a multi-line AWS error doesn't corrupt the next entry's
14961    // line (parser reads line-by-line).
14962    let outcome = match result {
14963        Ok(()) => "outcome=ok".to_string(),
14964        Err(e) => format!("outcome=err err=\"{}\"", crate::audit::escape_value(e)),
14965    };
14966    let detail = format!("stage=completed action={action:?} target={env} {outcome}");
14967    write_audit_line(account, profile, region, &detail);
14968}
14969
14970/// Soft cap on `audit.log` size before we rotate to `audit.log.1` (single
14971/// historical backup, older history is discarded). 1 MiB ≈ ~5k action entries,
14972/// plenty for an interactive operator tool.
14973const AUDIT_LOG_MAX_BYTES: u64 = 1 << 20;
14974
14975fn write_audit_line(account: Option<&str>, profile: Option<&str>, region: &str, detail: &str) {
14976    let dir = crate::util::cache_dir();
14977    if std::fs::create_dir_all(&dir).is_err() {
14978        return;
14979    }
14980    let path = dir.join("audit.log");
14981    rotate_if_oversize(&path, AUDIT_LOG_MAX_BYTES);
14982    let when = chrono::Utc::now().to_rfc3339();
14983    let line = format!(
14984        "{when}\taccount={}\tprofile={}\tregion={}\t{detail}\n",
14985        account.unwrap_or("-"),
14986        profile.unwrap_or("-"),
14987        region,
14988    );
14989    use std::io::Write;
14990    if let Ok(mut f) = std::fs::OpenOptions::new()
14991        .create(true)
14992        .append(true)
14993        .open(&path)
14994    {
14995        let _ = f.write_all(line.as_bytes());
14996    }
14997    // Fan-out to the configured webhook, if any. Sync-callable
14998    // (just spawns a detached tokio task) so call sites don't
14999    // have to change. Failures are logged via tracing and never
15000    // alarm the operator — the local audit file is the source of
15001    // truth; the webhook is a convenience fan-out.
15002    if let Some(url) = NOTIFY_WEBHOOK_URL.get().and_then(|o| o.as_deref()) {
15003        fire_audit_webhook(url, account, profile, region, detail, &when);
15004    }
15005}
15006
15007/// Process-wide webhook URL. Set once at App startup from the
15008/// resolved Config; read from `write_audit_line` (which is sync
15009/// and called from many places, so threading it through every
15010/// call site would be invasive churn). The `Option` layer lets
15011/// the operator explicitly disable via `notify_webhook = ""` or
15012/// by omitting the key entirely.
15013pub(crate) static NOTIFY_WEBHOOK_URL: std::sync::OnceLock<Option<String>> =
15014    std::sync::OnceLock::new();
15015
15016/// Build the JSON body that goes to `notify_webhook`. Pure +
15017/// deterministic so the shape is unit-testable. Top-level `text`
15018/// gets the rendered audit line so the body is
15019/// Slack-incoming-webhook-compatible out of the box; the other
15020/// keys give consumers structured fields for routing / filtering.
15021pub(crate) fn build_audit_webhook_body(
15022    account: Option<&str>,
15023    profile: Option<&str>,
15024    region: &str,
15025    detail: &str,
15026    when: &str,
15027) -> String {
15028    let text = format!(
15029        "[ebman] {} account={} profile={} region={} {}",
15030        when,
15031        account.unwrap_or("-"),
15032        profile.unwrap_or("-"),
15033        region,
15034        detail,
15035    );
15036    format!(
15037        "{{\"text\":\"{}\",\"at\":\"{}\",\"account\":\"{}\",\"profile\":\"{}\",\"region\":\"{}\",\"detail\":\"{}\"}}",
15038        json_escape(&text),
15039        json_escape(when),
15040        json_escape(account.unwrap_or("")),
15041        json_escape(profile.unwrap_or("")),
15042        json_escape(region),
15043        json_escape(detail),
15044    )
15045}
15046
15047/// Fire-and-forget webhook POST. Shells out to curl so we don't
15048/// pull in an HTTP-client dep (same pattern as `fetch_url_text`).
15049/// 10s cap so a slow webhook can't accumulate hung curls. The
15050/// caller must be inside a tokio runtime; every audit-line site
15051/// in ebman is, so this is fine in practice.
15052fn fire_audit_webhook(
15053    url: &str,
15054    account: Option<&str>,
15055    profile: Option<&str>,
15056    region: &str,
15057    detail: &str,
15058    when: &str,
15059) {
15060    let body = build_audit_webhook_body(account, profile, region, detail, when);
15061    let url = url.to_string();
15062    // `tokio::spawn` requires an active runtime; ebman's audit
15063    // sites all live under the `#[tokio::main]` umbrella. If we
15064    // somehow end up off-runtime (test code, etc.), the spawn
15065    // panics — wrap in a `Handle::try_current()` guard so we
15066    // silently skip rather than crash.
15067    if tokio::runtime::Handle::try_current().is_err() {
15068        return;
15069    }
15070    tokio::spawn(async move {
15071        use tokio::process::Command;
15072        let result = Command::new("curl")
15073            .args([
15074                "-s",
15075                "-S",
15076                "-X",
15077                "POST",
15078                "-H",
15079                "Content-Type: application/json",
15080                "--max-time",
15081                "10",
15082                "--data-binary",
15083                "@-",
15084            ])
15085            .arg(&url)
15086            .stdin(std::process::Stdio::piped())
15087            .stdout(std::process::Stdio::null())
15088            .stderr(std::process::Stdio::piped())
15089            .spawn();
15090        let Ok(mut child) = result else {
15091            tracing::warn!(
15092                target: "ebman::notify",
15093                url = %url,
15094                "audit webhook: could not spawn curl"
15095            );
15096            return;
15097        };
15098        if let Some(mut stdin) = child.stdin.take() {
15099            use tokio::io::AsyncWriteExt;
15100            let _ = stdin.write_all(body.as_bytes()).await;
15101            let _ = stdin.shutdown().await;
15102        }
15103        match child.wait_with_output().await {
15104            Ok(out) if out.status.success() => {}
15105            Ok(out) => {
15106                tracing::warn!(
15107                    target: "ebman::notify",
15108                    url = %url,
15109                    status = ?out.status.code(),
15110                    stderr = %String::from_utf8_lossy(&out.stderr).trim(),
15111                    "audit webhook returned non-zero"
15112                );
15113            }
15114            Err(e) => {
15115                tracing::warn!(
15116                    target: "ebman::notify",
15117                    url = %url,
15118                    error = %e,
15119                    "audit webhook curl exited with error"
15120                );
15121            }
15122        }
15123    });
15124}
15125
15126/// If `path` exists and is larger than `max_bytes`, move it to `path.1`
15127/// (overwriting any previous backup) so the next write starts a fresh file.
15128/// Best-effort: any I/O error is swallowed — we don't want to lose the audit
15129/// entry just because rotation failed.
15130fn rotate_if_oversize(path: &std::path::Path, max_bytes: u64) {
15131    let Ok(meta) = std::fs::metadata(path) else {
15132        return;
15133    };
15134    if meta.len() <= max_bytes {
15135        return;
15136    }
15137    let backup = {
15138        let mut name = path
15139            .file_name()
15140            .map(|s| s.to_os_string())
15141            .unwrap_or_default();
15142        name.push(".1");
15143        path.with_file_name(name)
15144    };
15145    let _ = std::fs::rename(path, backup);
15146}
15147
15148/// One action (or batch of actions) queued for dispatch with a brief
15149/// cancel window. After the operator authorises a confirm (Y on a
15150/// YesNo modal, typed name on a TypeName modal) or runs a
15151/// `:batch-*` command, ebman doesn't fire the AWS call immediately —
15152/// it holds the dispatch here, shows a countdown in the header, and
15153/// fires only when [`UNDO_WINDOW`] elapses. `U` in Normal mode
15154/// aborts before the deadline.
15155///
15156/// One pending dispatch at a time. The `kind` carries the work
15157/// shape; the deadline + display labels are shared.
15158#[derive(Clone)]
15159pub struct PendingDispatch {
15160    pub deadline: Instant,
15161    /// Label rendered in the header pill — `"Rebuild env"` or
15162    /// `"Batch rebuild × 5"`. Captured at queue time so the
15163    /// rendering doesn't have to walk the kind on every frame.
15164    pub label: String,
15165    /// Display target. For singles it's the env name; for batches
15166    /// it's the count summary (`"5 envs"`) so the pill stays compact.
15167    pub target: String,
15168    pub kind: PendingDispatchKind,
15169}
15170
15171/// The actual work `tick_pending_dispatch` dispatches when the
15172/// cancel window elapses. Mirrors the existing dispatch paths:
15173/// `Single` re-uses [`App::spawn_action`]; the batch variants
15174/// re-use the per-env `spawn_batch_*` helpers in a loop.
15175// See the matching allow on `ActionFlow` — same trade-off.
15176#[allow(clippy::large_enum_variant)]
15177#[derive(Clone)]
15178pub enum PendingDispatchKind {
15179    /// A single Y/TypeName-confirm dispatch — preserves the full
15180    /// `ConfirmModal` because `spawn_action` reads params off it
15181    /// (deploy version, swap target, scale min/max, etc.).
15182    Single { modal: ConfirmModal },
15183    /// `:batch-rebuild` / `:batch-restart` — one [`Action`] applied
15184    /// to every env in the captured set.
15185    BatchAction {
15186        action: Action,
15187        env_names: Vec<String>,
15188    },
15189    /// `:batch-deploy LABEL` — same version label fanned out.
15190    BatchDeploy {
15191        env_names: Vec<String>,
15192        version_label: String,
15193    },
15194    /// `:batch-tag KEY VALUE` (`value = Some`) / `:batch-untag KEY`
15195    /// (`value = None`). ARN per env captured at queue time so a
15196    /// mid-window refresh that drops an env's ARN can't break the
15197    /// fan-out.
15198    BatchTag {
15199        envs_with_arns: Vec<(String, String)>,
15200        key: String,
15201        value: Option<String>,
15202    },
15203    /// `:batch-set-option NAMESPACE NAME VALUE`.
15204    BatchSetOption {
15205        env_names: Vec<String>,
15206        namespace: String,
15207        option_name: String,
15208        value: String,
15209    },
15210}
15211
15212/// Cancel window after a confirm — long enough that an "oops" reflex
15213/// can recover but short enough that operators don't notice it on a
15214/// deliberate action. The UX review flagged the absence of any
15215/// abort affordance after dispatch as a real safety gap.
15216pub const UNDO_WINDOW: Duration = Duration::from_secs(5);
15217
15218/// Items the Apps-scope action overlay (`Overlay::AppsActionMenu`)
15219/// offers when the operator presses `a` from the Apps table. Each
15220/// dispatches via `cmd_batch_*` after seeding `multi_selected` with the
15221/// envs captured at menu-open time.
15222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15223pub enum AppsActionItem {
15224    Drill,
15225    BatchRebuild,
15226    BatchRestart,
15227    BatchDeploy,
15228    OpenInConsole,
15229}
15230
15231impl AppsActionItem {
15232    pub fn label(self) -> &'static str {
15233        match self {
15234            Self::Drill => "Drill into envs",
15235            Self::BatchRebuild => "Rebuild all envs in app",
15236            Self::BatchRestart => "Restart all envs in app",
15237            Self::BatchDeploy => "Deploy version label to all envs",
15238            Self::OpenInConsole => "Open application in AWS console",
15239        }
15240    }
15241}
15242
15243/// Menu order — Drill at the top because it's the default action
15244/// operators reach for; OpenInConsole at the bottom so it's not the
15245/// thumb-stroke option.
15246pub const APPS_ACTION_ITEMS: &[AppsActionItem] = &[
15247    AppsActionItem::Drill,
15248    AppsActionItem::BatchRebuild,
15249    AppsActionItem::BatchRestart,
15250    AppsActionItem::BatchDeploy,
15251    AppsActionItem::OpenInConsole,
15252];
15253
15254/// Rollup of operational signals across every env in an application.
15255/// Pure — driven entirely by the in-memory env list, so the Apps
15256/// table can refresh as part of the same view-rebuild that touches
15257/// the Envs table.
15258#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15259pub struct AppRollup {
15260    pub env_count: usize,
15261    pub red_count: usize,
15262    pub updating_count: usize,
15263    pub worker_dlq_alerts: usize,
15264}
15265
15266/// Compute the rollup for one application. Iterates `envs` once and
15267/// counts Red / Updating envs (case-insensitive on the health + status
15268/// columns). Worker-DLQ alerts come from `dlq_depths` which the App
15269/// owns globally — passed in so this stays a free fn that test code
15270/// can call without a full `App`.
15271pub fn app_rollup(
15272    envs: &[crate::aws::Environment],
15273    app_name: &str,
15274    dlq_depths: &HashMap<String, i64>,
15275) -> AppRollup {
15276    let mut out = AppRollup::default();
15277    for e in envs.iter().filter(|e| e.application == app_name) {
15278        out.env_count += 1;
15279        // Red / Severe — operator-visible distress signals.
15280        if matches!(
15281            e.health.to_lowercase().as_str(),
15282            "red" | "severe" | "degraded"
15283        ) {
15284            out.red_count += 1;
15285        }
15286        if e.status.eq_ignore_ascii_case("Updating")
15287            || e.status.eq_ignore_ascii_case("Launching")
15288            || e.status.eq_ignore_ascii_case("Terminating")
15289        {
15290            out.updating_count += 1;
15291        }
15292        if e.tier.eq_ignore_ascii_case("Worker")
15293            && dlq_depths.get(&e.name).copied().unwrap_or(0) > 0
15294        {
15295            out.worker_dlq_alerts += 1;
15296        }
15297    }
15298    out
15299}
15300
15301/// Pure: format the temp-file body the `:env-edit` flow opens
15302/// in `$EDITOR`. Header comment explains the contract (lines look
15303/// like `KEY=VALUE`; `#` comments and blank lines are ignored;
15304/// save+quit applies; quit-without-save / unchanged-body cancels).
15305/// Existing env vars are sorted alphabetically so the operator
15306/// gets a stable target for diffs across runs.
15307pub(crate) fn build_env_edit_body(env_name: &str, vars: &[(String, String)]) -> String {
15308    let mut out = String::new();
15309    out.push_str(&format!("# ebman env-var editor — {env_name}\n"));
15310    out.push_str("#\n");
15311    out.push_str("# Lines that look like KEY=VALUE are interpreted as env vars.\n");
15312    out.push_str("# Lines starting with # are comments.\n");
15313    out.push_str("# Blank lines are ignored.\n");
15314    out.push_str("#\n");
15315    out.push_str("# Save and quit to apply changes. Saving an unchanged file is a clean\n");
15316    out.push_str("# no-op. To reference a Secrets Manager value, store the ARN here\n");
15317    out.push_str("# (e.g. `DB_PASSWORD_SECRET_ARN=arn:aws:secretsmanager:...`) and have\n");
15318    out.push_str("# your app's bootstrap call GetSecretValue at runtime — EB does not\n");
15319    out.push_str("# resolve secretsmanager:// references natively.\n\n");
15320    let mut sorted: Vec<&(String, String)> = vars.iter().collect();
15321    sorted.sort_by(|a, b| a.0.cmp(&b.0));
15322    for (k, v) in sorted {
15323        out.push_str(&format!("{k}={v}\n"));
15324    }
15325    out
15326}
15327
15328/// Pure: parse the operator's edited `:env-edit` body back into a
15329/// `KEY -> VALUE` map. Splits each non-comment line on the *first*
15330/// `=` so values containing `=` (common for query-string-style
15331/// settings or base64-encoded secrets) pass through intact.
15332/// Keys that fail to validate (empty after trim, contain whitespace)
15333/// are dropped — EB's option-settings API would reject them anyway,
15334/// and the operator gets the diff feedback after save.
15335pub(crate) fn parse_env_edit_body(text: &str) -> std::collections::BTreeMap<String, String> {
15336    let mut out = std::collections::BTreeMap::new();
15337    for raw in text.lines() {
15338        let trimmed = raw.trim_start();
15339        if trimmed.is_empty() || trimmed.starts_with('#') {
15340            continue;
15341        }
15342        let Some((key, value)) = trimmed.split_once('=') else {
15343            continue;
15344        };
15345        let key = key.trim();
15346        if key.is_empty() || key.chars().any(char::is_whitespace) {
15347            continue;
15348        }
15349        // Trailing whitespace + a single optional carriage return
15350        // (Windows line endings) get stripped from the value, but
15351        // intentional internal whitespace is preserved.
15352        let value = value.trim_end_matches('\r').trim_end_matches('\n');
15353        out.insert(key.to_string(), value.to_string());
15354    }
15355    out
15356}
15357
15358/// Pure: produce `(to_set, to_remove)` deltas from two env-var
15359/// snapshots. `to_set` carries the EB option-settings triple
15360/// `(namespace, name, value)`; `to_remove` carries `(namespace,
15361/// name)`. Caller-supplied namespace because the same shape is
15362/// reusable beyond `aws:elasticbeanstalk:application:environment`
15363/// (e.g. future `:options-edit` could feed any namespace).
15364/// `(namespace, key, value)` triples — the shape EB's option-settings
15365/// update API expects for "set these". Aliased so [`diff_env_vars`]'s
15366/// signature isn't tripping the complex-type clippy lint.
15367pub(crate) type OptionSet = Vec<(String, String, String)>;
15368/// `(namespace, key)` pairs — "remove these" shape.
15369pub(crate) type OptionRemove = Vec<(String, String)>;
15370
15371pub(crate) fn diff_env_vars(
15372    namespace: &str,
15373    original: &std::collections::BTreeMap<String, String>,
15374    edited: &std::collections::BTreeMap<String, String>,
15375) -> (OptionSet, OptionRemove) {
15376    let mut to_set: OptionSet = Vec::new();
15377    let mut to_remove: OptionRemove = Vec::new();
15378    // Set or update: any key present in `edited` whose value
15379    // differs from the original (or was missing entirely).
15380    for (k, v) in edited {
15381        match original.get(k) {
15382            Some(prev) if prev == v => continue,
15383            _ => to_set.push((namespace.to_string(), k.clone(), v.clone())),
15384        }
15385    }
15386    // Remove: keys present in `original` but absent from `edited`.
15387    for k in original.keys() {
15388        if !edited.contains_key(k) {
15389            to_remove.push((namespace.to_string(), k.clone()));
15390        }
15391    }
15392    (to_set, to_remove)
15393}
15394
15395/// Pure: parse an AWS `AccessDenied` error message into
15396/// `(principal_arn, action)`. Returns `None` when the message
15397/// doesn't match a recognised shape.
15398///
15399/// Recognised shapes:
15400///   - `User: arn:aws:sts::ACCOUNT:assumed-role/ROLE/SESSION is
15401///     not authorized to perform: SERVICE:ACTION ...`
15402///   - `User: arn:aws:iam::ACCOUNT:{user,role}/NAME is not
15403///     authorized to perform: SERVICE:ACTION ...`
15404///
15405/// Assumed-role ARNs are rewritten to the underlying role ARN
15406/// (`arn:aws:iam::ACCOUNT:role/ROLE`) because that's what
15407/// `iam:SimulatePrincipalPolicy` wants as the policy source —
15408/// the session credentials themselves aren't a policy attachment
15409/// point.
15410pub(crate) fn parse_access_denied(msg: &str) -> Option<(String, String)> {
15411    let user_prefix = "User: ";
15412    let action_prefix = "is not authorized to perform:";
15413    let user_start = msg.find(user_prefix)? + user_prefix.len();
15414    let user_end = msg[user_start..]
15415        .find(|c: char| c.is_whitespace())
15416        .map(|i| user_start + i)?;
15417    let principal_raw = &msg[user_start..user_end];
15418    let action_start = msg.find(action_prefix)? + action_prefix.len();
15419    let action_rest = msg[action_start..].trim_start();
15420    let action_end = action_rest
15421        .find(|c: char| c.is_whitespace() || c == ',')
15422        .unwrap_or(action_rest.len());
15423    let action = action_rest[..action_end].to_string();
15424    let principal = if let Some(rest) = principal_raw.strip_prefix("arn:aws:sts::") {
15425        // `arn:aws:sts::ACCOUNT:assumed-role/ROLE/SESSION`
15426        let parts: Vec<&str> = rest.splitn(2, ':').collect();
15427        let account = parts.first()?;
15428        let role_part = parts.get(1)?;
15429        let role_name = role_part.strip_prefix("assumed-role/")?.split('/').next()?;
15430        format!("arn:aws:iam::{account}:role/{role_name}")
15431    } else {
15432        principal_raw.to_string()
15433    };
15434    Some((principal, action))
15435}
15436
15437/// Pure: render the result of an IAM `SimulatePrincipalPolicy`
15438/// call. One section per evaluated action, with the decision +
15439/// matched statements + SCP / boundary blockers + a concrete
15440/// suggestion of what policy statement to add when the decision
15441/// was implicitDeny.
15442pub(crate) fn render_explain_overlay(principal: &str, rows: &[crate::aws::IamSimResult]) -> String {
15443    let mut out = String::new();
15444    out.push_str(&format!("IAM diagnosis for {principal}\n"));
15445    out.push_str("═══════════════════════════════════════════════════\n\n");
15446    if rows.is_empty() {
15447        out.push_str("(no evaluation results returned)\n\nesc / q to close");
15448        return out;
15449    }
15450    for (idx, r) in rows.iter().enumerate() {
15451        if idx > 0 {
15452            out.push('\n');
15453        }
15454        out.push_str(&format!("Action:   {}\n", r.action));
15455        if !r.resource.is_empty() {
15456            out.push_str(&format!("Resource: {}\n", r.resource));
15457        }
15458        let (mark, label) = match r.decision.as_str() {
15459            "allowed" => ("✓", "allowed"),
15460            "explicitDeny" => ("✗", "explicitDeny — a policy *denies* this action"),
15461            "implicitDeny" => ("✗", "implicitDeny — no policy allows this action"),
15462            other => ("?", other),
15463        };
15464        out.push_str(&format!("Decision: {mark} {label}\n"));
15465        if r.blocked_by_scp {
15466            out.push_str("          ⚠ also blocked by an Organizations SCP at the org level\n");
15467        }
15468        if r.blocked_by_boundary {
15469            out.push_str("          ⚠ also blocked by the role's permission boundary\n");
15470        }
15471        if !r.matched_statements.is_empty() {
15472            out.push_str("Matched statements:\n");
15473            for s in &r.matched_statements {
15474                out.push_str(&format!("  ▸ {s}\n"));
15475            }
15476        }
15477        if !r.missing_context.is_empty() {
15478            out.push_str("Missing context keys (conditions unsatisfied):\n");
15479            for c in &r.missing_context {
15480                out.push_str(&format!("  ▸ {c}\n"));
15481            }
15482        }
15483        if r.decision == "implicitDeny" {
15484            out.push_str(&format!(
15485                "\nTo allow, add this statement to one of the role's policies:\n\
15486                 \n\
15487                 {{\n\
15488                 \x20\x20\"Effect\": \"Allow\",\n\
15489                 \x20\x20\"Action\": \"{}\",\n\
15490                 \x20\x20\"Resource\": \"*\"\n\
15491                 }}\n",
15492                r.action
15493            ));
15494        } else if r.decision == "explicitDeny" {
15495            out.push_str(
15496                "\nAn explicit Deny in the matched statement(s) above is\n\
15497                 overriding any Allow. Remove or scope down the Deny to\n\
15498                 unblock — explicit Deny always wins.\n",
15499            );
15500        }
15501    }
15502    out.push_str("\nesc / q to close");
15503    out
15504}
15505
15506/// Pure: render the env's underlying AWS resources as a tree.
15507/// Replaces the previous flat-section dump. The hierarchy mirrors
15508/// the conceptual graph an operator builds in their head:
15509///
15510///   env  (Tier)
15511///   ├─ ASGs
15512///   │  └─ <asg-name>
15513///   │     ├─ <instance-id>
15514///   │     └─ <instance-id>
15515///   ├─ Launch template / config
15516///   ├─ Load balancers
15517///   ├─ Triggers
15518///   └─ Queues  (Worker only)
15519///      ├─ WorkerQueue
15520///      │     https://sqs.../...
15521///      └─ WorkerDeadLetterQueue
15522///            https://sqs.../...
15523///
15524/// Instances are nested under ASGs because EB envs typically have
15525/// one ASG that owns every instance. The first ASG in the list
15526/// carries the instance children; if the env has zero ASGs but
15527/// non-zero instances (rare; mid-launch maybe), those instances
15528/// surface as a separate "orphan" section.
15529pub(crate) fn render_env_resources_tree(
15530    res: &crate::aws::EnvResources,
15531    env_name: &str,
15532    tier: &str,
15533) -> String {
15534    let mut out = String::new();
15535    out.push_str(&format!("Resources for {env_name}  ({tier})\n"));
15536    out.push_str("═══════════════════════════════════════\n\n");
15537
15538    // Collect non-empty sections first. The last kept section
15539    // uses `└─`; the rest `├─`. Easier to track once we know how
15540    // many sections survive than to count inline.
15541    let mut sections: Vec<(String, Vec<String>)> = Vec::new();
15542
15543    if !res.asgs.is_empty() {
15544        let mut lines: Vec<String> = Vec::new();
15545        let n_asgs = res.asgs.len();
15546        for (asg_idx, asg) in res.asgs.iter().enumerate() {
15547            let last_asg = asg_idx + 1 == n_asgs;
15548            let asg_prefix = if last_asg { "└─" } else { "├─" };
15549            lines.push(format!("  {asg_prefix} {asg}"));
15550            // Only the first ASG carries the instance children
15551            // (typical case: one ASG per env).
15552            if asg_idx == 0 && !res.instances.is_empty() {
15553                let n_inst = res.instances.len();
15554                let cont = if last_asg { "  " } else { "│ " };
15555                for (i, id) in res.instances.iter().enumerate() {
15556                    let last_inst = i + 1 == n_inst;
15557                    let glyph = if last_inst { "└─" } else { "├─" };
15558                    lines.push(format!("  {cont}   {glyph} {id}"));
15559                }
15560            }
15561        }
15562        sections.push((format!("Auto-scaling groups ({})", res.asgs.len()), lines));
15563    } else if !res.instances.is_empty() {
15564        let mut lines: Vec<String> = Vec::new();
15565        let n = res.instances.len();
15566        for (i, id) in res.instances.iter().enumerate() {
15567            let last = i + 1 == n;
15568            let glyph = if last { "└─" } else { "├─" };
15569            lines.push(format!("  {glyph} {id}"));
15570        }
15571        sections.push((format!("Instances ({n}) — orphan (no ASG attached)"), lines));
15572    }
15573
15574    if !res.launch_templates.is_empty() {
15575        let mut lines: Vec<String> = Vec::new();
15576        let n = res.launch_templates.len();
15577        for (i, t) in res.launch_templates.iter().enumerate() {
15578            let glyph = if i + 1 == n { "└─" } else { "├─" };
15579            lines.push(format!("  {glyph} {t}"));
15580        }
15581        sections.push((format!("Launch templates ({n})"), lines));
15582    }
15583    if !res.launch_configs.is_empty() {
15584        let mut lines: Vec<String> = Vec::new();
15585        let n = res.launch_configs.len();
15586        for (i, lc) in res.launch_configs.iter().enumerate() {
15587            let glyph = if i + 1 == n { "└─" } else { "├─" };
15588            lines.push(format!("  {glyph} {lc}"));
15589        }
15590        sections.push((format!("Launch configurations ({n})"), lines));
15591    }
15592    if !res.load_balancers.is_empty() {
15593        let mut lines: Vec<String> = Vec::new();
15594        let n = res.load_balancers.len();
15595        for (i, lb) in res.load_balancers.iter().enumerate() {
15596            let glyph = if i + 1 == n { "└─" } else { "├─" };
15597            lines.push(format!("  {glyph} {lb}"));
15598        }
15599        sections.push((format!("Load balancers ({n})"), lines));
15600    }
15601    if !res.triggers.is_empty() {
15602        let mut lines: Vec<String> = Vec::new();
15603        let n = res.triggers.len();
15604        for (i, t) in res.triggers.iter().enumerate() {
15605            let glyph = if i + 1 == n { "└─" } else { "├─" };
15606            lines.push(format!("  {glyph} {t}"));
15607        }
15608        sections.push((format!("Triggers ({n})"), lines));
15609    }
15610    if !res.queues.is_empty() {
15611        let mut lines: Vec<String> = Vec::new();
15612        let n = res.queues.len();
15613        for (i, q) in res.queues.iter().enumerate() {
15614            let last = i + 1 == n;
15615            let glyph = if last { "└─" } else { "├─" };
15616            lines.push(format!("  {glyph} {}", q.name));
15617            if !q.url.is_empty() {
15618                let url_prefix = if last { "       " } else { "  │    " };
15619                lines.push(format!("{url_prefix}{}", q.url));
15620            }
15621        }
15622        sections.push((format!("Queues ({n})"), lines));
15623    }
15624
15625    if sections.is_empty() {
15626        out.push_str("  (no resources reported — env may still be launching)\n");
15627    } else {
15628        let n_sections = sections.len();
15629        for (idx, (label, lines)) in sections.iter().enumerate() {
15630            let last_section = idx + 1 == n_sections;
15631            let section_glyph = if last_section { "└─" } else { "├─" };
15632            out.push_str(&format!("{section_glyph} {label}\n"));
15633            let prefix = if last_section { "  " } else { "│ " };
15634            for line in lines {
15635                out.push_str(&format!("{prefix}{line}\n"));
15636            }
15637            if !last_section {
15638                out.push_str("│\n");
15639            }
15640        }
15641    }
15642
15643    out.push_str("\nesc / q to close");
15644    out
15645}
15646
15647/// Pure: edit (Levenshtein) distance between two strings, counting
15648/// single-character insertions / deletions / substitutions. Used by
15649/// the unknown-command `did-you-mean` path; small enough that
15650/// pulling in the `strsim` crate would be over-spec.
15651///
15652/// Implemented as the standard O(m·n) DP table with byte-level
15653/// iteration. ASCII-only paths get exact answers; multi-byte
15654/// UTF-8 still terminates but the distance is counted in bytes,
15655/// not graphemes. Acceptable for the command-name use case
15656/// (every built-in is ASCII).
15657pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
15658    let a_bytes = a.as_bytes();
15659    let b_bytes = b.as_bytes();
15660    if a_bytes.is_empty() {
15661        return b_bytes.len();
15662    }
15663    if b_bytes.is_empty() {
15664        return a_bytes.len();
15665    }
15666    // Two-row rolling DP: only the previous row's distances are
15667    // needed to compute the current row. Saves O(m·n) memory →
15668    // O(min(m,n)) without changing the answer.
15669    let (short, long) = if a_bytes.len() < b_bytes.len() {
15670        (a_bytes, b_bytes)
15671    } else {
15672        (b_bytes, a_bytes)
15673    };
15674    let mut prev: Vec<usize> = (0..=short.len()).collect();
15675    let mut curr: Vec<usize> = vec![0; short.len() + 1];
15676    for (i, lc) in long.iter().enumerate() {
15677        curr[0] = i + 1;
15678        for (j, sc) in short.iter().enumerate() {
15679            let cost = if lc == sc { 0 } else { 1 };
15680            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
15681        }
15682        std::mem::swap(&mut prev, &mut curr);
15683    }
15684    prev[short.len()]
15685}
15686
15687/// Suggest the closest registry name to `input` within an
15688/// edit-distance threshold. Returns `None` when no candidate is
15689/// close enough — a wild guess would mislead rather than help.
15690///
15691/// Threshold is length-dependent: short inputs (`:q`, `:r`) get
15692/// distance ≤ 1; longer ones tolerate up to 2 typos. The
15693/// length-aware threshold prevents a 2-char miss like `:xy`
15694/// from "matching" every 3-char name in the registry.
15695pub(crate) fn suggest_command(input: &str) -> Option<String> {
15696    let threshold = if input.len() <= 3 { 1 } else { 2 };
15697    let mut best: Option<(usize, String)> = None;
15698    for name in crate::commands::all_names() {
15699        let d = edit_distance(input, name);
15700        if d <= threshold && best.as_ref().is_none_or(|(bd, _)| d < *bd) {
15701            best = Some((d, name.to_string()));
15702        }
15703    }
15704    best.map(|(_, name)| name)
15705}
15706
15707/// Pure: return the built-in command names + aliases that begin
15708/// with `prefix`. Sorted alphabetically. Empty prefix returns every
15709/// name (still alpha-sorted). De-duplicated so a command's
15710/// canonical name and any aliases don't both surface for the same
15711/// dispatch arm — first occurrence wins.
15712///
15713/// Used by command-mode Tab cycling. Plugins (`commands.toml`) are
15714/// not included here because plugins are operator-specific and
15715/// can change without a registry update; future enhancement could
15716/// merge them in but the registry-driven first cut keeps the
15717/// behaviour predictable.
15718pub(crate) fn completion_candidates(prefix: &str) -> Vec<String> {
15719    let mut names: Vec<String> = crate::commands::all_names()
15720        .into_iter()
15721        .filter(|n| n.starts_with(prefix))
15722        .map(String::from)
15723        .collect();
15724    names.sort();
15725    names.dedup();
15726    names
15727}
15728
15729/// Pure render of the `:options` overlay body. Groups `rows` by
15730/// namespace; within each group, operator-set rows come first
15731/// (marked `▸`), defaults follow (marked `•`). Optional
15732/// `filter_ns` restricts to one namespace.
15733///
15734/// Format per row:
15735///   `<marker> NAME[<padding>]  = VALUE       (default: X, type: T, ...)`
15736///
15737/// The metadata trailer (`default:`, `type:`, `severity:`, ranges,
15738/// value_options) only renders when the field is set — keeps the
15739/// line lean. Long value-option lists get truncated to "first 5 +
15740/// …" to avoid one option blowing past the popup width.
15741///
15742/// Top of the body carries a one-line legend so the operator
15743/// doesn't have to learn the marker convention from `?`.
15744/// One option-setting that differs between two envs.
15745#[derive(Debug, Clone, PartialEq, Eq)]
15746pub struct ConfigDiff {
15747    pub namespace: String,
15748    pub name: String,
15749    pub left: Option<String>,
15750    pub right: Option<String>,
15751}
15752
15753/// Pure: option-settings that differ between two envs. Compares the
15754/// operator-set `value` per `(namespace, name)`; rows where both
15755/// sides agree — including both unset — are dropped. EB's
15756/// `Some("")` and `None` both mean "unset", so they're normalised
15757/// to equal. Result is sorted `(namespace, name)` for a stable
15758/// overlay.
15759pub fn diff_config_options(
15760    left: &[crate::aws::ConfigOption],
15761    right: &[crate::aws::ConfigOption],
15762) -> Vec<ConfigDiff> {
15763    use std::collections::{BTreeMap, BTreeSet};
15764    let norm = |v: &Option<String>| v.clone().filter(|s| !s.is_empty());
15765    let to_map = |opts: &[crate::aws::ConfigOption]| -> BTreeMap<(String, String), Option<String>> {
15766        opts.iter()
15767            .map(|o| ((o.namespace.clone(), o.name.clone()), norm(&o.value)))
15768            .collect()
15769    };
15770    let lmap = to_map(left);
15771    let rmap = to_map(right);
15772    let mut keys: BTreeSet<(String, String)> = lmap.keys().cloned().collect();
15773    keys.extend(rmap.keys().cloned());
15774    keys.into_iter()
15775        .filter_map(|k| {
15776            let l = lmap.get(&k).cloned().flatten();
15777            let r = rmap.get(&k).cloned().flatten();
15778            if l == r {
15779                None
15780            } else {
15781                Some(ConfigDiff {
15782                    namespace: k.0,
15783                    name: k.1,
15784                    left: l,
15785                    right: r,
15786                })
15787            }
15788        })
15789        .collect()
15790}
15791
15792/// Render the `:config-diff` overlay — the option-settings that
15793/// differ between `left_env` and `right_env`, grouped by namespace.
15794pub(crate) fn render_config_diff_overlay(
15795    left_env: &str,
15796    right_env: &str,
15797    diffs: &[ConfigDiff],
15798) -> String {
15799    if diffs.is_empty() {
15800        return format!(
15801            "Config diff — {left_env}  ↔  {right_env}\n\n\
15802             ✓ identical: every operator-set option-setting matches.\n\n\
15803             esc / q to close"
15804        );
15805    }
15806    let mut body = format!(
15807        "Config diff — {left_env}  ↔  {right_env}\n\
15808         {n} option-setting(s) differ.  L = {left_env}   R = {right_env}\n\
15809         (unset = at the platform default)\n\n",
15810        n = diffs.len()
15811    );
15812    let mut current_ns: Option<&str> = None;
15813    let show = |v: &Option<String>| v.clone().unwrap_or_else(|| "(unset)".into());
15814    for d in diffs {
15815        if Some(d.namespace.as_str()) != current_ns {
15816            if current_ns.is_some() {
15817                body.push('\n');
15818            }
15819            body.push_str(&format!("── {} ──\n", d.namespace));
15820            current_ns = Some(d.namespace.as_str());
15821        }
15822        body.push_str(&format!(
15823            "  {}\n      L: {}\n      R: {}\n",
15824            d.name,
15825            show(&d.left),
15826            show(&d.right),
15827        ));
15828    }
15829    body.push_str("\nesc / q to close");
15830    body
15831}
15832
15833pub(crate) fn render_options_overlay(
15834    rows: &[crate::aws::ConfigOption],
15835    filter_ns: Option<&str>,
15836    env_name: &str,
15837) -> String {
15838    let filtered: Vec<&crate::aws::ConfigOption> = rows
15839        .iter()
15840        .filter(|r| filter_ns.is_none_or(|ns| r.namespace == ns))
15841        .collect();
15842    if filtered.is_empty() {
15843        return match filter_ns {
15844            Some(ns) => format!(
15845                "No options found for namespace '{ns}' on env '{env_name}'.\n\n\
15846                 Spelling? Try `:options` (no arg) to see the full list of\n\
15847                 namespaces available for this env's platform.\n\n\
15848                 esc / q to close"
15849            ),
15850            None => format!(
15851                "No configuration options returned for env '{env_name}'.\n\n\
15852                 This usually means the env's platform doesn't expose an option\n\
15853                 vocabulary (custom platform or stale solution-stack). Try\n\
15854                 `:set-option` directly if you know what you want to change.\n\n\
15855                 esc / q to close"
15856            ),
15857        };
15858    }
15859    // Compute the longest name within each namespace so the `= value`
15860    // columns line up per group. Walking once first; second pass renders.
15861    let mut max_name_per_ns: std::collections::HashMap<&str, usize> =
15862        std::collections::HashMap::new();
15863    for r in &filtered {
15864        let e = max_name_per_ns.entry(r.namespace.as_str()).or_insert(0);
15865        *e = (*e).max(r.name.chars().count()).min(38);
15866    }
15867
15868    let user_set = filtered.iter().filter(|r| r.value.is_some()).count();
15869    let mut body = String::new();
15870    body.push_str(&format!(
15871        "Configuration vocabulary for {env_name}\n\
15872         {user_set}/{total} options are operator-set; the rest are at default.\n\n\
15873         ▸ = operator-set    • = default    severity warns when changing rolls instances\n\n",
15874        total = filtered.len()
15875    ));
15876
15877    let mut current_ns: Option<&str> = None;
15878    for r in &filtered {
15879        if Some(r.namespace.as_str()) != current_ns {
15880            if current_ns.is_some() {
15881                body.push('\n');
15882            }
15883            body.push_str(&format!("── {} ──\n", r.namespace));
15884            current_ns = Some(r.namespace.as_str());
15885        }
15886        let marker = if r.value.is_some() { "▸" } else { "•" };
15887        let name_width = max_name_per_ns
15888            .get(r.namespace.as_str())
15889            .copied()
15890            .unwrap_or(20);
15891        let name_padded = if r.name.chars().count() < name_width {
15892            format!("{name:<width$}", name = r.name, width = name_width)
15893        } else {
15894            r.name.clone()
15895        };
15896        let value_str = match &r.value {
15897            Some(v) => format!(" = {v}"),
15898            None => String::new(),
15899        };
15900        // Trailing metadata — only emit what's set so short-form
15901        // rows stay short.
15902        let mut meta: Vec<String> = Vec::new();
15903        if let Some(d) = &r.default_value {
15904            if !d.is_empty() {
15905                meta.push(format!("default: {d}"));
15906            }
15907        }
15908        if !r.value_type.is_empty() && r.value_type != "Scalar" {
15909            // Scalar is the default; only call out non-scalars
15910            // (`List`) which surprise the operator.
15911            meta.push(format!("type: {}", r.value_type));
15912        }
15913        if let Some(s) = &r.change_severity {
15914            if s != "NoInterruption" && s != "Unknown" {
15915                meta.push(format!("severity: {s}"));
15916            }
15917        }
15918        match (r.min_value, r.max_value) {
15919            (Some(min), Some(max)) => meta.push(format!("range: {min}-{max}")),
15920            (Some(min), None) => meta.push(format!("min: {min}")),
15921            (None, Some(max)) => meta.push(format!("max: {max}")),
15922            (None, None) => {}
15923        }
15924        if let Some(maxlen) = r.max_length {
15925            meta.push(format!("max_len: {maxlen}"));
15926        }
15927        if !r.value_options.is_empty() {
15928            let preview: Vec<&str> = r.value_options.iter().take(5).map(String::as_str).collect();
15929            let more = r.value_options.len().saturating_sub(5);
15930            let suffix = if more > 0 {
15931                format!(", … +{more}")
15932            } else {
15933                String::new()
15934            };
15935            meta.push(format!("oneof: {}{suffix}", preview.join(", ")));
15936        }
15937        let meta_str = if meta.is_empty() {
15938            String::new()
15939        } else {
15940            format!("  ({})", meta.join(", "))
15941        };
15942        body.push_str(&format!("  {marker} {name_padded}{value_str}{meta_str}\n"));
15943    }
15944    body.push_str(
15945        "\n`:set-option NAMESPACE NAME VALUE` to change a setting.\n\
15946         `:options NAMESPACE` to filter to one family.\n\
15947         esc / q to close",
15948    );
15949    body
15950}
15951
15952/// Render the `:secrets` overlay — metadata only, never values.
15953/// Pure (takes the SDK rows + filter, returns the body string) so
15954/// the table layout / empty-state copy can be unit-tested without
15955/// hitting Secrets Manager.
15956pub(crate) fn render_secrets_overlay(
15957    rows: &[crate::aws::SecretSummary],
15958    filter: Option<&str>,
15959) -> String {
15960    if rows.is_empty() {
15961        return match filter {
15962            Some(f) => format!(
15963                "No secrets matching '{f}'.\n\n\
15964                 `:secrets` (no arg) to see everything in this region.\n\
15965                 Secrets Manager is region-scoped — switch with `:region` first if needed.\n\n\
15966                 esc / q to close"
15967            ),
15968            None => "No Secrets Manager secrets in this region.\n\n\
15969                 Either none have been created, or the caller is missing\n\
15970                 `secretsmanager:ListSecrets`. Try `:explain :secrets` to check.\n\n\
15971                 esc / q to close"
15972                .to_string(),
15973        };
15974    }
15975    let now = chrono::Utc::now();
15976    let mut body = String::new();
15977    body.push_str(&match filter {
15978        Some(f) => format!(
15979            "Secrets Manager — {n} matching '{f}'\n\
15980             Sorted by last-changed (newest first). Values not shown — use `:secret NAME`.\n\n",
15981            n = rows.len()
15982        ),
15983        None => format!(
15984            "Secrets Manager — {n} secrets\n\
15985             Sorted by last-changed (newest first). Values not shown — use `:secret NAME`.\n\n",
15986            n = rows.len()
15987        ),
15988    });
15989    for r in rows {
15990        body.push_str(&format!("▸ {}\n", r.name));
15991        if !r.arn.is_empty() {
15992            body.push_str(&format!("    arn: {}\n", r.arn));
15993        }
15994        if let Some(d) = &r.description {
15995            body.push_str(&format!("    desc: {d}\n"));
15996        }
15997        let changed = r.last_changed.map(|t| format_age(now, t));
15998        let rotated = r.last_rotated.map(|t| format_age(now, t));
15999        match (changed, rotated) {
16000            (Some(c), Some(r)) => {
16001                body.push_str(&format!("    changed: {c}    rotated: {r}\n"));
16002            }
16003            (Some(c), None) => {
16004                body.push_str(&format!("    changed: {c}    rotated: never\n"));
16005            }
16006            (None, Some(r)) => {
16007                body.push_str(&format!("    rotated: {r}\n"));
16008            }
16009            (None, None) => {}
16010        }
16011        if let Some(k) = &r.kms_key_id {
16012            body.push_str(&format!("    kms: {k}\n"));
16013        }
16014        body.push('\n');
16015    }
16016    body.push_str(
16017        "y to yank an ARN (select first) · `:secret NAME` to read the value\n\
16018         esc / q to close",
16019    );
16020    body
16021}
16022
16023/// Render the `:secret NAME` overlay — the single-secret detail view.
16024/// Honours `redact` mode by replacing the value with a length + sha
16025/// hint, so an operator on a screen-share can confirm "yes I have
16026/// the right secret" without exposing it. JSON-shaped values are
16027/// pretty-printed for readability (Secrets Manager's common k/v
16028/// idiom is `{"USERNAME":"…","PASSWORD":"…"}`).
16029pub(crate) fn render_secret_value_overlay(name: &str, value: &str, redact: bool) -> String {
16030    let mut body = String::new();
16031    body.push_str(&format!("Secret — {name}\n\n"));
16032    if redact {
16033        body.push_str(&format!(
16034            "value: <redacted; {} chars, fingerprint {}>\n\
16035             Run `:redact off` then re-fetch if you need the cleartext.\n\n\
16036             esc / q to close",
16037            value.chars().count(),
16038            short_fingerprint(value),
16039        ));
16040        return body;
16041    }
16042    // Try to pretty-print JSON so k/v secrets are scannable.
16043    let pretty = try_pretty_json(value);
16044    body.push_str("value:\n");
16045    body.push_str(&pretty);
16046    if !pretty.ends_with('\n') {
16047        body.push('\n');
16048    }
16049    body.push_str("\ny to yank the value · esc / q to close");
16050    body
16051}
16052
16053/// FNV-1a 32-bit fingerprint of the value, hex-encoded — short,
16054/// dependency-free, good enough to confirm "same secret as before"
16055/// without leaking the value itself. NOT a cryptographic hash and
16056/// not used for security decisions; only for the redact-mode
16057/// "is this the right one" eyeball check.
16058fn short_fingerprint(s: &str) -> String {
16059    let mut h: u32 = 0x811C_9DC5;
16060    for b in s.as_bytes() {
16061        h ^= *b as u32;
16062        h = h.wrapping_mul(0x0100_0193);
16063    }
16064    format!("{h:08x}")
16065}
16066
16067/// If the value parses as JSON, return a pretty-printed form;
16068/// otherwise return the raw string. Uses a very minimal recursive
16069/// parser instead of pulling in `serde_json` for one render path.
16070fn try_pretty_json(s: &str) -> String {
16071    let trimmed = s.trim();
16072    if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
16073        return s.to_string();
16074    }
16075    // Minimal pass: walk chars, indenting on { [ and dedenting on } ].
16076    // Quoted strings are preserved verbatim. This handles the
16077    // Secrets-Manager k/v idiom without taking a hard JSON dep.
16078    let mut out = String::with_capacity(s.len() + 32);
16079    let mut depth: usize = 0;
16080    let mut in_str = false;
16081    let mut escape = false;
16082    let mut chars = trimmed.chars().peekable();
16083    while let Some(c) = chars.next() {
16084        if in_str {
16085            out.push(c);
16086            if escape {
16087                escape = false;
16088            } else if c == '\\' {
16089                escape = true;
16090            } else if c == '"' {
16091                in_str = false;
16092            }
16093            continue;
16094        }
16095        match c {
16096            '"' => {
16097                in_str = true;
16098                out.push(c);
16099            }
16100            '{' | '[' => {
16101                out.push(c);
16102                // Empty container — emit `{}` / `[]` inline. Consume
16103                // the closing bracket here so the `}`/`]` arm (which
16104                // would add its own newline + indent) never sees it.
16105                if matches!(chars.peek(), Some('}') | Some(']')) {
16106                    if let Some(close) = chars.next() {
16107                        out.push(close);
16108                    }
16109                    continue;
16110                }
16111                depth += 1;
16112                out.push('\n');
16113                out.push_str(&"  ".repeat(depth));
16114            }
16115            '}' | ']' => {
16116                depth = depth.saturating_sub(1);
16117                out.push('\n');
16118                out.push_str(&"  ".repeat(depth));
16119                out.push(c);
16120            }
16121            ',' => {
16122                out.push(c);
16123                out.push('\n');
16124                out.push_str(&"  ".repeat(depth));
16125            }
16126            ':' => {
16127                out.push(c);
16128                out.push(' ');
16129            }
16130            ' ' | '\n' | '\t' | '\r' => {} // collapse whitespace outside strings
16131            _ => out.push(c),
16132        }
16133    }
16134    out
16135}
16136
16137/// Format an "age" against now. Pure; keeps the secrets renderer
16138/// from depending on ui.rs's private `humanize_age`.
16139fn format_age(now: chrono::DateTime<chrono::Utc>, t: chrono::DateTime<chrono::Utc>) -> String {
16140    let d = now.signed_duration_since(t);
16141    let secs = d.num_seconds().max(0);
16142    if secs < 60 {
16143        return format!("{secs}s ago");
16144    }
16145    let mins = secs / 60;
16146    if mins < 60 {
16147        return format!("{mins}m ago");
16148    }
16149    let hrs = mins / 60;
16150    if hrs < 48 {
16151        return format!("{hrs}h ago");
16152    }
16153    let days = hrs / 24;
16154    if days < 60 {
16155        return format!("{days}d ago");
16156    }
16157    let months = days / 30;
16158    if months < 24 {
16159        return format!("~{months}mo ago");
16160    }
16161    format!("~{}y ago", days / 365)
16162}
16163
16164fn console_url(region: &str, app_name: &str, env_name: &str) -> String {
16165    let app = urlencode(app_name);
16166    let env = urlencode(env_name);
16167    format!(
16168        "https://{region}.console.aws.amazon.com/elasticbeanstalk/home?region={region}#/environment/dashboard?applicationName={app}&environmentName={env}"
16169    )
16170}
16171
16172fn urlencode(s: &str) -> String {
16173    // Minimal URL-encode of the characters that appear in EB app / env names.
16174    // EB names are restricted to a–z A–Z 0–9 - _ so most input passes through;
16175    // we still encode space and any non-ASCII for safety.
16176    let mut out = String::with_capacity(s.len());
16177    for c in s.chars() {
16178        if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16179            out.push(c);
16180        } else {
16181            for b in c.to_string().bytes() {
16182                out.push_str(&format!("%{b:02X}"));
16183            }
16184        }
16185    }
16186    out
16187}
16188
16189fn open_url(url: &str) -> std::result::Result<(), String> {
16190    #[cfg(target_os = "macos")]
16191    let cmd = "open";
16192    #[cfg(all(unix, not(target_os = "macos")))]
16193    let cmd = "xdg-open";
16194    #[cfg(target_os = "windows")]
16195    let cmd = "explorer";
16196
16197    #[cfg(not(any(unix, target_os = "windows")))]
16198    {
16199        let _ = url;
16200        return Err("don't know how to open a URL on this platform".into());
16201    }
16202    #[cfg(any(unix, target_os = "windows"))]
16203    {
16204        std::process::Command::new(cmd)
16205            .arg(url)
16206            .stdout(std::process::Stdio::null())
16207            .stderr(std::process::Stdio::null())
16208            .spawn()
16209            .map(|_| ())
16210            .map_err(|e| e.to_string())
16211    }
16212}
16213
16214fn describe_env(e: &Environment) -> String {
16215    let updated = e
16216        .updated
16217        .map(|u| u.to_rfc3339())
16218        .unwrap_or_else(|| "null".into());
16219    format!(
16220        "{{\n  \"name\":            \"{}\",\n  \"application\":     \"{}\",\n  \"tier\":            \"{}\",\n  \"status\":          \"{}\",\n  \"health\":          \"{}\",\n  \"platform\":        \"{}\",\n  \"version_label\":   \"{}\",\n  \"cname\":           \"{}\",\n  \"updated\":         {}\n}}",
16221        json_escape(&e.name),
16222        json_escape(&e.application),
16223        json_escape(&e.tier),
16224        json_escape(&e.status),
16225        json_escape(&e.health),
16226        json_escape(&e.platform),
16227        json_escape(&e.version_label),
16228        json_escape(&e.cname),
16229        if updated == "null" { updated } else { format!("\"{updated}\"") },
16230    )
16231}
16232
16233fn json_escape(s: &str) -> String {
16234    let mut out = String::with_capacity(s.len());
16235    for c in s.chars() {
16236        match c {
16237            '"' => out.push_str("\\\""),
16238            '\\' => out.push_str("\\\\"),
16239            '\n' => out.push_str("\\n"),
16240            '\r' => out.push_str("\\r"),
16241            '\t' => out.push_str("\\t"),
16242            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
16243            c => out.push(c),
16244        }
16245    }
16246    out
16247}
16248
16249fn redact_block(value: &str) -> String {
16250    if value.is_empty() {
16251        return value.to_string();
16252    }
16253    "▓".repeat(value.chars().count())
16254}
16255
16256#[cfg(test)]
16257mod tests {
16258    use super::*;
16259
16260    #[test]
16261    fn loading_linger_target_none_when_no_load() {
16262        let now = Instant::now();
16263        assert!(compute_loading_linger_target(
16264            None,
16265            Duration::from_millis(300),
16266            Duration::from_millis(500),
16267            now,
16268        )
16269        .is_none());
16270    }
16271
16272    #[test]
16273    fn loading_linger_target_none_when_under_threshold() {
16274        let now = Instant::now();
16275        // Load started 100 ms ago — threshold (300 ms) not crossed.
16276        let started = now - Duration::from_millis(100);
16277        assert!(compute_loading_linger_target(
16278            Some(started),
16279            Duration::from_millis(300),
16280            Duration::from_millis(500),
16281            now,
16282        )
16283        .is_none());
16284    }
16285
16286    #[test]
16287    fn loading_linger_target_arms_past_threshold() {
16288        let now = Instant::now();
16289        let started = now - Duration::from_millis(400);
16290        let until = compute_loading_linger_target(
16291            Some(started),
16292            Duration::from_millis(300),
16293            Duration::from_millis(500),
16294            now,
16295        )
16296        .expect("should arm linger past threshold");
16297        // Linger should extend ~500 ms past `now`. Allow a tiny slop so the
16298        // assertion isn't sensitive to test runner clock granularity.
16299        let target_delta = until.duration_since(now);
16300        assert!(
16301            target_delta >= Duration::from_millis(495)
16302                && target_delta <= Duration::from_millis(505),
16303            "linger target should be ~500ms in the future, got {target_delta:?}"
16304        );
16305    }
16306
16307    #[test]
16308    fn sort_key_cycle_matches_ui_column_order() {
16309        let order = [
16310            SortKey::Name,
16311            SortKey::App,
16312            SortKey::Status,
16313            SortKey::Health,
16314            SortKey::Version,
16315            SortKey::Age,
16316        ];
16317        let mut cur = order[0];
16318        for expected in order.iter().skip(1).chain(std::iter::once(&order[0])) {
16319            cur = cur.next();
16320            assert_eq!(cur, *expected);
16321        }
16322    }
16323
16324    #[test]
16325    fn sort_key_parse_roundtrip() {
16326        for k in [
16327            SortKey::Name,
16328            SortKey::App,
16329            SortKey::Status,
16330            SortKey::Health,
16331            SortKey::Version,
16332            SortKey::Age,
16333        ] {
16334            assert_eq!(SortKey::parse(k.label()), Some(k));
16335        }
16336        assert_eq!(SortKey::parse("bogus"), None);
16337    }
16338
16339    #[test]
16340    fn parse_sort_handles_directions() {
16341        assert_eq!(parse_sort(Some("app:desc")), (SortKey::App, true));
16342        assert_eq!(parse_sort(Some("name:asc")), (SortKey::Name, false));
16343        assert_eq!(parse_sort(Some("name")), (SortKey::Name, false));
16344        assert_eq!(parse_sort(Some("bogus:desc")), (SortKey::App, true)); // unknown key → default key, dir kept
16345        assert_eq!(parse_sort(None), (SortKey::App, false));
16346    }
16347
16348    #[test]
16349    fn parse_toggle_explicit_and_default() {
16350        assert!(parse_toggle(Some("on"), false));
16351        assert!(parse_toggle(Some("yes"), false));
16352        assert!(parse_toggle(Some("1"), false));
16353        assert!(!parse_toggle(Some("off"), true));
16354        assert!(!parse_toggle(Some("no"), true));
16355        // No arg → toggle current.
16356        assert!(parse_toggle(None, false));
16357        assert!(!parse_toggle(None, true));
16358        // Garbage → toggle current.
16359        assert!(parse_toggle(Some("maybe"), false));
16360    }
16361
16362    #[test]
16363    fn health_rank_orders_severities() {
16364        assert!(health_rank("green") < health_rank("grey"));
16365        assert!(health_rank("grey") < health_rank("yellow"));
16366        assert!(health_rank("yellow") < health_rank("red"));
16367        assert_eq!(health_rank("ok"), health_rank("Green"));
16368    }
16369
16370    #[test]
16371    fn scroll_apply_clamps_at_zero() {
16372        assert_eq!(scroll_apply(0, -1), 0);
16373        assert_eq!(scroll_apply(0, 0), 0);
16374        assert_eq!(scroll_apply(0, 1), 1);
16375        assert_eq!(scroll_apply(5, -10), 0);
16376        assert_eq!(scroll_apply(5, 3), 8);
16377    }
16378
16379    #[test]
16380    fn redact_block_preserves_length() {
16381        assert_eq!(redact_block(""), "");
16382        assert_eq!(redact_block("hello").chars().count(), 5);
16383        assert_eq!(redact_block("über-café").chars().count(), 9);
16384    }
16385
16386    #[test]
16387    fn scope_next_alternates() {
16388        assert_eq!(Scope::Envs.next(), Scope::Apps);
16389        assert_eq!(Scope::Apps.next(), Scope::Envs);
16390    }
16391
16392    #[test]
16393    fn action_destructive_only_for_terminate() {
16394        assert!(Action::Terminate.destructive());
16395        assert!(!Action::Rebuild.destructive());
16396        assert!(!Action::RestartAppServer.destructive());
16397        assert!(!Action::SwapCnames.destructive());
16398    }
16399
16400    #[test]
16401    fn scope_prev_is_inverse_of_next() {
16402        assert_eq!(Scope::Envs.next(), Scope::Apps);
16403        assert_eq!(Scope::Envs.prev(), Scope::Apps);
16404        assert_eq!(Scope::Apps.next().next(), Scope::Apps);
16405        assert_eq!(Scope::Envs.prev().prev(), Scope::Envs);
16406    }
16407
16408    #[test]
16409    fn view_mode_labels() {
16410        assert_eq!(ViewMode::Default.label(), "default");
16411        assert_eq!(ViewMode::Compact.label(), "compact");
16412        assert_eq!(ViewMode::Spacious.label(), "spacious");
16413    }
16414
16415    #[test]
16416    fn console_url_includes_region_app_env() {
16417        let url = console_url("us-east-1", "myapp", "myenv");
16418        assert!(url.contains("us-east-1.console.aws.amazon.com"));
16419        assert!(url.contains("region=us-east-1"));
16420        assert!(url.contains("applicationName=myapp"));
16421        assert!(url.contains("environmentName=myenv"));
16422    }
16423
16424    #[test]
16425    fn console_url_encodes_special_chars() {
16426        // Reserved or non-alnum chars get %XX'd so the URL stays valid.
16427        let url = console_url("us-east-1", "my app", "env/with?slash");
16428        assert!(url.contains("applicationName=my%20app"));
16429        assert!(url.contains("environmentName=env%2Fwith%3Fslash"));
16430    }
16431
16432    #[test]
16433    fn urlencode_keeps_safe_chars() {
16434        assert_eq!(urlencode("hello-world_1.0"), "hello-world_1.0");
16435        assert_eq!(urlencode("a b"), "a%20b");
16436        assert_eq!(urlencode("a/b?c=d"), "a%2Fb%3Fc%3Dd");
16437        // Unicode is byte-wise percent-encoded.
16438        assert!(urlencode("café").starts_with("caf"));
16439    }
16440
16441    #[test]
16442    fn json_escape_handles_quotes_and_controls() {
16443        assert_eq!(json_escape("hello"), "hello");
16444        assert_eq!(json_escape(r#"he said "hi""#), r#"he said \"hi\""#);
16445        assert_eq!(json_escape("line\nbreak"), "line\\nbreak");
16446        assert_eq!(json_escape("\\path"), "\\\\path");
16447        // Control character → \uXXXX.
16448        let out = json_escape("\u{0001}");
16449        assert_eq!(out, "\\u0001");
16450    }
16451
16452    #[test]
16453    fn build_describe_cli_no_profile() {
16454        let cmd = build_describe_cli("my-env", "eu-west-2", None);
16455        assert_eq!(
16456            cmd,
16457            "aws elasticbeanstalk describe-environments --environment-names my-env --region eu-west-2"
16458        );
16459    }
16460
16461    #[test]
16462    fn build_describe_cli_with_profile_and_special_chars() {
16463        let cmd = build_describe_cli("my env!", "eu-west-2", Some("prod"));
16464        assert!(cmd.contains("--environment-names 'my env!'"));
16465        assert!(cmd.contains("--profile prod"));
16466    }
16467
16468    fn fake_env_with(
16469        name: &str,
16470        status: &str,
16471        health: &str,
16472        updated_minutes_ago: Option<i64>,
16473    ) -> Environment {
16474        let updated =
16475            updated_minutes_ago.map(|m| chrono::Utc::now() - chrono::Duration::minutes(m));
16476        Environment {
16477            name: name.into(),
16478            application: "app".into(),
16479            status: status.into(),
16480            health: health.into(),
16481            platform: "Java 17".into(),
16482            solution_stack: String::new(),
16483            tier: "Web".into(),
16484            cname: "x.elb".into(),
16485            version_label: "v1".into(),
16486            arn: None,
16487            updated,
16488            id: None,
16489            region: None,
16490        }
16491    }
16492
16493    #[test]
16494    fn app_rollup_counts_envs_red_and_updating() {
16495        let envs = vec![
16496            crate::aws::Environment {
16497                name: "prod".into(),
16498                application: "foo".into(),
16499                status: "Ready".into(),
16500                health: "Green".into(),
16501                platform: "Java 17".into(),
16502                solution_stack: String::new(),
16503                tier: "WebServer".into(),
16504                cname: String::new(),
16505                version_label: String::new(),
16506                arn: None,
16507                updated: None,
16508                id: None,
16509                region: None,
16510            },
16511            crate::aws::Environment {
16512                name: "staging".into(),
16513                application: "foo".into(),
16514                status: "Updating".into(),
16515                health: "Red".into(),
16516                platform: "Java 17".into(),
16517                solution_stack: String::new(),
16518                tier: "WebServer".into(),
16519                cname: String::new(),
16520                version_label: String::new(),
16521                arn: None,
16522                updated: None,
16523                id: None,
16524                region: None,
16525            },
16526            crate::aws::Environment {
16527                name: "other-app".into(),
16528                application: "bar".into(),
16529                status: "Ready".into(),
16530                health: "Green".into(),
16531                platform: "Java 17".into(),
16532                solution_stack: String::new(),
16533                tier: "WebServer".into(),
16534                cname: String::new(),
16535                version_label: String::new(),
16536                arn: None,
16537                updated: None,
16538                id: None,
16539                region: None,
16540            },
16541        ];
16542        let dlq: HashMap<String, i64> = HashMap::new();
16543        let r = super::app_rollup(&envs, "foo", &dlq);
16544        assert_eq!(r.env_count, 2, "foo has 2 envs (prod + staging)");
16545        assert_eq!(r.red_count, 1, "staging is Red");
16546        assert_eq!(r.updating_count, 1, "staging is Updating");
16547        assert_eq!(r.worker_dlq_alerts, 0, "no worker envs in foo");
16548    }
16549
16550    #[test]
16551    fn app_rollup_worker_dlq_alert_counts() {
16552        let envs = vec![crate::aws::Environment {
16553            name: "worker-prod".into(),
16554            application: "wapp".into(),
16555            status: "Ready".into(),
16556            health: "Green".into(),
16557            platform: "Java 17".into(),
16558            solution_stack: String::new(),
16559            tier: "Worker".into(),
16560            cname: String::new(),
16561            version_label: String::new(),
16562            arn: None,
16563            updated: None,
16564            id: None,
16565            region: None,
16566        }];
16567        let mut dlq: HashMap<String, i64> = HashMap::new();
16568        dlq.insert("worker-prod".into(), 7);
16569        let r = super::app_rollup(&envs, "wapp", &dlq);
16570        // EB calls it Green; ebman flags it because the DLQ is non-empty.
16571        assert_eq!(r.env_count, 1);
16572        assert_eq!(r.red_count, 0, "EB health stays Green");
16573        assert_eq!(
16574            r.worker_dlq_alerts, 1,
16575            "worker env with DLQ depth > 0 counts as alerting"
16576        );
16577    }
16578
16579    #[test]
16580    fn app_rollup_empty_for_unknown_app() {
16581        let envs: Vec<crate::aws::Environment> = vec![];
16582        let dlq: HashMap<String, i64> = HashMap::new();
16583        let r = super::app_rollup(&envs, "nope", &dlq);
16584        assert_eq!(r, super::AppRollup::default());
16585    }
16586
16587    fn opt(
16588        ns: &str,
16589        name: &str,
16590        value: Option<&str>,
16591        default: Option<&str>,
16592    ) -> crate::aws::ConfigOption {
16593        crate::aws::ConfigOption {
16594            namespace: ns.into(),
16595            name: name.into(),
16596            value: value.map(String::from),
16597            default_value: default.map(String::from),
16598            value_type: "Scalar".into(),
16599            value_options: vec![],
16600            change_severity: None,
16601            user_defined: Some(true),
16602            min_value: None,
16603            max_value: None,
16604            max_length: None,
16605        }
16606    }
16607
16608    #[test]
16609    fn diff_config_options_reports_only_differences() {
16610        let left = vec![
16611            opt("aws:autoscaling:asg", "MinSize", Some("2"), None),
16612            opt("aws:autoscaling:asg", "MaxSize", Some("4"), None),
16613            opt(
16614                "aws:elasticbeanstalk:application:environment",
16615                "LOG",
16616                Some("info"),
16617                None,
16618            ),
16619            opt("aws:foo", "Same", Some("x"), None),
16620        ];
16621        let right = vec![
16622            opt("aws:autoscaling:asg", "MinSize", Some("3"), None), // changed
16623            opt("aws:autoscaling:asg", "MaxSize", Some("4"), None), // same
16624            opt(
16625                "aws:elasticbeanstalk:application:environment",
16626                "LOG",
16627                None,
16628                None,
16629            ), // unset on right
16630            opt("aws:foo", "Same", Some("x"), None),                // same
16631        ];
16632        let diffs = super::diff_config_options(&left, &right);
16633        assert_eq!(diffs.len(), 2, "got {diffs:?}");
16634        let min = diffs.iter().find(|d| d.name == "MinSize").unwrap();
16635        assert_eq!(min.left.as_deref(), Some("2"));
16636        assert_eq!(min.right.as_deref(), Some("3"));
16637        let log = diffs.iter().find(|d| d.name == "LOG").unwrap();
16638        assert_eq!(log.left.as_deref(), Some("info"));
16639        assert_eq!(log.right, None);
16640    }
16641
16642    #[test]
16643    fn diff_config_options_treats_empty_string_as_unset() {
16644        // EB returns Some("") for some unset options — must not show
16645        // as a difference against an actually-unset None.
16646        let left = vec![opt("aws:foo", "Bar", Some(""), None)];
16647        let right = vec![opt("aws:foo", "Bar", None, None)];
16648        assert!(super::diff_config_options(&left, &right).is_empty());
16649    }
16650
16651    #[test]
16652    fn render_config_diff_overlay_states() {
16653        // No differences → identical message.
16654        let body = super::render_config_diff_overlay("staging", "prod", &[]);
16655        assert!(body.contains("identical"));
16656        // With a diff → the namespace + name + both values appear.
16657        let diffs = vec![super::ConfigDiff {
16658            namespace: "aws:autoscaling:asg".into(),
16659            name: "MinSize".into(),
16660            left: Some("2".into()),
16661            right: None,
16662        }];
16663        let body = super::render_config_diff_overlay("staging", "prod", &diffs);
16664        assert!(body.contains("aws:autoscaling:asg"));
16665        assert!(body.contains("MinSize"));
16666        assert!(body.contains("L: 2"));
16667        assert!(body.contains("R: (unset)"));
16668    }
16669
16670    #[test]
16671    fn build_env_edit_body_sorts_keys_and_emits_header() {
16672        let vars = vec![
16673            ("LOG_LEVEL".into(), "info".into()),
16674            ("DB_HOST".into(), "db.example".into()),
16675            ("DB_PORT".into(), "5432".into()),
16676        ];
16677        let body = super::build_env_edit_body("prod", &vars);
16678        // Header comment present.
16679        assert!(body.starts_with("# ebman env-var editor — prod\n"));
16680        assert!(body.contains("Secrets Manager"));
16681        // Keys sorted alphabetically.
16682        let db_host_pos = body.find("DB_HOST=").expect("DB_HOST line");
16683        let db_port_pos = body.find("DB_PORT=").expect("DB_PORT line");
16684        let log_pos = body.find("LOG_LEVEL=").expect("LOG_LEVEL line");
16685        assert!(db_host_pos < db_port_pos && db_port_pos < log_pos);
16686    }
16687
16688    #[test]
16689    fn parse_env_edit_body_round_trip() {
16690        let vars = vec![
16691            ("LOG_LEVEL".into(), "info".into()),
16692            (
16693                "DB_URL".into(),
16694                "postgres://user:pass@host:5432/db?sslmode=require".into(),
16695            ),
16696        ];
16697        let body = super::build_env_edit_body("env", &vars);
16698        let parsed = super::parse_env_edit_body(&body);
16699        assert_eq!(parsed.get("LOG_LEVEL").map(String::as_str), Some("info"));
16700        // Value containing `=` (postgres URL) passes through intact
16701        // because we split on the *first* `=` only.
16702        assert_eq!(
16703            parsed.get("DB_URL").map(String::as_str),
16704            Some("postgres://user:pass@host:5432/db?sslmode=require")
16705        );
16706    }
16707
16708    #[test]
16709    fn parse_env_edit_body_skips_comments_and_blanks() {
16710        let body = "# comment\n\nDB_HOST=localhost\n   # indented comment\n\nLOG=debug\n";
16711        let parsed = super::parse_env_edit_body(body);
16712        assert_eq!(parsed.len(), 2);
16713        assert_eq!(parsed.get("DB_HOST").map(String::as_str), Some("localhost"));
16714        assert_eq!(parsed.get("LOG").map(String::as_str), Some("debug"));
16715    }
16716
16717    #[test]
16718    fn parse_env_edit_body_drops_invalid_keys() {
16719        let body = "= no-key\n KEY WITH SPACES=foo\nGOOD=val\n";
16720        let parsed = super::parse_env_edit_body(body);
16721        assert_eq!(parsed.len(), 1);
16722        assert!(parsed.contains_key("GOOD"));
16723    }
16724
16725    #[test]
16726    fn diff_env_vars_produces_set_and_remove_lists() {
16727        let mut original = std::collections::BTreeMap::new();
16728        original.insert("KEEP".into(), "same".into());
16729        original.insert("CHANGE".into(), "old".into());
16730        original.insert("DROP".into(), "going".into());
16731        let mut edited = std::collections::BTreeMap::new();
16732        edited.insert("KEEP".into(), "same".into()); // unchanged
16733        edited.insert("CHANGE".into(), "new".into()); // updated
16734        edited.insert("NEW".into(), "added".into()); // added
16735
16736        let (to_set, to_remove) = super::diff_env_vars("ns", &original, &edited);
16737        // CHANGE + NEW should be in to_set; KEEP excluded (unchanged).
16738        let set_keys: std::collections::BTreeSet<&str> =
16739            to_set.iter().map(|(_, k, _)| k.as_str()).collect();
16740        assert_eq!(
16741            set_keys,
16742            ["CHANGE", "NEW"]
16743                .into_iter()
16744                .collect::<std::collections::BTreeSet<_>>(),
16745            "to_set should include changed + added keys"
16746        );
16747        assert!(
16748            !set_keys.contains("KEEP"),
16749            "unchanged key must not re-dispatch"
16750        );
16751        // DROP should be in to_remove.
16752        assert_eq!(to_remove.len(), 1);
16753        assert_eq!(to_remove[0].1, "DROP");
16754    }
16755
16756    #[test]
16757    fn diff_env_vars_empty_when_unchanged() {
16758        let mut original = std::collections::BTreeMap::new();
16759        original.insert("A".into(), "1".into());
16760        original.insert("B".into(), "2".into());
16761        let edited = original.clone();
16762        let (to_set, to_remove) = super::diff_env_vars("ns", &original, &edited);
16763        assert!(to_set.is_empty());
16764        assert!(to_remove.is_empty());
16765    }
16766
16767    #[test]
16768    fn parse_access_denied_handles_assumed_role() {
16769        let msg = "User: arn:aws:sts::123456789012:assumed-role/EbmanReadOnly/session-abc \
16770                   is not authorized to perform: elasticbeanstalk:RebuildEnvironment \
16771                   on resource: arn:aws:elasticbeanstalk:eu-west-2:123:environment/foo/bar";
16772        let parsed = super::parse_access_denied(msg);
16773        assert_eq!(
16774            parsed,
16775            Some((
16776                "arn:aws:iam::123456789012:role/EbmanReadOnly".into(),
16777                "elasticbeanstalk:RebuildEnvironment".into()
16778            )),
16779            "assumed-role should be rewritten to the role ARN"
16780        );
16781    }
16782
16783    #[test]
16784    fn parse_access_denied_handles_iam_user() {
16785        let msg = "User: arn:aws:iam::123456789012:user/alice is not authorized to \
16786                   perform: s3:GetObject on resource: arn:aws:s3:::bucket/key";
16787        let parsed = super::parse_access_denied(msg);
16788        assert_eq!(
16789            parsed,
16790            Some((
16791                "arn:aws:iam::123456789012:user/alice".into(),
16792                "s3:GetObject".into()
16793            )),
16794            "IAM-user ARN should pass through unchanged"
16795        );
16796    }
16797
16798    #[test]
16799    fn parse_access_denied_returns_none_on_unrelated_error() {
16800        assert_eq!(
16801            super::parse_access_denied("ThrottlingException: rate exceeded"),
16802            None
16803        );
16804        assert_eq!(super::parse_access_denied("random garbage text"), None);
16805    }
16806
16807    #[test]
16808    fn render_explain_overlay_marks_decisions_and_suggests_fix() {
16809        let rows = vec![
16810            crate::aws::IamSimResult {
16811                action: "elasticbeanstalk:RebuildEnvironment".into(),
16812                resource: "*".into(),
16813                decision: "implicitDeny".into(),
16814                matched_statements: vec![],
16815                missing_context: vec![],
16816                blocked_by_scp: false,
16817                blocked_by_boundary: false,
16818            },
16819            crate::aws::IamSimResult {
16820                action: "ec2:DescribeInstances".into(),
16821                resource: "*".into(),
16822                decision: "allowed".into(),
16823                matched_statements: vec![
16824                    "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess @ 0:0".into()
16825                ],
16826                missing_context: vec![],
16827                blocked_by_scp: false,
16828                blocked_by_boundary: false,
16829            },
16830        ];
16831        let body = super::render_explain_overlay("arn:aws:iam::123:role/EbmanReadOnly", &rows);
16832        // Both action sections present, marked with correct decision glyphs.
16833        assert!(body.contains("Action:   elasticbeanstalk:RebuildEnvironment"));
16834        assert!(body.contains("✗ implicitDeny"));
16835        assert!(body.contains("Action:   ec2:DescribeInstances"));
16836        assert!(body.contains("✓ allowed"));
16837        // implicitDeny suggests the JSON-policy fix.
16838        assert!(body.contains("\"Effect\": \"Allow\""));
16839        assert!(body.contains("\"Action\": \"elasticbeanstalk:RebuildEnvironment\""));
16840        // The allowed action does NOT get the fix suggestion.
16841        assert!(body.matches("To allow, add this statement").count() == 1);
16842        // Matched statement surfaces for the allowed action.
16843        assert!(body.contains("AmazonEC2ReadOnlyAccess"));
16844    }
16845
16846    #[test]
16847    fn render_explain_overlay_flags_scp_and_boundary_blockers() {
16848        let rows = vec![crate::aws::IamSimResult {
16849            action: "ec2:TerminateInstances".into(),
16850            resource: "*".into(),
16851            decision: "explicitDeny".into(),
16852            matched_statements: vec!["org-scp/SCPDenyTerminate @ 0:0".into()],
16853            missing_context: vec![],
16854            blocked_by_scp: true,
16855            blocked_by_boundary: true,
16856        }];
16857        let body = super::render_explain_overlay("arn:aws:iam::123:role/X", &rows);
16858        assert!(body.contains("Organizations SCP"));
16859        assert!(body.contains("permission boundary"));
16860        // explicitDeny gives the "Remove the Deny" hint instead of
16861        // the implicitDeny JSON snippet.
16862        assert!(body.contains("explicit Deny always wins"));
16863        assert!(!body.contains("\"Effect\": \"Allow\""));
16864    }
16865
16866    fn empty_resources() -> crate::aws::EnvResources {
16867        crate::aws::EnvResources::default()
16868    }
16869
16870    #[test]
16871    fn render_env_resources_tree_shows_asg_with_nested_instances() {
16872        let mut res = empty_resources();
16873        res.asgs = vec!["awseb-AWSEBAutoScalingGroup-XYZ".into()];
16874        res.instances = vec!["i-0abc".into(), "i-0def".into(), "i-0ghi".into()];
16875        let body = super::render_env_resources_tree(&res, "prod-api", "Web");
16876        // Section header for ASG group.
16877        assert!(body.contains("Auto-scaling groups (1)"));
16878        // ASG node under it (└─ since only one ASG).
16879        assert!(body.contains("└─ awseb-AWSEBAutoScalingGroup-XYZ"));
16880        // Instances nested below the ASG with proper tree glyphs.
16881        assert!(body.contains("├─ i-0abc"));
16882        assert!(body.contains("├─ i-0def"));
16883        assert!(body.contains("└─ i-0ghi"));
16884    }
16885
16886    #[test]
16887    fn render_env_resources_tree_skips_empty_sections() {
16888        let mut res = empty_resources();
16889        res.asgs = vec!["asg-1".into()];
16890        // Everything else empty.
16891        let body = super::render_env_resources_tree(&res, "small-env", "Web");
16892        assert!(body.contains("Auto-scaling groups (1)"));
16893        // No load-balancer / launch-config / queue headers when
16894        // the lists are empty.
16895        assert!(!body.contains("Load balancers"));
16896        assert!(!body.contains("Launch configurations"));
16897        assert!(!body.contains("Queues"));
16898    }
16899
16900    #[test]
16901    fn render_env_resources_tree_marks_orphan_instances_when_no_asg() {
16902        let mut res = empty_resources();
16903        res.instances = vec!["i-stranded".into()];
16904        let body = super::render_env_resources_tree(&res, "env", "Web");
16905        assert!(body.contains("orphan (no ASG attached)"));
16906        assert!(body.contains("i-stranded"));
16907    }
16908
16909    #[test]
16910    fn render_env_resources_tree_renders_queue_urls_inline() {
16911        let mut res = empty_resources();
16912        res.queues = vec![
16913            crate::aws::EnvResourceQueue {
16914                name: "WorkerQueue".into(),
16915                url: "https://sqs.eu-west-2.amazonaws.com/123/main".into(),
16916            },
16917            crate::aws::EnvResourceQueue {
16918                name: "WorkerDeadLetterQueue".into(),
16919                url: "https://sqs.eu-west-2.amazonaws.com/123/dlq".into(),
16920            },
16921        ];
16922        let body = super::render_env_resources_tree(&res, "worker-prod", "Worker");
16923        assert!(body.contains("├─ WorkerQueue"));
16924        assert!(body.contains("https://sqs.eu-west-2.amazonaws.com/123/main"));
16925        assert!(body.contains("└─ WorkerDeadLetterQueue"));
16926        assert!(body.contains("https://sqs.eu-west-2.amazonaws.com/123/dlq"));
16927    }
16928
16929    #[test]
16930    fn render_env_resources_tree_handles_zero_resources() {
16931        let res = empty_resources();
16932        let body = super::render_env_resources_tree(&res, "fresh-env", "Web");
16933        assert!(body.contains("(no resources reported"));
16934    }
16935
16936    #[tokio::test]
16937    async fn first_run_hint_dismisses_on_first_key() {
16938        let mut app = test_app();
16939        app.first_run_hint = true;
16940        press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE);
16941        assert!(
16942            !app.first_run_hint,
16943            "first key event should clear first_run_hint"
16944        );
16945    }
16946
16947    #[tokio::test]
16948    async fn first_run_hint_stays_false_for_subsequent_launches() {
16949        // Simulates "ebman has run before; state.toml exists."
16950        // The test harness defaults first_run_hint to false anyway,
16951        // but this nails down the contract: a state.toml on disk
16952        // means no hint, full stop.
16953        let app = test_app();
16954        assert!(
16955            !app.first_run_hint,
16956            "test harness must default first_run_hint=false (state.toml presumed present)"
16957        );
16958    }
16959
16960    #[test]
16961    fn edit_distance_basic_cases() {
16962        assert_eq!(super::edit_distance("", ""), 0);
16963        assert_eq!(super::edit_distance("abc", ""), 3);
16964        assert_eq!(super::edit_distance("", "abc"), 3);
16965        assert_eq!(super::edit_distance("kitten", "sitting"), 3);
16966        assert_eq!(super::edit_distance("restart", "restart"), 0);
16967        assert_eq!(super::edit_distance("restrt", "restart"), 1);
16968        assert_eq!(super::edit_distance("rebild", "rebuild"), 1);
16969        assert_eq!(super::edit_distance("scal", "scale"), 1);
16970    }
16971
16972    #[test]
16973    fn suggest_command_catches_one_char_typos() {
16974        // Operator typo: forgot the 'a' in restart.
16975        assert_eq!(super::suggest_command("restrt").as_deref(), Some("restart"));
16976        // Operator typo: dropped a 'u' in rebuild.
16977        assert_eq!(super::suggest_command("rebild").as_deref(), Some("rebuild"));
16978        // Operator typo: dropped the 'e' in scale.
16979        assert_eq!(super::suggest_command("scal").as_deref(), Some("scale"));
16980    }
16981
16982    #[test]
16983    fn suggest_command_returns_none_when_too_far() {
16984        // Nonsense input — no command is within edit-distance 2.
16985        assert_eq!(super::suggest_command("zzzzzz"), None);
16986    }
16987
16988    #[test]
16989    fn suggest_command_threshold_is_strict_for_short_input() {
16990        // 2-char input shouldn't "match" every 3-char alias —
16991        // the operator's intent is too ambiguous to guess.
16992        // `:zz` is distance 2 from many names; we cap at 1.
16993        let suggestion = super::suggest_command("zz");
16994        assert!(
16995            suggestion.is_none(),
16996            "2-char typo should require distance ≤ 1; got {suggestion:?}"
16997        );
16998    }
16999
17000    #[test]
17001    fn completion_candidates_filters_by_prefix() {
17002        let c = super::completion_candidates("ba");
17003        assert!(
17004            c.iter().any(|s| s == "batch-rebuild"),
17005            "expected batch-rebuild among ba-prefixed candidates; got {c:?}"
17006        );
17007        assert!(
17008            c.iter().all(|s| s.starts_with("ba")),
17009            "every candidate must start with the prefix; got {c:?}"
17010        );
17011        assert_eq!(
17012            c.clone(),
17013            {
17014                let mut sorted = c.clone();
17015                sorted.sort();
17016                sorted
17017            },
17018            "candidates must be alphabetically sorted"
17019        );
17020    }
17021
17022    #[test]
17023    fn completion_candidates_with_empty_prefix_returns_full_list() {
17024        let c = super::completion_candidates("");
17025        // The registry has 80+ names + aliases — exact count drifts
17026        // with each release, just sanity-check the shape.
17027        assert!(
17028            c.len() > 50,
17029            "expected the full command list; got {} entries",
17030            c.len()
17031        );
17032        assert!(c.iter().any(|s| s == "why"));
17033        assert!(c.iter().any(|s| s == "rebuild"));
17034    }
17035
17036    #[tokio::test]
17037    async fn tab_in_command_mode_cycles_through_matches() {
17038        let mut app = test_app();
17039        app.mode = Mode::Command;
17040        app.command_input = "bat".into();
17041        // First Tab → first match (batch-deploy alphabetically).
17042        press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
17043        let first = app.command_input.clone();
17044        assert!(
17045            first.starts_with("bat"),
17046            "Tab should keep the bat-prefix; got {first:?}"
17047        );
17048        assert!(
17049            crate::commands::all_names().contains(&first.as_str()),
17050            "Tab should expand to a real command name; got {first:?}"
17051        );
17052        // Second Tab cycles forward; should differ from first.
17053        press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
17054        let second = app.command_input.clone();
17055        assert_ne!(first, second, "second Tab should advance the cycle");
17056    }
17057
17058    #[tokio::test]
17059    async fn typing_in_command_mode_breaks_the_completion_cycle() {
17060        let mut app = test_app();
17061        app.mode = Mode::Command;
17062        app.command_input = "re".into();
17063        press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
17064        assert!(app.completion.origin.is_some());
17065        // Operator types — cycle should reset.
17066        press(&mut app, KeyCode::Char('s'), KeyModifiers::NONE);
17067        assert!(
17068            app.completion.origin.is_none(),
17069            "typing must reset the completion origin"
17070        );
17071    }
17072
17073    #[tokio::test]
17074    async fn shift_tab_cycles_backward() {
17075        let mut app = test_app();
17076        app.mode = Mode::Command;
17077        app.command_input = "ba".into();
17078        press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
17079        let forward = app.command_input.clone();
17080        press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
17081        press(&mut app, KeyCode::BackTab, KeyModifiers::NONE);
17082        // Two forward + one back = same as one forward.
17083        assert_eq!(
17084            app.command_input, forward,
17085            "Tab Tab BackTab should land on the first match"
17086        );
17087    }
17088
17089    #[test]
17090    fn render_options_overlay_groups_by_namespace_and_marks_set_vs_default() {
17091        let rows = vec![
17092            opt("aws:autoscaling:asg", "MinSize", Some("2"), Some("1")),
17093            opt("aws:autoscaling:asg", "MaxSize", None, Some("4")),
17094            opt(
17095                "aws:elasticbeanstalk:command",
17096                "DeploymentPolicy",
17097                Some("Rolling"),
17098                Some("AllAtOnce"),
17099            ),
17100        ];
17101        let body = super::render_options_overlay(&rows, None, "uflexi-prod");
17102        // Section headers per namespace.
17103        assert!(body.contains("── aws:autoscaling:asg ──"));
17104        assert!(body.contains("── aws:elasticbeanstalk:command ──"));
17105        // Operator-set rows marked with ▸; default rows with •.
17106        assert!(body.contains("▸ MinSize"));
17107        assert!(body.contains("• MaxSize"));
17108        assert!(body.contains("▸ DeploymentPolicy"));
17109        // Default value is surfaced.
17110        assert!(body.contains("default: 1"));
17111        assert!(body.contains("default: 4"));
17112        // Top header counts set vs default.
17113        assert!(body.contains("2/3 options are operator-set"));
17114    }
17115
17116    #[test]
17117    fn render_options_overlay_filters_to_namespace_when_given() {
17118        let rows = vec![
17119            opt("aws:autoscaling:asg", "MinSize", Some("2"), None),
17120            opt(
17121                "aws:elasticbeanstalk:command",
17122                "DeploymentPolicy",
17123                Some("Rolling"),
17124                None,
17125            ),
17126        ];
17127        let body = super::render_options_overlay(&rows, Some("aws:autoscaling:asg"), "uflexi-prod");
17128        assert!(body.contains("MinSize"));
17129        assert!(!body.contains("DeploymentPolicy"));
17130    }
17131
17132    #[test]
17133    fn render_options_overlay_handles_unknown_namespace() {
17134        let rows = vec![opt("aws:autoscaling:asg", "MinSize", Some("2"), None)];
17135        let body = super::render_options_overlay(&rows, Some("aws:bogus:ns"), "uflexi-prod");
17136        assert!(body.contains("No options found"));
17137        assert!(body.contains("aws:bogus:ns"));
17138    }
17139
17140    #[test]
17141    fn render_secrets_overlay_empty_with_filter_explains_region_scope() {
17142        let body = super::render_secrets_overlay(&[], Some("prod-db"));
17143        assert!(body.contains("No secrets matching 'prod-db'"));
17144        assert!(body.contains("region-scoped"));
17145    }
17146
17147    #[test]
17148    fn render_secrets_overlay_empty_no_filter_hints_at_iam() {
17149        let body = super::render_secrets_overlay(&[], None);
17150        assert!(body.contains("No Secrets Manager secrets"));
17151        assert!(body.contains("ListSecrets"));
17152    }
17153
17154    #[test]
17155    fn render_secrets_overlay_lists_metadata_only() {
17156        let now = chrono::Utc::now();
17157        let rows = vec![crate::aws::SecretSummary {
17158            name: "prod/db/password".into(),
17159            arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db/password-AbCdEf"
17160                .into(),
17161            description: Some("RDS master".into()),
17162            last_changed: Some(now - chrono::Duration::days(3)),
17163            last_rotated: Some(now - chrono::Duration::days(30)),
17164            kms_key_id: Some("alias/aws/secretsmanager".into()),
17165        }];
17166        let body = super::render_secrets_overlay(&rows, None);
17167        assert!(body.contains("prod/db/password"));
17168        assert!(body.contains("RDS master"));
17169        assert!(body.contains("arn:aws:secretsmanager"));
17170        assert!(body.contains("changed:"));
17171        assert!(body.contains("rotated:"));
17172        assert!(body.contains("alias/aws/secretsmanager"));
17173        // The values themselves must never appear in :secrets output.
17174        assert!(!body.to_lowercase().contains("password:"));
17175    }
17176
17177    #[test]
17178    fn render_secrets_overlay_marks_never_rotated() {
17179        let now = chrono::Utc::now();
17180        let rows = vec![crate::aws::SecretSummary {
17181            name: "api-key".into(),
17182            arn: "arn:aws:secretsmanager:us-east-1:1:secret:api-key-x".into(),
17183            description: None,
17184            last_changed: Some(now - chrono::Duration::hours(2)),
17185            last_rotated: None,
17186            kms_key_id: None,
17187        }];
17188        let body = super::render_secrets_overlay(&rows, None);
17189        assert!(body.contains("rotated: never"));
17190    }
17191
17192    #[test]
17193    fn render_secret_value_overlay_redacts_when_redact_on() {
17194        let body = super::render_secret_value_overlay("api-key", "hunter2", true);
17195        assert!(body.contains("<redacted; 7 chars"));
17196        assert!(body.contains("fingerprint"));
17197        assert!(!body.contains("hunter2"));
17198        assert!(body.contains(":redact off"));
17199    }
17200
17201    #[test]
17202    fn render_secret_value_overlay_shows_value_when_redact_off() {
17203        let body = super::render_secret_value_overlay("api-key", "hunter2", false);
17204        assert!(body.contains("hunter2"));
17205        assert!(body.contains("yank"));
17206    }
17207
17208    #[test]
17209    fn render_secret_value_overlay_pretty_prints_json() {
17210        let body = super::render_secret_value_overlay(
17211            "prod/db",
17212            r#"{"USERNAME":"app","PASSWORD":"x"}"#,
17213            false,
17214        );
17215        // Expect a multi-line shape, not the input one-liner.
17216        assert!(body.contains("USERNAME"));
17217        assert!(body.contains("PASSWORD"));
17218        assert!(
17219            body.matches('\n').count() >= 4,
17220            "should pretty-print: {body}"
17221        );
17222    }
17223
17224    #[test]
17225    fn render_secret_value_overlay_leaves_non_json_alone() {
17226        let body = super::render_secret_value_overlay("flat", "ABC-DEF-GHI", false);
17227        assert!(body.contains("ABC-DEF-GHI"));
17228    }
17229
17230    #[test]
17231    fn short_fingerprint_is_stable_and_diffs() {
17232        let a = super::short_fingerprint("hunter2");
17233        let b = super::short_fingerprint("hunter2");
17234        let c = super::short_fingerprint("hunter3");
17235        assert_eq!(a, b);
17236        assert_ne!(a, c);
17237        assert_eq!(a.len(), 8);
17238    }
17239
17240    #[test]
17241    fn try_pretty_json_passes_through_non_json() {
17242        assert_eq!(super::try_pretty_json("just a string"), "just a string");
17243        assert_eq!(super::try_pretty_json(""), "");
17244    }
17245
17246    #[test]
17247    fn try_pretty_json_indents_objects() {
17248        let pretty = super::try_pretty_json(r#"{"a":1,"b":2}"#);
17249        let lines: Vec<&str> = pretty.lines().collect();
17250        assert!(lines.len() >= 4, "lines={lines:?}");
17251        assert!(lines.iter().any(|l| l.contains("\"a\": 1")));
17252        assert!(lines.iter().any(|l| l.contains("\"b\": 2")));
17253    }
17254
17255    #[test]
17256    fn try_pretty_json_emits_empty_containers_inline() {
17257        // Empty container must stay on one line, not split to `{\n}`.
17258        assert_eq!(super::try_pretty_json("{}"), "{}");
17259        assert_eq!(super::try_pretty_json("[]"), "[]");
17260        // Nested empty container — the outer object expands, the
17261        // inner `{}` stays inline beside its key.
17262        let pretty = super::try_pretty_json(r#"{"a":{}}"#);
17263        assert!(pretty.contains("\"a\": {}"), "got: {pretty}");
17264    }
17265
17266    #[test]
17267    fn try_pretty_json_preserves_strings_with_braces() {
17268        // A `{` inside a string must not trigger indent.
17269        let pretty = super::try_pretty_json(r#"{"msg":"hello {world}"}"#);
17270        assert!(pretty.contains("hello {world}"));
17271    }
17272
17273    #[test]
17274    fn format_age_buckets() {
17275        let now = chrono::Utc::now();
17276        assert!(super::format_age(now, now).ends_with("s ago"));
17277        assert!(super::format_age(now, now - chrono::Duration::seconds(120)).ends_with("m ago"));
17278        assert!(super::format_age(now, now - chrono::Duration::hours(5)).ends_with("h ago"));
17279        assert!(super::format_age(now, now - chrono::Duration::days(10)).ends_with("d ago"));
17280        let body = super::format_age(now, now - chrono::Duration::days(120));
17281        assert!(body.starts_with('~') && body.contains("mo"));
17282    }
17283
17284    #[test]
17285    fn render_options_overlay_truncates_long_value_options_list() {
17286        let mut row = opt("aws:foo", "Enum", Some("a"), None);
17287        row.value_options = (0..20).map(|i| format!("v{i}")).collect();
17288        let rows = vec![row];
17289        let body = super::render_options_overlay(&rows, None, "env");
17290        assert!(body.contains("oneof: v0, v1, v2, v3, v4, … +15"));
17291    }
17292
17293    #[test]
17294    fn flatten_err_marks_access_denied() {
17295        let e = color_eyre::eyre::eyre!("operation failed")
17296            .wrap_err("AccessDeniedException: User: arn:aws:sts::1234 is not authorized");
17297        let out = super::flatten_err_to_string(&e);
17298        assert!(out.starts_with("AccessDenied:"), "got: {out}");
17299    }
17300
17301    #[test]
17302    fn flatten_err_marks_not_found() {
17303        let e = color_eyre::eyre::eyre!("operation failed")
17304            .wrap_err("ResourceNotFoundException: alarm 'foo' does not exist");
17305        let out = super::flatten_err_to_string(&e);
17306        assert!(out.starts_with("NotFound:"), "got: {out}");
17307    }
17308
17309    #[test]
17310    fn flatten_err_marks_dependency_violation() {
17311        let e = color_eyre::eyre::eyre!("operation failed")
17312            .wrap_err("DependencyViolation: resource still has dependencies");
17313        let out = super::flatten_err_to_string(&e);
17314        assert!(out.starts_with("Conflict:"), "got: {out}");
17315    }
17316
17317    #[test]
17318    fn flatten_err_marks_expired_token() {
17319        let e = color_eyre::eyre::eyre!("operation failed")
17320            .wrap_err("ExpiredToken: session credentials expired");
17321        let out = super::flatten_err_to_string(&e);
17322        assert!(out.starts_with("ExpiredToken:"), "got: {out}");
17323    }
17324
17325    #[test]
17326    fn flatten_err_passes_unknown_through_unchanged() {
17327        let e = color_eyre::eyre::eyre!("some other failure");
17328        let out = super::flatten_err_to_string(&e);
17329        assert!(
17330            !out.contains(":"),
17331            "expected no classification prefix; got: {out}"
17332        );
17333    }
17334
17335    #[test]
17336    fn traffic_warning_flags_updating() {
17337        let e = fake_env_with("prod", "Updating", "Yellow", Some(20));
17338        assert!(super::compute_traffic_warning(&e)
17339            .unwrap()
17340            .contains("ACTIVE DEPLOY"));
17341    }
17342
17343    #[test]
17344    fn traffic_warning_flags_recent_change() {
17345        let e = fake_env_with("prod", "Ready", "Green", Some(2));
17346        assert!(super::compute_traffic_warning(&e)
17347            .unwrap()
17348            .contains("RECENT CHANGE"));
17349    }
17350
17351    #[test]
17352    fn traffic_warning_silent_on_quiet_env() {
17353        let e = fake_env_with("prod", "Ready", "Green", Some(60));
17354        assert!(super::compute_traffic_warning(&e).is_none());
17355    }
17356
17357    #[test]
17358    fn traffic_warning_flags_red_health() {
17359        let e = fake_env_with("prod", "Ready", "Red", Some(120));
17360        assert!(super::compute_traffic_warning(&e).unwrap().contains("Red"));
17361    }
17362
17363    #[test]
17364    fn is_throttling_error_matches_common_aws_strings() {
17365        assert!(is_throttling_error("ThrottlingException: Rate exceeded"));
17366        assert!(is_throttling_error(
17367            "service error: ThrottlingException — please slow down"
17368        ));
17369        assert!(is_throttling_error("RequestLimitExceeded"));
17370        assert!(is_throttling_error("HTTP 429 Too Many Requests"));
17371        assert!(is_throttling_error("rate exceeded for this account"));
17372        // Negative cases.
17373        assert!(!is_throttling_error("EnvironmentNotFound"));
17374        assert!(!is_throttling_error("AccessDenied"));
17375        assert!(!is_throttling_error(""));
17376    }
17377
17378    #[test]
17379    fn throttle_backoff_grows_then_caps() {
17380        let base = Duration::from_secs(15);
17381        let b0 = throttle_backoff(base, 0);
17382        let b1 = throttle_backoff(base, 1);
17383        let b2 = throttle_backoff(base, 2);
17384        // First throttle: 2x base (30 s); second: 4x; third: 8x.
17385        assert_eq!(b0, Duration::from_secs(30));
17386        assert_eq!(b1, Duration::from_secs(60));
17387        assert_eq!(b2, Duration::from_secs(120));
17388        // Way past the cap stays at the cap.
17389        let bn = throttle_backoff(base, 30);
17390        assert_eq!(bn, Duration::from_secs(300));
17391    }
17392
17393    #[test]
17394    fn throttle_backoff_handles_overflow_safely() {
17395        // Pathologically large base must not panic — saturating_mul keeps us safe.
17396        let base = Duration::MAX;
17397        let b = throttle_backoff(base, 5);
17398        assert_eq!(b, Duration::from_secs(300));
17399    }
17400
17401    #[test]
17402    fn delta_toast_key_extracts_bucket_for_delta_shapes() {
17403        assert_eq!(super::delta_toast_key("▲2 Red").as_deref(), Some("Red"));
17404        assert_eq!(
17405            super::delta_toast_key("▼1 Yellow").as_deref(),
17406            Some("Yellow")
17407        );
17408        // Leading whitespace is allowed.
17409        assert_eq!(
17410            super::delta_toast_key("  ▲10 Green").as_deref(),
17411            Some("Green")
17412        );
17413    }
17414
17415    #[test]
17416    fn format_app_versions_marks_deployed_and_shows_total_when_truncated() {
17417        use crate::aws::AppVersion;
17418        let mk = |label: &str, desc: &str| AppVersion {
17419            label: label.into(),
17420            description: desc.into(),
17421            created: None,
17422        };
17423        let versions: Vec<AppVersion> = (1..=30)
17424            .map(|i| {
17425                mk(
17426                    &format!("build-{i}"),
17427                    &format!("Application version created from https://example.com/build/{i}"),
17428                )
17429            })
17430            .rev()
17431            .collect();
17432        // build-5 is outside the top 20 (which is build-30 down to build-11
17433        // after the rev). Lets us check the truncation banner without the
17434        // deployed marker showing up.
17435        let out = super::format_app_versions(&versions, Some("build-5"), 20);
17436        assert!(out.contains("showing 20 of 30"));
17437        assert!(!out.contains("◀ deployed"));
17438        // Description prefix stripped.
17439        assert!(out.contains("https://example.com/build/"));
17440        assert!(!out.contains("Application version created from "));
17441    }
17442
17443    #[test]
17444    fn format_app_versions_marks_deployed_when_present() {
17445        use crate::aws::AppVersion;
17446        let versions = vec![
17447            AppVersion {
17448                label: "build-3".into(),
17449                description: String::new(),
17450                created: None,
17451            },
17452            AppVersion {
17453                label: "build-2".into(),
17454                description: String::new(),
17455                created: None,
17456            },
17457        ];
17458        let out = super::format_app_versions(&versions, Some("build-2"), 20);
17459        assert!(out.contains("◀ deployed"));
17460        // No truncation banner when total <= limit.
17461        assert!(!out.contains("showing "));
17462    }
17463
17464    #[test]
17465    fn wrap_with_hanging_indent_first_line_keeps_lead_marker() {
17466        let out = super::wrap_with_hanging_indent(
17467            "Threshold Crossed: alarm details continue",
17468            30,
17469            "  ↳ ",
17470            "    ",
17471        );
17472        let lines: Vec<&str> = out.lines().collect();
17473        assert!(lines[0].starts_with("  ↳ "));
17474        // Continuation line uses the cont prefix.
17475        if lines.len() > 1 {
17476            assert!(lines[1].starts_with("    "));
17477        }
17478    }
17479
17480    #[test]
17481    fn wrap_with_hanging_indent_hard_breaks_oversize_words() {
17482        // A single 50-char word at width 20 + 4-char lead → body width 16.
17483        let big_word = "x".repeat(50);
17484        let out = super::wrap_with_hanging_indent(&big_word, 20, "    ", "    ");
17485        let lines: Vec<&str> = out.lines().collect();
17486        assert!(lines.len() >= 3);
17487    }
17488
17489    #[test]
17490    fn parse_s3_url_extracts_bucket_and_key() {
17491        let (b, k) = super::parse_s3_url("s3://my-bucket/path/to/bundle.zip").unwrap();
17492        assert_eq!(b, "my-bucket");
17493        assert_eq!(k, "path/to/bundle.zip");
17494    }
17495
17496    #[test]
17497    fn parse_s3_url_rejects_malformed() {
17498        assert!(super::parse_s3_url("/local/path.zip").is_none());
17499        assert!(super::parse_s3_url("s3://").is_none());
17500        assert!(super::parse_s3_url("s3://bucket").is_none());
17501        assert!(super::parse_s3_url("s3://bucket/").is_none());
17502        assert!(super::parse_s3_url("s3:///key").is_none());
17503    }
17504
17505    #[test]
17506    fn parse_metric_extra_args_defaults_to_average() {
17507        let (stat, dims) = super::parse_metric_extra_args(&[]);
17508        assert_eq!(stat, "Average");
17509        assert!(dims.is_empty());
17510    }
17511
17512    #[test]
17513    fn parse_metric_extra_args_picks_stat_first() {
17514        let (stat, dims) = super::parse_metric_extra_args(&["Sum"]);
17515        assert_eq!(stat, "Sum");
17516        assert!(dims.is_empty());
17517    }
17518
17519    #[test]
17520    fn parse_metric_extra_args_picks_dims_when_present() {
17521        let (stat, dims) = super::parse_metric_extra_args(&["InstanceId=i-abc"]);
17522        assert_eq!(stat, "Average");
17523        assert_eq!(dims, vec![("InstanceId".into(), "i-abc".into())]);
17524    }
17525
17526    #[test]
17527    fn parse_metric_extra_args_supports_both_in_any_order() {
17528        let (stat, dims) = super::parse_metric_extra_args(&["Sum", "InstanceId=i-abc,Tier=web"]);
17529        assert_eq!(stat, "Sum");
17530        assert_eq!(
17531            dims,
17532            vec![
17533                ("InstanceId".into(), "i-abc".into()),
17534                ("Tier".into(), "web".into()),
17535            ]
17536        );
17537        // Reversed order: dims first.
17538        let (stat, dims) = super::parse_metric_extra_args(&["InstanceId=i-abc", "Sum"]);
17539        assert_eq!(stat, "Sum");
17540        assert_eq!(dims, vec![("InstanceId".into(), "i-abc".into())]);
17541    }
17542
17543    #[test]
17544    fn derive_version_label_uses_filename_stem_and_timestamp() {
17545        let l = super::derive_version_label("./build.zip", 1684512345);
17546        assert_eq!(l, "build_1684512345");
17547        let l = super::derive_version_label("/tmp/myapp-2.1.0.zip", 42);
17548        assert_eq!(l, "myapp-2.1.0_42");
17549    }
17550
17551    #[test]
17552    fn derive_version_label_sanitises_disallowed_chars() {
17553        // EB version labels don't allow spaces or weird punctuation; we
17554        // replace them with `_` so the operator gets a valid label even from
17555        // a goofy filename.
17556        let l = super::derive_version_label("/tmp/build with spaces & specials!.zip", 1);
17557        assert_eq!(l, "build_with_spaces___specials__1");
17558    }
17559
17560    #[test]
17561    fn derive_version_label_falls_back_to_bundle_on_pathological_input() {
17562        // Bare `/` has no filename stem.
17563        let l = super::derive_version_label("/", 9);
17564        assert_eq!(l, "bundle_9");
17565    }
17566
17567    #[test]
17568    fn expand_tilde_only_replaces_leading() {
17569        // Set HOME for the test.
17570        let prev = std::env::var_os("HOME");
17571        // SAFETY: tests run single-threaded by default; restore at the end.
17572        unsafe {
17573            std::env::set_var("HOME", "/Users/tester");
17574        }
17575        assert_eq!(super::expand_tilde("~/foo/bar"), "/Users/tester/foo/bar");
17576        // No leading tilde → unchanged.
17577        assert_eq!(super::expand_tilde("/abs/path"), "/abs/path");
17578        // `~name` left alone (not supported).
17579        assert_eq!(super::expand_tilde("~tom/foo"), "~tom/foo");
17580        // Mid-path tilde left alone.
17581        assert_eq!(super::expand_tilde("/foo/~/bar"), "/foo/~/bar");
17582        if let Some(v) = prev {
17583            unsafe {
17584                std::env::set_var("HOME", v);
17585            }
17586        } else {
17587            unsafe {
17588                std::env::remove_var("HOME");
17589            }
17590        }
17591    }
17592
17593    #[test]
17594    fn pick_default_log_group_prefers_web_stdout() {
17595        let groups: Vec<String> = vec![
17596            "/aws/elasticbeanstalk/myenv/var/log/eb-engine.log".into(),
17597            "/aws/elasticbeanstalk/myenv/var/log/web.stdout.log".into(),
17598            "/aws/elasticbeanstalk/myenv/var/log/nginx/access.log".into(),
17599        ];
17600        assert_eq!(
17601            super::pick_default_log_group(&groups).as_deref(),
17602            Some("/aws/elasticbeanstalk/myenv/var/log/web.stdout.log")
17603        );
17604    }
17605
17606    #[test]
17607    fn pick_default_log_group_falls_back_to_first() {
17608        let groups: Vec<String> = vec!["/aws/elasticbeanstalk/myenv/var/log/custom.log".into()];
17609        assert_eq!(
17610            super::pick_default_log_group(&groups).as_deref(),
17611            Some("/aws/elasticbeanstalk/myenv/var/log/custom.log")
17612        );
17613        // No groups at all → None.
17614        assert_eq!(super::pick_default_log_group(&[]), None);
17615    }
17616
17617    #[test]
17618    fn pick_default_log_group_prefers_engine_log_when_stdout_absent() {
17619        let groups: Vec<String> = vec![
17620            "/aws/elasticbeanstalk/myenv/var/log/nginx/access.log".into(),
17621            "/aws/elasticbeanstalk/myenv/var/log/eb-engine.log".into(),
17622        ];
17623        assert_eq!(
17624            super::pick_default_log_group(&groups).as_deref(),
17625            Some("/aws/elasticbeanstalk/myenv/var/log/eb-engine.log")
17626        );
17627    }
17628
17629    #[test]
17630    fn format_env_vars_aligns_on_equals() {
17631        let vars = vec![
17632            ("DEBUG".into(), "1".into()),
17633            ("DATABASE_URL".into(), "postgres://x".into()),
17634        ];
17635        let out = super::format_env_vars(&vars);
17636        assert!(out.contains("DEBUG"));
17637        assert!(out.contains("= 1"));
17638        assert!(out.contains("DATABASE_URL"));
17639        let vars = vec![("EMPTY".into(), "".into())];
17640        assert!(super::format_env_vars(&vars).contains("\"\""));
17641    }
17642
17643    #[test]
17644    fn format_env_vars_handles_empty_input() {
17645        assert_eq!(super::format_env_vars(&[]), "(no env vars set)");
17646    }
17647
17648    #[test]
17649    fn parse_named_arg_picks_up_value_after_flag() {
17650        let rest: Vec<&str> = vec!["on", "--retention", "14"];
17651        assert_eq!(
17652            super::parse_named_arg::<i32>(&rest, "--retention"),
17653            Some(14)
17654        );
17655        // Flag absent.
17656        assert_eq!(super::parse_named_arg::<i32>(&["on"], "--retention"), None);
17657        // Flag present but no following value.
17658        assert_eq!(
17659            super::parse_named_arg::<i32>(&["on", "--retention"], "--retention"),
17660            None
17661        );
17662        // Following value doesn't parse.
17663        assert_eq!(
17664            super::parse_named_arg::<i32>(&["on", "--retention", "abc"], "--retention"),
17665            None
17666        );
17667    }
17668
17669    #[test]
17670    fn alarm_kind_to_metric_covers_known_kinds() {
17671        use crate::app::alarm_kind_to_metric;
17672        let (m, op, _) = alarm_kind_to_metric("health").unwrap();
17673        assert_eq!(m, "EnvironmentHealth");
17674        // Health is "drop below" → LessThanOrEqualToThreshold.
17675        assert_eq!(op, "LessThanOrEqualToThreshold");
17676        let (m, op, _) = alarm_kind_to_metric("5xx").unwrap();
17677        assert_eq!(m, "ApplicationRequests5xx");
17678        assert_eq!(op, "GreaterThanThreshold");
17679        // Aliases.
17680        assert_eq!(alarm_kind_to_metric("req5xx"), alarm_kind_to_metric("5xx"));
17681        assert_eq!(alarm_kind_to_metric("p90"), alarm_kind_to_metric("latency"));
17682        // Unknown.
17683        assert!(alarm_kind_to_metric("cpu").is_none());
17684        assert!(alarm_kind_to_metric("").is_none());
17685    }
17686
17687    #[test]
17688    fn format_template_settings_groups_by_namespace() {
17689        let s = vec![
17690            (
17691                "aws:elasticbeanstalk:environment".into(),
17692                "EnvironmentType".into(),
17693                "LoadBalanced".into(),
17694            ),
17695            ("aws:autoscaling:asg".into(), "MinSize".into(), "2".into()),
17696            ("aws:autoscaling:asg".into(), "MaxSize".into(), "8".into()),
17697        ];
17698        let out = super::format_template_settings(&s);
17699        assert!(out.contains("[aws:autoscaling:asg]"));
17700        assert!(out.contains("[aws:elasticbeanstalk:environment]"));
17701        assert!(out.contains("MinSize"));
17702        assert!(out.contains("= 2"));
17703        // Empty value renders as the literal "" so operators can tell empty
17704        // from unset.
17705        let s = vec![(
17706            "aws:elasticbeanstalk:application:environment".into(),
17707            "DEBUG".into(),
17708            String::new(),
17709        )];
17710        assert!(super::format_template_settings(&s).contains("DEBUG"));
17711        assert!(super::format_template_settings(&s).contains("\"\""));
17712    }
17713
17714    #[test]
17715    fn format_template_settings_handles_empty_input() {
17716        assert_eq!(super::format_template_settings(&[]), "(no option settings)");
17717    }
17718
17719    #[test]
17720    fn action_labels_are_distinct_and_non_empty() {
17721        // Catches accidental "placeholder Action::Rebuild" reuses — every
17722        // variant must carry its own label so audit logs + toasts reflect
17723        // what was actually dispatched.
17724        use crate::app::Action;
17725        use std::collections::HashSet;
17726        let all = [
17727            Action::Rebuild,
17728            Action::RestartAppServer,
17729            Action::SwapCnames,
17730            Action::Terminate,
17731            Action::Deploy,
17732            Action::UpgradePlatform,
17733            Action::Clone,
17734            Action::Scale,
17735            Action::AbortUpdate,
17736            Action::ConfigSave,
17737            Action::ConfigDelete,
17738            Action::ConfigApply,
17739            Action::TerminateInstance,
17740        ];
17741        let mut labels = HashSet::new();
17742        for a in all {
17743            let l = a.label();
17744            assert!(!l.is_empty(), "{a:?} has empty label");
17745            assert!(labels.insert(l), "{a:?} reuses label {l:?}");
17746        }
17747    }
17748
17749    #[test]
17750    fn collect_saved_configs_flattens_and_sorts_stably() {
17751        use crate::aws::Application;
17752        let app = |name: &str, templates: Vec<String>| Application {
17753            name: name.into(),
17754            description: String::new(),
17755            date_created: None,
17756            date_updated: None,
17757            version_count: 0,
17758            templates,
17759            latest_version_label: None,
17760            latest_version_created: None,
17761        };
17762        let apps = vec![
17763            app("beta", vec!["prod".into(), "canary".into()]),
17764            app("alpha", vec![]),
17765            app("alpha", vec!["staging".into()]),
17766        ];
17767        let out = super::collect_saved_configs(&apps);
17768        assert_eq!(
17769            out,
17770            vec![
17771                ("alpha".into(), "staging".into()),
17772                ("beta".into(), "canary".into()),
17773                ("beta".into(), "prod".into()),
17774            ]
17775        );
17776    }
17777
17778    #[test]
17779    fn collect_saved_configs_empty_when_no_templates() {
17780        use crate::aws::Application;
17781        let apps = vec![Application {
17782            name: "alpha".into(),
17783            description: String::new(),
17784            date_created: None,
17785            date_updated: None,
17786            version_count: 0,
17787            templates: vec![],
17788            latest_version_label: None,
17789            latest_version_created: None,
17790        }];
17791        assert!(super::collect_saved_configs(&apps).is_empty());
17792    }
17793
17794    #[test]
17795    fn merge_app_latest_versions_carries_previous_values_by_name() {
17796        use crate::aws::Application;
17797        let mk = |name: &str,
17798                  label: Option<&str>,
17799                  created: Option<chrono::DateTime<chrono::Utc>>|
17800         -> Application {
17801            Application {
17802                name: name.into(),
17803                description: String::new(),
17804                date_created: None,
17805                date_updated: None,
17806                version_count: 0,
17807                templates: vec![],
17808                latest_version_label: label.map(|s| s.into()),
17809                latest_version_created: created,
17810            }
17811        };
17812        let t0 = chrono::Utc::now();
17813        let prev = vec![
17814            mk("alpha", Some("build-1"), Some(t0)),
17815            mk("beta", Some("build-9"), Some(t0)),
17816        ];
17817        // Fresh refresh: same apps, plus a new one, all with empty LATEST.
17818        let mut next = vec![
17819            mk("alpha", None, None),
17820            mk("beta", None, None),
17821            mk("gamma", None, None),
17822        ];
17823        super::merge_app_latest_versions(&prev, &mut next);
17824        assert_eq!(next[0].latest_version_label.as_deref(), Some("build-1"));
17825        assert_eq!(next[0].latest_version_created, Some(t0));
17826        assert_eq!(next[1].latest_version_label.as_deref(), Some("build-9"));
17827        // New app has no prior value; stays None.
17828        assert_eq!(next[2].latest_version_label, None);
17829        assert_eq!(next[2].latest_version_created, None);
17830    }
17831
17832    #[test]
17833    fn merge_app_latest_versions_does_not_overwrite_already_populated_slots() {
17834        // Safety net: if a future caller pre-populates the LATEST fields on
17835        // `next` (e.g. a faster fan-out lands before the apps-list does),
17836        // the carry-forward must not stomp on fresher data.
17837        use crate::aws::Application;
17838        let mk = |name: &str, label: Option<&str>| -> Application {
17839            Application {
17840                name: name.into(),
17841                description: String::new(),
17842                date_created: None,
17843                date_updated: None,
17844                version_count: 0,
17845                templates: vec![],
17846                latest_version_label: label.map(|s| s.into()),
17847                latest_version_created: None,
17848            }
17849        };
17850        let prev = vec![mk("alpha", Some("OLD"))];
17851        let mut next = vec![mk("alpha", Some("NEW"))];
17852        super::merge_app_latest_versions(&prev, &mut next);
17853        assert_eq!(next[0].latest_version_label.as_deref(), Some("NEW"));
17854    }
17855
17856    #[test]
17857    fn merge_app_latest_versions_handles_app_disappearance() {
17858        // If an app is renamed / deleted between refreshes, its prev entry
17859        // simply has no matching `next` and the carry-forward is a no-op.
17860        use crate::aws::Application;
17861        let mk = |name: &str, label: Option<&str>| -> Application {
17862            Application {
17863                name: name.into(),
17864                description: String::new(),
17865                date_created: None,
17866                date_updated: None,
17867                version_count: 0,
17868                templates: vec![],
17869                latest_version_label: label.map(|s| s.into()),
17870                latest_version_created: None,
17871            }
17872        };
17873        let prev = vec![mk("alpha", Some("build-old")), mk("beta", Some("build-2"))];
17874        let mut next = vec![mk("beta", None)];
17875        super::merge_app_latest_versions(&prev, &mut next);
17876        assert_eq!(next.len(), 1);
17877        assert_eq!(next[0].latest_version_label.as_deref(), Some("build-2"));
17878    }
17879
17880    #[test]
17881    fn format_org_accounts_includes_switch_hint_when_configured() {
17882        use crate::aws::OrgAccount;
17883        let accounts = vec![
17884            OrgAccount {
17885                id: "111122223333".into(),
17886                name: "prod".into(),
17887                email: Some("prod@example.com".into()),
17888                status: "ACTIVE".into(),
17889            },
17890            OrgAccount {
17891                id: "444455556666".into(),
17892                name: "sandbox".into(),
17893                email: None,
17894                status: "SUSPENDED".into(),
17895            },
17896        ];
17897        let mut configured = std::collections::HashMap::new();
17898        configured.insert("prod".to_string(), "prod".to_string());
17899        let body = super::format_org_accounts(&accounts, &configured);
17900        assert!(body.contains("● prod"));
17901        assert!(body.contains("⊘ sandbox"));
17902        assert!(body.contains("prod@example.com"));
17903        // Switch hint only for the configured account.
17904        assert!(body.contains(":account prod"));
17905        assert!(!body.contains(":account sandbox"));
17906    }
17907
17908    #[test]
17909    fn format_org_accounts_empty_returns_hint() {
17910        let body = super::format_org_accounts(&[], &std::collections::HashMap::new());
17911        assert!(body.contains("no accounts returned"));
17912    }
17913
17914    #[test]
17915    fn format_org_accounts_matches_id_when_named_by_id() {
17916        use crate::aws::OrgAccount;
17917        let accounts = vec![OrgAccount {
17918            id: "111122223333".into(),
17919            name: "prod".into(),
17920            email: None,
17921            status: "ACTIVE".into(),
17922        }];
17923        // Operator named the AssumeRole entry by account-id rather
17924        // than friendly name — still matches.
17925        let mut configured = std::collections::HashMap::new();
17926        configured.insert("111122223333".to_string(), "111122223333".to_string());
17927        let body = super::format_org_accounts(&accounts, &configured);
17928        assert!(body.contains(":account 111122223333"));
17929    }
17930
17931    #[test]
17932    fn format_deploy_preview_happy_path() {
17933        use crate::aws::AppVersion;
17934        let now = chrono::Utc::now();
17935        let versions = vec![
17936            AppVersion {
17937                label: "build-142".into(),
17938                description: "fix: idempotent retries".into(),
17939                created: Some(now - chrono::Duration::hours(2)),
17940            },
17941            AppVersion {
17942                label: "build-141".into(),
17943                description: "feat: /metrics endpoint".into(),
17944                created: Some(now - chrono::Duration::days(1)),
17945            },
17946        ];
17947        let body = super::format_deploy_preview("uflexi-prod", "build-141", "build-142", &versions);
17948        assert!(body.contains("env:        uflexi-prod"));
17949        assert!(body.contains("current:    build-141"));
17950        assert!(body.contains("candidate:  build-142"));
17951        assert!(body.contains("fix: idempotent retries"));
17952        // Newer candidate → no rollback warning.
17953        assert!(!body.contains("rollback"));
17954    }
17955
17956    #[test]
17957    fn format_deploy_preview_rollback_warning_fires_when_older() {
17958        use crate::aws::AppVersion;
17959        let now = chrono::Utc::now();
17960        let versions = vec![
17961            AppVersion {
17962                label: "build-old".into(),
17963                description: String::new(),
17964                created: Some(now - chrono::Duration::days(7)),
17965            },
17966            AppVersion {
17967                label: "build-new".into(),
17968                description: String::new(),
17969                created: Some(now - chrono::Duration::hours(1)),
17970            },
17971        ];
17972        // Deploying the OLDER version on top of the NEWER one → rollback.
17973        let body = super::format_deploy_preview("uflexi-prod", "build-new", "build-old", &versions);
17974        assert!(
17975            body.contains("rollback"),
17976            "expected rollback warning, got: {body}"
17977        );
17978    }
17979
17980    #[test]
17981    fn format_deploy_preview_unknown_label_calls_out_the_gap() {
17982        use crate::aws::AppVersion;
17983        let versions = vec![AppVersion {
17984            label: "build-141".into(),
17985            description: String::new(),
17986            created: Some(chrono::Utc::now()),
17987        }];
17988        let body = super::format_deploy_preview(
17989            "uflexi-prod",
17990            "build-141",
17991            "build-DOES-NOT-EXIST",
17992            &versions,
17993        );
17994        assert!(body.contains("not found"));
17995        assert!(body.contains("build-DOES-NOT-EXIST"));
17996    }
17997
17998    fn make_event(msg: &str) -> crate::aws::Event {
17999        crate::aws::Event {
18000            at: Some(chrono::Utc::now()),
18001            env: "uflexi-prod".into(),
18002            application: "uflexi".into(),
18003            message: msg.into(),
18004            severity: "INFO".into(),
18005            version_label: None,
18006        }
18007    }
18008
18009    #[test]
18010    fn previous_version_label_finds_prior_deploy() {
18011        let ev = |vl: Option<&str>| crate::aws::Event {
18012            at: None,
18013            env: "e".into(),
18014            application: "a".into(),
18015            message: String::new(),
18016            severity: "INFO".into(),
18017            version_label: vl.map(String::from),
18018        };
18019        // Newest-first: current build-3, an untagged event, then the
18020        // older deploys. The first label ≠ current is the rollback target.
18021        let events = vec![
18022            ev(Some("build-3")),
18023            ev(None),
18024            ev(Some("build-3")),
18025            ev(Some("build-2")),
18026            ev(Some("build-1")),
18027        ];
18028        assert_eq!(
18029            super::previous_version_label(&events, "build-3"),
18030            Some("build-2".into())
18031        );
18032        // Only the current version (+ untagged) appears → None.
18033        let only_current = vec![ev(Some("build-3")), ev(None), ev(Some("build-3"))];
18034        assert_eq!(
18035            super::previous_version_label(&only_current, "build-3"),
18036            None
18037        );
18038        // No version labels at all → None.
18039        assert_eq!(
18040            super::previous_version_label(&[ev(None), ev(None)], "build-3"),
18041            None
18042        );
18043        // Empty event list → None.
18044        assert_eq!(super::previous_version_label(&[], "build-3"), None);
18045        // Empty-string labels are skipped.
18046        assert_eq!(
18047            super::previous_version_label(&[ev(Some("")), ev(Some("build-1"))], "build-3"),
18048            Some("build-1".into())
18049        );
18050    }
18051
18052    #[test]
18053    fn is_config_event_keeps_deploys_and_config_changes() {
18054        assert!(super::is_config_event(
18055            "Updating environment uflexi-prod to use version label 'build-9'."
18056        ));
18057        assert!(super::is_config_event(
18058            "Deploying new version to instance(s)."
18059        ));
18060        assert!(super::is_config_event(
18061            "Updating environment uflexi-prod's configuration settings."
18062        ));
18063        // Routine health / lifecycle noise is filtered out.
18064        assert!(!super::is_config_event(
18065            "Environment health transitioned from Ok to Severe."
18066        ));
18067        assert!(!super::is_config_event(
18068            "Added instance 'i-abc' to environment."
18069        ));
18070    }
18071
18072    #[test]
18073    fn render_changes_overlay_states() {
18074        let ev = |msg: &str, vl: Option<&str>| crate::aws::Event {
18075            at: None,
18076            env: "e".into(),
18077            application: "a".into(),
18078            message: msg.into(),
18079            severity: "INFO".into(),
18080            version_label: vl.map(String::from),
18081        };
18082        // Only noise → empty-state message.
18083        let noise = vec![ev("Environment health transitioned to Ok.", None)];
18084        assert!(super::render_changes_overlay("prod", &noise).contains("No deploy"));
18085        // A deploy event is kept and its version label shown.
18086        let evs = vec![
18087            ev("Deploying new version to instance(s).", Some("build-9")),
18088            ev("Environment health transitioned to Ok.", None),
18089        ];
18090        let body = super::render_changes_overlay("prod", &evs);
18091        assert!(body.contains("Deploying new version"));
18092        assert!(body.contains("[build-9]"));
18093        assert!(!body.contains("health transitioned"));
18094    }
18095
18096    #[test]
18097    fn build_lineage_collapses_consecutive_same_label_events() {
18098        // EB emits multiple events per deploy (started / instance OK /
18099        // env update completed). `build_lineage` must collapse them
18100        // into one row carrying the full first→last span. Newest-first
18101        // input → newest-first output.
18102        use chrono::TimeZone;
18103        let ts = |y, mo, d, h, mi| chrono::Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap();
18104        let mk = |t, vl: &str| crate::aws::Event {
18105            at: Some(t),
18106            env: "e".into(),
18107            application: "a".into(),
18108            message: "deploy event".into(),
18109            severity: "INFO".into(),
18110            version_label: Some(vl.into()),
18111        };
18112        // 3 events for build-9 (latest deploy) then 2 for build-8.
18113        let evs = vec![
18114            mk(ts(2026, 5, 24, 12, 7), "build-9"),
18115            mk(ts(2026, 5, 24, 12, 5), "build-9"),
18116            mk(ts(2026, 5, 24, 12, 0), "build-9"),
18117            mk(ts(2026, 5, 24, 11, 3), "build-8"),
18118            mk(ts(2026, 5, 24, 11, 0), "build-8"),
18119        ];
18120        let rows = super::build_lineage(&evs);
18121        assert_eq!(rows.len(), 2, "expected 2 distinct deploys, got {rows:?}");
18122        // Newest first: build-9 then build-8.
18123        assert_eq!(rows[0].label, "build-9");
18124        assert_eq!(rows[1].label, "build-8");
18125        // first_at = earliest, last_at = latest within the group.
18126        assert_eq!(rows[0].first_at, Some(ts(2026, 5, 24, 12, 0)));
18127        assert_eq!(rows[0].last_at, Some(ts(2026, 5, 24, 12, 7)));
18128        assert_eq!(rows[1].first_at, Some(ts(2026, 5, 24, 11, 0)));
18129        assert_eq!(rows[1].last_at, Some(ts(2026, 5, 24, 11, 3)));
18130    }
18131
18132    #[test]
18133    fn build_lineage_drops_events_without_version_label() {
18134        // Events without a version_label (routine health transitions,
18135        // scaling notices) must not produce phantom rows.
18136        let ev = |vl: Option<&str>| crate::aws::Event {
18137            at: None,
18138            env: "e".into(),
18139            application: "a".into(),
18140            message: "noise".into(),
18141            severity: "INFO".into(),
18142            version_label: vl.map(String::from),
18143        };
18144        let evs = vec![ev(None), ev(Some("")), ev(None)];
18145        assert!(super::build_lineage(&evs).is_empty());
18146    }
18147
18148    #[test]
18149    fn format_lineage_shows_gap_and_span_between_deploys() {
18150        // Δ-since-previous and `took` lines appear when timestamps
18151        // allow the maths. Empty event window → stub body so the
18152        // operator isn't left wondering whether the fetch failed.
18153        use chrono::TimeZone;
18154        let ts = |h, mi| chrono::Utc.with_ymd_and_hms(2026, 5, 24, h, mi, 0).unwrap();
18155        let mk = |t, vl: &str| crate::aws::Event {
18156            at: Some(t),
18157            env: "e".into(),
18158            application: "a".into(),
18159            message: "deploy event".into(),
18160            severity: "INFO".into(),
18161            version_label: Some(vl.into()),
18162        };
18163        // Empty input → stub.
18164        assert!(super::format_lineage("prod", &[]).contains("No deploys"));
18165        // Two deploys: build-9 12:00→12:05 (took 5m), build-8 at 10:00
18166        // (gap of 2h since previous from build-9's POV).
18167        let evs = vec![
18168            mk(ts(12, 5), "build-9"),
18169            mk(ts(12, 0), "build-9"),
18170            mk(ts(10, 0), "build-8"),
18171        ];
18172        let body = super::format_lineage("prod", &evs);
18173        // Both labels appear, newest first.
18174        let p9 = body.find("build-9").expect("build-9 row");
18175        let p8 = body.find("build-8").expect("build-8 row");
18176        assert!(p9 < p8, "build-9 should come before build-8 (newest first)");
18177        // Span row visible for build-9 (5min).
18178        assert!(
18179            body.contains("took"),
18180            "expected `took` span line, got:\n{body}"
18181        );
18182        // Δ-since-previous visible for build-9 → 2h gap.
18183        assert!(
18184            body.contains("Δ"),
18185            "expected `Δ since previous` line, got:\n{body}"
18186        );
18187    }
18188
18189    #[test]
18190    fn classify_update_kind_deploy_extracts_label() {
18191        let evs = vec![make_event(
18192            "Updating environment uflexi-prod to use version label 'build-142'.",
18193        )];
18194        match super::classify_update_kind(&evs) {
18195            super::UpdateKind::Deploy { version_label } => {
18196                assert_eq!(version_label.as_deref(), Some("build-142"));
18197            }
18198            other => panic!("expected Deploy, got {other:?}"),
18199        }
18200    }
18201
18202    #[test]
18203    fn classify_update_kind_deploy_without_label_still_classifies() {
18204        let evs = vec![make_event("Deploying new version to instance i-abc123.")];
18205        match super::classify_update_kind(&evs) {
18206            super::UpdateKind::Deploy { version_label } => {
18207                // Label can't be extracted from this message shape — that's
18208                // fine, it's still a Deploy.
18209                assert!(version_label.is_none());
18210            }
18211            other => panic!("expected Deploy, got {other:?}"),
18212        }
18213    }
18214
18215    #[test]
18216    fn classify_update_kind_platform_update() {
18217        let evs = vec![make_event(
18218            "Updating environment to use platform 'arn:aws:elasticbeanstalk:…:platform/Corretto 17'.",
18219        )];
18220        // Even though the message also contains 'platform', deploy
18221        // pattern (`version label`) isn't matched, so we fall through
18222        // to the platform branch.
18223        assert_eq!(
18224            super::classify_update_kind(&evs),
18225            super::UpdateKind::Platform
18226        );
18227    }
18228
18229    #[test]
18230    fn classify_update_kind_config_change() {
18231        let evs = vec![make_event("Updating environment configuration completed.")];
18232        assert_eq!(super::classify_update_kind(&evs), super::UpdateKind::Config);
18233    }
18234
18235    #[test]
18236    fn classify_update_kind_scale_event() {
18237        let evs = vec![make_event("Adding instance 'i-abc123' to environment.")];
18238        assert_eq!(super::classify_update_kind(&evs), super::UpdateKind::Scale);
18239    }
18240
18241    #[test]
18242    fn classify_update_kind_unknown_message_falls_through_to_generic() {
18243        let evs = vec![make_event("Something cryptic happened.")];
18244        assert_eq!(
18245            super::classify_update_kind(&evs),
18246            super::UpdateKind::Generic
18247        );
18248    }
18249
18250    #[test]
18251    fn classify_update_kind_picks_most_recent_match() {
18252        // Events are newest-first; the deploy event sits ahead of the
18253        // older scale event, so Deploy wins.
18254        let evs = vec![
18255            make_event("Updating environment to use version label 'build-99'."),
18256            make_event("Adding instance 'i-old' to environment."),
18257        ];
18258        match super::classify_update_kind(&evs) {
18259            super::UpdateKind::Deploy { version_label } => {
18260                assert_eq!(version_label.as_deref(), Some("build-99"));
18261            }
18262            other => panic!("expected Deploy from newest match, got {other:?}"),
18263        }
18264    }
18265
18266    #[test]
18267    fn classify_update_kind_empty_events_is_generic() {
18268        assert_eq!(super::classify_update_kind(&[]), super::UpdateKind::Generic);
18269    }
18270
18271    #[test]
18272    fn compute_red_alerts_counts_eb_red_and_worker_dlq() {
18273        use crate::aws::Environment;
18274        let mk = |name: &str, tier: &str, health: &str| Environment {
18275            name: name.into(),
18276            application: "uflexi".into(),
18277            status: "Ready".into(),
18278            health: health.into(),
18279            platform: "Java 17".into(),
18280            solution_stack: String::new(),
18281            tier: tier.into(),
18282            cname: String::new(),
18283            version_label: String::new(),
18284            arn: None,
18285            updated: None,
18286            id: None,
18287            region: None,
18288        };
18289        let envs = vec![
18290            mk("web-prod", "Web", "Green"),
18291            mk("web-red", "Web", "Red"),
18292            mk("worker-green-dlq", "Worker", "Green"),
18293            mk("worker-clean", "Worker", "Green"),
18294            mk("worker-red", "Worker", "Severe"),
18295        ];
18296        let mut dlq = std::collections::HashMap::new();
18297        dlq.insert("worker-green-dlq".to_string(), 3);
18298        dlq.insert("worker-clean".to_string(), 0);
18299        // EB-Red + DLQ-Red + EB-Red-on-worker = 3 alerts (worker-red counted once).
18300        assert_eq!(super::compute_red_alerts(&envs, &dlq), 3);
18301    }
18302
18303    #[test]
18304    fn compute_red_alerts_ignores_dlq_for_web_tier() {
18305        use crate::aws::Environment;
18306        let env = Environment {
18307            name: "web-prod".into(),
18308            application: "uflexi".into(),
18309            status: "Ready".into(),
18310            health: "Green".into(),
18311            platform: "Java 17".into(),
18312            solution_stack: String::new(),
18313            tier: "Web".into(),
18314            cname: String::new(),
18315            version_label: String::new(),
18316            arn: None,
18317            updated: None,
18318            id: None,
18319            region: None,
18320        };
18321        // Even with a spurious "web-prod" entry in dlq_depths, a Web env
18322        // never counts as DLQ-red. Belt-and-braces against a stale cache
18323        // entry surviving a tier change.
18324        let mut dlq = std::collections::HashMap::new();
18325        dlq.insert("web-prod".to_string(), 99);
18326        assert_eq!(super::compute_red_alerts(&[env], &dlq), 0);
18327    }
18328
18329    #[test]
18330    fn compute_red_alerts_zero_dlq_is_not_alert_worthy() {
18331        use crate::aws::Environment;
18332        let env = Environment {
18333            name: "worker-clean".into(),
18334            application: "uflexi".into(),
18335            status: "Ready".into(),
18336            health: "Green".into(),
18337            platform: "Java 17".into(),
18338            solution_stack: String::new(),
18339            tier: "Worker".into(),
18340            cname: String::new(),
18341            version_label: String::new(),
18342            arn: None,
18343            updated: None,
18344            id: None,
18345            region: None,
18346        };
18347        let mut dlq = std::collections::HashMap::new();
18348        dlq.insert("worker-clean".to_string(), 0);
18349        assert_eq!(super::compute_red_alerts(&[env], &dlq), 0);
18350    }
18351
18352    #[test]
18353    fn redact_for_log_preserves_length_with_block_chars() {
18354        assert_eq!(super::redact_for_log("540847557034", true), "▓".repeat(12));
18355        assert_eq!(super::redact_for_log("540847557034", false), "540847557034");
18356        // Em-dash placeholder + empty stay readable so the context line
18357        // doesn't render `▓` for "no account known yet".
18358        assert_eq!(super::redact_for_log("—", true), "—");
18359        assert_eq!(super::redact_for_log("", true), "");
18360    }
18361
18362    #[test]
18363    fn parse_tag_args_happy_path() {
18364        let v: Vec<&str> = vec!["Owner", "platform-team"];
18365        let (k, v) = super::parse_tag_args(&v).unwrap();
18366        assert_eq!(k, "Owner");
18367        assert_eq!(v, "platform-team");
18368    }
18369
18370    #[test]
18371    fn parse_tag_args_joins_value_tokens_with_spaces() {
18372        let v: Vec<&str> = vec!["Description", "owned", "by", "platform"];
18373        let (k, v) = super::parse_tag_args(&v).unwrap();
18374        assert_eq!(k, "Description");
18375        assert_eq!(v, "owned by platform");
18376    }
18377
18378    #[test]
18379    fn parse_tag_args_rejects_missing_value() {
18380        // Bare key with no value tokens.
18381        let v: Vec<&str> = vec!["Owner"];
18382        assert!(super::parse_tag_args(&v).is_none());
18383        // Empty input.
18384        let v: Vec<&str> = vec![];
18385        assert!(super::parse_tag_args(&v).is_none());
18386    }
18387
18388    #[test]
18389    fn delta_toast_key_returns_none_for_non_delta_text() {
18390        assert_eq!(super::delta_toast_key("refreshing…"), None);
18391        assert_eq!(super::delta_toast_key(""), None);
18392        assert_eq!(super::delta_toast_key("▲"), None);
18393        // Arrow with no count.
18394        assert_eq!(super::delta_toast_key("▲ Red"), None);
18395        // Arrow + count but no bucket word.
18396        assert_eq!(super::delta_toast_key("▲5 "), None);
18397    }
18398
18399    #[test]
18400    fn assign_app_colors_stable_first_appearance() {
18401        use ratatui::style::Color;
18402        let palette = vec![Color::Red, Color::Green, Color::Blue];
18403        let names = ["app-a", "app-b", "app-a", "app-c", "app-b"];
18404        let m = assign_app_colors(names.iter().copied(), &palette);
18405        assert_eq!(m.get("app-a").copied(), Some(Color::Red));
18406        assert_eq!(m.get("app-b").copied(), Some(Color::Green));
18407        assert_eq!(m.get("app-c").copied(), Some(Color::Blue));
18408        assert_eq!(m.len(), 3);
18409    }
18410
18411    #[test]
18412    fn assign_app_colors_wraps_when_palette_exhausted() {
18413        use ratatui::style::Color;
18414        let palette = vec![Color::Red, Color::Green];
18415        let names = ["a", "b", "c", "d"];
18416        let m = assign_app_colors(names.iter().copied(), &palette);
18417        assert_eq!(m.get("a").copied(), Some(Color::Red));
18418        assert_eq!(m.get("b").copied(), Some(Color::Green));
18419        // c wraps back to palette[0]; d to palette[1].
18420        assert_eq!(m.get("c").copied(), Some(Color::Red));
18421        assert_eq!(m.get("d").copied(), Some(Color::Green));
18422    }
18423
18424    #[test]
18425    fn assign_app_colors_empty_palette_yields_empty_map() {
18426        let m = assign_app_colors(["a", "b"].iter().copied(), &[]);
18427        assert!(m.is_empty());
18428    }
18429
18430    #[test]
18431    fn rotate_if_oversize_renames_when_too_big() {
18432        let dir = std::env::temp_dir().join(format!("ebman-rotate-{}", std::process::id()));
18433        let _ = std::fs::create_dir_all(&dir);
18434        let path = dir.join("audit.log");
18435        let backup = dir.join("audit.log.1");
18436        let _ = std::fs::remove_file(&path);
18437        let _ = std::fs::remove_file(&backup);
18438        // Write 100 bytes; rotation threshold = 50.
18439        std::fs::write(&path, vec![b'x'; 100]).unwrap();
18440        rotate_if_oversize(&path, 50);
18441        assert!(!path.exists(), "current file should have been renamed");
18442        assert!(backup.exists(), "rotated backup should now exist");
18443        let _ = std::fs::remove_file(&backup);
18444        let _ = std::fs::remove_dir(&dir);
18445    }
18446
18447    #[test]
18448    fn rotate_if_oversize_leaves_small_files_alone() {
18449        let dir = std::env::temp_dir().join(format!("ebman-rotate-small-{}", std::process::id()));
18450        let _ = std::fs::create_dir_all(&dir);
18451        let path = dir.join("audit.log");
18452        let _ = std::fs::remove_file(&path);
18453        std::fs::write(&path, b"tiny").unwrap();
18454        rotate_if_oversize(&path, 1_000);
18455        assert!(path.exists());
18456        assert!(!dir.join("audit.log.1").exists());
18457        let _ = std::fs::remove_file(&path);
18458        let _ = std::fs::remove_dir(&dir);
18459    }
18460
18461    #[test]
18462    fn event_time_format_cycles_utc_local_age() {
18463        let f = EventTimeFormat::default();
18464        assert_eq!(f, EventTimeFormat::Utc);
18465        assert_eq!(f.next(), EventTimeFormat::Local);
18466        assert_eq!(f.next().next(), EventTimeFormat::Age);
18467        assert_eq!(f.next().next().next(), EventTimeFormat::Utc);
18468    }
18469
18470    #[test]
18471    fn event_time_format_parse_round_trips() {
18472        for f in [
18473            EventTimeFormat::Utc,
18474            EventTimeFormat::Local,
18475            EventTimeFormat::Age,
18476        ] {
18477            assert_eq!(EventTimeFormat::parse(f.label()), Some(f));
18478        }
18479        // Case-insensitive + the "relative" alias for age.
18480        assert_eq!(EventTimeFormat::parse("UTC"), Some(EventTimeFormat::Utc));
18481        assert_eq!(
18482            EventTimeFormat::parse("relative"),
18483            Some(EventTimeFormat::Age)
18484        );
18485        assert_eq!(EventTimeFormat::parse("nonsense"), None);
18486    }
18487
18488    #[test]
18489    fn shell_quote_passes_safe_chars_unchanged() {
18490        assert_eq!(shell_quote("safe-Name_1.0"), "safe-Name_1.0");
18491        assert_eq!(shell_quote("with space"), "'with space'");
18492        // Single quote escape uses POSIX trick: '\''
18493        assert_eq!(shell_quote("o'clock"), "'o'\\''clock'");
18494    }
18495
18496    #[test]
18497    fn instance_hourly_usd_known_types() {
18498        assert!(instance_hourly_usd("t3.micro").unwrap() > 0.0);
18499        assert!(instance_hourly_usd("m5.large").unwrap() > 0.0);
18500        assert_eq!(instance_hourly_usd("not-a-real-type"), None);
18501    }
18502
18503    #[test]
18504    fn estimate_cost_handles_mixed() {
18505        let mk = |t: &str, az: &str| Instance {
18506            id: "i-1".into(),
18507            health: "Ok".into(),
18508            color: "Green".into(),
18509            causes: vec![],
18510            instance_type: t.into(),
18511            availability_zone: az.into(),
18512            launched_at: None,
18513        };
18514        let instances = vec![
18515            mk("t3.micro", "us-east-1a"),
18516            mk("t3.micro", "us-east-1b"),
18517            mk("unknown-type-xyz", "us-east-1c"),
18518        ];
18519        let (hourly, missing) = estimate_cost(&instances);
18520        assert_eq!(missing, 1);
18521        // Two t3.micro at $0.0104/hr each.
18522        assert!((hourly - 0.0208).abs() < 1e-9);
18523    }
18524
18525    fn fake_env(name: &str, status: &str, health: &str, version: &str) -> Environment {
18526        Environment {
18527            name: name.into(),
18528            application: "my-app".into(),
18529            status: status.into(),
18530            health: health.into(),
18531            platform: "Java 17".into(),
18532            solution_stack: String::new(),
18533            tier: "Web".into(),
18534            cname: format!("{name}.elb.amazonaws.com"),
18535            version_label: version.into(),
18536            arn: None,
18537            updated: None,
18538            id: None,
18539            region: None,
18540        }
18541    }
18542
18543    #[test]
18544    fn palette_score_prefers_label_prefix_then_substring_then_detail() {
18545        // Empty needle returns score 0 for everything.
18546        assert_eq!(palette_score("", "anything", "anything"), Some(0));
18547        // Label prefix → 0.
18548        assert_eq!(palette_score("reg", "region", "switch AWS region"), Some(0));
18549        // Label substring later in string → higher score.
18550        let s_label = palette_score("ion", "region", "switch AWS region").unwrap();
18551        assert!(s_label > 0 && s_label < 1_000);
18552        // Detail-only match is penalised by +1000 vs label.
18553        let s_detail = palette_score("aws", ":region", "switch AWS profile").unwrap();
18554        let s_label_match = palette_score("aws", "aws-thing", "irrelevant").unwrap();
18555        assert!(s_detail >= 1_000);
18556        assert!(s_label_match < s_detail);
18557        // No match → None.
18558        assert_eq!(palette_score("xyzzy", "region", "switch AWS region"), None);
18559    }
18560
18561    #[test]
18562    fn bucket_delta_only_envs_in_both() {
18563        let mut prev = HashMap::new();
18564        prev.insert("a".into(), "Green".into());
18565        prev.insert("b".into(), "Red".into());
18566        prev.insert("c".into(), "Green".into()); // c disappears in next, so dropped from delta
18567        let next = vec![
18568            fake_env("a", "Ready", "Yellow", "v1"), // Green → Yellow: −1 Green, +1 Yellow
18569            fake_env("b", "Ready", "Red", "v1"),    // Red → Red: no change
18570            fake_env("d", "Ready", "Green", "v1"),  // new env: ignored (no prev state)
18571        ];
18572        let delta = bucket_delta(&prev, &next, |e| e.health.clone());
18573        let map: BTreeMap<String, i32> = delta.into_iter().collect();
18574        // Only env `a` transitions: −1 Green, +1 Yellow. b unchanged; c disappeared (ignored); d is new (ignored).
18575        assert_eq!(map.get("Green").copied(), Some(-1));
18576        assert_eq!(map.get("Yellow").copied(), Some(1));
18577        assert_eq!(map.get("Red").copied(), None);
18578    }
18579
18580    #[test]
18581    fn bucket_delta_empty_prev_yields_no_deltas() {
18582        // Regression: when prev_health is cleared (e.g. on context switch),
18583        // the delta against the new env list should produce nothing. Otherwise
18584        // every env shows up as a transition.
18585        let prev = HashMap::new();
18586        let next = vec![
18587            fake_env("a", "Ready", "Green", "v1"),
18588            fake_env("b", "Ready", "Red", "v1"),
18589        ];
18590        let delta = bucket_delta(&prev, &next, |e| e.health.clone());
18591        assert!(
18592            delta.is_empty(),
18593            "expected no deltas with empty prev, got {delta:?}"
18594        );
18595    }
18596
18597    #[test]
18598    fn diff_envs_marks_differing_fields() {
18599        let a = fake_env("prod", "Ready", "Green", "v1");
18600        let b = fake_env("staging", "Updating", "Yellow", "v2");
18601        let out = diff_envs(&a, &b, false);
18602        // Differing fields prefixed by ≠
18603        assert!(out.contains("≠ Status"));
18604        assert!(out.contains("≠ Health"));
18605        assert!(out.contains("≠ Version"));
18606        assert!(out.contains("≠ Name"));
18607        assert!(out.contains("≠ CNAME"));
18608        // Identical fields prefixed by space
18609        assert!(out.contains("  Application"));
18610        assert!(out.contains("  Tier"));
18611        assert!(out.contains("  Platform"));
18612    }
18613
18614    #[test]
18615    fn diff_envs_redacts_cname() {
18616        let a = fake_env("prod", "Ready", "Green", "v1");
18617        let b = fake_env("staging", "Updating", "Yellow", "v2");
18618        let out = diff_envs(&a, &b, true);
18619        // CNAMEs become blocks; the canonical envname-portion shouldn't survive.
18620        assert!(!out.contains("prod.elb.amazonaws.com"));
18621        assert!(out.contains("▓"));
18622    }
18623
18624    #[test]
18625    fn encode_filter_only_view_emits_just_the_filter_part() {
18626        // The encoded form must omit sort/grouped/scope so loading
18627        // doesn't perturb those — `apply_view` "missing fields
18628        // untouched" semantics depend on it.
18629        let encoded = super::encode_filter_only_view("tag:env=prod");
18630        assert_eq!(encoded, "filter=tag:env=prod");
18631        // Empty filter — still emits `filter=` so load semantics
18632        // are consistent (filter clears to empty).
18633        assert_eq!(super::encode_filter_only_view(""), "filter=");
18634    }
18635
18636    #[test]
18637    fn view_filter_value_extracts_filter_or_empty() {
18638        assert_eq!(
18639            super::view_filter_value("filter=tag:env=prod"),
18640            "tag:env=prod"
18641        );
18642        // Filter portion in the middle of a full view.
18643        assert_eq!(
18644            super::view_filter_value("sort=name:asc;filter=tag:env=prod;grouped=false"),
18645            "tag:env=prod",
18646        );
18647        // No filter portion → empty (operator's view that doesn't
18648        // touch the filter).
18649        assert_eq!(super::view_filter_value("sort=name:asc;grouped=true"), "");
18650        // Empty encoded → empty filter.
18651        assert_eq!(super::view_filter_value(""), "");
18652        // Leading whitespace on a part is tolerated (matches the
18653        // tolerant parse in `apply_view`).
18654        assert_eq!(super::view_filter_value("sort=name:asc; filter=foo"), "foo",);
18655    }
18656
18657    #[tokio::test]
18658    async fn cycle_saved_view_wraps_forward_through_saved_views() {
18659        // Three saved views: cycling forward from "dev" → "prod" →
18660        // "staging" → back to "dev". Cycle order follows BTreeMap
18661        // iteration (alphabetical), matching the chip-bar render.
18662        let mut app = test_app();
18663        app.saved_views
18664            .insert("dev".into(), super::encode_filter_only_view("tag:env=dev"));
18665        app.saved_views.insert(
18666            "prod".into(),
18667            super::encode_filter_only_view("tag:env=prod"),
18668        );
18669        app.saved_views.insert(
18670            "staging".into(),
18671            super::encode_filter_only_view("tag:env=staging"),
18672        );
18673        // Start on "dev".
18674        app.filter = "tag:env=dev".into();
18675        app.cycle_saved_view(1);
18676        assert_eq!(app.filter, "tag:env=prod");
18677        app.cycle_saved_view(1);
18678        assert_eq!(app.filter, "tag:env=staging");
18679        // Wraps back to first.
18680        app.cycle_saved_view(1);
18681        assert_eq!(app.filter, "tag:env=dev");
18682    }
18683
18684    #[tokio::test]
18685    async fn cycle_saved_view_wraps_backward_and_handles_no_active() {
18686        // Backward from "dev" wraps to "staging" (last in sort).
18687        let mut app = test_app();
18688        app.saved_views
18689            .insert("dev".into(), super::encode_filter_only_view("tag:env=dev"));
18690        app.saved_views.insert(
18691            "staging".into(),
18692            super::encode_filter_only_view("tag:env=staging"),
18693        );
18694        app.filter = "tag:env=dev".into();
18695        app.cycle_saved_view(-1);
18696        assert_eq!(app.filter, "tag:env=staging");
18697        // No active filter (freeform or empty) → forward goes to first,
18698        // backward goes to last.
18699        app.filter = "some-random-text".into();
18700        app.cycle_saved_view(1);
18701        assert_eq!(app.filter, "tag:env=dev", "forward-with-no-active → first");
18702        app.filter = "some-random-text".into();
18703        app.cycle_saved_view(-1);
18704        assert_eq!(
18705            app.filter, "tag:env=staging",
18706            "backward-with-no-active → last"
18707        );
18708    }
18709
18710    #[tokio::test]
18711    async fn cycle_saved_view_noop_with_empty_views() {
18712        // Cycling when there are no saved views shouldn't crash or
18713        // mutate state. The keybind guard already short-circuits, but
18714        // the method itself is the actual safety net.
18715        let mut app = test_app();
18716        app.filter = "keep-me".into();
18717        app.cycle_saved_view(1);
18718        assert_eq!(app.filter, "keep-me");
18719    }
18720
18721    #[tokio::test]
18722    async fn cycle_saved_view_with_full_view_applies_sort_and_group_too() {
18723        // The point of unifying named_filters into saved_views: a
18724        // full view's encoded payload changes sort + group + scope
18725        // alongside the filter. This is the gh-dash-style "tabs"
18726        // behavior the BACKLOG had been promising since 2026-05-24.
18727        let mut app = test_app();
18728        // Filter-only view (from :save).
18729        app.saved_views
18730            .insert("dev".into(), super::encode_filter_only_view("tag:env=dev"));
18731        // Full view (from :save-view) — flips sort to App + groups.
18732        app.saved_views.insert(
18733            "by-app".into(),
18734            "filter=tag:env=prod;sort=app:asc;grouped=true;scope=envs".into(),
18735        );
18736        app.filter = "tag:env=dev".into();
18737        app.grouped = false;
18738        app.cycle_saved_view(1); // dev → by-app
18739        assert_eq!(app.filter, "tag:env=prod");
18740        assert!(
18741            app.grouped,
18742            "full view must apply its grouped=true alongside the filter"
18743        );
18744    }
18745
18746    #[tokio::test]
18747    async fn ssh_with_instance_id_arg_queues_pending_shell_target() {
18748        // `:ssh i-abc` is the direct path: just stages the target and
18749        // lets the main-loop tick pick it up. No picker, no fetch.
18750        let mut app = test_app();
18751        app.execute_command("ssh i-0abc1234567890def");
18752        assert_eq!(
18753            app.pending_shell_target.as_deref(),
18754            Some("i-0abc1234567890def")
18755        );
18756        assert!(
18757            app.error_message.is_none(),
18758            "unexpected: {:?}",
18759            app.error_message
18760        );
18761        assert!(
18762            app.mode == Mode::Normal,
18763            "ssh-with-arg should not change mode"
18764        );
18765    }
18766
18767    #[tokio::test]
18768    async fn ssh_rejects_non_instance_id_arg() {
18769        // A typo'd arg ("staging") looks like an env name, not an EC2 ID.
18770        // Better to refuse than to attempt an SSM session against
18771        // garbage and get an opaque CLI error.
18772        let mut app = test_app();
18773        app.execute_command("ssh staging-web");
18774        assert!(app.pending_shell_target.is_none());
18775        let err = app.error_message.as_deref().unwrap_or("");
18776        assert!(
18777            err.contains("instance ID") && err.contains("staging-web"),
18778            "expected guidance + offending value, got: {err}"
18779        );
18780    }
18781
18782    #[tokio::test]
18783    async fn ssh_no_arg_without_detail_errors_clearly() {
18784        // Without an arg and without Detail/Instances loaded, there's
18785        // nothing to populate the picker with. Surface that the
18786        // operator either needs to open Detail or pass an ID — don't
18787        // silently no-op.
18788        let mut app = test_app();
18789        app.execute_command("ssh");
18790        assert!(app.picker.is_none());
18791        let err = app.error_message.as_deref().unwrap_or("");
18792        assert!(
18793            err.contains("Detail") || err.contains("instance ID"),
18794            "expected guidance about Detail/Instances or instance ID, got: {err}"
18795        );
18796    }
18797
18798    #[test]
18799    fn deploy_snapshot_round_trips_through_persisted_form() {
18800        // Capture → serialize → parse must produce a snapshot equal
18801        // (up to chrono precision) to the original. The pipe separator
18802        // doesn't collide with any legal version-label character.
18803        use chrono::TimeZone;
18804        let original = DeploySnapshot {
18805            env_name: "prod-api".into(),
18806            previous_version_label: "build-825".into(),
18807            taken_at: chrono::Utc
18808                .with_ymd_and_hms(2026, 5, 25, 14, 30, 0)
18809                .unwrap(),
18810        };
18811        let raw = original.to_persisted();
18812        assert_eq!(raw, "build-825|2026-05-25T14:30:00+00:00");
18813        let parsed = DeploySnapshot::parse_persisted("prod-api", &raw).expect("parses");
18814        assert_eq!(parsed.env_name, original.env_name);
18815        assert_eq!(
18816            parsed.previous_version_label,
18817            original.previous_version_label
18818        );
18819        assert_eq!(parsed.taken_at, original.taken_at);
18820    }
18821
18822    #[test]
18823    fn deploy_snapshot_parse_persisted_rejects_garbage() {
18824        // No pipe, missing timestamp, malformed RFC3339 — all return
18825        // None so the App-init loop silently drops bad lines rather
18826        // than aborting startup.
18827        assert!(DeploySnapshot::parse_persisted("e", "nopipe").is_none());
18828        assert!(DeploySnapshot::parse_persisted("e", "|2026-05-25T14:30:00Z").is_none());
18829        assert!(DeploySnapshot::parse_persisted("e", "label|not-a-timestamp").is_none());
18830        assert!(DeploySnapshot::parse_persisted("e", "label|").is_none());
18831    }
18832
18833    #[tokio::test]
18834    async fn rebuild_clears_armed_watchdogs_and_snapshots() {
18835        // Operator arms an auto-rollback in account=A region=us-east-1
18836        // then switches to account=B / different region. The deadline
18837        // tokio task survives (no JoinHandle for cancellation), but
18838        // its late `AutoRollbackCheck` must not act on a same-named
18839        // env in the new context — apply_rebuild clears both the
18840        // armed_watchdogs slot AND the deploy_snapshot so a stale
18841        // deadline message can't trigger a spurious rollback in the
18842        // wrong account/region.
18843        let mut app = test_app();
18844        let now = chrono::Utc::now();
18845        app.armed_watchdogs.insert(
18846            "prod".into(),
18847            ArmedWatchdog {
18848                env_name: "prod".into(),
18849                target_label: "build-old".into(),
18850                armed_at: now,
18851                deadline_at: now + chrono::Duration::seconds(300),
18852            },
18853        );
18854        app.deploy_snapshots.insert(
18855            "prod".into(),
18856            DeploySnapshot {
18857                env_name: "prod".into(),
18858                previous_version_label: "build-old".into(),
18859                taken_at: now,
18860            },
18861        );
18862        // Simulate a context switch — apply_rebuild Ok-path drops
18863        // context-scoped state including armed_watchdogs +
18864        // deploy_snapshots. Use a stub client so the call doesn't
18865        // need real AWS.
18866        app.apply_rebuild(Ok(Box::new(crate::aws::AwsClient::stub())));
18867        assert!(
18868            app.armed_watchdogs.is_empty(),
18869            "context switch should drop armed watchdogs"
18870        );
18871        assert!(
18872            app.deploy_snapshots.is_empty(),
18873            "context switch should drop deploy snapshots"
18874        );
18875    }
18876
18877    #[tokio::test]
18878    async fn rollback_to_label_opens_confirm_for_named_label() {
18879        // `:rollback --to LABEL` skips the snapshot+event-scan
18880        // detection and routes straight to the deploy confirm. Pins
18881        // that the operator's explicit choice wins over any captured
18882        // snapshot.
18883        let mut app = test_app();
18884        app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
18885        app.rebuild_view();
18886        app.table_state.select(Some(0));
18887        // Snapshot exists with a DIFFERENT label; --to must override.
18888        app.deploy_snapshots.insert(
18889            "prod".into(),
18890            DeploySnapshot {
18891                env_name: "prod".into(),
18892                previous_version_label: "build-snap".into(),
18893                taken_at: chrono::Utc::now(),
18894            },
18895        );
18896        app.execute_command("rollback --to build-820");
18897        // Confirm modal opened with the operator-named label.
18898        match &app.action_flow {
18899            Some(ActionFlow::Confirm(modal)) => {
18900                assert_eq!(modal.deploy_version.as_deref(), Some("build-820"));
18901                // No watchdog when --auto-rollback wasn't passed.
18902                assert!(modal.auto_rollback_secs.is_none());
18903            }
18904            _ => panic!("expected confirm modal open"),
18905        }
18906    }
18907
18908    #[tokio::test]
18909    async fn rollback_to_label_with_auto_rollback_threads_secs_through() {
18910        // `:rollback --to LABEL --auto-rollback 5m` composes:
18911        // confirm modal carries both the label AND the watchdog
18912        // duration, so the operator can roll back AND arm a
18913        // roll-forward in one dispatch.
18914        let mut app = test_app();
18915        app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
18916        app.rebuild_view();
18917        app.table_state.select(Some(0));
18918        app.execute_command("rollback --to build-820 --auto-rollback 5m");
18919        match &app.action_flow {
18920            Some(ActionFlow::Confirm(modal)) => {
18921                assert_eq!(modal.deploy_version.as_deref(), Some("build-820"));
18922                assert_eq!(modal.auto_rollback_secs, Some(300));
18923            }
18924            _ => panic!("expected confirm modal open"),
18925        }
18926    }
18927
18928    #[tokio::test]
18929    async fn rollback_to_same_label_as_deployed_refuses() {
18930        // Typo / stale arg — operator passed the version already
18931        // running. Surface a clear error rather than dispatching a
18932        // no-op deploy.
18933        let mut app = test_app();
18934        let mut env = mk_env("prod", "shop", "Web", "Red");
18935        env.version_label = "build-822".into();
18936        app.environments = vec![env];
18937        app.rebuild_view();
18938        app.table_state.select(Some(0));
18939        app.execute_command("rollback --to build-822");
18940        let err = app.error_message.as_deref().unwrap_or("");
18941        assert!(
18942            err.contains("already the deployed version"),
18943            "expected idempotent guard, got: {err}"
18944        );
18945        assert!(app.action_flow.is_none(), "no confirm modal on no-op");
18946    }
18947
18948    #[tokio::test]
18949    async fn rollback_auto_rollback_without_snapshot_errors_clearly() {
18950        // Operator asked for a watchdog but there's no snapshot to
18951        // arm against and they didn't pass --to. The event-scan
18952        // fallback path doesn't currently thread auto_rollback_secs,
18953        // so surface a hint pointing at `--to LABEL` rather than
18954        // silently dropping the flag.
18955        let mut app = test_app();
18956        app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
18957        app.rebuild_view();
18958        app.table_state.select(Some(0));
18959        // deploy_snapshots intentionally empty.
18960        app.execute_command("rollback --auto-rollback 5m");
18961        let err = app.error_message.as_deref().unwrap_or("");
18962        assert!(
18963            err.contains("needs an in-memory snapshot") && err.contains("--to LABEL"),
18964            "expected refusal + hint, got: {err}"
18965        );
18966    }
18967
18968    #[tokio::test]
18969    async fn deploy_wait_for_green_threads_secs_through_to_modal() {
18970        // `:deploy LABEL --wait-for-green 5m` carries the duration
18971        // into the ConfirmModal where spawn_action picks it up.
18972        // No watcher is armed until the operator confirms — that's
18973        // tested separately at the spawn_action layer.
18974        let mut app = test_app();
18975        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
18976        app.rebuild_view();
18977        app.table_state.select(Some(0));
18978        app.execute_command("deploy build-900 --wait-for-green 5m");
18979        match &app.action_flow {
18980            Some(ActionFlow::Confirm(modal)) => {
18981                assert_eq!(modal.deploy_version.as_deref(), Some("build-900"));
18982                assert_eq!(modal.wait_for_green_secs, Some(300));
18983                assert!(modal.auto_rollback_secs.is_none());
18984            }
18985            _ => panic!("expected confirm modal open"),
18986        }
18987    }
18988
18989    #[tokio::test]
18990    async fn deploy_wait_for_green_rejects_malformed_duration() {
18991        // Same friendly-error pattern as `--auto-rollback`. `forever`
18992        // isn't parseable, so refuse rather than silently dropping
18993        // the flag.
18994        let mut app = test_app();
18995        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
18996        app.rebuild_view();
18997        app.table_state.select(Some(0));
18998        app.execute_command("deploy build-900 --wait-for-green forever");
18999        let err = app.error_message.as_deref().unwrap_or("");
19000        assert!(
19001            err.contains("--wait-for-green") && err.contains("duration"),
19002            "expected parse refusal, got: {err}"
19003        );
19004        assert!(
19005            app.action_flow.is_none(),
19006            "no modal should open on malformed duration"
19007        );
19008    }
19009
19010    #[tokio::test]
19011    async fn deploy_with_both_flags_threads_both_through() {
19012        // Operator wants both: "watch for Green, and roll back if it
19013        // doesn't land". Modal carries both fields independently;
19014        // spawn_action registers in both maps.
19015        let mut app = test_app();
19016        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
19017        app.rebuild_view();
19018        app.table_state.select(Some(0));
19019        app.execute_command("deploy build-900 --auto-rollback 10m --wait-for-green 5m");
19020        match &app.action_flow {
19021            Some(ActionFlow::Confirm(modal)) => {
19022                assert_eq!(modal.deploy_version.as_deref(), Some("build-900"));
19023                assert_eq!(modal.auto_rollback_secs, Some(600));
19024                assert_eq!(modal.wait_for_green_secs, Some(300));
19025            }
19026            _ => panic!("expected confirm modal open"),
19027        }
19028    }
19029
19030    #[tokio::test]
19031    async fn apply_refresh_keeps_watching_when_status_is_updating_even_if_health_is_green() {
19032        // Regression: EB leaves health=Green briefly while status
19033        // flips to Updating right after UpdateEnvironment. The
19034        // watcher must NOT report success during that window —
19035        // otherwise the operator gets a false "✓ deploy reached
19036        // Green" pin before the deploy has actually started.
19037        let mut app = test_app();
19038        let now = chrono::Utc::now();
19039        app.watching_deploys.insert(
19040            "prod".into(),
19041            WatchingDeploy {
19042                env_name: "prod".into(),
19043                target_label: "build-900".into(),
19044                armed_at: now,
19045                deadline_at: now + chrono::Duration::seconds(300),
19046            },
19047        );
19048        let mut env = mk_env("prod", "shop", "Web", "Green");
19049        env.status = "Updating".into();
19050        app.apply_refresh(Ok(vec![env]));
19051        assert!(
19052            app.watching_deploys.contains_key("prod"),
19053            "Updating+Green is mid-deploy — watcher must remain armed"
19054        );
19055        let pinned = app.status_message.as_deref().unwrap_or("");
19056        assert!(
19057            !pinned.contains("reached Green"),
19058            "must not pin success during Updating, got: {pinned:?}"
19059        );
19060    }
19061
19062    #[tokio::test]
19063    async fn apply_refresh_keeps_armed_watchdog_when_status_is_updating_even_if_health_is_green() {
19064        // Same regression for the auto-rollback watchdog: a brief
19065        // Updating+Green window must not disarm the watchdog —
19066        // otherwise the rollback safety net evaporates before the
19067        // deploy has actually rolled.
19068        let mut app = test_app();
19069        let now = chrono::Utc::now();
19070        app.deploy_snapshots.insert(
19071            "prod".into(),
19072            DeploySnapshot {
19073                env_name: "prod".into(),
19074                previous_version_label: "build-820".into(),
19075                taken_at: now,
19076            },
19077        );
19078        app.armed_watchdogs.insert(
19079            "prod".into(),
19080            ArmedWatchdog {
19081                env_name: "prod".into(),
19082                target_label: "build-820".into(),
19083                armed_at: now,
19084                deadline_at: now + chrono::Duration::seconds(300),
19085            },
19086        );
19087        let mut env = mk_env("prod", "shop", "Web", "Green");
19088        env.status = "Updating".into();
19089        app.apply_refresh(Ok(vec![env]));
19090        assert!(
19091            app.armed_watchdogs.contains_key("prod"),
19092            "Updating+Green is mid-deploy — watchdog must remain armed"
19093        );
19094    }
19095
19096    #[test]
19097    fn deploy_settled_green_requires_both_status_ready_and_health_green_or_ok() {
19098        assert!(super::deploy_settled_green("Ready", "Green"));
19099        assert!(super::deploy_settled_green("Ready", "Ok"));
19100        assert!(super::deploy_settled_green("ready", "green")); // case-insensitive
19101        assert!(super::deploy_settled_green("READY", "OK"));
19102        // Status mismatch — false even if health is Green.
19103        assert!(!super::deploy_settled_green("Updating", "Green"));
19104        assert!(!super::deploy_settled_green("Launching", "Ok"));
19105        assert!(!super::deploy_settled_green("Terminating", "Green"));
19106        // Health mismatch — false even if status is Ready.
19107        assert!(!super::deploy_settled_green("Ready", "Red"));
19108        assert!(!super::deploy_settled_green("Ready", "Yellow"));
19109        assert!(!super::deploy_settled_green("Ready", "Severe"));
19110        // Both wrong.
19111        assert!(!super::deploy_settled_green("", ""));
19112    }
19113
19114    #[test]
19115    fn build_health_check_probe_url_normalises_path() {
19116        // Path starting with `/` passes through.
19117        assert_eq!(
19118            super::build_health_check_probe_url("api.example.com", "/healthz"),
19119            "http://api.example.com/healthz"
19120        );
19121        // Path without leading `/` gets one prepended (covers operators
19122        // who configure `healthz` rather than `/healthz` in EB).
19123        assert_eq!(
19124            super::build_health_check_probe_url("api.example.com", "healthz"),
19125            "http://api.example.com/healthz"
19126        );
19127        // Empty path collapses to `/` — EB's default health check.
19128        assert_eq!(
19129            super::build_health_check_probe_url("api.example.com", ""),
19130            "http://api.example.com/"
19131        );
19132        // Root path round-trips.
19133        assert_eq!(
19134            super::build_health_check_probe_url("api.example.com", "/"),
19135            "http://api.example.com/"
19136        );
19137    }
19138
19139    #[test]
19140    fn classify_health_check_status_treats_2xx_as_ok_and_others_as_warning() {
19141        // 2xx range — all clear.
19142        assert!(super::classify_health_check_status(200).is_ok());
19143        assert!(super::classify_health_check_status(201).is_ok());
19144        assert!(super::classify_health_check_status(299).is_ok());
19145        // 0 means curl couldn't even connect.
19146        let err = super::classify_health_check_status(0).unwrap_err();
19147        assert!(err.contains("no response"));
19148        // 3xx still warns because we already pass -L to curl;
19149        // seeing a redirect in the final code means a loop.
19150        let err = super::classify_health_check_status(301).unwrap_err();
19151        assert!(err.contains("301"));
19152        // 404 — the canonical auto-rollback footgun.
19153        let err = super::classify_health_check_status(404).unwrap_err();
19154        assert!(err.contains("404"));
19155        // 5xx — server is up but failing.
19156        let err = super::classify_health_check_status(503).unwrap_err();
19157        assert!(err.contains("503"));
19158    }
19159
19160    #[test]
19161    fn compute_unavailability_count_per_policy() {
19162        // AllAtOnce — every instance flips at once.
19163        assert_eq!(
19164            super::compute_unavailability_count("AllAtOnce", 1, "Fixed", 4),
19165            4
19166        );
19167        // Rolling, fixed batch of 1 on 4 instances → 1 unavailable.
19168        assert_eq!(
19169            super::compute_unavailability_count("Rolling", 1, "Fixed", 4),
19170            1
19171        );
19172        // Rolling, fixed batch of 2 on 4 → 2.
19173        assert_eq!(
19174            super::compute_unavailability_count("Rolling", 2, "Fixed", 4),
19175            2
19176        );
19177        // Rolling, 50% on 4 → 2.
19178        assert_eq!(
19179            super::compute_unavailability_count("Rolling", 50, "Percentage", 4),
19180            2
19181        );
19182        // Rolling, 33% on 4 → ceil(1.32) = 2.
19183        assert_eq!(
19184            super::compute_unavailability_count("Rolling", 33, "Percentage", 4),
19185            2
19186        );
19187        // RollingWithAdditionalBatch — extra batch first, zero impact.
19188        assert_eq!(
19189            super::compute_unavailability_count("RollingWithAdditionalBatch", 1, "Fixed", 4),
19190            0
19191        );
19192        // Immutable + TrafficSplitting — new fleet, zero impact.
19193        assert_eq!(
19194            super::compute_unavailability_count("Immutable", 1, "Fixed", 4),
19195            0
19196        );
19197        assert_eq!(
19198            super::compute_unavailability_count("TrafficSplitting", 1, "Fixed", 4),
19199            0
19200        );
19201        // Unknown policy → assume worst case rather than lulling
19202        // the operator with a false zero.
19203        assert_eq!(
19204            super::compute_unavailability_count("WeirdCustomPolicy", 1, "Fixed", 4),
19205            4
19206        );
19207        // Case-insensitive (EB API can return mixed casing).
19208        assert_eq!(
19209            super::compute_unavailability_count("allatonce", 1, "Fixed", 4),
19210            4
19211        );
19212    }
19213
19214    #[test]
19215    fn compute_batch_count_clamps_and_rounds_up() {
19216        // Fixed clamps to [1, max].
19217        assert_eq!(super::compute_batch_count(0, "Fixed", 4), 1);
19218        assert_eq!(super::compute_batch_count(10, "Fixed", 4), 4);
19219        assert_eq!(super::compute_batch_count(2, "Fixed", 4), 2);
19220        // Percentage rounds up.
19221        assert_eq!(super::compute_batch_count(33, "Percentage", 4), 2); // ceil(1.32)=2
19222        assert_eq!(super::compute_batch_count(25, "Percentage", 4), 1);
19223        assert_eq!(super::compute_batch_count(26, "Percentage", 4), 2); // ceil(1.04)=2
19224        assert_eq!(super::compute_batch_count(100, "Percentage", 4), 4);
19225        // Out-of-range percentage clamps.
19226        assert_eq!(super::compute_batch_count(0, "Percentage", 4), 1);
19227        assert_eq!(super::compute_batch_count(200, "Percentage", 4), 4);
19228    }
19229
19230    #[test]
19231    fn format_unavailability_line_distinguishes_zero_from_partial_from_full() {
19232        let (text, caution) = super::format_unavailability_line("Immutable", 0, 4);
19233        assert!(text.contains("no in-service unavailability"));
19234        assert!(!caution);
19235        let (text, caution) = super::format_unavailability_line("Rolling", 1, 4);
19236        assert!(text.contains("max 1/4 instance unavailable"));
19237        assert!(caution);
19238        let (text, caution) = super::format_unavailability_line("AllAtOnce", 4, 4);
19239        assert!(text.contains("max 4/4 instances unavailable"));
19240        assert!(caution);
19241    }
19242
19243    #[test]
19244    fn extract_unavailability_inputs_uses_eb_defaults_on_missing_settings() {
19245        // Empty option-settings — defaults match what EB itself
19246        // uses when no explicit value is configured.
19247        let (policy, batch, btype, asg) = super::extract_unavailability_inputs(&[]);
19248        assert_eq!(policy, "AllAtOnce");
19249        assert_eq!(batch, 1);
19250        assert_eq!(btype, "Fixed");
19251        assert_eq!(asg, 1);
19252
19253        // Partial — operator only set MaxSize.
19254        let opts = vec![("aws:autoscaling:asg".into(), "MaxSize".into(), "6".into())];
19255        let (_, _, _, asg) = super::extract_unavailability_inputs(&opts);
19256        assert_eq!(asg, 6);
19257
19258        // Empty string values collapse to default rather than the
19259        // empty string being mistaken for a policy.
19260        let opts = vec![(
19261            "aws:elasticbeanstalk:command".into(),
19262            "DeploymentPolicy".into(),
19263            String::new(),
19264        )];
19265        let (policy, _, _, _) = super::extract_unavailability_inputs(&opts);
19266        assert_eq!(policy, "AllAtOnce");
19267    }
19268
19269    #[tokio::test]
19270    async fn handle_unavailability_estimate_stuffs_line_into_modal() {
19271        let mut app = test_app();
19272        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
19273        app.rebuild_view();
19274        app.table_state.select(Some(0));
19275        app.execute_command("deploy build-900");
19276        app.handle_msg(AppMsg::UnavailabilityEstimate {
19277            gen: app.generation,
19278            env_name: "prod".into(),
19279            line: Some((
19280                "deploy plan: Rolling → max 1/4 instance unavailable".into(),
19281                true,
19282            )),
19283        });
19284        match &app.action_flow {
19285            Some(ActionFlow::Confirm(modal)) => {
19286                assert!(!modal.loading_unavailability);
19287                let (text, caution) = modal.unavailability_line.as_ref().unwrap();
19288                assert!(text.contains("max 1/4"));
19289                assert!(*caution);
19290            }
19291            _ => panic!("expected confirm modal"),
19292        }
19293    }
19294
19295    #[test]
19296    fn build_undo_entry_set_with_prior_value_reverses_to_set() {
19297        // Original write: set EC2KeyName=foo. Prior value: bar.
19298        // Reverse: set EC2KeyName=bar.
19299        let pre = vec![(
19300            "aws:autoscaling:launchconfiguration".into(),
19301            "EC2KeyName".into(),
19302            "bar".into(),
19303        )];
19304        let to_set = vec![(
19305            "aws:autoscaling:launchconfiguration".into(),
19306            "EC2KeyName".into(),
19307            "foo".into(),
19308        )];
19309        let entry = super::build_undo_entry("prod", "keypair foo", &to_set, &[], &pre);
19310        assert_eq!(entry.to_set.len(), 1);
19311        assert_eq!(entry.to_set[0].2, "bar");
19312        assert!(entry.to_remove.is_empty());
19313        assert_eq!(entry.env_name, "prod");
19314        assert_eq!(entry.original_summary, "keypair foo");
19315    }
19316
19317    #[test]
19318    fn build_undo_entry_set_with_no_prior_value_reverses_to_remove() {
19319        // Original write: set a key that was previously unset.
19320        // Reverse: remove the key (don't leave it as "" — that's a
19321        // different EB state from "unset").
19322        let pre: Vec<(String, String, String)> = vec![];
19323        let to_set = vec![(
19324            "aws:elasticbeanstalk:application".into(),
19325            "Application Healthcheck URL".into(),
19326            "/healthz".into(),
19327        )];
19328        let entry =
19329            super::build_undo_entry("prod", "health-check-url /healthz", &to_set, &[], &pre);
19330        assert!(entry.to_set.is_empty());
19331        assert_eq!(entry.to_remove.len(), 1);
19332        assert_eq!(entry.to_remove[0].1, "Application Healthcheck URL");
19333    }
19334
19335    #[test]
19336    fn build_undo_entry_empty_string_prior_treated_as_unset() {
19337        // EB doesn't distinguish "unset" from "set-to-empty"; we
19338        // treat empty-string-prior as unset and reverse via remove.
19339        let pre = vec![(
19340            "aws:autoscaling:launchconfiguration".into(),
19341            "EC2KeyName".into(),
19342            String::new(),
19343        )];
19344        let to_set = vec![(
19345            "aws:autoscaling:launchconfiguration".into(),
19346            "EC2KeyName".into(),
19347            "foo".into(),
19348        )];
19349        let entry = super::build_undo_entry("prod", "keypair foo", &to_set, &[], &pre);
19350        assert!(entry.to_set.is_empty());
19351        assert_eq!(entry.to_remove.len(), 1);
19352    }
19353
19354    #[test]
19355    fn build_undo_entry_remove_with_prior_value_reverses_to_set() {
19356        // Original write: remove a key that had a value. Reverse:
19357        // restore the value via to_set.
19358        let pre = vec![(
19359            "aws:autoscaling:launchconfiguration".into(),
19360            "EC2KeyName".into(),
19361            "bar".into(),
19362        )];
19363        let to_remove = vec![(
19364            "aws:autoscaling:launchconfiguration".into(),
19365            "EC2KeyName".into(),
19366        )];
19367        let entry = super::build_undo_entry("prod", "clear keypair", &[], &to_remove, &pre);
19368        assert_eq!(entry.to_set.len(), 1);
19369        assert_eq!(entry.to_set[0].2, "bar");
19370        assert!(entry.to_remove.is_empty());
19371    }
19372
19373    #[test]
19374    fn build_undo_entry_remove_with_no_prior_value_is_a_noop_reverse() {
19375        // Original: remove a key that was already absent. Reverse:
19376        // nothing (both sides empty).
19377        let entry = super::build_undo_entry(
19378            "prod",
19379            "clear keypair",
19380            &[],
19381            &[(
19382                "aws:autoscaling:launchconfiguration".into(),
19383                "EC2KeyName".into(),
19384            )],
19385            &[],
19386        );
19387        assert!(entry.to_set.is_empty());
19388        assert!(entry.to_remove.is_empty());
19389    }
19390
19391    #[tokio::test]
19392    async fn batch_set_option_skips_envs_no_longer_in_view() {
19393        // Race: operator multi-selects envs A + B, fires
19394        // :batch-set-option, then context switches before the
19395        // batch loop reaches B. spawn_batch_set_option must skip
19396        // (audit-log only) the env that's no longer in the
19397        // cached fleet rather than dispatching a write against a
19398        // stale name. Without this guard, the write fails at AWS
19399        // with a confusing error.
19400        let mut app = test_app();
19401        app.environments = vec![mk_env("prod-api", "shop", "Web", "Green")];
19402        app.rebuild_view();
19403        // Dispatch against an env that's NOT in self.environments.
19404        app.spawn_batch_set_option(
19405            "vanished".into(),
19406            "aws:elasticbeanstalk:application".into(),
19407            "Application Healthcheck URL".into(),
19408            "/healthz".into(),
19409        );
19410        // pending_actions should be empty — the write was skipped.
19411        assert!(
19412            app.pending_actions.iter().all(|p| p.target != "vanished"),
19413            "expected no pending action for vanished env"
19414        );
19415    }
19416
19417    #[tokio::test]
19418    async fn handle_undo_captured_pushes_into_history_with_cap() {
19419        // Pushing UNDO_HISTORY_CAP + 2 entries leaves CAP-many in
19420        // the deque, with the OLDEST entries evicted from the
19421        // front. Confirms the ring-buffer eviction logic.
19422        let mut app = test_app();
19423        for i in 0..(super::UNDO_HISTORY_CAP + 2) {
19424            let entry = super::UndoEntry {
19425                env_name: "prod".into(),
19426                to_set: vec![("ns".into(), format!("k{i}"), "v".into())],
19427                to_remove: vec![],
19428                original_summary: format!("write #{i}"),
19429                captured_at: chrono::Utc::now(),
19430            };
19431            app.handle_msg(AppMsg::UndoCaptured {
19432                gen: app.generation,
19433                entry,
19434            });
19435        }
19436        assert_eq!(app.undo_history.len(), super::UNDO_HISTORY_CAP);
19437        // The two oldest (#0, #1) should have been evicted; #2
19438        // becomes the front-most surviving entry.
19439        assert_eq!(
19440            app.undo_history.front().unwrap().original_summary,
19441            "write #2"
19442        );
19443        // Back of the deque is the most-recent push.
19444        assert_eq!(
19445            app.undo_history.back().unwrap().original_summary,
19446            format!("write #{}", super::UNDO_HISTORY_CAP + 1)
19447        );
19448    }
19449
19450    #[tokio::test]
19451    async fn cmd_undo_with_empty_history_hints_at_the_buffer() {
19452        let mut app = test_app();
19453        app.execute_command("undo");
19454        let status = app.status_message.as_deref().unwrap_or("");
19455        assert!(
19456            status.contains("no undo history"),
19457            "expected empty-history hint, got: {status}"
19458        );
19459    }
19460
19461    #[tokio::test]
19462    async fn cmd_undo_with_no_op_reverse_surfaces_clearly() {
19463        // Reverse-action with both sides empty (e.g. write matched
19464        // prior state exactly) yields a friendly status rather than
19465        // a silent dispatch of a no-op write.
19466        let mut app = test_app();
19467        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
19468        app.rebuild_view();
19469        app.undo_history.push_back(super::UndoEntry {
19470            env_name: "prod".into(),
19471            to_set: vec![],
19472            to_remove: vec![],
19473            original_summary: "keypair foo".into(),
19474            captured_at: chrono::Utc::now(),
19475        });
19476        app.execute_command("undo");
19477        let status = app.status_message.as_deref().unwrap_or("");
19478        assert!(
19479            status.contains("prior state was identical"),
19480            "expected no-op hint, got: {status}"
19481        );
19482    }
19483
19484    #[tokio::test]
19485    async fn cmd_undo_uses_display_row_index_not_envs_vec_index() {
19486        // Regression: when a filter is active, `selected_env()`
19487        // reads from `display_rows()` not `environments`. The
19488        // earlier cut of `cmd_undo` set `table_state` using the
19489        // env-vec position, which targets the wrong row in a
19490        // filtered view. This test pins that the dispatch reaches
19491        // the right env after filtering shrinks the visible set.
19492        let mut app = test_app();
19493        let mut prod_api = mk_env("prod-api", "shop", "Web", "Green");
19494        prod_api.application = "shop".into();
19495        let mut staging_api = mk_env("staging-api", "shop", "Web", "Green");
19496        staging_api.application = "shop".into();
19497        let mut prod_web = mk_env("prod-web", "shop", "Web", "Green");
19498        prod_web.application = "shop".into();
19499        app.environments = vec![prod_api, staging_api, prod_web];
19500        // Filter to only the "prod-" envs — display_rows now has
19501        // 2 entries (env-vec indices 0 and 2), so the envs-vec
19502        // index 2 (prod-web) maps to display-row index 1.
19503        app.filter = "prod-".into();
19504        app.rebuild_view();
19505        // Captured undo targets prod-web (envs-vec idx 2).
19506        app.undo_history.push_back(super::UndoEntry {
19507            env_name: "prod-web".into(),
19508            to_set: vec![(
19509                "aws:autoscaling:launchconfiguration".into(),
19510                "EC2KeyName".into(),
19511                "bar".into(),
19512            )],
19513            to_remove: vec![],
19514            original_summary: "keypair foo".into(),
19515            captured_at: chrono::Utc::now(),
19516        });
19517        // Pre-undo: cursor on prod-api (display row 0). After
19518        // dispatch the cursor should be restored.
19519        app.table_state.select(Some(0));
19520        app.execute_command("undo");
19521        // No error message about wrong env or out-of-bounds; the
19522        // dispatch should have reached the right target. The fix
19523        // is sufficient if no error fires + cursor is restored.
19524        assert!(
19525            app.error_message.is_none(),
19526            "expected dispatch to succeed, got error: {:?}",
19527            app.error_message
19528        );
19529        assert_eq!(
19530            app.table_state.selected(),
19531            Some(0),
19532            "cursor must be restored to the prior selection"
19533        );
19534    }
19535
19536    #[tokio::test]
19537    async fn cmd_undo_refuses_with_hint_when_env_filtered_out() {
19538        // Captured env exists in self.environments but is hidden
19539        // by the active filter. Refuse with a hint pointing at
19540        // the filter, and keep the entry on the deque so the
19541        // operator can retry after clearing.
19542        let mut app = test_app();
19543        app.environments = vec![
19544            mk_env("prod-api", "shop", "Web", "Green"),
19545            mk_env("staging-api", "shop", "Web", "Green"),
19546        ];
19547        app.filter = "staging-".into();
19548        app.rebuild_view();
19549        app.undo_history.push_back(super::UndoEntry {
19550            env_name: "prod-api".into(),
19551            to_set: vec![("ns".into(), "k".into(), "v".into())],
19552            to_remove: vec![],
19553            original_summary: "keypair foo".into(),
19554            captured_at: chrono::Utc::now(),
19555        });
19556        app.execute_command("undo");
19557        let err = app.error_message.as_deref().unwrap_or("");
19558        assert!(
19559            err.contains("filtered out") && err.contains("clear the filter"),
19560            "expected filter hint, got: {err}"
19561        );
19562        assert_eq!(
19563            app.undo_history.len(),
19564            1,
19565            "entry must be put back on the deque so the operator can retry"
19566        );
19567    }
19568
19569    #[tokio::test]
19570    async fn cmd_undo_refuses_when_target_env_no_longer_visible() {
19571        // Captured entry references an env that's been filtered
19572        // out or terminated. Refuse rather than dispatch against
19573        // a missing env.
19574        let mut app = test_app();
19575        app.undo_history.push_back(super::UndoEntry {
19576            env_name: "vanished".into(),
19577            to_set: vec![("ns".into(), "k".into(), "v".into())],
19578            to_remove: vec![],
19579            original_summary: "keypair foo".into(),
19580            captured_at: chrono::Utc::now(),
19581        });
19582        app.execute_command("undo");
19583        let err = app.error_message.as_deref().unwrap_or("");
19584        assert!(
19585            err.contains("no longer in the current view"),
19586            "expected missing-env refusal, got: {err}"
19587        );
19588    }
19589
19590    #[test]
19591    fn expand_command_alias_pass_through_when_no_match() {
19592        use std::collections::HashMap;
19593        let mut aliases = HashMap::new();
19594        aliases.insert("dp".to_string(), "deploy --auto-rollback 5m".to_string());
19595        // No alias matched — line unchanged.
19596        assert_eq!(super::expand_command_alias("rebuild", &aliases), "rebuild");
19597        // Empty alias map — line unchanged.
19598        assert_eq!(
19599            super::expand_command_alias("deploy build-x", &HashMap::new()),
19600            "deploy build-x"
19601        );
19602    }
19603
19604    #[test]
19605    fn expand_command_alias_swaps_first_token_and_keeps_args() {
19606        use std::collections::HashMap;
19607        let mut aliases = HashMap::new();
19608        aliases.insert("dp".to_string(), "deploy --auto-rollback 5m".to_string());
19609        assert_eq!(
19610            super::expand_command_alias("dp build-900", &aliases),
19611            "deploy --auto-rollback 5m build-900"
19612        );
19613        // No args after alias — expansion stands alone.
19614        assert_eq!(
19615            super::expand_command_alias("dp", &aliases),
19616            "deploy --auto-rollback 5m"
19617        );
19618    }
19619
19620    #[test]
19621    fn expand_command_alias_does_not_chain_transitively() {
19622        // Single-level expansion only. `dp → bare deploy` does NOT
19623        // get re-expanded via a second-tier alias map lookup.
19624        use std::collections::HashMap;
19625        let mut aliases = HashMap::new();
19626        aliases.insert("a".to_string(), "b stuff".to_string());
19627        aliases.insert("b".to_string(), "c things".to_string());
19628        assert_eq!(super::expand_command_alias("a", &aliases), "b stuff");
19629        // No infinite loop on self-referential aliases.
19630        let mut aliases = HashMap::new();
19631        aliases.insert("loop".to_string(), "loop forever".to_string());
19632        assert_eq!(
19633            super::expand_command_alias("loop", &aliases),
19634            "loop forever"
19635        );
19636    }
19637
19638    #[tokio::test]
19639    async fn execute_command_uses_command_aliases() {
19640        // End-to-end: define a command alias, dispatch it, expect
19641        // the expansion to run. We probe via `:freeze-deploys`'s
19642        // observable side-effect (the toast text) rather than
19643        // having to mock a deploy.
19644        let mut app = test_app();
19645        app.command_aliases
19646            .insert("emergency".into(), "freeze-deploys incident #1234".into());
19647        app.execute_command("emergency");
19648        assert!(app.deploy_freeze.is_some());
19649        let reason = app
19650            .deploy_freeze
19651            .as_ref()
19652            .map(|f| f.reason.clone())
19653            .unwrap();
19654        assert_eq!(reason, "incident #1234");
19655    }
19656
19657    #[tokio::test]
19658    async fn freeze_deploys_blocks_writes_with_reason_surfaced() {
19659        // Operator dispatches `:freeze-deploys incident #1234` →
19660        // every destructive action refuses, with the reason
19661        // surfaced in the toast. Same gate as the read-only pins
19662        // but more visible (the reason is operator-supplied).
19663        let mut app = test_app();
19664        app.execute_command("freeze-deploys incident #1234");
19665        assert!(app.deploy_freeze.is_some(), "freeze should be set");
19666        assert!(
19667            app.is_read_only_for("any-env"),
19668            "freeze must block every env"
19669        );
19670        let reason = app.read_only_reason("any-env").unwrap_or_default();
19671        assert!(
19672            reason.contains("deploys frozen") && reason.contains("incident #1234"),
19673            "expected reason to surface, got: {reason}"
19674        );
19675    }
19676
19677    #[tokio::test]
19678    async fn freeze_deploys_with_no_reason_still_blocks() {
19679        // Reason is optional — empty-reason freeze still blocks
19680        // but the toast wording shifts.
19681        let mut app = test_app();
19682        app.execute_command("freeze-deploys");
19683        assert!(app.deploy_freeze.is_some());
19684        let reason = app.read_only_reason("env").unwrap_or_default();
19685        assert!(
19686            reason.contains("deploys frozen") && !reason.contains(": "),
19687            "no-reason wording shouldn't include `: <reason>`, got: {reason}"
19688        );
19689    }
19690
19691    #[tokio::test]
19692    async fn thaw_deploys_clears_the_freeze() {
19693        let mut app = test_app();
19694        app.execute_command("freeze-deploys testing");
19695        assert!(app.deploy_freeze.is_some());
19696        app.execute_command("thaw-deploys");
19697        assert!(app.deploy_freeze.is_none(), "thaw should clear freeze");
19698        assert!(
19699            !app.is_read_only_for("env"),
19700            "thaw must restore writes (no other locks set in this test)"
19701        );
19702    }
19703
19704    #[tokio::test]
19705    async fn re_freezing_updates_the_reason_in_place() {
19706        // Operator refines the reason mid-incident; replace not stack.
19707        let mut app = test_app();
19708        app.execute_command("freeze-deploys rolling back");
19709        app.execute_command("freeze-deploys rolling back — PROD only");
19710        let reason = app
19711            .deploy_freeze
19712            .as_ref()
19713            .map(|f| f.reason.clone())
19714            .unwrap();
19715        assert_eq!(reason, "rolling back — PROD only");
19716    }
19717
19718    #[tokio::test]
19719    async fn freeze_overrides_per_env_pin_in_read_only_reason() {
19720        // When BOTH a freeze AND a per-env safety pin are active,
19721        // the freeze reason wins in the toast — it's the more-
19722        // recent operator gesture and the more informative message.
19723        let mut app = test_app();
19724        app.safety_envs.insert("prod".into(), true);
19725        app.execute_command("freeze-deploys incident");
19726        let reason = app.read_only_reason("prod").unwrap_or_default();
19727        assert!(
19728            reason.contains("deploys frozen"),
19729            "freeze reason must win over per-env pin, got: {reason}"
19730        );
19731    }
19732
19733    #[tokio::test]
19734    async fn handle_unavailability_estimate_silent_on_fetch_failure() {
19735        // Option-settings fetch failed → line stays None; UI
19736        // silently omits the row rather than rendering an error.
19737        let mut app = test_app();
19738        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
19739        app.rebuild_view();
19740        app.table_state.select(Some(0));
19741        app.execute_command("deploy build-900");
19742        app.handle_msg(AppMsg::UnavailabilityEstimate {
19743            gen: app.generation,
19744            env_name: "prod".into(),
19745            line: None,
19746        });
19747        match &app.action_flow {
19748            Some(ActionFlow::Confirm(modal)) => {
19749                assert!(!modal.loading_unavailability);
19750                assert!(modal.unavailability_line.is_none());
19751            }
19752            _ => panic!("expected confirm modal"),
19753        }
19754    }
19755
19756    #[tokio::test]
19757    async fn handle_health_check_probe_renders_warning_on_failure() {
19758        // Probe failed → modal carries an `Err` so the UI renders
19759        // the yellow warning line. Loading flag clears.
19760        let mut app = test_app();
19761        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
19762        app.rebuild_view();
19763        app.table_state.select(Some(0));
19764        app.execute_command("deploy build-900");
19765        app.handle_msg(AppMsg::HealthCheckProbe {
19766            gen: app.generation,
19767            env_name: "prod".into(),
19768            result: Err("HTTP 404".into()),
19769        });
19770        match &app.action_flow {
19771            Some(ActionFlow::Confirm(modal)) => {
19772                assert!(!modal.loading_health_check);
19773                assert_eq!(
19774                    modal.health_check_probe.as_ref().map(|r| r.is_err()),
19775                    Some(true)
19776                );
19777            }
19778            _ => panic!("expected confirm modal open"),
19779        }
19780    }
19781
19782    #[tokio::test]
19783    async fn handle_health_check_probe_silent_on_ok() {
19784        // Probe succeeded → modal carries `Ok(())` and the UI
19785        // renders nothing (silence is golden — the operator
19786        // confirm flow stays uncluttered).
19787        let mut app = test_app();
19788        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
19789        app.rebuild_view();
19790        app.table_state.select(Some(0));
19791        app.execute_command("deploy build-900");
19792        app.handle_msg(AppMsg::HealthCheckProbe {
19793            gen: app.generation,
19794            env_name: "prod".into(),
19795            result: Ok(()),
19796        });
19797        match &app.action_flow {
19798            Some(ActionFlow::Confirm(modal)) => {
19799                assert!(!modal.loading_health_check);
19800                assert_eq!(
19801                    modal.health_check_probe.as_ref().map(|r| r.is_ok()),
19802                    Some(true)
19803                );
19804            }
19805            _ => panic!("expected confirm modal open"),
19806        }
19807    }
19808
19809    #[tokio::test]
19810    async fn apply_refresh_drains_watching_deploy_on_green() {
19811        // Watcher armed; next apply_refresh sees Green → drain +
19812        // pinned success. Operator sees "✓ deploy reached Green: prod"
19813        // without having to stare at the table.
19814        let mut app = test_app();
19815        let now = chrono::Utc::now();
19816        app.watching_deploys.insert(
19817            "prod".into(),
19818            WatchingDeploy {
19819                env_name: "prod".into(),
19820                target_label: "build-900".into(),
19821                armed_at: now,
19822                deadline_at: now + chrono::Duration::seconds(300),
19823            },
19824        );
19825        app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Green")]));
19826        assert!(
19827            app.watching_deploys.is_empty(),
19828            "Green should drain the watcher"
19829        );
19830        // The pin flag survives only one refresh tick — by the time
19831        // apply_refresh returns, the message stays in the slot but the
19832        // pinned flag has been reset. We assert on the message itself.
19833        let pinned = app.status_message.as_deref().unwrap_or("");
19834        assert!(
19835            pinned.contains("reached Green") && pinned.contains("prod"),
19836            "expected pinned success status, got: {pinned:?}"
19837        );
19838    }
19839
19840    #[tokio::test]
19841    async fn apply_refresh_drains_watching_deploy_on_timeout() {
19842        // Watcher armed with a deadline already in the past; next
19843        // apply_refresh with the env still non-Green → drain + pinned
19844        // timeout error. The error pin survives the auto-clear at the
19845        // bottom of apply_refresh.
19846        let mut app = test_app();
19847        let now = chrono::Utc::now();
19848        app.watching_deploys.insert(
19849            "prod".into(),
19850            WatchingDeploy {
19851                env_name: "prod".into(),
19852                target_label: "build-900".into(),
19853                armed_at: now - chrono::Duration::seconds(600),
19854                deadline_at: now - chrono::Duration::seconds(60),
19855            },
19856        );
19857        app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Red")]));
19858        assert!(
19859            app.watching_deploys.is_empty(),
19860            "expired watcher should drain on timeout"
19861        );
19862        let pinned = app.error_message.as_deref().unwrap_or("");
19863        assert!(
19864            pinned.contains("did not reach Green") && pinned.contains("prod"),
19865            "expected pinned timeout error, got: {pinned:?}"
19866        );
19867    }
19868
19869    #[tokio::test]
19870    async fn rebuild_clears_watching_deploys() {
19871        // Context switch (account / region change) flushes
19872        // env-scoped state. watching_deploys is env-name keyed, so
19873        // it must drop alongside armed_watchdogs + deploy_snapshots.
19874        let mut app = test_app();
19875        app.watching_deploys.insert(
19876            "prod".into(),
19877            WatchingDeploy {
19878                env_name: "prod".into(),
19879                target_label: "build-900".into(),
19880                armed_at: chrono::Utc::now(),
19881                deadline_at: chrono::Utc::now() + chrono::Duration::seconds(300),
19882            },
19883        );
19884        app.apply_rebuild(Ok(Box::new(crate::aws::AwsClient::stub())));
19885        assert!(
19886            app.watching_deploys.is_empty(),
19887            "watching_deploys must clear on context rebuild"
19888        );
19889    }
19890
19891    #[test]
19892    fn soonest_watching_deploy_picks_earliest_deadline() {
19893        // Two watchers; pill shows the one firing first. Mirrors
19894        // the soonest_armed_rollback contract.
19895        let mut map: std::collections::HashMap<String, WatchingDeploy> =
19896            std::collections::HashMap::new();
19897        let now = chrono::Utc::now();
19898        map.insert(
19899            "later".into(),
19900            WatchingDeploy {
19901                env_name: "later".into(),
19902                target_label: "v2".into(),
19903                armed_at: now,
19904                deadline_at: now + chrono::Duration::seconds(600),
19905            },
19906        );
19907        map.insert(
19908            "sooner".into(),
19909            WatchingDeploy {
19910                env_name: "sooner".into(),
19911                target_label: "v1".into(),
19912                armed_at: now,
19913                deadline_at: now + chrono::Duration::seconds(120),
19914            },
19915        );
19916        let (env, _remaining) = soonest_watching_deploy(&map, now).expect("not empty");
19917        assert_eq!(env, "sooner");
19918    }
19919
19920    #[tokio::test]
19921    async fn promote_env_opens_deploy_confirm_on_target_with_sources_version() {
19922        // `:promote-env staging prod` takes staging's current
19923        // version_label, opens the deploy confirm on PROD (not the
19924        // selected env), and threads the label as deploy_version.
19925        let mut app = test_app();
19926        let mut staging = mk_env("staging", "shop", "Web", "Green");
19927        staging.version_label = "build-900".into();
19928        let mut prod = mk_env("prod", "shop", "Web", "Green");
19929        prod.version_label = "build-820".into();
19930        app.environments = vec![staging, prod];
19931        app.rebuild_view();
19932        // Cursor is on staging — the modal must still target prod
19933        // because the command names target explicitly, not via the
19934        // table cursor.
19935        app.table_state.select(Some(0));
19936        app.execute_command("promote-env staging prod");
19937        match &app.action_flow {
19938            Some(ActionFlow::Confirm(modal)) => {
19939                assert_eq!(modal.target_env, "prod");
19940                assert_eq!(modal.deploy_version.as_deref(), Some("build-900"));
19941                assert!(matches!(modal.action, Action::Deploy));
19942            }
19943            _ => panic!("expected confirm modal open on target"),
19944        }
19945    }
19946
19947    #[tokio::test]
19948    async fn promote_env_composes_with_watchdog_flags() {
19949        // The full daily gesture: ship staging → prod with both
19950        // safety nets armed. Both fields must thread through.
19951        let mut app = test_app();
19952        let mut staging = mk_env("staging", "shop", "Web", "Green");
19953        staging.version_label = "build-900".into();
19954        let prod = mk_env("prod", "shop", "Web", "Green");
19955        app.environments = vec![staging, prod];
19956        app.rebuild_view();
19957        app.table_state.select(Some(0));
19958        app.execute_command("promote-env staging prod --auto-rollback 10m --wait-for-green 5m");
19959        match &app.action_flow {
19960            Some(ActionFlow::Confirm(modal)) => {
19961                assert_eq!(modal.target_env, "prod");
19962                assert_eq!(modal.deploy_version.as_deref(), Some("build-900"));
19963                assert_eq!(modal.auto_rollback_secs, Some(600));
19964                assert_eq!(modal.wait_for_green_secs, Some(300));
19965            }
19966            _ => panic!("expected confirm modal open"),
19967        }
19968    }
19969
19970    #[tokio::test]
19971    async fn promote_env_refuses_when_versions_match() {
19972        // Idempotent-deploy guard: if SOURCE's version is already on
19973        // TARGET there's nothing to promote.
19974        let mut app = test_app();
19975        let mut staging = mk_env("staging", "shop", "Web", "Green");
19976        staging.version_label = "build-900".into();
19977        let mut prod = mk_env("prod", "shop", "Web", "Green");
19978        prod.version_label = "build-900".into();
19979        app.environments = vec![staging, prod];
19980        app.rebuild_view();
19981        app.execute_command("promote-env staging prod");
19982        let err = app.error_message.as_deref().unwrap_or("");
19983        assert!(
19984            err.contains("already deployed to prod"),
19985            "expected idempotent guard, got: {err}"
19986        );
19987        assert!(app.action_flow.is_none(), "no modal on no-op");
19988    }
19989
19990    #[tokio::test]
19991    async fn promote_env_refuses_when_source_has_no_version() {
19992        // Brand-new env with no deploy yet — nothing to ship.
19993        let mut app = test_app();
19994        let mut staging = mk_env("staging", "shop", "Web", "Pending");
19995        staging.version_label = String::new();
19996        let prod = mk_env("prod", "shop", "Web", "Green");
19997        app.environments = vec![staging, prod];
19998        app.rebuild_view();
19999        app.execute_command("promote-env staging prod");
20000        let err = app.error_message.as_deref().unwrap_or("");
20001        assert!(
20002            err.contains("no version deployed"),
20003            "expected no-version refusal, got: {err}"
20004        );
20005    }
20006
20007    #[tokio::test]
20008    async fn promote_env_refuses_same_source_and_target() {
20009        // Operator typo guard.
20010        let mut app = test_app();
20011        let mut staging = mk_env("staging", "shop", "Web", "Green");
20012        staging.version_label = "build-900".into();
20013        app.environments = vec![staging];
20014        app.rebuild_view();
20015        app.execute_command("promote-env staging staging");
20016        let err = app.error_message.as_deref().unwrap_or("");
20017        assert!(
20018            err.contains("must be different"),
20019            "expected same-env refusal, got: {err}"
20020        );
20021    }
20022
20023    #[tokio::test]
20024    async fn promote_env_refuses_unknown_env() {
20025        let mut app = test_app();
20026        let mut staging = mk_env("staging", "shop", "Web", "Green");
20027        staging.version_label = "build-900".into();
20028        app.environments = vec![staging];
20029        app.rebuild_view();
20030        app.execute_command("promote-env staging nope");
20031        let err = app.error_message.as_deref().unwrap_or("");
20032        assert!(
20033            err.contains("no env named 'nope'"),
20034            "expected unknown-env refusal, got: {err}"
20035        );
20036    }
20037
20038    #[tokio::test]
20039    async fn deploy_modal_opens_with_version_preview_loading_flag_set() {
20040        // `:deploy LABEL` (no --preview) now sets
20041        // `loading_version_preview` so the modal reserves space
20042        // for the preview block. The actual fetch lands via
20043        // `handle_version_preview` and unsets the flag.
20044        let mut app = test_app();
20045        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
20046        app.rebuild_view();
20047        app.table_state.select(Some(0));
20048        app.execute_command("deploy build-900");
20049        match &app.action_flow {
20050            Some(ActionFlow::Confirm(modal)) => {
20051                assert!(
20052                    modal.loading_version_preview,
20053                    "Deploy modal must reserve space for the inline preview"
20054                );
20055                assert!(modal.version_preview.is_none());
20056            }
20057            _ => panic!("expected confirm modal open"),
20058        }
20059    }
20060
20061    #[tokio::test]
20062    async fn deploy_modal_handle_version_preview_stuffs_body_in_slot() {
20063        // Simulate the AppMsg landing — handler should clear the
20064        // loading flag and store the body.
20065        let mut app = test_app();
20066        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
20067        app.rebuild_view();
20068        app.table_state.select(Some(0));
20069        app.execute_command("deploy build-900");
20070        let body = "candidate: build-900\ncurrent: build-820\n".to_string();
20071        app.handle_msg(AppMsg::VersionPreview {
20072            gen: app.generation,
20073            env_name: "prod".into(),
20074            result: Ok(body.clone()),
20075        });
20076        match &app.action_flow {
20077            Some(ActionFlow::Confirm(modal)) => {
20078                assert!(!modal.loading_version_preview);
20079                assert_eq!(modal.version_preview.as_deref(), Some(body.as_str()));
20080            }
20081            _ => panic!("expected confirm modal still open"),
20082        }
20083    }
20084
20085    #[tokio::test]
20086    async fn deploy_modal_handle_version_preview_error_renders_inline() {
20087        // AWS error should not leave the modal stuck in loading;
20088        // the failure becomes a one-line inline message.
20089        let mut app = test_app();
20090        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
20091        app.rebuild_view();
20092        app.table_state.select(Some(0));
20093        app.execute_command("deploy build-900");
20094        app.handle_msg(AppMsg::VersionPreview {
20095            gen: app.generation,
20096            env_name: "prod".into(),
20097            result: Err("ListApplicationVersions throttled".into()),
20098        });
20099        match &app.action_flow {
20100            Some(ActionFlow::Confirm(modal)) => {
20101                assert!(!modal.loading_version_preview);
20102                let preview = modal.version_preview.as_deref().unwrap_or("");
20103                assert!(
20104                    preview.contains("version preview unavailable")
20105                        && preview.contains("throttled"),
20106                    "expected inline error, got: {preview}"
20107                );
20108            }
20109            _ => panic!("expected confirm modal still open"),
20110        }
20111    }
20112
20113    #[tokio::test]
20114    async fn handle_confirm_modal_lint_stuffs_issues_into_modal() {
20115        // After spawn_confirm_lint emits its message, the handler
20116        // clears the loading flag and stores the issues vec.
20117        // Modal renders Warn+ as inline warnings on the next draw.
20118        let mut app = test_app();
20119        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
20120        app.rebuild_view();
20121        app.table_state.select(Some(0));
20122        app.execute_command("deploy build-900");
20123        // Empty issues — handler still clears the loading flag.
20124        app.handle_msg(AppMsg::ConfirmModalLint {
20125            gen: app.generation,
20126            env_name: "prod".into(),
20127            issues: vec![],
20128        });
20129        match &app.action_flow {
20130            Some(ActionFlow::Confirm(modal)) => {
20131                assert!(!modal.loading_lint, "loading flag should clear");
20132                assert_eq!(modal.lint_issues.as_ref().map(|v| v.len()), Some(0));
20133            }
20134            _ => panic!("expected confirm modal open"),
20135        }
20136    }
20137
20138    #[tokio::test]
20139    async fn handle_confirm_modal_lint_drops_stale_target_results() {
20140        // If the operator opens a deploy on prod, closes it, then
20141        // opens a deploy on staging, the in-flight prod lint result
20142        // shouldn't land on the staging modal. Handler guards on
20143        // `modal.target_env == env_name`.
20144        let mut app = test_app();
20145        app.environments = vec![
20146            mk_env("prod", "shop", "Web", "Green"),
20147            mk_env("staging", "shop", "Web", "Green"),
20148        ];
20149        app.rebuild_view();
20150        // Open modal on staging.
20151        app.table_state.select(Some(1));
20152        app.execute_command("deploy build-900");
20153        // Late-arriving lint result for prod — should be dropped.
20154        app.handle_msg(AppMsg::ConfirmModalLint {
20155            gen: app.generation,
20156            env_name: "prod".into(),
20157            issues: vec![crate::lint::Issue {
20158                rule_id: "EBL001".into(),
20159                severity: crate::lint::Severity::Warn,
20160                env_name: Some("prod".into()),
20161                title: "stale".into(),
20162                detail: "stale".into(),
20163                suggestion: None,
20164                fields: Default::default(),
20165            }],
20166        });
20167        match &app.action_flow {
20168            Some(ActionFlow::Confirm(modal)) => {
20169                // loading_lint should still be true (we never
20170                // applied the stale result).
20171                assert!(
20172                    modal.loading_lint,
20173                    "loading flag must stay true on stale result"
20174                );
20175                assert!(
20176                    modal.lint_issues.is_none(),
20177                    "stale result must not populate"
20178                );
20179            }
20180            _ => panic!("expected confirm modal open"),
20181        }
20182    }
20183
20184    #[tokio::test]
20185    async fn refresh_tf_managed_envs_derives_set_from_tf_state() {
20186        // The HashSet caching the tf-managed names should match
20187        // `tf_state.managed_names()` after any mutation. Used by
20188        // the env-table badge for O(1) per-row lookup.
20189        let mut app = test_app();
20190        assert!(app.tf_managed_envs.is_empty(), "starts empty");
20191        app.tf_state = Some(crate::terraform::TfState {
20192            envs: vec![
20193                crate::terraform::TfEnv {
20194                    name: "prod-api".into(),
20195                    application: "shop".into(),
20196                    version_label: "build-820".into(),
20197                    options: vec![],
20198                    tags: Default::default(),
20199                },
20200                crate::terraform::TfEnv {
20201                    name: "prod-web".into(),
20202                    application: "shop".into(),
20203                    version_label: "build-820".into(),
20204                    options: vec![],
20205                    tags: Default::default(),
20206                },
20207            ],
20208        });
20209        app.refresh_tf_managed_envs();
20210        assert_eq!(app.tf_managed_envs.len(), 2);
20211        assert!(app.tf_managed_envs.contains("prod-api"));
20212        assert!(app.tf_managed_envs.contains("prod-web"));
20213        assert!(!app.tf_managed_envs.contains("staging-api"));
20214        // Clearing tf_state should empty the set on next refresh.
20215        app.tf_state = None;
20216        app.refresh_tf_managed_envs();
20217        assert!(app.tf_managed_envs.is_empty());
20218    }
20219
20220    #[tokio::test]
20221    async fn cmd_drift_refresh_reloads_tf_state_and_pins_status() {
20222        // `:drift refresh` re-reads tfstate from cwd. We can't
20223        // easily test the cwd discovery in isolation, but we
20224        // can verify the command path completes + pins a status
20225        // (either "reloaded N envs" or "no tfstate found").
20226        let mut app = test_app();
20227        app.environments = vec![mk_env("prod-api", "shop", "Web", "Green")];
20228        app.rebuild_view();
20229        app.execute_command("drift refresh");
20230        // Status message should mention tfstate either way.
20231        let msg = app.status_message.as_deref().unwrap_or("");
20232        assert!(
20233            msg.contains("tfstate"),
20234            "expected tfstate status, got: {msg}"
20235        );
20236    }
20237
20238    #[tokio::test]
20239    async fn cmd_drift_with_no_tfstate_loaded_hints_at_discovery() {
20240        // No tfstate cached → :drift surfaces a discovery hint
20241        // rather than firing an empty drift report. Sets the
20242        // operator on the right path (run from a tf project dir).
20243        let mut app = test_app();
20244        app.environments = vec![mk_env("prod-api", "shop", "Web", "Green")];
20245        app.rebuild_view();
20246        app.table_state.select(Some(0));
20247        app.tf_state = None;
20248        app.execute_command("drift");
20249        let msg = app.status_message.as_deref().unwrap_or("");
20250        assert!(
20251            msg.contains("no terraform.tfstate found"),
20252            "expected discovery hint, got: {msg}"
20253        );
20254    }
20255
20256    #[test]
20257    fn render_lint_overlay_empty_shows_clean_stub() {
20258        let body = crate::app::cmd_misc::render_lint_overlay("prod-api", &[]);
20259        assert!(body.contains("prod-api"));
20260        assert!(body.contains("✓ No issues found"));
20261        assert!(body.contains("esc / q to close"));
20262    }
20263
20264    #[test]
20265    fn render_lint_overlay_with_issues_renders_per_severity_glyph() {
20266        use crate::lint::{Issue, Severity};
20267        use std::collections::BTreeMap;
20268        let issues = vec![
20269            Issue {
20270                rule_id: "EBL001".into(),
20271                severity: Severity::Warn,
20272                env_name: Some("prod".into()),
20273                title: "AllAtOnce on 4-instance env".into(),
20274                detail: "Deployment policy AllAtOnce with MaxSize=4 means full unavailability."
20275                    .into(),
20276                suggestion: Some(":deployment-policy Rolling".into()),
20277                fields: BTreeMap::new(),
20278            },
20279            Issue {
20280                rule_id: "EBL005".into(),
20281                severity: Severity::Info,
20282                env_name: Some("prod".into()),
20283                title: "Single-instance env".into(),
20284                detail: "MinSize=MaxSize=1.".into(),
20285                suggestion: None,
20286                fields: BTreeMap::new(),
20287            },
20288        ];
20289        let body = crate::app::cmd_misc::render_lint_overlay("prod-api", &issues);
20290        // Warn gets ⚠, Info gets ·.
20291        assert!(body.contains("⚠ [EBL001]"));
20292        assert!(body.contains("· [EBL005]"));
20293        // Suggestion lines prefixed with →.
20294        assert!(body.contains("→ :deployment-policy Rolling"));
20295        // Detail wrapped under each issue with indent.
20296        assert!(body.contains("    Deployment policy AllAtOnce"));
20297        // Plural / singular handling.
20298        assert!(body.contains("2 issues found"));
20299    }
20300
20301    #[test]
20302    fn build_audit_webhook_body_has_slack_compatible_text_plus_structured_fields() {
20303        // Slack incoming webhooks consume a top-level `text` field;
20304        // anything else is metadata for other consumers. Both must
20305        // be present in the body so a single endpoint can serve both.
20306        let body = super::build_audit_webhook_body(
20307            Some("123456789012"),
20308            Some("prod"),
20309            "us-east-1",
20310            "stage=request action=Deploy target=prod-api",
20311            "2026-05-25T12:00:00Z",
20312        );
20313        assert!(body.starts_with('{') && body.ends_with('}'));
20314        assert!(
20315            body.contains("\"text\":\"[ebman]"),
20316            "missing slack-shaped text field"
20317        );
20318        assert!(body.contains("\"at\":\"2026-05-25T12:00:00Z\""));
20319        assert!(body.contains("\"account\":\"123456789012\""));
20320        assert!(body.contains("\"profile\":\"prod\""));
20321        assert!(body.contains("\"region\":\"us-east-1\""));
20322        assert!(body.contains("\"detail\":\"stage=request action=Deploy target=prod-api\""));
20323    }
20324
20325    #[test]
20326    fn build_audit_webhook_body_dashes_missing_account_and_profile_in_text() {
20327        // When account / profile aren't known (early init, unset
20328        // creds), the rendered text uses `-` placeholders so the
20329        // line is still readable in a Slack channel.
20330        let body = super::build_audit_webhook_body(
20331            None,
20332            None,
20333            "eu-west-1",
20334            "stage=event kind=red_transition env=prod-api",
20335            "2026-05-25T12:00:00Z",
20336        );
20337        assert!(
20338            body.contains("account=- profile=- region=eu-west-1"),
20339            "missing dash placeholders in text, got: {body}"
20340        );
20341        // But the structured fields use empty strings, not "-",
20342        // so consumers can distinguish "unknown" from "literal dash".
20343        assert!(body.contains("\"account\":\"\""));
20344        assert!(body.contains("\"profile\":\"\""));
20345    }
20346
20347    #[test]
20348    fn build_audit_webhook_body_escapes_quotes_in_detail() {
20349        // Audit detail occasionally embeds quoted strings; the body
20350        // must stay valid JSON after escaping.
20351        let body = super::build_audit_webhook_body(
20352            None,
20353            None,
20354            "us-east-1",
20355            "stage=event message=\"deploy started\"",
20356            "2026-05-25T12:00:00Z",
20357        );
20358        // The escaped string appears once inside the `text` field's
20359        // value and once inside `detail` — both must escape.
20360        assert!(body.contains("\\\"deploy started\\\""));
20361        // Round-trip via serde_yml's JSON-tolerant path: should parse.
20362        let _: serde_yml::Value = serde_yml::from_str(&body)
20363            .expect("webhook body must be parseable JSON / YAML-superset");
20364    }
20365
20366    #[tokio::test]
20367    async fn dispatch_auto_rollback_also_drains_watching_deploys() {
20368        // When the rollback watchdog fires, any parallel
20369        // `--wait-for-green` watcher for the same env must drain
20370        // too — otherwise the rolled-back version reaching Green
20371        // would pin "✓ deploy reached Green: env (build-900)" even
20372        // though build-900 is the version we just rolled away from.
20373        let mut app = test_app();
20374        app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
20375        app.rebuild_view();
20376        // Both watchers armed for the same env.
20377        app.deploy_snapshots.insert(
20378            "prod".into(),
20379            DeploySnapshot {
20380                env_name: "prod".into(),
20381                previous_version_label: "build-820".into(),
20382                taken_at: chrono::Utc::now(),
20383            },
20384        );
20385        app.armed_watchdogs.insert(
20386            "prod".into(),
20387            ArmedWatchdog {
20388                env_name: "prod".into(),
20389                target_label: "build-820".into(),
20390                armed_at: chrono::Utc::now(),
20391                deadline_at: chrono::Utc::now() - chrono::Duration::seconds(1),
20392            },
20393        );
20394        app.watching_deploys.insert(
20395            "prod".into(),
20396            WatchingDeploy {
20397                env_name: "prod".into(),
20398                target_label: "build-900".into(),
20399                armed_at: chrono::Utc::now(),
20400                deadline_at: chrono::Utc::now() + chrono::Duration::seconds(300),
20401            },
20402        );
20403        app.dispatch_auto_rollback("prod".into(), "Red".into());
20404        assert!(
20405            !app.watching_deploys.contains_key("prod"),
20406            "rollback dispatch must drain the parallel wait-for-green watcher"
20407        );
20408        assert!(
20409            !app.armed_watchdogs.contains_key("prod"),
20410            "rollback dispatch must drain its own armed watchdog"
20411        );
20412    }
20413
20414    #[test]
20415    fn soonest_watching_deploy_empty_returns_none() {
20416        let map: std::collections::HashMap<String, WatchingDeploy> =
20417            std::collections::HashMap::new();
20418        assert!(soonest_watching_deploy(&map, chrono::Utc::now()).is_none());
20419    }
20420
20421    #[tokio::test]
20422    async fn abort_rollback_named_env_disarms_just_that_one() {
20423        // Operator armed two; aborts only `staging`. `prod` stays
20424        // armed (and the deadline can still fire — apply_refresh
20425        // will decide as normal).
20426        let mut app = test_app();
20427        let now = chrono::Utc::now();
20428        for env in ["prod", "staging"] {
20429            app.armed_watchdogs.insert(
20430                env.into(),
20431                ArmedWatchdog {
20432                    env_name: env.into(),
20433                    target_label: "build-820".into(),
20434                    armed_at: now,
20435                    deadline_at: now + chrono::Duration::seconds(300),
20436                },
20437            );
20438        }
20439        app.execute_command("abort-rollback staging");
20440        assert!(
20441            !app.armed_watchdogs.contains_key("staging"),
20442            "named env should be drained"
20443        );
20444        assert!(
20445            app.armed_watchdogs.contains_key("prod"),
20446            "other env's watchdog must stay armed"
20447        );
20448        let status = app.status_message.as_deref().unwrap_or("");
20449        assert!(
20450            status.contains("aborted auto-rollback for staging"),
20451            "expected confirm in status, got: {status}"
20452        );
20453    }
20454
20455    #[tokio::test]
20456    async fn abort_rollback_named_env_not_armed_errors_clearly() {
20457        // Typo or stale name → no silent drain. Operator gets a
20458        // pointer at `:rollbacks-armed` to see what's actually
20459        // armed.
20460        let mut app = test_app();
20461        app.execute_command("abort-rollback ghost");
20462        let err = app.error_message.as_deref().unwrap_or("");
20463        assert!(
20464            err.contains("no auto-rollback armed for 'ghost'") && err.contains("rollbacks-armed"),
20465            "expected not-armed + discovery hint, got: {err}"
20466        );
20467    }
20468
20469    #[tokio::test]
20470    async fn abort_rollback_no_args_drains_every_watchdog() {
20471        // No arg → drain all. Status names them so the operator can
20472        // see what was cleared.
20473        let mut app = test_app();
20474        let now = chrono::Utc::now();
20475        for env in ["a", "b", "c"] {
20476            app.armed_watchdogs.insert(
20477                env.into(),
20478                ArmedWatchdog {
20479                    env_name: env.into(),
20480                    target_label: "x".into(),
20481                    armed_at: now,
20482                    deadline_at: now + chrono::Duration::seconds(300),
20483                },
20484            );
20485        }
20486        app.execute_command("abort-rollback");
20487        assert!(
20488            app.armed_watchdogs.is_empty(),
20489            "drain-all clears everything"
20490        );
20491        let status = app.status_message.as_deref().unwrap_or("");
20492        assert!(status.contains("aborted 3 auto-rollbacks"), "got: {status}");
20493        // Each named env surfaces in the toast.
20494        for env in ["a", "b", "c"] {
20495            assert!(
20496                status.contains(env),
20497                "expected {env} in status, got: {status}"
20498            );
20499        }
20500    }
20501
20502    #[tokio::test]
20503    async fn abort_rollback_no_args_empty_is_a_noop_status() {
20504        // Operator runs `:abort-rollback` with nothing armed → soft
20505        // status, not an error. Avoids surprising the operator who
20506        // just wanted to sanity-check.
20507        let mut app = test_app();
20508        app.execute_command("abort-rollback");
20509        let status = app.status_message.as_deref().unwrap_or("");
20510        assert!(status.contains("no auto-rollbacks armed to abort"));
20511        assert!(app.error_message.is_none());
20512    }
20513
20514    #[test]
20515    fn format_armed_rollbacks_empty_returns_stub() {
20516        let armed = std::collections::HashMap::new();
20517        let body = super::format_armed_rollbacks(&armed, chrono::Utc::now());
20518        assert!(body.contains("no auto-rollbacks armed"));
20519    }
20520
20521    #[test]
20522    fn format_armed_rollbacks_sorts_by_deadline_ascending() {
20523        use chrono::TimeZone;
20524        let now = chrono::Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
20525        let mut armed = std::collections::HashMap::new();
20526        // Two watchdogs — `staging-api` deadlines first.
20527        armed.insert(
20528            "prod-api".into(),
20529            ArmedWatchdog {
20530                env_name: "prod-api".into(),
20531                target_label: "build-820".into(),
20532                armed_at: now - chrono::Duration::seconds(60),
20533                deadline_at: now + chrono::Duration::seconds(300),
20534            },
20535        );
20536        armed.insert(
20537            "staging-api".into(),
20538            ArmedWatchdog {
20539                env_name: "staging-api".into(),
20540                target_label: "build-822".into(),
20541                armed_at: now - chrono::Duration::seconds(30),
20542                deadline_at: now + chrono::Duration::seconds(60),
20543            },
20544        );
20545        let body = super::format_armed_rollbacks(&armed, now);
20546        // The soonest deadline (staging-api, 1m left) appears first.
20547        let p_staging = body.find("staging-api").expect("staging-api row");
20548        let p_prod = body.find("prod-api").expect("prod-api row");
20549        assert!(
20550            p_staging < p_prod,
20551            "soonest-firing row should sort first; got body:\n{body}"
20552        );
20553        // Target labels surface so the operator can pre-read what'd
20554        // get redeployed.
20555        assert!(body.contains("build-822"));
20556        assert!(body.contains("build-820"));
20557    }
20558
20559    #[test]
20560    fn format_armed_rollbacks_expired_deadline_reads_as_expired() {
20561        use chrono::TimeZone;
20562        let now = chrono::Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
20563        let mut armed = std::collections::HashMap::new();
20564        armed.insert(
20565            "prod-api".into(),
20566            ArmedWatchdog {
20567                env_name: "prod-api".into(),
20568                target_label: "build-820".into(),
20569                armed_at: now - chrono::Duration::seconds(600),
20570                deadline_at: now - chrono::Duration::seconds(5),
20571            },
20572        );
20573        let body = super::format_armed_rollbacks(&armed, now);
20574        assert!(
20575            body.contains("fired / expired"),
20576            "expected expired marker, got: {body}"
20577        );
20578    }
20579
20580    #[test]
20581    fn soonest_armed_rollback_picks_the_earliest_deadline() {
20582        use chrono::TimeZone;
20583        let now = chrono::Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
20584        let mut armed = std::collections::HashMap::new();
20585        armed.insert(
20586            "later".into(),
20587            ArmedWatchdog {
20588                env_name: "later".into(),
20589                target_label: "x".into(),
20590                armed_at: now,
20591                deadline_at: now + chrono::Duration::seconds(600),
20592            },
20593        );
20594        armed.insert(
20595            "sooner".into(),
20596            ArmedWatchdog {
20597                env_name: "sooner".into(),
20598                target_label: "x".into(),
20599                armed_at: now,
20600                deadline_at: now + chrono::Duration::seconds(60),
20601            },
20602        );
20603        let (env, remaining) = super::soonest_armed_rollback(&armed, now).expect("one armed");
20604        assert_eq!(env, "sooner");
20605        // Remaining is in humanize-short-age form — 60s renders as "1m".
20606        assert!(remaining.contains('m') || remaining.contains('s'));
20607    }
20608
20609    #[test]
20610    fn soonest_armed_rollback_returns_none_when_empty() {
20611        let armed = std::collections::HashMap::new();
20612        assert!(super::soonest_armed_rollback(&armed, chrono::Utc::now()).is_none());
20613    }
20614
20615    #[tokio::test]
20616    async fn refresh_early_disarms_armed_watchdog_when_env_goes_green() {
20617        // Operator runs `:deploy --auto-rollback 5m` → watchdog
20618        // armed. Two refresh ticks later, the env reaches Green.
20619        // apply_refresh should clear the watchdog and surface the
20620        // disarm to the operator — without waiting for the 5m timer.
20621        let mut app = test_app();
20622        let now = chrono::Utc::now();
20623        app.armed_watchdogs.insert(
20624            "prod".into(),
20625            ArmedWatchdog {
20626                env_name: "prod".into(),
20627                target_label: "build-old".into(),
20628                armed_at: now,
20629                deadline_at: now + chrono::Duration::seconds(300),
20630            },
20631        );
20632        // Refresh delivers a Green prod env.
20633        app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Green")]));
20634        assert!(
20635            app.armed_watchdogs.is_empty(),
20636            "Green refresh should clear the armed watchdog"
20637        );
20638        let status = app.status_message.as_deref().unwrap_or("");
20639        assert!(
20640            status.contains("watchdog disarmed"),
20641            "expected disarm status, got: {status}"
20642        );
20643    }
20644
20645    #[tokio::test]
20646    async fn refresh_leaves_watchdog_armed_when_env_still_non_green() {
20647        // Inverse: env is Red on the refresh tick → watchdog stays
20648        // armed (the deadline timer is the next checkpoint).
20649        let mut app = test_app();
20650        let now = chrono::Utc::now();
20651        app.armed_watchdogs.insert(
20652            "prod".into(),
20653            ArmedWatchdog {
20654                env_name: "prod".into(),
20655                target_label: "build-old".into(),
20656                armed_at: now,
20657                deadline_at: now + chrono::Duration::seconds(300),
20658            },
20659        );
20660        app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Red")]));
20661        assert!(
20662            app.armed_watchdogs.contains_key("prod"),
20663            "Red refresh must leave watchdog armed"
20664        );
20665    }
20666
20667    #[tokio::test]
20668    async fn auto_rollback_check_is_noop_when_no_watchdog_armed() {
20669        // The deadline timer always fires (fire-and-forget
20670        // `tokio::spawn`, no JoinHandle for cancellation). If
20671        // apply_refresh's early-disarm pass already drained the
20672        // slot, the deadline message arriving later must be a no-op
20673        // — no spurious refresh, no pending row, no status churn.
20674        let mut app = test_app();
20675        app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
20676        // armed_watchdogs intentionally empty. deploy_snapshots
20677        // intentionally populated to prove that even with a usable
20678        // snapshot we don't fire when the slot's clean.
20679        app.deploy_snapshots.insert(
20680            "prod".into(),
20681            DeploySnapshot {
20682                env_name: "prod".into(),
20683                previous_version_label: "build-old".into(),
20684                taken_at: chrono::Utc::now(),
20685            },
20686        );
20687        let pending_before = app.pending_actions.len();
20688        let load_before = app.load_state;
20689        app.handle_msg(AppMsg::AutoRollbackCheck {
20690            gen: app.generation,
20691            env_name: "prod".into(),
20692        });
20693        assert_eq!(
20694            app.pending_actions.len(),
20695            pending_before,
20696            "noop check shouldn't push pending"
20697        );
20698        // No refresh kicked: load_state should be unchanged. (A
20699        // spurious refresh wouldn't directly hurt operators but
20700        // would burn an API call per stale deadline tick.)
20701        assert_eq!(
20702            app.load_state, load_before,
20703            "noop check shouldn't kick a refresh"
20704        );
20705    }
20706
20707    #[tokio::test]
20708    async fn apply_refresh_disarms_armed_watchdog_when_env_reaches_green() {
20709        // The refresh decision path's headline outcome — operator's
20710        // deploy succeeded, env Green, watchdog disarms with a
20711        // status toast.
20712        let mut app = test_app();
20713        let now = chrono::Utc::now();
20714        app.armed_watchdogs.insert(
20715            "prod".into(),
20716            ArmedWatchdog {
20717                env_name: "prod".into(),
20718                target_label: "build-old".into(),
20719                armed_at: now,
20720                deadline_at: now + chrono::Duration::seconds(300),
20721            },
20722        );
20723        app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Green")]));
20724        assert!(
20725            app.armed_watchdogs.is_empty(),
20726            "Green refresh should disarm"
20727        );
20728        let status = app.status_message.as_deref().unwrap_or("");
20729        assert!(status.contains("watchdog disarmed"));
20730    }
20731
20732    #[tokio::test]
20733    async fn apply_refresh_dispatches_rollback_when_deadline_passed_and_env_non_green() {
20734        // Refresh tick after the deadline + env still bad → dispatch.
20735        // The decision uses the freshly-applied env health, eliminating
20736        // the stale-cache race the inline-dispatch shape had.
20737        let mut app = test_app();
20738        let now = chrono::Utc::now();
20739        app.armed_watchdogs.insert(
20740            "prod".into(),
20741            ArmedWatchdog {
20742                env_name: "prod".into(),
20743                target_label: "build-old".into(),
20744                armed_at: now - chrono::Duration::seconds(600),
20745                deadline_at: now - chrono::Duration::seconds(1),
20746            },
20747        );
20748        app.deploy_snapshots.insert(
20749            "prod".into(),
20750            DeploySnapshot {
20751                env_name: "prod".into(),
20752                previous_version_label: "build-old".into(),
20753                taken_at: now - chrono::Duration::seconds(600),
20754            },
20755        );
20756        let pending_before = app.pending_actions.len();
20757        app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Red")]));
20758        assert!(
20759            app.armed_watchdogs.is_empty(),
20760            "dispatch should drain the watchdog"
20761        );
20762        assert_eq!(
20763            app.pending_actions.len(),
20764            pending_before + 1,
20765            "rollback dispatch should push a pending row"
20766        );
20767        assert!(app
20768            .pending_actions
20769            .iter()
20770            .any(|p| p.label.contains("Auto-rollback") && p.target == "prod"));
20771        let status = app.status_message.as_deref().unwrap_or("");
20772        assert!(status.contains("redeploying build-old"));
20773        assert!(status.contains("Red"));
20774    }
20775
20776    #[tokio::test]
20777    async fn apply_refresh_keeps_watchdog_armed_before_deadline_even_when_non_green() {
20778        // Refresh tick while still inside the auto-rollback window
20779        // and env still bad → watchdog stays armed for the next
20780        // refresh to re-evaluate. Pins the "don't dispatch early"
20781        // invariant — deploys often run Yellow for a minute before
20782        // settling Green.
20783        let mut app = test_app();
20784        let now = chrono::Utc::now();
20785        app.armed_watchdogs.insert(
20786            "prod".into(),
20787            ArmedWatchdog {
20788                env_name: "prod".into(),
20789                target_label: "build-old".into(),
20790                armed_at: now,
20791                deadline_at: now + chrono::Duration::seconds(300),
20792            },
20793        );
20794        app.deploy_snapshots.insert(
20795            "prod".into(),
20796            DeploySnapshot {
20797                env_name: "prod".into(),
20798                previous_version_label: "build-old".into(),
20799                taken_at: now,
20800            },
20801        );
20802        let pending_before = app.pending_actions.len();
20803        app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Yellow")]));
20804        assert!(
20805            app.armed_watchdogs.contains_key("prod"),
20806            "Yellow + pre-deadline must keep watchdog armed"
20807        );
20808        assert_eq!(
20809            app.pending_actions.len(),
20810            pending_before,
20811            "no dispatch before the deadline"
20812        );
20813    }
20814
20815    #[tokio::test]
20816    async fn apply_refresh_errors_when_deadline_passed_but_no_snapshot() {
20817        // Edge case: deadline expired + env non-Green + no captured
20818        // snapshot. Surface a clear error pointing the operator at
20819        // manual rollback rather than silently no-op.
20820        let mut app = test_app();
20821        let now = chrono::Utc::now();
20822        app.armed_watchdogs.insert(
20823            "prod".into(),
20824            ArmedWatchdog {
20825                env_name: "prod".into(),
20826                target_label: "build-old".into(),
20827                armed_at: now - chrono::Duration::seconds(600),
20828                deadline_at: now - chrono::Duration::seconds(1),
20829            },
20830        );
20831        // deploy_snapshots intentionally empty.
20832        app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Red")]));
20833        let err = app.error_message.as_deref().unwrap_or("");
20834        assert!(
20835            err.contains("no pre-deploy snapshot"),
20836            "expected missing-snapshot guidance, got: {err}"
20837        );
20838        assert!(
20839            app.armed_watchdogs.is_empty(),
20840            "missing-snapshot path still drains the watchdog"
20841        );
20842    }
20843
20844    #[tokio::test]
20845    async fn diff_two_arg_form_opens_overlay_for_named_envs() {
20846        // `:diff ENV-A ENV-B` is the post-0.8 shape that lets the
20847        // operator name both sides without first selecting one of
20848        // them. Verifies the new two-arg dispatch lands the Diff
20849        // overlay without complaining about "no env selected".
20850        let mut app = test_app();
20851        app.environments = vec![
20852            mk_env("staging", "uflexi", "Web", "Green"),
20853            mk_env("prod", "uflexi", "Web", "Green"),
20854        ];
20855        app.rebuild_view();
20856        // Deliberately leave no selection — the two-arg form should
20857        // ignore the selected-env fallback entirely.
20858        app.execute_command("diff staging prod");
20859        assert!(
20860            matches!(app.current_overlay, Some(Overlay::Diff(_))),
20861            "expected Overlay::Diff, got {:?}",
20862            app.current_overlay.is_some()
20863        );
20864        assert!(
20865            app.error_message.is_none(),
20866            "unexpected error: {:?}",
20867            app.error_message
20868        );
20869    }
20870
20871    #[tokio::test]
20872    async fn diff_two_arg_form_rejects_same_env_twice() {
20873        // `:diff ENV ENV` is a typo, not a request — surface a clear
20874        // error rather than silently comparing an env against itself.
20875        let mut app = test_app();
20876        app.environments = vec![mk_env("prod", "uflexi", "Web", "Green")];
20877        app.rebuild_view();
20878        app.execute_command("diff prod prod");
20879        assert!(
20880            app.current_overlay.is_none(),
20881            "shouldn't open overlay for same-env diff"
20882        );
20883        let err = app.error_message.as_deref().unwrap_or("");
20884        assert!(
20885            err.contains("different envs"),
20886            "expected 'different envs' guidance, got: {err}"
20887        );
20888    }
20889
20890    #[tokio::test]
20891    async fn diff_two_arg_form_errors_on_unknown_env() {
20892        // Missing env-B → no overlay, clear error message naming the
20893        // missing env so the operator knows which arg to fix.
20894        let mut app = test_app();
20895        app.environments = vec![mk_env("staging", "uflexi", "Web", "Green")];
20896        app.rebuild_view();
20897        app.execute_command("diff staging missing-env");
20898        assert!(app.current_overlay.is_none());
20899        let err = app.error_message.as_deref().unwrap_or("");
20900        assert!(
20901            err.contains("missing-env"),
20902            "expected error to name the missing env, got: {err}"
20903        );
20904    }
20905
20906    #[test]
20907    fn format_ssm_results_renders_per_instance_sections() {
20908        // Two instances with different statuses → each gets its own
20909        // header (instance id + status + exit code) and stdout/stderr
20910        // sections. Empty-output instance shows the `(no output)` stub
20911        // so the operator can distinguish "ran cleanly, said nothing"
20912        // from "didn't run".
20913        let rows = vec![
20914            crate::aws::SsmRunResult {
20915                instance_id: "i-aaa".into(),
20916                status: "Success".into(),
20917                exit_code: 0,
20918                stdout: "hello world\nline two".into(),
20919                stderr: String::new(),
20920            },
20921            crate::aws::SsmRunResult {
20922                instance_id: "i-bbb".into(),
20923                status: "Failed".into(),
20924                exit_code: 2,
20925                stdout: String::new(),
20926                stderr: "permission denied".into(),
20927            },
20928        ];
20929        let body = super::format_ssm_results("uptime", &rows);
20930        // Command line surfaced in header.
20931        assert!(body.contains("`uptime`"));
20932        // Both per-instance section headers present with exit codes.
20933        assert!(body.contains("i-aaa [Success, exit=0]"));
20934        assert!(body.contains("i-bbb [Failed, exit=2]"));
20935        // stdout content present.
20936        assert!(body.contains("hello world"));
20937        assert!(body.contains("line two"));
20938        // stderr content present.
20939        assert!(body.contains("permission denied"));
20940    }
20941
20942    #[test]
20943    fn format_ssm_results_truncates_long_output() {
20944        // A 100-line stdout blob must collapse to MAX_LINES_PER_STREAM
20945        // (50) + a "… (N more lines truncated)" footer so the overlay
20946        // stays scannable.
20947        let stdout: String = (0..100).map(|i| format!("line {i}\n")).collect();
20948        let rows = vec![crate::aws::SsmRunResult {
20949            instance_id: "i-aaa".into(),
20950            status: "Success".into(),
20951            exit_code: 0,
20952            stdout,
20953            stderr: String::new(),
20954        }];
20955        let body = super::format_ssm_results("seq 0 99", &rows);
20956        // Truncation footer cites the number of dropped lines.
20957        assert!(
20958            body.contains("50 more lines truncated"),
20959            "expected truncation footer, got body:\n{body}"
20960        );
20961        // Last preserved line is `line 49`, not `line 99`.
20962        assert!(body.contains("line 49"));
20963        assert!(!body.contains("line 99"));
20964    }
20965
20966    #[test]
20967    fn format_ssm_results_empty_rows_produces_stub() {
20968        let body = super::format_ssm_results("uptime", &[]);
20969        assert!(body.contains("No instances targeted"));
20970    }
20971
20972    #[tokio::test]
20973    async fn ssm_run_without_args_errors_clearly() {
20974        let mut app = test_app();
20975        app.execute_command("ssm-run");
20976        let err = app.error_message.as_deref().unwrap_or("");
20977        assert!(
20978            err.contains("usage:") && err.contains("shell-command"),
20979            "expected usage hint, got: {err}"
20980        );
20981    }
20982
20983    #[tokio::test]
20984    async fn ssm_run_without_detail_errors_with_instances_guidance() {
20985        // No Detail open → no cached instances → command should refuse
20986        // rather than silently no-op.
20987        let mut app = test_app();
20988        app.execute_command("ssm-run \"uptime\"");
20989        let err = app.error_message.as_deref().unwrap_or("");
20990        assert!(
20991            err.contains("Detail") && err.contains("Instances"),
20992            "expected Detail/Instances guidance, got: {err}"
20993        );
20994    }
20995
20996    #[test]
20997    fn format_alarm_history_renders_entries_and_empty_stub() {
20998        use chrono::TimeZone;
20999        let ts = |h, mi| chrono::Utc.with_ymd_and_hms(2026, 5, 24, h, mi, 0).unwrap();
21000        let mk = |t, kind: &str, summary: &str| crate::aws::AlarmHistoryEntry {
21001            at: Some(t),
21002            kind: kind.into(),
21003            summary: summary.into(),
21004        };
21005        // Empty entries → stub body + the 90-day retention hint so an
21006        // operator looking at an alarm with no recent transitions
21007        // doesn't assume the fetch broke.
21008        let stub = super::format_alarm_history("high-cpu", &[]);
21009        assert!(stub.contains("No history items"));
21010        assert!(stub.contains("90 days"));
21011        // Real entries → each row carries timestamp, kind in brackets,
21012        // and the summary line. Order preserved (newest-first per the
21013        // SDK's default).
21014        let entries = vec![
21015            mk(ts(12, 5), "StateUpdate", "Alarm updated from OK to ALARM"),
21016            mk(ts(11, 0), "ConfigurationUpdate", "Threshold changed to 80"),
21017        ];
21018        let body = super::format_alarm_history("high-cpu", &entries);
21019        assert!(body.contains("[StateUpdate]"));
21020        assert!(body.contains("[ConfigurationUpdate]"));
21021        assert!(body.contains("Alarm updated from OK to ALARM"));
21022        assert!(body.contains("Threshold changed to 80"));
21023        // Newest-first preserved: StateUpdate appears before ConfigurationUpdate.
21024        let p_state = body.find("StateUpdate").unwrap();
21025        let p_cfg = body.find("ConfigurationUpdate").unwrap();
21026        assert!(p_state < p_cfg);
21027    }
21028
21029    #[test]
21030    fn format_alarm_history_handles_missing_timestamp() {
21031        // A history item without a timestamp shouldn't blank out the
21032        // row — render `—` so the kind/summary still scan.
21033        let entries = vec![crate::aws::AlarmHistoryEntry {
21034            at: None,
21035            kind: "StateUpdate".into(),
21036            summary: "Alarm went ALARM".into(),
21037        }];
21038        let body = super::format_alarm_history("high-cpu", &entries);
21039        assert!(body.contains("—"));
21040        assert!(body.contains("Alarm went ALARM"));
21041    }
21042
21043    #[test]
21044    fn format_alarms_handles_empty_and_error() {
21045        let none = format_alarms(Ok(vec![]));
21046        assert!(none.contains("no CloudWatch alarms"));
21047        let err = format_alarms(Err("boom".into()));
21048        assert!(err.contains("error"));
21049        let alarms = format_alarms(Ok(vec![CwAlarm {
21050            name: "high-cpu".into(),
21051            state: "ALARM".into(),
21052            state_reason: "CPU > 80%".into(),
21053            metric_name: "CPUUtilization".into(),
21054            namespace: "AWS/EC2".into(),
21055        }]));
21056        assert!(alarms.contains("ALARM"));
21057        assert!(alarms.contains("high-cpu"));
21058        assert!(alarms.contains("CPU > 80%"));
21059    }
21060
21061    #[test]
21062    fn view_round_trips() {
21063        // We can't easily construct an App in tests, but encode_view's format
21064        // is straightforward — check a hand-built snap round-trips through
21065        // parse_sort and the trivial fields.
21066        let snap = "filter=prod;sort=health:desc;grouped=true;scope=apps";
21067        let mut got_filter = String::new();
21068        let mut got_sort = (SortKey::App, false);
21069        let mut got_grouped = false;
21070        let mut got_scope = Scope::Envs;
21071        for part in snap.split(';') {
21072            let (k, v) = part.split_once('=').unwrap();
21073            match k {
21074                "filter" => got_filter = v.into(),
21075                "sort" => got_sort = parse_sort(Some(v)),
21076                "grouped" => got_grouped = v == "true",
21077                "scope" => {
21078                    got_scope = if v == "apps" {
21079                        Scope::Apps
21080                    } else {
21081                        Scope::Envs
21082                    }
21083                }
21084                _ => {}
21085            }
21086        }
21087        assert_eq!(got_filter, "prod");
21088        assert_eq!(got_sort, (SortKey::Health, true));
21089        assert!(got_grouped);
21090        assert_eq!(got_scope, Scope::Apps);
21091    }
21092
21093    #[test]
21094    fn view_mode_cycle_includes_spacious() {
21095        assert_eq!(ViewMode::Default.next(), ViewMode::Compact);
21096        assert_eq!(ViewMode::Compact.next(), ViewMode::Spacious);
21097        assert_eq!(ViewMode::Spacious.next(), ViewMode::Default);
21098        assert_eq!(ViewMode::Spacious.label(), "spacious");
21099    }
21100
21101    #[test]
21102    fn md_escape_protects_pipes_and_backslashes() {
21103        assert_eq!(md_escape("simple"), "simple");
21104        assert_eq!(md_escape("a|b|c"), "a\\|b\\|c");
21105        assert_eq!(md_escape("back\\slash"), "back\\\\slash");
21106        assert_eq!(md_escape("a\\|b"), "a\\\\\\|b");
21107    }
21108
21109    #[test]
21110    fn describe_env_dumps_known_fields() {
21111        let env = Environment {
21112            name: "my-env".into(),
21113            application: "my-app".into(),
21114            status: "Ready".into(),
21115            health: "Green".into(),
21116            platform: "Java 17".into(),
21117            solution_stack: String::new(),
21118            tier: "Web".into(),
21119            cname: "my-env.elb.amazonaws.com".into(),
21120            version_label: "v42".into(),
21121            arn: None,
21122            updated: None,
21123            id: None,
21124            region: None,
21125        };
21126        let text = describe_env(&env);
21127        assert!(text.contains("\"name\""));
21128        assert!(text.contains("my-env"));
21129        assert!(text.contains("\"updated\":         null"));
21130    }
21131
21132    #[test]
21133    fn detail_tab_titles_are_distinct() {
21134        use std::collections::HashSet;
21135        let titles: HashSet<&str> = [
21136            DetailTab::Health,
21137            DetailTab::Events,
21138            DetailTab::Instances,
21139            DetailTab::Metrics,
21140            DetailTab::Queue,
21141            DetailTab::Config,
21142        ]
21143        .iter()
21144        .map(|t| t.title())
21145        .collect();
21146        assert_eq!(titles.len(), 6);
21147    }
21148
21149    // ── UI integration harness ──────────────────────────────────────
21150    //
21151    // These tests drive `crossterm::Event`s through `handle_event` and
21152    // (optionally) render to a `ratatui::TestBackend`-backed Terminal
21153    // to inspect the resulting buffer. The harness uses `App::for_tests`
21154    // — synchronous, no AWS network, no disk reads — so each test starts
21155    // from a known clean state.
21156    //
21157    // What this catches that the pure-helper tests don't:
21158    //   - Mode-transition glitches (overlay closes correctly, Filter
21159    //     mode swallows printable keys, etc.)
21160    //   - Key-precedence regressions (Mode::Picker over LogTail
21161    //     overlay, ESC routing, Tab cycling)
21162    //   - Render-side state-dependent bugs (a field is None, the
21163    //     renderer panics; an overlay shape changes, the dispatch
21164    //     desyncs).
21165    //
21166    // Pattern:
21167    //   1. `let mut app = test_app();` — clean App.
21168    //   2. Mutate state as needed (push fake envs onto `app.environments`,
21169    //      flip toggles, etc.). The struct is fully `pub` so tests can
21170    //      seed any shape without going through async fetchers.
21171    //   3. `press(&mut app, KeyCode::*, KeyModifiers::*)` — feed a key.
21172    //   4. Assert on `app.<field>` — or render to a buffer string via
21173    //      `render(&mut app, w, h)` and grep.
21174
21175    use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
21176
21177    /// Build a minimal App in a deterministic state. Useful for tests
21178    /// that don't care about real AWS data — just keyboard flow + mode
21179    /// transitions. Seed envs / overlays / detail state by mutating
21180    /// the returned App directly.
21181    fn test_app() -> App {
21182        // Match the unicode/dark defaults so the renderer's per-theme
21183        // branches are exercised on the common path.
21184        let cfg = crate::config::Config {
21185            theme: "dark".into(),
21186            icons: "unicode".into(),
21187            ..crate::config::Config::default()
21188        };
21189        App::for_tests(crate::aws::AwsClient::stub(), cfg)
21190    }
21191
21192    /// Synthesize a `KeyEvent::Press` and dispatch it through
21193    /// `handle_event`. Mirrors how `run()` feeds real terminal events.
21194    fn press(app: &mut App, code: KeyCode, mods: KeyModifiers) {
21195        app.handle_event(Event::Key(KeyEvent {
21196            code,
21197            modifiers: mods,
21198            kind: KeyEventKind::Press,
21199            state: crossterm::event::KeyEventState::NONE,
21200        }));
21201    }
21202
21203    /// Render the App into a fixed-size `TestBackend` buffer and return
21204    /// the flattened string (one row per line, joined with `\n`).
21205    /// Useful for grep-style assertions on rendered output.
21206    fn render(app: &mut App, w: u16, h: u16) -> String {
21207        use ratatui::backend::TestBackend;
21208        use ratatui::Terminal;
21209        let backend = TestBackend::new(w, h);
21210        let mut terminal = Terminal::new(backend).expect("terminal");
21211        terminal.draw(|f| crate::ui::draw(f, app)).expect("draw");
21212        let buf = terminal.backend().buffer();
21213        let mut out = String::new();
21214        for y in 0..buf.area.height {
21215            for x in 0..buf.area.width {
21216                out.push_str(buf[(x, y)].symbol());
21217            }
21218            out.push('\n');
21219        }
21220        out
21221    }
21222
21223    fn mk_env(name: &str, app: &str, tier: &str, health: &str) -> crate::aws::Environment {
21224        crate::aws::Environment {
21225            name: name.into(),
21226            application: app.into(),
21227            status: "Ready".into(),
21228            health: health.into(),
21229            platform: "Java 17".into(),
21230            solution_stack: String::new(),
21231            tier: tier.into(),
21232            cname: format!("{name}.example.com"),
21233            version_label: "build-1".into(),
21234            arn: Some(format!("arn:aws:eb:us-east-1:0:env/{name}")),
21235            updated: None,
21236            id: None,
21237            region: None,
21238        }
21239    }
21240
21241    #[tokio::test]
21242    async fn persist_state_is_a_noop_in_demo_mode() {
21243        // `--demo` runs against a synthetic fleet on a fake profile +
21244        // region with `cost_enabled = true` from the fixture. Without
21245        // this bypass, exiting demo mode would write that synthetic
21246        // state to ~/.config/ebman/state.toml, clobbering the
21247        // operator's real saved state (selected env, sort, named
21248        // filters, cost-tracking opt-in, …).
21249        //
21250        // The test pivots on persist_state's `state::file_path()`
21251        // touch: if we set a sentinel path via $XDG_STATE_HOME +
21252        // confirm no file lands there post-persist, the bypass is
21253        // working. Skipping file-path indirection keeps the test
21254        // hermetic; we just assert demo_mode short-circuits before
21255        // any disk write would happen by checking the function's
21256        // observable effect via `state::load`-after.
21257        let mut app = test_app();
21258        app.demo_mode = true;
21259        // No panic, no file write. The function should return early
21260        // before constructing the persisted struct or reaching
21261        // write_atomic. Smoke test: just calling it must not error.
21262        app.persist_state();
21263    }
21264
21265    #[tokio::test]
21266    async fn tab_cycles_scope_envs_to_apps_and_back() {
21267        let mut app = test_app();
21268        assert_eq!(app.scope, Scope::Envs);
21269        press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
21270        assert_eq!(app.scope, Scope::Apps);
21271        press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
21272        assert_eq!(app.scope, Scope::Envs);
21273    }
21274
21275    #[tokio::test]
21276    async fn question_mark_opens_help_and_escape_dismisses_it() {
21277        let mut app = test_app();
21278        assert_eq!(app.mode, Mode::Normal);
21279        press(&mut app, KeyCode::Char('?'), KeyModifiers::NONE);
21280        assert_eq!(app.mode, Mode::Help);
21281        press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21282        assert_eq!(app.mode, Mode::Normal);
21283    }
21284
21285    #[tokio::test]
21286    async fn colon_enters_command_mode_and_esc_cancels() {
21287        let mut app = test_app();
21288        press(&mut app, KeyCode::Char(':'), KeyModifiers::NONE);
21289        assert_eq!(app.mode, Mode::Command);
21290        // Typed chars land in the command input buffer.
21291        press(&mut app, KeyCode::Char('q'), KeyModifiers::NONE);
21292        assert_eq!(app.command_input, "q");
21293        press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21294        assert_eq!(app.mode, Mode::Normal);
21295        // Input cleared on cancel.
21296        assert!(app.command_input.is_empty());
21297    }
21298
21299    #[tokio::test]
21300    async fn slash_enters_filter_mode_and_text_lands() {
21301        let mut app = test_app();
21302        // Seed an env so filter has something to operate on.
21303        app.environments = vec![
21304            mk_env("prod-web", "uflexi", "Web", "Green"),
21305            mk_env("staging-web", "uflexi", "Web", "Green"),
21306        ];
21307        app.rebuild_view();
21308        press(&mut app, KeyCode::Char('/'), KeyModifiers::NONE);
21309        assert_eq!(app.mode, Mode::Filter);
21310        for c in "prod".chars() {
21311            press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
21312        }
21313        assert_eq!(app.filter, "prod");
21314        // Esc clears the filter and returns to Normal.
21315        press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21316        assert_eq!(app.mode, Mode::Normal);
21317        assert!(app.filter.is_empty());
21318    }
21319
21320    #[tokio::test]
21321    async fn enter_on_red_env_opens_why_via_bang_keybind() {
21322        let mut app = test_app();
21323        // Seed a Red env + select it.
21324        app.environments = vec![mk_env("prod-web", "uflexi", "Web", "Red")];
21325        app.rebuild_view();
21326        app.table_state.select(Some(0));
21327        // `!` shortcut in Envs scope opens the :why overlay.
21328        press(&mut app, KeyCode::Char('!'), KeyModifiers::NONE);
21329        assert!(
21330            matches!(app.current_overlay, Some(Overlay::WhyRed { .. })),
21331            "expected WhyRed overlay, got {:?}",
21332            app.current_overlay
21333        );
21334    }
21335
21336    #[tokio::test]
21337    async fn render_main_table_includes_seeded_env_name() {
21338        let mut app = test_app();
21339        app.environments = vec![mk_env("api-prod-canary", "uflexi", "Web", "Green")];
21340        app.rebuild_view();
21341        let frame = render(&mut app, 160, 24);
21342        assert!(
21343            frame.contains("api-prod-canary"),
21344            "rendered frame should show seeded env name; got:\n{frame}"
21345        );
21346    }
21347
21348    #[tokio::test]
21349    async fn render_main_table_includes_inst_column_header_and_data() {
21350        // INST column should appear in the main table header by default
21351        // (not in hidden_cols) and render the per-env counts when the
21352        // env_instance_counts cache has data, em-dash when it doesn't.
21353        let mut app = test_app();
21354        app.environments = vec![
21355            mk_env("api-prod", "uflexi", "Web", "Green"),
21356            mk_env("api-staging", "uflexi", "Web", "Green"),
21357        ];
21358        // Seed counts: prod has 3/3 healthy, staging unknown (no entry).
21359        app.env_instance_counts.insert(
21360            "api-prod".into(),
21361            crate::aws::EnvInstanceCounts {
21362                healthy: 3,
21363                total: 3,
21364            },
21365        );
21366        app.rebuild_view();
21367        let frame = render(&mut app, 160, 24);
21368        assert!(
21369            frame.contains("INST"),
21370            "expected INST column header in rendered frame; got:\n{frame}"
21371        );
21372        assert!(
21373            frame.contains("3/3"),
21374            "expected '3/3' for env with seeded counts; got:\n{frame}"
21375        );
21376        // Staging has no counts entry → em-dash placeholder.
21377        assert!(
21378            frame.contains("—"),
21379            "expected em-dash placeholder for env with no counts; got:\n{frame}"
21380        );
21381    }
21382
21383    #[tokio::test]
21384    async fn is_read_only_for_layers_global_env_and_account() {
21385        // Global toggle wins over everything — even an env not in the
21386        // pin map.
21387        let mut app = test_app();
21388        app.read_only = true;
21389        assert!(app.is_read_only_for("any-env"));
21390        assert!(app.read_only_reason("any-env").unwrap().contains("global"));
21391
21392        // Global off + per-env pin → that one env is locked, others
21393        // aren't.
21394        let mut app = test_app();
21395        app.safety_envs.insert("uflexi-prod".into(), true);
21396        app.safety_envs.insert("uflexi-staging".into(), false);
21397        assert!(app.is_read_only_for("uflexi-prod"));
21398        assert!(!app.is_read_only_for("uflexi-staging"));
21399        assert!(!app.is_read_only_for("uflexi-dev"));
21400        assert!(app
21401            .read_only_reason("uflexi-prod")
21402            .unwrap()
21403            .contains("safety.envs.uflexi-prod"));
21404
21405        // Global off + per-account pin → every env in that profile is
21406        // locked.
21407        let mut app = test_app();
21408        app.context.profile = Some("prod-acct".into());
21409        app.safety_accounts.insert("prod-acct".into(), true);
21410        assert!(app.is_read_only_for("any-env"));
21411        assert!(app
21412            .read_only_reason("any-env")
21413            .unwrap()
21414            .contains("safety.accounts.prod-acct"));
21415        // Switching profile away clears the lock.
21416        app.context.profile = Some("dev-acct".into());
21417        assert!(!app.is_read_only_for("any-env"));
21418
21419        // Nothing pinned → unlocked + reason is None.
21420        let app = test_app();
21421        assert!(!app.is_read_only_for("any-env"));
21422        assert!(app.read_only_reason("any-env").is_none());
21423    }
21424
21425    #[tokio::test]
21426    async fn ctrl_x_toggles_redact() {
21427        let mut app = test_app();
21428        assert!(!app.redact);
21429        press(&mut app, KeyCode::Char('x'), KeyModifiers::CONTROL);
21430        assert!(app.redact);
21431        press(&mut app, KeyCode::Char('x'), KeyModifiers::CONTROL);
21432        assert!(!app.redact);
21433    }
21434
21435    #[tokio::test]
21436    async fn space_toggles_multi_select_and_esc_clears_it() {
21437        let mut app = test_app();
21438        app.environments = vec![
21439            mk_env("api-prod", "uflexi", "Web", "Green"),
21440            mk_env("api-staging", "uflexi", "Web", "Green"),
21441        ];
21442        app.rebuild_view();
21443        // Cursor on row 0 by default. Space adds it to multi-select.
21444        press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
21445        assert_eq!(app.multi_selected.len(), 1);
21446        assert!(app.multi_selected.contains("api-prod"));
21447        // Second space toggles the same row off.
21448        press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
21449        assert!(app.multi_selected.is_empty());
21450        // Select both rows again, then Esc → clears in one keystroke.
21451        press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
21452        press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE);
21453        press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
21454        assert_eq!(app.multi_selected.len(), 2);
21455        press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21456        assert!(app.multi_selected.is_empty());
21457    }
21458
21459    #[tokio::test]
21460    async fn filter_mode_text_input_and_backspace_round_trips() {
21461        let mut app = test_app();
21462        app.environments = vec![
21463            mk_env("api-prod", "uflexi", "Web", "Green"),
21464            mk_env("api-staging", "uflexi", "Web", "Green"),
21465        ];
21466        app.rebuild_view();
21467        // `/` enters Filter mode.
21468        press(&mut app, KeyCode::Char('/'), KeyModifiers::NONE);
21469        assert_eq!(app.mode, Mode::Filter);
21470        // Type "prod" — filter text accumulates, view rebuilds on each char.
21471        for c in "prod".chars() {
21472            press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
21473        }
21474        assert_eq!(app.filter, "prod");
21475        // Backspace removes the last char.
21476        press(&mut app, KeyCode::Backspace, KeyModifiers::NONE);
21477        assert_eq!(app.filter, "pro");
21478        // Enter commits the filter and returns to Normal — the filter
21479        // string SURVIVES (it's how `:filter` works as a stateful
21480        // search).
21481        press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
21482        assert_eq!(app.mode, Mode::Normal);
21483        assert_eq!(app.filter, "pro");
21484    }
21485
21486    #[tokio::test]
21487    async fn esc_in_filter_mode_clears_the_filter() {
21488        let mut app = test_app();
21489        app.environments = vec![mk_env("api-prod", "uflexi", "Web", "Green")];
21490        app.rebuild_view();
21491        press(&mut app, KeyCode::Char('/'), KeyModifiers::NONE);
21492        for c in "x".chars() {
21493            press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
21494        }
21495        assert_eq!(app.filter, "x");
21496        // Esc abandons the filter — both the text AND the mode revert.
21497        press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21498        assert_eq!(app.mode, Mode::Normal);
21499        assert!(app.filter.is_empty());
21500    }
21501
21502    #[tokio::test]
21503    async fn star_toggles_pinned_set_for_selected_env() {
21504        let mut app = test_app();
21505        app.environments = vec![
21506            mk_env("api-prod", "uflexi", "Web", "Green"),
21507            mk_env("api-staging", "uflexi", "Web", "Green"),
21508        ];
21509        app.rebuild_view();
21510        // Star pins the cursor's env.
21511        press(&mut app, KeyCode::Char('*'), KeyModifiers::NONE);
21512        assert!(app.pinned.contains("api-prod"));
21513        // Second star unpins it.
21514        press(&mut app, KeyCode::Char('*'), KeyModifiers::NONE);
21515        assert!(!app.pinned.contains("api-prod"));
21516    }
21517
21518    #[tokio::test]
21519    async fn picker_workflow_open_filter_enter_dispatches_choice() {
21520        // `r` opens the region picker. Typing filters the list; Enter
21521        // applies the highlighted choice. We don't try to assert that
21522        // the AwsClient actually swapped (the test stub doesn't fire
21523        // real calls) — we assert the mode transitions and that the
21524        // picker's selected_value resolves the expected entry.
21525        let mut app = test_app();
21526        press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
21527        assert_eq!(app.mode, Mode::Picker);
21528        assert!(app.picker.is_some());
21529        // Esc cancels — picker cleared, mode back to Normal.
21530        press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21531        assert_eq!(app.mode, Mode::Normal);
21532        assert!(app.picker.is_none());
21533    }
21534
21535    /// Helper for the cancel-window tests — build a ConfirmModal for
21536    /// the given Action / env. Mirrors the shape `advance_action_flow`
21537    /// produces; pre-flight fields stay None (the cancel-window code
21538    /// path doesn't read them).
21539    fn mk_modal(action: Action, env: &str) -> ConfirmModal {
21540        ConfirmModal {
21541            action,
21542            target_env: env.into(),
21543            swap_with: None,
21544            typed: String::new(),
21545            kind: ConfirmKind::YesNo,
21546            dryrun: None,
21547            loading_dryrun: false,
21548            recent_events: None,
21549            loading_events: false,
21550            traffic_warning: None,
21551            deploy_version: None,
21552            upgrade_platform_arn: None,
21553            upgrade_platform_label: None,
21554            clone_target: None,
21555            scale_min: None,
21556            scale_max: None,
21557            auto_rollback_secs: None,
21558            wait_for_green_secs: None,
21559            version_preview: None,
21560            loading_version_preview: false,
21561            health_check_probe: None,
21562            loading_health_check: false,
21563            unavailability_line: None,
21564            loading_unavailability: false,
21565            lint_issues: None,
21566            loading_lint: false,
21567        }
21568    }
21569
21570    #[tokio::test]
21571    async fn queue_action_dispatch_holds_action_for_cancel_window() {
21572        let mut app = test_app();
21573        let modal = mk_modal(Action::Rebuild, "uflexi-prod");
21574        app.queue_action_dispatch(modal);
21575        let pd = app
21576            .pending_dispatch
21577            .as_ref()
21578            .expect("queue should set pending_dispatch");
21579        assert_eq!(pd.target, "uflexi-prod");
21580        assert!(
21581            matches!(pd.kind, PendingDispatchKind::Single { .. }),
21582            "queue_action_dispatch should produce a Single variant"
21583        );
21584        assert!(
21585            pd.deadline > std::time::Instant::now(),
21586            "deadline must be in the future"
21587        );
21588        let remaining = pd
21589            .deadline
21590            .saturating_duration_since(std::time::Instant::now());
21591        assert!(
21592            remaining <= UNDO_WINDOW && remaining >= UNDO_WINDOW - Duration::from_millis(500),
21593            "deadline should be roughly UNDO_WINDOW from now; got {remaining:?}"
21594        );
21595    }
21596
21597    #[tokio::test]
21598    async fn cancel_pending_dispatch_clears_field_and_emits_status() {
21599        let mut app = test_app();
21600        app.queue_action_dispatch(mk_modal(Action::Terminate, "uflexi-prod"));
21601        assert!(app.pending_dispatch.is_some());
21602        app.cancel_pending_dispatch();
21603        assert!(app.pending_dispatch.is_none());
21604        let msg = app.status_message.as_deref().unwrap_or("");
21605        assert!(
21606            msg.contains("undone") && msg.contains("uflexi-prod"),
21607            "status should mention the undo + env; got: {msg:?}"
21608        );
21609    }
21610
21611    #[tokio::test]
21612    async fn second_queue_attempt_errors_while_first_pending() {
21613        let mut app = test_app();
21614        app.queue_action_dispatch(mk_modal(Action::Rebuild, "first"));
21615        assert!(app.pending_dispatch.is_some());
21616        let first_deadline = app.pending_dispatch.as_ref().unwrap().deadline;
21617        // Second queue attempt is rejected; first dispatch is untouched.
21618        app.queue_action_dispatch(mk_modal(Action::Rebuild, "second"));
21619        assert_eq!(
21620            app.pending_dispatch.as_ref().unwrap().target,
21621            "first",
21622            "second queue must not replace the first"
21623        );
21624        assert_eq!(
21625            app.pending_dispatch.as_ref().unwrap().deadline,
21626            first_deadline,
21627            "second queue must not bump the deadline"
21628        );
21629        assert!(
21630            app.error_message
21631                .as_deref()
21632                .unwrap_or("")
21633                .contains("press U to undo"),
21634            "second queue should surface a useful error"
21635        );
21636    }
21637
21638    #[tokio::test]
21639    async fn tick_pending_dispatch_fires_after_deadline() {
21640        let mut app = test_app();
21641        // Forge a pending dispatch whose deadline has already elapsed
21642        // so tick_pending_dispatch fires synchronously without us
21643        // having to wait 5 seconds.
21644        let modal = mk_modal(Action::Rebuild, "expired");
21645        app.pending_dispatch = Some(PendingDispatch {
21646            deadline: std::time::Instant::now() - Duration::from_millis(1),
21647            label: "Rebuild env".into(),
21648            target: "expired".into(),
21649            kind: PendingDispatchKind::Single { modal },
21650        });
21651        app.tick_pending_dispatch();
21652        assert!(
21653            app.pending_dispatch.is_none(),
21654            "expired tick should clear the field (dispatch handed to spawn_action)"
21655        );
21656    }
21657
21658    #[tokio::test]
21659    async fn batch_action_routes_through_cancel_window() {
21660        let mut app = test_app();
21661        app.environments = vec![
21662            mk_env("prod-web", "uflexi", "Web", "Green"),
21663            mk_env("staging-web", "uflexi", "Web", "Green"),
21664        ];
21665        app.multi_selected.insert("prod-web".into());
21666        app.multi_selected.insert("staging-web".into());
21667        app.cmd_batch_action(Action::Rebuild);
21668        // Multi-select cleared; dispatch queued with a 5s deadline.
21669        assert!(
21670            app.multi_selected.is_empty(),
21671            "multi-select should clear once the batch is queued"
21672        );
21673        let pd = app
21674            .pending_dispatch
21675            .as_ref()
21676            .expect("batch action should queue a pending dispatch");
21677        match &pd.kind {
21678            PendingDispatchKind::BatchAction { action, env_names } => {
21679                assert_eq!(*action, Action::Rebuild);
21680                assert_eq!(env_names.len(), 2);
21681            }
21682            other => panic!(
21683                "expected BatchAction variant; got {other:?}",
21684                other = match other {
21685                    PendingDispatchKind::Single { .. } => "Single",
21686                    PendingDispatchKind::BatchAction { .. } => "BatchAction",
21687                    PendingDispatchKind::BatchDeploy { .. } => "BatchDeploy",
21688                    PendingDispatchKind::BatchTag { .. } => "BatchTag",
21689                    PendingDispatchKind::BatchSetOption { .. } => "BatchSetOption",
21690                }
21691            ),
21692        }
21693    }
21694
21695    #[tokio::test]
21696    async fn batch_action_undo_cancels_whole_fanout() {
21697        let mut app = test_app();
21698        app.environments = vec![
21699            mk_env("e1", "uflexi", "Web", "Green"),
21700            mk_env("e2", "uflexi", "Web", "Green"),
21701            mk_env("e3", "uflexi", "Web", "Green"),
21702        ];
21703        for name in ["e1", "e2", "e3"] {
21704            app.multi_selected.insert(name.into());
21705        }
21706        app.cmd_batch_action(Action::RestartAppServer);
21707        assert!(app.pending_dispatch.is_some());
21708        app.cancel_pending_dispatch();
21709        assert!(
21710            app.pending_dispatch.is_none(),
21711            "cancel should drop the whole batch, not just one env"
21712        );
21713        let msg = app.status_message.as_deref().unwrap_or("");
21714        assert!(
21715            msg.contains("undone") && msg.contains("3 env(s)"),
21716            "status should call out the 3-env batch; got: {msg:?}"
21717        );
21718    }
21719
21720    #[tokio::test]
21721    async fn apps_scope_space_toggles_apps_selected() {
21722        let mut app = test_app();
21723        // Seed two apps + select Apps scope.
21724        app.applications = vec![
21725            crate::aws::Application {
21726                name: "billing".into(),
21727                description: String::new(),
21728                date_created: None,
21729                date_updated: None,
21730                version_count: 0,
21731                templates: vec![],
21732                latest_version_label: None,
21733                latest_version_created: None,
21734            },
21735            crate::aws::Application {
21736                name: "checkout".into(),
21737                description: String::new(),
21738                date_created: None,
21739                date_updated: None,
21740                version_count: 0,
21741                templates: vec![],
21742                latest_version_label: None,
21743                latest_version_created: None,
21744            },
21745        ];
21746        app.set_scope(Scope::Apps);
21747        app.app_table_state.select(Some(0));
21748        // First space adds; second space removes.
21749        press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
21750        assert!(app.apps_selected.contains("billing"));
21751        press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
21752        assert!(!app.apps_selected.contains("billing"));
21753    }
21754
21755    #[tokio::test]
21756    async fn apps_scope_star_pins_and_unpins_app() {
21757        let mut app = test_app();
21758        app.applications = vec![crate::aws::Application {
21759            name: "billing".into(),
21760            description: String::new(),
21761            date_created: None,
21762            date_updated: None,
21763            version_count: 0,
21764            templates: vec![],
21765            latest_version_label: None,
21766            latest_version_created: None,
21767        }];
21768        app.set_scope(Scope::Apps);
21769        app.app_table_state.select(Some(0));
21770        assert!(!app.pinned_apps.contains("billing"));
21771        press(&mut app, KeyCode::Char('*'), KeyModifiers::SHIFT);
21772        assert!(app.pinned_apps.contains("billing"));
21773        press(&mut app, KeyCode::Char('*'), KeyModifiers::SHIFT);
21774        assert!(!app.pinned_apps.contains("billing"));
21775    }
21776
21777    #[tokio::test]
21778    async fn esc_clears_apps_selected_when_no_envs_selected() {
21779        let mut app = test_app();
21780        app.apps_selected.insert("billing".into());
21781        app.apps_selected.insert("checkout".into());
21782        press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21783        assert!(app.apps_selected.is_empty());
21784    }
21785
21786    #[tokio::test]
21787    async fn capital_u_cancels_pending_dispatch_in_normal_mode() {
21788        let mut app = test_app();
21789        app.queue_action_dispatch(mk_modal(Action::Rebuild, "uflexi-prod"));
21790        assert!(app.pending_dispatch.is_some());
21791        press(&mut app, KeyCode::Char('U'), KeyModifiers::SHIFT);
21792        assert!(
21793            app.pending_dispatch.is_none(),
21794            "capital U in Normal mode should cancel the pending dispatch"
21795        );
21796    }
21797}