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    /// `monitor`: opens the monitor wizard in the TUI (#47).
32    Wizard,
33}
34
35/// The commands the TUI does not run, by name, and what to do instead.
36/// `route` and the TUI help both read this list (#66).
37pub(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
49/// The refusal reason for the command `name`, from [`TUI_REFUSED`].
50fn 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
58/// Decides where a command runs.
59///
60/// The match has no wildcard arm on purpose: a new CLI command does not
61/// compile until it has a TUI route (issue #47, requirement 5).
62pub 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        // A crawl by name or symbol asks which token in the pane (#109).
76        | Commands::Crawl(_)
77        | Commands::Report(_) => Route::Pane,
78        // Only --status is free of prompts.
79        Commands::Setup(args) => {
80            if args.status {
81                Route::Pane
82            } else {
83                Route::Suspend
84            }
85        }
86        // The monitor wizard (#47). The old dashboard draws its own screen
87        // until it goes (Q1).
88        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/// A CLI command that the TUI will run.
102#[derive(Debug)]
103pub struct Invocation {
104    /// The parsed command.
105    pub command: Commands,
106    /// Where it runs.
107    pub route: Route,
108    /// `--ai` was given: use markdown output for this command.
109    pub ai: bool,
110    /// The argument vector that clap parsed, after context injection.
111    /// Recorded in the event log.
112    pub argv: Vec<String>,
113}
114
115/// What the TUI must do with one line of input.
116#[derive(Debug)]
117pub enum Plan {
118    /// Nothing to run: the line was empty, a session command, or an error
119    /// that is already written to the output.
120    Done,
121    /// Leave the TUI.
122    Exit,
123    /// Run a CLI command. Boxed: `Commands` is large.
124    Run(Box<Invocation>),
125    /// A `watch` session command for the grid (#47).
126    Watch(WatchCommand),
127}
128
129/// What the `watch` session command asks the TUI to do.
130#[derive(Debug)]
131pub enum WatchCommand {
132    /// Start this watchlist in the grid.
133    Open(Box<scope::watch::Watchlist>),
134    /// Stop the running watchlist.
135    Stop,
136    /// Show the grid.
137    Show,
138}
139
140/// Plans one line of input.
141///
142/// Session commands run here and write to `out`. Parse errors and `--help`
143/// text are written to `out` too.
144///
145/// # Errors
146///
147/// Returns the output's error when a message cannot be written.
148pub 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        // Hidden debug command for the manual crash-log check. It is not in
166        // SESSION_COMMANDS, so help, completion and the palette skip it.
167        #[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            // A session command has no clap help: show its own line.
175            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            // `help market summary` is `market summary --help`: the same
181            // text as `scope help market summary`.
182            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
245/// The usage of the `watch` session command.
246const WATCH_USAGE: &str = "Usage: watch [list | open <name|file.yaml> | stop]";
247
248/// Runs the `watch` session command. `dir` holds the saved watchlists;
249/// `venues` are the venue IDs a watchlist may name.
250fn 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
297/// Parses a CLI command line with the clap definition, after it adds the
298/// session context.
299fn 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        // `address` / `tx` with no target reuse the last one, as in the REPL.
311        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
349/// Writes a clap error. `--help` and `--version` go to the data channel.
350///
351/// For a usage error, clap's `--help` hint is replaced by the TUI form,
352/// `run 'help <command>' for its options`.
353fn 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
369/// The start of clap's own hint line, which names the CLI `--help` flag.
370const CLAP_HELP_HINT: &str = "For more information";
371
372/// The TUI next step for a usage error: `help` for the command the user
373/// typed. The command is the longest run of subcommand words in `argv`.
374fn 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
394/// One session-context value that becomes a CLI flag.
395struct ContextFlag {
396    /// The long flag name.
397    long: &'static str,
398    /// The value, or `None` for a boolean flag.
399    value: Option<String>,
400    /// Commands the flag applies to. `None` means every command that has it.
401    only: Option<&'static [&'static str]>,
402}
403
404/// Adds the session context as flags, for the flags the user did not type.
405///
406/// A flag is added only when the target command defines it and accepts the
407/// value. The command is found by walking the clap subcommand tree, so
408/// aliases (`addr`, `token`) work.
409fn 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    // Only a format the user chose differs from the configured default.
419    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    // Other commands also have --limit, with other meanings and defaults.
443    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
506/// The last address or transaction to reuse when `address` / `tx` has no
507/// target.
508fn 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
519/// Records the target of `address` / `tx` for later reuse.
520fn 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
593/// Saves the token aliases. Injected so tests never write the user's file.
594type SaveAliases<'a> = dyn Fn(&TokenAliases) -> scope::error::Result<()> + 'a;
595
596/// The `tokens` session command: list, add and remove saved token aliases.
597fn 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
702/// The session commands: (word, usage, description). Help, completion and
703/// the palette read this list.
704pub(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
742/// Other words for the session commands: `(alias, SESSION_COMMANDS name)`.
743/// The one list: dispatch, help and the exit check all read it.
744const 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
762/// The `SESSION_COMMANDS` name for a typed word (any case), or the word
763/// itself when it is not an alias.
764/// Splits a line of input into words; `None` when a quote is not closed.
765///
766/// On unix the rules are the shell's (shlex). On Windows a backslash is
767/// a path separator, so `save C:\Users\me\out.json` keeps its path; see
768/// [`split_backslash_literal`].
769pub(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
777/// Splits `line` at whitespace outside quotes. Single and double quotes
778/// group words and are removed; a backslash is an ordinary character, so
779/// a quote cannot be escaped (quote it with the other kind). `None` when a
780/// quote is not closed.
781fn 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
812/// Writes the TUI help: the session commands, then every CLI command.
813fn write_help(out: &Output) -> io::Result<()> {
814    outln!(out, "Session commands:")?;
815    for (name, usage, what) in SESSION_COMMANDS {
816        // Name the other words for the command too (#66: `quit`, `q`).
817        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    // Key names in the key color, what they do dim. The ANSI becomes styles
850    // in the pane; the copy and the log strip it.
851    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// ============================================================================
898// Unit Tests
899// ============================================================================
900
901#[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    // ---- Parity (issue #47, requirement 5) ----
949
950    #[test]
951    fn test_every_cli_command_parses_and_has_a_tui_route() {
952        // Every CLI subcommand must reach a handler from the TUI, except
953        // the ones TUI_REFUSED lists. A new command fails here (and in
954        // `route`, at compile time) until it has a TUI path.
955        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            // `--help` parses for every command without its required args.
961            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                    // A refused route never becomes Plan::Run.
986                    assert!(
987                        matches!(p, Plan::Run(_)),
988                        "`{}` did not parse: {}",
989                        line,
990                        cap.err()
991                    );
992                }
993            }
994        }
995    }
996
997    /// The smallest valid command line for a leaf command.
998    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    // ---- Session commands ----
1028
1029    #[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        // `tokens` is not a CLI command; it must reach the alias manager.
1102        // An unknown subcommand neither reads nor writes anything to save.
1103        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        // Only address and tx reuse the last target; crawl must still fail.
1111        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        // `--ai` alone names no command: nothing may be appended to it.
1123        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    // ---- tokens ----
1193
1194    /// Runs `tokens …` against an in-memory store; returns output and how
1195    /// many times it saved.
1196    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        // A failed save after a remove must be loud, not "Removed".
1278        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    // ---- CLI parsing and context injection ----
1285
1286    #[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        // The CLI infers the chain from the address; the TUI must not
1315        // override that with a default.
1316        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        // `address` has no --trace; it must not be injected there.
1335        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        // market summary accepts text|json only: csv must not break it.
1357        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    // ---- watch ----
1427
1428    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    // ---- Routing ----
1494
1495    #[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        // The monitor wizard (#47); the old dashboard still runs outside.
1516        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        // The REPL passed SessionContext.chain ("auto") straight to the
1529        // monitor, which has no "auto" handling. The TUI parses `monitor`
1530        // with clap: an auto context adds no --chain, so clap's default
1531        // applies; a pinned chain is passed as given.
1532        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}