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