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::tokens::TokenAliases;
678
679    let mut aliases = TokenAliases::load();
680
681    if args.is_empty() {
682        // List all saved tokens
683        let tokens = aliases.list();
684        if tokens.is_empty() {
685            println!("No saved token aliases.");
686            println!("Use 'crawl <token_name> --save' to save a token alias.");
687            return Ok(());
688        }
689
690        println!("\nSaved Token Aliases\n{}\n", "=".repeat(60));
691        println!("{:<10} {:<12} {:<20} Address", "Symbol", "Chain", "Name");
692        println!("{}", "-".repeat(80));
693
694        for token in tokens {
695            println!(
696                "{:<10} {:<12} {:<20} {}",
697                token.symbol, token.chain, token.name, token.address
698            );
699        }
700        println!();
701        return Ok(());
702    }
703
704    let subcommand = args[0].to_lowercase();
705    match subcommand.as_str() {
706        "list" | "ls" => {
707            let tokens = aliases.list();
708            if tokens.is_empty() {
709                println!("No saved token aliases.");
710                return Ok(());
711            }
712
713            println!("\nSaved Token Aliases\n{}\n", "=".repeat(60));
714            println!("{:<10} {:<12} {:<20} Address", "Symbol", "Chain", "Name");
715            println!("{}", "-".repeat(80));
716
717            for token in tokens {
718                println!(
719                    "{:<10} {:<12} {:<20} {}",
720                    token.symbol, token.chain, token.name, token.address
721                );
722            }
723            println!();
724        }
725
726        "recent" => {
727            let recent = aliases.recent();
728            if recent.is_empty() {
729                println!("No recently used tokens.");
730                return Ok(());
731            }
732
733            println!("\nRecently Used Tokens\n{}\n", "=".repeat(60));
734            println!("{:<10} {:<12} {:<20} Address", "Symbol", "Chain", "Name");
735            println!("{}", "-".repeat(80));
736
737            for token in recent {
738                println!(
739                    "{:<10} {:<12} {:<20} {}",
740                    token.symbol, token.chain, token.name, token.address
741                );
742            }
743            println!();
744        }
745
746        "remove" | "rm" | "delete" => {
747            if args.len() < 2 {
748                eprintln!("Usage: tokens remove <symbol> [--chain <chain>]");
749                return Ok(());
750            }
751
752            let symbol = args[1];
753            let chain = if args.len() > 3 && args[2] == "--chain" {
754                Some(args[3])
755            } else {
756                None
757            };
758
759            aliases.remove(symbol, chain);
760            if let Err(e) = aliases.save() {
761                eprintln!("Failed to save: {}", e);
762            } else {
763                println!("Removed alias: {}", symbol);
764            }
765        }
766
767        "add" => {
768            if args.len() < 4 {
769                eprintln!("Usage: tokens add <symbol> <chain> <address> [name]");
770                return Ok(());
771            }
772
773            let symbol = args[1];
774            let chain = args[2];
775            let address = args[3];
776            let name = if args.len() > 4 {
777                args[4..].join(" ")
778            } else {
779                symbol.to_string()
780            };
781
782            aliases.add(symbol, chain, address, &name);
783            if let Err(e) = aliases.save() {
784                eprintln!("Failed to save: {}", e);
785            } else {
786                println!("Added alias: {} -> {} on {}", symbol, address, chain);
787            }
788        }
789
790        _ => {
791            eprintln!("Unknown tokens subcommand: {}", subcommand);
792            eprintln!("Available: list, recent, add, remove");
793        }
794    }
795
796    Ok(())
797}
798
799/// Execute address book subcommand
800async fn execute_address_book(
801    input: &str,
802    context: &SessionContext,
803    config: &Config,
804    clients: &dyn ChainClientFactory,
805) -> Result<()> {
806    let parts: Vec<&str> = input.split_whitespace().collect();
807    if parts.is_empty() {
808        eprintln!("Address book subcommand required: add, remove, list, summary");
809        return Ok(());
810    }
811
812    use super::address_book::{AddArgs, AddressBookCommands, RemoveArgs, SummaryArgs};
813
814    let subcommand = parts[0].to_lowercase();
815
816    let address_book_args = match subcommand.as_str() {
817        "add" => {
818            if parts.len() < 2 {
819                eprintln!("Usage: address-book add <address> [--label <label>] [--tags <tags>]");
820                return Ok(());
821            }
822            let address = parts[1].to_string();
823            let mut label = None;
824            let mut tags = Vec::new();
825
826            let mut i = 2;
827            while i < parts.len() {
828                if parts[i] == "--label" && i + 1 < parts.len() {
829                    label = Some(parts[i + 1].to_string());
830                    i += 2;
831                } else if parts[i] == "--tags" && i + 1 < parts.len() {
832                    tags = parts[i + 1]
833                        .split(',')
834                        .map(|s| s.trim().to_string())
835                        .collect();
836                    i += 2;
837                } else {
838                    i += 1;
839                }
840            }
841
842            AddressBookArgs {
843                command: AddressBookCommands::Add(AddArgs {
844                    chain: if context.is_auto_chain() {
845                        crate::chains::infer_chain_from_address(&address)
846                            .unwrap_or("ethereum")
847                            .to_string()
848                    } else {
849                        context.chain.clone()
850                    },
851                    address,
852                    label,
853                    tags,
854                }),
855                format: Some(context.format),
856            }
857        }
858        "remove" | "rm" => {
859            if parts.len() < 2 {
860                eprintln!("Usage: address-book remove <address>");
861                return Ok(());
862            }
863            AddressBookArgs {
864                command: AddressBookCommands::Remove(RemoveArgs {
865                    address: parts[1].to_string(),
866                }),
867                format: Some(context.format),
868            }
869        }
870        "list" | "ls" => AddressBookArgs {
871            command: AddressBookCommands::List,
872            format: Some(context.format),
873        },
874        "summary" => {
875            let mut chain = None;
876            let mut tag = None;
877            let mut include_tokens = context.include_tokens;
878
879            let mut i = 1;
880            while i < parts.len() {
881                if parts[i] == "--chain" && i + 1 < parts.len() {
882                    chain = Some(parts[i + 1].to_string());
883                    i += 2;
884                } else if parts[i] == "--tag" && i + 1 < parts.len() {
885                    tag = Some(parts[i + 1].to_string());
886                    i += 2;
887                } else if parts[i] == "--tokens" {
888                    include_tokens = true;
889                    i += 1;
890                } else {
891                    i += 1;
892                }
893            }
894
895            AddressBookArgs {
896                command: AddressBookCommands::Summary(SummaryArgs {
897                    chain,
898                    tag,
899                    include_tokens,
900                    report: None,
901                }),
902                format: Some(context.format),
903            }
904        }
905        _ => {
906            eprintln!(
907                "Unknown address book subcommand: {}. Use: add, remove, list, summary",
908                subcommand
909            );
910            return Ok(());
911        }
912    };
913
914    address_book::run(address_book_args, config, clients).await
915}
916
917/// Print help message for interactive mode.
918fn print_help() {
919    println!(
920        r#"
921Scope Interactive Mode - Available Commands
922==========================================
923
924Navigation & Control:
925  help, ?           Show this help message
926  exit, quit, q     Exit interactive mode
927  ctx, context      Show current session context
928  clear, reset      Reset context to defaults
929
930Context Settings:
931  chain [name]      Set or show current chain (default: auto)
932                    auto = infer chain from each input
933                    Valid: auto, ethereum, polygon, arbitrum, optimism, base, bsc, solana, tron
934  format [fmt]      Set or show output format (table, json, csv)
935  limit [n]         Set or show transaction limit
936  +tokens           Toggle include_tokens flag for address analysis
937  +txs              Toggle include_txs flag
938  trace             Toggle trace flag
939  decode            Toggle decode flag
940
941Analysis Commands:
942  address <addr>    Analyze an address (uses current chain/format)
943  addr              Shorthand for address
944  tx <hash>         Analyze a transaction (uses current chain/format)
945  contract <addr>   Analyze a smart contract (security, proxy, access control)
946  ct                Shorthand for contract
947  crawl <token>     Crawl token analytics (holders, volume, price)
948  token             Shorthand for crawl
949  monitor <token>   Live-updating charts for a token (TUI mode)
950  mon               Shorthand for monitor
951
952Token Search:
953  crawl USDC        Search for token by name/symbol (interactive selection)
954  crawl 0x...       Use address directly (no search)
955  tokens            List saved token aliases
956  tokens recent     Show recently used tokens
957  tokens add <sym> <chain> <addr> [name]    Add a token alias
958  tokens remove <sym> [--chain <chain>]     Remove a token alias
959
960Address Book Commands:
961  address-book add <addr> [--label <name>] [--tags <t1,t2>]
962  address-book remove <addr>
963  address-book list
964  address-book summary [--chain <name>] [--tag <tag>] [--tokens]
965
966Configuration:
967  setup             Run the setup wizard to configure API keys
968  setup --status    Show current configuration status
969  setup --key <provider>    Configure a specific API key
970  config            Alias for setup
971
972Inline Overrides:
973  address 0x... --chain=polygon --tokens
974  tx 0x... --chain=arbitrum --trace --decode
975  contract 0x... --chain=polygon --json
976  crawl USDC --chain=ethereum --period=7d --report=report.md
977
978Live Monitor:
979  monitor USDC      Start live monitoring with real-time charts
980  mon 0x...         Monitor by address
981  Time periods: [1]=15m [2]=1h [3]=6h [4]=24h [T]=cycle
982  Chart modes: [C]=toggle between Line and Candlestick
983  Controls: [Q]uit [R]efresh [P]ause [+/-]speed [Esc]exit
984  Data is cached to temp folder and persists between sessions (24h retention)
985
986Tips:
987  - Search by token name: 'crawl WETH' or 'crawl "wrapped ether"'
988  - Save aliases for quick access: select a token and choose to save
989  - Context persists: set chain once, use it for multiple commands
990  - Use Ctrl+C to cancel, Ctrl+D to exit
991"#
992    );
993}
994
995// ============================================================================
996// Unit Tests
997// ============================================================================
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002
1003    #[test]
1004    fn test_session_context_default() {
1005        let ctx = SessionContext::default();
1006        assert_eq!(ctx.chain, "auto");
1007        assert_eq!(ctx.format, OutputFormat::Table);
1008        assert!(!ctx.include_tokens);
1009        assert!(!ctx.include_txs);
1010        assert!(!ctx.trace);
1011        assert!(!ctx.decode);
1012        assert_eq!(ctx.limit, 100);
1013        assert!(ctx.last_address.is_none());
1014        assert!(ctx.last_tx.is_none());
1015    }
1016
1017    #[test]
1018    fn test_session_context_display() {
1019        let ctx = SessionContext::default();
1020        let display = format!("{}", ctx);
1021        assert!(display.contains("auto"));
1022        assert!(display.contains("Table"));
1023    }
1024
1025    #[test]
1026    fn test_interactive_args_default() {
1027        let args = InteractiveArgs { no_banner: false };
1028        assert!(!args.no_banner);
1029    }
1030
1031    // ========================================================================
1032    // SessionContext serialization/deserialization
1033    // ========================================================================
1034
1035    #[test]
1036    fn test_session_context_serialization() {
1037        let ctx = SessionContext {
1038            chain: "polygon".to_string(),
1039            format: OutputFormat::Json,
1040            last_address: Some("0xabc".to_string()),
1041            last_tx: Some("0xdef".to_string()),
1042            include_tokens: true,
1043            include_txs: true,
1044            trace: true,
1045            decode: true,
1046            limit: 50,
1047        };
1048
1049        let yaml = serde_yaml::to_string(&ctx).unwrap();
1050        let deserialized: SessionContext = serde_yaml::from_str(&yaml).unwrap();
1051        assert_eq!(deserialized.chain, "polygon");
1052        assert!(!deserialized.is_auto_chain());
1053        assert_eq!(deserialized.format, OutputFormat::Json);
1054        assert_eq!(deserialized.last_address.as_deref(), Some("0xabc"));
1055        assert_eq!(deserialized.last_tx.as_deref(), Some("0xdef"));
1056        assert!(deserialized.include_tokens);
1057        assert!(deserialized.include_txs);
1058        assert!(deserialized.trace);
1059        assert!(deserialized.decode);
1060        assert_eq!(deserialized.limit, 50);
1061    }
1062
1063    #[test]
1064    fn test_session_context_display_with_address_and_tx() {
1065        let ctx = SessionContext {
1066            chain: "polygon".to_string(),
1067            last_address: Some("0x1234".to_string()),
1068            last_tx: Some("0xabcd".to_string()),
1069            ..Default::default()
1070        };
1071        let display = format!("{}", ctx);
1072        assert!(display.contains("0x1234"));
1073        assert!(display.contains("0xabcd"));
1074        assert!(display.contains("(pinned)"));
1075    }
1076
1077    #[test]
1078    fn test_session_context_display_auto_chain() {
1079        let ctx = SessionContext::default();
1080        let display = format!("{}", ctx);
1081        assert!(display.contains("auto"));
1082        assert!(display.contains("inferred from input"));
1083    }
1084
1085    // ========================================================================
1086    // execute_input tests for context-modifying commands
1087    // ========================================================================
1088
1089    fn test_config() -> Config {
1090        Config::default()
1091    }
1092
1093    fn test_factory() -> crate::chains::DefaultClientFactory {
1094        crate::chains::DefaultClientFactory {
1095            chains_config: crate::config::ChainsConfig::default(),
1096        }
1097    }
1098
1099    #[tokio::test]
1100    async fn test_exit_commands() {
1101        let config = test_config();
1102        for cmd in &["exit", "quit", "q", ".exit", ".quit"] {
1103            let mut ctx = SessionContext::default();
1104            let result = execute_input(cmd, &mut ctx, &config, &test_factory())
1105                .await
1106                .unwrap();
1107            assert!(result, "'{cmd}' should return true (exit)");
1108        }
1109    }
1110
1111    #[tokio::test]
1112    async fn test_help_command() {
1113        let config = test_config();
1114        let mut ctx = SessionContext::default();
1115        let result = execute_input("help", &mut ctx, &config, &test_factory())
1116            .await
1117            .unwrap();
1118        assert!(!result);
1119    }
1120
1121    #[tokio::test]
1122    async fn test_context_command() {
1123        let config = test_config();
1124        let mut ctx = SessionContext::default();
1125        let result = execute_input("ctx", &mut ctx, &config, &test_factory())
1126            .await
1127            .unwrap();
1128        assert!(!result);
1129    }
1130
1131    #[tokio::test]
1132    async fn test_clear_command() {
1133        let config = test_config();
1134        let mut ctx = SessionContext {
1135            chain: "polygon".to_string(),
1136            include_tokens: true,
1137            limit: 42,
1138            ..Default::default()
1139        };
1140
1141        let result = execute_input("clear", &mut ctx, &config, &test_factory())
1142            .await
1143            .unwrap();
1144        assert!(!result);
1145        assert_eq!(ctx.chain, "auto");
1146        assert!(!ctx.include_tokens);
1147        assert_eq!(ctx.limit, 100);
1148    }
1149
1150    #[tokio::test]
1151    async fn test_chain_set_valid() {
1152        let config = test_config();
1153        let mut ctx = SessionContext::default();
1154
1155        execute_input("chain polygon", &mut ctx, &config, &test_factory())
1156            .await
1157            .unwrap();
1158        assert_eq!(ctx.chain, "polygon");
1159        assert!(!ctx.is_auto_chain());
1160    }
1161
1162    #[tokio::test]
1163    async fn test_chain_set_solana() {
1164        let config = test_config();
1165        let mut ctx = SessionContext::default();
1166
1167        execute_input("chain solana", &mut ctx, &config, &test_factory())
1168            .await
1169            .unwrap();
1170        assert_eq!(ctx.chain, "solana");
1171        assert!(!ctx.is_auto_chain());
1172    }
1173
1174    #[tokio::test]
1175    async fn test_chain_auto() {
1176        let config = test_config();
1177        let mut ctx = SessionContext {
1178            chain: "polygon".to_string(),
1179            ..Default::default()
1180        };
1181
1182        execute_input("chain auto", &mut ctx, &config, &test_factory())
1183            .await
1184            .unwrap();
1185        assert_eq!(ctx.chain, "auto");
1186        assert!(ctx.is_auto_chain());
1187    }
1188
1189    #[tokio::test]
1190    async fn test_chain_invalid() {
1191        let config = test_config();
1192        let mut ctx = SessionContext::default();
1193        // Invalid chain should not change context
1194        execute_input("chain foobar", &mut ctx, &config, &test_factory())
1195            .await
1196            .unwrap();
1197        assert_eq!(ctx.chain, "auto");
1198        assert!(ctx.is_auto_chain());
1199    }
1200
1201    #[tokio::test]
1202    async fn test_chain_show() {
1203        let config = test_config();
1204        let mut ctx = SessionContext::default();
1205        // No arg → just prints current chain, doesn't change anything
1206        let result = execute_input("chain", &mut ctx, &config, &test_factory())
1207            .await
1208            .unwrap();
1209        assert!(!result);
1210        assert_eq!(ctx.chain, "auto");
1211    }
1212
1213    #[tokio::test]
1214    async fn test_format_set_json() {
1215        let config = test_config();
1216        let mut ctx = SessionContext::default();
1217        execute_input("format json", &mut ctx, &config, &test_factory())
1218            .await
1219            .unwrap();
1220        assert_eq!(ctx.format, OutputFormat::Json);
1221    }
1222
1223    #[tokio::test]
1224    async fn test_format_set_csv() {
1225        let config = test_config();
1226        let mut ctx = SessionContext::default();
1227        execute_input("format csv", &mut ctx, &config, &test_factory())
1228            .await
1229            .unwrap();
1230        assert_eq!(ctx.format, OutputFormat::Csv);
1231    }
1232
1233    #[tokio::test]
1234    async fn test_format_set_table() {
1235        let config = test_config();
1236        let mut ctx = SessionContext {
1237            format: OutputFormat::Json,
1238            ..Default::default()
1239        };
1240        execute_input("format table", &mut ctx, &config, &test_factory())
1241            .await
1242            .unwrap();
1243        assert_eq!(ctx.format, OutputFormat::Table);
1244    }
1245
1246    #[tokio::test]
1247    async fn test_format_invalid() {
1248        let config = test_config();
1249        let mut ctx = SessionContext::default();
1250        execute_input("format xml", &mut ctx, &config, &test_factory())
1251            .await
1252            .unwrap();
1253        // Should remain unchanged
1254        assert_eq!(ctx.format, OutputFormat::Table);
1255    }
1256
1257    #[tokio::test]
1258    async fn test_format_show() {
1259        let config = test_config();
1260        let mut ctx = SessionContext::default();
1261        let result = execute_input("format", &mut ctx, &config, &test_factory())
1262            .await
1263            .unwrap();
1264        assert!(!result);
1265    }
1266
1267    #[tokio::test]
1268    async fn test_toggle_tokens() {
1269        let config = test_config();
1270        let mut ctx = SessionContext::default();
1271        assert!(!ctx.include_tokens);
1272
1273        execute_input("+tokens", &mut ctx, &config, &test_factory())
1274            .await
1275            .unwrap();
1276        assert!(ctx.include_tokens);
1277
1278        execute_input("+tokens", &mut ctx, &config, &test_factory())
1279            .await
1280            .unwrap();
1281        assert!(!ctx.include_tokens);
1282    }
1283
1284    #[tokio::test]
1285    async fn test_toggle_txs() {
1286        let config = test_config();
1287        let mut ctx = SessionContext::default();
1288        assert!(!ctx.include_txs);
1289
1290        execute_input("+txs", &mut ctx, &config, &test_factory())
1291            .await
1292            .unwrap();
1293        assert!(ctx.include_txs);
1294
1295        execute_input("+txs", &mut ctx, &config, &test_factory())
1296            .await
1297            .unwrap();
1298        assert!(!ctx.include_txs);
1299    }
1300
1301    #[tokio::test]
1302    async fn test_toggle_trace() {
1303        let config = test_config();
1304        let mut ctx = SessionContext::default();
1305        assert!(!ctx.trace);
1306
1307        execute_input("trace", &mut ctx, &config, &test_factory())
1308            .await
1309            .unwrap();
1310        assert!(ctx.trace);
1311
1312        execute_input("trace", &mut ctx, &config, &test_factory())
1313            .await
1314            .unwrap();
1315        assert!(!ctx.trace);
1316    }
1317
1318    #[tokio::test]
1319    async fn test_toggle_decode() {
1320        let config = test_config();
1321        let mut ctx = SessionContext::default();
1322        assert!(!ctx.decode);
1323
1324        execute_input("decode", &mut ctx, &config, &test_factory())
1325            .await
1326            .unwrap();
1327        assert!(ctx.decode);
1328
1329        execute_input("decode", &mut ctx, &config, &test_factory())
1330            .await
1331            .unwrap();
1332        assert!(!ctx.decode);
1333    }
1334
1335    #[tokio::test]
1336    async fn test_limit_set_valid() {
1337        let config = test_config();
1338        let mut ctx = SessionContext::default();
1339        execute_input("limit 50", &mut ctx, &config, &test_factory())
1340            .await
1341            .unwrap();
1342        assert_eq!(ctx.limit, 50);
1343    }
1344
1345    #[tokio::test]
1346    async fn test_limit_set_invalid() {
1347        let config = test_config();
1348        let mut ctx = SessionContext::default();
1349        execute_input("limit abc", &mut ctx, &config, &test_factory())
1350            .await
1351            .unwrap();
1352        // Should remain unchanged
1353        assert_eq!(ctx.limit, 100);
1354    }
1355
1356    #[tokio::test]
1357    async fn test_limit_show() {
1358        let config = test_config();
1359        let mut ctx = SessionContext::default();
1360        let result = execute_input("limit", &mut ctx, &config, &test_factory())
1361            .await
1362            .unwrap();
1363        assert!(!result);
1364    }
1365
1366    #[tokio::test]
1367    async fn test_unknown_command() {
1368        let config = test_config();
1369        let mut ctx = SessionContext::default();
1370        let result = execute_input("foobar", &mut ctx, &config, &test_factory())
1371            .await
1372            .unwrap();
1373        assert!(!result);
1374    }
1375
1376    #[tokio::test]
1377    async fn test_empty_input() {
1378        let config = test_config();
1379        let mut ctx = SessionContext::default();
1380        let result = execute_input("", &mut ctx, &config, &test_factory())
1381            .await
1382            .unwrap();
1383        assert!(!result);
1384    }
1385
1386    #[tokio::test]
1387    async fn test_address_no_arg_no_last() {
1388        let config = test_config();
1389        let mut ctx = SessionContext::default();
1390        // address with no arg and no last_address → prints error, returns Ok(false)
1391        let result = execute_input("address", &mut ctx, &config, &test_factory())
1392            .await
1393            .unwrap();
1394        assert!(!result);
1395    }
1396
1397    #[tokio::test]
1398    async fn test_tx_no_arg_no_last() {
1399        let config = test_config();
1400        let mut ctx = SessionContext::default();
1401        // tx with no arg and no last_tx → prints error, returns Ok(false)
1402        let result = execute_input("tx", &mut ctx, &config, &test_factory())
1403            .await
1404            .unwrap();
1405        assert!(!result);
1406    }
1407
1408    #[tokio::test]
1409    async fn test_crawl_no_arg() {
1410        let config = test_config();
1411        let mut ctx = SessionContext::default();
1412        // crawl with no arg → prints usage, returns Ok(false)
1413        let result = execute_input("crawl", &mut ctx, &config, &test_factory())
1414            .await
1415            .unwrap();
1416        assert!(!result);
1417    }
1418
1419    #[tokio::test]
1420    async fn test_multiple_context_commands() {
1421        let config = test_config();
1422        let mut ctx = SessionContext::default();
1423
1424        // Set chain, format, toggle flags, set limit
1425        execute_input("chain polygon", &mut ctx, &config, &test_factory())
1426            .await
1427            .unwrap();
1428        execute_input("format json", &mut ctx, &config, &test_factory())
1429            .await
1430            .unwrap();
1431        execute_input("+tokens", &mut ctx, &config, &test_factory())
1432            .await
1433            .unwrap();
1434        execute_input("trace", &mut ctx, &config, &test_factory())
1435            .await
1436            .unwrap();
1437        execute_input("limit 25", &mut ctx, &config, &test_factory())
1438            .await
1439            .unwrap();
1440
1441        assert_eq!(ctx.chain, "polygon");
1442        assert_eq!(ctx.format, OutputFormat::Json);
1443        assert!(ctx.include_tokens);
1444        assert!(ctx.trace);
1445        assert_eq!(ctx.limit, 25);
1446
1447        // Clear resets everything
1448        execute_input("clear", &mut ctx, &config, &test_factory())
1449            .await
1450            .unwrap();
1451        assert_eq!(ctx.chain, "auto");
1452        assert!(!ctx.include_tokens);
1453        assert!(!ctx.trace);
1454        assert_eq!(ctx.limit, 100);
1455    }
1456
1457    #[tokio::test]
1458    async fn test_dot_prefix_commands() {
1459        let config = test_config();
1460        let mut ctx = SessionContext::default();
1461
1462        // Dot-prefixed variants
1463        let result = execute_input(".help", &mut ctx, &config, &test_factory())
1464            .await
1465            .unwrap();
1466        assert!(!result);
1467
1468        execute_input(".chain polygon", &mut ctx, &config, &test_factory())
1469            .await
1470            .unwrap();
1471        assert_eq!(ctx.chain, "polygon");
1472
1473        execute_input(".format json", &mut ctx, &config, &test_factory())
1474            .await
1475            .unwrap();
1476        assert_eq!(ctx.format, OutputFormat::Json);
1477
1478        execute_input(".trace", &mut ctx, &config, &test_factory())
1479            .await
1480            .unwrap();
1481        assert!(ctx.trace);
1482
1483        execute_input(".decode", &mut ctx, &config, &test_factory())
1484            .await
1485            .unwrap();
1486        assert!(ctx.decode);
1487    }
1488
1489    #[tokio::test]
1490    async fn test_all_valid_chains() {
1491        let config = test_config();
1492        let valid_chains = [
1493            "ethereum", "polygon", "arbitrum", "optimism", "base", "bsc", "solana", "tron",
1494        ];
1495        for chain in valid_chains {
1496            let mut ctx = SessionContext::default();
1497            execute_input(
1498                &format!("chain {}", chain),
1499                &mut ctx,
1500                &config,
1501                &test_factory(),
1502            )
1503            .await
1504            .unwrap();
1505            assert_eq!(ctx.chain, chain);
1506            assert!(!ctx.is_auto_chain());
1507        }
1508    }
1509
1510    // ========================================================================
1511    // Command dispatch tests (with MockClientFactory)
1512    // ========================================================================
1513
1514    use crate::chains::mocks::MockClientFactory;
1515
1516    fn mock_factory() -> MockClientFactory {
1517        let mut factory = MockClientFactory::new();
1518        factory.mock_client.transactions = vec![factory.mock_client.transaction.clone()];
1519        factory.mock_client.token_balances = vec![crate::chains::TokenBalance {
1520            token: crate::chains::Token {
1521                contract_address: "0xtoken".to_string(),
1522                symbol: "TEST".to_string(),
1523                name: "Test Token".to_string(),
1524                decimals: 18,
1525            },
1526            balance: "1000".to_string(),
1527            formatted_balance: "0.001".to_string(),
1528            usd_value: None,
1529        }];
1530        factory
1531    }
1532
1533    #[tokio::test]
1534    async fn test_address_command_with_args() {
1535        let config = test_config();
1536        let factory = mock_factory();
1537        let mut ctx = SessionContext::default();
1538        let result = execute_input(
1539            "address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1540            &mut ctx,
1541            &config,
1542            &factory,
1543        )
1544        .await;
1545        assert!(result.is_ok());
1546        assert!(!result.unwrap());
1547        assert_eq!(
1548            ctx.last_address,
1549            Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string())
1550        );
1551    }
1552
1553    #[tokio::test]
1554    async fn test_address_command_with_chain_override() {
1555        let config = test_config();
1556        let factory = mock_factory();
1557        let mut ctx = SessionContext::default();
1558        let result = execute_input(
1559            "address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2 --chain=polygon",
1560            &mut ctx,
1561            &config,
1562            &factory,
1563        )
1564        .await;
1565        assert!(result.is_ok());
1566    }
1567
1568    #[tokio::test]
1569    async fn test_address_command_with_tokens_flag() {
1570        let config = test_config();
1571        let factory = mock_factory();
1572        let mut ctx = SessionContext::default();
1573        let result = execute_input(
1574            "address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2 --tokens",
1575            &mut ctx,
1576            &config,
1577            &factory,
1578        )
1579        .await;
1580        assert!(result.is_ok());
1581    }
1582
1583    #[tokio::test]
1584    async fn test_address_command_with_txs_flag() {
1585        let config = test_config();
1586        let factory = mock_factory();
1587        let mut ctx = SessionContext::default();
1588        let result = execute_input(
1589            "address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2 --txs",
1590            &mut ctx,
1591            &config,
1592            &factory,
1593        )
1594        .await;
1595        assert!(result.is_ok());
1596    }
1597
1598    #[tokio::test]
1599    async fn test_address_reuses_last_address() {
1600        let config = test_config();
1601        let factory = mock_factory();
1602        let mut ctx = SessionContext {
1603            last_address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
1604            ..Default::default()
1605        };
1606        let result = execute_input("address", &mut ctx, &config, &factory).await;
1607        assert!(result.is_ok());
1608    }
1609
1610    #[tokio::test]
1611    async fn test_address_auto_detects_solana() {
1612        let config = test_config();
1613        let factory = mock_factory();
1614        let mut ctx = SessionContext::default();
1615        // Solana address format
1616        let result = execute_input(
1617            "address DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy",
1618            &mut ctx,
1619            &config,
1620            &factory,
1621        )
1622        .await;
1623        assert!(result.is_ok());
1624        // Context chain stays "auto" (inferred per command, not stored)
1625        assert_eq!(ctx.chain, "auto");
1626    }
1627
1628    #[tokio::test]
1629    async fn test_tx_command_with_args() {
1630        let config = test_config();
1631        let factory = mock_factory();
1632        let mut ctx = SessionContext::default();
1633        let result = execute_input(
1634            "tx 0xabc123def456789012345678901234567890123456789012345678901234abcd",
1635            &mut ctx,
1636            &config,
1637            &factory,
1638        )
1639        .await;
1640        assert!(result.is_ok());
1641        assert_eq!(
1642            ctx.last_tx,
1643            Some("0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string())
1644        );
1645    }
1646
1647    #[tokio::test]
1648    async fn test_tx_command_with_trace_decode() {
1649        let config = test_config();
1650        let factory = mock_factory();
1651        let mut ctx = SessionContext::default();
1652        let result = execute_input(
1653            "tx 0xabc123def456789012345678901234567890123456789012345678901234abcd --trace --decode",
1654            &mut ctx,
1655            &config,
1656            &factory,
1657        )
1658        .await;
1659        assert!(result.is_ok());
1660    }
1661
1662    #[tokio::test]
1663    async fn test_tx_command_with_chain_override() {
1664        let config = test_config();
1665        let factory = mock_factory();
1666        let mut ctx = SessionContext::default();
1667        let result = execute_input(
1668            "tx 0xabc123def456789012345678901234567890123456789012345678901234abcd --chain=polygon",
1669            &mut ctx,
1670            &config,
1671            &factory,
1672        )
1673        .await;
1674        assert!(result.is_ok());
1675    }
1676
1677    #[tokio::test]
1678    async fn test_tx_reuses_last_tx() {
1679        let config = test_config();
1680        let factory = mock_factory();
1681        let mut ctx = SessionContext {
1682            last_tx: Some(
1683                "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1684            ),
1685            ..Default::default()
1686        };
1687        let result = execute_input("tx", &mut ctx, &config, &factory).await;
1688        assert!(result.is_ok());
1689    }
1690
1691    #[tokio::test]
1692    async fn test_tx_auto_detects_tron() {
1693        let config = test_config();
1694        let factory = mock_factory();
1695        let mut ctx = SessionContext::default();
1696        let result = execute_input(
1697            "tx abc123def456789012345678901234567890123456789012345678901234abcd",
1698            &mut ctx,
1699            &config,
1700            &factory,
1701        )
1702        .await;
1703        assert!(result.is_ok());
1704        // Context chain stays "auto" (inferred per command, not stored)
1705        assert_eq!(ctx.chain, "auto");
1706    }
1707
1708    #[tokio::test]
1709    async fn test_crawl_command_with_args() {
1710        let config = test_config();
1711        let factory = mock_factory();
1712        let mut ctx = SessionContext::default();
1713        let result = execute_input(
1714            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --no-charts",
1715            &mut ctx,
1716            &config,
1717            &factory,
1718        )
1719        .await;
1720        assert!(result.is_ok());
1721    }
1722
1723    #[tokio::test]
1724    async fn test_crawl_command_with_period() {
1725        let config = test_config();
1726        let factory = mock_factory();
1727        let mut ctx = SessionContext::default();
1728        let result = execute_input(
1729            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period=7d --no-charts",
1730            &mut ctx,
1731            &config,
1732            &factory,
1733        )
1734        .await;
1735        assert!(result.is_ok());
1736    }
1737
1738    #[tokio::test]
1739    async fn test_crawl_command_with_chain_flag() {
1740        let config = test_config();
1741        let factory = mock_factory();
1742        let mut ctx = SessionContext::default();
1743        let result = execute_input(
1744            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --chain polygon --no-charts",
1745            &mut ctx,
1746            &config,
1747            &factory,
1748        )
1749        .await;
1750        assert!(result.is_ok());
1751    }
1752
1753    #[tokio::test]
1754    async fn test_crawl_command_with_period_flag() {
1755        let config = test_config();
1756        let factory = mock_factory();
1757        let mut ctx = SessionContext::default();
1758        let result = execute_input(
1759            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period 1h --no-charts",
1760            &mut ctx,
1761            &config,
1762            &factory,
1763        )
1764        .await;
1765        assert!(result.is_ok());
1766    }
1767
1768    #[tokio::test]
1769    async fn test_crawl_command_with_report() {
1770        let config = test_config();
1771        let factory = mock_factory();
1772        let mut ctx = SessionContext::default();
1773        let tmp = tempfile::NamedTempFile::new().unwrap();
1774        let result = execute_input(
1775            &format!(
1776                "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --report {} --no-charts",
1777                tmp.path().display()
1778            ),
1779            &mut ctx,
1780            &config,
1781            &factory,
1782        )
1783        .await;
1784        assert!(result.is_ok());
1785    }
1786
1787    #[tokio::test]
1788    async fn test_portfolio_list_command() {
1789        let tmp_dir = tempfile::tempdir().unwrap();
1790        let config = Config {
1791            address_book: crate::config::AddressBookConfig {
1792                data_dir: Some(tmp_dir.path().to_path_buf()),
1793            },
1794            ..Default::default()
1795        };
1796        let factory = mock_factory();
1797        let mut ctx = SessionContext::default();
1798        let result = execute_input("portfolio list", &mut ctx, &config, &factory).await;
1799        assert!(result.is_ok());
1800    }
1801
1802    #[tokio::test]
1803    async fn test_portfolio_add_command() {
1804        let tmp_dir = tempfile::tempdir().unwrap();
1805        let config = Config {
1806            address_book: crate::config::AddressBookConfig {
1807                data_dir: Some(tmp_dir.path().to_path_buf()),
1808            },
1809            ..Default::default()
1810        };
1811        let factory = mock_factory();
1812        let mut ctx = SessionContext::default();
1813        let result = execute_input(
1814            "portfolio add 0xtest --label mytest",
1815            &mut ctx,
1816            &config,
1817            &factory,
1818        )
1819        .await;
1820        assert!(result.is_ok());
1821    }
1822
1823    #[tokio::test]
1824    async fn test_portfolio_summary_command() {
1825        let tmp_dir = tempfile::tempdir().unwrap();
1826        let config = Config {
1827            address_book: crate::config::AddressBookConfig {
1828                data_dir: Some(tmp_dir.path().to_path_buf()),
1829            },
1830            ..Default::default()
1831        };
1832        let factory = mock_factory();
1833        let mut ctx = SessionContext::default();
1834        // Add first
1835        execute_input("portfolio add 0xtest", &mut ctx, &config, &factory)
1836            .await
1837            .unwrap();
1838        // Then summary
1839        let result = execute_input("portfolio summary", &mut ctx, &config, &factory).await;
1840        assert!(result.is_ok());
1841    }
1842
1843    #[tokio::test]
1844    async fn test_portfolio_remove_command() {
1845        let tmp_dir = tempfile::tempdir().unwrap();
1846        let config = Config {
1847            address_book: crate::config::AddressBookConfig {
1848                data_dir: Some(tmp_dir.path().to_path_buf()),
1849            },
1850            ..Default::default()
1851        };
1852        let factory = mock_factory();
1853        let mut ctx = SessionContext::default();
1854        let result = execute_input("portfolio remove 0xtest", &mut ctx, &config, &factory).await;
1855        assert!(result.is_ok());
1856    }
1857
1858    #[tokio::test]
1859    async fn test_portfolio_no_subcommand() {
1860        let config = test_config();
1861        let factory = mock_factory();
1862        let mut ctx = SessionContext::default();
1863        let result = execute_input("portfolio", &mut ctx, &config, &factory).await;
1864        assert!(result.is_ok());
1865    }
1866
1867    #[tokio::test]
1868    async fn test_portfolio_unknown_subcommand() {
1869        let tmp_dir = tempfile::tempdir().unwrap();
1870        let config = Config {
1871            address_book: crate::config::AddressBookConfig {
1872                data_dir: Some(tmp_dir.path().to_path_buf()),
1873            },
1874            ..Default::default()
1875        };
1876        let factory = mock_factory();
1877        let mut ctx = SessionContext::default();
1878        let result = execute_input("portfolio foobar", &mut ctx, &config, &factory).await;
1879        assert!(result.is_ok());
1880    }
1881
1882    #[tokio::test]
1883    async fn test_tokens_command_list() {
1884        let config = test_config();
1885        let factory = mock_factory();
1886        let mut ctx = SessionContext::default();
1887        let result = execute_input("tokens list", &mut ctx, &config, &factory).await;
1888        assert!(result.is_ok());
1889    }
1890
1891    #[tokio::test]
1892    async fn test_tokens_command_no_args() {
1893        let config = test_config();
1894        let factory = mock_factory();
1895        let mut ctx = SessionContext::default();
1896        let result = execute_input("tokens", &mut ctx, &config, &factory).await;
1897        assert!(result.is_ok());
1898    }
1899
1900    #[tokio::test]
1901    async fn test_tokens_command_recent() {
1902        let config = test_config();
1903        let factory = mock_factory();
1904        let mut ctx = SessionContext::default();
1905        let result = execute_input("tokens recent", &mut ctx, &config, &factory).await;
1906        assert!(result.is_ok());
1907    }
1908
1909    #[tokio::test]
1910    async fn test_tokens_command_remove_no_args() {
1911        let config = test_config();
1912        let factory = mock_factory();
1913        let mut ctx = SessionContext::default();
1914        let result = execute_input("tokens remove", &mut ctx, &config, &factory).await;
1915        assert!(result.is_ok());
1916    }
1917
1918    #[tokio::test]
1919    async fn test_tokens_command_add_no_args() {
1920        let config = test_config();
1921        let factory = mock_factory();
1922        let mut ctx = SessionContext::default();
1923        let result = execute_input("tokens add", &mut ctx, &config, &factory).await;
1924        assert!(result.is_ok());
1925    }
1926
1927    #[tokio::test]
1928    async fn test_tokens_command_unknown() {
1929        let config = test_config();
1930        let factory = mock_factory();
1931        let mut ctx = SessionContext::default();
1932        let result = execute_input("tokens foobar", &mut ctx, &config, &factory).await;
1933        assert!(result.is_ok());
1934    }
1935
1936    #[tokio::test]
1937    async fn test_setup_command_status() {
1938        let config = test_config();
1939        let factory = mock_factory();
1940        let mut ctx = SessionContext::default();
1941        let result = execute_input("setup --status", &mut ctx, &config, &factory).await;
1942        assert!(result.is_ok());
1943    }
1944
1945    #[tokio::test]
1946    async fn test_transaction_alias() {
1947        let config = test_config();
1948        let factory = mock_factory();
1949        let mut ctx = SessionContext::default();
1950        let result = execute_input(
1951            "transaction 0xabc123def456789012345678901234567890123456789012345678901234abcd",
1952            &mut ctx,
1953            &config,
1954            &factory,
1955        )
1956        .await;
1957        assert!(result.is_ok());
1958    }
1959
1960    #[tokio::test]
1961    async fn test_token_alias_for_crawl() {
1962        let config = test_config();
1963        let factory = mock_factory();
1964        let mut ctx = SessionContext::default();
1965        let result = execute_input(
1966            "token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --no-charts",
1967            &mut ctx,
1968            &config,
1969            &factory,
1970        )
1971        .await;
1972        assert!(result.is_ok());
1973    }
1974
1975    #[tokio::test]
1976    async fn test_port_alias_for_portfolio() {
1977        let tmp_dir = tempfile::tempdir().unwrap();
1978        let config = Config {
1979            address_book: crate::config::AddressBookConfig {
1980                data_dir: Some(tmp_dir.path().to_path_buf()),
1981            },
1982            ..Default::default()
1983        };
1984        let factory = mock_factory();
1985        let mut ctx = SessionContext::default();
1986        let result = execute_input("port list", &mut ctx, &config, &factory).await;
1987        assert!(result.is_ok());
1988    }
1989
1990    // ========================================================================
1991    // execute_tokens_command direct tests
1992    // ========================================================================
1993
1994    #[tokio::test]
1995    async fn test_execute_tokens_list_empty() {
1996        let result = execute_tokens_command(&[]).await;
1997        assert!(result.is_ok());
1998    }
1999
2000    #[tokio::test]
2001    async fn test_execute_tokens_list_subcommand() {
2002        let result = execute_tokens_command(&["list"]).await;
2003        assert!(result.is_ok());
2004    }
2005
2006    #[tokio::test]
2007    async fn test_execute_tokens_recent() {
2008        let result = execute_tokens_command(&["recent"]).await;
2009        assert!(result.is_ok());
2010    }
2011
2012    #[tokio::test]
2013    async fn test_execute_tokens_add_insufficient_args() {
2014        let result = execute_tokens_command(&["add"]).await;
2015        assert!(result.is_ok());
2016    }
2017
2018    #[tokio::test]
2019    async fn test_execute_tokens_add_success() {
2020        let result = execute_tokens_command(&[
2021            "add",
2022            "TEST_INTERACTIVE",
2023            "ethereum",
2024            "0xtest123456789",
2025            "Test Token",
2026        ])
2027        .await;
2028        assert!(result.is_ok());
2029        let _ = execute_tokens_command(&["remove", "TEST_INTERACTIVE"]).await;
2030    }
2031
2032    #[tokio::test]
2033    async fn test_execute_tokens_remove_no_args() {
2034        let result = execute_tokens_command(&["remove"]).await;
2035        assert!(result.is_ok());
2036    }
2037
2038    #[tokio::test]
2039    async fn test_execute_tokens_remove_with_symbol() {
2040        let _ =
2041            execute_tokens_command(&["add", "RMTEST", "ethereum", "0xrmtest", "Remove Test"]).await;
2042        let result = execute_tokens_command(&["remove", "RMTEST"]).await;
2043        assert!(result.is_ok());
2044    }
2045
2046    #[tokio::test]
2047    async fn test_execute_tokens_unknown_subcommand() {
2048        let result = execute_tokens_command(&["invalid"]).await;
2049        assert!(result.is_ok());
2050    }
2051
2052    // ========================================================================
2053    // SessionContext additional tests (default and display already exist above)
2054    // ========================================================================
2055
2056    #[test]
2057    fn test_session_context_serialization_roundtrip() {
2058        let ctx = SessionContext {
2059            chain: "solana".to_string(),
2060            include_tokens: true,
2061            limit: 25,
2062            last_address: Some("0xtest".to_string()),
2063            ..Default::default()
2064        };
2065
2066        let yaml = serde_yaml::to_string(&ctx).unwrap();
2067        let deserialized: SessionContext = serde_yaml::from_str(&yaml).unwrap();
2068        assert_eq!(deserialized.chain, "solana");
2069        assert!(deserialized.include_tokens);
2070        assert_eq!(deserialized.limit, 25);
2071        assert_eq!(deserialized.last_address, Some("0xtest".to_string()));
2072    }
2073
2074    // ========================================================================
2075    // Tests for previously uncovered execute_input branches
2076    // ========================================================================
2077
2078    #[tokio::test]
2079    async fn test_chain_show_explicit() {
2080        let config = test_config();
2081        let factory = test_factory();
2082        let mut context = SessionContext {
2083            chain: "polygon".to_string(),
2084            ..Default::default()
2085        };
2086
2087        // Just showing chain status when chain is pinned
2088        let result = execute_input("chain", &mut context, &config, &factory).await;
2089        assert!(result.is_ok());
2090        assert!(!result.unwrap()); // Should not exit
2091    }
2092
2093    #[tokio::test]
2094    async fn test_address_with_explicit_chain() {
2095        let config = test_config();
2096        let factory = mock_factory();
2097        let mut context = SessionContext {
2098            chain: "polygon".to_string(),
2099            ..Default::default()
2100        };
2101
2102        // Address command with explicit chain — should use context.chain directly
2103        let result = execute_input(
2104            "address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2105            &mut context,
2106            &config,
2107            &factory,
2108        )
2109        .await;
2110        // May fail due to network but should not panic
2111        assert!(result.is_ok() || result.is_err());
2112    }
2113
2114    #[tokio::test]
2115    async fn test_tx_with_explicit_chain() {
2116        let config = test_config();
2117        let factory = mock_factory();
2118        let mut context = SessionContext {
2119            chain: "polygon".to_string(),
2120            ..Default::default()
2121        };
2122
2123        // TX command with explicit chain — should use context.chain
2124        let result = execute_input("tx 0xabc123def456789", &mut context, &config, &factory).await;
2125        assert!(result.is_ok() || result.is_err());
2126    }
2127
2128    #[tokio::test]
2129    async fn test_crawl_with_period_eq_flag() {
2130        let config = test_config();
2131        let factory = test_factory();
2132        let mut context = SessionContext::default();
2133
2134        // crawl with --period=7d syntax
2135        let result = execute_input(
2136            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period=7d",
2137            &mut context,
2138            &config,
2139            &factory,
2140        )
2141        .await;
2142        // Will attempt network call, may succeed or fail
2143        assert!(result.is_ok() || result.is_err());
2144    }
2145
2146    #[tokio::test]
2147    async fn test_crawl_with_period_space_flag() {
2148        let config = test_config();
2149        let factory = test_factory();
2150        let mut context = SessionContext::default();
2151
2152        // crawl with --period 1h syntax (space-separated)
2153        let result = execute_input(
2154            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period 1h",
2155            &mut context,
2156            &config,
2157            &factory,
2158        )
2159        .await;
2160        assert!(result.is_ok() || result.is_err());
2161    }
2162
2163    #[tokio::test]
2164    async fn test_crawl_with_chain_eq_flag() {
2165        let config = test_config();
2166        let factory = test_factory();
2167        let mut context = SessionContext::default();
2168
2169        // crawl with --chain=polygon syntax
2170        let result = execute_input(
2171            "crawl 0xAddress --chain=polygon",
2172            &mut context,
2173            &config,
2174            &factory,
2175        )
2176        .await;
2177        assert!(result.is_ok() || result.is_err());
2178    }
2179
2180    #[tokio::test]
2181    async fn test_crawl_with_chain_space_flag() {
2182        let config = test_config();
2183        let factory = test_factory();
2184        let mut context = SessionContext::default();
2185
2186        // crawl with --chain polygon syntax
2187        let result = execute_input(
2188            "crawl 0xAddress --chain polygon",
2189            &mut context,
2190            &config,
2191            &factory,
2192        )
2193        .await;
2194        assert!(result.is_ok() || result.is_err());
2195    }
2196
2197    #[tokio::test]
2198    async fn test_crawl_with_report_flag() {
2199        let config = test_config();
2200        let factory = test_factory();
2201        let mut context = SessionContext::default();
2202
2203        let tmp = tempfile::NamedTempFile::new().unwrap();
2204        let path = tmp.path().to_string_lossy();
2205        let input = format!(
2206            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --report={}",
2207            path
2208        );
2209        let result = execute_input(&input, &mut context, &config, &factory).await;
2210        assert!(result.is_ok() || result.is_err());
2211    }
2212
2213    #[tokio::test]
2214    async fn test_crawl_with_no_charts_flag() {
2215        let config = test_config();
2216        let factory = test_factory();
2217        let mut context = SessionContext::default();
2218
2219        let result = execute_input(
2220            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --no-charts",
2221            &mut context,
2222            &config,
2223            &factory,
2224        )
2225        .await;
2226        assert!(result.is_ok() || result.is_err());
2227    }
2228
2229    #[tokio::test]
2230    async fn test_crawl_with_explicit_chain() {
2231        let config = test_config();
2232        let factory = test_factory();
2233        let mut context = SessionContext {
2234            chain: "arbitrum".to_string(),
2235            ..Default::default()
2236        };
2237
2238        let result = execute_input("crawl 0xAddress", &mut context, &config, &factory).await;
2239        assert!(result.is_ok() || result.is_err());
2240    }
2241
2242    #[tokio::test]
2243    async fn test_portfolio_add_with_label_and_tags() {
2244        let tmp_dir = tempfile::tempdir().unwrap();
2245        let config = Config {
2246            address_book: crate::config::AddressBookConfig {
2247                data_dir: Some(tmp_dir.path().to_path_buf()),
2248            },
2249            ..Default::default()
2250        };
2251        let factory = mock_factory();
2252        let mut context = SessionContext::default();
2253
2254        let result = execute_input(
2255            "portfolio add 0xAbC123 --label MyWallet --tags defi,staking",
2256            &mut context,
2257            &config,
2258            &factory,
2259        )
2260        .await;
2261        assert!(result.is_ok());
2262    }
2263
2264    #[tokio::test]
2265    async fn test_portfolio_remove_no_args() {
2266        let tmp_dir = tempfile::tempdir().unwrap();
2267        let config = Config {
2268            address_book: crate::config::AddressBookConfig {
2269                data_dir: Some(tmp_dir.path().to_path_buf()),
2270            },
2271            ..Default::default()
2272        };
2273        let factory = mock_factory();
2274        let mut context = SessionContext::default();
2275
2276        let result = execute_input("portfolio remove", &mut context, &config, &factory).await;
2277        assert!(result.is_ok());
2278    }
2279
2280    #[tokio::test]
2281    async fn test_portfolio_summary_with_chain_and_tag() {
2282        let tmp_dir = tempfile::tempdir().unwrap();
2283        let config = Config {
2284            address_book: crate::config::AddressBookConfig {
2285                data_dir: Some(tmp_dir.path().to_path_buf()),
2286            },
2287            ..Default::default()
2288        };
2289        let factory = mock_factory();
2290        let mut context = SessionContext::default();
2291
2292        let result = execute_input(
2293            "portfolio summary --chain ethereum --tag defi --tokens",
2294            &mut context,
2295            &config,
2296            &factory,
2297        )
2298        .await;
2299        assert!(result.is_ok());
2300    }
2301
2302    #[tokio::test]
2303    async fn test_tokens_add_with_name() {
2304        let result = execute_tokens_command(&[
2305            "add",
2306            "USDC",
2307            "ethereum",
2308            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2309            "USD",
2310            "Coin",
2311        ])
2312        .await;
2313        assert!(result.is_ok());
2314    }
2315
2316    #[tokio::test]
2317    async fn test_tokens_remove_with_chain() {
2318        let result = execute_tokens_command(&["remove", "USDC", "--chain", "ethereum"]).await;
2319        assert!(result.is_ok());
2320    }
2321
2322    #[tokio::test]
2323    async fn test_tokens_add_then_list_nonempty() {
2324        // Add a token first
2325        let _ = execute_tokens_command(&[
2326            "add",
2327            "TEST_TOKEN_XYZ",
2328            "ethereum",
2329            "0x1234567890abcdef1234567890abcdef12345678",
2330            "Test",
2331            "Token",
2332        ])
2333        .await;
2334
2335        // Now list should show it
2336        let result = execute_tokens_command(&["list"]).await;
2337        assert!(result.is_ok());
2338
2339        // And recent should show it
2340        let result = execute_tokens_command(&["recent"]).await;
2341        assert!(result.is_ok());
2342
2343        // Clean up
2344        let _ = execute_tokens_command(&["remove", "TEST_TOKEN_XYZ"]).await;
2345    }
2346
2347    #[tokio::test]
2348    async fn test_session_context_save_and_load() {
2349        // SessionContext::save() and ::load() use dirs::data_dir()
2350        // We just verify they don't panic
2351        let ctx = SessionContext {
2352            chain: "solana".to_string(),
2353            last_address: Some("0xabc".to_string()),
2354            last_tx: Some("0xdef".to_string()),
2355            ..Default::default()
2356        };
2357        // save may fail if data dir doesn't exist, but should not panic
2358        let _ = ctx.save();
2359        // load should return default or saved data
2360        let loaded = SessionContext::load();
2361        // At least the struct is valid
2362        assert!(!loaded.chain.is_empty());
2363    }
2364
2365    // ========================================================================
2366    // Command alias coverage
2367    // ========================================================================
2368
2369    #[tokio::test]
2370    async fn test_help_alias_question_mark() {
2371        let config = test_config();
2372        let mut ctx = SessionContext::default();
2373        let result = execute_input("?", &mut ctx, &config, &test_factory())
2374            .await
2375            .unwrap();
2376        assert!(!result);
2377    }
2378
2379    #[tokio::test]
2380    async fn test_context_alias() {
2381        let config = test_config();
2382        let mut ctx = SessionContext::default();
2383        let result = execute_input("context", &mut ctx, &config, &test_factory())
2384            .await
2385            .unwrap();
2386        assert!(!result);
2387    }
2388
2389    #[tokio::test]
2390    async fn test_dot_context_alias() {
2391        let config = test_config();
2392        let mut ctx = SessionContext::default();
2393        let result = execute_input(".context", &mut ctx, &config, &test_factory())
2394            .await
2395            .unwrap();
2396        assert!(!result);
2397    }
2398
2399    #[tokio::test]
2400    async fn test_reset_alias() {
2401        let config = test_config();
2402        let mut ctx = SessionContext {
2403            chain: "ethereum".to_string(),
2404            ..Default::default()
2405        };
2406        execute_input("reset", &mut ctx, &config, &test_factory())
2407            .await
2408            .unwrap();
2409        assert_eq!(ctx.chain, "auto");
2410    }
2411
2412    #[tokio::test]
2413    async fn test_dot_reset_alias() {
2414        let config = test_config();
2415        let mut ctx = SessionContext {
2416            chain: "base".to_string(),
2417            ..Default::default()
2418        };
2419        execute_input(".reset", &mut ctx, &config, &test_factory())
2420            .await
2421            .unwrap();
2422        assert_eq!(ctx.chain, "auto");
2423    }
2424
2425    #[tokio::test]
2426    async fn test_dot_clear_alias() {
2427        let config = test_config();
2428        let mut ctx = SessionContext {
2429            chain: "bsc".to_string(),
2430            ..Default::default()
2431        };
2432        execute_input(".clear", &mut ctx, &config, &test_factory())
2433            .await
2434            .unwrap();
2435        assert_eq!(ctx.chain, "auto");
2436    }
2437
2438    #[tokio::test]
2439    async fn test_showtokens_alias() {
2440        let config = test_config();
2441        let mut ctx = SessionContext::default();
2442        execute_input("showtokens", &mut ctx, &config, &test_factory())
2443            .await
2444            .unwrap();
2445        assert!(ctx.include_tokens);
2446    }
2447
2448    #[tokio::test]
2449    async fn test_showtxs_alias() {
2450        let config = test_config();
2451        let mut ctx = SessionContext::default();
2452        execute_input("showtxs", &mut ctx, &config, &test_factory())
2453            .await
2454            .unwrap();
2455        assert!(ctx.include_txs);
2456    }
2457
2458    #[tokio::test]
2459    async fn test_txs_alias() {
2460        let config = test_config();
2461        let mut ctx = SessionContext::default();
2462        execute_input("txs", &mut ctx, &config, &test_factory())
2463            .await
2464            .unwrap();
2465        assert!(ctx.include_txs);
2466    }
2467
2468    #[tokio::test]
2469    async fn test_dot_txs_alias() {
2470        let config = test_config();
2471        let mut ctx = SessionContext::default();
2472        execute_input(".txs", &mut ctx, &config, &test_factory())
2473            .await
2474            .unwrap();
2475        assert!(ctx.include_txs);
2476    }
2477
2478    #[tokio::test]
2479    async fn test_addr_alias() {
2480        let config = test_config();
2481        let factory = mock_factory();
2482        let mut ctx = SessionContext::default();
2483        let result = execute_input(
2484            "addr 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2485            &mut ctx,
2486            &config,
2487            &factory,
2488        )
2489        .await;
2490        assert!(result.is_ok());
2491        assert_eq!(
2492            ctx.last_address,
2493            Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string())
2494        );
2495    }
2496
2497    #[test]
2498    fn test_session_context_is_auto_chain() {
2499        let auto_ctx = SessionContext::default();
2500        assert!(auto_ctx.is_auto_chain());
2501        let pinned_ctx = SessionContext {
2502            chain: "ethereum".to_string(),
2503            ..Default::default()
2504        };
2505        assert!(!pinned_ctx.is_auto_chain());
2506    }
2507
2508    #[test]
2509    fn test_print_help_no_panic() {
2510        print_help();
2511    }
2512
2513    // ========================================================================
2514    // Contract command tests
2515    // ========================================================================
2516
2517    #[tokio::test]
2518    async fn test_contract_no_args() {
2519        let config = test_config();
2520        let factory = mock_factory();
2521        let mut ctx = SessionContext::default();
2522        let result = execute_input("contract", &mut ctx, &config, &factory).await;
2523        assert!(result.is_ok());
2524        assert!(!result.unwrap());
2525    }
2526
2527    #[tokio::test]
2528    async fn test_contract_ct_alias_with_args() {
2529        let config = test_config();
2530        let factory = mock_factory();
2531        let mut ctx = SessionContext::default();
2532        let result = execute_input(
2533            "ct 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2534            &mut ctx,
2535            &config,
2536            &factory,
2537        )
2538        .await;
2539        if let Ok(should_exit) = result {
2540            assert!(!should_exit);
2541        }
2542    }
2543
2544    #[tokio::test]
2545    async fn test_contract_with_chain_and_json() {
2546        let config = test_config();
2547        let factory = mock_factory();
2548        let mut ctx = SessionContext::default();
2549        let result = execute_input(
2550            "contract 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2 --chain=polygon --json",
2551            &mut ctx,
2552            &config,
2553            &factory,
2554        )
2555        .await;
2556        if let Ok(should_exit) = result {
2557            assert!(!should_exit);
2558        }
2559    }
2560
2561    // ========================================================================
2562    // address-book and address_book aliases
2563    // ========================================================================
2564
2565    #[tokio::test]
2566    async fn test_address_book_list_command() {
2567        let tmp_dir = tempfile::tempdir().unwrap();
2568        let config = Config {
2569            address_book: crate::config::AddressBookConfig {
2570                data_dir: Some(tmp_dir.path().to_path_buf()),
2571            },
2572            ..Default::default()
2573        };
2574        let factory = mock_factory();
2575        let mut ctx = SessionContext::default();
2576        let result = execute_input("address-book list", &mut ctx, &config, &factory).await;
2577        assert!(result.is_ok());
2578    }
2579
2580    #[tokio::test]
2581    async fn test_address_book_underscore_list() {
2582        let tmp_dir = tempfile::tempdir().unwrap();
2583        let config = Config {
2584            address_book: crate::config::AddressBookConfig {
2585                data_dir: Some(tmp_dir.path().to_path_buf()),
2586            },
2587            ..Default::default()
2588        };
2589        let factory = mock_factory();
2590        let mut ctx = SessionContext::default();
2591        let result = execute_input("address_book list", &mut ctx, &config, &factory).await;
2592        assert!(result.is_ok());
2593    }
2594
2595    #[tokio::test]
2596    async fn test_address_book_add_insufficient_args() {
2597        let tmp_dir = tempfile::tempdir().unwrap();
2598        let config = Config {
2599            address_book: crate::config::AddressBookConfig {
2600                data_dir: Some(tmp_dir.path().to_path_buf()),
2601            },
2602            ..Default::default()
2603        };
2604        let factory = mock_factory();
2605        let mut ctx = SessionContext::default();
2606        let result = execute_input("address-book add", &mut ctx, &config, &factory).await;
2607        assert!(result.is_ok());
2608    }
2609
2610    #[tokio::test]
2611    async fn test_address_book_remove_insufficient_args() {
2612        let tmp_dir = tempfile::tempdir().unwrap();
2613        let config = Config {
2614            address_book: crate::config::AddressBookConfig {
2615                data_dir: Some(tmp_dir.path().to_path_buf()),
2616            },
2617            ..Default::default()
2618        };
2619        let factory = mock_factory();
2620        let mut ctx = SessionContext::default();
2621        let result = execute_input("address-book remove", &mut ctx, &config, &factory).await;
2622        assert!(result.is_ok());
2623    }
2624
2625    #[tokio::test]
2626    async fn test_address_book_empty_subcommand() {
2627        let tmp_dir = tempfile::tempdir().unwrap();
2628        let config = Config {
2629            address_book: crate::config::AddressBookConfig {
2630                data_dir: Some(tmp_dir.path().to_path_buf()),
2631            },
2632            ..Default::default()
2633        };
2634        let factory = mock_factory();
2635        let mut ctx = SessionContext::default();
2636        let result = execute_input("address-book", &mut ctx, &config, &factory).await;
2637        assert!(result.is_ok());
2638    }
2639
2640    // ========================================================================
2641    // aliases, config, monitor commands
2642    // ========================================================================
2643
2644    #[tokio::test]
2645    async fn test_aliases_command() {
2646        let config = test_config();
2647        let factory = mock_factory();
2648        let mut ctx = SessionContext::default();
2649        let result = execute_input("aliases", &mut ctx, &config, &factory).await;
2650        assert!(result.is_ok());
2651    }
2652
2653    #[tokio::test]
2654    async fn test_config_alias() {
2655        let config = test_config();
2656        let factory = mock_factory();
2657        let mut ctx = SessionContext::default();
2658        let result = execute_input("config --status", &mut ctx, &config, &factory).await;
2659        assert!(result.is_ok());
2660    }
2661
2662    #[tokio::test]
2663    #[ignore = "setup --key prompts for API key input on stdin"]
2664    async fn test_setup_with_key_flag() {
2665        let config = test_config();
2666        let factory = mock_factory();
2667        let mut ctx = SessionContext::default();
2668        let result = execute_input("setup --key=etherscan", &mut ctx, &config, &factory).await;
2669        assert!(result.is_ok());
2670    }
2671
2672    #[tokio::test]
2673    async fn test_setup_with_key_short_flag() {
2674        let config = test_config();
2675        let factory = mock_factory();
2676        let mut ctx = SessionContext::default();
2677        let result = execute_input("setup -s", &mut ctx, &config, &factory).await;
2678        assert!(result.is_ok());
2679    }
2680
2681    // Note: setup --reset is not tested here; it prompts for stdin confirmation
2682    // and can block. See setup::tests::test_reset_config_impl_* for reset coverage.
2683
2684    #[tokio::test]
2685    #[ignore = "monitor starts TUI and blocks until exit"]
2686    async fn test_monitor_command_no_token() {
2687        let config = test_config();
2688        let factory = mock_factory();
2689        let mut ctx = SessionContext::default();
2690        let result = execute_input("monitor", &mut ctx, &config, &factory).await;
2691        assert!(result.is_ok() || result.is_err());
2692    }
2693
2694    #[tokio::test]
2695    #[ignore = "monitor starts TUI and blocks until exit"]
2696    async fn test_mon_alias() {
2697        let config = test_config();
2698        let factory = mock_factory();
2699        let mut ctx = SessionContext::default();
2700        let result = execute_input(
2701            "mon 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2702            &mut ctx,
2703            &config,
2704            &factory,
2705        )
2706        .await;
2707        assert!(result.is_ok() || result.is_err());
2708    }
2709
2710    // ========================================================================
2711    // tokens ls alias and crawl period variants
2712    // ========================================================================
2713
2714    #[tokio::test]
2715    async fn test_tokens_ls_alias() {
2716        let config = test_config();
2717        let factory = mock_factory();
2718        let mut ctx = SessionContext::default();
2719        let result = execute_input("tokens ls", &mut ctx, &config, &factory).await;
2720        assert!(result.is_ok());
2721    }
2722
2723    #[tokio::test]
2724    async fn test_execute_tokens_ls_alias() {
2725        let result = execute_tokens_command(&["ls"]).await;
2726        assert!(result.is_ok());
2727    }
2728
2729    #[tokio::test]
2730    async fn test_crawl_period_1h() {
2731        let config = test_config();
2732        let factory = mock_factory();
2733        let mut ctx = SessionContext::default();
2734        let result = execute_input(
2735            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period=1h --no-charts",
2736            &mut ctx,
2737            &config,
2738            &factory,
2739        )
2740        .await;
2741        assert!(result.is_ok());
2742    }
2743
2744    #[tokio::test]
2745    async fn test_crawl_period_30d() {
2746        let config = test_config();
2747        let factory = mock_factory();
2748        let mut ctx = SessionContext::default();
2749        let result = execute_input(
2750            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period=30d --no-charts",
2751            &mut ctx,
2752            &config,
2753            &factory,
2754        )
2755        .await;
2756        assert!(result.is_ok());
2757    }
2758
2759    #[tokio::test]
2760    async fn test_crawl_invalid_period_defaults() {
2761        let config = test_config();
2762        let factory = mock_factory();
2763        let mut ctx = SessionContext::default();
2764        let result = execute_input(
2765            "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period=invalid --no-charts",
2766            &mut ctx,
2767            &config,
2768            &factory,
2769        )
2770        .await;
2771        assert!(result.is_ok());
2772    }
2773
2774    #[tokio::test]
2775    async fn test_tokens_add_three_args_insufficient() {
2776        let result = execute_tokens_command(&["add", "SYM", "ethereum"]).await;
2777        assert!(result.is_ok());
2778    }
2779
2780    #[tokio::test]
2781    async fn test_format_show_when_csv() {
2782        let config = test_config();
2783        let mut ctx = SessionContext {
2784            format: OutputFormat::Csv,
2785            ..Default::default()
2786        };
2787        let result = execute_input("format", &mut ctx, &config, &test_factory())
2788            .await
2789            .unwrap();
2790        assert!(!result);
2791    }
2792}