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