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