Skip to main content

scope/cli/
mod.rs

1//! # CLI Module
2//!
3//! This module defines the command-line interface using `clap` with derive macros.
4//! It provides the main `Cli` struct and `Commands` enum that define all
5//! available commands and their arguments.
6//!
7//! ## Command Structure
8//!
9//! ```text
10//! scope [OPTIONS] <COMMAND>
11//!
12//! Commands:
13//!   address      Analyze a blockchain address
14//!   tx           Analyze a transaction
15//!   crawl        Crawl a token for analytics data
16//!   monitor      Live token monitor with real-time TUI dashboard
17//!   portfolio    Portfolio management commands
18//!   export       Export analysis data
19//!   interactive  Interactive mode with preserved context
20//!   setup        Configure settings and API keys
21//!   compliance   Compliance and risk analysis commands
22//!
23//! Options:
24//!   --config <PATH>   Path to configuration file
25//!   -v, --verbose...  Increase logging verbosity
26//!   -h, --help        Print help
27//!   -V, --version     Print version
28//! ```
29
30pub mod address;
31pub mod compliance;
32pub mod crawl;
33pub mod export;
34pub mod interactive;
35pub mod monitor;
36pub mod portfolio;
37pub mod setup;
38pub mod tx;
39
40use clap::{Parser, Subcommand};
41use std::path::PathBuf;
42
43pub use address::AddressArgs;
44pub use crawl::CrawlArgs;
45pub use export::ExportArgs;
46pub use interactive::InteractiveArgs;
47pub use monitor::MonitorArgs;
48pub use portfolio::PortfolioArgs;
49pub use setup::SetupArgs;
50pub use tx::TxArgs;
51
52/// Blockchain Analysis CLI - A tool for blockchain data analysis.
53///
54/// Scope provides comprehensive blockchain analysis capabilities including
55/// address investigation, transaction decoding, portfolio tracking, and
56/// data export functionality.
57#[derive(Debug, Parser)]
58#[command(
59    name = "scope",
60    version,
61    about = "Scope Blockchain Analysis - A tool for blockchain data analysis",
62    long_about = "Scope Blockchain Analysis is a production-grade tool for \
63                  blockchain data analysis, portfolio tracking, and transaction investigation.\n\n\
64                  Use --help with any subcommand for detailed usage information."
65)]
66pub struct Cli {
67    /// Subcommand to execute.
68    #[command(subcommand)]
69    pub command: Commands,
70
71    /// Path to configuration file.
72    ///
73    /// Overrides the default location (~/.config/scope/config.yaml).
74    #[arg(long, global = true, value_name = "PATH")]
75    pub config: Option<PathBuf>,
76
77    /// Increase logging verbosity.
78    ///
79    /// Can be specified multiple times:
80    /// -v    = INFO level
81    /// -vv   = DEBUG level
82    /// -vvv  = TRACE level
83    #[arg(short, long, global = true, action = clap::ArgAction::Count)]
84    pub verbose: u8,
85
86    /// Disable colored output.
87    #[arg(long, global = true)]
88    pub no_color: bool,
89}
90
91/// Available CLI subcommands.
92#[derive(Debug, Subcommand)]
93pub enum Commands {
94    /// Analyze a blockchain address.
95    ///
96    /// Retrieves balance, transaction history, and token holdings
97    /// for the specified address.
98    #[command(visible_alias = "addr")]
99    Address(AddressArgs),
100
101    /// Analyze a transaction.
102    ///
103    /// Decodes transaction data, traces execution, and displays
104    /// detailed information about the transaction.
105    #[command(visible_alias = "transaction")]
106    Tx(TxArgs),
107
108    /// Crawl a token for analytics data.
109    ///
110    /// Retrieves comprehensive token information including top holders,
111    /// volume statistics, price data, and liquidity. Displays results
112    /// with ASCII charts and can generate markdown reports.
113    #[command(visible_alias = "token")]
114    Crawl(CrawlArgs),
115
116    /// Portfolio management commands.
117    ///
118    /// Add, remove, and list watched addresses. View aggregated
119    /// portfolio balances across multiple chains.
120    #[command(visible_alias = "port")]
121    Portfolio(PortfolioArgs),
122
123    /// Export analysis data.
124    ///
125    /// Export transaction history, balances, or analysis results
126    /// to various formats (JSON, CSV).
127    Export(ExportArgs),
128
129    /// Interactive mode with preserved context.
130    ///
131    /// Launch a REPL where chain, format, and other settings persist
132    /// between commands for faster workflow.
133    #[command(visible_alias = "shell")]
134    Interactive(InteractiveArgs),
135
136    /// Live token monitor with real-time TUI dashboard.
137    ///
138    /// Launches a terminal UI with price/volume charts, buy/sell gauges,
139    /// transaction feed, and more. Shortcut for `scope interactive` + `monitor <token>`.
140    #[command(visible_alias = "mon")]
141    Monitor(MonitorArgs),
142
143    /// Configure Scope settings and API keys.
144    ///
145    /// Run the setup wizard to configure API keys and preferences,
146    /// or use --status to view current configuration.
147    #[command(visible_alias = "config")]
148    Setup(SetupArgs),
149
150    /// Compliance and risk analysis commands.
151    ///
152    /// Assess risk, trace taint, detect patterns, and generate
153    /// compliance reports for blockchain addresses.
154    #[command(subcommand)]
155    Compliance(compliance::ComplianceCommands),
156}
157
158impl Cli {
159    /// Parses CLI arguments from the environment.
160    ///
161    /// This is a convenience wrapper around `clap::Parser::parse()`.
162    pub fn parse_args() -> Self {
163        Self::parse()
164    }
165
166    /// Returns the log level based on verbosity flag.
167    ///
168    /// Maps the `-v` count to tracing log levels:
169    /// - 0: WARN (default)
170    /// - 1: INFO
171    /// - 2: DEBUG
172    /// - 3+: TRACE
173    pub fn log_level(&self) -> tracing::Level {
174        match self.verbose {
175            0 => tracing::Level::WARN,
176            1 => tracing::Level::INFO,
177            2 => tracing::Level::DEBUG,
178            _ => tracing::Level::TRACE,
179        }
180    }
181}
182
183// ============================================================================
184// Unit Tests
185// ============================================================================
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use clap::Parser;
191
192    #[test]
193    fn test_cli_parse_address_command() {
194        let cli = Cli::try_parse_from([
195            "scope",
196            "address",
197            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
198        ])
199        .unwrap();
200
201        assert!(matches!(cli.command, Commands::Address(_)));
202        assert!(cli.config.is_none());
203        assert_eq!(cli.verbose, 0);
204    }
205
206    #[test]
207    fn test_cli_parse_address_alias() {
208        let cli = Cli::try_parse_from([
209            "scope",
210            "addr",
211            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
212        ])
213        .unwrap();
214
215        assert!(matches!(cli.command, Commands::Address(_)));
216    }
217
218    #[test]
219    fn test_cli_parse_tx_command() {
220        let cli = Cli::try_parse_from([
221            "scope",
222            "tx",
223            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
224        ])
225        .unwrap();
226
227        assert!(matches!(cli.command, Commands::Tx(_)));
228    }
229
230    #[test]
231    fn test_cli_parse_tx_alias() {
232        let cli = Cli::try_parse_from([
233            "scope",
234            "transaction",
235            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
236        ])
237        .unwrap();
238
239        assert!(matches!(cli.command, Commands::Tx(_)));
240    }
241
242    #[test]
243    fn test_cli_parse_portfolio_command() {
244        let cli = Cli::try_parse_from(["scope", "portfolio", "list"]).unwrap();
245
246        assert!(matches!(cli.command, Commands::Portfolio(_)));
247    }
248
249    #[test]
250    fn test_cli_parse_export_command() {
251        let cli = Cli::try_parse_from([
252            "scope",
253            "export",
254            "--address",
255            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
256            "--output",
257            "data.json",
258        ])
259        .unwrap();
260
261        assert!(matches!(cli.command, Commands::Export(_)));
262    }
263
264    #[test]
265    fn test_cli_parse_interactive_command() {
266        let cli = Cli::try_parse_from(["scope", "interactive"]).unwrap();
267
268        assert!(matches!(cli.command, Commands::Interactive(_)));
269    }
270
271    #[test]
272    fn test_cli_parse_interactive_alias() {
273        let cli = Cli::try_parse_from(["scope", "shell"]).unwrap();
274
275        assert!(matches!(cli.command, Commands::Interactive(_)));
276    }
277
278    #[test]
279    fn test_cli_parse_interactive_no_banner() {
280        let cli = Cli::try_parse_from(["scope", "interactive", "--no-banner"]).unwrap();
281
282        if let Commands::Interactive(args) = cli.command {
283            assert!(args.no_banner);
284        } else {
285            panic!("Expected Interactive command");
286        }
287    }
288
289    #[test]
290    fn test_cli_verbose_flag_counting() {
291        let cli = Cli::try_parse_from([
292            "scope",
293            "-vvv",
294            "address",
295            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
296        ])
297        .unwrap();
298
299        assert_eq!(cli.verbose, 3);
300    }
301
302    #[test]
303    fn test_cli_verbose_separate_flags() {
304        let cli = Cli::try_parse_from([
305            "scope",
306            "-v",
307            "-v",
308            "address",
309            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
310        ])
311        .unwrap();
312
313        assert_eq!(cli.verbose, 2);
314    }
315
316    #[test]
317    fn test_cli_global_config_option() {
318        let cli = Cli::try_parse_from([
319            "scope",
320            "--config",
321            "/custom/path.yaml",
322            "tx",
323            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
324        ])
325        .unwrap();
326
327        assert_eq!(cli.config, Some(PathBuf::from("/custom/path.yaml")));
328    }
329
330    #[test]
331    fn test_cli_config_long_flag() {
332        let cli = Cli::try_parse_from([
333            "scope",
334            "--config",
335            "/custom/config.yaml",
336            "address",
337            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
338        ])
339        .unwrap();
340
341        assert_eq!(cli.config, Some(PathBuf::from("/custom/config.yaml")));
342    }
343
344    #[test]
345    fn test_cli_no_color_flag() {
346        let cli = Cli::try_parse_from([
347            "scope",
348            "--no-color",
349            "address",
350            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
351        ])
352        .unwrap();
353
354        assert!(cli.no_color);
355    }
356
357    #[test]
358    fn test_cli_missing_required_args_fails() {
359        let result = Cli::try_parse_from(["scope", "address"]);
360        assert!(result.is_err());
361    }
362
363    #[test]
364    fn test_cli_invalid_subcommand_fails() {
365        let result = Cli::try_parse_from(["scope", "invalid"]);
366        assert!(result.is_err());
367    }
368
369    #[test]
370    fn test_cli_log_level_default() {
371        let cli = Cli::try_parse_from([
372            "scope",
373            "address",
374            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
375        ])
376        .unwrap();
377
378        assert_eq!(cli.log_level(), tracing::Level::WARN);
379    }
380
381    #[test]
382    fn test_cli_log_level_info() {
383        let cli = Cli::try_parse_from([
384            "scope",
385            "-v",
386            "address",
387            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
388        ])
389        .unwrap();
390
391        assert_eq!(cli.log_level(), tracing::Level::INFO);
392    }
393
394    #[test]
395    fn test_cli_log_level_debug() {
396        let cli = Cli::try_parse_from([
397            "scope",
398            "-vv",
399            "address",
400            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
401        ])
402        .unwrap();
403
404        assert_eq!(cli.log_level(), tracing::Level::DEBUG);
405    }
406
407    #[test]
408    fn test_cli_log_level_trace() {
409        let cli = Cli::try_parse_from([
410            "scope",
411            "-vvvv",
412            "address",
413            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
414        ])
415        .unwrap();
416
417        assert_eq!(cli.log_level(), tracing::Level::TRACE);
418    }
419
420    #[test]
421    fn test_cli_debug_impl() {
422        let cli = Cli::try_parse_from([
423            "scope",
424            "address",
425            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
426        ])
427        .unwrap();
428
429        let debug_str = format!("{:?}", cli);
430        assert!(debug_str.contains("Cli"));
431        assert!(debug_str.contains("Address"));
432    }
433
434    // ========================================================================
435    // Monitor command tests
436    // ========================================================================
437
438    #[test]
439    fn test_cli_parse_monitor_command() {
440        let cli = Cli::try_parse_from([
441            "scope",
442            "monitor",
443            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
444        ])
445        .unwrap();
446
447        assert!(matches!(cli.command, Commands::Monitor(_)));
448    }
449
450    #[test]
451    fn test_cli_parse_monitor_alias_mon() {
452        let cli = Cli::try_parse_from(["scope", "mon", "USDC"]).unwrap();
453
454        assert!(matches!(cli.command, Commands::Monitor(_)));
455        if let Commands::Monitor(args) = cli.command {
456            assert_eq!(args.token, "USDC");
457            assert_eq!(args.chain, "ethereum"); // default
458            assert!(args.layout.is_none());
459            assert!(args.refresh.is_none());
460            assert!(args.scale.is_none());
461            assert!(args.color_scheme.is_none());
462            assert!(args.export.is_none());
463        }
464    }
465
466    #[test]
467    fn test_cli_parse_monitor_with_chain() {
468        let cli = Cli::try_parse_from(["scope", "monitor", "USDC", "--chain", "solana"]).unwrap();
469
470        if let Commands::Monitor(args) = cli.command {
471            assert_eq!(args.token, "USDC");
472            assert_eq!(args.chain, "solana");
473        } else {
474            panic!("Expected Monitor command");
475        }
476    }
477
478    #[test]
479    fn test_cli_parse_monitor_chain_short_flag() {
480        let cli = Cli::try_parse_from(["scope", "monitor", "PEPE", "-c", "ethereum"]).unwrap();
481
482        if let Commands::Monitor(args) = cli.command {
483            assert_eq!(args.token, "PEPE");
484            assert_eq!(args.chain, "ethereum");
485        } else {
486            panic!("Expected Monitor command");
487        }
488    }
489
490    #[test]
491    fn test_cli_parse_monitor_with_layout() {
492        let cli =
493            Cli::try_parse_from(["scope", "monitor", "USDC", "--layout", "chart-focus"]).unwrap();
494
495        if let Commands::Monitor(args) = cli.command {
496            assert_eq!(args.layout, Some(monitor::LayoutPreset::ChartFocus));
497        } else {
498            panic!("Expected Monitor command");
499        }
500    }
501
502    #[test]
503    fn test_cli_parse_monitor_with_refresh() {
504        let cli = Cli::try_parse_from(["scope", "monitor", "USDC", "--refresh", "3"]).unwrap();
505
506        if let Commands::Monitor(args) = cli.command {
507            assert_eq!(args.refresh, Some(3));
508        } else {
509            panic!("Expected Monitor command");
510        }
511    }
512
513    #[test]
514    fn test_cli_parse_monitor_with_scale() {
515        let cli = Cli::try_parse_from(["scope", "monitor", "USDC", "--scale", "log"]).unwrap();
516
517        if let Commands::Monitor(args) = cli.command {
518            assert_eq!(args.scale, Some(monitor::ScaleMode::Log));
519        } else {
520            panic!("Expected Monitor command");
521        }
522    }
523
524    #[test]
525    fn test_cli_parse_monitor_with_color_scheme() {
526        let cli =
527            Cli::try_parse_from(["scope", "monitor", "USDC", "--color-scheme", "blue-orange"])
528                .unwrap();
529
530        if let Commands::Monitor(args) = cli.command {
531            assert_eq!(args.color_scheme, Some(monitor::ColorScheme::BlueOrange));
532        } else {
533            panic!("Expected Monitor command");
534        }
535    }
536
537    #[test]
538    fn test_cli_parse_monitor_with_export() {
539        let cli =
540            Cli::try_parse_from(["scope", "monitor", "USDC", "--export", "/tmp/out.csv"]).unwrap();
541
542        if let Commands::Monitor(args) = cli.command {
543            assert_eq!(args.export, Some(std::path::PathBuf::from("/tmp/out.csv")));
544        } else {
545            panic!("Expected Monitor command");
546        }
547    }
548
549    #[test]
550    fn test_cli_parse_monitor_short_flags() {
551        let cli = Cli::try_parse_from([
552            "scope", "mon", "USDC", "-c", "solana", "-l", "compact", "-r", "10", "-s", "log", "-e",
553            "data.csv",
554        ])
555        .unwrap();
556
557        if let Commands::Monitor(args) = cli.command {
558            assert_eq!(args.token, "USDC");
559            assert_eq!(args.chain, "solana");
560            assert_eq!(args.layout, Some(monitor::LayoutPreset::Compact));
561            assert_eq!(args.refresh, Some(10));
562            assert_eq!(args.scale, Some(monitor::ScaleMode::Log));
563            assert_eq!(args.export, Some(std::path::PathBuf::from("data.csv")));
564        } else {
565            panic!("Expected Monitor command");
566        }
567    }
568
569    #[test]
570    fn test_cli_parse_monitor_missing_token_fails() {
571        let result = Cli::try_parse_from(["scope", "monitor"]);
572        assert!(result.is_err());
573    }
574
575    #[test]
576    fn test_cli_parse_monitor_invalid_layout_fails() {
577        let result = Cli::try_parse_from(["scope", "monitor", "USDC", "--layout", "invalid"]);
578        assert!(result.is_err());
579    }
580
581    #[test]
582    fn test_cli_parse_monitor_invalid_scale_fails() {
583        let result = Cli::try_parse_from(["scope", "monitor", "USDC", "--scale", "quadratic"]);
584        assert!(result.is_err());
585    }
586}