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