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