1pub mod address;
58pub mod address_book;
59pub mod address_report;
60pub mod compliance;
61pub mod contract;
62pub mod crawl;
63pub mod discover;
64pub mod errors;
65pub mod export;
66pub mod health_args;
67pub mod insights;
68pub mod interactive;
69pub mod market;
70pub mod monitor;
71pub mod progress;
72pub mod report;
73pub mod setup;
74pub mod token_health;
75pub mod tx;
76pub mod venues;
77
78use clap::{Parser, Subcommand};
79use std::path::PathBuf;
80
81pub use address::AddressArgs;
82pub use address_book::AddressBookArgs;
83pub use crawl::CrawlArgs;
84pub use export::ExportArgs;
85pub use interactive::InteractiveArgs;
86pub use monitor::MonitorArgs;
87pub use setup::SetupArgs;
88pub use tx::TxArgs;
89
90#[derive(Debug, Parser)]
96#[command(
97 name = "scope",
98 version,
99 about = "Scope Blockchain Analysis - A tool for blockchain data analysis",
100 long_about = format!(
101 "Scope Blockchain Analysis v{}\n\n\
102 A production-grade tool for blockchain data analysis, address management,\n\
103 and transaction investigation.\n\n\
104 Use --help with any subcommand for detailed usage information.",
105 env!("CARGO_PKG_VERSION")
106 ),
107 after_help = "\x1b[1mExamples:\x1b[0m\n \
108 scope address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2\n \
109 scope address @main-wallet \x1b[2m# address book shortcut\x1b[0m\n \
110 scope contract 0xdAC17F958D2ee523a2206206994597C13D831ec7\n \
111 scope crawl USDC --chain ethereum\n \
112 scope insights 0xabc123...\n \
113 scope monitor USDC\n \
114 scope compliance risk 0x742d...\n \
115 scope setup\n\n\
116 \x1b[2mTip: Use @label in any command to recall an address from the address book.\x1b[0m\n\n\
117 \x1b[1mDocumentation:\x1b[0m\n \
118 https://github.com/robot-accomplice/scope-blockchain-analysis\n \
119 Quickstart guide: docs/QUICKSTART.md"
120)]
121pub struct Cli {
122 #[command(subcommand)]
124 pub command: Commands,
125
126 #[arg(long, global = true, value_name = "PATH")]
130 pub config: Option<PathBuf>,
131
132 #[arg(short, long, global = true, action = clap::ArgAction::Count)]
139 pub verbose: u8,
140
141 #[arg(long, global = true)]
143 pub no_color: bool,
144
145 #[arg(long, global = true)]
150 pub ai: bool,
151}
152
153#[derive(Debug, Subcommand)]
155#[allow(clippy::large_enum_variant)]
156pub enum Commands {
157 #[command(visible_alias = "addr")]
163 Address(AddressArgs),
164
165 #[command(visible_alias = "transaction")]
170 Tx(TxArgs),
171
172 #[command(visible_alias = "insight")]
177 Insights(insights::InsightsArgs),
178
179 #[command(visible_alias = "ct")]
185 Contract(contract::ContractArgs),
186
187 #[command(visible_alias = "token")]
194 Crawl(CrawlArgs),
195
196 #[command(visible_alias = "health")]
201 TokenHealth(token_health::TokenHealthArgs),
202
203 #[command(visible_alias = "disc")]
208 Discover(discover::DiscoverArgs),
209
210 #[command(visible_alias = "mon")]
215 Monitor(MonitorArgs),
216
217 #[command(subcommand)]
222 Market(market::MarketCommands),
223
224 #[command(subcommand, visible_alias = "ven")]
229 Venues(venues::VenuesCommands),
230
231 #[command(subcommand)]
237 Compliance(compliance::ComplianceCommands),
238
239 #[command(visible_alias = "ab", alias = "portfolio", alias = "port")]
246 AddressBook(AddressBookArgs),
247
248 Export(ExportArgs),
253
254 #[command(subcommand)]
256 Report(report::ReportCommands),
257
258 #[command(visible_alias = "shell")]
264 Interactive(InteractiveArgs),
265
266 #[command(visible_alias = "config")]
271 Setup(SetupArgs),
272
273 Completions(CompletionsArgs),
278
279 #[command(visible_alias = "serve")]
285 Web(WebArgs),
286}
287
288impl Commands {
289 pub fn is_interactive(&self) -> bool {
293 matches!(
294 self,
295 Commands::Interactive(_)
296 | Commands::Monitor(_)
297 | Commands::Setup(_)
298 | Commands::Completions(_)
299 | Commands::Web(_)
300 )
301 }
302}
303
304#[derive(Debug, Clone, clap::Args)]
306#[command(after_help = "\x1b[1mExamples:\x1b[0m
307 scope web
308 scope web --port 3000 --bind 0.0.0.0
309 scope serve --daemon
310 scope web --stop")]
311pub struct WebArgs {
312 #[arg(long, short, default_value = "8080")]
314 pub port: u16,
315
316 #[arg(long, default_value = "127.0.0.1")]
320 pub bind: String,
321
322 #[arg(long, short)]
324 pub daemon: bool,
325
326 #[arg(long)]
328 pub stop: bool,
329}
330
331#[derive(Debug, Clone, clap::Args)]
333#[command(after_help = "\x1b[1mExamples:\x1b[0m
334 scope completions bash >> ~/.bashrc
335 scope completions zsh > ~/.zfunc/_scope
336 scope completions fish > ~/.config/fish/completions/scope.fish")]
337pub struct CompletionsArgs {
338 #[arg(value_enum)]
340 pub shell: clap_complete::Shell,
341}
342
343impl Cli {
344 pub fn parse_args() -> Self {
348 Self::parse()
349 }
350
351 pub fn log_level(&self) -> tracing::Level {
359 match self.verbose {
360 0 => tracing::Level::WARN,
361 1 => tracing::Level::INFO,
362 2 => tracing::Level::DEBUG,
363 _ => tracing::Level::TRACE,
364 }
365 }
366}
367
368#[cfg(test)]
373mod tests {
374 use super::*;
375 use clap::Parser;
376
377 #[test]
378 fn test_cli_parse_address_command() {
379 let cli = Cli::try_parse_from([
380 "scope",
381 "address",
382 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
383 ])
384 .unwrap();
385
386 assert!(matches!(cli.command, Commands::Address(_)));
387 assert!(cli.config.is_none());
388 assert_eq!(cli.verbose, 0);
389 }
390
391 #[test]
392 fn test_cli_parse_address_alias() {
393 let cli = Cli::try_parse_from([
394 "scope",
395 "addr",
396 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
397 ])
398 .unwrap();
399
400 assert!(matches!(cli.command, Commands::Address(_)));
401 }
402
403 #[test]
404 fn test_cli_parse_tx_command() {
405 let cli = Cli::try_parse_from([
406 "scope",
407 "tx",
408 "0xabc123def456789012345678901234567890123456789012345678901234abcd",
409 ])
410 .unwrap();
411
412 assert!(matches!(cli.command, Commands::Tx(_)));
413 }
414
415 #[test]
416 fn test_cli_parse_tx_alias() {
417 let cli = Cli::try_parse_from([
418 "scope",
419 "transaction",
420 "0xabc123def456789012345678901234567890123456789012345678901234abcd",
421 ])
422 .unwrap();
423
424 assert!(matches!(cli.command, Commands::Tx(_)));
425 }
426
427 #[test]
428 fn test_cli_parse_address_book_command() {
429 let cli = Cli::try_parse_from(["scope", "address-book", "list"]).unwrap();
430
431 assert!(matches!(cli.command, Commands::AddressBook(_)));
432 }
433
434 #[test]
435 fn test_cli_parse_address_book_portfolio_alias() {
436 let cli = Cli::try_parse_from(["scope", "portfolio", "list"]).unwrap();
437
438 assert!(matches!(cli.command, Commands::AddressBook(_)));
439 }
440
441 #[test]
442 fn test_cli_parse_export_command() {
443 let cli = Cli::try_parse_from([
444 "scope",
445 "export",
446 "--address",
447 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
448 "--output",
449 "data.json",
450 ])
451 .unwrap();
452
453 assert!(matches!(cli.command, Commands::Export(_)));
454 }
455
456 #[test]
457 fn test_cli_parse_interactive_command() {
458 let cli = Cli::try_parse_from(["scope", "interactive"]).unwrap();
459
460 assert!(matches!(cli.command, Commands::Interactive(_)));
461 }
462
463 #[test]
464 fn test_cli_parse_interactive_alias() {
465 let cli = Cli::try_parse_from(["scope", "shell"]).unwrap();
466
467 assert!(matches!(cli.command, Commands::Interactive(_)));
468 }
469
470 #[test]
471 fn test_cli_parse_interactive_no_banner() {
472 let cli = Cli::try_parse_from(["scope", "interactive", "--no-banner"]).unwrap();
473
474 if let Commands::Interactive(args) = cli.command {
475 assert!(args.no_banner);
476 } else {
477 panic!("Expected Interactive command");
478 }
479 }
480
481 #[test]
482 fn test_cli_verbose_flag_counting() {
483 let cli = Cli::try_parse_from([
484 "scope",
485 "-vvv",
486 "address",
487 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
488 ])
489 .unwrap();
490
491 assert_eq!(cli.verbose, 3);
492 }
493
494 #[test]
495 fn test_cli_verbose_separate_flags() {
496 let cli = Cli::try_parse_from([
497 "scope",
498 "-v",
499 "-v",
500 "address",
501 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
502 ])
503 .unwrap();
504
505 assert_eq!(cli.verbose, 2);
506 }
507
508 #[test]
509 fn test_cli_global_config_option() {
510 let cli = Cli::try_parse_from([
511 "scope",
512 "--config",
513 "/custom/path.yaml",
514 "tx",
515 "0xabc123def456789012345678901234567890123456789012345678901234abcd",
516 ])
517 .unwrap();
518
519 assert_eq!(cli.config, Some(PathBuf::from("/custom/path.yaml")));
520 }
521
522 #[test]
523 fn test_cli_config_long_flag() {
524 let cli = Cli::try_parse_from([
525 "scope",
526 "--config",
527 "/custom/config.yaml",
528 "address",
529 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
530 ])
531 .unwrap();
532
533 assert_eq!(cli.config, Some(PathBuf::from("/custom/config.yaml")));
534 }
535
536 #[test]
537 fn test_cli_no_color_flag() {
538 let cli = Cli::try_parse_from([
539 "scope",
540 "--no-color",
541 "address",
542 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
543 ])
544 .unwrap();
545
546 assert!(cli.no_color);
547 }
548
549 #[test]
550 fn test_cli_missing_required_args_fails() {
551 let result = Cli::try_parse_from(["scope", "address"]);
552 assert!(result.is_err());
553 }
554
555 #[test]
556 fn test_cli_invalid_subcommand_fails() {
557 let result = Cli::try_parse_from(["scope", "invalid"]);
558 assert!(result.is_err());
559 }
560
561 #[test]
562 fn test_cli_log_level_default() {
563 let cli = Cli::try_parse_from([
564 "scope",
565 "address",
566 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
567 ])
568 .unwrap();
569
570 assert_eq!(cli.log_level(), tracing::Level::WARN);
571 }
572
573 #[test]
574 fn test_cli_log_level_info() {
575 let cli = Cli::try_parse_from([
576 "scope",
577 "-v",
578 "address",
579 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
580 ])
581 .unwrap();
582
583 assert_eq!(cli.log_level(), tracing::Level::INFO);
584 }
585
586 #[test]
587 fn test_cli_log_level_debug() {
588 let cli = Cli::try_parse_from([
589 "scope",
590 "-vv",
591 "address",
592 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
593 ])
594 .unwrap();
595
596 assert_eq!(cli.log_level(), tracing::Level::DEBUG);
597 }
598
599 #[test]
600 fn test_cli_log_level_trace() {
601 let cli = Cli::try_parse_from([
602 "scope",
603 "-vvvv",
604 "address",
605 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
606 ])
607 .unwrap();
608
609 assert_eq!(cli.log_level(), tracing::Level::TRACE);
610 }
611
612 #[test]
613 fn test_cli_debug_impl() {
614 let cli = Cli::try_parse_from([
615 "scope",
616 "address",
617 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
618 ])
619 .unwrap();
620
621 let debug_str = format!("{:?}", cli);
622 assert!(debug_str.contains("Cli"));
623 assert!(debug_str.contains("Address"));
624 }
625
626 #[test]
631 fn test_cli_parse_monitor_command() {
632 let cli = Cli::try_parse_from([
633 "scope",
634 "monitor",
635 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
636 ])
637 .unwrap();
638
639 assert!(matches!(cli.command, Commands::Monitor(_)));
640 }
641
642 #[test]
643 fn test_cli_parse_monitor_alias_mon() {
644 let cli = Cli::try_parse_from(["scope", "mon", "USDC"]).unwrap();
645
646 assert!(matches!(cli.command, Commands::Monitor(_)));
647 if let Commands::Monitor(args) = cli.command {
648 assert_eq!(args.token, "USDC");
649 assert_eq!(args.chain, "ethereum"); assert!(args.layout.is_none());
651 assert!(args.refresh.is_none());
652 assert!(args.scale.is_none());
653 assert!(args.color_scheme.is_none());
654 assert!(args.export.is_none());
655 }
656 }
657
658 #[test]
659 fn test_cli_parse_monitor_with_chain() {
660 let cli = Cli::try_parse_from(["scope", "monitor", "USDC", "--chain", "solana"]).unwrap();
661
662 if let Commands::Monitor(args) = cli.command {
663 assert_eq!(args.token, "USDC");
664 assert_eq!(args.chain, "solana");
665 } else {
666 panic!("Expected Monitor command");
667 }
668 }
669
670 #[test]
671 fn test_cli_parse_monitor_chain_short_flag() {
672 let cli = Cli::try_parse_from(["scope", "monitor", "PEPE", "-c", "ethereum"]).unwrap();
673
674 if let Commands::Monitor(args) = cli.command {
675 assert_eq!(args.token, "PEPE");
676 assert_eq!(args.chain, "ethereum");
677 } else {
678 panic!("Expected Monitor command");
679 }
680 }
681
682 #[test]
683 fn test_cli_parse_monitor_with_layout() {
684 let cli =
685 Cli::try_parse_from(["scope", "monitor", "USDC", "--layout", "chart-focus"]).unwrap();
686
687 if let Commands::Monitor(args) = cli.command {
688 assert_eq!(args.layout, Some(monitor::LayoutPreset::ChartFocus));
689 } else {
690 panic!("Expected Monitor command");
691 }
692 }
693
694 #[test]
695 fn test_cli_parse_monitor_with_refresh() {
696 let cli = Cli::try_parse_from(["scope", "monitor", "USDC", "--refresh", "3"]).unwrap();
697
698 if let Commands::Monitor(args) = cli.command {
699 assert_eq!(args.refresh, Some(3));
700 } else {
701 panic!("Expected Monitor command");
702 }
703 }
704
705 #[test]
706 fn test_cli_parse_monitor_with_scale() {
707 let cli = Cli::try_parse_from(["scope", "monitor", "USDC", "--scale", "log"]).unwrap();
708
709 if let Commands::Monitor(args) = cli.command {
710 assert_eq!(args.scale, Some(monitor::ScaleMode::Log));
711 } else {
712 panic!("Expected Monitor command");
713 }
714 }
715
716 #[test]
717 fn test_cli_parse_monitor_with_color_scheme() {
718 let cli =
719 Cli::try_parse_from(["scope", "monitor", "USDC", "--color-scheme", "blue-orange"])
720 .unwrap();
721
722 if let Commands::Monitor(args) = cli.command {
723 assert_eq!(args.color_scheme, Some(monitor::ColorScheme::BlueOrange));
724 } else {
725 panic!("Expected Monitor command");
726 }
727 }
728
729 #[test]
730 fn test_cli_parse_monitor_with_export() {
731 let cli =
732 Cli::try_parse_from(["scope", "monitor", "USDC", "--export", "/tmp/out.csv"]).unwrap();
733
734 if let Commands::Monitor(args) = cli.command {
735 assert_eq!(args.export, Some(std::path::PathBuf::from("/tmp/out.csv")));
736 } else {
737 panic!("Expected Monitor command");
738 }
739 }
740
741 #[test]
742 fn test_cli_parse_monitor_short_flags() {
743 let cli = Cli::try_parse_from([
744 "scope", "mon", "USDC", "-c", "solana", "-l", "compact", "-r", "10", "-s", "log", "-e",
745 "data.csv",
746 ])
747 .unwrap();
748
749 if let Commands::Monitor(args) = cli.command {
750 assert_eq!(args.token, "USDC");
751 assert_eq!(args.chain, "solana");
752 assert_eq!(args.layout, Some(monitor::LayoutPreset::Compact));
753 assert_eq!(args.refresh, Some(10));
754 assert_eq!(args.scale, Some(monitor::ScaleMode::Log));
755 assert_eq!(args.export, Some(std::path::PathBuf::from("data.csv")));
756 } else {
757 panic!("Expected Monitor command");
758 }
759 }
760
761 #[test]
762 fn test_cli_parse_monitor_missing_token_fails() {
763 let result = Cli::try_parse_from(["scope", "monitor"]);
764 assert!(result.is_err());
765 }
766
767 #[test]
768 fn test_cli_parse_monitor_invalid_layout_fails() {
769 let result = Cli::try_parse_from(["scope", "monitor", "USDC", "--layout", "invalid"]);
770 assert!(result.is_err());
771 }
772
773 #[test]
774 fn test_cli_parse_monitor_invalid_scale_fails() {
775 let result = Cli::try_parse_from(["scope", "monitor", "USDC", "--scale", "quadratic"]);
776 assert!(result.is_err());
777 }
778
779 #[test]
780 fn test_cli_parse_market_summary() {
781 let cli = Cli::try_parse_from(["scope", "market", "summary"]).unwrap();
782 assert!(matches!(cli.command, Commands::Market(_)));
783 }
784
785 #[test]
786 fn test_cli_parse_market_summary_with_pair() {
787 let cli = Cli::try_parse_from(["scope", "market", "summary", "DAI_USDT"]).unwrap();
788 if let Commands::Market(market::MarketCommands::Summary(args)) = cli.command {
789 assert_eq!(args.pair, "DAI_USDT");
790 } else {
791 panic!("Expected Market Summary command");
792 }
793 }
794
795 #[test]
796 fn test_cli_parse_report_batch() {
797 let cli = Cli::try_parse_from([
798 "scope",
799 "report",
800 "batch",
801 "--addresses",
802 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
803 "--output",
804 "report.md",
805 ])
806 .unwrap();
807 assert!(matches!(cli.command, Commands::Report(_)));
808 }
809
810 #[test]
811 fn test_cli_parse_market_summary_with_thresholds() {
812 let cli = Cli::try_parse_from([
813 "scope",
814 "market",
815 "summary",
816 "--peg-range",
817 "0.002",
818 "--min-bid-ask-ratio",
819 "0.1",
820 "--max-bid-ask-ratio",
821 "10",
822 ])
823 .unwrap();
824 if let Commands::Market(market::MarketCommands::Summary(args)) = cli.command {
825 assert_eq!(args.health.peg_range, Some(0.002));
826 assert_eq!(args.health.min_bid_ask_ratio, Some(0.1));
827 assert_eq!(args.health.max_bid_ask_ratio, Some(10.0));
828 } else {
829 panic!("Expected Market Summary command");
830 }
831 }
832
833 #[test]
834 fn test_cli_parse_token_health() {
835 let cli = Cli::try_parse_from(["scope", "token-health", "USDC"]).unwrap();
836 if let Commands::TokenHealth(args) = cli.command {
837 assert_eq!(args.token, "USDC");
838 assert!(!args.with_market);
839 } else {
840 panic!("Expected TokenHealth command");
841 }
842 }
843
844 #[test]
845 fn test_cli_parse_token_health_alias() {
846 let cli = Cli::try_parse_from(["scope", "health", "USDC", "--with-market"]).unwrap();
847 if let Commands::TokenHealth(args) = cli.command {
848 assert_eq!(args.token, "USDC");
849 assert!(args.with_market);
850 assert_eq!(args.venue, "binance"); } else {
852 panic!("Expected TokenHealth command");
853 }
854 }
855
856 #[test]
857 fn test_cli_parse_token_health_venue_biconomy() {
858 let cli = Cli::try_parse_from([
859 "scope",
860 "token-health",
861 "USDC",
862 "--with-market",
863 "--venue",
864 "biconomy",
865 ])
866 .unwrap();
867 if let Commands::TokenHealth(args) = cli.command {
868 assert_eq!(args.venue, "biconomy");
869 } else {
870 panic!("Expected TokenHealth command");
871 }
872 }
873
874 #[test]
875 fn test_cli_parse_token_health_venue_eth() {
876 let cli = Cli::try_parse_from([
877 "scope",
878 "token-health",
879 "USDC",
880 "--with-market",
881 "--venue",
882 "eth",
883 ])
884 .unwrap();
885 if let Commands::TokenHealth(args) = cli.command {
886 assert_eq!(args.venue, "eth");
887 } else {
888 panic!("Expected TokenHealth command");
889 }
890 }
891
892 #[test]
893 fn test_cli_parse_token_health_venue_solana() {
894 let cli = Cli::try_parse_from([
895 "scope",
896 "token-health",
897 "USDC",
898 "--with-market",
899 "--venue",
900 "solana",
901 ])
902 .unwrap();
903 if let Commands::TokenHealth(args) = cli.command {
904 assert_eq!(args.venue, "solana");
905 } else {
906 panic!("Expected TokenHealth command");
907 }
908 }
909
910 #[test]
911 fn test_cli_parse_ai_flag() {
912 let cli = Cli::try_parse_from([
913 "scope",
914 "--ai",
915 "address",
916 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
917 ])
918 .unwrap();
919 assert!(cli.ai);
920 }
921}