1use 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#[derive(Debug, Clone, Args)]
34pub struct InteractiveArgs {
35 #[arg(long)]
37 pub no_banner: bool,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SessionContext {
43 pub chain: String,
45
46 #[serde(default)]
48 pub chain_explicit: bool,
49
50 pub format: OutputFormat,
52
53 pub last_address: Option<String>,
55
56 pub last_tx: Option<String>,
58
59 pub include_tokens: bool,
61
62 pub include_txs: bool,
64
65 pub trace: bool,
67
68 pub decode: bool,
70
71 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 fn context_path() -> Option<PathBuf> {
116 dirs::data_dir().map(|p| p.join("scope").join("session.yaml"))
117 }
118
119 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 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
141pub async fn run(
143 args: InteractiveArgs,
144 config: &Config,
145 clients: &dyn ChainClientFactory,
146) -> Result<()> {
147 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 let mut context = SessionContext::load();
158
159 if context.chain == "ethereum" && context.format == OutputFormat::Table {
161 context.format = config.output.format;
162 }
163
164 let mut rl = DefaultEditor::new().map_err(|e| {
166 crate::error::ScopeError::Chain(format!("Failed to initialize readline: {}", e))
167 })?;
168
169 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 let _ = rl.add_history_entry(line);
187
188 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 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 if let Err(e) = context.save() {
225 tracing::warn!("Failed to save session context: {}", e);
226 }
227
228 println!("Goodbye!");
229 Ok(())
230}
231
232async 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" | "quit" | ".exit" | ".quit" | "q" => {
250 return Ok(true);
251 }
252
253 "help" | "?" | ".help" => {
255 print_help();
256 }
257
258 "ctx" | "context" | ".ctx" | ".context" => {
260 println!("{}", context);
261 }
262
263 "clear" | ".clear" | "reset" | ".reset" => {
265 *context = SessionContext::default();
266 context.format = config.output.format;
267 println!("Context reset to defaults.");
268 }
269
270 "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 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 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 "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 "+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 "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" | "addr" => {
370 let addr = if args.is_empty() {
371 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 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 let effective_chain = if let Some(chain) = chain_override {
393 chain
394 } else if !context.chain_explicit {
395 if let Some(inferred) = crate::chains::infer_chain_from_address(&addr) {
397 if inferred != context.chain {
398 println!("Auto-detected chain: {}", inferred);
399 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 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 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 context.last_address = Some(addr);
433
434 address::run(address_args, config, clients).await?;
436 }
437
438 "tx" | "transaction" => {
440 let hash = if args.is_empty() {
441 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 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 let effective_chain = if let Some(chain) = chain_override {
463 chain
464 } else if !context.chain_explicit {
465 if let Some(inferred) = crate::chains::infer_chain_from_hash(&hash) {
467 if inferred != context.chain {
468 println!("Auto-detected chain: {}", inferred);
469 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 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 context.last_tx = Some(hash);
499
500 tx::run(tx_args, config, clients).await?;
502 }
503
504 "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 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 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, save: false, };
587
588 crawl::run(crawl_args, config, clients).await?;
589 }
590
591 "portfolio" | "port" => {
593 let portfolio_input = args.join(" ");
595 execute_portfolio(&portfolio_input, context, config, clients).await?;
596 }
597
598 "tokens" | "aliases" => {
600 execute_tokens_command(args).await?;
601 }
602
603 "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 "monitor" | "mon" => {
624 let token = args.first().map(|s| s.to_string());
625 monitor::run(token, context, config, clients).await?;
626 }
627
628 _ => {
630 eprintln!(
631 "Unknown command: {}. Type 'help' for available commands.",
632 command
633 );
634 }
635 }
636
637 Ok(false)
638}
639
640async 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 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
764async 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
876fn 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#[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 let result = execute_input(
1574 "address DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy",
1575 &mut ctx,
1576 &config,
1577 &factory,
1578 )
1579 .await;
1580 assert!(result.is_ok());
1581 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 execute_input("portfolio add 0xtest", &mut ctx, &config, &factory)
1792 .await
1793 .unwrap();
1794 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 #[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 #[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 #[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 let result = execute_input("chain", &mut context, &config, &factory).await;
2046 assert!(result.is_ok());
2047 assert!(!result.unwrap()); }
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 let result = execute_input(
2062 "address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2063 &mut context,
2064 &config,
2065 &factory,
2066 )
2067 .await;
2068 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 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 let result = execute_input(
2095 "crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --period=7d",
2096 &mut context,
2097 &config,
2098 &factory,
2099 )
2100 .await;
2101 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 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 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 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 let _ = execute_tokens_command(&[
2286 "add",
2287 "TEST_TOKEN_XYZ",
2288 "ethereum",
2289 "0x1234567890abcdef1234567890abcdef12345678",
2290 "Test",
2291 "Token",
2292 ])
2293 .await;
2294
2295 let result = execute_tokens_command(&["list"]).await;
2297 assert!(result.is_ok());
2298
2299 let result = execute_tokens_command(&["recent"]).await;
2301 assert!(result.is_ok());
2302
2303 let _ = execute_tokens_command(&["remove", "TEST_TOKEN_XYZ"]).await;
2305 }
2306
2307 #[tokio::test]
2308 async fn test_session_context_save_and_load() {
2309 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 let _ = ctx.save();
2319 let loaded = SessionContext::load();
2321 assert!(!loaded.chain.is_empty());
2323 }
2324}