Skip to main content

scope_cli/cli/
dispatch.rs

1//! # Command Dispatch
2//!
3//! Routes a parsed [`Commands`] value to its handler. The `scope` binary and
4//! the TUI both call [`dispatch`], so a command gives the same result in
5//! both places. Process concerns (the version line, error display, the exit
6//! code) stay with the caller.
7
8use crate::cli::output::Output;
9use crate::cli::{Cli, Commands};
10use crate::{note, out};
11use clap::CommandFactory;
12use clap_complete::generate;
13use scope::Config;
14use scope::chains::{ChainClientFactory, DefaultClientFactory};
15use scope::error::{Result, ScopeError};
16use scope::http::HttpClient;
17use std::sync::Arc;
18
19/// Routes one command to its handler.
20///
21/// Creates the HTTP transport and client factory, then runs the handler
22/// with `out` as its output. Returns the process exit code: 0, or the
23/// TUI's code (128+N after signal N ended it).
24///
25/// # Errors
26///
27/// Returns the handler's error. `Commands::Web` is an error here: the web
28/// server lives in the `scope-bca` crate, and its binary starts it before
29/// dispatch.
30pub async fn dispatch(command: Commands, config: &Config, out: &Output) -> Result<i32> {
31    let http: Arc<dyn HttpClient> = create_http_client(config, out)?;
32    let factory = DefaultClientFactory {
33        chains_config: config.chains.clone(),
34        http,
35    };
36    // Only the TUI can end with a code other than 0, so it starts here.
37    if let Commands::Interactive(args) = command {
38        let exit = crate::cli::interactive::run(args, config, &factory).await?;
39        return Ok(exit.code);
40    }
41    dispatch_with(command, config, &factory, out)
42        .await
43        .map(|()| 0)
44}
45
46/// Routes one command to its handler, using the given client factory.
47///
48/// The TUI and the tests call this with their own factory (for example a
49/// mock). [`dispatch`] calls it with the default factory.
50///
51/// # Errors
52///
53/// Returns the handler's error. See [`dispatch`]. `Commands::Interactive`
54/// is an error here: the TUI starts only from [`dispatch`], which returns
55/// its exit code.
56pub async fn dispatch_with(
57    command: Commands,
58    config: &Config,
59    clients: &dyn ChainClientFactory,
60    out: &Output,
61) -> Result<()> {
62    match command {
63        Commands::Completions(args) => {
64            let mut cmd = Cli::command();
65            let mut buf = Vec::new();
66            generate(args.shell, &mut cmd, "scope", &mut buf);
67            out!(out, "{}", String::from_utf8_lossy(&buf))?;
68            Ok(())
69        }
70        Commands::Address(args) => crate::cli::address::run(args, config, clients, out).await,
71        Commands::Tx(args) => crate::cli::tx::run(args, config, clients, out).await,
72        Commands::Crawl(args) => crate::cli::crawl::run(args, config, clients, out).await,
73        Commands::AddressBook(args) => {
74            crate::cli::address_book::run(args, config, clients, out).await
75        }
76        Commands::Export(args) => crate::cli::export::run(args, config, clients, out).await,
77        // The TUI calls dispatch_with and refuses a nested `interactive`
78        // before it gets here (exec::route).
79        Commands::Interactive(_) => Err(ScopeError::Other(
80            "the TUI cannot start from here; start it with `scope tui`".into(),
81        )),
82        Commands::Monitor(args) => {
83            crate::cli::monitor::run_direct(args, config, clients, out).await
84        }
85        Commands::Setup(args) => crate::cli::setup::run(args, config, out).await,
86        Commands::Compliance(compliance_cmd) => {
87            dispatch_compliance(compliance_cmd, config, out).await
88        }
89        Commands::Market(cmd) => crate::cli::market::run(cmd, config, clients, out).await,
90        Commands::TokenHealth(args) => {
91            crate::cli::token_health::run(args, config, clients, out).await
92        }
93        Commands::Venues(cmd) => crate::cli::venues::run(cmd, out),
94        Commands::Report(cmd) => crate::cli::report::run(cmd, config, clients, out).await,
95        Commands::Discover(args) => crate::cli::discover::run(args, config.output.format, out)
96            .await
97            .map_err(|e| match e {
98                // An I/O error keeps its kind (a closed pipe exits quietly).
99                ScopeError::IoError(_) => e,
100                other => ScopeError::Discovery(other.to_string()),
101            }),
102        Commands::Insights(args) => crate::cli::insights::run(args, config, clients, out).await,
103        Commands::Contract(ref args) => crate::cli::contract::run(args, config, clients, out).await,
104        Commands::Web(_) => Err(ScopeError::Other(
105            "the web server cannot run from here; start it with `scope web`".into(),
106        )),
107    }
108}
109
110/// Routes a compliance subcommand. `@label` inputs resolve through the
111/// address book first.
112async fn dispatch_compliance(
113    cmd: crate::cli::compliance::ComplianceCommands,
114    config: &Config,
115    out: &Output,
116) -> Result<()> {
117    use crate::cli::address_book::resolve_address_book_input;
118    use crate::cli::compliance::{self, ComplianceCommands};
119
120    let result = match cmd {
121        ComplianceCommands::Risk(mut args) => {
122            if let Some((addr, chain)) = resolve_address_book_input(&args.address, config)? {
123                args.address = addr;
124                if args.chain.is_none() {
125                    args.chain = Some(chain);
126                }
127            }
128            compliance::handle_risk(args, out).await
129        }
130        ComplianceCommands::Trace(args) => compliance::handle_trace(args, out).await,
131        ComplianceCommands::Analyze(mut args) => {
132            if let Some((addr, _chain)) = resolve_address_book_input(&args.address, config)? {
133                args.address = addr;
134            }
135            compliance::handle_analyze(args, out).await
136        }
137        ComplianceCommands::ComplianceReport(mut args) => {
138            if !std::path::Path::new(&args.target).exists()
139                && let Some((addr, _chain)) = resolve_address_book_input(&args.target, config)?
140            {
141                args.target = addr;
142            }
143            compliance::handle_compliance_report(args, out).await
144        }
145    };
146    result.map_err(|e| from_anyhow(e, ScopeError::Compliance))
147}
148
149/// A command error from an `anyhow` handler. An I/O error keeps its kind
150/// (a closed pipe stays a closed pipe) and a `ScopeError` stays itself;
151/// anything else is wrapped by `kind` with its message.
152fn from_anyhow(e: anyhow::Error, kind: fn(String) -> ScopeError) -> ScopeError {
153    match e.downcast::<std::io::Error>() {
154        Ok(io) => ScopeError::IoError(io),
155        Err(e) => match e.downcast::<ScopeError>() {
156            Ok(scope) => scope,
157            Err(e) => kind(e.to_string()),
158        },
159    }
160}
161
162/// Installs the process transport on the first call (see `scope::http::shared`)
163/// and prints the notices. Later calls return the same transport.
164///
165/// # Errors
166///
167/// Returns the error when the transport cannot be built.
168pub fn create_http_client(config: &Config, out: &Output) -> Result<Arc<dyn HttpClient>> {
169    let built = scope::http::init(config)?;
170    for notice in &built.notices {
171        note!(out, "{notice}");
172    }
173    Ok(built.client)
174}
175
176// ============================================================================
177// Unit Tests
178// ============================================================================
179
180#[cfg(test)]
181mod tests {
182    #[test]
183    fn test_anyhow_io_errors_keep_their_kind() {
184        // A closed pipe in `compliance … | head` must still read as a
185        // closed pipe, so the CLI exits quietly.
186        let pipe = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "closed");
187        let e = super::from_anyhow(pipe.into(), ScopeError::Compliance);
188        assert!(
189            matches!(&e, ScopeError::IoError(io) if io.kind() == std::io::ErrorKind::BrokenPipe)
190        );
191        let scope = super::from_anyhow(
192            ScopeError::NotFound("x".into()).into(),
193            ScopeError::Compliance,
194        );
195        assert!(matches!(scope, ScopeError::NotFound(_)));
196        let other = super::from_anyhow(anyhow::anyhow!("bad"), ScopeError::Compliance);
197        assert_eq!(
198            other.to_string(),
199            ScopeError::Compliance("bad".into()).to_string()
200        );
201    }
202
203    use super::*;
204    use clap::Parser;
205    use scope::http::shared::Kind;
206
207    fn quiet() -> Output {
208        Output::capture().0
209    }
210
211    #[test]
212    fn test_create_http_client_returns_the_installed_transport() {
213        // Two calls must give the same transport: one process, one transport.
214        // Default config only: `init` installs for the whole test binary, and
215        // a non-native transport would reach the mockito tests too.
216        let config = Config::default();
217        let (out, captured) = Output::capture();
218        let a = create_http_client(&config, &out).unwrap();
219        let b = create_http_client(&config, &out).unwrap();
220        assert!(Arc::ptr_eq(&a, &b));
221        // Default config selects native, which has nothing to report.
222        assert!(captured.err().is_empty(), "{}", captured.err());
223    }
224
225    /// Selection for a Ghola config, without installing it for the process.
226    /// The test machine may or may not run a Ghola sidecar: the result must
227    /// be Ghola with no notice, or native with notices that say so.
228    fn assert_ghola_selection(stealth: bool) {
229        let mut config = Config::default();
230        config.ghola.enabled = true;
231        config.ghola.stealth = stealth;
232        let built = scope::http::shared::build_with(&config, &|_| None).unwrap();
233        match built.kind {
234            Kind::Ghola => assert!(built.notices.is_empty(), "{:?}", built.notices),
235            Kind::Native => {
236                let all = built.notices.join("\n");
237                assert!(all.contains("Ghola"), "{all}");
238                assert!(all.contains("Falling back"), "{all}");
239            }
240            other => panic!("unexpected kind {other:?}"),
241        }
242    }
243
244    #[test]
245    fn test_transport_selection_default_config_is_native() {
246        let built = scope::http::shared::build_with(&Config::default(), &|_| None).unwrap();
247        assert_eq!(built.kind, Kind::Native);
248        assert!(built.notices.is_empty(), "{:?}", built.notices);
249    }
250
251    #[test]
252    fn test_transport_selection_ghola_enabled() {
253        assert_ghola_selection(true);
254    }
255
256    #[test]
257    fn test_transport_selection_ghola_no_stealth() {
258        assert_ghola_selection(false);
259    }
260
261    #[tokio::test]
262    async fn test_dispatch_writes_completions_to_output() {
263        // The TUI shows dispatch output in a pane: completions must go to
264        // the injected writer, not straight to the process stdout.
265        let cli = Cli::try_parse_from(["scope", "completions", "bash"]).unwrap();
266        let (o, cap) = Output::capture();
267        dispatch(cli.command, &Config::default(), &o).await.unwrap();
268        assert!(cap.out().contains("_scope()"));
269    }
270
271    #[tokio::test]
272    async fn test_dispatch_fails_when_output_cannot_be_written() {
273        // `scope completions zsh > /full/disk` must exit non-zero, not
274        // report success with no file content.
275        let cli = Cli::try_parse_from(["scope", "completions", "zsh"]).unwrap();
276        let o = Output::from_writers(
277            crate::cli::output::tests::ClosedPipe,
278            crate::cli::output::tests::ClosedPipe,
279        );
280        let err = dispatch(cli.command, &Config::default(), &o)
281            .await
282            .unwrap_err();
283        assert!(
284            matches!(err, ScopeError::IoError(ref e) if e.kind() == std::io::ErrorKind::BrokenPipe)
285        );
286    }
287
288    #[tokio::test]
289    async fn test_dispatch_rejects_web() {
290        // `web` needs the scope-bca crate; dispatch must fail, not panic.
291        let cli = Cli::try_parse_from(["scope", "web"]).unwrap();
292        let err = dispatch(cli.command, &Config::default(), &quiet())
293            .await
294            .unwrap_err();
295        assert!(err.to_string().contains("scope web"));
296    }
297
298    // ---- dispatch_with routes to the real handlers (offline, mocks) ----
299
300    const ADDR: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
301
302    async fn run_line(args: &[&str]) -> (Result<()>, String) {
303        let mut argv = vec!["scope"];
304        argv.extend_from_slice(args);
305        let cli = Cli::try_parse_from(argv).unwrap();
306        let (o, cap) = Output::capture_merged();
307        let factory = scope::chains::mocks::MockClientFactory::new();
308        let res = dispatch_with(cli.command, &Config::default(), &factory, &o).await;
309        (res, cap.out())
310    }
311
312    #[tokio::test]
313    async fn test_dispatch_with_crawl_by_address() {
314        let (res, out) = run_line(&["crawl", ADDR, "--yes", "--no-charts"]).await;
315        res.unwrap();
316        assert!(!out.trim().is_empty());
317    }
318
319    #[tokio::test]
320    async fn test_dispatch_with_token_health() {
321        let (res, out) = run_line(&["token-health", ADDR]).await;
322        res.unwrap();
323        assert!(!out.trim().is_empty());
324    }
325
326    #[tokio::test]
327    async fn test_dispatch_with_report_batch_writes_the_file() {
328        let dir = tempfile::tempdir().unwrap();
329        let path = dir.path().join("batch.md");
330        let (res, _) = run_line(&[
331            "report",
332            "batch",
333            "--addresses",
334            ADDR,
335            "--output",
336            path.to_str().unwrap(),
337        ])
338        .await;
339        res.unwrap();
340        assert!(std::fs::read_to_string(&path).unwrap().contains(ADDR));
341    }
342
343    #[tokio::test]
344    async fn test_dispatch_with_refuses_the_tui() {
345        // Only `dispatch` carries the TUI's exit code, so dispatch_with
346        // (which the TUI itself calls) must refuse `interactive`.
347        let (res, _) = run_line(&["interactive"]).await;
348        assert!(res.unwrap_err().to_string().contains("scope tui"));
349    }
350
351    #[tokio::test]
352    async fn test_compliance_errors_are_labelled_as_compliance_errors() {
353        // Scripts match on the error kind, so a handler failure must come
354        // back as ScopeError::Compliance, not as the handler's own error.
355        let dir = tempfile::tempdir().unwrap();
356        let empty = dir.path().join("none.txt");
357        std::fs::write(&empty, "# no addresses\n").unwrap();
358        let out = dir.path().join("report.txt");
359        let (res, _) = run_line(&[
360            "compliance",
361            "compliance-report",
362            empty.to_str().unwrap(),
363            "--jurisdiction",
364            "us",
365            "--output",
366            out.to_str().unwrap(),
367        ])
368        .await;
369        let err = res.unwrap_err();
370        assert!(matches!(err, ScopeError::Compliance(_)), "{err:?}");
371        assert!(err.to_string().contains("No addresses to analyze"));
372
373        let (res, _) = run_line(&["compliance", "risk", "not-an-address"]).await;
374        assert!(matches!(res.unwrap_err(), ScopeError::Compliance(_)));
375    }
376
377    #[tokio::test]
378    async fn test_compliance_unknown_label_fails_before_any_lookup() {
379        // A typo in `@label` must stop at the address book (a NotFound
380        // error that lists the labels), never reach a handler.
381        let dir = tempfile::tempdir().unwrap();
382        let mut config = Config::default();
383        config.address_book.data_dir = Some(dir.path().to_path_buf());
384        let factory = scope::chains::mocks::MockClientFactory::new();
385        for args in [
386            vec!["compliance", "risk", "@nobody"],
387            vec!["compliance", "analyze", "@nobody"],
388            vec![
389                "compliance",
390                "compliance-report",
391                "@nobody",
392                "--jurisdiction",
393                "eu",
394                "--output",
395                "unused.txt",
396            ],
397        ] {
398            let mut argv = vec!["scope"];
399            argv.extend_from_slice(&args);
400            let cli = Cli::try_parse_from(argv).unwrap();
401            let (o, cap) = Output::capture_merged();
402            let err = dispatch_with(cli.command, &config, &factory, &o)
403                .await
404                .unwrap_err();
405            assert!(matches!(err, ScopeError::NotFound(_)), "{args:?}: {err:?}");
406            assert!(err.to_string().contains("@nobody"), "{args:?}");
407            assert!(!cap.out().contains("Analyzing"), "handler must not run");
408        }
409    }
410}