Skip to main content

scope_cli/cli/tui/
exec.rs

1//! # TUI Command Execution
2//!
3//! Turns one line of TUI input into a [`Plan`]. Session commands (`chain`,
4//! `format`, `ctx`, `tokens`, …) run here and change the [`SessionContext`].
5//! Every other line is parsed by the CLI's own clap definition ([`Cli`]), so
6//! commands, aliases and flags are the same as on the command line.
7//!
8//! The session context fills in flags the user did not type, for example
9//! `--chain` when a chain is pinned. A flag the user types always wins.
10
11use 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/// Where a parsed command runs.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Route {
24    /// In the TUI: the output goes to the output pane.
25    Pane,
26    /// On the plain terminal: the TUI steps aside while the command runs,
27    /// because the command reads from stdin or draws its own screen.
28    Suspend,
29    /// Not inside the TUI. The reason tells the user what to do instead.
30    Refuse(&'static str),
31}
32
33/// The commands the TUI does not run, by name, and what to do instead.
34/// `route` and the TUI help both read this list (#66).
35pub(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
47/// The refusal reason for the command `name`, from [`TUI_REFUSED`].
48fn 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
56/// Decides where a command runs.
57///
58/// The match has no wildcard arm on purpose: a new CLI command does not
59/// compile until it has a TUI route (issue #47, requirement 5).
60pub 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        // A name or symbol search asks the user to pick a token and offers
75        // to save an alias. An address or --yes never prompts.
76        Commands::Crawl(args) => {
77            if args.yes || TokenAliases::is_address(args.token.trim()) {
78                Route::Pane
79            } else {
80                Route::Suspend
81            }
82        }
83        // Only --status is free of prompts.
84        Commands::Setup(args) => {
85            if args.status {
86                Route::Pane
87            } else {
88                Route::Suspend
89            }
90        }
91        // The live dashboard draws its own screen (until phase 6 of #47).
92        Commands::Monitor(_) => Route::Suspend,
93        Commands::Interactive(_) => refused("interactive"),
94        Commands::Web(_) => refused("web"),
95        Commands::Completions(_) => refused("completions"),
96    }
97}
98
99/// A CLI command that the TUI will run.
100#[derive(Debug)]
101pub struct Invocation {
102    /// The parsed command.
103    pub command: Commands,
104    /// Where it runs.
105    pub route: Route,
106    /// `--ai` was given: use markdown output for this command.
107    pub ai: bool,
108    /// The argument vector that clap parsed, after context injection.
109    /// Recorded in the event log.
110    pub argv: Vec<String>,
111}
112
113/// What the TUI must do with one line of input.
114#[derive(Debug)]
115pub enum Plan {
116    /// Nothing to run: the line was empty, a session command, or an error
117    /// that is already written to the output.
118    Done,
119    /// Leave the TUI.
120    Exit,
121    /// Run a CLI command. Boxed: `Commands` is large.
122    Run(Box<Invocation>),
123}
124
125/// Plans one line of input.
126///
127/// Session commands run here and write to `out`. Parse errors and `--help`
128/// text are written to `out` too.
129///
130/// # Errors
131///
132/// Returns the output's error when a message cannot be written.
133pub 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        // Hidden debug command for the manual crash-log check. It is not in
151        // SESSION_COMMANDS, so help, completion and the palette skip it.
152        #[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            // A session command has no clap help: show its own line.
160            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            // `help market summary` is `market summary --help`: the same
166            // text as `scope help market summary`.
167            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
222/// Parses a CLI command line with the clap definition, after it adds the
223/// session context.
224fn 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        // `address` / `tx` with no target reuse the last one, as in the REPL.
236        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
274/// Writes a clap error. `--help` and `--version` go to the data channel.
275///
276/// For a usage error, clap's `--help` hint is replaced by the TUI form,
277/// `run 'help <command>' for its options`.
278fn 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
294/// The start of clap's own hint line, which names the CLI `--help` flag.
295const CLAP_HELP_HINT: &str = "For more information";
296
297/// The TUI next step for a usage error: `help` for the command the user
298/// typed. The command is the longest run of subcommand words in `argv`.
299fn 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
319/// One session-context value that becomes a CLI flag.
320struct ContextFlag {
321    /// The long flag name.
322    long: &'static str,
323    /// The value, or `None` for a boolean flag.
324    value: Option<String>,
325    /// Commands the flag applies to. `None` means every command that has it.
326    only: Option<&'static [&'static str]>,
327}
328
329/// Adds the session context as flags, for the flags the user did not type.
330///
331/// A flag is added only when the target command defines it and accepts the
332/// value. The command is found by walking the clap subcommand tree, so
333/// aliases (`addr`, `token`) work.
334fn 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    // Only a format the user chose differs from the configured default.
344    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    // Other commands also have --limit, with other meanings and defaults.
368    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
431/// The last address or transaction to reuse when `address` / `tx` has no
432/// target.
433fn 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
444/// Records the target of `address` / `tx` for later reuse.
445fn 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
518/// Saves the token aliases. Injected so tests never write the user's file.
519type SaveAliases<'a> = dyn Fn(&TokenAliases) -> scope::error::Result<()> + 'a;
520
521/// The `tokens` session command: list, add and remove saved token aliases.
522fn 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
627/// The session commands: (word, usage, description). Help, completion and
628/// the palette read this list.
629pub(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
662/// Other words for the session commands: `(alias, SESSION_COMMANDS name)`.
663/// The one list: dispatch, help and the exit check all read it.
664const 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
682/// The `SESSION_COMMANDS` name for a typed word (any case), or the word
683/// itself when it is not an alias.
684pub(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
692/// Writes the TUI help: the session commands, then every CLI command.
693fn write_help(out: &Output) -> io::Result<()> {
694    outln!(out, "Session commands:")?;
695    for (name, usage, what) in SESSION_COMMANDS {
696        // Name the other words for the command too (#66: `quit`, `q`).
697        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    // Key names in the key color, what they do dim. The ANSI becomes styles
730    // in the pane; the copy and the log strip it.
731    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// ============================================================================
778// Unit Tests
779// ============================================================================
780
781#[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    // ---- Parity (issue #47, requirement 5) ----
802
803    #[test]
804    fn test_every_cli_command_parses_and_has_a_tui_route() {
805        // Every CLI subcommand must reach a handler from the TUI, except
806        // the ones TUI_REFUSED lists. A new command fails here (and in
807        // `route`, at compile time) until it has a TUI path.
808        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            // `--help` parses for every command without its required args.
814            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                    // A refused route never becomes Plan::Run.
839                    assert!(
840                        matches!(p, Plan::Run(_)),
841                        "`{}` did not parse: {}",
842                        line,
843                        cap.err()
844                    );
845                }
846            }
847        }
848    }
849
850    /// The smallest valid command line for a leaf command.
851    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    // ---- Session commands ----
881
882    #[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        // `tokens` is not a CLI command; it must reach the alias manager.
955        // An unknown subcommand neither reads nor writes anything to save.
956        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        // Only address and tx reuse the last target; crawl must still fail.
964        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        // `--ai` alone names no command: nothing may be appended to it.
976        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    // ---- tokens ----
1046
1047    /// Runs `tokens …` against an in-memory store; returns output and how
1048    /// many times it saved.
1049    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        // A failed save after a remove must be loud, not "Removed".
1131        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    // ---- CLI parsing and context injection ----
1138
1139    #[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        // The CLI infers the chain from the address; the TUI must not
1168        // override that with a default.
1169        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        // `address` has no --trace; it must not be injected there.
1188        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        // market summary accepts text|json only: csv must not break it.
1210        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    // ---- Routing ----
1280
1281    #[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        // The REPL passed SessionContext.chain ("auto") straight to the
1310        // monitor, which has no "auto" handling. The TUI parses `monitor`
1311        // with clap: an auto context adds no --chain, so clap's default
1312        // applies; a pinned chain is passed as given.
1313        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}