1use crate::cli::interactive::SessionContext;
12use crate::cli::output::Output;
13use crate::cli::{Cli, Commands};
14use crate::{errln, out, outln};
15use clap::error::ErrorKind;
16use clap::{CommandFactory, Parser, ValueEnum};
17use scope::config::{Config, OutputFormat};
18use scope::tokens::TokenAliases;
19use std::io;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Route {
24 Pane,
26 Suspend,
29 Refuse(&'static str),
31 Wizard,
33}
34
35pub(crate) const TUI_REFUSED: &[(&str, &str)] = &[
38 ("interactive", "you are already in the TUI"),
39 (
40 "web",
41 "the web server runs in the foreground; start it from a shell with `scope web`",
42 ),
43 (
44 "completions",
45 "it prints a script for your shell; run `scope completions <shell>` in a shell",
46 ),
47];
48
49fn refused(name: &str) -> Route {
51 let reason = TUI_REFUSED
52 .iter()
53 .find(|(n, _)| *n == name)
54 .map_or("it does not run in the TUI", |(_, why)| why);
55 Route::Refuse(reason)
56}
57
58pub fn route(command: &Commands) -> Route {
63 match command {
64 Commands::Address(_)
65 | Commands::Tx(_)
66 | Commands::Insights(_)
67 | Commands::Contract(_)
68 | Commands::TokenHealth(_)
69 | Commands::Discover(_)
70 | Commands::Market(_)
71 | Commands::Venues(_)
72 | Commands::Compliance(_)
73 | Commands::AddressBook(_)
74 | Commands::Export(_)
75 | Commands::Crawl(_)
77 | Commands::Report(_) => Route::Pane,
78 Commands::Setup(args) => {
80 if args.status {
81 Route::Pane
82 } else {
83 Route::Suspend
84 }
85 }
86 Commands::Monitor(args) => {
89 if args.watch.dashboard {
90 Route::Suspend
91 } else {
92 Route::Wizard
93 }
94 }
95 Commands::Interactive(_) => refused("interactive"),
96 Commands::Web(_) => refused("web"),
97 Commands::Completions(_) => refused("completions"),
98 }
99}
100
101#[derive(Debug)]
103pub struct Invocation {
104 pub command: Commands,
106 pub route: Route,
108 pub ai: bool,
110 pub argv: Vec<String>,
113}
114
115#[derive(Debug)]
117pub enum Plan {
118 Done,
121 Exit,
123 Run(Box<Invocation>),
125 Watch(WatchCommand),
127}
128
129#[derive(Debug)]
131pub enum WatchCommand {
132 Open(Box<scope::watch::Watchlist>),
134 Stop,
136 Show,
138}
139
140pub fn plan(
149 line: &str,
150 ctx: &mut SessionContext,
151 config: &Config,
152 out: &Output,
153) -> io::Result<Plan> {
154 let Some(words) = split_line(line) else {
155 errln!(out, "Unbalanced quotes in: {}", line)?;
156 return Ok(Plan::Done);
157 };
158 let Some(first) = words.first() else {
159 return Ok(Plan::Done);
160 };
161 let rest: Vec<&str> = words[1..].iter().map(String::as_str).collect();
162
163 match session_command_name(first).as_str() {
164 "exit" => return Ok(Plan::Exit),
165 #[cfg(debug_assertions)]
168 "__panic" => panic!("__panic: manual crash-log check"),
169 "help" => {
170 if rest.is_empty() {
171 write_help(out)?;
172 return Ok(Plan::Done);
173 }
174 let name = session_command_name(&words[1]);
176 if let Some((_, usage, what)) = SESSION_COMMANDS.iter().find(|(n, _, _)| *n == name) {
177 outln!(out, " {:<34} {}", usage, what)?;
178 return Ok(Plan::Done);
179 }
180 let mut words: Vec<String> = words[1..].to_vec();
183 words.push("--help".to_string());
184 return parse_cli(words, ctx, config, out);
185 }
186 "ctx" => {
187 out!(out, "{}", ctx)?;
188 return Ok(Plan::Done);
189 }
190 "reset" => {
191 *ctx = SessionContext::default();
192 outln!(out, "Context reset to defaults.")?;
193 return Ok(Plan::Done);
194 }
195 "chain" => {
196 set_chain(ctx, rest.first().copied(), out)?;
197 return Ok(Plan::Done);
198 }
199 "format" => {
200 set_format(ctx, rest.first().copied(), out)?;
201 return Ok(Plan::Done);
202 }
203 "+tokens" => {
204 ctx.include_tokens = !ctx.include_tokens;
205 outln!(out, "Include tokens: {}", on_off(ctx.include_tokens))?;
206 return Ok(Plan::Done);
207 }
208 "+txs" => {
209 ctx.include_txs = !ctx.include_txs;
210 outln!(out, "Include transactions: {}", on_off(ctx.include_txs))?;
211 return Ok(Plan::Done);
212 }
213 "trace" => {
214 ctx.trace = !ctx.trace;
215 outln!(out, "Trace: {}", on_off(ctx.trace))?;
216 return Ok(Plan::Done);
217 }
218 "decode" => {
219 ctx.decode = !ctx.decode;
220 outln!(out, "Decode: {}", on_off(ctx.decode))?;
221 return Ok(Plan::Done);
222 }
223 "limit" => {
224 set_limit(ctx, rest.first().copied(), out)?;
225 return Ok(Plan::Done);
226 }
227 "tokens" => {
228 tokens_command(&rest, &mut TokenAliases::load(), &TokenAliases::save, out)?;
229 return Ok(Plan::Done);
230 }
231 "watch" => {
232 return watch_command(
233 &rest,
234 &scope::paths::watchlists_dir(),
235 &crate::cli::watch::venue_ids(),
236 out,
237 );
238 }
239 _ => {}
240 }
241
242 parse_cli(words, ctx, config, out)
243}
244
245const WATCH_USAGE: &str = "Usage: watch [list | open <name|file.yaml> | stop]";
247
248fn watch_command(
251 rest: &[&str],
252 dir: &std::path::Path,
253 venues: &[String],
254 out: &Output,
255) -> io::Result<Plan> {
256 use scope::watch::file;
257 match rest {
258 [] | ["show"] => Ok(Plan::Watch(WatchCommand::Show)),
259 ["stop"] => Ok(Plan::Watch(WatchCommand::Stop)),
260 ["list"] => {
261 let names = file::list(dir);
262 if names.is_empty() {
263 outln!(out, "No saved watchlists in {}.", dir.display())?;
264 } else {
265 for name in names {
266 outln!(out, " {}", name)?;
267 }
268 }
269 Ok(Plan::Done)
270 }
271 ["open", name] => {
272 let is_path = name.ends_with(&format!(".{}", file::EXTENSION))
273 || name.contains(['/', std::path::MAIN_SEPARATOR]);
274 let path = if is_path {
275 std::path::PathBuf::from(name)
276 } else {
277 file::path_in(dir, name)
278 };
279 match file::load(&path, Some(venues)) {
280 Ok(w) => Ok(Plan::Watch(WatchCommand::Open(Box::new(w)))),
281 Err(errors) => {
282 errln!(out, "{} is not a valid watchlist:", path.display())?;
283 for e in errors.0 {
284 errln!(out, " {}", e)?;
285 }
286 Ok(Plan::Done)
287 }
288 }
289 }
290 _ => {
291 errln!(out, "{}", WATCH_USAGE)?;
292 Ok(Plan::Done)
293 }
294 }
295}
296
297fn parse_cli(
300 words: Vec<String>,
301 ctx: &mut SessionContext,
302 config: &Config,
303 out: &Output,
304) -> io::Result<Plan> {
305 let mut argv = vec!["scope".to_string()];
306 argv.extend(inject_context(words, ctx, config));
307
308 let cli = match Cli::try_parse_from(&argv) {
309 Ok(cli) => cli,
310 Err(e) if e.kind() == ErrorKind::MissingRequiredArgument => match last_target(&argv, ctx) {
312 Some(target) => {
313 argv.push(target);
314 match Cli::try_parse_from(&argv) {
315 Ok(cli) => cli,
316 Err(e) => return write_clap_error(&e, &argv, out),
317 }
318 }
319 None => return write_clap_error(&e, &argv, out),
320 },
321 Err(e) => return write_clap_error(&e, &argv, out),
322 };
323
324 if let Some(path) = &cli.config {
325 errln!(
326 out,
327 "--config {} has no effect inside the TUI. Restart with `scope --config {} tui`.",
328 path.display(),
329 path.display()
330 )?;
331 return Ok(Plan::Done);
332 }
333
334 let route = route(&cli.command);
335 if let Route::Refuse(reason) = route {
336 errln!(out, "Not available here: {}.", reason)?;
337 return Ok(Plan::Done);
338 }
339
340 remember_target(&cli.command, ctx);
341 Ok(Plan::Run(Box::new(Invocation {
342 command: cli.command,
343 route,
344 ai: cli.ai,
345 argv,
346 })))
347}
348
349fn write_clap_error(e: &clap::Error, argv: &[String], out: &Output) -> io::Result<Plan> {
354 let text = e.render().ansi().to_string();
355 match e.kind() {
356 ErrorKind::DisplayHelp
357 | ErrorKind::DisplayVersion
358 | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => out!(out, "{}", text)?,
359 _ => {
360 for line in text.lines().filter(|l| !l.contains(CLAP_HELP_HINT)) {
361 errln!(out, "{}", line)?;
362 }
363 errln!(out, "{}", tui_help_hint(argv))?;
364 }
365 }
366 Ok(Plan::Done)
367}
368
369const CLAP_HELP_HINT: &str = "For more information";
371
372fn tui_help_hint(argv: &[String]) -> String {
375 let root = Cli::command();
376 let mut cmd = &root;
377 let mut words = Vec::new();
378 for w in argv.iter().skip(1).filter(|w| !w.starts_with('-')) {
379 match cmd.find_subcommand(w) {
380 Some(sub) => {
381 words.push(w.as_str());
382 cmd = sub;
383 }
384 None => break,
385 }
386 }
387 if words.is_empty() {
388 "run 'help <command>' for its options".to_string()
389 } else {
390 format!("run 'help {}' for its options", words.join(" "))
391 }
392}
393
394struct ContextFlag {
396 long: &'static str,
398 value: Option<String>,
400 only: Option<&'static [&'static str]>,
402}
403
404fn inject_context(mut words: Vec<String>, ctx: &SessionContext, config: &Config) -> Vec<String> {
410 let mut flags = Vec::new();
411 if !ctx.is_auto_chain() {
412 flags.push(ContextFlag {
413 long: "chain",
414 value: Some(ctx.chain.clone()),
415 only: None,
416 });
417 }
418 if ctx.format != config.output.format
420 && let Some(v) = ctx.format.to_possible_value()
421 {
422 flags.push(ContextFlag {
423 long: "format",
424 value: Some(v.get_name().to_string()),
425 only: None,
426 });
427 }
428 for (on, long) in [
429 (ctx.include_tokens, "include-tokens"),
430 (ctx.include_txs, "include-txs"),
431 (ctx.trace, "trace"),
432 (ctx.decode, "decode"),
433 ] {
434 if on {
435 flags.push(ContextFlag {
436 long,
437 value: None,
438 only: None,
439 });
440 }
441 }
442 if ctx.limit != SessionContext::default().limit {
444 flags.push(ContextFlag {
445 long: "limit",
446 value: Some(ctx.limit.to_string()),
447 only: Some(&["address"]),
448 });
449 }
450 if flags.is_empty() {
451 return words;
452 }
453
454 let root = Cli::command();
455 let mut cmd = &root;
456 for w in &words {
457 if w.starts_with('-') {
458 continue;
459 }
460 match cmd.find_subcommand(w) {
461 Some(sub) => cmd = sub,
462 None => break,
463 }
464 }
465 if std::ptr::eq(cmd, &root) {
466 return words;
467 }
468
469 for flag in flags {
470 if flag
471 .only
472 .is_some_and(|names| !names.contains(&cmd.get_name()))
473 {
474 continue;
475 }
476 let Some(arg) = cmd
477 .get_arguments()
478 .find(|a| a.get_long() == Some(flag.long))
479 else {
480 continue;
481 };
482 let typed = words.iter().any(|w| {
483 w == &format!("--{}", flag.long)
484 || w.starts_with(&format!("--{}=", flag.long))
485 || arg
486 .get_short()
487 .is_some_and(|s| w.starts_with(&format!("-{}", s)) && !w.starts_with("--"))
488 });
489 if typed {
490 continue;
491 }
492 if let Some(value) = &flag.value {
493 let values = arg.get_possible_values();
494 if !values.is_empty() && !values.iter().any(|p| p.matches(value, true)) {
495 continue;
496 }
497 words.push(format!("--{}", flag.long));
498 words.push(value.clone());
499 } else {
500 words.push(format!("--{}", flag.long));
501 }
502 }
503 words
504}
505
506fn last_target(argv: &[String], ctx: &SessionContext) -> Option<String> {
509 let root = Cli::command();
510 let name = argv.get(1)?;
511 let sub = root.find_subcommand(name)?;
512 match sub.get_name() {
513 "address" => ctx.last_address.clone(),
514 "tx" => ctx.last_tx.clone(),
515 _ => None,
516 }
517}
518
519fn remember_target(command: &Commands, ctx: &mut SessionContext) {
521 match command {
522 Commands::Address(args) => ctx.last_address = Some(args.address.clone()),
523 Commands::Tx(args) => ctx.last_tx = Some(args.hash.clone()),
524 _ => {}
525 }
526}
527
528fn on_off(b: bool) -> &'static str {
529 if b { "on" } else { "off" }
530}
531
532fn set_chain(ctx: &mut SessionContext, arg: Option<&str>, out: &Output) -> io::Result<()> {
533 match arg.map(str::to_lowercase) {
534 None if ctx.is_auto_chain() => outln!(out, "Current chain: auto (inferred from input)"),
535 None => outln!(out, "Current chain: {} (pinned)", ctx.chain),
536 Some(c) if c == "auto" => {
537 ctx.chain = "auto".into();
538 outln!(out, "Chain set to auto — will infer from each input")
539 }
540 Some(c) if scope::chains::registry::client_chain_ids().contains(&c.as_str()) => {
541 outln!(out, "Chain pinned to: {}", c)?;
542 ctx.chain = c;
543 Ok(())
544 }
545 Some(c) => errln!(
546 out,
547 "Unknown chain: {}. Valid chains: auto, {}",
548 c,
549 scope::chains::registry::client_chain_list()
550 ),
551 }
552}
553
554fn set_format(ctx: &mut SessionContext, arg: Option<&str>, out: &Output) -> io::Result<()> {
555 let Some(arg) = arg else {
556 return outln!(out, "Current format: {:?}", ctx.format);
557 };
558 match OutputFormat::from_str(arg, true) {
559 Ok(f) => {
560 ctx.format = f;
561 outln!(out, "Format set to: {}", arg.to_lowercase())
562 }
563 Err(_) => {
564 let names: Vec<String> = OutputFormat::value_variants()
565 .iter()
566 .filter_map(|v| v.to_possible_value().map(|p| p.get_name().to_string()))
567 .collect();
568 errln!(
569 out,
570 "Unknown format: {}. Valid formats: {}",
571 arg,
572 names.join(", ")
573 )
574 }
575 }
576}
577
578fn set_limit(ctx: &mut SessionContext, arg: Option<&str>, out: &Output) -> io::Result<()> {
579 match arg.map(str::parse::<u32>) {
580 None => outln!(out, "Current limit: {}", ctx.limit),
581 Some(Ok(n)) if n > 0 => {
582 ctx.limit = n;
583 outln!(out, "Limit set to: {}", n)
584 }
585 Some(_) => errln!(
586 out,
587 "Invalid limit: {}. Must be a positive integer.",
588 arg.unwrap_or_default()
589 ),
590 }
591}
592
593type SaveAliases<'a> = dyn Fn(&TokenAliases) -> scope::error::Result<()> + 'a;
595
596fn tokens_command(
598 args: &[&str],
599 aliases: &mut TokenAliases,
600 save: &SaveAliases<'_>,
601 out: &Output,
602) -> io::Result<()> {
603 match args.first().map(|s| s.to_lowercase()).as_deref() {
604 None | Some("list") | Some("ls") => {
605 let tokens = aliases.list();
606 if tokens.is_empty() {
607 outln!(
608 out,
609 "{}",
610 scope::display::terminal::info_row("No saved token aliases.")
611 )?;
612 return outln!(
613 out,
614 "{}",
615 scope::display::terminal::info_row(
616 "Use 'crawl <token_name> --save' to save a token alias."
617 )
618 );
619 }
620 write_token_table(out, "Saved Token Aliases", &tokens)
621 }
622 Some("recent") => {
623 let recent = aliases.recent();
624 if recent.is_empty() {
625 return outln!(
626 out,
627 "{}",
628 scope::display::terminal::info_row("No recently used tokens.")
629 );
630 }
631 write_token_table(out, "Recently Used Tokens", recent)
632 }
633 Some("remove") | Some("rm") | Some("delete") => {
634 let Some(symbol) = args.get(1) else {
635 return errln!(out, "Usage: tokens remove <symbol> [--chain <chain>]");
636 };
637 let chain = match (args.get(2), args.get(3)) {
638 (Some(&"--chain"), Some(c)) => Some(*c),
639 _ => None,
640 };
641 aliases.remove(symbol, chain);
642 match save(aliases) {
643 Ok(()) => outln!(out, "Removed alias: {}", symbol),
644 Err(e) => errln!(out, "Failed to save the token aliases: {}", e),
645 }
646 }
647 Some("add") => {
648 let (Some(symbol), Some(chain), Some(address)) =
649 (args.get(1), args.get(2), args.get(3))
650 else {
651 return errln!(out, "Usage: tokens add <symbol> <chain> <address> [name]");
652 };
653 let name = if args.len() > 4 {
654 args[4..].join(" ")
655 } else {
656 symbol.to_string()
657 };
658 aliases.add(symbol, chain, address, &name);
659 match save(aliases) {
660 Ok(()) => outln!(out, "Added alias: {} -> {} on {}", symbol, address, chain),
661 Err(e) => errln!(out, "Failed to save the token aliases: {}", e),
662 }
663 }
664 Some(other) => {
665 errln!(out, "Unknown tokens subcommand: {}", other)?;
666 errln!(out, "Available: list, recent, add, remove")
667 }
668 }
669}
670
671fn write_token_table<T>(out: &Output, title: &str, tokens: &[T]) -> io::Result<()>
672where
673 T: std::borrow::Borrow<scope::tokens::TokenInfo>,
674{
675 use scope::display::terminal as t;
676 let col = |label, width| t::Col {
677 label,
678 width,
679 align: '<',
680 };
681 let cols = &[
682 col("Symbol", 10),
683 col("Chain", 12),
684 col("Name", 20),
685 col("Address", 42),
686 ];
687 outln!(out, "{}", t::section_header(title))?;
688 outln!(out, "{}", t::table_header(cols))?;
689 for token in tokens.iter().map(std::borrow::Borrow::borrow) {
690 outln!(
691 out,
692 "{}",
693 t::table_row(
694 cols,
695 &[&token.symbol, &token.chain, &token.name, &token.address]
696 )
697 )?;
698 }
699 outln!(out, "{}", t::section_footer())
700}
701
702pub(crate) const SESSION_COMMANDS: &[(&str, &str, &str)] = &[
705 ("chain", "chain [name|auto]", "Show, pin or unpin the chain"),
706 (
707 "format",
708 "format [table|json|csv|markdown]",
709 "Show or set the output format",
710 ),
711 ("+tokens", "+tokens", "Toggle token balances for address"),
712 ("+txs", "+txs", "Toggle transactions for address"),
713 ("trace", "trace", "Toggle trace for tx"),
714 ("decode", "decode", "Toggle decode for tx"),
715 (
716 "limit",
717 "limit [n]",
718 "Show or set the transaction limit for address",
719 ),
720 (
721 "tokens",
722 "tokens [list|recent|add|remove]",
723 "Manage saved token aliases",
724 ),
725 (
726 "watch",
727 "watch [list|open <name>|stop]",
728 "Run a saved watchlist in the grid (Ctrl-G shows it)",
729 ),
730 (
731 "save",
732 "save <file> [--force]",
733 "Write the last output to a file",
734 ),
735 ("ctx", "ctx", "Show the session context"),
736 ("clear", "clear", "Clear the output pane"),
737 ("reset", "reset", "Reset the session context"),
738 ("help", "help", "Show this help"),
739 ("exit", "exit", "Leave the TUI (also Ctrl-D)"),
740];
741
742const SESSION_ALIASES: &[(&str, &str)] = &[
745 ("quit", "exit"),
746 ("q", "exit"),
747 (".exit", "exit"),
748 (".quit", "exit"),
749 ("context", "ctx"),
750 (".ctx", "ctx"),
751 (".context", "ctx"),
752 (".clear", "clear"),
753 (".reset", "reset"),
754 ("?", "help"),
755 (".help", "help"),
756 ("showtokens", "+tokens"),
757 ("showtxs", "+txs"),
758 ("txs", "+txs"),
759 ("aliases", "tokens"),
760];
761
762pub(crate) fn split_line(line: &str) -> Option<Vec<String>> {
770 if cfg!(windows) {
771 split_backslash_literal(line)
772 } else {
773 shlex::split(line)
774 }
775}
776
777fn split_backslash_literal(line: &str) -> Option<Vec<String>> {
782 let mut words = Vec::new();
783 let mut word: Option<String> = None;
784 let mut quote: Option<char> = None;
785 for c in line.chars() {
786 match quote {
787 Some(q) if c == q => quote = None,
788 Some(_) => word.get_or_insert_default().push(c),
789 None if c == '"' || c == '\'' => {
790 quote = Some(c);
791 word.get_or_insert_default();
792 }
793 None if c.is_whitespace() => words.extend(word.take()),
794 None => word.get_or_insert_default().push(c),
795 }
796 }
797 if quote.is_some() {
798 return None;
799 }
800 words.extend(word);
801 Some(words)
802}
803
804pub(crate) fn session_command_name(word: &str) -> String {
805 let word = word.to_lowercase();
806 SESSION_ALIASES
807 .iter()
808 .find(|(alias, _)| *alias == word)
809 .map_or(word, |(_, name)| (*name).to_string())
810}
811
812fn write_help(out: &Output) -> io::Result<()> {
814 outln!(out, "Session commands:")?;
815 for (name, usage, what) in SESSION_COMMANDS {
816 let aliases: Vec<&str> = SESSION_ALIASES
818 .iter()
819 .filter(|(_, n)| n == name)
820 .map(|(alias, _)| *alias)
821 .filter(|alias| !alias.starts_with('.'))
822 .collect();
823 if aliases.is_empty() {
824 outln!(out, " {:<34} {}", usage, what)?;
825 } else {
826 outln!(
827 out,
828 " {:<34} {} (also: {})",
829 usage,
830 what,
831 aliases.join(", ")
832 )?;
833 }
834 }
835 outln!(out)?;
836 outln!(
837 out,
838 "CLI commands (same flags as on the command line; add --help for details):"
839 )?;
840 for sub in Cli::command().get_subcommands() {
841 let refused = TUI_REFUSED.iter().any(|(n, _)| *n == sub.get_name());
842 if sub.is_hide_set() || refused {
843 continue;
844 }
845 let about = sub.get_about().map(|a| a.to_string()).unwrap_or_default();
846 outln!(out, " {:<34} {}", sub.get_name(), about)?;
847 }
848 outln!(out)?;
849 let painter = scope::display::theme::Painter::new(true);
852 let key = |k: &str| painter.paint(scope::display::theme::Role::Key, k);
853 let what = |w: &str| painter.paint(scope::display::theme::Role::Secondary, w);
854 outln!(
855 out,
856 "Keys: {} {} · {} {} · {} {} · {} {} · {} {} · {} {}",
857 key("Enter"),
858 what("run"),
859 key("Tab"),
860 what("complete"),
861 key("Ctrl-K"),
862 what("palette"),
863 key("↑/↓"),
864 what("history"),
865 key("PgUp/PgDn"),
866 what("scroll"),
867 format_args!("{} or {}", key("Esc"), key("Ctrl-C")),
868 what("cancel"),
869 )?;
870 outln!(
871 out,
872 " {} {} · {} or {} {} · {} {} · {} {}",
873 key("Ctrl-Y"),
874 what("copy last output"),
875 key("Ctrl-F"),
876 key("/"),
877 what("search"),
878 key("Ctrl-R"),
879 what("history search"),
880 key("Shift-drag"),
881 what("selects text"),
882 )?;
883 outln!(
884 out,
885 " {}",
886 what("Copy needs a terminal that allows OSC 52; save <file> works everywhere")
887 )?;
888 outln!(
889 out,
890 " {}",
891 what(
892 "A command with --every shows one live view: the latest report and its trends. After it ends, any key leaves the view; save <file>.csv writes the series."
893 )
894 )
895}
896
897#[cfg(test)]
902mod tests {
903 use super::*;
904
905 const ADDR: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
906 const TX: &str = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
907
908 #[test]
909 fn test_backslash_literal_split_keeps_windows_paths() {
910 let split = |s: &str| split_backslash_literal(s).unwrap();
911 assert_eq!(
912 split(r"save C:\Users\x\f.json"),
913 ["save", r"C:\Users\x\f.json"]
914 );
915 assert_eq!(
916 split(r#"save "C:\My Files\f.json""#),
917 ["save", r"C:\My Files\f.json"]
918 );
919 assert_eq!(split(r"a 'b c'd e"), ["a", "b cd", "e"]);
920 assert_eq!(split(r#"x "" y"#), ["x", "", "y"]);
921 assert_eq!(split(" "), Vec::<String>::new());
922 assert_eq!(split_backslash_literal(r#"save "C:\x"#), None);
923 }
924
925 #[test]
926 fn test_split_line_uses_the_platform_rules() {
927 let words = split_line(r"save C:\x\f.json").unwrap();
928 if cfg!(windows) {
929 assert_eq!(words, ["save", r"C:\x\f.json"]);
930 } else {
931 assert_eq!(words, ["save", "C:xf.json"]);
932 }
933 }
934
935 fn plan_line(line: &str, ctx: &mut SessionContext) -> (Plan, crate::cli::output::Captured) {
936 let (o, cap) = Output::capture();
937 let p = plan(line, ctx, &Config::default(), &o).unwrap();
938 (p, cap)
939 }
940
941 fn run_of(p: Plan) -> Invocation {
942 match p {
943 Plan::Run(inv) => *inv,
944 other => panic!("expected Plan::Run, got {:?}", other),
945 }
946 }
947
948 #[test]
951 fn test_every_cli_command_parses_and_has_a_tui_route() {
952 let refused: Vec<&str> = TUI_REFUSED.iter().map(|(n, _)| *n).collect();
956 for sub in Cli::command().get_subcommands() {
957 let name = sub.get_name();
958 let (o, cap) = Output::capture();
959 let mut ctx = SessionContext::default();
960 let p = plan(
962 &format!("{} --help", name),
963 &mut ctx,
964 &Config::default(),
965 &o,
966 )
967 .unwrap();
968 assert!(matches!(p, Plan::Done), "{}", name);
969 assert!(cap.out().contains("Usage"), "no help for {}", name);
970
971 let mut subcmds = sub.get_subcommands().map(|s| s.get_name()).peekable();
972 let leaf_names: Vec<String> = if subcmds.peek().is_some() {
973 subcmds.map(|s| format!("{} {}", name, s)).collect()
974 } else {
975 vec![name.to_string()]
976 };
977 for leaf in leaf_names {
978 let line = minimal_line(&leaf);
979 let (o, cap) = Output::capture();
980 let p = plan(&line, &mut ctx, &Config::default(), &o).unwrap();
981 if refused.contains(&name) {
982 assert!(matches!(p, Plan::Done), "{} must be refused", leaf);
983 assert!(cap.err().contains("Not available here"), "{}", cap.err());
984 } else {
985 assert!(
987 matches!(p, Plan::Run(_)),
988 "`{}` did not parse: {}",
989 line,
990 cap.err()
991 );
992 }
993 }
994 }
995 }
996
997 fn minimal_line(leaf: &str) -> String {
999 match leaf {
1000 "address" | "insights" | "contract" => format!("{} {}", leaf, ADDR),
1001 "tx" => format!("tx {}", TX),
1002 "crawl" | "token-health" | "monitor" => format!("{} USDC", leaf),
1003 "compliance risk" | "compliance trace" | "compliance analyze" => {
1004 format!("{} {}", leaf, ADDR)
1005 }
1006 "compliance compliance-report" => {
1007 format!("{} {} --output r.md --jurisdiction us", leaf, ADDR)
1008 }
1009 "address-book add" | "address-book remove" => format!("{} {}", leaf, ADDR),
1010 "venues validate" => format!("{} venue.yaml", leaf),
1011 "market summary" | "market ohlc" | "market trades" => format!("{} USDC", leaf),
1012 "report batch" => format!("{} --addresses {} --output r.md", leaf, ADDR),
1013 "export" => format!("export --address {} --output out.json", ADDR),
1014 "completions" => "completions bash".into(),
1015 other => other.to_string(),
1016 }
1017 }
1018
1019 #[test]
1020 fn test_clap_error_gives_tui_next_steps_not_the_cli_hint() {
1021 let (_, cap) = plan_line("market summary --bogus", &mut SessionContext::default());
1022 let text = cap.err();
1023 assert!(!text.contains("try '--help'"), "{text}");
1024 assert!(text.contains("help market summary"), "{text}");
1025 }
1026
1027 #[test]
1030 fn test_exit_words() {
1031 for w in ["exit", "quit", "q", ".exit", ".quit", "EXIT"] {
1032 assert!(matches!(
1033 plan_line(w, &mut SessionContext::default()).0,
1034 Plan::Exit
1035 ));
1036 }
1037 }
1038
1039 #[test]
1040 fn test_empty_line_does_nothing() {
1041 let (p, cap) = plan_line(" ", &mut SessionContext::default());
1042 assert!(matches!(p, Plan::Done));
1043 assert_eq!(cap.out() + &cap.err(), "");
1044 }
1045
1046 #[test]
1047 fn test_chain_pin_and_unpin() {
1048 let mut ctx = SessionContext::default();
1049 plan_line("chain solana", &mut ctx);
1050 assert_eq!(ctx.chain, "solana");
1051 plan_line("chain auto", &mut ctx);
1052 assert!(ctx.is_auto_chain());
1053 let (_, cap) = plan_line("chain notachain", &mut ctx);
1054 assert!(ctx.is_auto_chain());
1055 assert!(cap.err().contains("Unknown chain"));
1056 }
1057
1058 #[test]
1059 fn test_format_set_and_reject() {
1060 let mut ctx = SessionContext::default();
1061 plan_line("format json", &mut ctx);
1062 assert_eq!(ctx.format, OutputFormat::Json);
1063 let (_, cap) = plan_line("format xml", &mut ctx);
1064 assert_eq!(ctx.format, OutputFormat::Json);
1065 assert!(cap.err().contains("Unknown format"));
1066 }
1067
1068 #[test]
1069 fn test_toggles_limit_and_clear() {
1070 let mut ctx = SessionContext::default();
1071 for w in ["+tokens", "+txs", "trace", "decode"] {
1072 plan_line(w, &mut ctx);
1073 }
1074 assert!(ctx.include_tokens && ctx.include_txs && ctx.trace && ctx.decode);
1075 plan_line("limit 25", &mut ctx);
1076 assert_eq!(ctx.limit, 25);
1077 let (_, cap) = plan_line("limit 0", &mut ctx);
1078 assert_eq!(ctx.limit, 25);
1079 assert!(cap.err().contains("Invalid limit"));
1080 plan_line("reset", &mut ctx);
1081 assert_eq!(ctx.limit, SessionContext::default().limit);
1082 assert!(!ctx.include_tokens);
1083 }
1084
1085 #[test]
1086 fn test_bare_session_commands_show_current_values() {
1087 let mut ctx = SessionContext::default();
1088 let (_, cap) = plan_line("chain", &mut ctx);
1089 assert!(cap.out().contains("Current chain: auto"));
1090 plan_line("chain tron", &mut ctx);
1091 let (_, cap) = plan_line("chain", &mut ctx);
1092 assert!(cap.out().contains("Current chain: tron (pinned)"));
1093 let (_, cap) = plan_line("format", &mut ctx);
1094 assert!(cap.out().contains("Current format: Table"));
1095 let (_, cap) = plan_line("limit", &mut ctx);
1096 assert!(cap.out().contains("Current limit: 100"));
1097 }
1098
1099 #[test]
1100 fn test_tokens_session_command_is_routed_not_parsed_by_clap() {
1101 let (p, cap) = plan_line("tokens frobnicate", &mut SessionContext::default());
1104 assert!(matches!(p, Plan::Done));
1105 assert!(cap.err().contains("Unknown tokens subcommand: frobnicate"));
1106 }
1107
1108 #[test]
1109 fn test_missing_target_on_other_commands_is_not_filled_from_context() {
1110 let mut ctx = SessionContext {
1112 last_address: Some(ADDR.into()),
1113 ..Default::default()
1114 };
1115 let (p, cap) = plan_line("crawl", &mut ctx);
1116 assert!(matches!(p, Plan::Done));
1117 assert!(cap.err().contains("required"));
1118 }
1119
1120 #[test]
1121 fn test_context_is_not_injected_before_a_subcommand_is_named() {
1122 let mut ctx = SessionContext {
1124 chain: "base".into(),
1125 ..Default::default()
1126 };
1127 let (p, cap) = plan_line("--ai", &mut ctx);
1128 assert!(matches!(p, Plan::Done));
1129 assert!(!cap.err().contains("--chain"), "{}", cap.err());
1130 }
1131
1132 #[test]
1133 fn test_ctx_prints_context() {
1134 let mut ctx = SessionContext::default();
1135 plan_line("chain base", &mut ctx);
1136 let (_, cap) = plan_line("ctx", &mut ctx);
1137 assert!(cap.out().contains("base (pinned)"));
1138 }
1139
1140 #[test]
1141 fn test_help_lists_session_and_cli_commands() {
1142 let (_, cap) = plan_line("help", &mut SessionContext::default());
1143 for word in ["+tokens", "address", "market", "compliance", "venues"] {
1144 assert!(cap.out().contains(word), "help is missing {}", word);
1145 }
1146 }
1147
1148 #[test]
1149 fn test_help_lists_the_search_keys() {
1150 let (_, cap) = plan_line("help", &mut SessionContext::default());
1151 let help = crate::cli::ansi::strip(&cap.out());
1152 for keys in ["Ctrl-F or / search", "Ctrl-R history search"] {
1153 assert!(help.contains(keys), "help is missing {}", keys);
1154 }
1155 }
1156
1157 #[test]
1158 fn test_help_with_a_command_shows_that_commands_help() {
1159 let (p, cap) = plan_line("help market summary", &mut SessionContext::default());
1160 assert!(matches!(p, Plan::Done));
1161 assert!(cap.out().contains("Usage:"), "{}", cap.out());
1162 assert!(cap.out().contains("--venue"), "{}", cap.out());
1163 assert!(!cap.out().contains("Session commands"), "{}", cap.out());
1164 }
1165
1166 #[test]
1167 fn test_help_with_a_session_command_shows_its_line() {
1168 for (typed, usage) in [
1169 ("help ctx", "ctx"),
1170 ("help tokens", "tokens [list|recent|add|remove]"),
1171 ("help clear", "Clear the output pane"),
1172 ("help reset", "Reset the session context"),
1173 ("help save", "save <file> [--force]"),
1174 ("help quit", "exit"),
1175 ] {
1176 let (p, cap) = plan_line(typed, &mut SessionContext::default());
1177 assert!(matches!(p, Plan::Done));
1178 assert!(cap.out().contains(usage), "{typed}: {}", cap.out());
1179 assert!(cap.err().is_empty(), "{typed}: {}", cap.err());
1180 assert!(!cap.out().contains("Usage:"), "{typed}");
1181 }
1182 }
1183
1184 #[test]
1185 fn test_help_with_an_unknown_command_is_a_clap_error() {
1186 let (p, cap) = plan_line("help nosuchcmd", &mut SessionContext::default());
1187 assert!(matches!(p, Plan::Done));
1188 assert!(cap.err().contains("nosuchcmd"), "{}", cap.err());
1189 assert!(cap.out().is_empty(), "{}", cap.out());
1190 }
1191
1192 fn tokens(line: &str, aliases: &mut TokenAliases, fail_save: bool) -> (String, String, usize) {
1197 let saves = std::cell::Cell::new(0);
1198 let save = |_: &TokenAliases| {
1199 saves.set(saves.get() + 1);
1200 if fail_save {
1201 Err(scope::error::ScopeError::IoError(std::io::Error::other(
1202 "disk full",
1203 )))
1204 } else {
1205 Ok(())
1206 }
1207 };
1208 let (o, cap) = Output::capture();
1209 let args: Vec<&str> = line.split_whitespace().collect();
1210 tokens_command(&args, aliases, &save, &o).unwrap();
1211 (cap.out(), cap.err(), saves.get())
1212 }
1213
1214 #[test]
1215 fn test_tokens_add_list_recent_remove() {
1216 let mut a = TokenAliases::default();
1217 let (out, _, saves) = tokens(
1218 &format!("add USDN ethereum {} Nova USD", ADDR),
1219 &mut a,
1220 false,
1221 );
1222 assert!(out.contains("Added alias: USDN"));
1223 assert_eq!(saves, 1);
1224 assert_eq!(a.get("USDN", None).unwrap().name, "Nova USD");
1225
1226 for cmd in ["", "list", "ls"] {
1227 let (out, _, _) = tokens(cmd, &mut a, false);
1228 assert!(
1229 out.contains("Saved Token Aliases") && out.contains("USDN"),
1230 "{}",
1231 cmd
1232 );
1233 }
1234 let (out, _, _) = tokens("recent", &mut a, false);
1235 assert!(out.contains("No recently used tokens") || out.contains("USDN"));
1236
1237 let (out, _, saves) = tokens("rm USDN --chain ethereum", &mut a, false);
1238 assert!(out.contains("Removed alias: USDN"));
1239 assert_eq!(saves, 1);
1240 assert!(a.get("USDN", None).is_none());
1241 let (out, _, _) = tokens("list", &mut a, false);
1242 assert!(out.contains("No saved token aliases"));
1243 }
1244
1245 #[test]
1246 fn test_tokens_usage_errors_do_not_save() {
1247 let mut a = TokenAliases::default();
1248 for line in ["add USDN ethereum", "remove", "frobnicate"] {
1249 let (out, err, saves) = tokens(line, &mut a, false);
1250 assert_eq!(out, "", "{}", line);
1251 assert!(!err.is_empty(), "{}", line);
1252 assert_eq!(saves, 0, "{}", line);
1253 }
1254 }
1255
1256 #[test]
1257 fn test_tokens_save_failure_is_reported() {
1258 let mut a = TokenAliases::default();
1259 let (out, err, _) = tokens(&format!("add USDN ethereum {}", ADDR), &mut a, true);
1260 assert!(!out.contains("Added"));
1261 assert!(
1262 err.contains("Failed to save the token aliases: "),
1263 "{}",
1264 err
1265 );
1266 assert!(err.contains("disk full"));
1267 }
1268
1269 #[test]
1270 fn test_tokens_recent_and_remove_report_empty_and_failed_saves() {
1271 let mut a = TokenAliases::default();
1272 let (out, _, saves) = tokens("recent", &mut a, false);
1273 assert!(out.contains("No recently used tokens"), "{out}");
1274 assert_eq!(saves, 0, "listing never writes the file");
1275 let (out, _, _) = tokens("list", &mut a, false);
1276 assert!(out.contains("Use 'crawl <token_name> --save'"), "{out}");
1277 tokens(&format!("add USDN ethereum {}", ADDR), &mut a, false);
1279 let (out, err, _) = tokens("remove USDN", &mut a, true);
1280 assert!(!out.contains("Removed"), "{out}");
1281 assert!(err.contains("Failed to save the token aliases: "), "{err}");
1282 }
1283
1284 #[test]
1287 fn test_cli_command_parses_with_aliases() {
1288 let inv = run_of(plan_line(&format!("addr {}", ADDR), &mut SessionContext::default()).0);
1289 assert!(matches!(inv.command, Commands::Address(_)));
1290 assert_eq!(inv.route, Route::Pane);
1291 }
1292
1293 #[test]
1294 fn test_pinned_chain_is_injected_but_typed_flag_wins() {
1295 let mut ctx = SessionContext {
1296 chain: "polygon".into(),
1297 ..Default::default()
1298 };
1299 let inv = run_of(plan_line(&format!("address {}", ADDR), &mut ctx).0);
1300 let Commands::Address(a) = inv.command else {
1301 panic!()
1302 };
1303 assert_eq!(a.chain, "polygon");
1304
1305 let inv = run_of(plan_line(&format!("address {} -c base", ADDR), &mut ctx).0);
1306 let Commands::Address(a) = inv.command else {
1307 panic!()
1308 };
1309 assert_eq!(a.chain, "base");
1310 }
1311
1312 #[test]
1313 fn test_auto_chain_injects_nothing() {
1314 let inv = run_of(plan_line(&format!("address {}", ADDR), &mut SessionContext::default()).0);
1317 assert_eq!(inv.argv, vec!["scope", "address", ADDR]);
1318 }
1319
1320 #[test]
1321 fn test_context_flags_apply_only_where_defined() {
1322 let mut ctx = SessionContext {
1323 include_tokens: true,
1324 trace: true,
1325 limit: 7,
1326 ..Default::default()
1327 };
1328 let inv = run_of(plan_line(&format!("address {}", ADDR), &mut ctx).0);
1329 let Commands::Address(a) = inv.command else {
1330 panic!()
1331 };
1332 assert!(a.include_tokens);
1333 assert_eq!(a.limit, 7);
1334 assert!(!inv.argv.contains(&"--trace".to_string()));
1336
1337 let inv = run_of(plan_line(&format!("tx {}", TX), &mut ctx).0);
1338 let Commands::Tx(t) = inv.command else {
1339 panic!()
1340 };
1341 assert!(t.trace);
1342 assert!(!inv.argv.contains(&"--limit".to_string()));
1343 }
1344
1345 #[test]
1346 fn test_format_injected_only_where_value_is_accepted() {
1347 let mut ctx = SessionContext {
1348 format: OutputFormat::Csv,
1349 ..Default::default()
1350 };
1351 let inv = run_of(plan_line(&format!("address {}", ADDR), &mut ctx).0);
1352 let Commands::Address(a) = inv.command else {
1353 panic!()
1354 };
1355 assert_eq!(a.format, Some(OutputFormat::Csv));
1356 let inv = run_of(plan_line("market summary USDC", &mut ctx).0);
1358 assert!(!inv.argv.contains(&"csv".to_string()));
1359 }
1360
1361 #[test]
1362 fn test_last_address_and_tx_are_reused() {
1363 let mut ctx = SessionContext::default();
1364 run_of(plan_line(&format!("address {}", ADDR), &mut ctx).0);
1365 let inv = run_of(plan_line("address", &mut ctx).0);
1366 let Commands::Address(a) = inv.command else {
1367 panic!()
1368 };
1369 assert_eq!(a.address, ADDR);
1370
1371 run_of(plan_line(&format!("tx {}", TX), &mut ctx).0);
1372 let inv = run_of(plan_line("tx", &mut ctx).0);
1373 let Commands::Tx(t) = inv.command else {
1374 panic!()
1375 };
1376 assert_eq!(t.hash, TX);
1377 }
1378
1379 #[test]
1380 fn test_missing_target_without_history_is_a_clap_error() {
1381 let (p, cap) = plan_line("address", &mut SessionContext::default());
1382 assert!(matches!(p, Plan::Done));
1383 assert!(cap.err().contains("required"), "{}", cap.err());
1384 }
1385
1386 #[test]
1387 fn test_unknown_command_suggests_like_the_cli() {
1388 let (p, cap) = plan_line("adress 0x1", &mut SessionContext::default());
1389 assert!(matches!(p, Plan::Done));
1390 assert!(cap.err().contains("address"), "{}", cap.err());
1391 }
1392
1393 #[test]
1394 fn test_quoted_arguments_and_unbalanced_quotes() {
1395 let inv = run_of(
1396 plan_line(
1397 &format!("address-book add {} --label \"cold wallet\"", ADDR),
1398 &mut SessionContext::default(),
1399 )
1400 .0,
1401 );
1402 assert!(inv.argv.contains(&"cold wallet".to_string()));
1403 let (p, cap) = plan_line("address \"0x1", &mut SessionContext::default());
1404 assert!(matches!(p, Plan::Done));
1405 assert!(cap.err().contains("Unbalanced quotes"));
1406 }
1407
1408 #[test]
1409 fn test_ai_flag_and_config_flag() {
1410 let inv = run_of(
1411 plan_line(
1412 &format!("--ai address {}", ADDR),
1413 &mut SessionContext::default(),
1414 )
1415 .0,
1416 );
1417 assert!(inv.ai);
1418 let (p, cap) = plan_line(
1419 &format!("--config x.yaml address {}", ADDR),
1420 &mut SessionContext::default(),
1421 );
1422 assert!(matches!(p, Plan::Done));
1423 assert!(cap.err().contains("no effect inside the TUI"));
1424 }
1425
1426 fn watch(rest: &[&str], dir: &std::path::Path) -> (Plan, String) {
1429 let (o, cap) = Output::capture();
1430 let venues = vec!["binance".to_string()];
1431 let p = watch_command(rest, dir, &venues, &o).unwrap();
1432 (p, format!("{}{}", cap.out(), cap.err()))
1433 }
1434
1435 #[test]
1436 fn test_watch_list_open_stop_and_usage() {
1437 let dir = tempfile::tempdir().unwrap();
1438 let (p, text) = watch(&["list"], dir.path());
1439 assert!(matches!(p, Plan::Done));
1440 assert!(text.contains("No saved watchlists"), "{text}");
1441
1442 std::fs::write(
1443 dir.path().join("stables.yaml"),
1444 "targets:\n - kind: pair\n id: USDC\n venue: binance\n preset: market health\n",
1445 )
1446 .unwrap();
1447 let (_, text) = watch(&["list"], dir.path());
1448 assert!(text.contains(" stables"), "{text}");
1449 let (p, _) = watch(&["open", "stables"], dir.path());
1450 let Plan::Watch(WatchCommand::Open(w)) = p else {
1451 panic!("expected open, got {p:?}")
1452 };
1453 assert_eq!(w.name, "stables");
1454 assert_eq!(w.targets.len(), 1);
1455
1456 assert!(matches!(
1457 watch(&["stop"], dir.path()).0,
1458 Plan::Watch(WatchCommand::Stop)
1459 ));
1460 assert!(matches!(
1461 watch(&[], dir.path()).0,
1462 Plan::Watch(WatchCommand::Show)
1463 ));
1464 let (p, text) = watch(&["nonsense"], dir.path());
1465 assert!(matches!(p, Plan::Done));
1466 assert!(text.contains(WATCH_USAGE), "{text}");
1467 }
1468
1469 #[test]
1470 fn test_watch_open_of_a_bad_file_lists_every_error_and_starts_nothing() {
1471 let dir = tempfile::tempdir().unwrap();
1472 let path = dir.path().join("bad.yaml");
1473 std::fs::write(
1474 &path,
1475 "targets:\n - kind: pair\n id: USDC\n venue: nowhere\n rules: [\"holdrs > 1\"]\n",
1476 )
1477 .unwrap();
1478 let (p, text) = watch(&["open", path.to_str().unwrap()], dir.path());
1479 assert!(matches!(p, Plan::Done));
1480 assert!(text.contains("is not a valid watchlist"), "{text}");
1481 assert!(text.contains("unknown venue `nowhere`"), "{text}");
1482 assert!(text.contains("unknown metric `holdrs`"), "{text}");
1483 let (p, text) = watch(&["open", "missing"], dir.path());
1484 assert!(matches!(p, Plan::Done));
1485 assert!(text.contains("cannot read"), "{text}");
1486 }
1487
1488 #[test]
1489 fn test_watch_is_a_session_command_in_help() {
1490 assert!(SESSION_COMMANDS.iter().any(|(n, _, _)| *n == "watch"));
1491 }
1492
1493 #[test]
1496 fn test_routes_for_prompting_commands() {
1497 let mut ctx = SessionContext::default();
1498 assert_eq!(
1499 run_of(plan_line("crawl USDC", &mut ctx).0).route,
1500 Route::Pane
1501 );
1502 assert_eq!(
1503 run_of(plan_line("crawl USDC --yes", &mut ctx).0).route,
1504 Route::Pane
1505 );
1506 assert_eq!(
1507 run_of(plan_line(&format!("crawl {}", ADDR), &mut ctx).0).route,
1508 Route::Pane
1509 );
1510 assert_eq!(run_of(plan_line("setup", &mut ctx).0).route, Route::Suspend);
1511 assert_eq!(
1512 run_of(plan_line("setup --status", &mut ctx).0).route,
1513 Route::Pane
1514 );
1515 assert_eq!(
1517 run_of(plan_line("monitor USDC", &mut ctx).0).route,
1518 Route::Wizard
1519 );
1520 assert_eq!(
1521 run_of(plan_line("monitor USDC --dashboard", &mut ctx).0).route,
1522 Route::Suspend
1523 );
1524 }
1525
1526 #[test]
1527 fn test_monitor_never_gets_the_auto_chain() {
1528 let inv = run_of(plan_line("monitor USDC", &mut SessionContext::default()).0);
1533 let Commands::Monitor(m) = inv.command else {
1534 panic!("expected monitor")
1535 };
1536 assert_eq!(m.chain, "ethereum");
1537
1538 let mut ctx = SessionContext {
1539 chain: "solana".into(),
1540 ..Default::default()
1541 };
1542 let inv = run_of(plan_line("monitor USDC", &mut ctx).0);
1543 let Commands::Monitor(m) = inv.command else {
1544 panic!("expected monitor")
1545 };
1546 assert_eq!(m.chain, "solana");
1547 }
1548
1549 #[test]
1550 fn test_hidden_panic_command_is_not_listed() {
1551 assert!(
1552 SESSION_COMMANDS
1553 .iter()
1554 .all(|(n, u, _)| *n != "__panic" && !u.contains("__panic"))
1555 );
1556 }
1557
1558 #[test]
1559 fn test_nested_tui_and_web_are_refused() {
1560 for line in ["interactive", "tui", "shell", "web"] {
1561 let (p, cap) = plan_line(line, &mut SessionContext::default());
1562 assert!(matches!(p, Plan::Done), "{}", line);
1563 assert!(cap.err().contains("Not available here"), "{}", line);
1564 }
1565 }
1566}