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