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