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