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