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//!   portfolio    Portfolio management commands
16//!   export       Export analysis data
17//!   interactive  Interactive mode with preserved context
18//!
19//! Options:
20//!   --config <PATH>   Path to configuration file
21//!   -v, --verbose...  Increase logging verbosity
22//!   -h, --help        Print help
23//!   -V, --version     Print version
24//! ```
25
26pub mod address;
27pub mod compliance;
28pub mod crawl;
29pub mod export;
30pub mod interactive;
31pub mod monitor;
32pub mod portfolio;
33pub mod setup;
34pub mod tx;
35
36use clap::{Parser, Subcommand};
37use std::path::PathBuf;
38
39pub use address::AddressArgs;
40pub use crawl::CrawlArgs;
41pub use export::ExportArgs;
42pub use interactive::InteractiveArgs;
43pub use portfolio::PortfolioArgs;
44pub use setup::SetupArgs;
45pub use tx::TxArgs;
46
47/// Blockchain Analysis CLI - A tool for blockchain data analysis.
48///
49/// Scope provides comprehensive blockchain analysis capabilities including
50/// address investigation, transaction decoding, portfolio tracking, and
51/// data export functionality.
52#[derive(Debug, Parser)]
53#[command(
54    name = "scope",
55    version,
56    about = "Scope Blockchain Analysis - A tool for blockchain data analysis",
57    long_about = "Scope Blockchain Analysis is a production-grade tool for \
58                  blockchain data analysis, portfolio tracking, and transaction investigation.\n\n\
59                  Use --help with any subcommand for detailed usage information."
60)]
61pub struct Cli {
62    /// Subcommand to execute.
63    #[command(subcommand)]
64    pub command: Commands,
65
66    /// Path to configuration file.
67    ///
68    /// Overrides the default location (~/.config/scope/config.yaml).
69    #[arg(long, global = true, value_name = "PATH")]
70    pub config: Option<PathBuf>,
71
72    /// Increase logging verbosity.
73    ///
74    /// Can be specified multiple times:
75    /// -v    = INFO level
76    /// -vv   = DEBUG level
77    /// -vvv  = TRACE level
78    #[arg(short, long, global = true, action = clap::ArgAction::Count)]
79    pub verbose: u8,
80
81    /// Disable colored output.
82    #[arg(long, global = true)]
83    pub no_color: bool,
84}
85
86/// Available CLI subcommands.
87#[derive(Debug, Subcommand)]
88pub enum Commands {
89    /// Analyze a blockchain address.
90    ///
91    /// Retrieves balance, transaction history, and token holdings
92    /// for the specified address.
93    #[command(visible_alias = "addr")]
94    Address(AddressArgs),
95
96    /// Analyze a transaction.
97    ///
98    /// Decodes transaction data, traces execution, and displays
99    /// detailed information about the transaction.
100    #[command(visible_alias = "transaction")]
101    Tx(TxArgs),
102
103    /// Crawl a token for analytics data.
104    ///
105    /// Retrieves comprehensive token information including top holders,
106    /// volume statistics, price data, and liquidity. Displays results
107    /// with ASCII charts and can generate markdown reports.
108    #[command(visible_alias = "token")]
109    Crawl(CrawlArgs),
110
111    /// Portfolio management commands.
112    ///
113    /// Add, remove, and list watched addresses. View aggregated
114    /// portfolio balances across multiple chains.
115    #[command(visible_alias = "port")]
116    Portfolio(PortfolioArgs),
117
118    /// Export analysis data.
119    ///
120    /// Export transaction history, balances, or analysis results
121    /// to various formats (JSON, CSV).
122    Export(ExportArgs),
123
124    /// Interactive mode with preserved context.
125    ///
126    /// Launch a REPL where chain, format, and other settings persist
127    /// between commands for faster workflow.
128    #[command(visible_alias = "shell")]
129    Interactive(InteractiveArgs),
130
131    /// Configure Scope settings and API keys.
132    ///
133    /// Run the setup wizard to configure API keys and preferences,
134    /// or use --status to view current configuration.
135    #[command(visible_alias = "config")]
136    Setup(SetupArgs),
137
138    /// Compliance and risk analysis commands.
139    ///
140    /// Assess risk, trace taint, detect patterns, and generate
141    /// compliance reports for blockchain addresses.
142    #[command(subcommand)]
143    Compliance(compliance::ComplianceCommands),
144}
145
146impl Cli {
147    /// Parses CLI arguments from the environment.
148    ///
149    /// This is a convenience wrapper around `clap::Parser::parse()`.
150    pub fn parse_args() -> Self {
151        Self::parse()
152    }
153
154    /// Returns the log level based on verbosity flag.
155    ///
156    /// Maps the `-v` count to tracing log levels:
157    /// - 0: WARN (default)
158    /// - 1: INFO
159    /// - 2: DEBUG
160    /// - 3+: TRACE
161    pub fn log_level(&self) -> tracing::Level {
162        match self.verbose {
163            0 => tracing::Level::WARN,
164            1 => tracing::Level::INFO,
165            2 => tracing::Level::DEBUG,
166            _ => tracing::Level::TRACE,
167        }
168    }
169}
170
171// ============================================================================
172// Unit Tests
173// ============================================================================
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use clap::Parser;
179
180    #[test]
181    fn test_cli_parse_address_command() {
182        let cli = Cli::try_parse_from([
183            "scope",
184            "address",
185            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
186        ])
187        .unwrap();
188
189        assert!(matches!(cli.command, Commands::Address(_)));
190        assert!(cli.config.is_none());
191        assert_eq!(cli.verbose, 0);
192    }
193
194    #[test]
195    fn test_cli_parse_address_alias() {
196        let cli = Cli::try_parse_from([
197            "scope",
198            "addr",
199            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
200        ])
201        .unwrap();
202
203        assert!(matches!(cli.command, Commands::Address(_)));
204    }
205
206    #[test]
207    fn test_cli_parse_tx_command() {
208        let cli = Cli::try_parse_from([
209            "scope",
210            "tx",
211            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
212        ])
213        .unwrap();
214
215        assert!(matches!(cli.command, Commands::Tx(_)));
216    }
217
218    #[test]
219    fn test_cli_parse_tx_alias() {
220        let cli = Cli::try_parse_from([
221            "scope",
222            "transaction",
223            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
224        ])
225        .unwrap();
226
227        assert!(matches!(cli.command, Commands::Tx(_)));
228    }
229
230    #[test]
231    fn test_cli_parse_portfolio_command() {
232        let cli = Cli::try_parse_from(["scope", "portfolio", "list"]).unwrap();
233
234        assert!(matches!(cli.command, Commands::Portfolio(_)));
235    }
236
237    #[test]
238    fn test_cli_parse_export_command() {
239        let cli = Cli::try_parse_from([
240            "scope",
241            "export",
242            "--address",
243            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
244            "--output",
245            "data.json",
246        ])
247        .unwrap();
248
249        assert!(matches!(cli.command, Commands::Export(_)));
250    }
251
252    #[test]
253    fn test_cli_parse_interactive_command() {
254        let cli = Cli::try_parse_from(["scope", "interactive"]).unwrap();
255
256        assert!(matches!(cli.command, Commands::Interactive(_)));
257    }
258
259    #[test]
260    fn test_cli_parse_interactive_alias() {
261        let cli = Cli::try_parse_from(["scope", "shell"]).unwrap();
262
263        assert!(matches!(cli.command, Commands::Interactive(_)));
264    }
265
266    #[test]
267    fn test_cli_parse_interactive_no_banner() {
268        let cli = Cli::try_parse_from(["scope", "interactive", "--no-banner"]).unwrap();
269
270        if let Commands::Interactive(args) = cli.command {
271            assert!(args.no_banner);
272        } else {
273            panic!("Expected Interactive command");
274        }
275    }
276
277    #[test]
278    fn test_cli_verbose_flag_counting() {
279        let cli = Cli::try_parse_from([
280            "scope",
281            "-vvv",
282            "address",
283            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
284        ])
285        .unwrap();
286
287        assert_eq!(cli.verbose, 3);
288    }
289
290    #[test]
291    fn test_cli_verbose_separate_flags() {
292        let cli = Cli::try_parse_from([
293            "scope",
294            "-v",
295            "-v",
296            "address",
297            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
298        ])
299        .unwrap();
300
301        assert_eq!(cli.verbose, 2);
302    }
303
304    #[test]
305    fn test_cli_global_config_option() {
306        let cli = Cli::try_parse_from([
307            "scope",
308            "--config",
309            "/custom/path.yaml",
310            "tx",
311            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
312        ])
313        .unwrap();
314
315        assert_eq!(cli.config, Some(PathBuf::from("/custom/path.yaml")));
316    }
317
318    #[test]
319    fn test_cli_config_long_flag() {
320        let cli = Cli::try_parse_from([
321            "scope",
322            "--config",
323            "/custom/config.yaml",
324            "address",
325            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
326        ])
327        .unwrap();
328
329        assert_eq!(cli.config, Some(PathBuf::from("/custom/config.yaml")));
330    }
331
332    #[test]
333    fn test_cli_no_color_flag() {
334        let cli = Cli::try_parse_from([
335            "scope",
336            "--no-color",
337            "address",
338            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
339        ])
340        .unwrap();
341
342        assert!(cli.no_color);
343    }
344
345    #[test]
346    fn test_cli_missing_required_args_fails() {
347        let result = Cli::try_parse_from(["scope", "address"]);
348        assert!(result.is_err());
349    }
350
351    #[test]
352    fn test_cli_invalid_subcommand_fails() {
353        let result = Cli::try_parse_from(["scope", "invalid"]);
354        assert!(result.is_err());
355    }
356
357    #[test]
358    fn test_cli_log_level_default() {
359        let cli = Cli::try_parse_from([
360            "scope",
361            "address",
362            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
363        ])
364        .unwrap();
365
366        assert_eq!(cli.log_level(), tracing::Level::WARN);
367    }
368
369    #[test]
370    fn test_cli_log_level_info() {
371        let cli = Cli::try_parse_from([
372            "scope",
373            "-v",
374            "address",
375            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
376        ])
377        .unwrap();
378
379        assert_eq!(cli.log_level(), tracing::Level::INFO);
380    }
381
382    #[test]
383    fn test_cli_log_level_debug() {
384        let cli = Cli::try_parse_from([
385            "scope",
386            "-vv",
387            "address",
388            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
389        ])
390        .unwrap();
391
392        assert_eq!(cli.log_level(), tracing::Level::DEBUG);
393    }
394
395    #[test]
396    fn test_cli_log_level_trace() {
397        let cli = Cli::try_parse_from([
398            "scope",
399            "-vvvv",
400            "address",
401            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
402        ])
403        .unwrap();
404
405        assert_eq!(cli.log_level(), tracing::Level::TRACE);
406    }
407
408    #[test]
409    fn test_cli_debug_impl() {
410        let cli = Cli::try_parse_from([
411            "scope",
412            "address",
413            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
414        ])
415        .unwrap();
416
417        let debug_str = format!("{:?}", cli);
418        assert!(debug_str.contains("Cli"));
419        assert!(debug_str.contains("Address"));
420    }
421}