1use crate::cli::output::Output;
9use crate::cli::{Cli, Commands};
10use crate::{note, out};
11use clap::CommandFactory;
12use clap_complete::generate;
13use scope::Config;
14use scope::chains::{ChainClientFactory, DefaultClientFactory};
15use scope::error::{Result, ScopeError};
16use scope::http::HttpClient;
17use std::sync::Arc;
18
19pub async fn dispatch(command: Commands, config: &Config, out: &Output) -> Result<i32> {
31 let http: Arc<dyn HttpClient> = create_http_client(config, out)?;
32 let factory = DefaultClientFactory {
33 chains_config: config.chains.clone(),
34 http,
35 };
36 if let Commands::Interactive(args) = command {
38 let exit = crate::cli::interactive::run(args, config, &factory).await?;
39 return Ok(exit.code);
40 }
41 dispatch_with(command, config, &factory, out)
42 .await
43 .map(|()| 0)
44}
45
46pub async fn dispatch_with(
57 command: Commands,
58 config: &Config,
59 clients: &dyn ChainClientFactory,
60 out: &Output,
61) -> Result<()> {
62 match command {
63 Commands::Completions(args) => {
64 let mut cmd = Cli::command();
65 let mut buf = Vec::new();
66 generate(args.shell, &mut cmd, "scope", &mut buf);
67 out!(out, "{}", String::from_utf8_lossy(&buf))?;
68 Ok(())
69 }
70 Commands::Address(args) => crate::cli::address::run(args, config, clients, out).await,
71 Commands::Tx(args) => crate::cli::tx::run(args, config, clients, out).await,
72 Commands::Crawl(args) => crate::cli::crawl::run(args, config, clients, out).await,
73 Commands::AddressBook(args) => {
74 crate::cli::address_book::run(args, config, clients, out).await
75 }
76 Commands::Export(args) => crate::cli::export::run(args, config, clients, out).await,
77 Commands::Interactive(_) => Err(ScopeError::Other(
80 "the TUI cannot start from here; start it with `scope tui`".into(),
81 )),
82 Commands::Monitor(args) => {
83 crate::cli::monitor::run_direct(args, config, clients, out).await
84 }
85 Commands::Setup(args) => crate::cli::setup::run(args, config, out).await,
86 Commands::Compliance(compliance_cmd) => {
87 dispatch_compliance(compliance_cmd, config, out).await
88 }
89 Commands::Market(cmd) => crate::cli::market::run(cmd, config, clients, out).await,
90 Commands::TokenHealth(args) => {
91 crate::cli::token_health::run(args, config, clients, out).await
92 }
93 Commands::Venues(cmd) => crate::cli::venues::run(cmd, out),
94 Commands::Report(cmd) => crate::cli::report::run(cmd, config, clients, out).await,
95 Commands::Discover(args) => crate::cli::discover::run(args, config.output.format, out)
96 .await
97 .map_err(|e| match e {
98 ScopeError::IoError(_) => e,
100 other => ScopeError::Discovery(other.to_string()),
101 }),
102 Commands::Insights(args) => crate::cli::insights::run(args, config, clients, out).await,
103 Commands::Contract(ref args) => crate::cli::contract::run(args, config, clients, out).await,
104 Commands::Web(_) => Err(ScopeError::Other(
105 "the web server cannot run from here; start it with `scope web`".into(),
106 )),
107 }
108}
109
110async fn dispatch_compliance(
113 cmd: crate::cli::compliance::ComplianceCommands,
114 config: &Config,
115 out: &Output,
116) -> Result<()> {
117 use crate::cli::address_book::resolve_address_book_input;
118 use crate::cli::compliance::{self, ComplianceCommands};
119
120 let result = match cmd {
121 ComplianceCommands::Risk(mut args) => {
122 if let Some((addr, chain)) = resolve_address_book_input(&args.address, config)? {
123 args.address = addr;
124 if args.chain.is_none() {
125 args.chain = Some(chain);
126 }
127 }
128 compliance::handle_risk(args, out).await
129 }
130 ComplianceCommands::Trace(args) => compliance::handle_trace(args, out).await,
131 ComplianceCommands::Analyze(mut args) => {
132 if let Some((addr, _chain)) = resolve_address_book_input(&args.address, config)? {
133 args.address = addr;
134 }
135 compliance::handle_analyze(args, out).await
136 }
137 ComplianceCommands::ComplianceReport(mut args) => {
138 if !std::path::Path::new(&args.target).exists()
139 && let Some((addr, _chain)) = resolve_address_book_input(&args.target, config)?
140 {
141 args.target = addr;
142 }
143 compliance::handle_compliance_report(args, out).await
144 }
145 };
146 result.map_err(|e| from_anyhow(e, ScopeError::Compliance))
147}
148
149fn from_anyhow(e: anyhow::Error, kind: fn(String) -> ScopeError) -> ScopeError {
153 match e.downcast::<std::io::Error>() {
154 Ok(io) => ScopeError::IoError(io),
155 Err(e) => match e.downcast::<ScopeError>() {
156 Ok(scope) => scope,
157 Err(e) => kind(e.to_string()),
158 },
159 }
160}
161
162pub fn create_http_client(config: &Config, out: &Output) -> Result<Arc<dyn HttpClient>> {
169 let built = scope::http::init(config)?;
170 for notice in &built.notices {
171 note!(out, "{notice}");
172 }
173 Ok(built.client)
174}
175
176#[cfg(test)]
181mod tests {
182 #[test]
183 fn test_anyhow_io_errors_keep_their_kind() {
184 let pipe = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "closed");
187 let e = super::from_anyhow(pipe.into(), ScopeError::Compliance);
188 assert!(
189 matches!(&e, ScopeError::IoError(io) if io.kind() == std::io::ErrorKind::BrokenPipe)
190 );
191 let scope = super::from_anyhow(
192 ScopeError::NotFound("x".into()).into(),
193 ScopeError::Compliance,
194 );
195 assert!(matches!(scope, ScopeError::NotFound(_)));
196 let other = super::from_anyhow(anyhow::anyhow!("bad"), ScopeError::Compliance);
197 assert_eq!(
198 other.to_string(),
199 ScopeError::Compliance("bad".into()).to_string()
200 );
201 }
202
203 use super::*;
204 use clap::Parser;
205 use scope::http::shared::Kind;
206
207 fn quiet() -> Output {
208 Output::capture().0
209 }
210
211 #[test]
212 fn test_create_http_client_returns_the_installed_transport() {
213 let config = Config::default();
217 let (out, captured) = Output::capture();
218 let a = create_http_client(&config, &out).unwrap();
219 let b = create_http_client(&config, &out).unwrap();
220 assert!(Arc::ptr_eq(&a, &b));
221 assert!(captured.err().is_empty(), "{}", captured.err());
223 }
224
225 fn assert_ghola_selection(stealth: bool) {
229 let mut config = Config::default();
230 config.ghola.enabled = true;
231 config.ghola.stealth = stealth;
232 let built = scope::http::shared::build_with(&config, &|_| None).unwrap();
233 match built.kind {
234 Kind::Ghola => assert!(built.notices.is_empty(), "{:?}", built.notices),
235 Kind::Native => {
236 let all = built.notices.join("\n");
237 assert!(all.contains("Ghola"), "{all}");
238 assert!(all.contains("Falling back"), "{all}");
239 }
240 other => panic!("unexpected kind {other:?}"),
241 }
242 }
243
244 #[test]
245 fn test_transport_selection_default_config_is_native() {
246 let built = scope::http::shared::build_with(&Config::default(), &|_| None).unwrap();
247 assert_eq!(built.kind, Kind::Native);
248 assert!(built.notices.is_empty(), "{:?}", built.notices);
249 }
250
251 #[test]
252 fn test_transport_selection_ghola_enabled() {
253 assert_ghola_selection(true);
254 }
255
256 #[test]
257 fn test_transport_selection_ghola_no_stealth() {
258 assert_ghola_selection(false);
259 }
260
261 #[tokio::test]
262 async fn test_dispatch_writes_completions_to_output() {
263 let cli = Cli::try_parse_from(["scope", "completions", "bash"]).unwrap();
266 let (o, cap) = Output::capture();
267 dispatch(cli.command, &Config::default(), &o).await.unwrap();
268 assert!(cap.out().contains("_scope()"));
269 }
270
271 #[tokio::test]
272 async fn test_dispatch_fails_when_output_cannot_be_written() {
273 let cli = Cli::try_parse_from(["scope", "completions", "zsh"]).unwrap();
276 let o = Output::from_writers(
277 crate::cli::output::tests::ClosedPipe,
278 crate::cli::output::tests::ClosedPipe,
279 );
280 let err = dispatch(cli.command, &Config::default(), &o)
281 .await
282 .unwrap_err();
283 assert!(
284 matches!(err, ScopeError::IoError(ref e) if e.kind() == std::io::ErrorKind::BrokenPipe)
285 );
286 }
287
288 #[tokio::test]
289 async fn test_dispatch_rejects_web() {
290 let cli = Cli::try_parse_from(["scope", "web"]).unwrap();
292 let err = dispatch(cli.command, &Config::default(), &quiet())
293 .await
294 .unwrap_err();
295 assert!(err.to_string().contains("scope web"));
296 }
297
298 const ADDR: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
301
302 async fn run_line(args: &[&str]) -> (Result<()>, String) {
303 let mut argv = vec!["scope"];
304 argv.extend_from_slice(args);
305 let cli = Cli::try_parse_from(argv).unwrap();
306 let (o, cap) = Output::capture_merged();
307 let factory = scope::chains::mocks::MockClientFactory::new();
308 let res = dispatch_with(cli.command, &Config::default(), &factory, &o).await;
309 (res, cap.out())
310 }
311
312 #[tokio::test]
313 async fn test_dispatch_with_crawl_by_address() {
314 let (res, out) = run_line(&["crawl", ADDR, "--yes", "--no-charts"]).await;
315 res.unwrap();
316 assert!(!out.trim().is_empty());
317 }
318
319 #[tokio::test]
320 async fn test_dispatch_with_token_health() {
321 let (res, out) = run_line(&["token-health", ADDR]).await;
322 res.unwrap();
323 assert!(!out.trim().is_empty());
324 }
325
326 #[tokio::test]
327 async fn test_dispatch_with_report_batch_writes_the_file() {
328 let dir = tempfile::tempdir().unwrap();
329 let path = dir.path().join("batch.md");
330 let (res, _) = run_line(&[
331 "report",
332 "batch",
333 "--addresses",
334 ADDR,
335 "--output",
336 path.to_str().unwrap(),
337 ])
338 .await;
339 res.unwrap();
340 assert!(std::fs::read_to_string(&path).unwrap().contains(ADDR));
341 }
342
343 #[tokio::test]
344 async fn test_dispatch_with_refuses_the_tui() {
345 let (res, _) = run_line(&["interactive"]).await;
348 assert!(res.unwrap_err().to_string().contains("scope tui"));
349 }
350
351 #[tokio::test]
352 async fn test_compliance_errors_are_labelled_as_compliance_errors() {
353 let dir = tempfile::tempdir().unwrap();
356 let empty = dir.path().join("none.txt");
357 std::fs::write(&empty, "# no addresses\n").unwrap();
358 let out = dir.path().join("report.txt");
359 let (res, _) = run_line(&[
360 "compliance",
361 "compliance-report",
362 empty.to_str().unwrap(),
363 "--jurisdiction",
364 "us",
365 "--output",
366 out.to_str().unwrap(),
367 ])
368 .await;
369 let err = res.unwrap_err();
370 assert!(matches!(err, ScopeError::Compliance(_)), "{err:?}");
371 assert!(err.to_string().contains("No addresses to analyze"));
372
373 let (res, _) = run_line(&["compliance", "risk", "not-an-address"]).await;
374 assert!(matches!(res.unwrap_err(), ScopeError::Compliance(_)));
375 }
376
377 #[tokio::test]
378 async fn test_compliance_unknown_label_fails_before_any_lookup() {
379 let dir = tempfile::tempdir().unwrap();
382 let mut config = Config::default();
383 config.address_book.data_dir = Some(dir.path().to_path_buf());
384 let factory = scope::chains::mocks::MockClientFactory::new();
385 for args in [
386 vec!["compliance", "risk", "@nobody"],
387 vec!["compliance", "analyze", "@nobody"],
388 vec![
389 "compliance",
390 "compliance-report",
391 "@nobody",
392 "--jurisdiction",
393 "eu",
394 "--output",
395 "unused.txt",
396 ],
397 ] {
398 let mut argv = vec!["scope"];
399 argv.extend_from_slice(&args);
400 let cli = Cli::try_parse_from(argv).unwrap();
401 let (o, cap) = Output::capture_merged();
402 let err = dispatch_with(cli.command, &config, &factory, &o)
403 .await
404 .unwrap_err();
405 assert!(matches!(err, ScopeError::NotFound(_)), "{args:?}: {err:?}");
406 assert!(err.to_string().contains("@nobody"), "{args:?}");
407 assert!(!cap.out().contains("Analyzing"), "handler must not run");
408 }
409 }
410}