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