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