1use crate::parse::ParsedLine;
10use crate::risk::RiskDirection;
11use zero_engine_client::ExecuteSide;
12
13#[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#[derive(Debug, Clone, PartialEq)]
43pub enum OverlayTarget {
44 State,
48 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
65impl Eq for OverlayTarget {}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum Command {
77 Help,
78 Quit,
79 Clear,
80 SwitchMode(ModeTarget),
81 Status,
82 Brief,
83 Risk,
84 HyperliquidStatus {
87 symbol: Option<String>,
88 },
89 HyperliquidAccount,
91 HyperliquidReconcile,
93 LiveCertify,
95 LiveCockpit,
97 LiveEvidence,
99 LiveReceipts,
101 LiveCanaryPolicy,
103 RuntimeParity,
105 Immune,
107 Quote {
110 symbol: Option<String>,
111 },
112 Regime {
113 coin: Option<String>,
114 },
115 Evaluate {
130 coin: Option<String>,
131 extras: Vec<String>,
132 },
133 Positions,
134 Pulse {
139 limit: Option<u32>,
140 },
141 Approaching,
145 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 Execute,
164 ExecuteOrder {
168 coin: Option<String>,
169 side: Option<ExecuteSide>,
170 size: Option<String>,
171 error: Option<String>,
172 },
173 State,
177 Sessions {
182 limit: Option<u32>,
183 },
184 Resume {
191 needle: Option<String>,
192 },
193 Fork,
198 Heat,
208 Save {
214 label: Option<String>,
215 },
216 Replay {
225 needle: Option<String>,
226 },
227 Share {
236 needle: Option<String>,
237 },
238 Config {
247 action: ConfigAction,
248 },
249 Verbose {
264 action: VerboseAction,
265 },
266 StateOverride {
275 label: Option<StateOverrideLabel>,
276 },
277 Continue,
281 Close {
290 coin: Option<String>,
291 },
292 WrapOff,
296 CoachingReset,
302 DisclosureOverride {
312 confirmed: bool,
313 },
314 Rate {
338 trade_id: Option<String>,
339 rating: Option<u8>,
340 },
341 ZeroPrefix {
354 rest: String,
355 },
356 Auto {
375 action: AutoAction,
376 },
377 Headless {
392 action: HeadlessAction,
393 },
394 Unknown(String),
395}
396
397#[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 #[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
445pub const DISCLOSURE_OVERRIDE_CONFIRM: &str = "--i-know-what-i-am-doing";
450
451#[derive(Debug, Clone, PartialEq, Eq)]
453pub enum VerboseAction {
454 On,
455 Off,
456 Toggle,
457 Unknown(String),
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
469pub enum ConfigAction {
470 Show,
471 Doctor,
472 Missing,
473 Unknown(String),
474}
475
476#[derive(Debug, Clone, PartialEq, Eq)]
484pub enum AutoAction {
485 On,
486 Off,
487 Status,
488 Missing,
489 Unknown(String),
490}
491
492impl AutoAction {
493 #[must_use]
497 pub const fn is_risk_increasing(&self) -> bool {
498 matches!(self, Self::On)
499 }
500}
501
502#[derive(Debug, Clone, PartialEq, Eq)]
509pub enum HeadlessAction {
510 Start,
511 Stop,
512 Status,
513 Missing,
514 Unknown(String),
515}
516
517impl Command {
518 #[must_use]
521 pub const fn risk(&self) -> RiskDirection {
522 match self {
523 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 Self::Quit
572 | Self::Kill
573 | Self::FlattenAll
574 | Self::PauseEntries
575 | Self::Break { .. }
576 | Self::Close { .. } => RiskDirection::Reduces,
577
578 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 Self::Auto { action } => {
612 if action.is_risk_increasing() {
613 RiskDirection::Increases
614 } else {
615 RiskDirection::Neutral
616 }
617 }
618 }
619 }
620
621 #[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 #[must_use]
688 pub const fn default_pulse_limit() -> u32 {
689 20
690 }
691
692 #[must_use]
696 pub const fn default_rejections_limit() -> u32 {
697 20
698 }
699
700 #[must_use]
707 pub const fn default_sessions_limit() -> u32 {
708 20
709 }
710
711 #[must_use]
714 pub const fn max_sessions_limit() -> u32 {
715 50
716 }
717}
718
719pub 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 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#[derive(Debug, Clone, Copy)]
980pub struct CommandInfo {
981 pub name: &'static str,
982 pub summary: &'static str,
983 pub risk: RiskDirection,
984}
985
986#[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" => 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 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 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" => 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 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" => {
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" => {
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" | "diag" | "diagnose" | "check" => Command::Config {
1218 action: ConfigAction::Doctor,
1219 },
1220 "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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(
2048 r("/rate"),
2049 Some(Command::Rate {
2050 trade_id: None,
2051 rating: None,
2052 })
2053 );
2054 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 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 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 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 assert_eq!(
2136 r("/disclosure-override"),
2137 Some(Command::DisclosureOverride { confirmed: false })
2138 );
2139 let exact = format!("/disclosure-override {DISCLOSURE_OVERRIDE_CONFIRM}");
2141 assert_eq!(
2142 r(&exact),
2143 Some(Command::DisclosureOverride { confirmed: true })
2144 );
2145 assert_eq!(
2148 r("/disclosure-override i-know-what-i-am-doing"),
2149 Some(Command::DisclosureOverride { confirmed: true })
2150 );
2151 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 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}