Skip to main content

zero_commands/
command.rs

1//! The `Command` enum — exhaustive list of what the TUI, command
2//! palette, and non-interactive entrypoint can dispatch.
3//!
4//! Each variant carries a const [`RiskDirection`] and resolves to
5//! a single handler inside `dispatch`. Adding a command is three
6//! steps: add a variant, add a `CatalogEntry` in [`CATALOG`], add
7//! a match arm in `dispatch::run`.
8
9use crate::parse::ParsedLine;
10use crate::risk::RiskDirection;
11use zero_engine_client::ExecuteSide;
12
13/// A mode switch the dispatcher can emit. Mirrors `zero-tui::Mode`
14/// but lives here so `zero-commands` does not have to depend on
15/// the TUI crate.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ModeTarget {
18    Conversation,
19    Positions,
20    Decisions,
21    Heat,
22    Cockpit,
23}
24
25impl ModeTarget {
26    #[must_use]
27    pub const fn as_str(self) -> &'static str {
28        match self {
29            Self::Conversation => "conversation",
30            Self::Positions => "positions",
31            Self::Decisions => "decisions",
32            Self::Heat => "heat",
33            Self::Cockpit => "cockpit",
34        }
35    }
36}
37
38/// A modal overlay the TUI should paint on top of the current
39/// mode. Same decoupling rationale as [`ModeTarget`]: dispatch
40/// side-effects stay addressable without the command crate
41/// depending on any widget types.
42#[derive(Debug, Clone, PartialEq)]
43pub enum OverlayTarget {
44    /// Full-screen-ish state overview, sourced from the engine's
45    /// operator-state mirror (ADR-016). See Addendum A §2.3 for
46    /// the semantic shape.
47    State,
48    /// Gate-level verdict for a single coin, fetched from
49    /// `GET /evaluate/{coin}`. The payload is the engine's
50    /// [`zero_engine_client::Evaluation`] so the overlay renders
51    /// exactly what the engine said, no local interpretation.
52    Verdict(Box<zero_engine_client::Evaluation>),
53}
54
55impl OverlayTarget {
56    #[must_use]
57    pub const fn as_str(&self) -> &'static str {
58        match self {
59            Self::State => "state",
60            Self::Verdict(_) => "verdict",
61        }
62    }
63}
64
65// `Evaluation` contains `f64` fields so it is `PartialEq` but not
66// `Eq`. We assert `Eq` manually at the enum level because every
67// variant is equality-decidable under `PartialEq` in practice and
68// `DispatchOutput` (which embeds `Option<OverlayTarget>`) derives
69// `Eq`. Accepting `f64::NaN` into a `Verdict` would produce a
70// reflexive-inequality — the engine does not emit NaN confidence,
71// and any regression will surface in dispatch tests.
72impl Eq for OverlayTarget {}
73
74/// An operator command.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum Command {
77    Help,
78    Quit,
79    Clear,
80    SwitchMode(ModeTarget),
81    Status,
82    Brief,
83    Risk,
84    /// `/hl-status [coin]` — read-only Hyperliquid public info status.
85    /// This cannot sign payloads or place orders, so it is Neutral.
86    HyperliquidStatus {
87        symbol: Option<String>,
88    },
89    /// `/hl-account` — read-only Hyperliquid account truth.
90    HyperliquidAccount,
91    /// `/hl-reconcile` — local runtime versus Hyperliquid account reconciliation.
92    HyperliquidReconcile,
93    /// `/live-certify` — dry-run live execution certification harness.
94    LiveCertify,
95    /// `/live-cockpit` — consolidated live readiness and breaker cockpit.
96    LiveCockpit,
97    /// `/live-evidence` — hash-only signed canary evidence bundle.
98    LiveEvidence,
99    /// `/live-receipts` — public-safe local live execution receipt bundle.
100    LiveReceipts,
101    /// `/live-canary` — readiness, arm/disarm, and evidence qualification policy.
102    LiveCanaryPolicy,
103    /// `/runtime-parity` — production-parity OODA report with live-shadow refusal.
104    RuntimeParity,
105    /// `/immune` — risk-blocking immune and circuit-breaker state.
106    Immune,
107    /// `/quote <coin>` — active paper quote source for a symbol.
108    /// This is read-only and cannot sign payloads or place orders.
109    Quote {
110        symbol: Option<String>,
111    },
112    Regime {
113        coin: Option<String>,
114    },
115    /// `/evaluate <coin>` — fetch the engine's gate-level verdict
116    /// for `coin` and surface it as a verdict overlay. `coin` is
117    /// required; a missing argument is resolved to the command
118    /// and the dispatcher emits a usage hint so the picker and
119    /// `/help` paths stay consistent.
120    ///
121    /// `extras` preserves any trailing tokens the operator typed
122    /// after the coin (e.g. `/evaluate sol short`). The engine
123    /// endpoint does not take a direction — `direction` is a
124    /// property of the verdict, not an input — so extras are
125    /// only surfaced as a warning during dispatch. We keep them
126    /// here (rather than discarding at parse time) so the
127    /// warning can echo the exact tokens the operator typed,
128    /// which is more informative than a generic "extra args".
129    Evaluate {
130        coin: Option<String>,
131        extras: Vec<String>,
132    },
133    Positions,
134    /// `/pulse [limit]` — stream the engine's recent-events tail
135    /// (signals, rejections, state transitions). `limit` is clamped
136    /// client-side to `1..=100` by [`HttpClient::pulse`]. A missing
137    /// limit falls back to [`Self::default_pulse_limit`].
138    Pulse {
139        limit: Option<u32>,
140    },
141    /// `/approaching` — coins within distance-to-gate of an entry
142    /// or exit trigger. No args; results come sorted by ascending
143    /// distance.
144    Approaching,
145    /// `/rejections [coin] [limit]` — recent gate rejections. Either
146    /// argument can be omitted; numeric tokens resolve to `limit`,
147    /// non-numeric tokens to `coin`. `limit` is clamped to `1..=500`
148    /// by the HTTP layer.
149    Rejections {
150        coin: Option<String>,
151        limit: Option<u32>,
152    },
153    Kill,
154    FlattenAll,
155    PauseEntries,
156    ResumeEntries,
157    Break {
158        minutes: Option<u32>,
159    },
160    /// Legacy bare `/execute` friction demonstration. Kept so stored
161    /// sessions and TUI friction fixtures remain decodable; real order
162    /// placement uses [`Self::ExecuteOrder`].
163    Execute,
164    /// `/execute <coin> <buy|sell> <size>` — place an operator-approved
165    /// order through the engine. Malformed invocations are neutral and
166    /// render usage without forcing the operator through friction first.
167    ExecuteOrder {
168        coin: Option<String>,
169        side: Option<ExecuteSide>,
170        size: Option<String>,
171        error: Option<String>,
172    },
173    /// Open the full-screen operator-state overview overlay
174    /// (Addendum A §2.3). Read-only — opens a modal sourced from
175    /// the engine mirror; the dispatcher itself emits no lines.
176    State,
177    /// `/sessions [limit]` — list recent sessions. Default limit is
178    /// [`Self::default_sessions_limit`]; the impl clamps higher
179    /// values to that ceiling so a stray `/sessions 100000` does
180    /// not blow the conversation pane.
181    Sessions {
182        limit: Option<u32>,
183    },
184    /// `/resume <ulid|label>` — append the prior session's events
185    /// into the current log, silently (without re-persisting).
186    /// The argument can be a ulid (full or 6+ char prefix) or a
187    /// human label set via [`Self::Save`]. Missing argument is
188    /// resolved to the command and the dispatcher emits a usage
189    /// hint, keeping the picker path consistent with `/evaluate`.
190    Resume {
191        needle: Option<String>,
192    },
193    /// `/fork` — start a new session whose `parent_ulid` is the
194    /// current one. Takes no arguments; the ulid is generated by
195    /// the session store. Rendered as a single "forked → <ulid>"
196    /// confirmation line.
197    Fork,
198    /// `/heat` — composite heat readout combining the
199    /// risk-summary percentages (drawdown / daily-loss /
200    /// exposure) with kill-switch + circuit-breaker state into a
201    /// single actionable "how hot am I?" line. Distinct from
202    /// `/risk` (which is a terse risk-only readout) because heat
203    /// folds in guardrail-proximity and circuit state so an
204    /// operator scanning for "am I close to a limit?" can get
205    /// that answer in one token. No args — heat is always the
206    /// current state.
207    Heat,
208    /// `/save <label>` — attach a human-friendly alias to the
209    /// current session. Labels are resolved later by `/resume
210    /// <label>` so operators can name a session "pre-cpi" or
211    /// "scratch" without memorizing ulids. Overwriting a label
212    /// is allowed and intentional.
213    Save {
214        label: Option<String>,
215    },
216    /// `/replay <ulid|label>` — paint a prior session into the
217    /// conversation log **without** switching to it. Identical to
218    /// `/resume` in every respect except that the `SessionSource`
219    /// adapter is not asked to rotate the active write target.
220    /// This is the path operators use when they want to study a
221    /// past session while continuing to record into the current
222    /// one; `/resume` implies "I am picking this up again",
223    /// `/replay` implies "I am looking at it".
224    Replay {
225        needle: Option<String>,
226    },
227    /// `/share [ulid|label]` — render a shareable text snapshot of
228    /// a session (metadata + events) as a single command block in
229    /// the conversation pane. Argument is optional; when omitted
230    /// the current session is shared. The dispatcher emits JSON
231    /// wrapped in a fenced block so the operator can select-and-
232    /// copy without format drift. File / clipboard export is
233    /// deferred — a snapshot in the log is the minimal viable
234    /// share primitive and avoids host-I/O policy decisions.
235    Share {
236        needle: Option<String>,
237    },
238    /// `/config <action>` — read-only introspection of the
239    /// operator's on-disk config + secret-resolution state.
240    /// Intentionally read-only at this layer: write paths
241    /// (`zero init`, `zero pair`) already have dedicated
242    /// entrypoints and we do not want the TUI to silently
243    /// rewrite `config.toml` from a slash command. Missing /
244    /// unknown action resolves to a usage hint rather than a
245    /// silent no-op.
246    Config {
247        action: ConfigAction,
248    },
249    /// `/verbose [on|off|toggle]` — toggle the TUI's verbose
250    /// rendering mode. Today that means "include date +
251    /// seconds in log timestamps" instead of the default HH:MM:SS.
252    /// Future verbose-gated surfaces (full event payload dumps,
253    /// richer friction reasoning) will key off the same flag.
254    ///
255    /// Argument grammar:
256    /// - bare `/verbose` → [`VerboseAction::Toggle`]
257    /// - `/verbose on`  → [`VerboseAction::On`]
258    /// - `/verbose off` → [`VerboseAction::Off`]
259    /// - anything else  → [`VerboseAction::Unknown`] so the
260    ///   dispatcher can surface a usage hint. Silent acceptance
261    ///   of an unknown argument would make the command seem
262    ///   inert.
263    Verbose {
264        action: VerboseAction,
265    },
266    /// `/state-override <label>` — operator-declared override of
267    /// the engine-computed behavioural label. Risk-*increasing*
268    /// (see [`RiskDirection::Increases`]) because a healthier-
269    /// than-observed claim unlocks lower friction; the ladder
270    /// *must* still gate it so an operator declaring STEADY
271    /// while the engine sees TILT pays the full L2 typed-confirm
272    /// cost. Passing `None` is resolved to the command so the
273    /// dispatcher can emit a usage hint with the valid labels.
274    StateOverride {
275        label: Option<StateOverrideLabel>,
276    },
277    /// `/continue` — acknowledge the most-recent coaching
278    /// notice and resume. No-op when no coaching is queued.
279    /// Neutral risk (pure acknowledgement).
280    Continue,
281    /// `/close [coin]` — close a single position. Per-coin
282    /// sibling to `/flatten-all`; risk-*reducing*, friction-
283    /// exempt at every state. `coin` is optional — a bare
284    /// `/close` resolves to the most recently actioned symbol
285    /// (when the positions model ships). For now the handler
286    /// surfaces a "pending positions model" line rather than
287    /// pretending to close anything — silence here would be
288    /// the worst possible failure mode for a risk-reducer.
289    Close {
290        coin: Option<String>,
291    },
292    /// `/wrap-off` — skip the daily wrap for *this session only*.
293    /// The next session runs the wrap again (per ADDENDUM_A §9.1,
294    /// the opt-out cannot be sticky). Neutral risk.
295    WrapOff,
296    /// `/coaching reset` — clear the rolling coaching notice
297    /// buffer. Neutral risk. Kept distinct from `/clear` (which
298    /// empties the whole conversation log) because operators
299    /// sometimes want to quiet coaching without losing the
300    /// decision trail.
301    CoachingReset,
302    /// `/disclosure-override --i-know-what-i-am-doing` — jump
303    /// ahead in progressive disclosure. Risk-*increasing*: the
304    /// operator is defeating a guardrail designed to throttle
305    /// feature exposure to earned competence. The `confirmed`
306    /// flag carries whether the literal phrase was typed. A
307    /// bare `/disclosure-override` or a typo in the phrase
308    /// resolves to `confirmed = false` so the dispatcher can
309    /// emit a usage hint naming the exact words required —
310    /// silent rejection would make the command seem broken.
311    DisclosureOverride {
312        confirmed: bool,
313    },
314    /// `/rate <trade_id> <1..=10>` — attach a conviction rating
315    /// to a past trade, feeding the operator-state classifier
316    /// and the eventual calibration overlay (Addendum A §10,
317    /// M1_PLAN §7a line 119). Neutral risk: a rating is a
318    /// self-report about a closed trade, not a position-change.
319    ///
320    /// Parse semantics: the rating is an integer in `1..=10`
321    /// (spec wording; the classifier's event field is `u8`).
322    /// Values outside the range, non-numeric tokens, or a
323    /// missing argument resolve to `rating = None` so the
324    /// dispatcher can emit a usage hint citing the full range
325    /// — silently clamping to 1 or 10 would launder a typo
326    /// into a recorded conviction. `trade_id` is passed
327    /// through verbatim (it's the engine's opaque identifier);
328    /// an empty one resolves to `None` and the same usage path.
329    ///
330    /// The handler is an **honest stub** for the engine POST
331    /// half: the rating is journaled locally via the operator-
332    /// state sink (so the classifier observes it deterministically
333    /// on replay), and the pane line says "recorded locally;
334    /// engine POST pending" so the operator never infers a
335    /// silent server-side success. The engine-side POST lands
336    /// with the rest of the ADR-016 operator-state writes.
337    Rate {
338        trade_id: Option<String>,
339        rating: Option<u8>,
340    },
341    /// Operator typed a shell-style invocation like `zero doctor`
342    /// inside the TUI prompt. Carried separately from
343    /// [`Command::Unknown`] so the dispatcher can emit a targeted
344    /// "you're already inside zero — did you mean /<rest>?" hint
345    /// instead of the generic "unknown command" warning.
346    ///
347    /// `rest` is the whitespace-joined args exactly as typed, so
348    /// the hint can reproduce the operator's intent verbatim
349    /// (`zero --version` → hint mentions `/version`,
350    /// `zero` alone → hint mentions no-command). The hint is
351    /// produced at dispatch time, not at parse time, so the
352    /// exact wording stays next to the other user-facing copy.
353    ZeroPrefix {
354        rest: String,
355    },
356    /// `/auto on | off | status` — toggle Auto mode, which
357    /// instructs the engine to take Plan-mode verdicts **without**
358    /// operator confirmation. Risk-*increasing*: flipping the
359    /// engine from a gated Plan posture to an auto-accept posture
360    /// unlocks engine-initiated position changes, the same kind
361    /// of exposure surface `/execute` opens. The friction ladder
362    /// gates `/auto on` exactly like `/execute` — a TILT operator
363    /// unlocking auto-acceptance at 2 AM is the canonical tired-
364    /// operator footgun.
365    ///
366    /// `/auto off` and `/auto status` are **Neutral** — turning
367    /// the accelerator off is a risk-*reducer*-shaped action and
368    /// status is read-only. Risk direction therefore depends on
369    /// the action; [`Self::risk`] resolves it by inspecting the
370    /// [`AutoAction`] carried here. A bare `/auto` resolves to
371    /// [`AutoAction::Missing`] so the dispatcher can emit a usage
372    /// hint rather than a silent toggle — guessing the operator's
373    /// intent at this much exposure would be an honesty failure.
374    Auto {
375        action: AutoAction,
376    },
377    /// `/headless start | stop | status` — spawn / stop / query
378    /// the operator-local supervisor daemon (ADR-006). The
379    /// command itself is **Neutral**: starting the daemon does
380    /// not take new positions (the daemon is a watchdog + kill-
381    /// switch surface), stopping it removes the supervisor but
382    /// does not touch live exposure, and status is read-only.
383    ///
384    /// The CLI does not implement the supervisor; dispatch
385    /// routes each action through the [`crate::SupervisorSource`]
386    /// trait on [`DispatchContext`]. When no adapter is
387    /// attached (tests + `--no-persist` paths), the dispatcher
388    /// emits a single "headless supervisor unavailable" alert
389    /// rather than pretending. Unknown / missing actions route
390    /// to a usage hint — same honesty contract as `/config`.
391    Headless {
392        action: HeadlessAction,
393    },
394    Unknown(String),
395}
396
397/// Labels the operator may self-declare via `/state-override`.
398///
399/// Mirrors `zero_operator_state::Label` shape-wise but lives
400/// here so the command crate does not take a dep on operator-
401/// state types just to parse an argument. The adapter in the
402/// TUI / engine routes each variant to the real classifier
403/// label without an additional mapping layer — the names are
404/// one-to-one by design.
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum StateOverrideLabel {
407    Fresh,
408    Steady,
409    Elevated,
410    Tilt,
411    Fatigued,
412    Recovery,
413}
414
415impl StateOverrideLabel {
416    #[must_use]
417    pub const fn as_str(self) -> &'static str {
418        match self {
419            Self::Fresh => "FRESH",
420            Self::Steady => "STEADY",
421            Self::Elevated => "ELEVATED",
422            Self::Tilt => "TILT",
423            Self::Fatigued => "FATIGUED",
424            Self::Recovery => "RECOVERY",
425        }
426    }
427
428    /// Parse a caller-supplied token. Case-insensitive. Returns
429    /// `None` on an unrecognized label so the parser can route
430    /// to the usage-hint arm.
431    #[must_use]
432    pub fn parse(s: &str) -> Option<Self> {
433        match s.trim().to_ascii_uppercase().as_str() {
434            "FRESH" => Some(Self::Fresh),
435            "STEADY" => Some(Self::Steady),
436            "ELEVATED" => Some(Self::Elevated),
437            "TILT" => Some(Self::Tilt),
438            "FATIGUED" => Some(Self::Fatigued),
439            "RECOVERY" => Some(Self::Recovery),
440            _ => None,
441        }
442    }
443}
444
445/// The exact phrase `/disclosure-override` requires. Declared
446/// here so tests, the parser, and the help text all reference
447/// the same string — drift between what the help says and what
448/// the parser accepts would be an honesty bug.
449pub const DISCLOSURE_OVERRIDE_CONFIRM: &str = "--i-know-what-i-am-doing";
450
451/// The `/verbose` argument.
452#[derive(Debug, Clone, PartialEq, Eq)]
453pub enum VerboseAction {
454    On,
455    Off,
456    Toggle,
457    Unknown(String),
458}
459
460/// The `/config` subcommand.
461///
462/// [`ConfigAction::Missing`] models the no-arg invocation (so
463/// the dispatcher can emit a usage hint without string-parsing
464/// the variant back). [`ConfigAction::Unknown`] preserves the
465/// typed token so the usage line can say exactly what was
466/// rejected — silent acceptance of an unknown action would
467/// leave operators wondering whether the command ran.
468#[derive(Debug, Clone, PartialEq, Eq)]
469pub enum ConfigAction {
470    Show,
471    Doctor,
472    Missing,
473    Unknown(String),
474}
475
476/// The `/auto` subcommand.
477///
478/// Only [`AutoAction::On`] is risk-*increasing*; the others are
479/// Neutral. [`AutoAction::Missing`] models a bare `/auto` so the
480/// dispatcher can emit a usage hint instead of silently toggling.
481/// [`AutoAction::Unknown`] preserves the typed token so the hint
482/// can quote it back.
483#[derive(Debug, Clone, PartialEq, Eq)]
484pub enum AutoAction {
485    On,
486    Off,
487    Status,
488    Missing,
489    Unknown(String),
490}
491
492impl AutoAction {
493    /// `true` when the action flips auto-acceptance from off to on.
494    /// The dispatcher's `risk()` function keys off this so the
495    /// friction ladder gates `/auto on` but not `/auto off|status`.
496    #[must_use]
497    pub const fn is_risk_increasing(&self) -> bool {
498        matches!(self, Self::On)
499    }
500}
501
502/// The `/headless` subcommand.
503///
504/// All variants are Neutral (see [`Command::Headless`] docs). The
505/// `Missing` / `Unknown` variants let the dispatcher emit usage
506/// hints with the exact token the operator typed — identical
507/// honesty contract to [`ConfigAction`].
508#[derive(Debug, Clone, PartialEq, Eq)]
509pub enum HeadlessAction {
510    Start,
511    Stop,
512    Status,
513    Missing,
514    Unknown(String),
515}
516
517impl Command {
518    /// Compile-time-style risk classification. `const` so callers
519    /// can `match` in const contexts (e.g. CI lints).
520    #[must_use]
521    pub const fn risk(&self) -> RiskDirection {
522        match self {
523            // Navigation / info — no exposure change.
524            Self::Help
525            | Self::Clear
526            | Self::SwitchMode(_)
527            | Self::Status
528            | Self::Brief
529            | Self::Risk
530            | Self::HyperliquidStatus { .. }
531            | Self::HyperliquidAccount
532            | Self::HyperliquidReconcile
533            | Self::LiveCertify
534            | Self::LiveCockpit
535            | Self::LiveEvidence
536            | Self::LiveReceipts
537            | Self::LiveCanaryPolicy
538            | Self::RuntimeParity
539            | Self::Immune
540            | Self::Quote { .. }
541            | Self::Regime { .. }
542            | Self::Evaluate { .. }
543            | Self::Positions
544            | Self::Pulse { .. }
545            | Self::Approaching
546            | Self::Rejections { .. }
547            | Self::State
548            | Self::Heat
549            | Self::Sessions { .. }
550            | Self::Resume { .. }
551            | Self::Fork
552            | Self::Save { .. }
553            | Self::Replay { .. }
554            | Self::Share { .. }
555            | Self::Config { .. }
556            | Self::Verbose { .. }
557            | Self::Continue
558            | Self::WrapOff
559            | Self::CoachingReset
560            | Self::Rate { .. }
561            | Self::ZeroPrefix { .. }
562            | Self::Headless { .. }
563            | Self::Unknown(_) => RiskDirection::Neutral,
564
565            // Risk-reducing. Instant, friction-exempt, always honored.
566            // `/quit` is a risk-reducer because the operator is
567            // stepping away from the terminal. `/close` is per-coin
568            // position close — sibling to `/flatten-all` and sharing
569            // its Reduces classification so the friction-asymmetry
570            // invariant ( `Reduces` never gated ) covers both paths.
571            Self::Quit
572            | Self::Kill
573            | Self::FlattenAll
574            | Self::PauseEntries
575            | Self::Break { .. }
576            | Self::Close { .. } => RiskDirection::Reduces,
577
578            // Risk-increasing. Subject to the friction ladder.
579            // `/state-override` and `/disclosure-override` are
580            // both operator self-declarations that defeat a
581            // guardrail; the ladder must gate them for the same
582            // reason it gates `/execute`.
583            Self::Execute
584            | Self::ResumeEntries
585            | Self::StateOverride { .. }
586            | Self::DisclosureOverride { .. } => RiskDirection::Increases,
587
588            Self::ExecuteOrder {
589                coin,
590                side,
591                size,
592                error,
593            } => {
594                if coin.is_some() && side.is_some() && size.is_some() && error.is_none() {
595                    RiskDirection::Increases
596                } else {
597                    RiskDirection::Neutral
598                }
599            }
600
601            // `/auto`'s risk direction depends on the action. `on`
602            // unlocks engine-initiated position changes and joins
603            // the friction ladder as Increases; `off` and `status`
604            // are Neutral (turning the accelerator off / reading
605            // state). `Missing` / `Unknown` are resolved to the
606            // command so the dispatcher can emit a usage hint —
607            // treating them as Neutral keeps the unresolved form
608            // un-gated (typing `/auto` alone should not trip L2
609            // friction at TILT just because the operator wanted
610            // to see the usage line).
611            Self::Auto { action } => {
612                if action.is_risk_increasing() {
613                    RiskDirection::Increases
614                } else {
615                    RiskDirection::Neutral
616                }
617            }
618        }
619    }
620
621    /// Display name for `/help` and the picker.
622    #[must_use]
623    pub const fn name(&self) -> &'static str {
624        match self {
625            Self::Help => "/help",
626            Self::Quit => "/quit",
627            Self::Clear => "/clear",
628            Self::SwitchMode(ModeTarget::Conversation) => "/conv",
629            Self::SwitchMode(ModeTarget::Positions) => "/positions (mode)",
630            Self::SwitchMode(ModeTarget::Decisions) => "/decisions",
631            Self::SwitchMode(ModeTarget::Heat) => "/heat-mode",
632            Self::SwitchMode(ModeTarget::Cockpit) => "/cockpit-mode",
633            Self::Heat => "/heat",
634            Self::Status => "/status",
635            Self::Brief => "/brief",
636            Self::Risk => "/risk",
637            Self::HyperliquidStatus { .. } => "/hl-status",
638            Self::HyperliquidAccount => "/hl-account",
639            Self::HyperliquidReconcile => "/hl-reconcile",
640            Self::LiveCertify => "/live-certify",
641            Self::LiveCockpit => "/live-cockpit",
642            Self::LiveEvidence => "/live-evidence",
643            Self::LiveReceipts => "/live-receipts",
644            Self::LiveCanaryPolicy => "/live-canary",
645            Self::RuntimeParity => "/runtime-parity",
646            Self::Immune => "/immune",
647            Self::Quote { .. } => "/quote",
648            Self::Regime { .. } => "/regime",
649            Self::Evaluate { .. } => "/evaluate",
650            Self::Positions => "/pos",
651            Self::Pulse { .. } => "/pulse",
652            Self::Approaching => "/approaching",
653            Self::Rejections { .. } => "/rejections",
654            Self::Kill => "/kill",
655            Self::FlattenAll => "/flatten-all",
656            Self::PauseEntries => "/pause-entries",
657            Self::ResumeEntries => "/resume-entries",
658            Self::Break { .. } => "/break",
659            Self::Execute | Self::ExecuteOrder { .. } => "/execute",
660            Self::State => "/state",
661            Self::Sessions { .. } => "/sessions",
662            Self::Resume { .. } => "/resume",
663            Self::Fork => "/fork",
664            Self::Save { .. } => "/save",
665            Self::Replay { .. } => "/replay",
666            Self::Share { .. } => "/share",
667            Self::Config { .. } => "/config",
668            Self::Verbose { .. } => "/verbose",
669            Self::StateOverride { .. } => "/state-override",
670            Self::Continue => "/continue",
671            Self::Close { .. } => "/close",
672            Self::WrapOff => "/wrap-off",
673            Self::CoachingReset => "/coaching reset",
674            Self::DisclosureOverride { .. } => "/disclosure-override",
675            Self::Rate { .. } => "/rate",
676            Self::ZeroPrefix { .. } => "(zero-prefix)",
677            Self::Auto { .. } => "/auto",
678            Self::Headless { .. } => "/headless",
679            Self::Unknown(_) => "(unknown)",
680        }
681    }
682
683    /// Default limit for `/pulse` when the operator omits it.
684    /// Chosen to fit comfortably in a scroll-less conversation
685    /// pane but still be meaningful; the HTTP layer clamps to
686    /// `1..=100` so raising this later is safe.
687    #[must_use]
688    pub const fn default_pulse_limit() -> u32 {
689        20
690    }
691
692    /// Default limit for `/rejections` when the operator omits it.
693    /// Matches `/pulse` for visual parity; the HTTP layer clamps
694    /// to `1..=500`.
695    #[must_use]
696    pub const fn default_rejections_limit() -> u32 {
697        20
698    }
699
700    /// Default limit for `/sessions` when the operator omits it.
701    /// Twenty rows is "everything I did this week" for a typical
702    /// session cadence; higher values make the pane scroll to read
703    /// the newest entries, which defeats the purpose of a listing.
704    /// Callers in `dispatch` clamp above this so `/sessions 1000`
705    /// still shows a tight, navigable list.
706    #[must_use]
707    pub const fn default_sessions_limit() -> u32 {
708        20
709    }
710
711    /// Hard ceiling on `/sessions` so a stray high value cannot
712    /// spawn a multi-page readout that hides the prompt.
713    #[must_use]
714    pub const fn max_sessions_limit() -> u32 {
715        50
716    }
717}
718
719/// Static catalog of user-visible slash commands, exposed for
720/// command pickers / help pages / documentation generators.
721///
722/// Kept in *listing order* (not alphabetical): diagnostics first,
723/// then live read-outs, then risk-reducing levers, then the gated
724/// risk-increasing action. Mode-switchers are omitted because
725/// operators reach them via `Ctrl+1..5`; leaving them out of the
726/// picker prevents stray mode changes from a mis-typed `/`.
727pub const COMMAND_CATALOG: &[CommandInfo] = &[
728    CommandInfo {
729        name: "/help",
730        summary: "list commands",
731        risk: RiskDirection::Neutral,
732    },
733    CommandInfo {
734        name: "/status",
735        summary: "operator + engine snapshot",
736        risk: RiskDirection::Neutral,
737    },
738    CommandInfo {
739        name: "/brief",
740        summary: "one-line situation readout",
741        risk: RiskDirection::Neutral,
742    },
743    CommandInfo {
744        name: "/risk",
745        summary: "risk posture",
746        risk: RiskDirection::Neutral,
747    },
748    CommandInfo {
749        name: "/hl-status",
750        summary: "read-only Hyperliquid info status",
751        risk: RiskDirection::Neutral,
752    },
753    CommandInfo {
754        name: "/hl-account",
755        summary: "read-only Hyperliquid account truth",
756        risk: RiskDirection::Neutral,
757    },
758    CommandInfo {
759        name: "/hl-reconcile",
760        summary: "Hyperliquid account reconciliation",
761        risk: RiskDirection::Neutral,
762    },
763    CommandInfo {
764        name: "/live-certify",
765        summary: "dry-run live certification harness",
766        risk: RiskDirection::Neutral,
767    },
768    CommandInfo {
769        name: "/live-cockpit",
770        summary: "live readiness cockpit",
771        risk: RiskDirection::Neutral,
772    },
773    CommandInfo {
774        name: "/live-evidence",
775        summary: "hash-only live evidence bundle",
776        risk: RiskDirection::Neutral,
777    },
778    CommandInfo {
779        name: "/live-receipts",
780        summary: "public-safe execution receipts",
781        risk: RiskDirection::Neutral,
782    },
783    CommandInfo {
784        name: "/live-canary",
785        summary: "canary readiness and proof policy",
786        risk: RiskDirection::Neutral,
787    },
788    CommandInfo {
789        name: "/runtime-parity",
790        summary: "production-parity OODA report",
791        risk: RiskDirection::Neutral,
792    },
793    CommandInfo {
794        name: "/immune",
795        summary: "immune breaker state",
796        risk: RiskDirection::Neutral,
797    },
798    CommandInfo {
799        name: "/quote",
800        summary: "active paper quote source",
801        risk: RiskDirection::Neutral,
802    },
803    CommandInfo {
804        name: "/heat",
805        summary: "composite heat (risk + circuit)",
806        risk: RiskDirection::Neutral,
807    },
808    CommandInfo {
809        name: "/regime",
810        summary: "market regime (optional coin)",
811        risk: RiskDirection::Neutral,
812    },
813    CommandInfo {
814        name: "/evaluate",
815        summary: "gate verdict for a coin (overlay)",
816        risk: RiskDirection::Neutral,
817    },
818    CommandInfo {
819        name: "/pos",
820        summary: "open positions",
821        risk: RiskDirection::Neutral,
822    },
823    CommandInfo {
824        name: "/pulse",
825        summary: "recent engine events",
826        risk: RiskDirection::Neutral,
827    },
828    CommandInfo {
829        name: "/approaching",
830        summary: "coins near a gate",
831        risk: RiskDirection::Neutral,
832    },
833    CommandInfo {
834        name: "/rejections",
835        summary: "recent gate rejections",
836        risk: RiskDirection::Neutral,
837    },
838    CommandInfo {
839        name: "/state",
840        summary: "operator-state overlay",
841        risk: RiskDirection::Neutral,
842    },
843    CommandInfo {
844        name: "/sessions",
845        summary: "list recent sessions",
846        risk: RiskDirection::Neutral,
847    },
848    CommandInfo {
849        name: "/resume",
850        summary: "replay a past session into the log",
851        risk: RiskDirection::Neutral,
852    },
853    CommandInfo {
854        name: "/fork",
855        summary: "start a new session, linked to this one",
856        risk: RiskDirection::Neutral,
857    },
858    CommandInfo {
859        name: "/save",
860        summary: "label the current session",
861        risk: RiskDirection::Neutral,
862    },
863    CommandInfo {
864        name: "/replay",
865        summary: "show a past session without switching",
866        risk: RiskDirection::Neutral,
867    },
868    CommandInfo {
869        name: "/share",
870        summary: "dump a session as copyable JSON",
871        risk: RiskDirection::Neutral,
872    },
873    CommandInfo {
874        name: "/config show",
875        summary: "show resolved config values",
876        risk: RiskDirection::Neutral,
877    },
878    CommandInfo {
879        name: "/config doctor",
880        summary: "self-diagnose config + secrets",
881        risk: RiskDirection::Neutral,
882    },
883    CommandInfo {
884        name: "/verbose",
885        summary: "toggle rich log timestamps",
886        risk: RiskDirection::Neutral,
887    },
888    CommandInfo {
889        name: "/continue",
890        summary: "acknowledge coaching notice",
891        risk: RiskDirection::Neutral,
892    },
893    CommandInfo {
894        name: "/rate",
895        summary: "attach conviction rating to a past trade",
896        risk: RiskDirection::Neutral,
897    },
898    CommandInfo {
899        name: "/coaching reset",
900        summary: "clear coaching notice buffer",
901        risk: RiskDirection::Neutral,
902    },
903    CommandInfo {
904        name: "/wrap-off",
905        summary: "skip the daily wrap (this session only)",
906        risk: RiskDirection::Neutral,
907    },
908    CommandInfo {
909        name: "/clear",
910        summary: "clear conversation log",
911        risk: RiskDirection::Neutral,
912    },
913    CommandInfo {
914        name: "/pause-entries",
915        summary: "block new positions",
916        risk: RiskDirection::Reduces,
917    },
918    CommandInfo {
919        name: "/break",
920        summary: "operator-initiated pause",
921        risk: RiskDirection::Reduces,
922    },
923    CommandInfo {
924        name: "/close",
925        summary: "close one position (per-coin)",
926        risk: RiskDirection::Reduces,
927    },
928    CommandInfo {
929        name: "/flatten-all",
930        summary: "close all positions",
931        risk: RiskDirection::Reduces,
932    },
933    CommandInfo {
934        name: "/kill",
935        summary: "hard stop — close + halt",
936        risk: RiskDirection::Reduces,
937    },
938    CommandInfo {
939        name: "/quit",
940        summary: "exit the CLI",
941        risk: RiskDirection::Reduces,
942    },
943    CommandInfo {
944        name: "/state-override",
945        summary: "declare operator-state label (gated)",
946        risk: RiskDirection::Increases,
947    },
948    CommandInfo {
949        name: "/disclosure-override",
950        summary: "bypass progressive disclosure (gated)",
951        risk: RiskDirection::Increases,
952    },
953    CommandInfo {
954        name: "/execute",
955        summary: "place a new order: /execute BTC buy 0.001 (gated)",
956        risk: RiskDirection::Increases,
957    },
958    // `/auto on` joins the friction ladder. The catalog row is
959    // labeled Increases — that is the most dangerous action on
960    // the command and the one pickers should colour-code for.
961    // `/auto off|status` land on the same head and degrade to
962    // Neutral at dispatch time; a single row keeps the picker
963    // clean.
964    CommandInfo {
965        name: "/auto",
966        summary: "toggle auto-accept (on: gated, off/status: neutral)",
967        risk: RiskDirection::Increases,
968    },
969    CommandInfo {
970        name: "/headless",
971        summary: "start/stop/status the supervisor daemon",
972        risk: RiskDirection::Neutral,
973    },
974];
975
976/// Picker-facing row. Keep the struct tiny: `name` is the literal
977/// the picker inserts on Tab; `summary` is the human-readable
978/// description rendered to the right.
979#[derive(Debug, Clone, Copy)]
980pub struct CommandInfo {
981    pub name: &'static str,
982    pub summary: &'static str,
983    pub risk: RiskDirection,
984}
985
986/// Resolve a parsed line to a [`Command`]. Unrecognized heads
987/// resolve to [`Command::Unknown`]; empty input returns `None` so
988/// the caller can skip dispatch silently.
989//
990// The function is long by design — it is a single flat dispatch
991// table from canonical head → Command variant, plus a handful of
992// multi-token subcommands (`/config`, `/coaching`, `/disclosure-
993// override`). Splitting the arms into helper fns would scatter a
994// grep-able, single-source registry across the file and trade
995// legibility for a line count. The `match` itself is where
996// reviewers look to verify "is `/foo` a thing, and what does it
997// parse to?" — we keep it in one place.
998#[must_use]
999#[allow(clippy::too_many_lines)]
1000pub fn resolve(line: &ParsedLine) -> Option<Command> {
1001    if line.is_empty() {
1002        return None;
1003    }
1004    let head = line.canonical_head();
1005    let cmd = match head.as_str() {
1006        "help" | "?" => Command::Help,
1007        "quit" | "exit" | "q" => Command::Quit,
1008        "clear" | "cls" => Command::Clear,
1009        "conv" | "conversation" => Command::SwitchMode(ModeTarget::Conversation),
1010        "pos-mode" | "positions-mode" => Command::SwitchMode(ModeTarget::Positions),
1011        "decisions" => Command::SwitchMode(ModeTarget::Decisions),
1012        // `heat` is the inline readout; the mode-switch variant is
1013        // still reachable for operators who explicitly want the full
1014        // pane (via `Ctrl+4` or `/heat-mode`). Keeping the short word
1015        // on the inline command matches operator expectation: typing
1016        // `/heat` should answer "how hot am I?" in the same pane.
1017        "heat" => Command::Heat,
1018        "heat-mode" | "heatmode" => Command::SwitchMode(ModeTarget::Heat),
1019        "cockpit-mode" | "live-mode" | "live-board" => Command::SwitchMode(ModeTarget::Cockpit),
1020        "status" => Command::Status,
1021        "brief" => Command::Brief,
1022        "risk" => Command::Risk,
1023        "hl-status" | "hl" | "hyperliquid" => Command::HyperliquidStatus {
1024            symbol: line.args.first().cloned(),
1025        },
1026        "hl-account" | "hyperliquid-account" => Command::HyperliquidAccount,
1027        "hl-reconcile" | "reconcile" | "hyperliquid-reconcile" => Command::HyperliquidReconcile,
1028        "live-certify" | "certify-live" | "live-certification" => Command::LiveCertify,
1029        "live-cockpit" | "cockpit" | "live" => Command::LiveCockpit,
1030        "live-evidence" | "evidence" | "canary-evidence" => Command::LiveEvidence,
1031        "live-receipts" | "receipts" | "execution-receipts" | "live-execution-receipts" => {
1032            Command::LiveReceipts
1033        }
1034        "live-canary" | "live-canary-policy" | "canary" | "canary-policy" => {
1035            Command::LiveCanaryPolicy
1036        }
1037        "runtime-parity" | "parity" | "ooda-parity" | "production-parity" => Command::RuntimeParity,
1038        "immune" | "breakers" | "circuit-breakers" => Command::Immune,
1039        "quote" | "price" => Command::Quote {
1040            symbol: line.args.first().cloned(),
1041        },
1042        "regime" => Command::Regime {
1043            coin: line.args.first().cloned(),
1044        },
1045        "evaluate" | "eval" => Command::Evaluate {
1046            coin: line.args.first().cloned(),
1047            extras: line.args.iter().skip(1).cloned().collect(),
1048        },
1049        "positions" | "pos" => Command::Positions,
1050        "pulse" => Command::Pulse {
1051            limit: line.args.first().and_then(|s| s.parse::<u32>().ok()),
1052        },
1053        "approaching" | "near" => Command::Approaching,
1054        "rejections" | "rej" => {
1055            // Accept the two args in any order: the first numeric
1056            // token resolves to `limit`, the first non-numeric to
1057            // `coin`. Keeps `/rejections BTC` and `/rejections 50`
1058            // and `/rejections BTC 50` all doing the obvious thing.
1059            let mut coin: Option<String> = None;
1060            let mut limit: Option<u32> = None;
1061            for a in &line.args {
1062                if let Ok(n) = a.parse::<u32>() {
1063                    if limit.is_none() {
1064                        limit = Some(n);
1065                    }
1066                } else if coin.is_none() {
1067                    coin = Some(a.clone());
1068                }
1069            }
1070            Command::Rejections { coin, limit }
1071        }
1072        "kill" => Command::Kill,
1073        "flatten-all" | "flatten" => Command::FlattenAll,
1074        "pause-entries" | "pause" => Command::PauseEntries,
1075        "resume-entries" | "live-resume" => Command::ResumeEntries,
1076        "break" => Command::Break {
1077            minutes: line.args.first().and_then(|s| s.parse::<u32>().ok()),
1078        },
1079        "execute" | "exec" | "e" => resolve_execute(&line.args),
1080        "state" => Command::State,
1081        "sessions" | "ls-sessions" => Command::Sessions {
1082            limit: line.args.first().and_then(|s| s.parse::<u32>().ok()),
1083        },
1084        "resume" => Command::Resume {
1085            needle: line.args.first().cloned(),
1086        },
1087        "fork" => Command::Fork,
1088        "save" => Command::Save {
1089            label: line.args.first().cloned(),
1090        },
1091        "replay" => Command::Replay {
1092            needle: line.args.first().cloned(),
1093        },
1094        "share" | "export" => Command::Share {
1095            needle: line.args.first().cloned(),
1096        },
1097        "state-override" | "stateoverride" => {
1098            let label = line.args.first().and_then(|s| StateOverrideLabel::parse(s));
1099            Command::StateOverride { label }
1100        }
1101        "continue" | "cont" => Command::Continue,
1102        "rate" => {
1103            // `/rate <trade_id> <rating>`. Either argument may
1104            // be missing; the dispatcher surfaces a usage hint
1105            // on any shape other than "id + numeric in 1..=10".
1106            // We route the *numeric* argument to `rating`
1107            // regardless of position so operators can type
1108            // `/rate 8 t-001` without getting a silent miss —
1109            // picking the first parsable u8 lets the id-first
1110            // and rating-first orders resolve identically.
1111            let mut trade_id: Option<String> = None;
1112            let mut rating: Option<u8> = None;
1113            for a in &line.args {
1114                if rating.is_none()
1115                    && let Ok(n) = a.parse::<u8>()
1116                    && (1..=10).contains(&n)
1117                {
1118                    rating = Some(n);
1119                    continue;
1120                }
1121                if trade_id.is_none() {
1122                    trade_id = Some(a.clone());
1123                }
1124            }
1125            Command::Rate { trade_id, rating }
1126        }
1127        "close" => Command::Close {
1128            coin: line.args.first().cloned(),
1129        },
1130        "wrap-off" | "wrapoff" => Command::WrapOff,
1131        // `/coaching reset` is a two-token form. A bare
1132        // `/coaching` without `reset` resolves to Unknown so
1133        // the usage hint fires — there is no other coaching
1134        // subcommand today and silent acceptance would be a
1135        // lie about what the command does. A single-token
1136        // `/coaching-reset` alias is accepted for operators who
1137        // prefer the dash form (consistent with /wrap-off).
1138        "coaching" => match line.args.first().map(|s| s.to_ascii_lowercase()).as_deref() {
1139            Some("reset") => Command::CoachingReset,
1140            Some(other) => Command::Unknown(format!("coaching {other}")),
1141            None => Command::Unknown("coaching".to_owned()),
1142        },
1143        "coaching-reset" => Command::CoachingReset,
1144        "disclosure-override" | "disclosureoverride" => {
1145            // Require the exact confirm flag. Accept it as
1146            // either a raw arg ("--i-know-what-i-am-doing")
1147            // or as a bareword with the leading dashes
1148            // stripped so operators who hand-type the phrase
1149            // land in the same place.
1150            let confirmed = line.args.iter().any(|a| {
1151                a == DISCLOSURE_OVERRIDE_CONFIRM
1152                    || a.trim_start_matches('-')
1153                        == DISCLOSURE_OVERRIDE_CONFIRM.trim_start_matches('-')
1154            });
1155            Command::DisclosureOverride { confirmed }
1156        }
1157        "verbose" => {
1158            let action = match line.args.first().map(|s| s.to_ascii_lowercase()).as_deref() {
1159                None | Some("toggle") => VerboseAction::Toggle,
1160                Some("on" | "1" | "true") => VerboseAction::On,
1161                Some("off" | "0" | "false") => VerboseAction::Off,
1162                Some(other) => VerboseAction::Unknown(other.to_owned()),
1163            };
1164            Command::Verbose { action }
1165        }
1166        "config" => {
1167            let action = match line.args.first().map(String::as_str) {
1168                None => ConfigAction::Missing,
1169                Some("show" | "view" | "list" | "ls") => ConfigAction::Show,
1170                Some("doctor" | "diag" | "diagnose" | "check") => ConfigAction::Doctor,
1171                Some(other) => ConfigAction::Unknown(other.to_owned()),
1172            };
1173            Command::Config { action }
1174        }
1175        // `/auto` subcommand. Case-insensitive first arg.
1176        // Unknown / missing arg resolves to a usage hint at
1177        // dispatch time — silent acceptance of `/auto foo`
1178        // would be a honesty bug on a risk-increasing surface.
1179        // `true` / `1` / `false` / `0` are accepted as friendly
1180        // aliases for `on` / `off` because scripts and muscle
1181        // memory will both reach for them.
1182        "auto" => {
1183            let action = match line.args.first().map(|s| s.to_ascii_lowercase()).as_deref() {
1184                None => AutoAction::Missing,
1185                Some("on" | "1" | "true") => AutoAction::On,
1186                Some("off" | "0" | "false") => AutoAction::Off,
1187                Some("status" | "stat" | "show") => AutoAction::Status,
1188                Some(other) => AutoAction::Unknown(other.to_owned()),
1189            };
1190            Command::Auto { action }
1191        }
1192        // `/headless` subcommand. Mirrors `/auto` for the
1193        // parser — case-insensitive, missing / unknown arg
1194        // resolves to a usage hint. No `true` / `false`
1195        // aliases: `start` / `stop` are the spawn-ish verbs
1196        // operators reach for and conflating them with booleans
1197        // would obscure the daemon lifecycle.
1198        "headless" => {
1199            let action = match line.args.first().map(|s| s.to_ascii_lowercase()).as_deref() {
1200                None => HeadlessAction::Missing,
1201                Some("start" | "up") => HeadlessAction::Start,
1202                Some("stop" | "down") => HeadlessAction::Stop,
1203                Some("status" | "stat" | "show") => HeadlessAction::Status,
1204                Some(other) => HeadlessAction::Unknown(other.to_owned()),
1205            };
1206            Command::Headless { action }
1207        }
1208        // `/doctor` is a top-level alias for `/config doctor`.
1209        // An operator hitting a broken-auth / broken-engine
1210        // state is going to type the single most obvious word
1211        // ("doctor") before they think about namespacing. The
1212        // original nested-only form is preserved so operators
1213        // who think in `/config` still find it there, and both
1214        // routes hit the same dispatch arm. Aliases like `diag`
1215        // / `diagnose` / `check` also resolve here so the
1216        // top-level surface matches the nested one 1:1.
1217        "doctor" | "diag" | "diagnose" | "check" => Command::Config {
1218            action: ConfigAction::Doctor,
1219        },
1220        // An operator in a broken session naturally re-types what
1221        // the README + `zero --help` told them to run: `zero
1222        // doctor`, `zero init`, `zero --version`. Inside the TUI
1223        // that parses as head=`zero`, so falling through to
1224        // `Command::Unknown` would give the generic "unknown
1225        // command: /zero" — technically true, operationally
1226        // cruel. Intercept here and carry the tail verbatim so
1227        // the dispatcher can emit a teaching hint that names the
1228        // correct in-TUI form (`/doctor`, `/init` if/when it
1229        // ships, `/version` if/when it ships). Works only for
1230        // the literal bareword `zero` at column 0 — a slash
1231        // `/zero` or a suffix (`zero-foo`) falls through
1232        // normally. Args are re-joined with single spaces; this
1233        // loses original whitespace but the hint only needs to
1234        // reproduce intent, not be round-trippable.
1235        "zero" => Command::ZeroPrefix {
1236            rest: line.args.join(" "),
1237        },
1238        _ => Command::Unknown(head),
1239    };
1240    Some(cmd)
1241}
1242
1243fn resolve_execute(args: &[String]) -> Command {
1244    if args.is_empty() {
1245        return Command::Execute;
1246    }
1247    let coin = args.first().map(|coin| coin.to_ascii_uppercase());
1248    let direction = args
1249        .get(1)
1250        .and_then(|raw| match raw.to_ascii_lowercase().as_str() {
1251            "buy" | "long" | "bid" => Some(ExecuteSide::Buy),
1252            "sell" | "short" | "ask" => Some(ExecuteSide::Sell),
1253            _ => None,
1254        });
1255    let quantity = args.get(2).cloned();
1256    let error = if args.len() > 3 {
1257        Some("too many arguments".to_string())
1258    } else if args.is_empty() {
1259        Some("missing coin, side, and size".to_string())
1260    } else if coin.is_none() {
1261        Some("missing coin".to_string())
1262    } else if args.get(1).is_none() {
1263        Some("missing side".to_string())
1264    } else if direction.is_none() {
1265        Some("side must be buy or sell".to_string())
1266    } else if args.get(2).is_none() {
1267        Some("missing size".to_string())
1268    } else if quantity
1269        .as_deref()
1270        .and_then(|value| value.parse::<f64>().ok())
1271        .is_none_or(|value| !value.is_finite() || value <= 0.0)
1272    {
1273        Some("size must be a positive number".to_string())
1274    } else {
1275        None
1276    };
1277    Command::ExecuteOrder {
1278        coin,
1279        side: direction,
1280        size: quantity,
1281        error,
1282    }
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287    use super::{
1288        Command, ConfigAction, DISCLOSURE_OVERRIDE_CONFIRM, ModeTarget, StateOverrideLabel,
1289        VerboseAction, resolve,
1290    };
1291    use crate::parse::parse_line;
1292    use crate::risk::RiskDirection;
1293    use zero_engine_client::ExecuteSide;
1294
1295    fn r(line: &str) -> Option<Command> {
1296        resolve(&parse_line(line))
1297    }
1298
1299    #[test]
1300    fn empty_input_returns_none() {
1301        assert_eq!(r(""), None);
1302        assert_eq!(r("   "), None);
1303    }
1304
1305    #[test]
1306    fn common_commands_resolve() {
1307        assert_eq!(r("/help"), Some(Command::Help));
1308        assert_eq!(r("?"), Some(Command::Help));
1309        assert_eq!(r("/quit"), Some(Command::Quit));
1310        assert_eq!(r("q"), Some(Command::Quit));
1311        assert_eq!(r("/status"), Some(Command::Status));
1312        assert_eq!(r("/brief"), Some(Command::Brief));
1313        assert_eq!(r("/risk"), Some(Command::Risk));
1314    }
1315
1316    #[test]
1317    fn regime_takes_optional_coin() {
1318        assert_eq!(r("/regime"), Some(Command::Regime { coin: None }));
1319        assert_eq!(
1320            r("/regime BTC"),
1321            Some(Command::Regime {
1322                coin: Some("BTC".into())
1323            })
1324        );
1325    }
1326
1327    #[test]
1328    fn break_parses_minutes() {
1329        assert_eq!(r("/break"), Some(Command::Break { minutes: None }));
1330        assert_eq!(r("/break 15"), Some(Command::Break { minutes: Some(15) }));
1331    }
1332
1333    #[test]
1334    fn mode_switches() {
1335        assert_eq!(
1336            r("/conv"),
1337            Some(Command::SwitchMode(ModeTarget::Conversation))
1338        );
1339        assert_eq!(
1340            r("/decisions"),
1341            Some(Command::SwitchMode(ModeTarget::Decisions))
1342        );
1343        // `/heat` is the inline heat readout; the mode variant is
1344        // reachable via the explicit `/heat-mode` synonym (and Ctrl+4).
1345        assert_eq!(r("/heat-mode"), Some(Command::SwitchMode(ModeTarget::Heat)));
1346        assert_eq!(
1347            r("/cockpit-mode"),
1348            Some(Command::SwitchMode(ModeTarget::Cockpit))
1349        );
1350    }
1351
1352    #[test]
1353    fn heat_resolves_to_inline_readout() {
1354        assert_eq!(r("/heat"), Some(Command::Heat));
1355        // Trailing junk ignored — heat takes no args.
1356        assert_eq!(r("/heat something"), Some(Command::Heat));
1357    }
1358
1359    #[test]
1360    fn heat_is_neutral_risk() {
1361        assert_eq!(Command::Heat.risk(), RiskDirection::Neutral);
1362    }
1363
1364    #[test]
1365    fn evaluate_takes_optional_coin() {
1366        assert_eq!(
1367            r("/evaluate"),
1368            Some(Command::Evaluate {
1369                coin: None,
1370                extras: vec![]
1371            })
1372        );
1373        assert_eq!(
1374            r("/evaluate BTC"),
1375            Some(Command::Evaluate {
1376                coin: Some("BTC".into()),
1377                extras: vec![]
1378            })
1379        );
1380        assert_eq!(
1381            r("/eval eth"),
1382            Some(Command::Evaluate {
1383                coin: Some("eth".into()),
1384                extras: vec![]
1385            })
1386        );
1387    }
1388
1389    #[test]
1390    fn evaluate_preserves_extra_args_for_warning() {
1391        // `/evaluate sol short` — the trailing `short` must be
1392        // kept on the command so the dispatcher can warn about
1393        // it explicitly; silently dropping it would let an
1394        // operator believe the bias was accepted.
1395        assert_eq!(
1396            r("/evaluate sol short"),
1397            Some(Command::Evaluate {
1398                coin: Some("sol".into()),
1399                extras: vec!["short".into()],
1400            })
1401        );
1402        assert_eq!(
1403            r("/evaluate BTC long now please"),
1404            Some(Command::Evaluate {
1405                coin: Some("BTC".into()),
1406                extras: vec!["long".into(), "now".into(), "please".into()],
1407            })
1408        );
1409    }
1410
1411    #[test]
1412    fn evaluate_is_neutral_risk() {
1413        assert_eq!(
1414            Command::Evaluate {
1415                coin: Some("BTC".into()),
1416                extras: vec![],
1417            }
1418            .risk(),
1419            RiskDirection::Neutral
1420        );
1421    }
1422
1423    #[test]
1424    fn pulse_parses_optional_limit() {
1425        assert_eq!(r("/pulse"), Some(Command::Pulse { limit: None }));
1426        assert_eq!(r("/pulse 50"), Some(Command::Pulse { limit: Some(50) }));
1427        // Non-numeric argument is silently discarded — /pulse never
1428        // took a coin, and flagging would surprise operators.
1429        assert_eq!(r("/pulse BTC"), Some(Command::Pulse { limit: None }));
1430    }
1431
1432    #[test]
1433    fn approaching_takes_no_args() {
1434        assert_eq!(r("/approaching"), Some(Command::Approaching));
1435        assert_eq!(r("/near"), Some(Command::Approaching));
1436        assert_eq!(r("/approaching ignored"), Some(Command::Approaching));
1437    }
1438
1439    #[test]
1440    fn rejections_parses_coin_and_limit_in_any_order() {
1441        assert_eq!(
1442            r("/rejections"),
1443            Some(Command::Rejections {
1444                coin: None,
1445                limit: None
1446            })
1447        );
1448        assert_eq!(
1449            r("/rejections BTC"),
1450            Some(Command::Rejections {
1451                coin: Some("BTC".into()),
1452                limit: None
1453            })
1454        );
1455        assert_eq!(
1456            r("/rejections 50"),
1457            Some(Command::Rejections {
1458                coin: None,
1459                limit: Some(50)
1460            })
1461        );
1462        assert_eq!(
1463            r("/rejections BTC 50"),
1464            Some(Command::Rejections {
1465                coin: Some("BTC".into()),
1466                limit: Some(50)
1467            })
1468        );
1469        assert_eq!(
1470            r("/rejections 50 BTC"),
1471            Some(Command::Rejections {
1472                coin: Some("BTC".into()),
1473                limit: Some(50)
1474            })
1475        );
1476        assert_eq!(
1477            r("/rej"),
1478            Some(Command::Rejections {
1479                coin: None,
1480                limit: None
1481            })
1482        );
1483    }
1484
1485    #[test]
1486    fn new_read_commands_are_neutral() {
1487        assert_eq!(
1488            Command::HyperliquidStatus { symbol: None }.risk(),
1489            RiskDirection::Neutral
1490        );
1491        assert_eq!(Command::HyperliquidAccount.risk(), RiskDirection::Neutral);
1492        assert_eq!(Command::HyperliquidReconcile.risk(), RiskDirection::Neutral);
1493        assert_eq!(Command::LiveCertify.risk(), RiskDirection::Neutral);
1494        assert_eq!(Command::LiveCockpit.risk(), RiskDirection::Neutral);
1495        assert_eq!(Command::LiveEvidence.risk(), RiskDirection::Neutral);
1496        assert_eq!(Command::LiveReceipts.risk(), RiskDirection::Neutral);
1497        assert_eq!(Command::LiveCanaryPolicy.risk(), RiskDirection::Neutral);
1498        assert_eq!(Command::RuntimeParity.risk(), RiskDirection::Neutral);
1499        assert_eq!(Command::Immune.risk(), RiskDirection::Neutral);
1500        assert_eq!(
1501            Command::Quote { symbol: None }.risk(),
1502            RiskDirection::Neutral
1503        );
1504        assert_eq!(
1505            Command::Pulse { limit: None }.risk(),
1506            RiskDirection::Neutral
1507        );
1508        assert_eq!(Command::Approaching.risk(), RiskDirection::Neutral);
1509        assert_eq!(
1510            Command::Rejections {
1511                coin: None,
1512                limit: None
1513            }
1514            .risk(),
1515            RiskDirection::Neutral
1516        );
1517    }
1518
1519    #[test]
1520    fn hyperliquid_status_takes_optional_symbol() {
1521        assert_eq!(
1522            r("/hl-status"),
1523            Some(Command::HyperliquidStatus { symbol: None })
1524        );
1525        assert_eq!(
1526            r("/hl BTC"),
1527            Some(Command::HyperliquidStatus {
1528                symbol: Some("BTC".into())
1529            })
1530        );
1531        assert_eq!(
1532            r("/hyperliquid ETH"),
1533            Some(Command::HyperliquidStatus {
1534                symbol: Some("ETH".into())
1535            })
1536        );
1537    }
1538
1539    #[test]
1540    fn hyperliquid_account_commands_parse() {
1541        assert_eq!(r("/hl-account"), Some(Command::HyperliquidAccount));
1542        assert_eq!(r("/hl-reconcile"), Some(Command::HyperliquidReconcile));
1543        assert_eq!(r("/reconcile"), Some(Command::HyperliquidReconcile));
1544        assert_eq!(r("/live-certify"), Some(Command::LiveCertify));
1545        assert_eq!(r("/certify-live"), Some(Command::LiveCertify));
1546        assert_eq!(r("/live-cockpit"), Some(Command::LiveCockpit));
1547        assert_eq!(r("/cockpit"), Some(Command::LiveCockpit));
1548        assert_eq!(r("/live-evidence"), Some(Command::LiveEvidence));
1549        assert_eq!(r("/canary-evidence"), Some(Command::LiveEvidence));
1550        assert_eq!(r("/live-receipts"), Some(Command::LiveReceipts));
1551        assert_eq!(r("/receipts"), Some(Command::LiveReceipts));
1552        assert_eq!(r("/live-canary"), Some(Command::LiveCanaryPolicy));
1553        assert_eq!(r("/canary-policy"), Some(Command::LiveCanaryPolicy));
1554        assert_eq!(r("/runtime-parity"), Some(Command::RuntimeParity));
1555        assert_eq!(r("/parity"), Some(Command::RuntimeParity));
1556        assert_eq!(r("/production-parity"), Some(Command::RuntimeParity));
1557        assert_eq!(r("/immune"), Some(Command::Immune));
1558        assert_eq!(r("/breakers"), Some(Command::Immune));
1559        assert_eq!(r("/resume-entries"), Some(Command::ResumeEntries));
1560        assert_eq!(r("/live-resume"), Some(Command::ResumeEntries));
1561    }
1562
1563    #[test]
1564    fn execute_order_parses_real_order_shape() {
1565        assert_eq!(
1566            r("/execute btc buy 0.001"),
1567            Some(Command::ExecuteOrder {
1568                coin: Some("BTC".to_string()),
1569                side: Some(ExecuteSide::Buy),
1570                size: Some("0.001".to_string()),
1571                error: None,
1572            })
1573        );
1574        assert_eq!(
1575            r("/exec ETH short 1.5"),
1576            Some(Command::ExecuteOrder {
1577                coin: Some("ETH".to_string()),
1578                side: Some(ExecuteSide::Sell),
1579                size: Some("1.5".to_string()),
1580                error: None,
1581            })
1582        );
1583    }
1584
1585    #[test]
1586    fn execute_usage_shapes_are_neutral_until_valid() {
1587        let missing = r("/execute BTC").expect("command");
1588        assert_eq!(missing.risk(), RiskDirection::Neutral);
1589        let bad_size = r("/execute BTC buy nope").expect("command");
1590        assert_eq!(bad_size.risk(), RiskDirection::Neutral);
1591        let valid = r("/execute BTC buy 0.001").expect("command");
1592        assert_eq!(valid.risk(), RiskDirection::Increases);
1593    }
1594
1595    #[test]
1596    fn quote_requires_symbol_at_dispatch() {
1597        assert_eq!(r("/quote"), Some(Command::Quote { symbol: None }));
1598        assert_eq!(
1599            r("/quote BTC"),
1600            Some(Command::Quote {
1601                symbol: Some("BTC".into())
1602            })
1603        );
1604        assert_eq!(
1605            r("/price eth"),
1606            Some(Command::Quote {
1607                symbol: Some("eth".into())
1608            })
1609        );
1610    }
1611
1612    #[test]
1613    fn sessions_parses_optional_limit() {
1614        assert_eq!(r("/sessions"), Some(Command::Sessions { limit: None }));
1615        assert_eq!(r("/sessions 5"), Some(Command::Sessions { limit: Some(5) }));
1616        // Alias + non-numeric gracefully drops the limit.
1617        assert_eq!(r("/ls-sessions"), Some(Command::Sessions { limit: None }));
1618        assert_eq!(r("/sessions BTC"), Some(Command::Sessions { limit: None }));
1619    }
1620
1621    #[test]
1622    fn resume_takes_optional_needle() {
1623        assert_eq!(r("/resume"), Some(Command::Resume { needle: None }));
1624        assert_eq!(
1625            r("/resume 01H"),
1626            Some(Command::Resume {
1627                needle: Some("01H".into())
1628            })
1629        );
1630        // Labels are free-form strings (no quoting needed for
1631        // single tokens); `/resume scratch` should carry the word
1632        // intact to the dispatcher.
1633        assert_eq!(
1634            r("/resume scratch"),
1635            Some(Command::Resume {
1636                needle: Some("scratch".into())
1637            })
1638        );
1639    }
1640
1641    #[test]
1642    fn fork_takes_no_args_and_ignores_extras() {
1643        assert_eq!(r("/fork"), Some(Command::Fork));
1644        // Trailing junk is ignored for consistency with /approaching;
1645        // operators hit Enter without clearing the prompt sometimes.
1646        assert_eq!(r("/fork ignored"), Some(Command::Fork));
1647    }
1648
1649    #[test]
1650    fn save_parses_label() {
1651        assert_eq!(r("/save"), Some(Command::Save { label: None }));
1652        assert_eq!(
1653            r("/save pre-cpi"),
1654            Some(Command::Save {
1655                label: Some("pre-cpi".into())
1656            })
1657        );
1658    }
1659
1660    #[test]
1661    fn session_cohort_is_neutral_risk() {
1662        assert_eq!(
1663            Command::Sessions { limit: None }.risk(),
1664            RiskDirection::Neutral
1665        );
1666        assert_eq!(
1667            Command::Resume { needle: None }.risk(),
1668            RiskDirection::Neutral
1669        );
1670        assert_eq!(Command::Fork.risk(), RiskDirection::Neutral);
1671        assert_eq!(Command::Save { label: None }.risk(), RiskDirection::Neutral);
1672        assert_eq!(
1673            Command::Replay { needle: None }.risk(),
1674            RiskDirection::Neutral
1675        );
1676        assert_eq!(
1677            Command::Share { needle: None }.risk(),
1678            RiskDirection::Neutral
1679        );
1680    }
1681
1682    #[test]
1683    fn replay_takes_optional_needle() {
1684        assert_eq!(r("/replay"), Some(Command::Replay { needle: None }));
1685        assert_eq!(
1686            r("/replay 01HOLD"),
1687            Some(Command::Replay {
1688                needle: Some("01HOLD".into())
1689            })
1690        );
1691        assert_eq!(
1692            r("/replay pre-cpi"),
1693            Some(Command::Replay {
1694                needle: Some("pre-cpi".into())
1695            })
1696        );
1697    }
1698
1699    #[test]
1700    fn share_takes_optional_needle_and_export_alias() {
1701        assert_eq!(r("/share"), Some(Command::Share { needle: None }));
1702        assert_eq!(
1703            r("/share 01HOLD"),
1704            Some(Command::Share {
1705                needle: Some("01HOLD".into())
1706            })
1707        );
1708        // `export` is a learnability alias; operators coming from
1709        // other CLIs reach for it first. Both paths resolve to the
1710        // same variant so `/help` only lists one.
1711        assert_eq!(
1712            r("/export 01HOLD"),
1713            Some(Command::Share {
1714                needle: Some("01HOLD".into())
1715            })
1716        );
1717    }
1718
1719    #[test]
1720    fn config_subcommand_parses_known_actions_and_aliases() {
1721        assert_eq!(
1722            r("/config show"),
1723            Some(Command::Config {
1724                action: ConfigAction::Show
1725            })
1726        );
1727        // `view`, `ls`, `list` all fold into Show — operators
1728        // coming from different CLIs land on the same handler.
1729        assert_eq!(
1730            r("/config view"),
1731            Some(Command::Config {
1732                action: ConfigAction::Show
1733            })
1734        );
1735        assert_eq!(
1736            r("/config ls"),
1737            Some(Command::Config {
1738                action: ConfigAction::Show
1739            })
1740        );
1741        assert_eq!(
1742            r("/config doctor"),
1743            Some(Command::Config {
1744                action: ConfigAction::Doctor
1745            })
1746        );
1747        assert_eq!(
1748            r("/config check"),
1749            Some(Command::Config {
1750                action: ConfigAction::Doctor
1751            })
1752        );
1753    }
1754
1755    #[test]
1756    fn zero_prefix_is_intercepted_with_typed_tail() {
1757        // `zero doctor` and friends must NOT fall through to
1758        // `Command::Unknown` — that would give the generic
1759        // "unknown command: /zero" and bury the teaching
1760        // opportunity. Instead they resolve to `ZeroPrefix`
1761        // carrying the typed tail verbatim so the dispatcher
1762        // can echo intent back.
1763        match r("zero doctor") {
1764            Some(Command::ZeroPrefix { rest }) => assert_eq!(rest, "doctor"),
1765            other => panic!("expected ZeroPrefix, got {other:?}"),
1766        }
1767        match r("zero --version") {
1768            Some(Command::ZeroPrefix { rest }) => assert_eq!(rest, "--version"),
1769            other => panic!("expected ZeroPrefix, got {other:?}"),
1770        }
1771        match r("zero init --force") {
1772            Some(Command::ZeroPrefix { rest }) => assert_eq!(rest, "init --force"),
1773            other => panic!("expected ZeroPrefix, got {other:?}"),
1774        }
1775        match r("zero") {
1776            Some(Command::ZeroPrefix { rest }) => assert_eq!(rest, ""),
1777            other => panic!("expected ZeroPrefix, got {other:?}"),
1778        }
1779    }
1780
1781    #[test]
1782    fn slash_zero_also_triggers_prefix_hint() {
1783        // Canonicalization strips the leading `/` before the
1784        // registry match (see `ParsedLine::canonical_head`), so
1785        // `/zero doctor` and `zero doctor` both hit the same
1786        // arm and both get the teaching hint. This is
1787        // deliberate: an operator who typed `/zero doctor`
1788        // because they half-remembered the slash prefix has
1789        // made exactly the same mistake as one who typed
1790        // `zero doctor`, and deserves the same help. Pinning
1791        // both shapes here means a future parser refactor
1792        // (e.g. distinguishing slash-prefixed commands from
1793        // bare commands) cannot silently regress this.
1794        match r("/zero doctor") {
1795            Some(Command::ZeroPrefix { rest }) => assert_eq!(rest, "doctor"),
1796            other => panic!("expected ZeroPrefix, got {other:?}"),
1797        }
1798    }
1799
1800    #[test]
1801    fn zero_prefix_is_neutral_risk() {
1802        // ZeroPrefix never mutates anything; it's a hint-emit.
1803        // The `risk()` mapping adds it to the Neutral bucket
1804        // next to Unknown, and this pins that. If a future
1805        // refactor accidentally promotes it to Increases /
1806        // Reduces the friction gate would fire on a bare
1807        // `zero doctor` typo, which is absurd.
1808        let cmd = Command::ZeroPrefix {
1809            rest: String::new(),
1810        };
1811        assert_eq!(cmd.risk(), RiskDirection::Neutral);
1812    }
1813
1814    #[test]
1815    fn doctor_top_level_alias_resolves_to_config_doctor() {
1816        // An operator hitting broken-auth state types the single
1817        // most obvious word. This test pins that `/doctor`,
1818        // `/diag`, `/diagnose`, `/check`, and the slash-less
1819        // `doctor` form all land on the same Config/Doctor
1820        // dispatch as the nested `/config doctor`. Keeping the
1821        // variants in the registry in one place means a future
1822        // reviewer checks one test, not four.
1823        for input in ["/doctor", "doctor", "/diag", "/diagnose", "/check"] {
1824            assert_eq!(
1825                r(input),
1826                Some(Command::Config {
1827                    action: ConfigAction::Doctor
1828                }),
1829                "input {input:?} did not alias to /config doctor",
1830            );
1831        }
1832    }
1833
1834    #[test]
1835    fn config_bare_invocation_is_missing_action() {
1836        // Must resolve to the command (so the dispatcher can
1837        // emit a usage hint) rather than falling through to
1838        // Unknown — the latter would make `/config` look like
1839        // a typo even though it is a valid command stem.
1840        assert_eq!(
1841            r("/config"),
1842            Some(Command::Config {
1843                action: ConfigAction::Missing
1844            })
1845        );
1846    }
1847
1848    #[test]
1849    fn config_unknown_action_preserved_for_hint() {
1850        // Keep the original token so the dispatcher can say
1851        // exactly what was rejected. Silent acceptance would
1852        // leave operators wondering whether `/config secrets`
1853        // did something.
1854        assert_eq!(
1855            r("/config secrets"),
1856            Some(Command::Config {
1857                action: ConfigAction::Unknown("secrets".into())
1858            })
1859        );
1860    }
1861
1862    #[test]
1863    fn config_is_neutral_risk() {
1864        assert_eq!(
1865            Command::Config {
1866                action: ConfigAction::Show
1867            }
1868            .risk(),
1869            RiskDirection::Neutral
1870        );
1871        assert_eq!(
1872            Command::Config {
1873                action: ConfigAction::Doctor
1874            }
1875            .risk(),
1876            RiskDirection::Neutral
1877        );
1878    }
1879
1880    #[test]
1881    fn verbose_parses_on_off_toggle() {
1882        assert_eq!(
1883            r("/verbose"),
1884            Some(Command::Verbose {
1885                action: VerboseAction::Toggle
1886            })
1887        );
1888        assert_eq!(
1889            r("/verbose toggle"),
1890            Some(Command::Verbose {
1891                action: VerboseAction::Toggle
1892            })
1893        );
1894        assert_eq!(
1895            r("/verbose on"),
1896            Some(Command::Verbose {
1897                action: VerboseAction::On
1898            })
1899        );
1900        assert_eq!(
1901            r("/verbose ON"),
1902            Some(Command::Verbose {
1903                action: VerboseAction::On
1904            })
1905        );
1906        assert_eq!(
1907            r("/verbose off"),
1908            Some(Command::Verbose {
1909                action: VerboseAction::Off
1910            })
1911        );
1912        // Booleans accepted too — operators script these.
1913        assert_eq!(
1914            r("/verbose true"),
1915            Some(Command::Verbose {
1916                action: VerboseAction::On
1917            })
1918        );
1919        assert_eq!(
1920            r("/verbose 0"),
1921            Some(Command::Verbose {
1922                action: VerboseAction::Off
1923            })
1924        );
1925    }
1926
1927    #[test]
1928    fn verbose_preserves_unknown_token_for_usage_hint() {
1929        assert_eq!(
1930            r("/verbose maybe"),
1931            Some(Command::Verbose {
1932                action: VerboseAction::Unknown("maybe".into())
1933            })
1934        );
1935    }
1936
1937    #[test]
1938    fn verbose_is_neutral_risk() {
1939        assert_eq!(
1940            Command::Verbose {
1941                action: VerboseAction::Toggle
1942            }
1943            .risk(),
1944            RiskDirection::Neutral
1945        );
1946    }
1947
1948    #[test]
1949    fn state_override_parses_canonical_labels() {
1950        // Case-insensitive; the valid set matches the engine-
1951        // side classifier. An unknown token resolves to the
1952        // command with `None` so the dispatcher can surface a
1953        // usage hint naming the valid labels — silent drop
1954        // would leave operators wondering whether the typo
1955        // was the reason nothing changed.
1956        assert_eq!(
1957            r("/state-override STEADY"),
1958            Some(Command::StateOverride {
1959                label: Some(StateOverrideLabel::Steady),
1960            })
1961        );
1962        assert_eq!(
1963            r("/state-override steady"),
1964            Some(Command::StateOverride {
1965                label: Some(StateOverrideLabel::Steady),
1966            })
1967        );
1968        assert_eq!(
1969            r("/state-override Tilt"),
1970            Some(Command::StateOverride {
1971                label: Some(StateOverrideLabel::Tilt),
1972            })
1973        );
1974        assert_eq!(
1975            r("/state-override blue"),
1976            Some(Command::StateOverride { label: None })
1977        );
1978        assert_eq!(
1979            r("/state-override"),
1980            Some(Command::StateOverride { label: None })
1981        );
1982    }
1983
1984    #[test]
1985    fn state_override_is_increases_risk() {
1986        // Friction asymmetry: a self-declared label must be
1987        // gated exactly like `/execute` because it can unlock
1988        // lower-friction risky moves.
1989        assert_eq!(
1990            Command::StateOverride {
1991                label: Some(StateOverrideLabel::Steady)
1992            }
1993            .risk(),
1994            RiskDirection::Increases
1995        );
1996    }
1997
1998    #[test]
1999    fn continue_parses_with_alias() {
2000        assert_eq!(r("/continue"), Some(Command::Continue));
2001        assert_eq!(r("/cont"), Some(Command::Continue));
2002        assert_eq!(Command::Continue.risk(), RiskDirection::Neutral);
2003    }
2004
2005    #[test]
2006    fn rate_parses_id_and_rating_in_either_order() {
2007        // Canonical shape — trade id first, then rating.
2008        assert_eq!(
2009            r("/rate t-001 8"),
2010            Some(Command::Rate {
2011                trade_id: Some("t-001".into()),
2012                rating: Some(8),
2013            })
2014        );
2015        // Rating first, id second: operators under pressure
2016        // should not get a silent miss for a transposed order.
2017        // The numeric token binds to `rating` irrespective of
2018        // position; the first non-numeric binds to `trade_id`.
2019        assert_eq!(
2020            r("/rate 8 t-001"),
2021            Some(Command::Rate {
2022                trade_id: Some("t-001".into()),
2023                rating: Some(8),
2024            })
2025        );
2026        // Boundary values inside the 1..=10 window parse.
2027        assert_eq!(
2028            r("/rate t 1"),
2029            Some(Command::Rate {
2030                trade_id: Some("t".into()),
2031                rating: Some(1),
2032            })
2033        );
2034        assert_eq!(
2035            r("/rate t 10"),
2036            Some(Command::Rate {
2037                trade_id: Some("t".into()),
2038                rating: Some(10),
2039            })
2040        );
2041    }
2042
2043    #[test]
2044    fn rate_rejects_out_of_range_and_missing_arguments() {
2045        // Bare invocation: both slots None so the dispatcher
2046        // emits a usage hint naming the full 1..=10 range.
2047        assert_eq!(
2048            r("/rate"),
2049            Some(Command::Rate {
2050                trade_id: None,
2051                rating: None,
2052            })
2053        );
2054        // Out-of-range numerics are *not* bound as `rating` —
2055        // silently clamping to 1 or 10 would launder a typo,
2056        // so the parser instead routes `0` / `11` to the
2057        // `trade_id` slot (first non-numeric-or-in-range
2058        // token). The usage hint fires because `rating` is
2059        // still None.
2060        assert_eq!(
2061            r("/rate 0"),
2062            Some(Command::Rate {
2063                trade_id: Some("0".into()),
2064                rating: None,
2065            })
2066        );
2067        assert_eq!(
2068            r("/rate 11"),
2069            Some(Command::Rate {
2070                trade_id: Some("11".into()),
2071                rating: None,
2072            })
2073        );
2074        // A well-formed id with no rating: the id binds, the
2075        // rating stays None so the handler's shape-check fires.
2076        assert_eq!(
2077            r("/rate t-001"),
2078            Some(Command::Rate {
2079                trade_id: Some("t-001".into()),
2080                rating: None,
2081            })
2082        );
2083    }
2084
2085    #[test]
2086    fn rate_is_neutral_risk() {
2087        assert_eq!(
2088            Command::Rate {
2089                trade_id: Some("t".into()),
2090                rating: Some(5),
2091            }
2092            .risk(),
2093            RiskDirection::Neutral,
2094            "/rate is a self-report about a past trade, not a position change",
2095        );
2096    }
2097
2098    #[test]
2099    fn close_takes_optional_coin() {
2100        assert_eq!(r("/close"), Some(Command::Close { coin: None }));
2101        assert_eq!(
2102            r("/close BTC"),
2103            Some(Command::Close {
2104                coin: Some("BTC".into())
2105            })
2106        );
2107        // The asymmetry invariant: /close must be a Reduces so
2108        // the friction ladder never gates a per-coin close.
2109        assert_eq!(Command::Close { coin: None }.risk(), RiskDirection::Reduces);
2110    }
2111
2112    #[test]
2113    fn wrap_off_parses_with_alias_and_is_neutral() {
2114        assert_eq!(r("/wrap-off"), Some(Command::WrapOff));
2115        assert_eq!(r("/wrapoff"), Some(Command::WrapOff));
2116        assert_eq!(Command::WrapOff.risk(), RiskDirection::Neutral);
2117    }
2118
2119    #[test]
2120    fn coaching_reset_parses_two_token_and_dash_forms() {
2121        assert_eq!(r("/coaching reset"), Some(Command::CoachingReset));
2122        assert_eq!(r("/coaching RESET"), Some(Command::CoachingReset));
2123        assert_eq!(r("/coaching-reset"), Some(Command::CoachingReset));
2124        // Bare `/coaching` without `reset` is honest-fail —
2125        // there is no other coaching subcommand today.
2126        assert!(matches!(r("/coaching"), Some(Command::Unknown(_))));
2127        assert!(matches!(r("/coaching wut"), Some(Command::Unknown(_))));
2128        assert_eq!(Command::CoachingReset.risk(), RiskDirection::Neutral);
2129    }
2130
2131    #[test]
2132    fn disclosure_override_requires_exact_phrase() {
2133        // No phrase → confirmed=false; dispatcher emits a
2134        // usage alert naming the exact words.
2135        assert_eq!(
2136            r("/disclosure-override"),
2137            Some(Command::DisclosureOverride { confirmed: false })
2138        );
2139        // Exact flag word with dashes:
2140        let exact = format!("/disclosure-override {DISCLOSURE_OVERRIDE_CONFIRM}");
2141        assert_eq!(
2142            r(&exact),
2143            Some(Command::DisclosureOverride { confirmed: true })
2144        );
2145        // Dashless variant (operator hand-types the phrase)
2146        // resolves the same — we do not gatekeep on punctuation.
2147        assert_eq!(
2148            r("/disclosure-override i-know-what-i-am-doing"),
2149            Some(Command::DisclosureOverride { confirmed: true })
2150        );
2151        // A wrong phrase leaves confirmed=false, not Unknown —
2152        // so the handler can tell the operator exactly what they
2153        // were missing.
2154        assert_eq!(
2155            r("/disclosure-override yolo"),
2156            Some(Command::DisclosureOverride { confirmed: false })
2157        );
2158    }
2159
2160    #[test]
2161    fn disclosure_override_is_increases_risk_regardless_of_confirm() {
2162        // Risk classification does not depend on argument
2163        // correctness — both confirmed/unconfirmed carry the
2164        // same Increases tag so the friction gate evaluates
2165        // consistently.
2166        assert_eq!(
2167            Command::DisclosureOverride { confirmed: true }.risk(),
2168            RiskDirection::Increases
2169        );
2170        assert_eq!(
2171            Command::DisclosureOverride { confirmed: false }.risk(),
2172            RiskDirection::Increases
2173        );
2174    }
2175
2176    #[test]
2177    fn risk_classification_holds() {
2178        assert_eq!(Command::Help.risk(), RiskDirection::Neutral);
2179        assert_eq!(Command::Status.risk(), RiskDirection::Neutral);
2180        assert_eq!(Command::Quit.risk(), RiskDirection::Reduces);
2181        assert_eq!(Command::Kill.risk(), RiskDirection::Reduces);
2182        assert_eq!(Command::FlattenAll.risk(), RiskDirection::Reduces);
2183        assert_eq!(Command::PauseEntries.risk(), RiskDirection::Reduces);
2184        assert_eq!(Command::ResumeEntries.risk(), RiskDirection::Increases);
2185        assert_eq!(
2186            Command::Break { minutes: None }.risk(),
2187            RiskDirection::Reduces
2188        );
2189    }
2190}