Skip to main content

scope/cli/
interactive.rs

1//! # Interactive Mode
2//!
3//! This module implements an interactive REPL for the Scope CLI where
4//! context is preserved between commands. The chain defaults to `auto`,
5//! meaning the CLI will infer the relevant chain from each input (e.g.,
6//! `0x…` → Ethereum/EVM, `T…` → Tron, base58 → Solana). Users can pin
7//! a chain with `chain solana` and unlock with `chain auto`.
8//!
9//! ## Usage
10//!
11//! ```bash
12//! scope interactive
13//!
14//! scope:auto> address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
15//! # Chain: ethereum (auto-detected)
16//!
17//! scope:auto> address DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy
18//! # Chain: solana (auto-detected)
19//!
20//! scope:auto> chain solana
21//! # Chain pinned to: solana
22//!
23//! scope:solana> address 7xKXtg...
24//! # Uses solana chain
25//! ```
26
27use crate::chains::ChainClientFactory;
28use crate::config::{Config, OutputFormat};
29use crate::error::Result;
30use clap::Args;
31use rustyline::DefaultEditor;
32use rustyline::error::ReadlineError;
33use serde::{Deserialize, Serialize};
34use std::fmt;
35use std::path::PathBuf;
36
37use super::{AddressArgs, AddressBookArgs, CrawlArgs, TxArgs};
38use super::{address, address_book, crawl, monitor, tx};
39
40/// Arguments for the interactive command.
41#[derive(Debug, Clone, Args)]
42#[command(after_help = "\x1b[1mExamples:\x1b[0m
43  scope interactive
44  scope shell
45  scope interactive --no-banner")]
46pub struct InteractiveArgs {
47    /// Skip displaying the banner on startup.
48    #[arg(long)]
49    pub no_banner: bool,
50}
51
52/// Session context that persists between commands in interactive mode.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SessionContext {
55    /// Current blockchain network. `"auto"` means infer from input at command time.
56    pub chain: String,
57
58    /// Current output format.
59    pub format: OutputFormat,
60
61    /// Last analyzed address (for quick re-analysis).
62    pub last_address: Option<String>,
63
64    /// Last analyzed transaction hash.
65    pub last_tx: Option<String>,
66
67    /// Include token balances in address analysis.
68    pub include_tokens: bool,
69
70    /// Include transactions in address analysis.
71    pub include_txs: bool,
72
73    /// Include internal transactions in tx analysis.
74    pub trace: bool,
75
76    /// Decode transaction input data.
77    pub decode: bool,
78
79    /// Transaction limit for queries.
80    pub limit: u32,
81}
82
83impl SessionContext {
84    /// Returns `true` when chain is in auto-detect mode (not pinned to a specific chain).
85    pub fn is_auto_chain(&self) -> bool {
86        self.chain == "auto"
87    }
88}
89
90impl Default for SessionContext {
91    fn default() -> Self {
92        Self {
93            chain: "auto".to_string(),
94            format: OutputFormat::Table,
95            last_address: None,
96            last_tx: None,
97            include_tokens: false,
98            include_txs: false,
99            trace: false,
100            decode: false,
101            limit: 100,
102        }
103    }
104}
105
106impl fmt::Display for SessionContext {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        writeln!(f, "Current Context:")?;
109        if self.is_auto_chain() {
110            writeln!(f, "  Chain:          auto (inferred from input)")?;
111        } else {
112            writeln!(f, "  Chain:          {} (pinned)", self.chain)?;
113        }
114        writeln!(f, "  Format:         {:?}", self.format)?;
115        writeln!(f, "  Include Tokens: {}", self.include_tokens)?;
116        writeln!(f, "  Include TXs:    {}", self.include_txs)?;
117        writeln!(f, "  Trace:          {}", self.trace)?;
118        writeln!(f, "  Decode:         {}", self.decode)?;
119        writeln!(f, "  Limit:          {}", self.limit)?;
120        if let Some(ref addr) = self.last_address {
121            writeln!(f, "  Last Address:   {}", addr)?;
122        }
123        if let Some(ref tx) = self.last_tx {
124            writeln!(f, "  Last TX:        {}", tx)?;
125        }
126        Ok(())
127    }
128}
129
130impl SessionContext {
131    /// Returns the path to the session context file.
132    fn context_path() -> Option<PathBuf> {
133        dirs::data_dir().map(|p| p.join("scope").join("session.yaml"))
134    }
135
136    /// Loads session context from file, or returns default if not found.
137    pub fn load() -> Self {
138        Self::context_path()
139            .and_then(|path| std::fs::read_to_string(&path).ok())
140            .and_then(|contents| serde_yaml::from_str(&contents).ok())
141            .unwrap_or_default()
142    }
143
144    /// Saves session context to file.
145    pub fn save(&self) -> Result<()> {
146        if let Some(path) = Self::context_path() {
147            if let Some(parent) = path.parent() {
148                std::fs::create_dir_all(parent)?;
149            }
150            let contents = serde_yaml::to_string(self)
151                .map_err(|e| crate::error::ScopeError::Export(e.to_string()))?;
152            std::fs::write(&path, contents)?;
153        }
154        Ok(())
155    }
156}
157
158/// Runs the interactive REPL.
159pub async fn run(
160    args: InteractiveArgs,
161    config: &Config,
162    clients: &dyn ChainClientFactory,
163) -> Result<()> {
164    // Show banner unless disabled
165    if !args.no_banner {
166        let banner = include_str!("../../assets/banner.txt");
167        eprintln!("{}", banner);
168    }
169
170    println!("Welcome to Scope Interactive Mode!");
171    println!("Type 'help' for available commands, 'exit' to quit.\n");
172
173    // Load previous session context or start fresh
174    let mut context = SessionContext::load();
175
176    // Apply config defaults if context is fresh
177    if context.is_auto_chain() && context.format == OutputFormat::Table {
178        context.format = config.output.format;
179    }
180
181    // Create readline editor
182    let mut rl = DefaultEditor::new().map_err(|e| {
183        crate::error::ScopeError::Chain(format!("Failed to initialize readline: {}", e))
184    })?;
185
186    // Try to load history
187    let history_path = dirs::data_dir().map(|p| p.join("scope").join("history.txt"));
188    if let Some(ref path) = history_path {
189        let _ = rl.load_history(path);
190    }
191
192    loop {
193        let prompt = format!("scope:{}> ", context.chain);
194
195        match rl.readline(&prompt) {
196            Ok(input_line) => {
197                let line = input_line.trim();
198                if line.is_empty() {
199                    continue;
200                }
201
202                // Add to history
203                let _ = rl.add_history_entry(line);
204
205                // Parse and execute command
206                match execute_input(line, &mut context, config, clients).await {
207                    Ok(should_exit) => {
208                        if should_exit {
209                            break;
210                        }
211                    }
212                    Err(e) => {
213                        eprintln!("Error: {}", e);
214                    }
215                }
216            }
217            Err(ReadlineError::Interrupted) => {
218                println!("^C");
219                continue;
220            }
221            Err(ReadlineError::Eof) => {
222                println!("exit");
223                break;
224            }
225            Err(err) => {
226                eprintln!("Error: {:?}", err);
227                break;
228            }
229        }
230    }
231
232    // Save history
233    if let Some(ref path) = history_path {
234        if let Some(parent) = path.parent() {
235            let _ = std::fs::create_dir_all(parent);
236        }
237        let _ = rl.save_history(path);
238    }
239
240    // Save session context for next time
241    if let Err(e) = context.save() {
242        tracing::debug!("Failed to save session context: {}", e);
243    }
244
245    println!("Goodbye!");
246    Ok(())
247}
248
249/// Executes a single input line. Returns Ok(true) if should exit.
250async fn execute_input(
251    input: &str,
252    context: &mut SessionContext,
253    config: &Config,
254    clients: &dyn ChainClientFactory,
255) -> Result<bool> {
256    let parts: Vec<&str> = input.split_whitespace().collect();
257    if parts.is_empty() {
258        return Ok(false);
259    }
260
261    let command = parts[0].to_lowercase();
262    let args = &parts[1..];
263
264    match command.as_str() {
265        // Exit commands
266        "exit" | "quit" | ".exit" | ".quit" | "q" => {
267            return Ok(true);
268        }
269
270        // Help
271        "help" | "?" | ".help" => {
272            print_help();
273        }
274
275        // Show context
276        "ctx" | "context" | ".ctx" | ".context" => {
277            println!("{}", context);
278        }
279
280        // Clear/reset context
281        "clear" | ".clear" | "reset" | ".reset" => {
282            *context = SessionContext::default();
283            context.format = config.output.format;
284            println!("Context reset to defaults.");
285        }
286
287        // Set chain
288        "chain" | ".chain" => {
289            if args.is_empty() {
290                if context.is_auto_chain() {
291                    println!("Current chain: auto (inferred from input)");
292                } else {
293                    println!("Current chain: {} (pinned)", context.chain);
294                }
295            } else {
296                let new_chain = args[0].to_lowercase();
297                let valid_chains = [
298                    "ethereum", "polygon", "arbitrum", "optimism", "base", "bsc", "solana", "tron",
299                ];
300                if new_chain == "auto" {
301                    context.chain = "auto".to_string();
302                    println!("Chain set to auto — will infer from each input");
303                } else if valid_chains.contains(&new_chain.as_str()) {
304                    context.chain = new_chain.clone();
305                    println!(
306                        "Chain pinned to: {}  (use `chain auto` to unlock)",
307                        new_chain
308                    );
309                } else {
310                    eprintln!(
311                        "  ✗ Unknown chain: {}. Valid: auto, {}",
312                        new_chain,
313                        valid_chains.join(", ")
314                    );
315                }
316            }
317        }
318
319        // Set format
320        "format" | ".format" => {
321            if args.is_empty() {
322                println!("Current format: {:?}", context.format);
323            } else {
324                match args[0].to_lowercase().as_str() {
325                    "table" => {
326                        context.format = OutputFormat::Table;
327                        println!("Format set to: table");
328                    }
329                    "json" => {
330                        context.format = OutputFormat::Json;
331                        println!("Format set to: json");
332                    }
333                    "csv" => {
334                        context.format = OutputFormat::Csv;
335                        println!("Format set to: csv");
336                    }
337                    other => {
338                        eprintln!("Unknown format: {}. Valid formats: table, json, csv", other);
339                    }
340                }
341            }
342        }
343
344        // Toggle flags
345        "+tokens" | "showtokens" => {
346            context.include_tokens = !context.include_tokens;
347            println!(
348                "Include tokens: {}",
349                if context.include_tokens { "on" } else { "off" }
350            );
351        }
352
353        "+txs" | "showtxs" | "txs" | ".txs" => {
354            context.include_txs = !context.include_txs;
355            println!(
356                "Include transactions: {}",
357                if context.include_txs { "on" } else { "off" }
358            );
359        }
360
361        "trace" | ".trace" => {
362            context.trace = !context.trace;
363            println!("Trace: {}", if context.trace { "on" } else { "off" });
364        }
365
366        "decode" | ".decode" => {
367            context.decode = !context.decode;
368            println!("Decode: {}", if context.decode { "on" } else { "off" });
369        }
370
371        // Set limit
372        "limit" | ".limit" => {
373            if args.is_empty() {
374                println!("Current limit: {}", context.limit);
375            } else if let Ok(n) = args[0].parse::<u32>() {
376                context.limit = n;
377                println!("Limit set to: {}", n);
378            } else {
379                eprintln!("Invalid limit: {}. Must be a positive integer.", args[0]);
380            }
381        }
382
383        // Address command
384        "address" | "addr" => {
385            let addr = if args.is_empty() {
386                // Use last address if available
387                match &context.last_address {
388                    Some(a) => a.clone(),
389                    None => {
390                        eprintln!("No address specified and no previous address in context.");
391                        return Ok(false);
392                    }
393                }
394            } else {
395                args[0].to_string()
396            };
397
398            // Determine chain: check for inline override first
399            let mut chain_override = None;
400            for arg in args.iter().skip(1) {
401                if arg.starts_with("--chain=") {
402                    chain_override = Some(arg.trim_start_matches("--chain=").to_string());
403                }
404            }
405
406            // Resolve chain: inline override > pinned context > auto-detect from address
407            let effective_chain = if let Some(chain) = chain_override {
408                chain
409            } else if context.is_auto_chain() {
410                if let Some(inferred) = crate::chains::infer_chain_from_address(&addr) {
411                    eprintln!("  Chain: {} (auto-detected)", inferred);
412                    inferred.to_string()
413                } else {
414                    // Default fallback when auto can't infer
415                    "ethereum".to_string()
416                }
417            } else {
418                context.chain.clone()
419            };
420
421            // Parse additional flags from args
422            let mut address_args = AddressArgs {
423                address: addr.clone(),
424                chain: effective_chain,
425                format: Some(context.format),
426                include_txs: context.include_txs,
427                include_tokens: context.include_tokens,
428                limit: context.limit,
429                report: None,
430                dossier: false,
431            };
432
433            // Check for other inline overrides
434            for arg in args.iter().skip(1) {
435                if *arg == "--tokens" {
436                    address_args.include_tokens = true;
437                } else if *arg == "--txs" {
438                    address_args.include_txs = true;
439                }
440            }
441
442            // Update context
443            context.last_address = Some(addr);
444
445            // Execute
446            address::run(address_args, config, clients).await?;
447        }
448
449        // Transaction command
450        "tx" | "transaction" => {
451            let hash = if args.is_empty() {
452                // Use last tx if available
453                match &context.last_tx {
454                    Some(h) => h.clone(),
455                    None => {
456                        eprintln!("No transaction hash specified and no previous hash in context.");
457                        return Ok(false);
458                    }
459                }
460            } else {
461                args[0].to_string()
462            };
463
464            // Determine chain: check for inline override first
465            let mut chain_override = None;
466            for arg in args.iter().skip(1) {
467                if arg.starts_with("--chain=") {
468                    chain_override = Some(arg.trim_start_matches("--chain=").to_string());
469                }
470            }
471
472            // Resolve chain: inline override > pinned context > auto-detect from hash
473            let effective_chain = if let Some(chain) = chain_override {
474                chain
475            } else if context.is_auto_chain() {
476                if let Some(inferred) = crate::chains::infer_chain_from_hash(&hash) {
477                    eprintln!("  Chain: {} (auto-detected)", inferred);
478                    inferred.to_string()
479                } else {
480                    "ethereum".to_string()
481                }
482            } else {
483                context.chain.clone()
484            };
485
486            let mut tx_args = TxArgs {
487                hash: hash.clone(),
488                chain: effective_chain,
489                format: Some(context.format),
490                trace: context.trace,
491                decode: context.decode,
492            };
493
494            // Check for other inline overrides
495            for arg in args.iter().skip(1) {
496                if *arg == "--trace" {
497                    tx_args.trace = true;
498                } else if *arg == "--decode" {
499                    tx_args.decode = true;
500                }
501            }
502
503            // Update context
504            context.last_tx = Some(hash);
505
506            // Execute
507            tx::run(tx_args, config, clients).await?;
508        }
509
510        // Contract analysis command
511        "contract" | "ct" => {
512            if args.is_empty() {
513                eprintln!("Usage: contract <address> [--chain=<chain>] [--json]");
514                return Ok(false);
515            }
516
517            let address = args[0].to_string();
518            let mut chain = context.chain.clone();
519            let mut json_output = false;
520
521            for arg in args.iter().skip(1) {
522                if arg.starts_with("--chain=") {
523                    chain = arg.trim_start_matches("--chain=").to_string();
524                } else if *arg == "--json" {
525                    json_output = true;
526                }
527            }
528
529            // Default to ethereum if auto
530            if chain == "auto" {
531                chain = "ethereum".to_string();
532            }
533
534            let ct_args = crate::cli::contract::ContractArgs {
535                address,
536                chain,
537                json: json_output,
538            };
539
540            crate::cli::contract::run(&ct_args, config, clients).await?;
541        }
542
543        // Crawl command for token analytics
544        "crawl" | "token" => {
545            if args.is_empty() {
546                eprintln!(
547                    "Usage: crawl <token_address> [--period <1h|24h|7d|30d>] [--report <path>]"
548                );
549                return Ok(false);
550            }
551
552            let token = args[0].to_string();
553
554            // Determine chain: check for inline override first
555            let mut chain_override = None;
556            let mut period = crawl::Period::Hour24;
557            let mut report_path = None;
558            let mut no_charts = false;
559
560            let mut i = 1;
561            while i < args.len() {
562                if args[i].starts_with("--chain=") {
563                    chain_override = Some(args[i].trim_start_matches("--chain=").to_string());
564                } else if args[i] == "--chain" && i + 1 < args.len() {
565                    chain_override = Some(args[i + 1].to_string());
566                    i += 1;
567                } else if args[i].starts_with("--period=") {
568                    let p = args[i].trim_start_matches("--period=");
569                    period = match p {
570                        "1h" => crawl::Period::Hour1,
571                        "24h" => crawl::Period::Hour24,
572                        "7d" => crawl::Period::Day7,
573                        "30d" => crawl::Period::Day30,
574                        _ => crawl::Period::Hour24,
575                    };
576                } else if args[i] == "--period" && i + 1 < args.len() {
577                    period = match args[i + 1] {
578                        "1h" => crawl::Period::Hour1,
579                        "24h" => crawl::Period::Hour24,
580                        "7d" => crawl::Period::Day7,
581                        "30d" => crawl::Period::Day30,
582                        _ => crawl::Period::Hour24,
583                    };
584                    i += 1;
585                } else if args[i].starts_with("--report=") {
586                    report_path = Some(std::path::PathBuf::from(
587                        args[i].trim_start_matches("--report="),
588                    ));
589                } else if args[i] == "--report" && i + 1 < args.len() {
590                    report_path = Some(std::path::PathBuf::from(args[i + 1]));
591                    i += 1;
592                } else if args[i] == "--no-charts" {
593                    no_charts = true;
594                }
595                i += 1;
596            }
597
598            // Resolve chain: inline override > pinned context > auto-detect from token address
599            let effective_chain = if let Some(chain) = chain_override {
600                chain
601            } else if context.is_auto_chain() {
602                if let Some(inferred) = crate::chains::infer_chain_from_address(&token) {
603                    eprintln!("  Chain: {} (auto-detected)", inferred);
604                    inferred.to_string()
605                } else {
606                    "ethereum".to_string()
607                }
608            } else {
609                context.chain.clone()
610            };
611
612            let crawl_args = CrawlArgs {
613                token,
614                chain: effective_chain,
615                period,
616                holders_limit: 10,
617                format: context.format,
618                no_charts,
619                report: report_path,
620                yes: false,  // Interactive mode uses prompts
621                save: false, // Will prompt if alias should be saved
622            };
623
624            crawl::run(crawl_args, config, clients).await?;
625        }
626
627        // Address book command (pass through to existing; portfolio/port as aliases)
628        "address-book" | "address_book" | "portfolio" | "port" => {
629            let input = args.join(" ");
630            execute_address_book(&input, context, config, clients).await?;
631        }
632
633        // Token alias management
634        "tokens" | "aliases" => {
635            execute_tokens_command(args).await?;
636        }
637
638        // Setup/config command
639        "setup" | "config" => {
640            use super::setup::{SetupArgs, run as setup_run};
641            let setup_args = SetupArgs {
642                status: args.contains(&"--status") || args.contains(&"-s"),
643                key: args
644                    .iter()
645                    .find(|a| a.starts_with("--key="))
646                    .map(|a| a.trim_start_matches("--key=").to_string())
647                    .or_else(|| {
648                        args.iter()
649                            .position(|&a| a == "--key" || a == "-k")
650                            .and_then(|i| args.get(i + 1).map(|s| s.to_string()))
651                    }),
652                reset: args.contains(&"--reset"),
653            };
654            setup_run(setup_args, config).await?;
655        }
656
657        // Live monitor command
658        "monitor" | "mon" => {
659            let token = args.first().map(|s| s.to_string());
660            monitor::run(token, None, context, config, clients).await?;
661        }
662
663        // Unknown command
664        _ => {
665            eprintln!(
666                "Unknown command: {}. Type 'help' for available commands.",
667                command
668            );
669        }
670    }
671
672    Ok(false)
673}
674
675/// Execute tokens subcommand for managing saved token aliases.
676async fn execute_tokens_command(args: &[&str]) -> Result<()> {
677    use crate::display::terminal as t;
678    use crate::tokens::TokenAliases;
679
680    let mut aliases = TokenAliases::load();
681
682    if args.is_empty() {
683        // List all saved tokens
684        let tokens = aliases.list();
685        if tokens.is_empty() {
686            println!("{}", t::info_row("No saved token aliases."));
687            println!(
688                "{}",
689                t::info_row("Use 'crawl <token_name> --save' to save a token alias.")
690            );
691            return Ok(());
692        }
693
694        println!("{}", t::section_header("Saved Token Aliases"));
695        let cols = &[
696            t::Col {
697                label: "Symbol",
698                width: 10,
699                align: '<',
700            },
701            t::Col {
702                label: "Chain",
703                width: 12,
704                align: '<',
705            },
706            t::Col {
707                label: "Name",
708                width: 20,
709                align: '<',
710            },
711            t::Col {
712                label: "Address",
713                width: 42,
714                align: '<',
715            },
716        ];
717        println!("{}", t::table_header(cols));
718        for token in tokens {
719            println!(
720                "{}",
721                t::table_row(
722                    cols,
723                    &[&token.symbol, &token.chain, &token.name, &token.address]
724                )
725            );
726        }
727        println!("{}", t::section_footer());
728        return Ok(());
729    }
730
731    let subcommand = args[0].to_lowercase();
732    match subcommand.as_str() {
733        "list" | "ls" => {
734            let tokens = aliases.list();
735            if tokens.is_empty() {
736                println!("{}", t::info_row("No saved token aliases."));
737                return Ok(());
738            }
739
740            println!("{}", t::section_header("Saved Token Aliases"));
741            let cols = &[
742                t::Col {
743                    label: "Symbol",
744                    width: 10,
745                    align: '<',
746                },
747                t::Col {
748                    label: "Chain",
749                    width: 12,
750                    align: '<',
751                },
752                t::Col {
753                    label: "Name",
754                    width: 20,
755                    align: '<',
756                },
757                t::Col {
758                    label: "Address",
759                    width: 42,
760                    align: '<',
761                },
762            ];
763            println!("{}", t::table_header(cols));
764            for token in tokens {
765                println!(
766                    "{}",
767                    t::table_row(
768                        cols,
769                        &[&token.symbol, &token.chain, &token.name, &token.address]
770                    )
771                );
772            }
773            println!("{}", t::section_footer());
774        }
775
776        "recent" => {
777            let recent = aliases.recent();
778            if recent.is_empty() {
779                println!("{}", t::info_row("No recently used tokens."));
780                return Ok(());
781            }
782
783            println!("{}", t::section_header("Recently Used Tokens"));
784            let cols = &[
785                t::Col {
786                    label: "Symbol",
787                    width: 10,
788                    align: '<',
789                },
790                t::Col {
791                    label: "Chain",
792                    width: 12,
793                    align: '<',
794                },
795                t::Col {
796                    label: "Name",
797                    width: 20,
798                    align: '<',
799                },
800                t::Col {
801                    label: "Address",
802                    width: 42,
803                    align: '<',
804                },
805            ];
806            println!("{}", t::table_header(cols));
807            for token in recent {
808                println!(
809                    "{}",
810                    t::table_row(
811                        cols,
812                        &[&token.symbol, &token.chain, &token.name, &token.address]
813                    )
814                );
815            }
816            println!("{}", t::section_footer());
817        }
818
819        "remove" | "rm" | "delete" => {
820            if args.len() < 2 {
821                eprintln!("Usage: tokens remove <symbol> [--chain <chain>]");
822                return Ok(());
823            }
824
825            let symbol = args[1];
826            let chain = if args.len() > 3 && args[2] == "--chain" {
827                Some(args[3])
828            } else {
829                None
830            };
831
832            aliases.remove(symbol, chain);
833            if let Err(e) = aliases.save() {
834                eprintln!("Failed to save: {}", e);
835            } else {
836                println!("Removed alias: {}", symbol);
837            }
838        }
839
840        "add" => {
841            if args.len() < 4 {
842                eprintln!("Usage: tokens add <symbol> <chain> <address> [name]");
843                return Ok(());
844            }
845
846            let symbol = args[1];
847            let chain = args[2];
848            let address = args[3];
849            let name = if args.len() > 4 {
850                args[4..].join(" ")
851            } else {
852                symbol.to_string()
853            };
854
855            aliases.add(symbol, chain, address, &name);
856            if let Err(e) = aliases.save() {
857                eprintln!("Failed to save: {}", e);
858            } else {
859                println!("Added alias: {} -> {} on {}", symbol, address, chain);
860            }
861        }
862
863        _ => {
864            eprintln!("Unknown tokens subcommand: {}", subcommand);
865            eprintln!("Available: list, recent, add, remove");
866        }
867    }
868
869    Ok(())
870}
871
872/// Execute address book subcommand
873async fn execute_address_book(
874    input: &str,
875    context: &SessionContext,
876    config: &Config,
877    clients: &dyn ChainClientFactory,
878) -> Result<()> {
879    let parts: Vec<&str> = input.split_whitespace().collect();
880    if parts.is_empty() {
881        eprintln!("Address book subcommand required: add, remove, list, summary");
882        return Ok(());
883    }
884
885    use super::address_book::{AddArgs, AddressBookCommands, RemoveArgs, SummaryArgs};
886
887    let subcommand = parts[0].to_lowercase();
888
889    let address_book_args = match subcommand.as_str() {
890        "add" => {
891            if parts.len() < 2 {
892                eprintln!("Usage: address-book add <address> [--label <label>] [--tags <tags>]");
893                return Ok(());
894            }
895            let address = parts[1].to_string();
896            let mut label = None;
897            let mut tags = Vec::new();
898
899            let mut i = 2;
900            while i < parts.len() {
901                if parts[i] == "--label" && i + 1 < parts.len() {
902                    label = Some(parts[i + 1].to_string());
903                    i += 2;
904                } else if parts[i] == "--tags" && i + 1 < parts.len() {
905                    tags = parts[i + 1]
906                        .split(',')
907                        .map(|s| s.trim().to_string())
908                        .collect();
909                    i += 2;
910                } else {
911                    i += 1;
912                }
913            }
914
915            AddressBookArgs {
916                command: AddressBookCommands::Add(AddArgs {
917                    chain: if context.is_auto_chain() {
918                        crate::chains::infer_chain_from_address(&address)
919                            .unwrap_or("ethereum")
920                            .to_string()
921                    } else {
922                        context.chain.clone()
923                    },
924                    address,
925                    label,
926                    tags,
927                }),
928                format: Some(context.format),
929            }
930        }
931        "remove" | "rm" => {
932            if parts.len() < 2 {
933                eprintln!("Usage: address-book remove <address>");
934                return Ok(());
935            }
936            AddressBookArgs {
937                command: AddressBookCommands::Remove(RemoveArgs {
938                    address: parts[1].to_string(),
939                }),
940                format: Some(context.format),
941            }
942        }
943        "list" | "ls" => AddressBookArgs {
944            command: AddressBookCommands::List,
945            format: Some(context.format),
946        },
947        "summary" => {
948            let mut chain = None;
949            let mut tag = None;
950            let mut include_tokens = context.include_tokens;
951
952            let mut i = 1;
953            while i < parts.len() {
954                if parts[i] == "--chain" && i + 1 < parts.len() {
955                    chain = Some(parts[i + 1].to_string());
956                    i += 2;
957                } else if parts[i] == "--tag" && i + 1 < parts.len() {
958                    tag = Some(parts[i + 1].to_string());
959                    i += 2;
960                } else if parts[i] == "--tokens" {
961                    include_tokens = true;
962                    i += 1;
963                } else {
964                    i += 1;
965                }
966            }
967
968            AddressBookArgs {
969                command: AddressBookCommands::Summary(SummaryArgs {
970                    chain,
971                    tag,
972                    include_tokens,
973                    report: None,
974                }),
975                format: Some(context.format),
976            }
977        }
978        _ => {
979            eprintln!(
980                "Unknown address book subcommand: {}. Use: add, remove, list, summary",
981                subcommand
982            );
983            return Ok(());
984        }
985    };
986
987    address_book::run(address_book_args, config, clients).await
988}
989
990/// Print help message for interactive mode.
991fn print_help() {
992    println!(
993        r#"
994Scope Interactive Mode - Available Commands
995==========================================
996
997Navigation & Control:
998  help, ?           Show this help message
999  exit, quit, q     Exit interactive mode
1000  ctx, context      Show current session context
1001  clear, reset      Reset context to defaults
1002
1003Context Settings:
1004  chain [name]      Set or show current chain (default: auto)
1005                    auto = infer chain from each input
1006                    Valid: auto, ethereum, polygon, arbitrum, optimism, base, bsc, solana, tron
1007  format [fmt]      Set or show output format (table, json, csv)
1008  limit [n]         Set or show transaction limit
1009  +tokens           Toggle include_tokens flag for address analysis
1010  +txs              Toggle include_txs flag
1011  trace             Toggle trace flag
1012  decode            Toggle decode flag
1013
1014Analysis Commands:
1015  address <addr>    Analyze an address (uses current chain/format)
1016  addr              Shorthand for address
1017  tx <hash>         Analyze a transaction (uses current chain/format)
1018  contract <addr>   Analyze a smart contract (security, proxy, access control)
1019  ct                Shorthand for contract
1020  crawl <token>     Crawl token analytics (holders, volume, price)
1021  token             Shorthand for crawl
1022  monitor <token>   Live-updating charts for a token (TUI mode)
1023  mon               Shorthand for monitor
1024
1025Token Search:
1026  crawl USDC        Search for token by name/symbol (interactive selection)
1027  crawl 0x...       Use address directly (no search)
1028  tokens            List saved token aliases
1029  tokens recent     Show recently used tokens
1030  tokens add <sym> <chain> <addr> [name]    Add a token alias
1031  tokens remove <sym> [--chain <chain>]     Remove a token alias
1032
1033Address Book Commands:
1034  address-book add <addr> [--label <name>] [--tags <t1,t2>]
1035  address-book remove <addr>
1036  address-book list
1037  address-book summary [--chain <name>] [--tag <tag>] [--tokens]
1038
1039Configuration:
1040  setup             Run the setup wizard to configure API keys
1041  setup --status    Show current configuration status
1042  setup --key <provider>    Configure a specific API key
1043  config            Alias for setup
1044
1045Inline Overrides:
1046  address 0x... --chain=polygon --tokens
1047  tx 0x... --chain=arbitrum --trace --decode
1048  contract 0x... --chain=polygon --json
1049  crawl USDC --chain=ethereum --period=7d --report=report.md
1050
1051Live Monitor:
1052  monitor USDC      Start live monitoring with real-time charts
1053  mon 0x...         Monitor by address
1054  Time periods: [1]=15m [2]=1h [3]=6h [4]=24h [T]=cycle
1055  Chart modes: [C]=toggle between Line and Candlestick
1056  Controls: [Q]uit [R]efresh [P]ause [+/-]speed [Esc]exit
1057  Data is cached to temp folder and persists between sessions (24h retention)
1058
1059Tips:
1060  - Search by token name: 'crawl WETH' or 'crawl "wrapped ether"'
1061  - Save aliases for quick access: select a token and choose to save
1062  - Context persists: set chain once, use it for multiple commands
1063  - Use Ctrl+C to cancel, Ctrl+D to exit
1064"#
1065    );
1066}
1067
1068// ============================================================================
1069// Unit Tests
1070// ============================================================================
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075
1076    #[test]
1077    fn test_session_context_default() {
1078        let ctx = SessionContext::default();
1079        assert_eq!(ctx.chain, "auto");
1080        assert_eq!(ctx.format, OutputFormat::Table);
1081        assert!(!ctx.include_tokens);
1082        assert!(!ctx.include_txs);
1083        assert!(!ctx.trace);
1084        assert!(!ctx.decode);
1085        assert_eq!(ctx.limit, 100);
1086        assert!(ctx.last_address.is_none());
1087        assert!(ctx.last_tx.is_none());
1088    }
1089
1090    #[test]
1091    fn test_session_context_display() {
1092        let ctx = SessionContext::default();
1093        let display = format!("{}", ctx);
1094        assert!(display.contains("auto"));
1095        assert!(display.contains("Table"));
1096    }
1097
1098    #[test]
1099    fn test_interactive_args_default() {
1100        let args = InteractiveArgs { no_banner: false };
1101        assert!(!args.no_banner);
1102    }
1103
1104    // ========================================================================
1105    // SessionContext serialization/deserialization
1106    // ========================================================================
1107
1108    #[test]
1109    fn test_session_context_serialization() {
1110        let ctx = SessionContext {
1111            chain: "polygon".to_string(),
1112            format: OutputFormat::Json,
1113            last_address: Some("0xabc".to_string()),
1114            last_tx: Some("0xdef".to_string()),
1115            include_tokens: true,
1116            include_txs: true,
1117            trace: true,
1118            decode: true,
1119            limit: 50,
1120        };
1121
1122        let yaml = serde_yaml::to_string(&ctx).unwrap();
1123        let deserialized: SessionContext = serde_yaml::from_str(&yaml).unwrap();
1124        assert_eq!(deserialized.chain, "polygon");
1125        assert!(!deserialized.is_auto_chain());
1126        assert_eq!(deserialized.format, OutputFormat::Json);
1127        assert_eq!(deserialized.last_address.as_deref(), Some("0xabc"));
1128        assert_eq!(deserialized.last_tx.as_deref(), Some("0xdef"));
1129        assert!(deserialized.include_tokens);
1130        assert!(deserialized.include_txs);
1131        assert!(deserialized.trace);
1132        assert!(deserialized.decode);
1133        assert_eq!(deserialized.limit, 50);
1134    }
1135
1136    #[test]
1137    fn test_session_context_display_with_address_and_tx() {
1138        let ctx = SessionContext {
1139            chain: "polygon".to_string(),
1140            last_address: Some("0x1234".to_string()),
1141            last_tx: Some("0xabcd".to_string()),
1142            ..Default::default()
1143        };
1144        let display = format!("{}", ctx);
1145        assert!(display.contains("0x1234"));
1146        assert!(display.contains("0xabcd"));
1147        assert!(display.contains("(pinned)"));
1148    }
1149
1150    #[test]
1151    fn test_session_context_display_auto_chain() {
1152        let ctx = SessionContext::default();
1153        let display = format!("{}", ctx);
1154        assert!(display.contains("auto"));
1155        assert!(display.contains("inferred from input"));
1156    }
1157
1158    // ========================================================================
1159    // execute_input tests for context-modifying commands
1160    // ========================================================================
1161
1162    fn test_config() -> Config {
1163        Config::default()
1164    }
1165
1166    fn test_factory() -> crate::chains::DefaultClientFactory {
1167        crate::chains::DefaultClientFactory {
1168            chains_config: crate::config::ChainsConfig::default(),
1169        }
1170    }
1171
1172    #[tokio::test]
1173    async fn test_exit_commands() {
1174        let config = test_config();
1175        for cmd in &["exit", "quit", "q", ".exit", ".quit"] {
1176            let mut ctx = SessionContext::default();
1177            let result = execute_input(cmd, &mut ctx, &config, &test_factory())
1178                .await
1179                .unwrap();
1180            assert!(result, "'{cmd}' should return true (exit)");
1181        }
1182    }
1183
1184    #[tokio::test]
1185    async fn test_help_command() {
1186        let config = test_config();
1187        let mut ctx = SessionContext::default();
1188        let result = execute_input("help", &mut ctx, &config, &test_factory())
1189            .await
1190            .unwrap();
1191        assert!(!result);
1192    }
1193
1194    #[tokio::test]
1195    async fn test_context_command() {
1196        let config = test_config();
1197        let mut ctx = SessionContext::default();
1198        let result = execute_input("ctx", &mut ctx, &config, &test_factory())
1199            .await
1200            .unwrap();
1201        assert!(!result);
1202    }
1203
1204    #[tokio::test]
1205    async fn test_clear_command() {
1206        let config = test_config();
1207        let mut ctx = SessionContext {
1208            chain: "polygon".to_string(),
1209            include_tokens: true,
1210            limit: 42,
1211            ..Default::default()
1212        };
1213
1214        let result = execute_input("clear", &mut ctx, &config, &test_factory())
1215            .await
1216            .unwrap();
1217        assert!(!result);
1218        assert_eq!(ctx.chain, "auto");
1219        assert!(!ctx.include_tokens);
1220        assert_eq!(ctx.limit, 100);
1221    }
1222
1223    #[tokio::test]
1224    async fn test_chain_set_valid() {
1225        let config = test_config();
1226        let mut ctx = SessionContext::default();
1227
1228        execute_input("chain polygon", &mut ctx, &config, &test_factory())
1229            .await
1230            .unwrap();
1231        assert_eq!(ctx.chain, "polygon");
1232        assert!(!ctx.is_auto_chain());
1233    }
1234
1235    #[tokio::test]
1236    async fn test_chain_set_solana() {
1237        let config = test_config();
1238        let mut ctx = SessionContext::default();
1239
1240        execute_input("chain solana", &mut ctx, &config, &test_factory())
1241            .await
1242            .unwrap();
1243        assert_eq!(ctx.chain, "solana");
1244        assert!(!ctx.is_auto_chain());
1245    }
1246
1247    #[tokio::test]
1248    async fn test_chain_auto() {
1249        let config = test_config();
1250        let mut ctx = SessionContext {
1251            chain: "polygon".to_string(),
1252            ..Default::default()
1253        };
1254
1255        execute_input("chain auto", &mut ctx, &config, &test_factory())
1256            .await
1257            .unwrap();
1258        assert_eq!(ctx.chain, "auto");
1259        assert!(ctx.is_auto_chain());
1260    }
1261
1262    #[tokio::test]
1263    async fn test_chain_invalid() {
1264        let config = test_config();
1265        let mut ctx = SessionContext::default();
1266        // Invalid chain should not change context
1267        execute_input("chain foobar", &mut ctx, &config, &test_factory())
1268            .await
1269            .unwrap();
1270        assert_eq!(ctx.chain, "auto");
1271        assert!(ctx.is_auto_chain());
1272    }
1273
1274    #[tokio::test]
1275    async fn test_chain_show() {
1276        let config = test_config();
1277        let mut ctx = SessionContext::default();
1278        // No arg → just prints current chain, doesn't change anything
1279        let result = execute_input("chain", &mut ctx, &config, &test_factory())
1280            .await
1281            .unwrap();
1282        assert!(!result);
1283        assert_eq!(ctx.chain, "auto");
1284    }
1285
1286    #[tokio::test]
1287    async fn test_format_set_json() {
1288        let config = test_config();
1289        let mut ctx = SessionContext::default();
1290        execute_input("format json", &mut ctx, &config, &test_factory())
1291            .await
1292            .unwrap();
1293        assert_eq!(ctx.format, OutputFormat::Json);
1294    }
1295
1296    #[tokio::test]
1297    async fn test_format_set_csv() {
1298        let config = test_config();
1299        let mut ctx = SessionContext::default();
1300        execute_input("format csv", &mut ctx, &config, &test_factory())
1301            .await
1302            .unwrap();
1303        assert_eq!(ctx.format, OutputFormat::Csv);
1304    }
1305
1306    #[tokio::test]
1307    async fn test_format_set_table() {
1308        let config = test_config();
1309        let mut ctx = SessionContext {
1310            format: OutputFormat::Json,
1311            ..Default::default()
1312        };
1313        execute_input("format table", &mut ctx, &config, &test_factory())
1314            .await
1315            .unwrap();
1316        assert_eq!(ctx.format, OutputFormat::Table);
1317    }
1318
1319    #[tokio::test]
1320    async fn test_format_invalid() {
1321        let config = test_config();
1322        let mut ctx = SessionContext::default();
1323        execute_input("format xml", &mut ctx, &config, &test_factory())
1324            .await
1325            .unwrap();
1326        // Should remain unchanged
1327        assert_eq!(ctx.format, OutputFormat::Table);
1328    }
1329
1330    #[tokio::test]
1331    async fn test_format_show() {
1332        let config = test_config();
1333        let mut ctx = SessionContext::default();
1334        let result = execute_input("format", &mut ctx, &config, &test_factory())
1335            .await
1336            .unwrap();
1337        assert!(!result);
1338    }
1339
1340    #[tokio::test]
1341    async fn test_toggle_tokens() {
1342        let config = test_config();
1343        let mut ctx = SessionContext::default();
1344        assert!(!ctx.include_tokens);
1345
1346        execute_input("+tokens", &mut ctx, &config, &test_factory())
1347            .await
1348            .unwrap();
1349        assert!(ctx.include_tokens);
1350
1351        execute_input("+tokens", &mut ctx, &config, &test_factory())
1352            .await
1353            .unwrap();
1354        assert!(!ctx.include_tokens);
1355    }
1356
1357    #[tokio::test]
1358    async fn test_toggle_txs() {
1359        let config = test_config();
1360        let mut ctx = SessionContext::default();
1361        assert!(!ctx.include_txs);
1362
1363        execute_input("+txs", &mut ctx, &config, &test_factory())
1364            .await
1365            .unwrap();
1366        assert!(ctx.include_txs);
1367
1368        execute_input("+txs", &mut ctx, &config, &test_factory())
1369            .await
1370            .unwrap();
1371        assert!(!ctx.include_txs);
1372    }
1373
1374    #[tokio::test]
1375    async fn test_toggle_trace() {
1376        let config = test_config();
1377        let mut ctx = SessionContext::default();
1378        assert!(!ctx.trace);
1379
1380        execute_input("trace", &mut ctx, &config, &test_factory())
1381            .await
1382            .unwrap();
1383        assert!(ctx.trace);
1384
1385        execute_input("trace", &mut ctx, &config, &test_factory())
1386            .await
1387            .unwrap();
1388        assert!(!ctx.trace);
1389    }
1390
1391    #[tokio::test]
1392    async fn test_toggle_decode() {
1393        let config = test_config();
1394        let mut ctx = SessionContext::default();
1395        assert!(!ctx.decode);
1396
1397        execute_input("decode", &mut ctx, &config, &test_factory())
1398            .await
1399            .unwrap();
1400        assert!(ctx.decode);
1401
1402        execute_input("decode", &mut ctx, &config, &test_factory())
1403            .await
1404            .unwrap();
1405        assert!(!ctx.decode);
1406    }
1407
1408    #[tokio::test]
1409    async fn test_limit_set_valid() {
1410        let config = test_config();
1411        let mut ctx = SessionContext::default();
1412        execute_input("limit 50", &mut ctx, &config, &test_factory())
1413            .await
1414            .unwrap();
1415        assert_eq!(ctx.limit, 50);
1416    }
1417
1418    #[tokio::test]
1419    async fn test_limit_set_invalid() {
1420        let config = test_config();
1421        let mut ctx = SessionContext::default();
1422        execute_input("limit abc", &mut ctx, &config, &test_factory())
1423            .await
1424            .unwrap();
1425        // Should remain unchanged
1426        assert_eq!(ctx.limit, 100);
1427    }
1428
1429    #[tokio::test]
1430    async fn test_limit_show() {
1431        let config = test_config();
1432        let mut ctx = SessionContext::default();
1433        let result = execute_input("limit", &mut ctx, &config, &test_factory())
1434            .await
1435            .unwrap();
1436        assert!(!result);
1437    }
1438
1439    #[tokio::test]
1440    async fn test_unknown_command() {
1441        let config = test_config();
1442        let mut ctx = SessionContext::default();
1443        let result = execute_input("foobar", &mut ctx, &config, &test_factory())
1444            .await
1445            .unwrap();
1446        assert!(!result);
1447    }
1448
1449    #[tokio::test]
1450    async fn test_empty_input() {
1451        let config = test_config();
1452        let mut ctx = SessionContext::default();
1453        let result = execute_input("", &mut ctx, &config, &test_factory())
1454            .await
1455            .unwrap();
1456        assert!(!result);
1457    }
1458
1459    #[tokio::test]
1460    async fn test_address_no_arg_no_last() {
1461        let config = test_config();
1462        let mut ctx = SessionContext::default();
1463        // address with no arg and no last_address → prints error, returns Ok(false)
1464        let result = execute_input("address", &mut ctx, &config, &test_factory())
1465            .await
1466            .unwrap();
1467        assert!(!result);
1468    }
1469
1470    #[tokio::test]
1471    async fn test_tx_no_arg_no_last() {
1472        let config = test_config();
1473        let mut ctx = SessionContext::default();
1474        // tx with no arg and no last_tx → prints error, returns Ok(false)
1475        let result = execute_input("tx", &mut ctx, &config, &test_factory())
1476            .await
1477            .unwrap();
1478        assert!(!result);
1479    }
1480
1481    #[tokio::test]
1482    async fn test_crawl_no_arg() {
1483        let config = test_config();
1484        let mut ctx = SessionContext::default();
1485        // crawl with no arg → prints usage, returns Ok(false)
1486        let result = execute_input("crawl", &mut ctx, &config, &test_factory())
1487            .await
1488            .unwrap();
1489        assert!(!result);
1490    }
1491
1492    #[tokio::test]
1493    async fn test_multiple_context_commands() {
1494        let config = test_config();
1495        let mut ctx = SessionContext::default();
1496
1497        // Set chain, format, toggle flags, set limit
1498        execute_input("chain polygon", &mut ctx, &config, &test_factory())
1499            .await
1500            .unwrap();
1501        execute_input("format json", &mut ctx, &config, &test_factory())
1502            .await
1503            .unwrap();
1504        execute_input("+tokens", &mut ctx, &config, &test_factory())
1505            .await
1506            .unwrap();
1507        execute_input("trace", &mut ctx, &config, &test_factory())
1508            .await
1509            .unwrap();
1510        execute_input("limit 25", &mut ctx, &config, &test_factory())
1511            .await
1512            .unwrap();
1513
1514        assert_eq!(ctx.chain, "polygon");
1515        assert_eq!(ctx.format, OutputFormat::Json);
1516        assert!(ctx.include_tokens);
1517        assert!(ctx.trace);
1518        assert_eq!(ctx.limit, 25);
1519
1520        // Clear resets everything
1521        execute_input("clear", &mut ctx, &config, &test_factory())
1522            .await
1523            .unwrap();
1524        assert_eq!(ctx.chain, "auto");
1525        assert!(!ctx.include_tokens);
1526        assert!(!ctx.trace);
1527        assert_eq!(ctx.limit, 100);
1528    }
1529
1530    #[tokio::test]
1531    async fn test_dot_prefix_commands() {
1532        let config = test_config();
1533        let mut ctx = SessionContext::default();
1534
1535        // Dot-prefixed variants
1536        let result = execute_input(".help", &mut ctx, &config, &test_factory())
1537            .await
1538            .unwrap();
1539        assert!(!result);
1540
1541        execute_input(".chain polygon", &mut ctx, &config, &test_factory())
1542            .await
1543            .unwrap();
1544        assert_eq!(ctx.chain, "polygon");
1545
1546        execute_input(".format json", &mut ctx, &config, &test_factory())
1547            .await
1548            .unwrap();
1549        assert_eq!(ctx.format, OutputFormat::Json);
1550
1551        execute_input(".trace", &mut ctx, &config, &test_factory())
1552            .await
1553            .unwrap();
1554        assert!(ctx.trace);
1555
1556        execute_input(".decode", &mut ctx, &config, &test_factory())
1557            .await
1558            .unwrap();
1559        assert!(ctx.decode);
1560    }
1561
1562    #[tokio::test]
1563    async fn test_all_valid_chains() {
1564        let config = test_config();
1565        let valid_chains = [
1566            "ethereum", "polygon", "arbitrum", "optimism", "base", "bsc", "solana", "tron",
1567        ];
1568        for chain in valid_chains {
1569            let mut ctx = SessionContext::default();
1570            execute_input(
1571                &format!("chain {}", chain),
1572                &mut ctx,
1573                &config,
1574                &test_factory(),
1575            )
1576            .await
1577            .unwrap();
1578            assert_eq!(ctx.chain, chain);
1579            assert!(!ctx.is_auto_chain());
1580        }
1581    }
1582
1583    // ========================================================================
1584    // Command dispatch tests (with MockClientFactory)
1585    // ========================================================================
1586
1587    use crate::chains::mocks::MockClientFactory;
1588
1589    fn mock_factory() -> MockClientFactory {
1590        let mut factory = MockClientFactory::new();
1591        factory.mock_client.transactions = vec![factory.mock_client.transaction.clone()];
1592        factory.mock_client.token_balances = vec![crate::chains::TokenBalance {
1593            token: crate::chains::Token {
1594                contract_address: "0xtoken".to_string(),
1595                symbol: "TEST".to_string(),
1596                name: "Test Token".to_string(),
1597                decimals: 18,
1598            },
1599            balance: "1000".to_string(),
1600            formatted_balance: "0.001".to_string(),
1601            usd_value: None,
1602        }];
1603        factory
1604    }
1605
1606    #[tokio::test]
1607    async fn test_address_command_with_args() {
1608        let config = test_config();
1609        let factory = mock_factory();
1610        let mut ctx = SessionContext::default();
1611        let result = execute_input(
1612            "address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1613            &mut ctx,
1614            &config,
1615            &factory,
1616        )
1617        .await;
1618        assert!(result.is_ok());
1619        assert!(!result.unwrap());
1620        assert_eq!(
1621            ctx.last_address,
1622            Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string())
1623        );
1624    }
1625
1626    #[tokio::test]
1627    async fn test_address_command_with_chain_override() {
1628        let config = test_config();
1629        let factory = mock_factory();
1630        let mut ctx = SessionContext::default();
1631        let result = execute_input(
1632            "address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2 --chain=polygon",
1633            &mut ctx,
1634            &config,
1635            &factory,
1636        )
1637        .await;
1638        assert!(result.is_ok());
1639    }
1640
1641    #[tokio::test]
1642    async fn test_address_command_with_tokens_flag() {
1643        let config = test_config();
1644        let factory = mock_factory();
1645        let mut ctx = SessionContext::default();
1646        let result = execute_input(
1647            "address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2 --tokens",
1648            &mut ctx,
1649            &config,
1650            &factory,
1651        )
1652        .await;
1653        assert!(result.is_ok());
1654    }
1655
1656    #[tokio::test]
1657    async fn test_address_command_with_txs_flag() {
1658        let config = test_config();
1659        let factory = mock_factory();
1660        let mut ctx = SessionContext::default();
1661        let result = execute_input(
1662            "address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2 --txs",
1663            &mut ctx,
1664            &config,
1665            &factory,
1666        )
1667        .await;
1668        assert!(result.is_ok());
1669    }
1670
1671    #[tokio::test]
1672    async fn test_address_reuses_last_address() {
1673        let config = test_config();
1674        let factory = mock_factory();
1675        let mut ctx = SessionContext {
1676            last_address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
1677            ..Default::default()
1678        };
1679        let result = execute_input("address", &mut ctx, &config, &factory).await;
1680        assert!(result.is_ok());
1681    }
1682
1683    #[tokio::test]
1684    async fn test_address_auto_detects_solana() {
1685        let config = test_config();
1686        let factory = mock_factory();
1687        let mut ctx = SessionContext::default();
1688        // Solana address format
1689        let result = execute_input(
1690            "address DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy",
1691            &mut ctx,
1692            &config,
1693            &factory,
1694        )
1695        .await;
1696        assert!(result.is_ok());
1697        // Context chain stays "auto" (inferred per command, not stored)
1698        assert_eq!(ctx.chain, "auto");
1699    }
1700
1701    #[tokio::test]
1702    async fn test_tx_command_with_args() {
1703        let config = test_config();
1704        let factory = mock_factory();
1705        let mut ctx = SessionContext::default();
1706        let result = execute_input(
1707            "tx 0xabc123def456789012345678901234567890123456789012345678901234abcd",
1708            &mut ctx,
1709            &config,
1710            &factory,
1711        )
1712        .await;
1713        assert!(result.is_ok());
1714        assert_eq!(
1715            ctx.last_tx,
1716            Some("0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string())
1717        );
1718    }
1719
1720    #[tokio::test]
1721    async fn test_tx_command_with_trace_decode() {
1722        let config = test_config();
1723        let factory = mock_factory();
1724        let mut ctx = SessionContext::default();
1725        let result = execute_input(
1726            "tx 0xabc123def456789012345678901234567890123456789012345678901234abcd --trace --decode",
1727            &mut ctx,
1728            &config,
1729            &factory,
1730        )
1731        .await;
1732        assert!(result.is_ok());
1733    }
1734
1735    #[tokio::test]
1736    async fn test_tx_command_with_chain_override() {
1737        let config = test_config();
1738        let factory = mock_factory();
1739        let mut ctx = SessionContext::default();
1740        let result = execute_input(
1741            "tx 0xabc123def456789012345678901234567890123456789012345678901234abcd --chain=polygon",
1742            &mut ctx,
1743            &config,
1744            &factory,
1745        )
1746        .await;
1747        assert!(result.is_ok());
1748    }
1749
1750    #[tokio::test]
1751    async fn test_tx_reuses_last_tx() {
1752        let config = test_config();
1753        let factory = mock_factory();
1754        let mut ctx = SessionContext {
1755            last_tx: Some(
1756                "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1757            ),
1758            ..Default::default()
1759        };
1760        let result = execute_input("tx", &mut ctx, &config, &factory).await;
1761        assert!(result.is_ok());
1762    }
1763
1764    #[tokio::test]
1765    async fn test_tx_auto_detects_tron() {
1766        let config = test_config();
1767        let factory = mock_factory();
1768        let mut ctx = SessionContext::default();
1769        let result = execute_input(
1770            "tx abc123def456789012345678901234567890123456789012345678901234abcd",
1771            &mut ctx,
1772            &config,
1773            &factory,
1774        )
1775        .await;
1776        assert!(result.is_ok());
1777        // Context chain stays "auto" (inferred per command, not stored)
1778        assert_eq!(ctx.chain, "auto");
1779    }
1780
1781    #[tokio::test]
1782    async fn test_crawl_command_with_args() {
1783        let config = test_config();
1784        let factory = mock_factory();
1785        let mut ctx = SessionContext::default();
1786        let result = execute_input(
1787            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --no-charts",
1788            &mut ctx,
1789            &config,
1790            &factory,
1791        )
1792        .await;
1793        assert!(result.is_ok());
1794    }
1795
1796    #[tokio::test]
1797    async fn test_crawl_command_with_period() {
1798        let config = test_config();
1799        let factory = mock_factory();
1800        let mut ctx = SessionContext::default();
1801        let result = execute_input(
1802            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period=7d --no-charts",
1803            &mut ctx,
1804            &config,
1805            &factory,
1806        )
1807        .await;
1808        assert!(result.is_ok());
1809    }
1810
1811    #[tokio::test]
1812    async fn test_crawl_command_with_chain_flag() {
1813        let config = test_config();
1814        let factory = mock_factory();
1815        let mut ctx = SessionContext::default();
1816        let result = execute_input(
1817            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --chain polygon --no-charts",
1818            &mut ctx,
1819            &config,
1820            &factory,
1821        )
1822        .await;
1823        assert!(result.is_ok());
1824    }
1825
1826    #[tokio::test]
1827    async fn test_crawl_command_with_period_flag() {
1828        let config = test_config();
1829        let factory = mock_factory();
1830        let mut ctx = SessionContext::default();
1831        let result = execute_input(
1832            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period 1h --no-charts",
1833            &mut ctx,
1834            &config,
1835            &factory,
1836        )
1837        .await;
1838        assert!(result.is_ok());
1839    }
1840
1841    #[tokio::test]
1842    async fn test_crawl_command_with_report() {
1843        let config = test_config();
1844        let factory = mock_factory();
1845        let mut ctx = SessionContext::default();
1846        let tmp = tempfile::NamedTempFile::new().unwrap();
1847        let result = execute_input(
1848            &format!(
1849                "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --report {} --no-charts",
1850                tmp.path().display()
1851            ),
1852            &mut ctx,
1853            &config,
1854            &factory,
1855        )
1856        .await;
1857        assert!(result.is_ok());
1858    }
1859
1860    #[tokio::test]
1861    async fn test_portfolio_list_command() {
1862        let tmp_dir = tempfile::tempdir().unwrap();
1863        let config = Config {
1864            address_book: crate::config::AddressBookConfig {
1865                data_dir: Some(tmp_dir.path().to_path_buf()),
1866            },
1867            ..Default::default()
1868        };
1869        let factory = mock_factory();
1870        let mut ctx = SessionContext::default();
1871        let result = execute_input("portfolio list", &mut ctx, &config, &factory).await;
1872        assert!(result.is_ok());
1873    }
1874
1875    #[tokio::test]
1876    async fn test_portfolio_add_command() {
1877        let tmp_dir = tempfile::tempdir().unwrap();
1878        let config = Config {
1879            address_book: crate::config::AddressBookConfig {
1880                data_dir: Some(tmp_dir.path().to_path_buf()),
1881            },
1882            ..Default::default()
1883        };
1884        let factory = mock_factory();
1885        let mut ctx = SessionContext::default();
1886        let result = execute_input(
1887            "portfolio add 0xtest --label mytest",
1888            &mut ctx,
1889            &config,
1890            &factory,
1891        )
1892        .await;
1893        assert!(result.is_ok());
1894    }
1895
1896    #[tokio::test]
1897    async fn test_portfolio_summary_command() {
1898        let tmp_dir = tempfile::tempdir().unwrap();
1899        let config = Config {
1900            address_book: crate::config::AddressBookConfig {
1901                data_dir: Some(tmp_dir.path().to_path_buf()),
1902            },
1903            ..Default::default()
1904        };
1905        let factory = mock_factory();
1906        let mut ctx = SessionContext::default();
1907        // Add first
1908        execute_input("portfolio add 0xtest", &mut ctx, &config, &factory)
1909            .await
1910            .unwrap();
1911        // Then summary
1912        let result = execute_input("portfolio summary", &mut ctx, &config, &factory).await;
1913        assert!(result.is_ok());
1914    }
1915
1916    #[tokio::test]
1917    async fn test_portfolio_remove_command() {
1918        let tmp_dir = tempfile::tempdir().unwrap();
1919        let config = Config {
1920            address_book: crate::config::AddressBookConfig {
1921                data_dir: Some(tmp_dir.path().to_path_buf()),
1922            },
1923            ..Default::default()
1924        };
1925        let factory = mock_factory();
1926        let mut ctx = SessionContext::default();
1927        let result = execute_input("portfolio remove 0xtest", &mut ctx, &config, &factory).await;
1928        assert!(result.is_ok());
1929    }
1930
1931    #[tokio::test]
1932    async fn test_portfolio_no_subcommand() {
1933        let config = test_config();
1934        let factory = mock_factory();
1935        let mut ctx = SessionContext::default();
1936        let result = execute_input("portfolio", &mut ctx, &config, &factory).await;
1937        assert!(result.is_ok());
1938    }
1939
1940    #[tokio::test]
1941    async fn test_portfolio_unknown_subcommand() {
1942        let tmp_dir = tempfile::tempdir().unwrap();
1943        let config = Config {
1944            address_book: crate::config::AddressBookConfig {
1945                data_dir: Some(tmp_dir.path().to_path_buf()),
1946            },
1947            ..Default::default()
1948        };
1949        let factory = mock_factory();
1950        let mut ctx = SessionContext::default();
1951        let result = execute_input("portfolio foobar", &mut ctx, &config, &factory).await;
1952        assert!(result.is_ok());
1953    }
1954
1955    #[tokio::test]
1956    async fn test_tokens_command_list() {
1957        let config = test_config();
1958        let factory = mock_factory();
1959        let mut ctx = SessionContext::default();
1960        let result = execute_input("tokens list", &mut ctx, &config, &factory).await;
1961        assert!(result.is_ok());
1962    }
1963
1964    #[tokio::test]
1965    async fn test_tokens_command_no_args() {
1966        let config = test_config();
1967        let factory = mock_factory();
1968        let mut ctx = SessionContext::default();
1969        let result = execute_input("tokens", &mut ctx, &config, &factory).await;
1970        assert!(result.is_ok());
1971    }
1972
1973    #[tokio::test]
1974    async fn test_tokens_command_recent() {
1975        let config = test_config();
1976        let factory = mock_factory();
1977        let mut ctx = SessionContext::default();
1978        let result = execute_input("tokens recent", &mut ctx, &config, &factory).await;
1979        assert!(result.is_ok());
1980    }
1981
1982    #[tokio::test]
1983    async fn test_tokens_command_remove_no_args() {
1984        let config = test_config();
1985        let factory = mock_factory();
1986        let mut ctx = SessionContext::default();
1987        let result = execute_input("tokens remove", &mut ctx, &config, &factory).await;
1988        assert!(result.is_ok());
1989    }
1990
1991    #[tokio::test]
1992    async fn test_tokens_command_add_no_args() {
1993        let config = test_config();
1994        let factory = mock_factory();
1995        let mut ctx = SessionContext::default();
1996        let result = execute_input("tokens add", &mut ctx, &config, &factory).await;
1997        assert!(result.is_ok());
1998    }
1999
2000    #[tokio::test]
2001    async fn test_tokens_command_unknown() {
2002        let config = test_config();
2003        let factory = mock_factory();
2004        let mut ctx = SessionContext::default();
2005        let result = execute_input("tokens foobar", &mut ctx, &config, &factory).await;
2006        assert!(result.is_ok());
2007    }
2008
2009    #[tokio::test]
2010    async fn test_setup_command_status() {
2011        let config = test_config();
2012        let factory = mock_factory();
2013        let mut ctx = SessionContext::default();
2014        let result = execute_input("setup --status", &mut ctx, &config, &factory).await;
2015        assert!(result.is_ok());
2016    }
2017
2018    #[tokio::test]
2019    async fn test_transaction_alias() {
2020        let config = test_config();
2021        let factory = mock_factory();
2022        let mut ctx = SessionContext::default();
2023        let result = execute_input(
2024            "transaction 0xabc123def456789012345678901234567890123456789012345678901234abcd",
2025            &mut ctx,
2026            &config,
2027            &factory,
2028        )
2029        .await;
2030        assert!(result.is_ok());
2031    }
2032
2033    #[tokio::test]
2034    async fn test_token_alias_for_crawl() {
2035        let config = test_config();
2036        let factory = mock_factory();
2037        let mut ctx = SessionContext::default();
2038        let result = execute_input(
2039            "token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --no-charts",
2040            &mut ctx,
2041            &config,
2042            &factory,
2043        )
2044        .await;
2045        assert!(result.is_ok());
2046    }
2047
2048    #[tokio::test]
2049    async fn test_port_alias_for_portfolio() {
2050        let tmp_dir = tempfile::tempdir().unwrap();
2051        let config = Config {
2052            address_book: crate::config::AddressBookConfig {
2053                data_dir: Some(tmp_dir.path().to_path_buf()),
2054            },
2055            ..Default::default()
2056        };
2057        let factory = mock_factory();
2058        let mut ctx = SessionContext::default();
2059        let result = execute_input("port list", &mut ctx, &config, &factory).await;
2060        assert!(result.is_ok());
2061    }
2062
2063    // ========================================================================
2064    // execute_tokens_command direct tests
2065    // ========================================================================
2066
2067    #[tokio::test]
2068    async fn test_execute_tokens_list_empty() {
2069        let result = execute_tokens_command(&[]).await;
2070        assert!(result.is_ok());
2071    }
2072
2073    #[tokio::test]
2074    async fn test_execute_tokens_list_subcommand() {
2075        let result = execute_tokens_command(&["list"]).await;
2076        assert!(result.is_ok());
2077    }
2078
2079    #[tokio::test]
2080    async fn test_execute_tokens_recent() {
2081        let result = execute_tokens_command(&["recent"]).await;
2082        assert!(result.is_ok());
2083    }
2084
2085    #[tokio::test]
2086    async fn test_execute_tokens_add_insufficient_args() {
2087        let result = execute_tokens_command(&["add"]).await;
2088        assert!(result.is_ok());
2089    }
2090
2091    #[tokio::test]
2092    async fn test_execute_tokens_add_success() {
2093        let result = execute_tokens_command(&[
2094            "add",
2095            "TEST_INTERACTIVE",
2096            "ethereum",
2097            "0xtest123456789",
2098            "Test Token",
2099        ])
2100        .await;
2101        assert!(result.is_ok());
2102        let _ = execute_tokens_command(&["remove", "TEST_INTERACTIVE"]).await;
2103    }
2104
2105    #[tokio::test]
2106    async fn test_execute_tokens_remove_no_args() {
2107        let result = execute_tokens_command(&["remove"]).await;
2108        assert!(result.is_ok());
2109    }
2110
2111    #[tokio::test]
2112    async fn test_execute_tokens_remove_with_symbol() {
2113        let _ =
2114            execute_tokens_command(&["add", "RMTEST", "ethereum", "0xrmtest", "Remove Test"]).await;
2115        let result = execute_tokens_command(&["remove", "RMTEST"]).await;
2116        assert!(result.is_ok());
2117    }
2118
2119    #[tokio::test]
2120    async fn test_execute_tokens_unknown_subcommand() {
2121        let result = execute_tokens_command(&["invalid"]).await;
2122        assert!(result.is_ok());
2123    }
2124
2125    // ========================================================================
2126    // SessionContext additional tests (default and display already exist above)
2127    // ========================================================================
2128
2129    #[test]
2130    fn test_session_context_serialization_roundtrip() {
2131        let ctx = SessionContext {
2132            chain: "solana".to_string(),
2133            include_tokens: true,
2134            limit: 25,
2135            last_address: Some("0xtest".to_string()),
2136            ..Default::default()
2137        };
2138
2139        let yaml = serde_yaml::to_string(&ctx).unwrap();
2140        let deserialized: SessionContext = serde_yaml::from_str(&yaml).unwrap();
2141        assert_eq!(deserialized.chain, "solana");
2142        assert!(deserialized.include_tokens);
2143        assert_eq!(deserialized.limit, 25);
2144        assert_eq!(deserialized.last_address, Some("0xtest".to_string()));
2145    }
2146
2147    // ========================================================================
2148    // Tests for previously uncovered execute_input branches
2149    // ========================================================================
2150
2151    #[tokio::test]
2152    async fn test_chain_show_explicit() {
2153        let config = test_config();
2154        let factory = test_factory();
2155        let mut context = SessionContext {
2156            chain: "polygon".to_string(),
2157            ..Default::default()
2158        };
2159
2160        // Just showing chain status when chain is pinned
2161        let result = execute_input("chain", &mut context, &config, &factory).await;
2162        assert!(result.is_ok());
2163        assert!(!result.unwrap()); // Should not exit
2164    }
2165
2166    #[tokio::test]
2167    async fn test_address_with_explicit_chain() {
2168        let config = test_config();
2169        let factory = mock_factory();
2170        let mut context = SessionContext {
2171            chain: "polygon".to_string(),
2172            ..Default::default()
2173        };
2174
2175        // Address command with explicit chain — should use context.chain directly
2176        let result = execute_input(
2177            "address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2178            &mut context,
2179            &config,
2180            &factory,
2181        )
2182        .await;
2183        // May fail due to network but should not panic
2184        assert!(result.is_ok() || result.is_err());
2185    }
2186
2187    #[tokio::test]
2188    async fn test_tx_with_explicit_chain() {
2189        let config = test_config();
2190        let factory = mock_factory();
2191        let mut context = SessionContext {
2192            chain: "polygon".to_string(),
2193            ..Default::default()
2194        };
2195
2196        // TX command with explicit chain — should use context.chain
2197        let result = execute_input("tx 0xabc123def456789", &mut context, &config, &factory).await;
2198        assert!(result.is_ok() || result.is_err());
2199    }
2200
2201    #[tokio::test]
2202    async fn test_crawl_with_period_eq_flag() {
2203        let config = test_config();
2204        let factory = test_factory();
2205        let mut context = SessionContext::default();
2206
2207        // crawl with --period=7d syntax
2208        let result = execute_input(
2209            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period=7d",
2210            &mut context,
2211            &config,
2212            &factory,
2213        )
2214        .await;
2215        // Will attempt network call, may succeed or fail
2216        assert!(result.is_ok() || result.is_err());
2217    }
2218
2219    #[tokio::test]
2220    async fn test_crawl_with_period_space_flag() {
2221        let config = test_config();
2222        let factory = test_factory();
2223        let mut context = SessionContext::default();
2224
2225        // crawl with --period 1h syntax (space-separated)
2226        let result = execute_input(
2227            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period 1h",
2228            &mut context,
2229            &config,
2230            &factory,
2231        )
2232        .await;
2233        assert!(result.is_ok() || result.is_err());
2234    }
2235
2236    #[tokio::test]
2237    async fn test_crawl_with_chain_eq_flag() {
2238        let config = test_config();
2239        let factory = test_factory();
2240        let mut context = SessionContext::default();
2241
2242        // crawl with --chain=polygon syntax
2243        let result = execute_input(
2244            "crawl 0xAddress --chain=polygon",
2245            &mut context,
2246            &config,
2247            &factory,
2248        )
2249        .await;
2250        assert!(result.is_ok() || result.is_err());
2251    }
2252
2253    #[tokio::test]
2254    async fn test_crawl_with_chain_space_flag() {
2255        let config = test_config();
2256        let factory = test_factory();
2257        let mut context = SessionContext::default();
2258
2259        // crawl with --chain polygon syntax
2260        let result = execute_input(
2261            "crawl 0xAddress --chain polygon",
2262            &mut context,
2263            &config,
2264            &factory,
2265        )
2266        .await;
2267        assert!(result.is_ok() || result.is_err());
2268    }
2269
2270    #[tokio::test]
2271    async fn test_crawl_with_report_flag() {
2272        let config = test_config();
2273        let factory = test_factory();
2274        let mut context = SessionContext::default();
2275
2276        let tmp = tempfile::NamedTempFile::new().unwrap();
2277        let path = tmp.path().to_string_lossy();
2278        let input = format!(
2279            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --report={}",
2280            path
2281        );
2282        let result = execute_input(&input, &mut context, &config, &factory).await;
2283        assert!(result.is_ok() || result.is_err());
2284    }
2285
2286    #[tokio::test]
2287    async fn test_crawl_with_no_charts_flag() {
2288        let config = test_config();
2289        let factory = test_factory();
2290        let mut context = SessionContext::default();
2291
2292        let result = execute_input(
2293            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --no-charts",
2294            &mut context,
2295            &config,
2296            &factory,
2297        )
2298        .await;
2299        assert!(result.is_ok() || result.is_err());
2300    }
2301
2302    #[tokio::test]
2303    async fn test_crawl_with_explicit_chain() {
2304        let config = test_config();
2305        let factory = test_factory();
2306        let mut context = SessionContext {
2307            chain: "arbitrum".to_string(),
2308            ..Default::default()
2309        };
2310
2311        let result = execute_input("crawl 0xAddress", &mut context, &config, &factory).await;
2312        assert!(result.is_ok() || result.is_err());
2313    }
2314
2315    #[tokio::test]
2316    async fn test_portfolio_add_with_label_and_tags() {
2317        let tmp_dir = tempfile::tempdir().unwrap();
2318        let config = Config {
2319            address_book: crate::config::AddressBookConfig {
2320                data_dir: Some(tmp_dir.path().to_path_buf()),
2321            },
2322            ..Default::default()
2323        };
2324        let factory = mock_factory();
2325        let mut context = SessionContext::default();
2326
2327        let result = execute_input(
2328            "portfolio add 0xAbC123 --label MyWallet --tags defi,staking",
2329            &mut context,
2330            &config,
2331            &factory,
2332        )
2333        .await;
2334        assert!(result.is_ok());
2335    }
2336
2337    #[tokio::test]
2338    async fn test_portfolio_remove_no_args() {
2339        let tmp_dir = tempfile::tempdir().unwrap();
2340        let config = Config {
2341            address_book: crate::config::AddressBookConfig {
2342                data_dir: Some(tmp_dir.path().to_path_buf()),
2343            },
2344            ..Default::default()
2345        };
2346        let factory = mock_factory();
2347        let mut context = SessionContext::default();
2348
2349        let result = execute_input("portfolio remove", &mut context, &config, &factory).await;
2350        assert!(result.is_ok());
2351    }
2352
2353    #[tokio::test]
2354    async fn test_portfolio_summary_with_chain_and_tag() {
2355        let tmp_dir = tempfile::tempdir().unwrap();
2356        let config = Config {
2357            address_book: crate::config::AddressBookConfig {
2358                data_dir: Some(tmp_dir.path().to_path_buf()),
2359            },
2360            ..Default::default()
2361        };
2362        let factory = mock_factory();
2363        let mut context = SessionContext::default();
2364
2365        let result = execute_input(
2366            "portfolio summary --chain ethereum --tag defi --tokens",
2367            &mut context,
2368            &config,
2369            &factory,
2370        )
2371        .await;
2372        assert!(result.is_ok());
2373    }
2374
2375    #[tokio::test]
2376    async fn test_tokens_add_with_name() {
2377        let result = execute_tokens_command(&[
2378            "add",
2379            "USDC",
2380            "ethereum",
2381            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2382            "USD",
2383            "Coin",
2384        ])
2385        .await;
2386        assert!(result.is_ok());
2387    }
2388
2389    #[tokio::test]
2390    async fn test_tokens_remove_with_chain() {
2391        let result = execute_tokens_command(&["remove", "USDC", "--chain", "ethereum"]).await;
2392        assert!(result.is_ok());
2393    }
2394
2395    #[tokio::test]
2396    async fn test_tokens_add_then_list_nonempty() {
2397        // Add a token first
2398        let _ = execute_tokens_command(&[
2399            "add",
2400            "TEST_TOKEN_XYZ",
2401            "ethereum",
2402            "0x1234567890abcdef1234567890abcdef12345678",
2403            "Test",
2404            "Token",
2405        ])
2406        .await;
2407
2408        // Now list should show it
2409        let result = execute_tokens_command(&["list"]).await;
2410        assert!(result.is_ok());
2411
2412        // And recent should show it
2413        let result = execute_tokens_command(&["recent"]).await;
2414        assert!(result.is_ok());
2415
2416        // Clean up
2417        let _ = execute_tokens_command(&["remove", "TEST_TOKEN_XYZ"]).await;
2418    }
2419
2420    #[tokio::test]
2421    async fn test_session_context_save_and_load() {
2422        // SessionContext::save() and ::load() use dirs::data_dir()
2423        // We just verify they don't panic
2424        let ctx = SessionContext {
2425            chain: "solana".to_string(),
2426            last_address: Some("0xabc".to_string()),
2427            last_tx: Some("0xdef".to_string()),
2428            ..Default::default()
2429        };
2430        // save may fail if data dir doesn't exist, but should not panic
2431        let _ = ctx.save();
2432        // load should return default or saved data
2433        let loaded = SessionContext::load();
2434        // At least the struct is valid
2435        assert!(!loaded.chain.is_empty());
2436    }
2437
2438    // ========================================================================
2439    // Command alias coverage
2440    // ========================================================================
2441
2442    #[tokio::test]
2443    async fn test_help_alias_question_mark() {
2444        let config = test_config();
2445        let mut ctx = SessionContext::default();
2446        let result = execute_input("?", &mut ctx, &config, &test_factory())
2447            .await
2448            .unwrap();
2449        assert!(!result);
2450    }
2451
2452    #[tokio::test]
2453    async fn test_context_alias() {
2454        let config = test_config();
2455        let mut ctx = SessionContext::default();
2456        let result = execute_input("context", &mut ctx, &config, &test_factory())
2457            .await
2458            .unwrap();
2459        assert!(!result);
2460    }
2461
2462    #[tokio::test]
2463    async fn test_dot_context_alias() {
2464        let config = test_config();
2465        let mut ctx = SessionContext::default();
2466        let result = execute_input(".context", &mut ctx, &config, &test_factory())
2467            .await
2468            .unwrap();
2469        assert!(!result);
2470    }
2471
2472    #[tokio::test]
2473    async fn test_reset_alias() {
2474        let config = test_config();
2475        let mut ctx = SessionContext {
2476            chain: "ethereum".to_string(),
2477            ..Default::default()
2478        };
2479        execute_input("reset", &mut ctx, &config, &test_factory())
2480            .await
2481            .unwrap();
2482        assert_eq!(ctx.chain, "auto");
2483    }
2484
2485    #[tokio::test]
2486    async fn test_dot_reset_alias() {
2487        let config = test_config();
2488        let mut ctx = SessionContext {
2489            chain: "base".to_string(),
2490            ..Default::default()
2491        };
2492        execute_input(".reset", &mut ctx, &config, &test_factory())
2493            .await
2494            .unwrap();
2495        assert_eq!(ctx.chain, "auto");
2496    }
2497
2498    #[tokio::test]
2499    async fn test_dot_clear_alias() {
2500        let config = test_config();
2501        let mut ctx = SessionContext {
2502            chain: "bsc".to_string(),
2503            ..Default::default()
2504        };
2505        execute_input(".clear", &mut ctx, &config, &test_factory())
2506            .await
2507            .unwrap();
2508        assert_eq!(ctx.chain, "auto");
2509    }
2510
2511    #[tokio::test]
2512    async fn test_showtokens_alias() {
2513        let config = test_config();
2514        let mut ctx = SessionContext::default();
2515        execute_input("showtokens", &mut ctx, &config, &test_factory())
2516            .await
2517            .unwrap();
2518        assert!(ctx.include_tokens);
2519    }
2520
2521    #[tokio::test]
2522    async fn test_showtxs_alias() {
2523        let config = test_config();
2524        let mut ctx = SessionContext::default();
2525        execute_input("showtxs", &mut ctx, &config, &test_factory())
2526            .await
2527            .unwrap();
2528        assert!(ctx.include_txs);
2529    }
2530
2531    #[tokio::test]
2532    async fn test_txs_alias() {
2533        let config = test_config();
2534        let mut ctx = SessionContext::default();
2535        execute_input("txs", &mut ctx, &config, &test_factory())
2536            .await
2537            .unwrap();
2538        assert!(ctx.include_txs);
2539    }
2540
2541    #[tokio::test]
2542    async fn test_dot_txs_alias() {
2543        let config = test_config();
2544        let mut ctx = SessionContext::default();
2545        execute_input(".txs", &mut ctx, &config, &test_factory())
2546            .await
2547            .unwrap();
2548        assert!(ctx.include_txs);
2549    }
2550
2551    #[tokio::test]
2552    async fn test_addr_alias() {
2553        let config = test_config();
2554        let factory = mock_factory();
2555        let mut ctx = SessionContext::default();
2556        let result = execute_input(
2557            "addr 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2558            &mut ctx,
2559            &config,
2560            &factory,
2561        )
2562        .await;
2563        assert!(result.is_ok());
2564        assert_eq!(
2565            ctx.last_address,
2566            Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string())
2567        );
2568    }
2569
2570    #[test]
2571    fn test_session_context_is_auto_chain() {
2572        let auto_ctx = SessionContext::default();
2573        assert!(auto_ctx.is_auto_chain());
2574        let pinned_ctx = SessionContext {
2575            chain: "ethereum".to_string(),
2576            ..Default::default()
2577        };
2578        assert!(!pinned_ctx.is_auto_chain());
2579    }
2580
2581    #[test]
2582    fn test_print_help_no_panic() {
2583        print_help();
2584    }
2585
2586    // ========================================================================
2587    // Contract command tests
2588    // ========================================================================
2589
2590    #[tokio::test]
2591    async fn test_contract_no_args() {
2592        let config = test_config();
2593        let factory = mock_factory();
2594        let mut ctx = SessionContext::default();
2595        let result = execute_input("contract", &mut ctx, &config, &factory).await;
2596        assert!(result.is_ok());
2597        assert!(!result.unwrap());
2598    }
2599
2600    #[tokio::test]
2601    async fn test_contract_ct_alias_with_args() {
2602        let config = test_config();
2603        let factory = mock_factory();
2604        let mut ctx = SessionContext::default();
2605        let result = execute_input(
2606            "ct 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2607            &mut ctx,
2608            &config,
2609            &factory,
2610        )
2611        .await;
2612        if let Ok(should_exit) = result {
2613            assert!(!should_exit);
2614        }
2615    }
2616
2617    #[tokio::test]
2618    async fn test_contract_with_chain_and_json() {
2619        let config = test_config();
2620        let factory = mock_factory();
2621        let mut ctx = SessionContext::default();
2622        let result = execute_input(
2623            "contract 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2 --chain=polygon --json",
2624            &mut ctx,
2625            &config,
2626            &factory,
2627        )
2628        .await;
2629        if let Ok(should_exit) = result {
2630            assert!(!should_exit);
2631        }
2632    }
2633
2634    // ========================================================================
2635    // address-book and address_book aliases
2636    // ========================================================================
2637
2638    #[tokio::test]
2639    async fn test_address_book_list_command() {
2640        let tmp_dir = tempfile::tempdir().unwrap();
2641        let config = Config {
2642            address_book: crate::config::AddressBookConfig {
2643                data_dir: Some(tmp_dir.path().to_path_buf()),
2644            },
2645            ..Default::default()
2646        };
2647        let factory = mock_factory();
2648        let mut ctx = SessionContext::default();
2649        let result = execute_input("address-book list", &mut ctx, &config, &factory).await;
2650        assert!(result.is_ok());
2651    }
2652
2653    #[tokio::test]
2654    async fn test_address_book_underscore_list() {
2655        let tmp_dir = tempfile::tempdir().unwrap();
2656        let config = Config {
2657            address_book: crate::config::AddressBookConfig {
2658                data_dir: Some(tmp_dir.path().to_path_buf()),
2659            },
2660            ..Default::default()
2661        };
2662        let factory = mock_factory();
2663        let mut ctx = SessionContext::default();
2664        let result = execute_input("address_book list", &mut ctx, &config, &factory).await;
2665        assert!(result.is_ok());
2666    }
2667
2668    #[tokio::test]
2669    async fn test_address_book_add_insufficient_args() {
2670        let tmp_dir = tempfile::tempdir().unwrap();
2671        let config = Config {
2672            address_book: crate::config::AddressBookConfig {
2673                data_dir: Some(tmp_dir.path().to_path_buf()),
2674            },
2675            ..Default::default()
2676        };
2677        let factory = mock_factory();
2678        let mut ctx = SessionContext::default();
2679        let result = execute_input("address-book add", &mut ctx, &config, &factory).await;
2680        assert!(result.is_ok());
2681    }
2682
2683    #[tokio::test]
2684    async fn test_address_book_remove_insufficient_args() {
2685        let tmp_dir = tempfile::tempdir().unwrap();
2686        let config = Config {
2687            address_book: crate::config::AddressBookConfig {
2688                data_dir: Some(tmp_dir.path().to_path_buf()),
2689            },
2690            ..Default::default()
2691        };
2692        let factory = mock_factory();
2693        let mut ctx = SessionContext::default();
2694        let result = execute_input("address-book remove", &mut ctx, &config, &factory).await;
2695        assert!(result.is_ok());
2696    }
2697
2698    #[tokio::test]
2699    async fn test_address_book_empty_subcommand() {
2700        let tmp_dir = tempfile::tempdir().unwrap();
2701        let config = Config {
2702            address_book: crate::config::AddressBookConfig {
2703                data_dir: Some(tmp_dir.path().to_path_buf()),
2704            },
2705            ..Default::default()
2706        };
2707        let factory = mock_factory();
2708        let mut ctx = SessionContext::default();
2709        let result = execute_input("address-book", &mut ctx, &config, &factory).await;
2710        assert!(result.is_ok());
2711    }
2712
2713    // ========================================================================
2714    // aliases, config, monitor commands
2715    // ========================================================================
2716
2717    #[tokio::test]
2718    async fn test_aliases_command() {
2719        let config = test_config();
2720        let factory = mock_factory();
2721        let mut ctx = SessionContext::default();
2722        let result = execute_input("aliases", &mut ctx, &config, &factory).await;
2723        assert!(result.is_ok());
2724    }
2725
2726    #[tokio::test]
2727    async fn test_config_alias() {
2728        let config = test_config();
2729        let factory = mock_factory();
2730        let mut ctx = SessionContext::default();
2731        let result = execute_input("config --status", &mut ctx, &config, &factory).await;
2732        assert!(result.is_ok());
2733    }
2734
2735    #[tokio::test]
2736    #[ignore = "setup --key prompts for API key input on stdin"]
2737    async fn test_setup_with_key_flag() {
2738        let config = test_config();
2739        let factory = mock_factory();
2740        let mut ctx = SessionContext::default();
2741        let result = execute_input("setup --key=etherscan", &mut ctx, &config, &factory).await;
2742        assert!(result.is_ok());
2743    }
2744
2745    #[tokio::test]
2746    async fn test_setup_with_key_short_flag() {
2747        let config = test_config();
2748        let factory = mock_factory();
2749        let mut ctx = SessionContext::default();
2750        let result = execute_input("setup -s", &mut ctx, &config, &factory).await;
2751        assert!(result.is_ok());
2752    }
2753
2754    // Note: setup --reset is not tested here; it prompts for stdin confirmation
2755    // and can block. See setup::tests::test_reset_config_impl_* for reset coverage.
2756
2757    #[tokio::test]
2758    #[ignore = "monitor starts TUI and blocks until exit"]
2759    async fn test_monitor_command_no_token() {
2760        let config = test_config();
2761        let factory = mock_factory();
2762        let mut ctx = SessionContext::default();
2763        let result = execute_input("monitor", &mut ctx, &config, &factory).await;
2764        assert!(result.is_ok() || result.is_err());
2765    }
2766
2767    #[tokio::test]
2768    #[ignore = "monitor starts TUI and blocks until exit"]
2769    async fn test_mon_alias() {
2770        let config = test_config();
2771        let factory = mock_factory();
2772        let mut ctx = SessionContext::default();
2773        let result = execute_input(
2774            "mon 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2775            &mut ctx,
2776            &config,
2777            &factory,
2778        )
2779        .await;
2780        assert!(result.is_ok() || result.is_err());
2781    }
2782
2783    // ========================================================================
2784    // tokens ls alias and crawl period variants
2785    // ========================================================================
2786
2787    #[tokio::test]
2788    async fn test_tokens_ls_alias() {
2789        let config = test_config();
2790        let factory = mock_factory();
2791        let mut ctx = SessionContext::default();
2792        let result = execute_input("tokens ls", &mut ctx, &config, &factory).await;
2793        assert!(result.is_ok());
2794    }
2795
2796    #[tokio::test]
2797    async fn test_execute_tokens_ls_alias() {
2798        let result = execute_tokens_command(&["ls"]).await;
2799        assert!(result.is_ok());
2800    }
2801
2802    #[tokio::test]
2803    async fn test_crawl_period_1h() {
2804        let config = test_config();
2805        let factory = mock_factory();
2806        let mut ctx = SessionContext::default();
2807        let result = execute_input(
2808            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period=1h --no-charts",
2809            &mut ctx,
2810            &config,
2811            &factory,
2812        )
2813        .await;
2814        assert!(result.is_ok());
2815    }
2816
2817    #[tokio::test]
2818    async fn test_crawl_period_30d() {
2819        let config = test_config();
2820        let factory = mock_factory();
2821        let mut ctx = SessionContext::default();
2822        let result = execute_input(
2823            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period=30d --no-charts",
2824            &mut ctx,
2825            &config,
2826            &factory,
2827        )
2828        .await;
2829        assert!(result.is_ok());
2830    }
2831
2832    #[tokio::test]
2833    async fn test_crawl_invalid_period_defaults() {
2834        let config = test_config();
2835        let factory = mock_factory();
2836        let mut ctx = SessionContext::default();
2837        let result = execute_input(
2838            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period=invalid --no-charts",
2839            &mut ctx,
2840            &config,
2841            &factory,
2842        )
2843        .await;
2844        assert!(result.is_ok());
2845    }
2846
2847    #[tokio::test]
2848    async fn test_tokens_add_three_args_insufficient() {
2849        let result = execute_tokens_command(&["add", "SYM", "ethereum"]).await;
2850        assert!(result.is_ok());
2851    }
2852
2853    #[tokio::test]
2854    async fn test_format_show_when_csv() {
2855        let config = test_config();
2856        let mut ctx = SessionContext {
2857            format: OutputFormat::Csv,
2858            ..Default::default()
2859        };
2860        let result = execute_input("format", &mut ctx, &config, &test_factory())
2861            .await
2862            .unwrap();
2863        assert!(!result);
2864    }
2865}