1use 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#[derive(Debug, Clone, Default)]
25pub struct ConfigSource {
26 pub path: Option<PathBuf>,
28 pub ai: bool,
30}
31
32impl ConfigSource {
33 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 pub fn apply(&self, config: &mut Config) {
46 if self.ai {
47 config.output.format = OutputFormat::Markdown;
48 }
49 }
50}
51
52pub async fn dispatch(
65 command: Commands,
66 source: &ConfigSource,
67 config: &Config,
68 out: &Output,
69) -> Result<i32> {
70 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 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 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
121pub 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 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 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
193fn 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
207async 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
247fn 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
260pub 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
270fn 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
279fn 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
298fn 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#[cfg(test)]
320pub(crate) mod tests {
321 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 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 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 assert!(captured.err().is_empty(), "{}", captured.err());
420 }
421
422 #[test]
423 fn test_a_reload_builds_the_transport_again() {
424 let (out, _) = Output::capture();
426 let current = create_http_client_with(&Config::default(), &out, &|_| None).unwrap();
427 let next = transport_for_reload(&Config::default(), ¤t, &out, &|_| None).unwrap();
428 assert!(!Arc::ptr_eq(¤t, &next));
429 }
430
431 #[test]
432 fn test_a_reload_keeps_the_replay_and_record_transport() {
433 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(), ¤t, &out, &env).unwrap();
444 assert!(Arc::ptr_eq(¤t, &next), "{mode}");
445 }
446 }
447
448 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 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 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 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 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 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 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 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 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 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 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 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 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}