1use crate::cli::output::Output;
28use crate::cli::progress::{self, Spinner};
29use crate::cli::resolve::{DexCandidates, ResolveContext, VenueKind, picker_for, resolve};
30use crate::outln;
31use clap::Args;
32use scope::chains::{
33 ChainClientFactory, DexClient, DexDataSource, DexPair, Token, TokenAnalytics, TokenHolder,
34 infer_chain_from_address,
35};
36use scope::config::{Config, OutputFormat};
37use scope::display::{charts, report};
38use scope::error::{Result, ScopeError};
39use scope::tokens::TokenAliases;
40use std::path::PathBuf;
41
42#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
44pub enum Period {
45 #[value(name = "1h")]
47 Hour1,
48 #[default]
50 #[value(name = "24h")]
51 Hour24,
52 #[value(name = "7d")]
54 Day7,
55 #[value(name = "30d")]
57 Day30,
58}
59
60impl Period {
61 pub fn as_seconds(&self) -> i64 {
63 match self {
64 Period::Hour1 => 3600,
65 Period::Hour24 => 86400,
66 Period::Day7 => 604800,
67 Period::Day30 => 2592000,
68 }
69 }
70
71 pub fn label(&self) -> &'static str {
73 match self {
74 Period::Hour1 => "1 Hour",
75 Period::Hour24 => "24 Hours",
76 Period::Day7 => "7 Days",
77 Period::Day30 => "30 Days",
78 }
79 }
80}
81
82#[derive(Debug, Args)]
84#[command(
85 after_help = "\x1b[1mExamples:\x1b[0m
86 scope crawl USDC
87 scope crawl @usdc-token \x1b[2m# address book shortcut\x1b[0m
88 scope crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --chain ethereum
89 scope crawl USDC --period 7d --report usdc_report.md
90 scope crawl PEPE --format json --no-charts",
91 after_long_help = "\x1b[1mExamples:\x1b[0m
92
93 \x1b[1m$ scope crawl USDC\x1b[0m
94
95 Key Metrics
96 ==================================================
97 Price: $0.999900
98 24h Change: -0.01%
99 24h Volume: $5.00M
100 Liquidity: $100.00M
101 Market Cap: $30.00B
102 FDV: $30.00B
103
104 Top Trading Pairs
105 ==================================================
106 1. Uniswap V3 USDC/WETH - $5.00M ($50.00M liq)
107 2. Uniswap V2 USDC/USDT - $2.50M ($25.00M liq)
108 ...
109
110 \x1b[1m$ scope crawl PEPE --period 7d --no-charts\x1b[0m
111
112 Key Metrics
113 ==================================================
114 Price: $0.000012
115 24h Change: +8.50%
116 24h Volume: $120.00M
117 Liquidity: $45.00M
118 Market Cap: $5.00B
119 ...
120
121 \x1b[1m$ scope crawl USDC --report usdc.md\x1b[0m
122
123 Key Metrics
124 ==================================================
125 Price: $0.999900
126 ...
127 Report saved to usdc.md"
128)]
129pub struct CrawlArgs {
130 pub token: String,
137
138 #[arg(short, long, default_value = scope::chains::DEFAULT_CHAIN)]
143 pub chain: String,
144
145 #[arg(short, long, default_value = "24h")]
147 pub period: Period,
148
149 #[arg(long, default_value = "10")]
151 pub holders_limit: u32,
152
153 #[arg(short, long, default_value = "table")]
155 pub format: OutputFormat,
156
157 #[arg(long)]
159 pub no_charts: bool,
160
161 #[arg(long, value_name = "PATH")]
163 pub report: Option<PathBuf>,
164
165 #[arg(long)]
168 pub yes: bool,
169
170 #[arg(long)]
172 pub save: bool,
173
174 #[arg(long = "token", value_name = "CONTRACT")]
176 pub token_contract: Option<String>,
177}
178
179#[derive(Debug, Clone)]
181struct ResolvedToken {
182 address: String,
183 chain: String,
184 alias_info: Option<(String, String)>,
186}
187
188async fn resolve_token_input(
198 args: &CrawlArgs,
199 aliases: &mut TokenAliases,
200 dex_client: &dyn DexDataSource,
201 http: std::sync::Arc<dyn scope::http::HttpClient>,
202 spinner: Option<&Spinner>,
203 out: &Output,
204) -> Result<ResolvedToken> {
205 let input = args.token.trim();
206
207 if TokenAliases::is_address(input) {
209 let chain = if args.chain == "ethereum" {
210 infer_chain_from_address(input)
211 .unwrap_or("ethereum")
212 .to_string()
213 } else {
214 args.chain.clone()
215 };
216 return Ok(ResolvedToken {
217 address: input.to_string(),
218 chain,
219 alias_info: None,
220 });
221 }
222
223 let ctx = ResolveContext {
224 chain: (args.chain != "ethereum").then(|| args.chain.clone()),
225 venue: VenueKind::Dex,
226 token_flag: args.token_contract.clone(),
227 yes: args.yes,
228 save: args.save,
229 };
230 let source = DexCandidates {
231 dex: dex_client,
232 out,
233 fallback: Some(http),
234 };
235 progress::status(spinner, out, &format!("Searching for '{}'...", input));
236 let picker = picker_for(out, matches!(args.format, OutputFormat::Json));
237 let r = resolve(input, &ctx, &source, picker.as_ref(), aliases, out).await?;
238 Ok(ResolvedToken {
239 address: r.address.unwrap_or_default(),
240 chain: if r.chain.is_empty() {
241 args.chain.clone()
242 } else {
243 r.chain
244 },
245 alias_info: Some((r.symbol, r.name)),
246 })
247}
248
249pub async fn fetch_analytics_for_input(
254 token_input: &str,
255 chain: &str,
256 period: Period,
257 holders_limit: u32,
258 clients: &dyn ChainClientFactory,
259 spinner: Option<&crate::cli::progress::Spinner>,
260 out: &Output,
261) -> Result<TokenAnalytics> {
262 let args = CrawlArgs {
265 token: token_input.to_string(),
266 chain: chain.to_string(),
267 period,
268 holders_limit,
269 format: OutputFormat::Table,
270 no_charts: true,
271 report: None,
272 yes: false,
273 save: false,
274 token_contract: None,
275 };
276 let mut aliases = TokenAliases::load();
277 let dex_client = clients.create_dex_client();
278 let resolved = resolve_token_input(
279 &args,
280 &mut aliases,
281 dex_client.as_ref(),
282 clients.http(),
283 spinner,
284 out,
285 )
286 .await?;
287 let fetching = format!(
288 "Fetching analytics for {} on {}...",
289 resolved.address, resolved.chain
290 );
291 progress::status(spinner, out, &fetching);
292 let mut analytics =
293 fetch_token_analytics(&resolved.address, &resolved.chain, &args, clients).await?;
294 if let Some((symbol, name)) = &resolved.alias_info
295 && (analytics.token.symbol == "UNKNOWN" || analytics.token.name == "Unknown Token")
296 {
297 analytics.token.symbol = symbol.clone();
298 analytics.token.name = name.clone();
299 }
300 Ok(analytics)
301}
302
303pub async fn run(
308 mut args: CrawlArgs,
309 config: &Config,
310 clients: &dyn ChainClientFactory,
311 out: &Output,
312) -> Result<()> {
313 if let Some((address, chain)) =
315 crate::cli::address_book::resolve_address_book_input(&args.token, config)?
316 {
317 args.token = address;
318 if args.chain == "ethereum" {
319 args.chain = chain;
320 }
321 }
322
323 let mut aliases = TokenAliases::load();
325
326 let sp = crate::cli::progress::Spinner::new(
328 &format!("Crawling token {} on {}...", args.token, args.chain),
329 out,
330 );
331
332 let dex_client = clients.create_dex_client();
334 let resolved = resolve_token_input(
335 &args,
336 &mut aliases,
337 dex_client.as_ref(),
338 clients.http(),
339 Some(&sp),
340 out,
341 )
342 .await?;
343
344 tracing::info!(
345 token = %resolved.address,
346 chain = %resolved.chain,
347 period = ?args.period,
348 "Starting token crawl"
349 );
350
351 sp.set_message(format!(
352 "Fetching analytics for {} on {}...",
353 resolved.address, resolved.chain
354 ));
355
356 let mut analytics =
358 fetch_token_analytics(&resolved.address, &resolved.chain, &args, clients).await?;
359
360 sp.finish("Token data loaded.");
361
362 if (analytics.token.symbol == "UNKNOWN" || analytics.token.name == "Unknown Token")
364 && let Some((symbol, name)) = &resolved.alias_info
365 {
366 analytics.token.symbol = symbol.clone();
367 analytics.token.name = name.clone();
368 }
369
370 match args.format {
372 OutputFormat::Json => {
373 let json = serde_json::to_string_pretty(&analytics)?;
374 outln!(out, "{}", json)?;
375 }
376 OutputFormat::Csv => {
377 output_csv(&analytics, out)?;
378 }
379 OutputFormat::Table => {
380 output_table(&analytics, &args, out)?;
381 }
382 OutputFormat::Markdown => {
383 let md = report::generate_report(&analytics);
384 outln!(out, "{}", md)?;
385 }
386 }
387
388 if let Some(ref report_path) = args.report {
390 let markdown_report = report::generate_report(&analytics);
391 report::save_report(&markdown_report, report_path)?;
392 out.note_saved("Report", report_path.display());
393 }
394
395 Ok(())
396}
397
398async fn fetch_token_analytics(
400 token_address: &str,
401 chain: &str,
402 args: &CrawlArgs,
403 clients: &dyn ChainClientFactory,
404) -> Result<TokenAnalytics> {
405 let dex_client = clients.create_dex_client();
407
408 let dex_result = dex_client.get_token_data(chain, token_address).await;
410
411 match dex_result {
413 Ok(dex_data) => {
414 fetch_analytics_with_dex(token_address, chain, args, clients, dex_data).await
416 }
417 Err(ScopeError::NotFound(_)) => {
418 tracing::debug!("No DEX data, falling back to block explorer");
420 fetch_analytics_from_explorer(token_address, chain, args, clients).await
421 }
422 Err(e) => Err(e),
423 }
424}
425
426async fn fetch_analytics_with_dex(
428 token_address: &str,
429 chain: &str,
430 args: &CrawlArgs,
431 clients: &dyn ChainClientFactory,
432 dex_data: scope::chains::dex::DexTokenData,
433) -> Result<TokenAnalytics> {
434 let holders = fetch_holders(token_address, chain, args.holders_limit, clients).await?;
436
437 let token = Token {
439 contract_address: token_address.to_string(),
440 symbol: dex_data.symbol.clone(),
441 name: dex_data.name.clone(),
442 decimals: 18, };
444
445 let top_10_pct: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
447 let top_50_pct: f64 = holders.iter().take(50).map(|h| h.percentage).sum();
448 let top_100_pct: f64 = holders.iter().take(100).map(|h| h.percentage).sum();
449
450 let dex_pairs: Vec<DexPair> = dex_data.pairs;
452
453 let volume_7d = DexClient::estimate_7d_volume(dex_data.volume_24h);
455
456 let fetched_at = chrono::Utc::now().timestamp();
458
459 let token_age_hours = dex_data.earliest_pair_created_at.map(|created_at| {
462 let now = chrono::Utc::now().timestamp();
463 let created_at_secs = if created_at > 32503680000 {
465 created_at / 1000
466 } else {
467 created_at
468 };
469 let age_secs = now - created_at_secs;
470 if age_secs > 0 {
471 (age_secs as f64) / 3600.0
472 } else {
473 0.0 }
475 });
476
477 let socials: Vec<scope::chains::TokenSocial> = dex_data
479 .socials
480 .iter()
481 .map(|s| scope::chains::TokenSocial {
482 platform: s.platform.clone(),
483 url: s.url.clone(),
484 })
485 .collect();
486
487 Ok(TokenAnalytics {
488 token,
489 chain: chain.to_string(),
490 holders,
491 total_holders: 0, volume_24h: dex_data.volume_24h,
493 volume_7d,
494 price_usd: dex_data.price_usd,
495 price_change_24h: dex_data.price_change_24h,
496 price_change_7d: 0.0, liquidity_usd: dex_data.liquidity_usd,
498 market_cap: dex_data.market_cap,
499 fdv: dex_data.fdv,
500 total_supply: None,
501 circulating_supply: None,
502 price_history: dex_data.price_history,
503 volume_history: dex_data.volume_history,
504 holder_history: Vec::new(), dex_pairs,
506 fetched_at,
507 top_10_concentration: if top_10_pct > 0.0 {
508 Some(top_10_pct)
509 } else {
510 None
511 },
512 top_50_concentration: if top_50_pct > 0.0 {
513 Some(top_50_pct)
514 } else {
515 None
516 },
517 top_100_concentration: if top_100_pct > 0.0 {
518 Some(top_100_pct)
519 } else {
520 None
521 },
522 price_change_6h: dex_data.price_change_6h,
523 price_change_1h: dex_data.price_change_1h,
524 total_buys_24h: dex_data.total_buys_24h,
525 total_sells_24h: dex_data.total_sells_24h,
526 total_buys_6h: dex_data.total_buys_6h,
527 total_sells_6h: dex_data.total_sells_6h,
528 total_buys_1h: dex_data.total_buys_1h,
529 total_sells_1h: dex_data.total_sells_1h,
530 token_age_hours,
531 image_url: dex_data.image_url.clone(),
532 websites: dex_data.websites.clone(),
533 socials,
534 dexscreener_url: dex_data.dexscreener_url.clone(),
535 })
536}
537
538async fn fetch_analytics_from_explorer(
540 token_address: &str,
541 chain: &str,
542 args: &CrawlArgs,
543 clients: &dyn ChainClientFactory,
544) -> Result<TokenAnalytics> {
545 let has_explorer = scope::chains::registry::has_explorer(chain);
547
548 if !has_explorer {
549 return Err(ScopeError::NotFound(format!(
550 "No DEX data found for token {} on {} and block explorer fallback not supported for this chain",
551 token_address, chain
552 )));
553 }
554
555 let client = clients.create_chain_client(chain)?;
557
558 let token = match client.get_token_info(token_address).await {
560 Ok(t) => t,
561 Err(e) => {
562 tracing::debug!("Failed to fetch token info: {}", e);
563 Token {
565 contract_address: token_address.to_string(),
566 symbol: "UNKNOWN".to_string(),
567 name: "Unknown Token".to_string(),
568 decimals: 18,
569 }
570 }
571 };
572
573 let holders = fetch_holders(token_address, chain, args.holders_limit, clients).await?;
575
576 let total_holders = match client.get_token_holder_count(token_address).await {
578 Ok(count) => count,
579 Err(e) => {
580 tracing::debug!("Failed to fetch holder count: {}", e);
581 0
582 }
583 };
584
585 let top_10_pct: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
587 let top_50_pct: f64 = holders.iter().take(50).map(|h| h.percentage).sum();
588 let top_100_pct: f64 = holders.iter().take(100).map(|h| h.percentage).sum();
589
590 let fetched_at = chrono::Utc::now().timestamp();
592
593 Ok(TokenAnalytics {
594 token,
595 chain: chain.to_string(),
596 holders,
597 total_holders,
598 volume_24h: 0.0,
599 volume_7d: 0.0,
600 price_usd: 0.0,
601 price_change_24h: 0.0,
602 price_change_7d: 0.0,
603 liquidity_usd: 0.0,
604 market_cap: None,
605 fdv: None,
606 total_supply: None,
607 circulating_supply: None,
608 price_history: Vec::new(),
609 volume_history: Vec::new(),
610 holder_history: Vec::new(),
611 dex_pairs: Vec::new(),
612 fetched_at,
613 top_10_concentration: if top_10_pct > 0.0 {
614 Some(top_10_pct)
615 } else {
616 None
617 },
618 top_50_concentration: if top_50_pct > 0.0 {
619 Some(top_50_pct)
620 } else {
621 None
622 },
623 top_100_concentration: if top_100_pct > 0.0 {
624 Some(top_100_pct)
625 } else {
626 None
627 },
628 price_change_6h: 0.0,
629 price_change_1h: 0.0,
630 total_buys_24h: 0,
631 total_sells_24h: 0,
632 total_buys_6h: 0,
633 total_sells_6h: 0,
634 total_buys_1h: 0,
635 total_sells_1h: 0,
636 token_age_hours: None,
637 image_url: None,
638 websites: Vec::new(),
639 socials: Vec::new(),
640 dexscreener_url: None,
641 })
642}
643
644async fn fetch_holders(
646 token_address: &str,
647 chain: &str,
648 limit: u32,
649 clients: &dyn ChainClientFactory,
650) -> Result<Vec<TokenHolder>> {
651 match scope::chains::registry::has_explorer(chain) {
653 true => {
654 let client = clients.create_chain_client(chain)?;
655 match client.get_token_holders(token_address, limit).await {
656 Ok(holders) => Ok(holders),
657 Err(e) => {
658 tracing::debug!("Failed to fetch holders: {}", e);
659 Ok(Vec::new())
660 }
661 }
662 }
663 false => {
664 tracing::info!("Holder data not available for chain: {}", chain);
665 Ok(Vec::new())
666 }
667 }
668}
669
670fn output_table(analytics: &TokenAnalytics, args: &CrawlArgs, out: &Output) -> Result<()> {
672 outln!(out)?;
673
674 let has_dex_data = analytics.price_usd > 0.0;
676
677 if has_dex_data {
678 output_table_with_dex(analytics, args, out)
680 } else {
681 output_table_explorer_only(analytics, out)
683 }
684}
685
686fn output_table_with_dex(analytics: &TokenAnalytics, args: &CrawlArgs, out: &Output) -> Result<()> {
688 use scope::display::terminal as t;
689
690 if !args.no_charts {
692 let dashboard = charts::render_analytics_dashboard(
693 &analytics.price_history,
694 &analytics.volume_history,
695 &analytics.holders,
696 &analytics.token.symbol,
697 &analytics.chain,
698 );
699 outln!(out, "{}", dashboard)?;
700 } else {
701 outln!(
703 out,
704 "{}",
705 t::section_header(&format!(
706 "{} ({})",
707 analytics.token.name, analytics.token.symbol
708 ))
709 )?;
710 outln!(out, "{}", t::kv_row("Chain", &analytics.chain))?;
711 outln!(
712 out,
713 "{}",
714 t::kv_row("Contract", &analytics.token.contract_address)
715 )?;
716 outln!(out, "{}", t::blank_row())?;
717 }
718
719 outln!(out, "{}", t::subsection_header("Key Metrics"))?;
721 outln!(
722 out,
723 "{}",
724 t::kv_row(
725 "Price",
726 &scope::display::format::format_price_usd(analytics.price_usd)
727 )
728 )?;
729 outln!(
730 out,
731 "{}",
732 t::kv_row_delta(
733 "24h Change",
734 analytics.price_change_24h,
735 &format!("{:+.2}%", analytics.price_change_24h)
736 )
737 )?;
738 outln!(
739 out,
740 "{}",
741 t::kv_row(
742 "24h Volume",
743 &scope::display::format_usd(analytics.volume_24h)
744 )
745 )?;
746 outln!(
747 out,
748 "{}",
749 t::kv_row(
750 "Liquidity",
751 &scope::display::format_usd(analytics.liquidity_usd)
752 )
753 )?;
754
755 if let Some(mc) = analytics.market_cap {
756 outln!(
757 out,
758 "{}",
759 t::kv_row("Market Cap", &scope::display::format_usd(mc))
760 )?;
761 }
762
763 if let Some(fdv) = analytics.fdv {
764 outln!(
765 out,
766 "{}",
767 t::kv_row("FDV", &scope::display::format_usd(fdv))
768 )?;
769 }
770
771 if !analytics.dex_pairs.is_empty() {
773 outln!(out, "{}", t::blank_row())?;
774 outln!(out, "{}", t::subsection_header("Top Trading Pairs"))?;
775
776 for (i, pair) in analytics.dex_pairs.iter().take(5).enumerate() {
777 let pair_str = format!(
778 "{} {}/{} - {} ({} liq)",
779 pair.dex_name,
780 pair.base_token,
781 pair.quote_token,
782 scope::display::format_usd(pair.volume_24h),
783 scope::display::format_usd(pair.liquidity_usd)
784 );
785 outln!(out, "{}", t::numbered_row(i + 1, &pair_str))?;
786 }
787 }
788
789 if let Some(top_10) = analytics.top_10_concentration {
791 outln!(out, "{}", t::blank_row())?;
792 outln!(out, "{}", t::subsection_header("Holder Concentration"))?;
793 outln!(
794 out,
795 "{}",
796 t::kv_row("Top 10 holders", &format!("{:.1}% of supply", top_10))
797 )?;
798
799 if let Some(top_50) = analytics.top_50_concentration {
800 outln!(
801 out,
802 "{}",
803 t::kv_row("Top 50 holders", &format!("{:.1}% of supply", top_50))
804 )?;
805 }
806 }
807
808 if args.no_charts {
809 outln!(out, "{}", t::section_footer())?;
810 }
811
812 Ok(())
813}
814
815fn output_table_explorer_only(analytics: &TokenAnalytics, out: &Output) -> Result<()> {
817 use scope::display::terminal as t;
818
819 outln!(out, "{}", t::section_header("Token Info (Block Explorer)"))?;
820
821 outln!(out, "{}", t::kv_row("Name", &analytics.token.name))?;
823 outln!(out, "{}", t::kv_row("Symbol", &analytics.token.symbol))?;
824 outln!(
825 out,
826 "{}",
827 t::kv_row("Contract", &analytics.token.contract_address)
828 )?;
829 outln!(out, "{}", t::kv_row("Chain", &analytics.chain))?;
830 outln!(
831 out,
832 "{}",
833 t::kv_row("Decimals", &analytics.token.decimals.to_string())
834 )?;
835
836 if analytics.total_holders > 0 {
837 outln!(
838 out,
839 "{}",
840 t::kv_row("Total Holders", &analytics.total_holders.to_string())
841 )?;
842 }
843
844 if let Some(supply) = &analytics.total_supply {
845 outln!(out, "{}", t::kv_row("Total Supply", supply))?;
846 }
847
848 outln!(out, "{}", t::blank_row())?;
850 outln!(
851 out,
852 "{}",
853 t::info_row(
854 "No DEX trading data available for this token. Price, volume, and liquidity data require active DEX pairs."
855 )
856 )?;
857
858 if !analytics.holders.is_empty() {
860 outln!(out, "{}", t::blank_row())?;
861 outln!(out, "{}", t::subsection_header("Top Holders"))?;
862
863 let cols = [
864 t::Col {
865 label: "Rank",
866 width: 4,
867 align: '>',
868 },
869 t::Col {
870 label: "Percent",
871 width: 10,
872 align: '>',
873 },
874 t::Col {
875 label: "Balance",
876 width: 20,
877 align: '>',
878 },
879 t::Col {
880 label: "Address",
881 width: 42,
882 align: '<',
883 },
884 ];
885 outln!(out, "{}", t::table_header(&cols))?;
886
887 for holder in analytics.holders.iter().take(10) {
888 let addr_display = scope::display::text::abbreviate(&holder.address);
889
890 let rank_str = holder.rank.to_string();
891 let percent_str = format!("{:.2}%", holder.percentage);
892 let values = [
893 rank_str.as_str(),
894 percent_str.as_str(),
895 holder.formatted_balance.as_str(),
896 addr_display.as_str(),
897 ];
898 outln!(out, "{}", t::table_row(&cols, &values))?;
899 }
900 }
901
902 if let Some(top_10) = analytics.top_10_concentration {
904 outln!(out, "{}", t::blank_row())?;
905 outln!(out, "{}", t::subsection_header("Holder Concentration"))?;
906 outln!(
907 out,
908 "{}",
909 t::kv_row("Top 10 holders", &format!("{:.1}% of supply", top_10))
910 )?;
911
912 if let Some(top_50) = analytics.top_50_concentration {
913 outln!(
914 out,
915 "{}",
916 t::kv_row("Top 50 holders", &format!("{:.1}% of supply", top_50))
917 )?;
918 }
919 }
920
921 outln!(out, "{}", t::section_footer())?;
922
923 Ok(())
924}
925
926fn output_csv(analytics: &TokenAnalytics, out: &Output) -> Result<()> {
928 outln!(out, "metric,value")?;
930
931 outln!(out, "symbol,{}", analytics.token.symbol)?;
933 outln!(out, "name,{}", analytics.token.name)?;
934 outln!(out, "chain,{}", analytics.chain)?;
935 outln!(out, "contract,{}", analytics.token.contract_address)?;
936
937 outln!(out, "price_usd,{}", analytics.price_usd)?;
939 outln!(out, "price_change_24h,{}", analytics.price_change_24h)?;
940 outln!(out, "volume_24h,{}", analytics.volume_24h)?;
941 outln!(out, "volume_7d,{}", analytics.volume_7d)?;
942 outln!(out, "liquidity_usd,{}", analytics.liquidity_usd)?;
943
944 if let Some(mc) = analytics.market_cap {
945 outln!(out, "market_cap,{}", mc)?;
946 }
947
948 if let Some(fdv) = analytics.fdv {
949 outln!(out, "fdv,{}", fdv)?;
950 }
951
952 outln!(out, "total_holders,{}", analytics.total_holders)?;
953
954 if let Some(top_10) = analytics.top_10_concentration {
955 outln!(out, "top_10_concentration,{}", top_10)?;
956 }
957
958 if !analytics.holders.is_empty() {
960 outln!(out)?;
961 outln!(out, "rank,address,balance,percentage")?;
962 for holder in &analytics.holders {
963 outln!(
964 out,
965 "{},{},{},{}",
966 holder.rank,
967 holder.address,
968 holder.balance,
969 holder.percentage
970 )?;
971 }
972 }
973
974 Ok(())
975}
976
977#[cfg(test)]
982mod tests {
983 use super::*;
984
985 fn quiet() -> crate::cli::output::Output {
986 crate::cli::output::Output::capture().0
987 }
988
989 #[test]
990 fn test_period_as_seconds() {
991 assert_eq!(Period::Hour1.as_seconds(), 3600);
992 assert_eq!(Period::Hour24.as_seconds(), 86400);
993 assert_eq!(Period::Day7.as_seconds(), 604800);
994 assert_eq!(Period::Day30.as_seconds(), 2592000);
995 }
996
997 #[test]
998 fn test_period_label() {
999 assert_eq!(Period::Hour1.label(), "1 Hour");
1000 assert_eq!(Period::Hour24.label(), "24 Hours");
1001 assert_eq!(Period::Day7.label(), "7 Days");
1002 assert_eq!(Period::Day30.label(), "30 Days");
1003 }
1004
1005 #[test]
1006 fn test_format_large_number() {
1007 assert_eq!(scope::display::format_large_number(500.0), "500.00");
1008 assert_eq!(scope::display::format_large_number(1500.0), "1.50K");
1009 assert_eq!(scope::display::format_large_number(1500000.0), "1.50M");
1010 assert_eq!(scope::display::format_large_number(1500000000.0), "1.50B");
1011 }
1012
1013 #[test]
1014 fn test_period_default() {
1015 let period = Period::default();
1016 assert!(matches!(period, Period::Hour24));
1017 }
1018
1019 #[test]
1020 fn test_crawl_args_defaults() {
1021 use clap::Parser;
1022
1023 #[derive(Parser)]
1024 struct TestCli {
1025 #[command(flatten)]
1026 crawl: CrawlArgs,
1027 }
1028
1029 let cli = TestCli::try_parse_from(["test", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"])
1030 .unwrap();
1031
1032 assert_eq!(cli.crawl.chain, "ethereum");
1033 assert!(matches!(cli.crawl.period, Period::Hour24));
1034 assert_eq!(cli.crawl.holders_limit, 10);
1035 assert!(!cli.crawl.no_charts);
1036 assert!(cli.crawl.report.is_none());
1037 }
1038
1039 #[test]
1044 fn test_format_large_number_zero() {
1045 assert_eq!(scope::display::format_large_number(0.0), "0.00");
1046 }
1047
1048 #[test]
1049 fn test_format_large_number_small() {
1050 assert_eq!(scope::display::format_large_number(0.12), "0.12");
1051 }
1052
1053 #[test]
1054 fn test_format_large_number_boundary_k() {
1055 assert_eq!(scope::display::format_large_number(999.99), "999.99");
1056 assert_eq!(scope::display::format_large_number(1000.0), "1.00K");
1057 }
1058
1059 #[test]
1060 fn test_format_large_number_boundary_m() {
1061 assert_eq!(scope::display::format_large_number(999_999.0), "1000.00K");
1062 assert_eq!(scope::display::format_large_number(1_000_000.0), "1.00M");
1063 }
1064
1065 #[test]
1066 fn test_format_large_number_boundary_b() {
1067 assert_eq!(
1068 scope::display::format_large_number(999_999_999.0),
1069 "1000.00M"
1070 );
1071 assert_eq!(
1072 scope::display::format_large_number(1_000_000_000.0),
1073 "1.00B"
1074 );
1075 }
1076
1077 #[test]
1078 fn test_format_large_number_very_large() {
1079 let result = scope::display::format_large_number(1_500_000_000_000.0);
1080 assert!(result.contains("B"));
1081 }
1082
1083 #[test]
1088 fn test_period_seconds_all() {
1089 assert_eq!(Period::Hour1.as_seconds(), 3600);
1090 assert_eq!(Period::Hour24.as_seconds(), 86400);
1091 assert_eq!(Period::Day7.as_seconds(), 604800);
1092 assert_eq!(Period::Day30.as_seconds(), 2592000);
1093 }
1094
1095 #[test]
1096 fn test_period_labels_all() {
1097 assert_eq!(Period::Hour1.label(), "1 Hour");
1098 assert_eq!(Period::Hour24.label(), "24 Hours");
1099 assert_eq!(Period::Day7.label(), "7 Days");
1100 assert_eq!(Period::Day30.label(), "30 Days");
1101 }
1102
1103 use scope::chains::{
1108 DexPair, PricePoint, Token, TokenAnalytics, TokenHolder, TokenSearchResult, TokenSocial,
1109 };
1110
1111 fn make_test_analytics(with_dex: bool) -> TokenAnalytics {
1112 TokenAnalytics {
1113 token: Token {
1114 contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1115 symbol: "USDC".to_string(),
1116 name: "USD Coin".to_string(),
1117 decimals: 6,
1118 },
1119 chain: "ethereum".to_string(),
1120 holders: vec![
1121 TokenHolder {
1122 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
1123 balance: "1000000000000".to_string(),
1124 formatted_balance: "1,000,000".to_string(),
1125 percentage: 12.5,
1126 rank: 1,
1127 },
1128 TokenHolder {
1129 address: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(),
1130 balance: "500000000000".to_string(),
1131 formatted_balance: "500,000".to_string(),
1132 percentage: 6.25,
1133 rank: 2,
1134 },
1135 ],
1136 total_holders: 150_000,
1137 volume_24h: if with_dex { 5_000_000.0 } else { 0.0 },
1138 volume_7d: if with_dex { 25_000_000.0 } else { 0.0 },
1139 price_usd: if with_dex { 0.9999 } else { 0.0 },
1140 price_change_24h: if with_dex { -0.01 } else { 0.0 },
1141 price_change_7d: if with_dex { 0.02 } else { 0.0 },
1142 liquidity_usd: if with_dex { 100_000_000.0 } else { 0.0 },
1143 market_cap: if with_dex {
1144 Some(30_000_000_000.0)
1145 } else {
1146 None
1147 },
1148 fdv: if with_dex {
1149 Some(30_000_000_000.0)
1150 } else {
1151 None
1152 },
1153 total_supply: Some("30000000000".to_string()),
1154 circulating_supply: Some("28000000000".to_string()),
1155 price_history: vec![
1156 PricePoint {
1157 timestamp: 1700000000,
1158 price: 0.9998,
1159 },
1160 PricePoint {
1161 timestamp: 1700003600,
1162 price: 0.9999,
1163 },
1164 ],
1165 volume_history: vec![],
1166 holder_history: vec![],
1167 dex_pairs: if with_dex {
1168 vec![DexPair {
1169 dex_name: "Uniswap V3".to_string(),
1170 pair_address: "0xpair".to_string(),
1171 base_token: "USDC".to_string(),
1172 quote_token: "WETH".to_string(),
1173 price_usd: 0.9999,
1174 volume_24h: 5_000_000.0,
1175 liquidity_usd: 50_000_000.0,
1176 liquidity_base: None,
1177 price_change_24h: -0.01,
1178 buys_24h: 1000,
1179 sells_24h: 900,
1180 buys_6h: 300,
1181 sells_6h: 250,
1182 buys_1h: 50,
1183 sells_1h: 45,
1184 pair_created_at: Some(1600000000),
1185 url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
1186 }]
1187 } else {
1188 vec![]
1189 },
1190 fetched_at: 1700003600,
1191 top_10_concentration: Some(35.5),
1192 top_50_concentration: Some(55.0),
1193 top_100_concentration: Some(65.0),
1194 price_change_6h: 0.01,
1195 price_change_1h: -0.005,
1196 total_buys_24h: 1000,
1197 total_sells_24h: 900,
1198 total_buys_6h: 300,
1199 total_sells_6h: 250,
1200 total_buys_1h: 50,
1201 total_sells_1h: 45,
1202 token_age_hours: Some(25000.0),
1203 image_url: None,
1204 websites: vec!["https://www.centre.io/usdc".to_string()],
1205 socials: vec![TokenSocial {
1206 platform: "twitter".to_string(),
1207 url: "https://twitter.com/circle".to_string(),
1208 }],
1209 dexscreener_url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
1210 }
1211 }
1212
1213 struct TwoUsdn;
1215
1216 #[async_trait::async_trait]
1217 impl scope::chains::DexDataSource for TwoUsdn {
1218 async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
1219 None
1220 }
1221 async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
1222 None
1223 }
1224 async fn get_token_data(
1225 &self,
1226 _chain: &str,
1227 _address: &str,
1228 ) -> Result<scope::chains::dex::DexTokenData> {
1229 Err(ScopeError::NotFound("not in this fake".into()))
1230 }
1231 async fn search_tokens(
1232 &self,
1233 _query: &str,
1234 _chain: Option<&str>,
1235 ) -> Result<Vec<scope::chains::dex::TokenSearchResult>> {
1236 let usdn = |chain: &str, address: &str| scope::chains::dex::TokenSearchResult {
1237 address: address.into(),
1238 symbol: "USDN".into(),
1239 name: "USDN".into(),
1240 chain: chain.into(),
1241 price_usd: Some(1.0),
1242 volume_24h: 0.0,
1243 liquidity_usd: 1.0,
1244 market_cap: None,
1245 };
1246 Ok(vec![usdn("ethereum", "0xA"), usdn("bsc", "0xB")])
1247 }
1248 }
1249
1250 #[tokio::test]
1251 async fn test_crawl_yes_with_two_matches_stops_with_the_candidates() {
1252 let mut aliases = TokenAliases::default();
1254 let mut args = make_test_crawl_args();
1255 args.token = "USDN".into();
1256 args.yes = true;
1257 let err = resolve_token_input(
1258 &args,
1259 &mut aliases,
1260 &TwoUsdn,
1261 scope::http::test_transport(),
1262 None,
1263 &quiet(),
1264 )
1265 .await
1266 .unwrap_err();
1267 assert!(matches!(err, ScopeError::AmbiguousToken { .. }), "{err}");
1268 assert!(err.to_string().contains("bsc 0xB"), "{err}");
1269 }
1270
1271 fn make_test_crawl_args() -> CrawlArgs {
1272 CrawlArgs {
1273 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1274 chain: "ethereum".to_string(),
1275 period: Period::Hour24,
1276 holders_limit: 10,
1277 format: OutputFormat::Table,
1278 no_charts: true,
1279 report: None,
1280 yes: false,
1281 save: false,
1282 token_contract: None,
1283 }
1284 }
1285
1286 #[test]
1287 fn test_output_table_with_dex_data() {
1288 let analytics = make_test_analytics(true);
1289 let args = make_test_crawl_args();
1290 let result = output_table(&analytics, &args, &quiet());
1291 assert!(result.is_ok());
1292 }
1293
1294 #[test]
1295 fn test_output_table_explorer_only() {
1296 let analytics = make_test_analytics(false);
1297 let args = make_test_crawl_args();
1298 let result = output_table(&analytics, &args, &quiet());
1299 assert!(result.is_ok());
1300 }
1301
1302 #[test]
1303 fn test_output_table_no_holders() {
1304 let mut analytics = make_test_analytics(false);
1305 analytics.holders = vec![];
1306 analytics.total_holders = 0;
1307 analytics.top_10_concentration = None;
1308 analytics.top_50_concentration = None;
1309 let args = make_test_crawl_args();
1310 let result = output_table(&analytics, &args, &quiet());
1311 assert!(result.is_ok());
1312 }
1313
1314 #[test]
1315 fn test_output_csv() {
1316 let analytics = make_test_analytics(true);
1317 let result = output_csv(&analytics, &quiet());
1318 assert!(result.is_ok());
1319 }
1320
1321 #[test]
1322 fn test_output_csv_no_market_cap() {
1323 let mut analytics = make_test_analytics(true);
1324 analytics.market_cap = None;
1325 analytics.fdv = None;
1326 analytics.top_10_concentration = None;
1327 let result = output_csv(&analytics, &quiet());
1328 assert!(result.is_ok());
1329 }
1330
1331 #[test]
1332 fn test_output_csv_no_holders() {
1333 let mut analytics = make_test_analytics(true);
1334 analytics.holders = vec![];
1335 let result = output_csv(&analytics, &quiet());
1336 assert!(result.is_ok());
1337 }
1338
1339 #[test]
1340 fn test_output_table_with_dex_no_charts() {
1341 let analytics = make_test_analytics(true);
1342 let mut args = make_test_crawl_args();
1343 args.no_charts = true;
1344 let result = output_table_with_dex(&analytics, &args, &quiet());
1345 assert!(result.is_ok());
1346 }
1347
1348 #[test]
1349 fn test_output_table_with_dex_no_market_cap() {
1350 let mut analytics = make_test_analytics(true);
1351 analytics.market_cap = None;
1352 analytics.fdv = None;
1353 analytics.top_10_concentration = None;
1354 let args = make_test_crawl_args();
1355 let result = output_table_with_dex(&analytics, &args, &quiet());
1356 assert!(result.is_ok());
1357 }
1358
1359 #[test]
1360 fn test_output_table_explorer_with_concentration() {
1361 let mut analytics = make_test_analytics(false);
1362 analytics.top_10_concentration = Some(40.0);
1363 analytics.top_50_concentration = Some(60.0);
1364 let result = output_table_explorer_only(&analytics, &quiet());
1365 assert!(result.is_ok());
1366 }
1367
1368 #[test]
1369 fn test_output_table_explorer_no_supply() {
1370 let mut analytics = make_test_analytics(false);
1371 analytics.total_supply = None;
1372 let result = output_table_explorer_only(&analytics, &quiet());
1373 assert!(result.is_ok());
1374 }
1375
1376 #[test]
1377 fn test_output_table_explorer_with_supply_and_holders() {
1378 let mut analytics = make_test_analytics(false);
1379 analytics.total_supply = Some("1000000000".to_string());
1380 analytics.total_holders = 50_000;
1381 let result = output_table_explorer_only(&analytics, &quiet());
1382 assert!(result.is_ok());
1383 }
1384
1385 #[test]
1386 fn test_output_table_with_dex_multiple_pairs() {
1387 let mut analytics = make_test_analytics(true);
1388 for i in 0..8 {
1389 analytics.dex_pairs.push(DexPair {
1390 dex_name: format!("DEX {}", i),
1391 pair_address: format!("0xpair{}", i),
1392 base_token: "USDC".to_string(),
1393 quote_token: "WETH".to_string(),
1394 price_usd: 0.9999,
1395 volume_24h: 1_000_000.0 - (i as f64 * 100_000.0),
1396 liquidity_usd: 10_000_000.0 - (i as f64 * 1_000_000.0),
1397 liquidity_base: None,
1398 price_change_24h: 0.0,
1399 buys_24h: 100,
1400 sells_24h: 90,
1401 buys_6h: 30,
1402 sells_6h: 25,
1403 buys_1h: 5,
1404 sells_1h: 4,
1405 pair_created_at: None,
1406 url: None,
1407 });
1408 }
1409 let args = make_test_crawl_args();
1410 let result = output_table_with_dex(&analytics, &args, &quiet());
1412 assert!(result.is_ok());
1413 }
1414
1415 #[test]
1420 fn test_crawl_args_with_report() {
1421 use clap::Parser;
1422
1423 #[derive(Parser)]
1424 struct TestCli {
1425 #[command(flatten)]
1426 crawl: CrawlArgs,
1427 }
1428
1429 let cli = TestCli::try_parse_from([
1430 "test",
1431 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1432 "--report",
1433 "output.md",
1434 ])
1435 .unwrap();
1436
1437 assert_eq!(
1438 cli.crawl.report,
1439 Some(std::path::PathBuf::from("output.md"))
1440 );
1441 }
1442
1443 #[test]
1444 fn test_crawl_args_with_chain_and_period() {
1445 use clap::Parser;
1446
1447 #[derive(Parser)]
1448 struct TestCli {
1449 #[command(flatten)]
1450 crawl: CrawlArgs,
1451 }
1452
1453 let cli = TestCli::try_parse_from([
1454 "test",
1455 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1456 "--chain",
1457 "polygon",
1458 "--period",
1459 "7d",
1460 "--no-charts",
1461 "--yes",
1462 "--save",
1463 ])
1464 .unwrap();
1465
1466 assert_eq!(cli.crawl.chain, "polygon");
1467 assert!(matches!(cli.crawl.period, Period::Day7));
1468 assert!(cli.crawl.no_charts);
1469 assert!(cli.crawl.yes);
1470 assert!(cli.crawl.save);
1471 }
1472
1473 #[test]
1474 fn test_crawl_args_all_periods() {
1475 use clap::Parser;
1476
1477 #[derive(Parser)]
1478 struct TestCli {
1479 #[command(flatten)]
1480 crawl: CrawlArgs,
1481 }
1482
1483 for (period_str, expected) in [
1484 ("1h", Period::Hour1),
1485 ("24h", Period::Hour24),
1486 ("7d", Period::Day7),
1487 ("30d", Period::Day30),
1488 ] {
1489 let cli = TestCli::try_parse_from(["test", "token", "--period", period_str]).unwrap();
1490 assert_eq!(cli.crawl.period.as_seconds(), expected.as_seconds());
1491 }
1492 }
1493
1494 #[test]
1499 fn test_analytics_json_serialization() {
1500 let analytics = make_test_analytics(true);
1501 let json = serde_json::to_string(&analytics).unwrap();
1502 assert!(json.contains("USDC"));
1503 assert!(json.contains("USD Coin"));
1504 assert!(json.contains("ethereum"));
1505 assert!(json.contains("0.9999"));
1506 }
1507
1508 #[test]
1509 fn test_analytics_json_no_optional_fields() {
1510 let mut analytics = make_test_analytics(false);
1511 analytics.market_cap = None;
1512 analytics.fdv = None;
1513 analytics.total_supply = None;
1514 analytics.top_10_concentration = None;
1515 analytics.top_50_concentration = None;
1516 analytics.top_100_concentration = None;
1517 analytics.token_age_hours = None;
1518 analytics.dexscreener_url = None;
1519 let json = serde_json::to_string(&analytics).unwrap();
1520 assert!(!json.contains("market_cap"));
1521 assert!(!json.contains("fdv"));
1522 }
1523
1524 use scope::chains::mocks::{MockClientFactory, MockDexSource};
1529
1530 fn mock_factory_for_crawl() -> MockClientFactory {
1531 let mut factory = MockClientFactory::new();
1532 factory.mock_dex = MockDexSource::new();
1534 factory
1535 }
1536
1537 #[tokio::test]
1538 async fn test_run_crawl_json_output() {
1539 let config = Config::default();
1540 let factory = mock_factory_for_crawl();
1541 let args = CrawlArgs {
1542 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1543 chain: "ethereum".to_string(),
1544 period: Period::Hour24,
1545 holders_limit: 5,
1546 format: OutputFormat::Json,
1547 no_charts: true,
1548 report: None,
1549 yes: true,
1550 save: false,
1551 token_contract: None,
1552 };
1553 let result = super::run(args, &config, &factory, &quiet()).await;
1554 assert!(result.is_ok());
1555 }
1556
1557 #[tokio::test]
1558 async fn test_run_crawl_table_output() {
1559 let config = Config::default();
1560 let factory = mock_factory_for_crawl();
1561 let args = CrawlArgs {
1562 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1563 chain: "ethereum".to_string(),
1564 period: Period::Hour24,
1565 holders_limit: 5,
1566 format: OutputFormat::Table,
1567 no_charts: true,
1568 report: None,
1569 yes: true,
1570 save: false,
1571 token_contract: None,
1572 };
1573 let result = super::run(args, &config, &factory, &quiet()).await;
1574 assert!(result.is_ok());
1575 }
1576
1577 #[tokio::test]
1578 async fn test_run_crawl_csv_output() {
1579 let config = Config::default();
1580 let factory = mock_factory_for_crawl();
1581 let args = CrawlArgs {
1582 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1583 chain: "ethereum".to_string(),
1584 period: Period::Hour24,
1585 holders_limit: 5,
1586 format: OutputFormat::Csv,
1587 no_charts: true,
1588 report: None,
1589 yes: true,
1590 save: false,
1591 token_contract: None,
1592 };
1593 let result = super::run(args, &config, &factory, &quiet()).await;
1594 assert!(result.is_ok());
1595 }
1596
1597 #[tokio::test]
1598 async fn test_json_with_a_report_file_keeps_stdout_pure_json() {
1599 let dir = tempfile::tempdir().unwrap();
1602 let report_path = dir.path().join("r.md");
1603 let factory = MockClientFactory::new();
1604 let args = CrawlArgs {
1605 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1606 chain: "ethereum".to_string(),
1607 period: Period::Hour24,
1608 holders_limit: 5,
1609 format: OutputFormat::Json,
1610 no_charts: true,
1611 report: Some(report_path.clone()),
1612 yes: true,
1613 save: false,
1614 token_contract: None,
1615 };
1616 let (out, cap) = crate::cli::output::Output::capture();
1617 super::run(args, &Config::default(), &factory, &out)
1618 .await
1619 .unwrap();
1620 let json: serde_json::Value = serde_json::from_str(&cap.out())
1621 .unwrap_or_else(|e| panic!("stdout is not JSON ({e}): {}", cap.out()));
1622 assert!(json.is_object());
1623 assert!(report_path.exists());
1624 assert!(cap.err().contains("Report saved to: "), "{}", cap.err());
1625 }
1626
1627 #[tokio::test]
1628 async fn test_run_crawl_symbol_resolution_via_factory_dex() {
1629 let mut factory = MockClientFactory::new();
1631 factory.mock_dex.search_results = vec![TokenSearchResult {
1632 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1633 symbol: "MOCK".to_string(),
1634 name: "Mock Token".to_string(),
1635 chain: "ethereum".to_string(),
1636 price_usd: Some(1.0),
1637 volume_24h: 1_000_000.0,
1638 liquidity_usd: 5_000_000.0,
1639 market_cap: Some(100_000_000.0),
1640 }];
1641 if let Some(ref mut td) = factory.mock_dex.token_data {
1643 td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
1644 }
1645
1646 let config = Config::default();
1647 let args = CrawlArgs {
1648 token: "MOCK".to_string(),
1649 chain: "ethereum".to_string(),
1650 period: Period::Hour24,
1651 holders_limit: 5,
1652 format: OutputFormat::Json,
1653 no_charts: true,
1654 report: None,
1655 yes: true,
1656 save: false,
1657 token_contract: None,
1658 };
1659 let result = super::run(args, &config, &factory, &quiet()).await;
1660 assert!(result.is_ok());
1661 }
1662
1663 #[tokio::test]
1664 async fn test_run_crawl_no_dex_data_evm() {
1665 let config = Config::default();
1666 let mut factory = MockClientFactory::new();
1667 factory.mock_dex.token_data = None; factory.mock_client.token_info = Some(Token {
1669 contract_address: "0xtoken".to_string(),
1670 symbol: "TEST".to_string(),
1671 name: "Test Token".to_string(),
1672 decimals: 18,
1673 });
1674 let args = CrawlArgs {
1675 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1676 chain: "ethereum".to_string(),
1677 period: Period::Hour24,
1678 holders_limit: 5,
1679 format: OutputFormat::Json,
1680 no_charts: true,
1681 report: None,
1682 yes: true,
1683 save: false,
1684 token_contract: None,
1685 };
1686 let result = super::run(args, &config, &factory, &quiet()).await;
1687 assert!(result.is_ok());
1688 }
1689
1690 #[tokio::test]
1691 async fn test_run_crawl_table_no_charts() {
1692 let config = Config::default();
1693 let factory = mock_factory_for_crawl();
1694 let args = CrawlArgs {
1695 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1696 chain: "ethereum".to_string(),
1697 period: Period::Hour24,
1698 holders_limit: 5,
1699 format: OutputFormat::Table,
1700 no_charts: true,
1701 report: None,
1702 yes: true,
1703 save: false,
1704 token_contract: None,
1705 };
1706 let result = super::run(args, &config, &factory, &quiet()).await;
1707 assert!(result.is_ok());
1708 }
1709
1710 #[tokio::test]
1711 async fn test_run_crawl_with_charts() {
1712 let config = Config::default();
1713 let factory = mock_factory_for_crawl();
1714 let args = CrawlArgs {
1715 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1716 chain: "ethereum".to_string(),
1717 period: Period::Hour1,
1718 holders_limit: 5,
1719 format: OutputFormat::Table,
1720 no_charts: false, report: None,
1722 yes: true,
1723 save: false,
1724 token_contract: None,
1725 };
1726 let result = super::run(args, &config, &factory, &quiet()).await;
1727 assert!(result.is_ok());
1728 }
1729
1730 #[tokio::test]
1731 async fn test_run_crawl_day7_period() {
1732 let config = Config::default();
1733 let factory = mock_factory_for_crawl();
1734 let args = CrawlArgs {
1735 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1736 chain: "ethereum".to_string(),
1737 period: Period::Day7,
1738 holders_limit: 5,
1739 format: OutputFormat::Table,
1740 no_charts: true,
1741 report: None,
1742 yes: true,
1743 save: false,
1744 token_contract: None,
1745 };
1746 let result = super::run(args, &config, &factory, &quiet()).await;
1747 assert!(result.is_ok());
1748 }
1749
1750 #[tokio::test]
1751 async fn test_run_crawl_markdown_output() {
1752 let config = Config::default();
1753 let factory = mock_factory_for_crawl();
1754 let args = CrawlArgs {
1755 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1756 chain: "ethereum".to_string(),
1757 period: Period::Hour24,
1758 holders_limit: 5,
1759 format: OutputFormat::Markdown,
1760 no_charts: true,
1761 report: None,
1762 yes: true,
1763 save: false,
1764 token_contract: None,
1765 };
1766 let result = super::run(args, &config, &factory, &quiet()).await;
1767 assert!(result.is_ok());
1768 }
1769
1770 #[tokio::test]
1771 async fn test_run_crawl_unsupported_chain_no_dex() {
1772 let mut factory = MockClientFactory::new();
1774 factory.mock_dex.token_data = None;
1775 factory.mock_dex.search_results = vec![];
1776
1777 let config = Config::default();
1778 let args = CrawlArgs {
1779 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1780 chain: "avalanche".to_string(), period: Period::Hour24,
1782 holders_limit: 5,
1783 format: OutputFormat::Json,
1784 no_charts: true,
1785 report: None,
1786 yes: true,
1787 save: false,
1788 token_contract: None,
1789 };
1790 let result = super::run(args, &config, &factory, &quiet()).await;
1791 assert!(result.is_err());
1792 let err_str = result.unwrap_err().to_string();
1793 assert!(
1794 err_str.contains("avalanche")
1795 || err_str.contains("block explorer")
1796 || err_str.contains("No DEX"),
1797 "Expected error about unsupported chain, got: {}",
1798 err_str
1799 );
1800 }
1801
1802 #[tokio::test]
1803 async fn test_run_crawl_day30_period() {
1804 let config = Config::default();
1805 let factory = mock_factory_for_crawl();
1806 let args = CrawlArgs {
1807 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1808 chain: "ethereum".to_string(),
1809 period: Period::Day30,
1810 holders_limit: 5,
1811 format: OutputFormat::Table,
1812 no_charts: true,
1813 report: None,
1814 yes: true,
1815 save: false,
1816 token_contract: None,
1817 };
1818 let result = super::run(args, &config, &factory, &quiet()).await;
1819 assert!(result.is_ok());
1820 }
1821
1822 #[test]
1823 fn test_output_table_with_dex_with_charts() {
1824 let analytics = make_test_analytics(true);
1825 let mut args = make_test_crawl_args();
1826 args.no_charts = false; let result = output_table_with_dex(&analytics, &args, &quiet());
1828 assert!(result.is_ok());
1829 }
1830
1831 #[test]
1832 fn test_output_table_explorer_short_addresses() {
1833 let mut analytics = make_test_analytics(false);
1834 analytics.holders = vec![TokenHolder {
1835 address: "0xshort".to_string(), balance: "100".to_string(),
1837 formatted_balance: "100".to_string(),
1838 percentage: 1.0,
1839 rank: 1,
1840 }];
1841 let result = output_table_explorer_only(&analytics, &quiet());
1842 assert!(result.is_ok());
1843 }
1844
1845 #[test]
1846 fn test_output_csv_with_all_fields() {
1847 let analytics = make_test_analytics(true);
1848 let result = output_csv(&analytics, &quiet());
1849 assert!(result.is_ok());
1850 }
1851
1852 #[tokio::test]
1853 async fn test_fetch_analytics_for_input() {
1854 let factory = mock_factory_for_crawl();
1855 let result = super::fetch_analytics_for_input(
1856 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1857 "ethereum",
1858 Period::Hour24,
1859 5,
1860 &factory,
1861 None,
1862 &quiet(),
1863 )
1864 .await;
1865 assert!(result.is_ok());
1866 let analytics = result.unwrap();
1867 assert_eq!(analytics.chain, "ethereum");
1868 assert!(!analytics.token.contract_address.is_empty());
1869 }
1870
1871 #[tokio::test]
1872 async fn test_run_crawl_chain_without_holder_support() {
1873 let mut factory = mock_factory_for_crawl();
1875 if let Some(ref mut td) = factory.mock_dex.token_data {
1876 td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
1877 }
1878 let config = Config::default();
1879 let args = CrawlArgs {
1880 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1881 chain: "fantom".to_string(),
1882 period: Period::Hour24,
1883 holders_limit: 5,
1884 format: OutputFormat::Json,
1885 no_charts: true,
1886 report: None,
1887 yes: true,
1888 save: false,
1889 token_contract: None,
1890 };
1891 let result = super::run(args, &config, &factory, &quiet()).await;
1892 assert!(result.is_ok());
1893 }
1894
1895 #[tokio::test]
1896 async fn test_run_crawl_with_report() {
1897 let config = Config::default();
1898 let factory = mock_factory_for_crawl();
1899 let tmp = tempfile::NamedTempFile::new().unwrap();
1900 let args = CrawlArgs {
1901 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1902 chain: "ethereum".to_string(),
1903 period: Period::Hour24,
1904 holders_limit: 5,
1905 format: OutputFormat::Table,
1906 no_charts: true,
1907 report: Some(tmp.path().to_path_buf()),
1908 yes: true,
1909 save: false,
1910 token_contract: None,
1911 };
1912 let result = super::run(args, &config, &factory, &quiet()).await;
1913 assert!(result.is_ok());
1914 let content = std::fs::read_to_string(tmp.path()).unwrap();
1916 assert!(content.contains("Token Analysis Report"));
1917 }
1918
1919 #[test]
1924 fn test_output_table_explorer_long_address_truncation() {
1925 let mut analytics = make_test_analytics(false);
1926 analytics.holders = vec![TokenHolder {
1927 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
1928 balance: "1000000".to_string(),
1929 formatted_balance: "1,000,000".to_string(),
1930 percentage: 50.0,
1931 rank: 1,
1932 }];
1933 let result = output_table_explorer_only(&analytics, &quiet());
1934 assert!(result.is_ok());
1935 }
1936
1937 #[test]
1938 fn test_output_table_with_dex_empty_pairs() {
1939 let mut analytics = make_test_analytics(true);
1940 analytics.dex_pairs = vec![];
1941 let args = make_test_crawl_args();
1942 let result = output_table_with_dex(&analytics, &args, &quiet());
1943 assert!(result.is_ok());
1944 }
1945
1946 #[test]
1947 fn test_output_table_explorer_no_concentration() {
1948 let mut analytics = make_test_analytics(false);
1949 analytics.top_10_concentration = None;
1950 analytics.top_50_concentration = None;
1951 analytics.top_100_concentration = None;
1952 let result = output_table_explorer_only(&analytics, &quiet());
1953 assert!(result.is_ok());
1954 }
1955
1956 #[test]
1957 fn test_output_table_with_dex_top_10_only() {
1958 let mut analytics = make_test_analytics(true);
1959 analytics.top_10_concentration = Some(25.0);
1960 analytics.top_50_concentration = None;
1961 analytics.top_100_concentration = None;
1962 let args = make_test_crawl_args();
1963 let result = output_table_with_dex(&analytics, &args, &quiet());
1964 assert!(result.is_ok());
1965 }
1966
1967 #[test]
1968 fn test_output_table_with_dex_top_100_concentration() {
1969 let mut analytics = make_test_analytics(true);
1970 analytics.top_10_concentration = Some(20.0);
1971 analytics.top_50_concentration = Some(45.0);
1972 analytics.top_100_concentration = Some(65.0);
1973 let args = make_test_crawl_args();
1974 let result = output_table_with_dex(&analytics, &args, &quiet());
1975 assert!(result.is_ok());
1976 }
1977
1978 #[test]
1979 fn test_output_csv_with_market_cap_and_fdv() {
1980 let mut analytics = make_test_analytics(true);
1981 analytics.market_cap = Some(1_000_000_000.0);
1982 analytics.fdv = Some(1_500_000_000.0);
1983 let result = output_csv(&analytics, &quiet());
1984 assert!(result.is_ok());
1985 }
1986
1987 #[test]
1988 fn test_output_table_routing_has_dex_data() {
1989 let analytics = make_test_analytics(true);
1990 assert!(analytics.price_usd > 0.0);
1991 let args = make_test_crawl_args();
1992 let result = output_table(&analytics, &args, &quiet());
1993 assert!(result.is_ok());
1994 }
1995
1996 #[test]
1997 fn test_output_table_routing_no_dex_data() {
1998 let analytics = make_test_analytics(false);
1999 assert_eq!(analytics.price_usd, 0.0);
2000 let args = make_test_crawl_args();
2001 let result = output_table(&analytics, &args, &quiet());
2002 assert!(result.is_ok());
2003 }
2004
2005 #[test]
2006 fn test_format_large_number_negative() {
2007 let result = scope::display::format_large_number(-1_000_000.0);
2008 assert!(result.contains("M") || result.contains("-"));
2009 }
2010
2011 #[test]
2012 fn test_output_table_with_dex_with_holders() {
2013 let mut analytics = make_test_analytics(true);
2014 analytics.holders = vec![
2015 TokenHolder {
2016 address: "0xholder1".to_string(),
2017 balance: "1000000".to_string(),
2018 formatted_balance: "1,000,000".to_string(),
2019 percentage: 30.0,
2020 rank: 1,
2021 },
2022 TokenHolder {
2023 address: "0xholder2".to_string(),
2024 balance: "500000".to_string(),
2025 formatted_balance: "500,000".to_string(),
2026 percentage: 15.0,
2027 rank: 2,
2028 },
2029 ];
2030 let args = make_test_crawl_args();
2031 let result = output_table_with_dex(&analytics, &args, &quiet());
2032 assert!(result.is_ok());
2033 }
2034
2035 #[test]
2036 fn test_output_json() {
2037 let analytics = make_test_analytics(true);
2038 let result = serde_json::to_string_pretty(&analytics);
2039 assert!(result.is_ok());
2040 }
2041
2042 #[test]
2047 fn test_output_csv_no_panic() {
2048 let analytics = create_test_analytics_minimal();
2049 let result = output_csv(&analytics, &quiet());
2050 assert!(result.is_ok());
2051 }
2052
2053 #[test]
2054 fn test_output_table_no_dex_data() {
2055 let analytics = create_test_analytics_minimal();
2057 let args = CrawlArgs {
2058 token: "0xtest".to_string(),
2059 chain: "ethereum".to_string(),
2060 period: Period::Hour24,
2061 holders_limit: 10,
2062 format: OutputFormat::Table,
2063 no_charts: true,
2064 report: None,
2065 yes: false,
2066 save: false,
2067 token_contract: None,
2068 };
2069 let result = output_table(&analytics, &args, &quiet());
2070 assert!(result.is_ok());
2071 }
2072
2073 #[test]
2074 fn test_output_table_with_dex_data_no_charts() {
2075 let mut analytics = create_test_analytics_minimal();
2076 analytics.price_usd = 1.0;
2077 analytics.volume_24h = 1_000_000.0;
2078 analytics.liquidity_usd = 500_000.0;
2079 analytics.market_cap = Some(1_000_000_000.0);
2080 analytics.fdv = Some(2_000_000_000.0);
2081
2082 let args = CrawlArgs {
2083 token: "0xtest".to_string(),
2084 chain: "ethereum".to_string(),
2085 period: Period::Hour24,
2086 holders_limit: 10,
2087 format: OutputFormat::Table,
2088 no_charts: true,
2089 report: None,
2090 yes: false,
2091 save: false,
2092 token_contract: None,
2093 };
2094 let result = output_table(&analytics, &args, &quiet());
2095 assert!(result.is_ok());
2096 }
2097
2098 #[test]
2099 fn test_output_table_with_dex_data_and_charts() {
2100 let mut analytics = create_test_analytics_minimal();
2101 analytics.price_usd = 1.0;
2102 analytics.volume_24h = 1_000_000.0;
2103 analytics.liquidity_usd = 500_000.0;
2104 analytics.price_history = vec![
2105 scope::chains::PricePoint {
2106 timestamp: 1,
2107 price: 0.99,
2108 },
2109 scope::chains::PricePoint {
2110 timestamp: 2,
2111 price: 1.01,
2112 },
2113 ];
2114 analytics.volume_history = vec![
2115 scope::chains::VolumePoint {
2116 timestamp: 1,
2117 volume: 50000.0,
2118 },
2119 scope::chains::VolumePoint {
2120 timestamp: 2,
2121 volume: 60000.0,
2122 },
2123 ];
2124
2125 let args = CrawlArgs {
2126 token: "0xtest".to_string(),
2127 chain: "ethereum".to_string(),
2128 period: Period::Hour24,
2129 holders_limit: 10,
2130 format: OutputFormat::Table,
2131 no_charts: false,
2132 report: None,
2133 yes: false,
2134 save: false,
2135 token_contract: None,
2136 };
2137 let result = output_table(&analytics, &args, &quiet());
2138 assert!(result.is_ok());
2139 }
2140
2141 fn create_test_analytics_minimal() -> TokenAnalytics {
2142 TokenAnalytics {
2143 token: Token {
2144 contract_address: "0xtest".to_string(),
2145 symbol: "TEST".to_string(),
2146 name: "Test Token".to_string(),
2147 decimals: 18,
2148 },
2149 chain: "ethereum".to_string(),
2150 holders: Vec::new(),
2151 total_holders: 0,
2152 volume_24h: 0.0,
2153 volume_7d: 0.0,
2154 price_usd: 0.0,
2155 price_change_24h: 0.0,
2156 price_change_7d: 0.0,
2157 liquidity_usd: 0.0,
2158 market_cap: None,
2159 fdv: None,
2160 total_supply: None,
2161 circulating_supply: None,
2162 price_history: Vec::new(),
2163 volume_history: Vec::new(),
2164 holder_history: Vec::new(),
2165 dex_pairs: Vec::new(),
2166 fetched_at: 0,
2167 top_10_concentration: None,
2168 top_50_concentration: None,
2169 top_100_concentration: None,
2170 price_change_6h: 0.0,
2171 price_change_1h: 0.0,
2172 total_buys_24h: 0,
2173 total_sells_24h: 0,
2174 total_buys_6h: 0,
2175 total_sells_6h: 0,
2176 total_buys_1h: 0,
2177 total_sells_1h: 0,
2178 token_age_hours: None,
2179 image_url: None,
2180 websites: Vec::new(),
2181 socials: Vec::new(),
2182 dexscreener_url: None,
2183 }
2184 }
2185}