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::tui::Settings;
10use crate::cli::{Cli, Commands};
11use crate::{note, out};
12use clap::CommandFactory;
13use clap_complete::generate;
14use scope::Config;
15use scope::chains::{ChainClientFactory, DefaultClientFactory};
16use scope::config::OutputFormat;
17use scope::error::{Result, ScopeError};
18use scope::http::HttpClient;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22/// Where the config comes from: the binary reads it this way at start,
23/// and the TUI reads it the same way again after `setup` (#99).
24#[derive(Debug, Clone, Default)]
25pub struct ConfigSource {
26    /// `--config`, when given; else `SCOPE_CONFIG` or the default file.
27    pub path: Option<PathBuf>,
28    /// `--ai`: Markdown output for an agent to read.
29    pub ai: bool,
30}
31
32impl ConfigSource {
33    /// Reads the config and applies the flags.
34    ///
35    /// # Errors
36    ///
37    /// Returns the config file's read or parse error.
38    pub fn load(&self) -> Result<Config> {
39        let mut config = Config::load(self.path.as_deref())?;
40        self.apply(&mut config);
41        Ok(config)
42    }
43
44    /// Applies the flags that override the file (`--ai`).
45    pub fn apply(&self, config: &mut Config) {
46        if self.ai {
47            config.output.format = OutputFormat::Markdown;
48        }
49    }
50}
51
52/// Routes one command to its handler.
53///
54/// Creates the HTTP transport and client factory, then runs the handler
55/// with `out` as its output. Returns the process exit code: 0, or the
56/// TUI's code (128+N after signal N ended it). `source` says how to read
57/// `config` again; the TUI does so after `setup`.
58///
59/// # Errors
60///
61/// Returns the handler's error. `Commands::Web` is an error here: the web
62/// server lives in the `scope-bca` crate, and its binary starts it before
63/// dispatch.
64pub async fn dispatch(
65    command: Commands,
66    source: &ConfigSource,
67    config: &Config,
68    out: &Output,
69) -> Result<i32> {
70    // A command run on a terminal may offer to install a missing Ghola.
71    // The TUI, the wizard, the web server and scripted runs never do.
72    use crate::cli::ghola_offer;
73    let offer = !command.is_interactive()
74        && ghola_offer::may_ask(out)
75        && !ghola_offer::scripted(&ghola_offer::process_args(), config);
76    let env = |k: &str| std::env::var(k).ok();
77    let http: Arc<dyn HttpClient> = create_http_client_offering(config, out, &env, offer)?;
78    // Only the TUI can end with a code other than 0, so it starts here.
79    // `scope monitor` opens it too, with the monitor wizard (#47).
80    let opens_tui = match &command {
81        Commands::Interactive(_) => true,
82        Commands::Monitor(args) => !args.watch.dashboard,
83        _ => false,
84    };
85    if opens_tui {
86        let settings = |config: Config, http: Arc<dyn HttpClient>| Settings {
87            clients: Box::new(DefaultClientFactory {
88                chains_config: config.chains.clone(),
89                http,
90            }),
91            config,
92        };
93        // A reload after `setup` builds the transport again, so transport
94        // settings (proxy, Ghola) apply as well as the chain keys.
95        let reload = || {
96            let config = source.load()?;
97            let http = transport_for_reload(&config, &http, out, &|k| std::env::var(k).ok())?;
98            Ok(settings(config, http))
99        };
100        let first = settings(config.clone(), http.clone());
101        let exit = match command {
102            Commands::Monitor(args) => {
103                crate::cli::interactive::run_monitor(args, first, &reload).await?
104            }
105            Commands::Interactive(args) => {
106                crate::cli::interactive::run(args, first, &reload).await?
107            }
108            _ => unreachable!("opens_tui is true only for these two"),
109        };
110        return Ok(exit.code);
111    }
112    let factory = DefaultClientFactory {
113        chains_config: config.chains.clone(),
114        http,
115    };
116    dispatch_with(command, config, &factory, out)
117        .await
118        .map(|()| 0)
119}
120
121/// Routes one command to its handler, using the given client factory.
122///
123/// The TUI and the tests call this with their own factory (for example a
124/// mock). [`dispatch`] calls it with the default factory.
125///
126/// # Errors
127///
128/// Returns the handler's error. See [`dispatch`]. `Commands::Interactive`
129/// is an error here: the TUI starts only from [`dispatch`], which returns
130/// its exit code.
131pub async fn dispatch_with(
132    command: Commands,
133    config: &Config,
134    clients: &dyn ChainClientFactory,
135    out: &Output,
136) -> Result<()> {
137    match command {
138        Commands::Completions(args) => {
139            let mut cmd = Cli::command();
140            let mut buf = Vec::new();
141            generate(args.shell, &mut cmd, "scope", &mut buf);
142            out!(out, "{}", String::from_utf8_lossy(&buf))?;
143            Ok(())
144        }
145        Commands::Address(args) => crate::cli::address::run(args, config, clients, out).await,
146        Commands::Tx(args) => crate::cli::tx::run(args, config, clients, out).await,
147        Commands::Crawl(args) => crate::cli::crawl::run(args, config, clients, out).await,
148        Commands::AddressBook(args) => {
149            crate::cli::address_book::run(args, config, clients, out).await
150        }
151        Commands::Export(args) => crate::cli::export::run(args, config, clients, out).await,
152        // The TUI calls dispatch_with and refuses a nested `interactive`
153        // before it gets here (exec::route).
154        Commands::Interactive(_) => Err(ScopeError::Other(
155            "the TUI cannot start from here; start it with `scope tui`".into(),
156        )),
157        Commands::Monitor(args) => {
158            crate::cli::monitor::run_direct(args, config, clients, out).await
159        }
160        Commands::Setup(args) => crate::cli::setup::run(args, config, out).await,
161        Commands::Compliance(compliance_cmd) => {
162            dispatch_compliance(compliance_cmd, config, clients.http(), out).await
163        }
164        Commands::Market(cmd) => crate::cli::market::run(cmd, config, clients, out).await,
165        Commands::TokenHealth(args) => {
166            crate::cli::token_health::run(args, config, clients, out).await
167        }
168        Commands::Venues(cmd) => crate::cli::venues::run(cmd, out),
169        Commands::Report(cmd) => crate::cli::report::run(cmd, config, clients, out).await,
170        Commands::Discover(args) => {
171            crate::cli::discover::run(args, config.output.format, clients.http(), out)
172                .await
173                .map_err(|e| match e {
174                    // An I/O error keeps its kind (a closed pipe exits quietly).
175                    ScopeError::IoError(_) => e,
176                    other => ScopeError::Discovery(other.to_string()),
177                })
178        }
179        Commands::Insights(args) => crate::cli::insights::run(args, config, clients, out).await,
180        Commands::Contract(ref args) => crate::cli::contract::run(args, config, clients, out).await,
181        Commands::Web(_) => Err(ScopeError::Other(
182            "the web server cannot run from here; start it with `scope web`".into(),
183        )),
184    }
185}
186
187/// Routes a compliance subcommand. `@label` inputs resolve through the
188/// address book first.
189async fn dispatch_compliance(
190    cmd: crate::cli::compliance::ComplianceCommands,
191    config: &Config,
192    http: Arc<dyn HttpClient>,
193    out: &Output,
194) -> Result<()> {
195    use crate::cli::address_book::resolve_address_book_input;
196    use crate::cli::compliance::{self, ComplianceCommands};
197
198    let result = match cmd {
199        ComplianceCommands::Risk(mut args) => {
200            if let Some((addr, chain)) = resolve_address_book_input(&args.address, config)? {
201                args.address = addr;
202                if args.chain.is_none() {
203                    args.chain = Some(chain);
204                }
205            }
206            compliance::handle_risk(args, http, out).await
207        }
208        ComplianceCommands::Trace(args) => compliance::handle_trace(args, http, out).await,
209        ComplianceCommands::Analyze(mut args) => {
210            if let Some((addr, _chain)) = resolve_address_book_input(&args.address, config)? {
211                args.address = addr;
212            }
213            compliance::handle_analyze(args, http, out).await
214        }
215        ComplianceCommands::ComplianceReport(mut args) => {
216            if !std::path::Path::new(&args.target).exists()
217                && let Some((addr, _chain)) = resolve_address_book_input(&args.target, config)?
218            {
219                args.target = addr;
220            }
221            compliance::handle_compliance_report(args, http, out).await
222        }
223    };
224    result.map_err(|e| from_anyhow(e, ScopeError::Compliance))
225}
226
227/// A command error from an `anyhow` handler. An I/O error keeps its kind
228/// (a closed pipe stays a closed pipe) and a `ScopeError` stays itself;
229/// anything else is wrapped by `kind` with its message.
230fn from_anyhow(e: anyhow::Error, kind: fn(String) -> ScopeError) -> ScopeError {
231    match e.downcast::<std::io::Error>() {
232        Ok(io) => ScopeError::IoError(io),
233        Err(e) => match e.downcast::<ScopeError>() {
234            Ok(scope) => scope,
235            Err(e) => kind(e.to_string()),
236        },
237    }
238}
239
240/// Builds the transport that `config` and the environment select (see
241/// `scope::http::shared`) and prints its notices. Each call builds a new one.
242///
243/// # Errors
244///
245/// Returns the error when the transport cannot be built.
246pub fn create_http_client(config: &Config, out: &Output) -> Result<Arc<dyn HttpClient>> {
247    create_http_client_with(config, out, &|k| std::env::var(k).ok())
248}
249
250/// [`create_http_client`], reading environment variables through `env`.
251fn create_http_client_with(
252    config: &Config,
253    out: &Output,
254    env: &dyn Fn(&str) -> Option<String>,
255) -> Result<Arc<dyn HttpClient>> {
256    create_http_client_offering(config, out, env, false)
257}
258
259/// Builds the transport. With `offer`, when Ghola is enabled but not
260/// installed, it asks whether to install it and, on a yes, builds again
261/// with the new binary (which starts the sidecar).
262fn create_http_client_offering(
263    config: &Config,
264    out: &Output,
265    env: &dyn Fn(&str) -> Option<String>,
266    offer: bool,
267) -> Result<Arc<dyn HttpClient>> {
268    let mut built = scope::http::shared::build_with(config, env)?;
269    if offer && built.ghola_missing && crate::cli::ghola_offer::offer_for_transport(out).is_some() {
270        built = scope::http::shared::build_with(config, env)?;
271    }
272    for notice in &built.notices {
273        note!(out, "{notice}");
274    }
275    Ok(built.client)
276}
277
278/// The transport for the TUI after `setup` saved `config`: a new one, so
279/// the saved transport settings apply. Replay and record keep `current`:
280/// it counts the responses served and the fixtures written in this run,
281/// and `setup` cannot change the mode.
282fn transport_for_reload(
283    config: &Config,
284    current: &Arc<dyn HttpClient>,
285    out: &Output,
286    env: &dyn Fn(&str) -> Option<String>,
287) -> Result<Arc<dyn HttpClient>> {
288    use scope::http::shared::{Kind, configured_kind_with};
289    match configured_kind_with(config, env) {
290        Kind::Replay | Kind::Record => Ok(current.clone()),
291        Kind::Native | Kind::Ghola => create_http_client_with(config, out, env),
292    }
293}
294
295// ============================================================================
296// Unit Tests
297// ============================================================================
298
299#[cfg(test)]
300pub(crate) mod tests {
301    /// The mock factory with a transport that never reaches the network:
302    /// every request it is sent misses (a replay of an empty folder). The
303    /// mock's own transport is a real one, which handlers that call
304    /// `clients.http()` (Etherscan with a configured key, venues, discover)
305    /// would use.
306    pub(crate) struct OfflineFactory {
307        pub(crate) mock: scope::chains::mocks::MockClientFactory,
308        http: std::sync::Arc<dyn scope::http::HttpClient>,
309        _fixtures: tempfile::TempDir,
310    }
311
312    impl OfflineFactory {
313        pub(crate) fn new() -> Self {
314            let fixtures = tempfile::tempdir().unwrap();
315            Self {
316                mock: scope::chains::mocks::MockClientFactory::new(),
317                http: std::sync::Arc::new(scope::http::replay::ReplayHttpClient::replay(
318                    fixtures.path().to_path_buf(),
319                )),
320                _fixtures: fixtures,
321            }
322        }
323    }
324
325    impl scope::chains::ChainClientFactory for OfflineFactory {
326        fn create_chain_client(
327            &self,
328            chain: &str,
329        ) -> scope::error::Result<Box<dyn scope::chains::ChainClient>> {
330            self.mock.create_chain_client(chain)
331        }
332
333        fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
334            self.mock.create_dex_client()
335        }
336
337        fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
338            self.http.clone()
339        }
340    }
341
342    #[test]
343    fn test_a_config_source_reads_its_file_and_applies_ai() {
344        use super::ConfigSource;
345        use scope::config::OutputFormat;
346        let dir = tempfile::tempdir().unwrap();
347        let path = dir.path().join("c.yaml");
348        std::fs::write(&path, "output:\n  format: json\n").unwrap();
349        let plain = ConfigSource {
350            path: Some(path.clone()),
351            ai: false,
352        };
353        assert_eq!(plain.load().unwrap().output.format, OutputFormat::Json);
354        let ai = ConfigSource {
355            path: Some(path),
356            ai: true,
357        };
358        assert_eq!(ai.load().unwrap().output.format, OutputFormat::Markdown);
359    }
360
361    #[test]
362    fn test_anyhow_io_errors_keep_their_kind() {
363        // A closed pipe in `compliance … | head` must still read as a
364        // closed pipe, so the CLI exits quietly.
365        let pipe = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "closed");
366        let e = super::from_anyhow(pipe.into(), ScopeError::Compliance);
367        assert!(
368            matches!(&e, ScopeError::IoError(io) if io.kind() == std::io::ErrorKind::BrokenPipe)
369        );
370        let scope = super::from_anyhow(
371            ScopeError::NotFound("x".into()).into(),
372            ScopeError::Compliance,
373        );
374        assert!(matches!(scope, ScopeError::NotFound(_)));
375        let other = super::from_anyhow(anyhow::anyhow!("bad"), ScopeError::Compliance);
376        assert_eq!(
377            other.to_string(),
378            ScopeError::Compliance("bad".into()).to_string()
379        );
380    }
381
382    use super::*;
383    use clap::Parser;
384    use scope::http::shared::Kind;
385
386    fn quiet() -> Output {
387        Output::capture().0
388    }
389
390    #[test]
391    fn test_create_http_client_builds_a_new_transport_each_call() {
392        // Nothing is installed for the process (#59): each caller gets its own.
393        let config = Config::default();
394        let (out, captured) = Output::capture();
395        let a = create_http_client_with(&config, &out, &|_| None).unwrap();
396        let b = create_http_client_with(&config, &out, &|_| None).unwrap();
397        assert!(!Arc::ptr_eq(&a, &b));
398        // Default config selects native, which has nothing to report.
399        assert!(captured.err().is_empty(), "{}", captured.err());
400    }
401
402    #[test]
403    fn test_a_reload_builds_the_transport_again() {
404        // After `setup`, transport settings apply without a restart.
405        let (out, _) = Output::capture();
406        let current = create_http_client_with(&Config::default(), &out, &|_| None).unwrap();
407        let next = transport_for_reload(&Config::default(), &current, &out, &|_| None).unwrap();
408        assert!(!Arc::ptr_eq(&current, &next));
409    }
410
411    #[test]
412    fn test_a_reload_keeps_the_replay_and_record_transport() {
413        // A new recorder would overwrite this run's fixtures, and a new
414        // replayer would serve each fixture from its first response again.
415        let (out, _) = Output::capture();
416        let current = create_http_client_with(&Config::default(), &out, &|_| None).unwrap();
417        for mode in ["replay", "record"] {
418            let env = move |k: &str| match k {
419                "SCOPE_HTTP_MODE" => Some(mode.to_string()),
420                "SCOPE_HTTP_FIXTURES" => Some("unused".to_string()),
421                _ => None,
422            };
423            let next = transport_for_reload(&Config::default(), &current, &out, &env).unwrap();
424            assert!(Arc::ptr_eq(&current, &next), "{mode}");
425        }
426    }
427
428    /// Selection for a Ghola config, without installing it for the process.
429    /// The test machine may or may not run a Ghola sidecar: the result must
430    /// be Ghola with no notice, or native with notices that say so.
431    fn assert_ghola_selection(stealth: bool) {
432        let mut config = Config::default();
433        config.ghola.enabled = true;
434        config.ghola.stealth = stealth;
435        let built = scope::http::shared::build_with(&config, &|_| None).unwrap();
436        match built.kind {
437            Kind::Ghola => assert!(built.notices.is_empty(), "{:?}", built.notices),
438            Kind::Native => {
439                let all = built.notices.join("\n");
440                assert!(all.contains("Ghola"), "{all}");
441                assert!(all.contains("Falling back"), "{all}");
442            }
443            other => panic!("unexpected kind {other:?}"),
444        }
445    }
446
447    #[test]
448    fn test_transport_selection_default_config_is_native() {
449        let built = scope::http::shared::build_with(&Config::default(), &|_| None).unwrap();
450        assert_eq!(built.kind, Kind::Native);
451        assert!(built.notices.is_empty(), "{:?}", built.notices);
452    }
453
454    #[test]
455    fn test_transport_selection_ghola_enabled() {
456        assert_ghola_selection(true);
457    }
458
459    #[test]
460    fn test_transport_selection_ghola_no_stealth() {
461        assert_ghola_selection(false);
462    }
463
464    #[tokio::test]
465    async fn test_dispatch_writes_completions_to_output() {
466        // The TUI shows dispatch output in a pane: completions must go to
467        // the injected writer, not straight to the process stdout.
468        let cli = Cli::try_parse_from(["scope", "completions", "bash"]).unwrap();
469        let (o, cap) = Output::capture();
470        dispatch(
471            cli.command,
472            &ConfigSource::default(),
473            &Config::default(),
474            &o,
475        )
476        .await
477        .unwrap();
478        assert!(cap.out().contains("_scope()"));
479    }
480
481    #[tokio::test]
482    async fn test_dispatch_fails_when_output_cannot_be_written() {
483        // `scope completions zsh > /full/disk` must exit non-zero, not
484        // report success with no file content.
485        let cli = Cli::try_parse_from(["scope", "completions", "zsh"]).unwrap();
486        let o = Output::from_writers(
487            crate::cli::output::tests::ClosedPipe,
488            crate::cli::output::tests::ClosedPipe,
489        );
490        let err = dispatch(
491            cli.command,
492            &ConfigSource::default(),
493            &Config::default(),
494            &o,
495        )
496        .await
497        .unwrap_err();
498        assert!(
499            matches!(err, ScopeError::IoError(ref e) if e.kind() == std::io::ErrorKind::BrokenPipe)
500        );
501    }
502
503    #[tokio::test]
504    async fn test_dispatch_rejects_web() {
505        // `web` needs the scope-bca crate; dispatch must fail, not panic.
506        let cli = Cli::try_parse_from(["scope", "web"]).unwrap();
507        let err = dispatch(
508            cli.command,
509            &ConfigSource::default(),
510            &Config::default(),
511            &quiet(),
512        )
513        .await
514        .unwrap_err();
515        assert!(err.to_string().contains("scope web"));
516    }
517
518    // ---- dispatch_with routes to the real handlers (offline, mocks) ----
519
520    const ADDR: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
521
522    async fn run_line(args: &[&str]) -> (Result<()>, String) {
523        let mut argv = vec!["scope"];
524        argv.extend_from_slice(args);
525        let cli = Cli::try_parse_from(argv).unwrap();
526        let (o, cap) = Output::capture_merged();
527        let factory = scope::chains::mocks::MockClientFactory::new();
528        let res = dispatch_with(cli.command, &Config::default(), &factory, &o).await;
529        (res, cap.out())
530    }
531
532    #[tokio::test]
533    async fn test_dispatch_with_crawl_by_address() {
534        let (res, out) = run_line(&["crawl", ADDR, "--yes", "--no-charts"]).await;
535        res.unwrap();
536        assert!(!out.trim().is_empty());
537    }
538
539    #[tokio::test]
540    async fn test_dispatch_with_token_health() {
541        let (res, out) = run_line(&["token-health", ADDR]).await;
542        res.unwrap();
543        assert!(!out.trim().is_empty());
544    }
545
546    #[tokio::test]
547    async fn test_dispatch_with_report_batch_writes_the_file() {
548        let dir = tempfile::tempdir().unwrap();
549        let path = dir.path().join("batch.md");
550        let (res, _) = run_line(&[
551            "report",
552            "batch",
553            "--addresses",
554            ADDR,
555            "--output",
556            path.to_str().unwrap(),
557        ])
558        .await;
559        res.unwrap();
560        assert!(std::fs::read_to_string(&path).unwrap().contains(ADDR));
561    }
562
563    #[tokio::test]
564    async fn test_dispatch_with_refuses_the_tui() {
565        // Only `dispatch` carries the TUI's exit code, so dispatch_with
566        // (which the TUI itself calls) must refuse `interactive`.
567        let (res, _) = run_line(&["interactive"]).await;
568        assert!(res.unwrap_err().to_string().contains("scope tui"));
569    }
570
571    #[tokio::test]
572    async fn test_compliance_errors_are_labelled_as_compliance_errors() {
573        // Scripts match on the error kind, so a handler failure must come
574        // back as ScopeError::Compliance, not as the handler's own error.
575        let dir = tempfile::tempdir().unwrap();
576        let empty = dir.path().join("none.txt");
577        std::fs::write(&empty, "# no addresses\n").unwrap();
578        let out = dir.path().join("report.txt");
579        let (res, _) = run_line(&[
580            "compliance",
581            "compliance-report",
582            empty.to_str().unwrap(),
583            "--jurisdiction",
584            "us",
585            "--output",
586            out.to_str().unwrap(),
587        ])
588        .await;
589        let err = res.unwrap_err();
590        assert!(matches!(err, ScopeError::Compliance(_)), "{err:?}");
591        assert!(err.to_string().contains("No addresses to analyze"));
592
593        let (res, _) = run_line(&["compliance", "risk", "not-an-address"]).await;
594        assert!(matches!(res.unwrap_err(), ScopeError::Compliance(_)));
595    }
596
597    /// Runs `scope <args>` through `dispatch_with` on an offline factory.
598    /// Returns the result, stdout and stderr (where notes go).
599    async fn run_offline(args: &[&str], config: &Config) -> (Result<()>, String, String) {
600        let mut argv = vec!["scope"];
601        argv.extend_from_slice(args);
602        let cli = Cli::try_parse_from(argv).unwrap();
603        let (o, cap) = Output::capture();
604        let factory = OfflineFactory::new();
605        let res = dispatch_with(cli.command, config, &factory, &o).await;
606        (res, cap.out(), cap.err())
607    }
608
609    #[tokio::test]
610    async fn test_compliance_resolves_a_saved_label_to_its_address() {
611        // `@label` reads the address book for risk, analyze and the report,
612        // so they look up the saved address, not the label text.
613        let dir = tempfile::tempdir().unwrap();
614        let mut config = Config::default();
615        config.address_book.data_dir = Some(dir.path().to_path_buf());
616        let (res, _, _) = run_offline(
617            &["address-book", "add", ADDR, "--label", "treasury"],
618            &config,
619        )
620        .await;
621        res.unwrap();
622        let (res, out, _) = run_offline(&["address-book", "list"], &config).await;
623        res.unwrap();
624        assert!(out.contains("treasury"), "{out}");
625
626        let (res, out, _) = run_offline(
627            &["compliance", "risk", "@treasury", "--format", "json"],
628            &config,
629        )
630        .await;
631        res.unwrap();
632        let v: serde_json::Value = serde_json::from_str(out.trim()).expect(&out);
633        assert_eq!(v["address"], ADDR, "{out}");
634        assert_eq!(v["chain"], "ethereum", "the saved chain: {out}");
635
636        let (res, _, err) = run_offline(&["compliance", "analyze", "@treasury"], &config).await;
637        res.unwrap();
638        assert!(
639            err.contains(&format!("Analyzing patterns for {ADDR}")),
640            "{err}"
641        );
642
643        let report = dir.path().join("report.json");
644        let (res, _, _) = run_offline(
645            &[
646                "compliance",
647                "compliance-report",
648                "@treasury",
649                "--jurisdiction",
650                "us",
651                "--output",
652                report.to_str().unwrap(),
653            ],
654            &config,
655        )
656        .await;
657        res.unwrap();
658        let text = std::fs::read_to_string(&report).unwrap();
659        assert!(text.contains(ADDR), "{text}");
660    }
661
662    #[tokio::test]
663    async fn test_compliance_trace_runs_its_handler() {
664        let hash = "0x0000000000000000000000000000000000000000000000000000000000000001";
665        let (res, _, err) = run_offline(&["compliance", "trace", hash], &Config::default()).await;
666        // With an Etherscan key on the test machine the lookup runs and,
667        // with no fixture to serve, fails as a compliance error; with none
668        // the handler says how to add one.
669        match res {
670            Ok(()) => assert!(
671                err.contains(&format!("Tracing transaction {hash}")),
672                "{err}"
673            ),
674            Err(e) => {
675                assert!(matches!(e, ScopeError::Compliance(_)), "{e:?}");
676                assert!(e.to_string().contains("cannot trace transaction"), "{e}");
677            }
678        }
679    }
680
681    #[tokio::test]
682    async fn test_discover_failures_are_labelled_as_discovery_errors() {
683        // The offline transport serves nothing, so the listing fails; the
684        // error must say it came from discover.
685        let (res, _, _) = run_offline(&["discover"], &Config::default()).await;
686        let err = res.unwrap_err();
687        assert!(matches!(err, ScopeError::Discovery(_)), "{err:?}");
688    }
689
690    #[tokio::test]
691    async fn test_dispatch_with_insights_and_contract() {
692        let (res, out, _) = run_offline(&["insights", ADDR], &Config::default()).await;
693        res.unwrap();
694        assert!(!out.trim().is_empty());
695        // The mock chain client reads no code; contract's error comes back
696        // as the chain error it is, not relabelled.
697        let (res, out, _) = run_offline(&["contract", ADDR, "--json"], &Config::default()).await;
698        let err = res.unwrap_err();
699        assert!(matches!(err, ScopeError::Chain(_)), "{err:?}");
700        assert!(out.is_empty(), "no report on a failed analysis: {out}");
701    }
702
703    #[test]
704    fn test_create_http_client_selects_the_configured_transport() {
705        let (out, captured) = Output::capture();
706        let client = create_http_client(&Config::default(), &out);
707        assert!(client.is_ok());
708        assert!(captured.err().is_empty(), "{}", captured.err());
709    }
710
711    #[tokio::test]
712    async fn test_compliance_unknown_label_fails_before_any_lookup() {
713        // A typo in `@label` must stop at the address book (a NotFound
714        // error that lists the labels), never reach a handler.
715        let dir = tempfile::tempdir().unwrap();
716        let mut config = Config::default();
717        config.address_book.data_dir = Some(dir.path().to_path_buf());
718        let factory = scope::chains::mocks::MockClientFactory::new();
719        for args in [
720            vec!["compliance", "risk", "@nobody"],
721            vec!["compliance", "analyze", "@nobody"],
722            vec![
723                "compliance",
724                "compliance-report",
725                "@nobody",
726                "--jurisdiction",
727                "eu",
728                "--output",
729                "unused.txt",
730            ],
731        ] {
732            let mut argv = vec!["scope"];
733            argv.extend_from_slice(&args);
734            let cli = Cli::try_parse_from(argv).unwrap();
735            let (o, cap) = Output::capture_merged();
736            let err = dispatch_with(cli.command, &config, &factory, &o)
737                .await
738                .unwrap_err();
739            assert!(matches!(err, ScopeError::NotFound(_)), "{args:?}: {err:?}");
740            assert!(err.to_string().contains("@nobody"), "{args:?}");
741            assert!(!cap.out().contains("Analyzing"), "handler must not run");
742        }
743    }
744}