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