Skip to main content

scope/cli/
interactive.rs

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