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}
32
33pub(crate) const TUI_REFUSED: &[(&str, &str)] = &[
36 ("interactive", "you are already in the TUI"),
37 (
38 "web",
39 "the web server runs in the foreground; start it from a shell with `scope web`",
40 ),
41 (
42 "completions",
43 "it prints a script for your shell; run `scope completions <shell>` in a shell",
44 ),
45];
46
47fn refused(name: &str) -> Route {
49 let reason = TUI_REFUSED
50 .iter()
51 .find(|(n, _)| *n == name)
52 .map_or("it does not run in the TUI", |(_, why)| why);
53 Route::Refuse(reason)
54}
55
56pub fn route(command: &Commands) -> Route {
61 match command {
62 Commands::Address(_)
63 | Commands::Tx(_)
64 | Commands::Insights(_)
65 | Commands::Contract(_)
66 | Commands::TokenHealth(_)
67 | Commands::Discover(_)
68 | Commands::Market(_)
69 | Commands::Venues(_)
70 | Commands::Compliance(_)
71 | Commands::AddressBook(_)
72 | Commands::Export(_)
73 | Commands::Report(_) => Route::Pane,
74 Commands::Crawl(args) => {
77 if args.yes || TokenAliases::is_address(args.token.trim()) {
78 Route::Pane
79 } else {
80 Route::Suspend
81 }
82 }
83 Commands::Setup(args) => {
85 if args.status {
86 Route::Pane
87 } else {
88 Route::Suspend
89 }
90 }
91 Commands::Monitor(_) => Route::Suspend,
93 Commands::Interactive(_) => refused("interactive"),
94 Commands::Web(_) => refused("web"),
95 Commands::Completions(_) => refused("completions"),
96 }
97}
98
99#[derive(Debug)]
101pub struct Invocation {
102 pub command: Commands,
104 pub route: Route,
106 pub ai: bool,
108 pub argv: Vec<String>,
111}
112
113#[derive(Debug)]
115pub enum Plan {
116 Done,
119 Exit,
121 Run(Box<Invocation>),
123}
124
125pub fn plan(
134 line: &str,
135 ctx: &mut SessionContext,
136 config: &Config,
137 out: &Output,
138) -> io::Result<Plan> {
139 let Some(words) = shlex::split(line) else {
140 errln!(out, "Unbalanced quotes in: {}", line)?;
141 return Ok(Plan::Done);
142 };
143 let Some(first) = words.first() else {
144 return Ok(Plan::Done);
145 };
146 let rest: Vec<&str> = words[1..].iter().map(String::as_str).collect();
147
148 match session_command_name(first).as_str() {
149 "exit" => return Ok(Plan::Exit),
150 #[cfg(debug_assertions)]
153 "__panic" => panic!("__panic: manual crash-log check"),
154 "help" => {
155 if rest.is_empty() {
156 write_help(out)?;
157 return Ok(Plan::Done);
158 }
159 let name = session_command_name(&words[1]);
161 if let Some((_, usage, what)) = SESSION_COMMANDS.iter().find(|(n, _, _)| *n == name) {
162 outln!(out, " {:<34} {}", usage, what)?;
163 return Ok(Plan::Done);
164 }
165 let mut words: Vec<String> = words[1..].to_vec();
168 words.push("--help".to_string());
169 return parse_cli(words, ctx, config, out);
170 }
171 "ctx" => {
172 out!(out, "{}", ctx)?;
173 return Ok(Plan::Done);
174 }
175 "reset" => {
176 *ctx = SessionContext::default();
177 outln!(out, "Context reset to defaults.")?;
178 return Ok(Plan::Done);
179 }
180 "chain" => {
181 set_chain(ctx, rest.first().copied(), out)?;
182 return Ok(Plan::Done);
183 }
184 "format" => {
185 set_format(ctx, rest.first().copied(), out)?;
186 return Ok(Plan::Done);
187 }
188 "+tokens" => {
189 ctx.include_tokens = !ctx.include_tokens;
190 outln!(out, "Include tokens: {}", on_off(ctx.include_tokens))?;
191 return Ok(Plan::Done);
192 }
193 "+txs" => {
194 ctx.include_txs = !ctx.include_txs;
195 outln!(out, "Include transactions: {}", on_off(ctx.include_txs))?;
196 return Ok(Plan::Done);
197 }
198 "trace" => {
199 ctx.trace = !ctx.trace;
200 outln!(out, "Trace: {}", on_off(ctx.trace))?;
201 return Ok(Plan::Done);
202 }
203 "decode" => {
204 ctx.decode = !ctx.decode;
205 outln!(out, "Decode: {}", on_off(ctx.decode))?;
206 return Ok(Plan::Done);
207 }
208 "limit" => {
209 set_limit(ctx, rest.first().copied(), out)?;
210 return Ok(Plan::Done);
211 }
212 "tokens" => {
213 tokens_command(&rest, &mut TokenAliases::load(), &TokenAliases::save, out)?;
214 return Ok(Plan::Done);
215 }
216 _ => {}
217 }
218
219 parse_cli(words, ctx, config, out)
220}
221
222fn parse_cli(
225 words: Vec<String>,
226 ctx: &mut SessionContext,
227 config: &Config,
228 out: &Output,
229) -> io::Result<Plan> {
230 let mut argv = vec!["scope".to_string()];
231 argv.extend(inject_context(words, ctx, config));
232
233 let cli = match Cli::try_parse_from(&argv) {
234 Ok(cli) => cli,
235 Err(e) if e.kind() == ErrorKind::MissingRequiredArgument => match last_target(&argv, ctx) {
237 Some(target) => {
238 argv.push(target);
239 match Cli::try_parse_from(&argv) {
240 Ok(cli) => cli,
241 Err(e) => return write_clap_error(&e, &argv, out),
242 }
243 }
244 None => return write_clap_error(&e, &argv, out),
245 },
246 Err(e) => return write_clap_error(&e, &argv, out),
247 };
248
249 if let Some(path) = &cli.config {
250 errln!(
251 out,
252 "--config {} has no effect inside the TUI. Restart with `scope --config {} tui`.",
253 path.display(),
254 path.display()
255 )?;
256 return Ok(Plan::Done);
257 }
258
259 let route = route(&cli.command);
260 if let Route::Refuse(reason) = route {
261 errln!(out, "Not available here: {}.", reason)?;
262 return Ok(Plan::Done);
263 }
264
265 remember_target(&cli.command, ctx);
266 Ok(Plan::Run(Box::new(Invocation {
267 command: cli.command,
268 route,
269 ai: cli.ai,
270 argv,
271 })))
272}
273
274fn write_clap_error(e: &clap::Error, argv: &[String], out: &Output) -> io::Result<Plan> {
279 let text = e.render().ansi().to_string();
280 match e.kind() {
281 ErrorKind::DisplayHelp
282 | ErrorKind::DisplayVersion
283 | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => out!(out, "{}", text)?,
284 _ => {
285 for line in text.lines().filter(|l| !l.contains(CLAP_HELP_HINT)) {
286 errln!(out, "{}", line)?;
287 }
288 errln!(out, "{}", tui_help_hint(argv))?;
289 }
290 }
291 Ok(Plan::Done)
292}
293
294const CLAP_HELP_HINT: &str = "For more information";
296
297fn tui_help_hint(argv: &[String]) -> String {
300 let root = Cli::command();
301 let mut cmd = &root;
302 let mut words = Vec::new();
303 for w in argv.iter().skip(1).filter(|w| !w.starts_with('-')) {
304 match cmd.find_subcommand(w) {
305 Some(sub) => {
306 words.push(w.as_str());
307 cmd = sub;
308 }
309 None => break,
310 }
311 }
312 if words.is_empty() {
313 "run 'help <command>' for its options".to_string()
314 } else {
315 format!("run 'help {}' for its options", words.join(" "))
316 }
317}
318
319struct ContextFlag {
321 long: &'static str,
323 value: Option<String>,
325 only: Option<&'static [&'static str]>,
327}
328
329fn inject_context(mut words: Vec<String>, ctx: &SessionContext, config: &Config) -> Vec<String> {
335 let mut flags = Vec::new();
336 if !ctx.is_auto_chain() {
337 flags.push(ContextFlag {
338 long: "chain",
339 value: Some(ctx.chain.clone()),
340 only: None,
341 });
342 }
343 if ctx.format != config.output.format
345 && let Some(v) = ctx.format.to_possible_value()
346 {
347 flags.push(ContextFlag {
348 long: "format",
349 value: Some(v.get_name().to_string()),
350 only: None,
351 });
352 }
353 for (on, long) in [
354 (ctx.include_tokens, "include-tokens"),
355 (ctx.include_txs, "include-txs"),
356 (ctx.trace, "trace"),
357 (ctx.decode, "decode"),
358 ] {
359 if on {
360 flags.push(ContextFlag {
361 long,
362 value: None,
363 only: None,
364 });
365 }
366 }
367 if ctx.limit != SessionContext::default().limit {
369 flags.push(ContextFlag {
370 long: "limit",
371 value: Some(ctx.limit.to_string()),
372 only: Some(&["address"]),
373 });
374 }
375 if flags.is_empty() {
376 return words;
377 }
378
379 let root = Cli::command();
380 let mut cmd = &root;
381 for w in &words {
382 if w.starts_with('-') {
383 continue;
384 }
385 match cmd.find_subcommand(w) {
386 Some(sub) => cmd = sub,
387 None => break,
388 }
389 }
390 if std::ptr::eq(cmd, &root) {
391 return words;
392 }
393
394 for flag in flags {
395 if flag
396 .only
397 .is_some_and(|names| !names.contains(&cmd.get_name()))
398 {
399 continue;
400 }
401 let Some(arg) = cmd
402 .get_arguments()
403 .find(|a| a.get_long() == Some(flag.long))
404 else {
405 continue;
406 };
407 let typed = words.iter().any(|w| {
408 w == &format!("--{}", flag.long)
409 || w.starts_with(&format!("--{}=", flag.long))
410 || arg
411 .get_short()
412 .is_some_and(|s| w.starts_with(&format!("-{}", s)) && !w.starts_with("--"))
413 });
414 if typed {
415 continue;
416 }
417 if let Some(value) = &flag.value {
418 let values = arg.get_possible_values();
419 if !values.is_empty() && !values.iter().any(|p| p.matches(value, true)) {
420 continue;
421 }
422 words.push(format!("--{}", flag.long));
423 words.push(value.clone());
424 } else {
425 words.push(format!("--{}", flag.long));
426 }
427 }
428 words
429}
430
431fn last_target(argv: &[String], ctx: &SessionContext) -> Option<String> {
434 let root = Cli::command();
435 let name = argv.get(1)?;
436 let sub = root.find_subcommand(name)?;
437 match sub.get_name() {
438 "address" => ctx.last_address.clone(),
439 "tx" => ctx.last_tx.clone(),
440 _ => None,
441 }
442}
443
444fn remember_target(command: &Commands, ctx: &mut SessionContext) {
446 match command {
447 Commands::Address(args) => ctx.last_address = Some(args.address.clone()),
448 Commands::Tx(args) => ctx.last_tx = Some(args.hash.clone()),
449 _ => {}
450 }
451}
452
453fn on_off(b: bool) -> &'static str {
454 if b { "on" } else { "off" }
455}
456
457fn set_chain(ctx: &mut SessionContext, arg: Option<&str>, out: &Output) -> io::Result<()> {
458 match arg.map(str::to_lowercase) {
459 None if ctx.is_auto_chain() => outln!(out, "Current chain: auto (inferred from input)"),
460 None => outln!(out, "Current chain: {} (pinned)", ctx.chain),
461 Some(c) if c == "auto" => {
462 ctx.chain = "auto".into();
463 outln!(out, "Chain set to auto — will infer from each input")
464 }
465 Some(c) if scope::chains::registry::client_chain_ids().contains(&c.as_str()) => {
466 outln!(out, "Chain pinned to: {}", c)?;
467 ctx.chain = c;
468 Ok(())
469 }
470 Some(c) => errln!(
471 out,
472 "Unknown chain: {}. Valid chains: auto, {}",
473 c,
474 scope::chains::registry::client_chain_list()
475 ),
476 }
477}
478
479fn set_format(ctx: &mut SessionContext, arg: Option<&str>, out: &Output) -> io::Result<()> {
480 let Some(arg) = arg else {
481 return outln!(out, "Current format: {:?}", ctx.format);
482 };
483 match OutputFormat::from_str(arg, true) {
484 Ok(f) => {
485 ctx.format = f;
486 outln!(out, "Format set to: {}", arg.to_lowercase())
487 }
488 Err(_) => {
489 let names: Vec<String> = OutputFormat::value_variants()
490 .iter()
491 .filter_map(|v| v.to_possible_value().map(|p| p.get_name().to_string()))
492 .collect();
493 errln!(
494 out,
495 "Unknown format: {}. Valid formats: {}",
496 arg,
497 names.join(", ")
498 )
499 }
500 }
501}
502
503fn set_limit(ctx: &mut SessionContext, arg: Option<&str>, out: &Output) -> io::Result<()> {
504 match arg.map(str::parse::<u32>) {
505 None => outln!(out, "Current limit: {}", ctx.limit),
506 Some(Ok(n)) if n > 0 => {
507 ctx.limit = n;
508 outln!(out, "Limit set to: {}", n)
509 }
510 Some(_) => errln!(
511 out,
512 "Invalid limit: {}. Must be a positive integer.",
513 arg.unwrap_or_default()
514 ),
515 }
516}
517
518type SaveAliases<'a> = dyn Fn(&TokenAliases) -> scope::error::Result<()> + 'a;
520
521fn tokens_command(
523 args: &[&str],
524 aliases: &mut TokenAliases,
525 save: &SaveAliases<'_>,
526 out: &Output,
527) -> io::Result<()> {
528 match args.first().map(|s| s.to_lowercase()).as_deref() {
529 None | Some("list") | Some("ls") => {
530 let tokens = aliases.list();
531 if tokens.is_empty() {
532 outln!(
533 out,
534 "{}",
535 scope::display::terminal::info_row("No saved token aliases.")
536 )?;
537 return outln!(
538 out,
539 "{}",
540 scope::display::terminal::info_row(
541 "Use 'crawl <token_name> --save' to save a token alias."
542 )
543 );
544 }
545 write_token_table(out, "Saved Token Aliases", &tokens)
546 }
547 Some("recent") => {
548 let recent = aliases.recent();
549 if recent.is_empty() {
550 return outln!(
551 out,
552 "{}",
553 scope::display::terminal::info_row("No recently used tokens.")
554 );
555 }
556 write_token_table(out, "Recently Used Tokens", recent)
557 }
558 Some("remove") | Some("rm") | Some("delete") => {
559 let Some(symbol) = args.get(1) else {
560 return errln!(out, "Usage: tokens remove <symbol> [--chain <chain>]");
561 };
562 let chain = match (args.get(2), args.get(3)) {
563 (Some(&"--chain"), Some(c)) => Some(*c),
564 _ => None,
565 };
566 aliases.remove(symbol, chain);
567 match save(aliases) {
568 Ok(()) => outln!(out, "Removed alias: {}", symbol),
569 Err(e) => errln!(out, "Failed to save the token aliases: {}", e),
570 }
571 }
572 Some("add") => {
573 let (Some(symbol), Some(chain), Some(address)) =
574 (args.get(1), args.get(2), args.get(3))
575 else {
576 return errln!(out, "Usage: tokens add <symbol> <chain> <address> [name]");
577 };
578 let name = if args.len() > 4 {
579 args[4..].join(" ")
580 } else {
581 symbol.to_string()
582 };
583 aliases.add(symbol, chain, address, &name);
584 match save(aliases) {
585 Ok(()) => outln!(out, "Added alias: {} -> {} on {}", symbol, address, chain),
586 Err(e) => errln!(out, "Failed to save the token aliases: {}", e),
587 }
588 }
589 Some(other) => {
590 errln!(out, "Unknown tokens subcommand: {}", other)?;
591 errln!(out, "Available: list, recent, add, remove")
592 }
593 }
594}
595
596fn write_token_table<T>(out: &Output, title: &str, tokens: &[T]) -> io::Result<()>
597where
598 T: std::borrow::Borrow<scope::tokens::TokenInfo>,
599{
600 use scope::display::terminal as t;
601 let col = |label, width| t::Col {
602 label,
603 width,
604 align: '<',
605 };
606 let cols = &[
607 col("Symbol", 10),
608 col("Chain", 12),
609 col("Name", 20),
610 col("Address", 42),
611 ];
612 outln!(out, "{}", t::section_header(title))?;
613 outln!(out, "{}", t::table_header(cols))?;
614 for token in tokens.iter().map(std::borrow::Borrow::borrow) {
615 outln!(
616 out,
617 "{}",
618 t::table_row(
619 cols,
620 &[&token.symbol, &token.chain, &token.name, &token.address]
621 )
622 )?;
623 }
624 outln!(out, "{}", t::section_footer())
625}
626
627pub(crate) const SESSION_COMMANDS: &[(&str, &str, &str)] = &[
630 ("chain", "chain [name|auto]", "Show, pin or unpin the chain"),
631 (
632 "format",
633 "format [table|json|csv|markdown]",
634 "Show or set the output format",
635 ),
636 ("+tokens", "+tokens", "Toggle token balances for address"),
637 ("+txs", "+txs", "Toggle transactions for address"),
638 ("trace", "trace", "Toggle trace for tx"),
639 ("decode", "decode", "Toggle decode for tx"),
640 (
641 "limit",
642 "limit [n]",
643 "Show or set the transaction limit for address",
644 ),
645 (
646 "tokens",
647 "tokens [list|recent|add|remove]",
648 "Manage saved token aliases",
649 ),
650 (
651 "save",
652 "save <file> [--force]",
653 "Write the last output to a file",
654 ),
655 ("ctx", "ctx", "Show the session context"),
656 ("clear", "clear", "Clear the output pane"),
657 ("reset", "reset", "Reset the session context"),
658 ("help", "help", "Show this help"),
659 ("exit", "exit", "Leave the TUI (also Ctrl-D)"),
660];
661
662const SESSION_ALIASES: &[(&str, &str)] = &[
665 ("quit", "exit"),
666 ("q", "exit"),
667 (".exit", "exit"),
668 (".quit", "exit"),
669 ("context", "ctx"),
670 (".ctx", "ctx"),
671 (".context", "ctx"),
672 (".clear", "clear"),
673 (".reset", "reset"),
674 ("?", "help"),
675 (".help", "help"),
676 ("showtokens", "+tokens"),
677 ("showtxs", "+txs"),
678 ("txs", "+txs"),
679 ("aliases", "tokens"),
680];
681
682pub(crate) fn session_command_name(word: &str) -> String {
685 let word = word.to_lowercase();
686 SESSION_ALIASES
687 .iter()
688 .find(|(alias, _)| *alias == word)
689 .map_or(word, |(_, name)| (*name).to_string())
690}
691
692fn write_help(out: &Output) -> io::Result<()> {
694 outln!(out, "Session commands:")?;
695 for (name, usage, what) in SESSION_COMMANDS {
696 let aliases: Vec<&str> = SESSION_ALIASES
698 .iter()
699 .filter(|(_, n)| n == name)
700 .map(|(alias, _)| *alias)
701 .filter(|alias| !alias.starts_with('.'))
702 .collect();
703 if aliases.is_empty() {
704 outln!(out, " {:<34} {}", usage, what)?;
705 } else {
706 outln!(
707 out,
708 " {:<34} {} (also: {})",
709 usage,
710 what,
711 aliases.join(", ")
712 )?;
713 }
714 }
715 outln!(out)?;
716 outln!(
717 out,
718 "CLI commands (same flags as on the command line; add --help for details):"
719 )?;
720 for sub in Cli::command().get_subcommands() {
721 let refused = TUI_REFUSED.iter().any(|(n, _)| *n == sub.get_name());
722 if sub.is_hide_set() || refused {
723 continue;
724 }
725 let about = sub.get_about().map(|a| a.to_string()).unwrap_or_default();
726 outln!(out, " {:<34} {}", sub.get_name(), about)?;
727 }
728 outln!(out)?;
729 let painter = scope::display::theme::Painter::new(true);
732 let key = |k: &str| painter.paint(scope::display::theme::Role::Key, k);
733 let what = |w: &str| painter.paint(scope::display::theme::Role::Secondary, w);
734 outln!(
735 out,
736 "Keys: {} {} · {} {} · {} {} · {} {} · {} {} · {} {}",
737 key("Enter"),
738 what("run"),
739 key("Tab"),
740 what("complete"),
741 key("Ctrl-K"),
742 what("palette"),
743 key("↑/↓"),
744 what("history"),
745 key("PgUp/PgDn"),
746 what("scroll"),
747 format_args!("{} or {}", key("Esc"), key("Ctrl-C")),
748 what("cancel"),
749 )?;
750 outln!(
751 out,
752 " {} {} · {} or {} {} · {} {} · {} {}",
753 key("Ctrl-Y"),
754 what("copy last output"),
755 key("Ctrl-F"),
756 key("/"),
757 what("search"),
758 key("Ctrl-R"),
759 what("history search"),
760 key("Shift-drag"),
761 what("selects text"),
762 )?;
763 outln!(
764 out,
765 " {}",
766 what("Copy needs a terminal that allows OSC 52; save <file> works everywhere")
767 )?;
768 outln!(
769 out,
770 " {}",
771 what(
772 "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."
773 )
774 )
775}
776
777#[cfg(test)]
782mod tests {
783 use super::*;
784
785 const ADDR: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
786 const TX: &str = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
787
788 fn plan_line(line: &str, ctx: &mut SessionContext) -> (Plan, crate::cli::output::Captured) {
789 let (o, cap) = Output::capture();
790 let p = plan(line, ctx, &Config::default(), &o).unwrap();
791 (p, cap)
792 }
793
794 fn run_of(p: Plan) -> Invocation {
795 match p {
796 Plan::Run(inv) => *inv,
797 other => panic!("expected Plan::Run, got {:?}", other),
798 }
799 }
800
801 #[test]
804 fn test_every_cli_command_parses_and_has_a_tui_route() {
805 let refused: Vec<&str> = TUI_REFUSED.iter().map(|(n, _)| *n).collect();
809 for sub in Cli::command().get_subcommands() {
810 let name = sub.get_name();
811 let (o, cap) = Output::capture();
812 let mut ctx = SessionContext::default();
813 let p = plan(
815 &format!("{} --help", name),
816 &mut ctx,
817 &Config::default(),
818 &o,
819 )
820 .unwrap();
821 assert!(matches!(p, Plan::Done), "{}", name);
822 assert!(cap.out().contains("Usage"), "no help for {}", name);
823
824 let mut subcmds = sub.get_subcommands().map(|s| s.get_name()).peekable();
825 let leaf_names: Vec<String> = if subcmds.peek().is_some() {
826 subcmds.map(|s| format!("{} {}", name, s)).collect()
827 } else {
828 vec![name.to_string()]
829 };
830 for leaf in leaf_names {
831 let line = minimal_line(&leaf);
832 let (o, cap) = Output::capture();
833 let p = plan(&line, &mut ctx, &Config::default(), &o).unwrap();
834 if refused.contains(&name) {
835 assert!(matches!(p, Plan::Done), "{} must be refused", leaf);
836 assert!(cap.err().contains("Not available here"), "{}", cap.err());
837 } else {
838 assert!(
840 matches!(p, Plan::Run(_)),
841 "`{}` did not parse: {}",
842 line,
843 cap.err()
844 );
845 }
846 }
847 }
848 }
849
850 fn minimal_line(leaf: &str) -> String {
852 match leaf {
853 "address" | "insights" | "contract" => format!("{} {}", leaf, ADDR),
854 "tx" => format!("tx {}", TX),
855 "crawl" | "token-health" | "monitor" => format!("{} USDC", leaf),
856 "compliance risk" | "compliance trace" | "compliance analyze" => {
857 format!("{} {}", leaf, ADDR)
858 }
859 "compliance compliance-report" => {
860 format!("{} {} --output r.md --jurisdiction us", leaf, ADDR)
861 }
862 "address-book add" | "address-book remove" => format!("{} {}", leaf, ADDR),
863 "venues validate" => format!("{} venue.yaml", leaf),
864 "market summary" | "market ohlc" | "market trades" => format!("{} USDC", leaf),
865 "report batch" => format!("{} --addresses {} --output r.md", leaf, ADDR),
866 "export" => format!("export --address {} --output out.json", ADDR),
867 "completions" => "completions bash".into(),
868 other => other.to_string(),
869 }
870 }
871
872 #[test]
873 fn test_clap_error_gives_tui_next_steps_not_the_cli_hint() {
874 let (_, cap) = plan_line("market summary --bogus", &mut SessionContext::default());
875 let text = cap.err();
876 assert!(!text.contains("try '--help'"), "{text}");
877 assert!(text.contains("help market summary"), "{text}");
878 }
879
880 #[test]
883 fn test_exit_words() {
884 for w in ["exit", "quit", "q", ".exit", ".quit", "EXIT"] {
885 assert!(matches!(
886 plan_line(w, &mut SessionContext::default()).0,
887 Plan::Exit
888 ));
889 }
890 }
891
892 #[test]
893 fn test_empty_line_does_nothing() {
894 let (p, cap) = plan_line(" ", &mut SessionContext::default());
895 assert!(matches!(p, Plan::Done));
896 assert_eq!(cap.out() + &cap.err(), "");
897 }
898
899 #[test]
900 fn test_chain_pin_and_unpin() {
901 let mut ctx = SessionContext::default();
902 plan_line("chain solana", &mut ctx);
903 assert_eq!(ctx.chain, "solana");
904 plan_line("chain auto", &mut ctx);
905 assert!(ctx.is_auto_chain());
906 let (_, cap) = plan_line("chain notachain", &mut ctx);
907 assert!(ctx.is_auto_chain());
908 assert!(cap.err().contains("Unknown chain"));
909 }
910
911 #[test]
912 fn test_format_set_and_reject() {
913 let mut ctx = SessionContext::default();
914 plan_line("format json", &mut ctx);
915 assert_eq!(ctx.format, OutputFormat::Json);
916 let (_, cap) = plan_line("format xml", &mut ctx);
917 assert_eq!(ctx.format, OutputFormat::Json);
918 assert!(cap.err().contains("Unknown format"));
919 }
920
921 #[test]
922 fn test_toggles_limit_and_clear() {
923 let mut ctx = SessionContext::default();
924 for w in ["+tokens", "+txs", "trace", "decode"] {
925 plan_line(w, &mut ctx);
926 }
927 assert!(ctx.include_tokens && ctx.include_txs && ctx.trace && ctx.decode);
928 plan_line("limit 25", &mut ctx);
929 assert_eq!(ctx.limit, 25);
930 let (_, cap) = plan_line("limit 0", &mut ctx);
931 assert_eq!(ctx.limit, 25);
932 assert!(cap.err().contains("Invalid limit"));
933 plan_line("reset", &mut ctx);
934 assert_eq!(ctx.limit, SessionContext::default().limit);
935 assert!(!ctx.include_tokens);
936 }
937
938 #[test]
939 fn test_bare_session_commands_show_current_values() {
940 let mut ctx = SessionContext::default();
941 let (_, cap) = plan_line("chain", &mut ctx);
942 assert!(cap.out().contains("Current chain: auto"));
943 plan_line("chain tron", &mut ctx);
944 let (_, cap) = plan_line("chain", &mut ctx);
945 assert!(cap.out().contains("Current chain: tron (pinned)"));
946 let (_, cap) = plan_line("format", &mut ctx);
947 assert!(cap.out().contains("Current format: Table"));
948 let (_, cap) = plan_line("limit", &mut ctx);
949 assert!(cap.out().contains("Current limit: 100"));
950 }
951
952 #[test]
953 fn test_tokens_session_command_is_routed_not_parsed_by_clap() {
954 let (p, cap) = plan_line("tokens frobnicate", &mut SessionContext::default());
957 assert!(matches!(p, Plan::Done));
958 assert!(cap.err().contains("Unknown tokens subcommand: frobnicate"));
959 }
960
961 #[test]
962 fn test_missing_target_on_other_commands_is_not_filled_from_context() {
963 let mut ctx = SessionContext {
965 last_address: Some(ADDR.into()),
966 ..Default::default()
967 };
968 let (p, cap) = plan_line("crawl", &mut ctx);
969 assert!(matches!(p, Plan::Done));
970 assert!(cap.err().contains("required"));
971 }
972
973 #[test]
974 fn test_context_is_not_injected_before_a_subcommand_is_named() {
975 let mut ctx = SessionContext {
977 chain: "base".into(),
978 ..Default::default()
979 };
980 let (p, cap) = plan_line("--ai", &mut ctx);
981 assert!(matches!(p, Plan::Done));
982 assert!(!cap.err().contains("--chain"), "{}", cap.err());
983 }
984
985 #[test]
986 fn test_ctx_prints_context() {
987 let mut ctx = SessionContext::default();
988 plan_line("chain base", &mut ctx);
989 let (_, cap) = plan_line("ctx", &mut ctx);
990 assert!(cap.out().contains("base (pinned)"));
991 }
992
993 #[test]
994 fn test_help_lists_session_and_cli_commands() {
995 let (_, cap) = plan_line("help", &mut SessionContext::default());
996 for word in ["+tokens", "address", "market", "compliance", "venues"] {
997 assert!(cap.out().contains(word), "help is missing {}", word);
998 }
999 }
1000
1001 #[test]
1002 fn test_help_lists_the_search_keys() {
1003 let (_, cap) = plan_line("help", &mut SessionContext::default());
1004 let help = crate::cli::ansi::strip(&cap.out());
1005 for keys in ["Ctrl-F or / search", "Ctrl-R history search"] {
1006 assert!(help.contains(keys), "help is missing {}", keys);
1007 }
1008 }
1009
1010 #[test]
1011 fn test_help_with_a_command_shows_that_commands_help() {
1012 let (p, cap) = plan_line("help market summary", &mut SessionContext::default());
1013 assert!(matches!(p, Plan::Done));
1014 assert!(cap.out().contains("Usage:"), "{}", cap.out());
1015 assert!(cap.out().contains("--venue"), "{}", cap.out());
1016 assert!(!cap.out().contains("Session commands"), "{}", cap.out());
1017 }
1018
1019 #[test]
1020 fn test_help_with_a_session_command_shows_its_line() {
1021 for (typed, usage) in [
1022 ("help ctx", "ctx"),
1023 ("help tokens", "tokens [list|recent|add|remove]"),
1024 ("help clear", "Clear the output pane"),
1025 ("help reset", "Reset the session context"),
1026 ("help save", "save <file> [--force]"),
1027 ("help quit", "exit"),
1028 ] {
1029 let (p, cap) = plan_line(typed, &mut SessionContext::default());
1030 assert!(matches!(p, Plan::Done));
1031 assert!(cap.out().contains(usage), "{typed}: {}", cap.out());
1032 assert!(cap.err().is_empty(), "{typed}: {}", cap.err());
1033 assert!(!cap.out().contains("Usage:"), "{typed}");
1034 }
1035 }
1036
1037 #[test]
1038 fn test_help_with_an_unknown_command_is_a_clap_error() {
1039 let (p, cap) = plan_line("help nosuchcmd", &mut SessionContext::default());
1040 assert!(matches!(p, Plan::Done));
1041 assert!(cap.err().contains("nosuchcmd"), "{}", cap.err());
1042 assert!(cap.out().is_empty(), "{}", cap.out());
1043 }
1044
1045 fn tokens(line: &str, aliases: &mut TokenAliases, fail_save: bool) -> (String, String, usize) {
1050 let saves = std::cell::Cell::new(0);
1051 let save = |_: &TokenAliases| {
1052 saves.set(saves.get() + 1);
1053 if fail_save {
1054 Err(scope::error::ScopeError::IoError(std::io::Error::other(
1055 "disk full",
1056 )))
1057 } else {
1058 Ok(())
1059 }
1060 };
1061 let (o, cap) = Output::capture();
1062 let args: Vec<&str> = line.split_whitespace().collect();
1063 tokens_command(&args, aliases, &save, &o).unwrap();
1064 (cap.out(), cap.err(), saves.get())
1065 }
1066
1067 #[test]
1068 fn test_tokens_add_list_recent_remove() {
1069 let mut a = TokenAliases::default();
1070 let (out, _, saves) = tokens(
1071 &format!("add USDN ethereum {} Nova USD", ADDR),
1072 &mut a,
1073 false,
1074 );
1075 assert!(out.contains("Added alias: USDN"));
1076 assert_eq!(saves, 1);
1077 assert_eq!(a.get("USDN", None).unwrap().name, "Nova USD");
1078
1079 for cmd in ["", "list", "ls"] {
1080 let (out, _, _) = tokens(cmd, &mut a, false);
1081 assert!(
1082 out.contains("Saved Token Aliases") && out.contains("USDN"),
1083 "{}",
1084 cmd
1085 );
1086 }
1087 let (out, _, _) = tokens("recent", &mut a, false);
1088 assert!(out.contains("No recently used tokens") || out.contains("USDN"));
1089
1090 let (out, _, saves) = tokens("rm USDN --chain ethereum", &mut a, false);
1091 assert!(out.contains("Removed alias: USDN"));
1092 assert_eq!(saves, 1);
1093 assert!(a.get("USDN", None).is_none());
1094 let (out, _, _) = tokens("list", &mut a, false);
1095 assert!(out.contains("No saved token aliases"));
1096 }
1097
1098 #[test]
1099 fn test_tokens_usage_errors_do_not_save() {
1100 let mut a = TokenAliases::default();
1101 for line in ["add USDN ethereum", "remove", "frobnicate"] {
1102 let (out, err, saves) = tokens(line, &mut a, false);
1103 assert_eq!(out, "", "{}", line);
1104 assert!(!err.is_empty(), "{}", line);
1105 assert_eq!(saves, 0, "{}", line);
1106 }
1107 }
1108
1109 #[test]
1110 fn test_tokens_save_failure_is_reported() {
1111 let mut a = TokenAliases::default();
1112 let (out, err, _) = tokens(&format!("add USDN ethereum {}", ADDR), &mut a, true);
1113 assert!(!out.contains("Added"));
1114 assert!(
1115 err.contains("Failed to save the token aliases: "),
1116 "{}",
1117 err
1118 );
1119 assert!(err.contains("disk full"));
1120 }
1121
1122 #[test]
1123 fn test_tokens_recent_and_remove_report_empty_and_failed_saves() {
1124 let mut a = TokenAliases::default();
1125 let (out, _, saves) = tokens("recent", &mut a, false);
1126 assert!(out.contains("No recently used tokens"), "{out}");
1127 assert_eq!(saves, 0, "listing never writes the file");
1128 let (out, _, _) = tokens("list", &mut a, false);
1129 assert!(out.contains("Use 'crawl <token_name> --save'"), "{out}");
1130 tokens(&format!("add USDN ethereum {}", ADDR), &mut a, false);
1132 let (out, err, _) = tokens("remove USDN", &mut a, true);
1133 assert!(!out.contains("Removed"), "{out}");
1134 assert!(err.contains("Failed to save the token aliases: "), "{err}");
1135 }
1136
1137 #[test]
1140 fn test_cli_command_parses_with_aliases() {
1141 let inv = run_of(plan_line(&format!("addr {}", ADDR), &mut SessionContext::default()).0);
1142 assert!(matches!(inv.command, Commands::Address(_)));
1143 assert_eq!(inv.route, Route::Pane);
1144 }
1145
1146 #[test]
1147 fn test_pinned_chain_is_injected_but_typed_flag_wins() {
1148 let mut ctx = SessionContext {
1149 chain: "polygon".into(),
1150 ..Default::default()
1151 };
1152 let inv = run_of(plan_line(&format!("address {}", ADDR), &mut ctx).0);
1153 let Commands::Address(a) = inv.command else {
1154 panic!()
1155 };
1156 assert_eq!(a.chain, "polygon");
1157
1158 let inv = run_of(plan_line(&format!("address {} -c base", ADDR), &mut ctx).0);
1159 let Commands::Address(a) = inv.command else {
1160 panic!()
1161 };
1162 assert_eq!(a.chain, "base");
1163 }
1164
1165 #[test]
1166 fn test_auto_chain_injects_nothing() {
1167 let inv = run_of(plan_line(&format!("address {}", ADDR), &mut SessionContext::default()).0);
1170 assert_eq!(inv.argv, vec!["scope", "address", ADDR]);
1171 }
1172
1173 #[test]
1174 fn test_context_flags_apply_only_where_defined() {
1175 let mut ctx = SessionContext {
1176 include_tokens: true,
1177 trace: true,
1178 limit: 7,
1179 ..Default::default()
1180 };
1181 let inv = run_of(plan_line(&format!("address {}", ADDR), &mut ctx).0);
1182 let Commands::Address(a) = inv.command else {
1183 panic!()
1184 };
1185 assert!(a.include_tokens);
1186 assert_eq!(a.limit, 7);
1187 assert!(!inv.argv.contains(&"--trace".to_string()));
1189
1190 let inv = run_of(plan_line(&format!("tx {}", TX), &mut ctx).0);
1191 let Commands::Tx(t) = inv.command else {
1192 panic!()
1193 };
1194 assert!(t.trace);
1195 assert!(!inv.argv.contains(&"--limit".to_string()));
1196 }
1197
1198 #[test]
1199 fn test_format_injected_only_where_value_is_accepted() {
1200 let mut ctx = SessionContext {
1201 format: OutputFormat::Csv,
1202 ..Default::default()
1203 };
1204 let inv = run_of(plan_line(&format!("address {}", ADDR), &mut ctx).0);
1205 let Commands::Address(a) = inv.command else {
1206 panic!()
1207 };
1208 assert_eq!(a.format, Some(OutputFormat::Csv));
1209 let inv = run_of(plan_line("market summary USDC", &mut ctx).0);
1211 assert!(!inv.argv.contains(&"csv".to_string()));
1212 }
1213
1214 #[test]
1215 fn test_last_address_and_tx_are_reused() {
1216 let mut ctx = SessionContext::default();
1217 run_of(plan_line(&format!("address {}", ADDR), &mut ctx).0);
1218 let inv = run_of(plan_line("address", &mut ctx).0);
1219 let Commands::Address(a) = inv.command else {
1220 panic!()
1221 };
1222 assert_eq!(a.address, ADDR);
1223
1224 run_of(plan_line(&format!("tx {}", TX), &mut ctx).0);
1225 let inv = run_of(plan_line("tx", &mut ctx).0);
1226 let Commands::Tx(t) = inv.command else {
1227 panic!()
1228 };
1229 assert_eq!(t.hash, TX);
1230 }
1231
1232 #[test]
1233 fn test_missing_target_without_history_is_a_clap_error() {
1234 let (p, cap) = plan_line("address", &mut SessionContext::default());
1235 assert!(matches!(p, Plan::Done));
1236 assert!(cap.err().contains("required"), "{}", cap.err());
1237 }
1238
1239 #[test]
1240 fn test_unknown_command_suggests_like_the_cli() {
1241 let (p, cap) = plan_line("adress 0x1", &mut SessionContext::default());
1242 assert!(matches!(p, Plan::Done));
1243 assert!(cap.err().contains("address"), "{}", cap.err());
1244 }
1245
1246 #[test]
1247 fn test_quoted_arguments_and_unbalanced_quotes() {
1248 let inv = run_of(
1249 plan_line(
1250 &format!("address-book add {} --label \"cold wallet\"", ADDR),
1251 &mut SessionContext::default(),
1252 )
1253 .0,
1254 );
1255 assert!(inv.argv.contains(&"cold wallet".to_string()));
1256 let (p, cap) = plan_line("address \"0x1", &mut SessionContext::default());
1257 assert!(matches!(p, Plan::Done));
1258 assert!(cap.err().contains("Unbalanced quotes"));
1259 }
1260
1261 #[test]
1262 fn test_ai_flag_and_config_flag() {
1263 let inv = run_of(
1264 plan_line(
1265 &format!("--ai address {}", ADDR),
1266 &mut SessionContext::default(),
1267 )
1268 .0,
1269 );
1270 assert!(inv.ai);
1271 let (p, cap) = plan_line(
1272 &format!("--config x.yaml address {}", ADDR),
1273 &mut SessionContext::default(),
1274 );
1275 assert!(matches!(p, Plan::Done));
1276 assert!(cap.err().contains("no effect inside the TUI"));
1277 }
1278
1279 #[test]
1282 fn test_routes_for_prompting_commands() {
1283 let mut ctx = SessionContext::default();
1284 assert_eq!(
1285 run_of(plan_line("crawl USDC", &mut ctx).0).route,
1286 Route::Suspend
1287 );
1288 assert_eq!(
1289 run_of(plan_line("crawl USDC --yes", &mut ctx).0).route,
1290 Route::Pane
1291 );
1292 assert_eq!(
1293 run_of(plan_line(&format!("crawl {}", ADDR), &mut ctx).0).route,
1294 Route::Pane
1295 );
1296 assert_eq!(run_of(plan_line("setup", &mut ctx).0).route, Route::Suspend);
1297 assert_eq!(
1298 run_of(plan_line("setup --status", &mut ctx).0).route,
1299 Route::Pane
1300 );
1301 assert_eq!(
1302 run_of(plan_line("monitor USDC", &mut ctx).0).route,
1303 Route::Suspend
1304 );
1305 }
1306
1307 #[test]
1308 fn test_monitor_never_gets_the_auto_chain() {
1309 let inv = run_of(plan_line("monitor USDC", &mut SessionContext::default()).0);
1314 let Commands::Monitor(m) = inv.command else {
1315 panic!("expected monitor")
1316 };
1317 assert_eq!(m.chain, "ethereum");
1318
1319 let mut ctx = SessionContext {
1320 chain: "solana".into(),
1321 ..Default::default()
1322 };
1323 let inv = run_of(plan_line("monitor USDC", &mut ctx).0);
1324 let Commands::Monitor(m) = inv.command else {
1325 panic!("expected monitor")
1326 };
1327 assert_eq!(m.chain, "solana");
1328 }
1329
1330 #[test]
1331 fn test_hidden_panic_command_is_not_listed() {
1332 assert!(
1333 SESSION_COMMANDS
1334 .iter()
1335 .all(|(n, u, _)| *n != "__panic" && !u.contains("__panic"))
1336 );
1337 }
1338
1339 #[test]
1340 fn test_nested_tui_and_web_are_refused() {
1341 for line in ["interactive", "tui", "shell", "web"] {
1342 let (p, cap) = plan_line(line, &mut SessionContext::default());
1343 assert!(matches!(p, Plan::Done), "{}", line);
1344 assert!(cap.err().contains("Not available here"), "{}", line);
1345 }
1346 }
1347}