1use crate::chains::{
28 ChainClientFactory, DexClient, DexDataSource, DexPair, Token, TokenAnalytics, TokenHolder,
29 TokenSearchResult, infer_chain_from_address,
30};
31use crate::config::{Config, OutputFormat};
32use crate::display::{charts, report};
33use crate::error::{Result, ScopeError};
34use crate::market::{ExchangeClient, VenueRegistry};
35use crate::tokens::TokenAliases;
36use clap::Args;
37use std::io::{self, BufRead, Write};
38use std::path::PathBuf;
39
40#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
42pub enum Period {
43 #[value(name = "1h")]
45 Hour1,
46 #[default]
48 #[value(name = "24h")]
49 Hour24,
50 #[value(name = "7d")]
52 Day7,
53 #[value(name = "30d")]
55 Day30,
56}
57
58impl Period {
59 pub fn as_seconds(&self) -> i64 {
61 match self {
62 Period::Hour1 => 3600,
63 Period::Hour24 => 86400,
64 Period::Day7 => 604800,
65 Period::Day30 => 2592000,
66 }
67 }
68
69 pub fn label(&self) -> &'static str {
71 match self {
72 Period::Hour1 => "1 Hour",
73 Period::Hour24 => "24 Hours",
74 Period::Day7 => "7 Days",
75 Period::Day30 => "30 Days",
76 }
77 }
78}
79
80#[derive(Debug, Args)]
82#[command(after_help = "\x1b[1mExamples:\x1b[0m
83 scope crawl USDC
84 scope crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --chain ethereum
85 scope crawl USDC --period 7d --report usdc_report.md
86 scope crawl PEPE --format json --no-charts")]
87pub struct CrawlArgs {
88 pub token: String,
94
95 #[arg(short, long, default_value = "ethereum")]
100 pub chain: String,
101
102 #[arg(short, long, default_value = "24h")]
104 pub period: Period,
105
106 #[arg(long, default_value = "10")]
108 pub holders_limit: u32,
109
110 #[arg(short, long, default_value = "table")]
112 pub format: OutputFormat,
113
114 #[arg(long)]
116 pub no_charts: bool,
117
118 #[arg(long, value_name = "PATH")]
120 pub report: Option<PathBuf>,
121
122 #[arg(long)]
124 pub yes: bool,
125
126 #[arg(long)]
128 pub save: bool,
129}
130
131#[derive(Debug, Clone)]
133struct ResolvedToken {
134 address: String,
135 chain: String,
136 alias_info: Option<(String, String)>,
138}
139
140async fn try_cex_fallback(symbol: &str, chain: &str) -> Option<TokenSearchResult> {
152 let registry = VenueRegistry::load().ok()?;
153 let venue_id = "binance";
154 let descriptor = registry.get(venue_id)?;
155 let client = ExchangeClient::from_descriptor(&descriptor.clone());
156 let pair = client.format_pair(&format!("{}USDT", symbol.to_uppercase()));
157 let ticker = client.fetch_ticker(&pair).await.ok()?;
158 let price = ticker.last_price.unwrap_or(0.0);
159 Some(TokenSearchResult {
160 address: String::new(), symbol: symbol.to_uppercase(),
162 name: symbol.to_uppercase(),
163 chain: chain.to_string(),
164 price_usd: Some(price),
165 volume_24h: ticker.volume_24h.unwrap_or(0.0),
166 liquidity_usd: 0.0,
167 market_cap: None,
168 })
169}
170
171async fn resolve_token_input(
177 args: &CrawlArgs,
178 aliases: &mut TokenAliases,
179 dex_client: &dyn DexDataSource,
180 spinner: Option<&crate::cli::progress::Spinner>,
181) -> Result<ResolvedToken> {
182 let input = args.token.trim();
183
184 if TokenAliases::is_address(input) {
186 let chain = if args.chain == "ethereum" {
187 infer_chain_from_address(input)
188 .unwrap_or("ethereum")
189 .to_string()
190 } else {
191 args.chain.clone()
192 };
193 return Ok(ResolvedToken {
194 address: input.to_string(),
195 chain,
196 alias_info: None,
197 });
198 }
199
200 let chain_filter = if args.chain != "ethereum" {
202 Some(args.chain.as_str())
203 } else {
204 None
205 };
206
207 if let Some(token_info) = aliases.get(input, chain_filter) {
208 let msg = format!(
209 "Using saved token: {} ({}) on {}",
210 token_info.symbol, token_info.name, token_info.chain
211 );
212 if let Some(sp) = spinner {
213 sp.set_message(msg);
214 } else {
215 eprintln!(" {}", msg);
216 }
217 return Ok(ResolvedToken {
218 address: token_info.address.clone(),
219 chain: token_info.chain.clone(),
220 alias_info: Some((token_info.symbol.clone(), token_info.name.clone())),
221 });
222 }
223
224 let search_msg = format!("Searching for '{}'...", input);
226 if let Some(sp) = spinner {
227 sp.set_message(search_msg);
228 } else {
229 eprintln!(" {}", search_msg);
230 }
231
232 let mut search_results = dex_client.search_tokens(input, chain_filter).await?;
233
234 if search_results.is_empty()
236 && let Some(fallback) = try_cex_fallback(input, &args.chain).await
237 {
238 let msg = format!(
239 "Not found on DexScreener; found {} on {} (CEX)",
240 fallback.symbol, fallback.chain
241 );
242 if let Some(sp) = spinner {
243 sp.println(&msg);
244 } else {
245 eprintln!(" {}", msg);
246 }
247 search_results.push(fallback);
248 }
249
250 if search_results.is_empty() {
251 return Err(ScopeError::NotFound(format!(
252 "No token found matching '{}' on {} (checked DexScreener and CEX venues)",
253 input, args.chain
254 )));
255 }
256
257 let selected = if let Some(sp) = spinner {
261 if search_results.len() == 1 || args.yes {
262 let sel = &search_results[0];
264 sp.set_message(format!(
265 "Selected: {} ({}) on {} - ${:.6}",
266 sel.symbol,
267 sel.name,
268 sel.chain,
269 sel.price_usd.unwrap_or(0.0)
270 ));
271 sel
272 } else {
273 let result = sp.suspend(|| select_token(&search_results, args.yes));
275 result?
276 }
277 } else {
278 select_token(&search_results, args.yes)?
279 };
280
281 if args.save || (!args.yes && prompt_save_alias()) {
283 aliases.add(
284 &selected.symbol,
285 &selected.chain,
286 &selected.address,
287 &selected.name,
288 );
289 if let Err(e) = aliases.save() {
290 tracing::debug!("Failed to save token alias: {}", e);
291 } else if let Some(sp) = spinner {
292 sp.println(&format!(
293 "Saved {} as alias for future use.",
294 selected.symbol
295 ));
296 } else {
297 println!("Saved {} as alias for future use.", selected.symbol);
298 }
299 }
300
301 Ok(ResolvedToken {
302 address: selected.address.clone(),
303 chain: selected.chain.clone(),
304 alias_info: Some((selected.symbol.clone(), selected.name.clone())),
305 })
306}
307
308fn select_token(results: &[TokenSearchResult], auto_select: bool) -> Result<&TokenSearchResult> {
310 let stdin = io::stdin();
311 let stdout = io::stdout();
312 select_token_impl(results, auto_select, &mut stdin.lock(), &mut stdout.lock())
313}
314
315fn select_token_impl<'a>(
317 results: &'a [TokenSearchResult],
318 auto_select: bool,
319 reader: &mut impl BufRead,
320 writer: &mut impl Write,
321) -> Result<&'a TokenSearchResult> {
322 if results.len() == 1 || auto_select {
323 let selected = &results[0];
324 writeln!(
325 writer,
326 "Selected: {} ({}) on {} - ${:.6}",
327 selected.symbol,
328 selected.name,
329 selected.chain,
330 selected.price_usd.unwrap_or(0.0)
331 )
332 .map_err(|e| ScopeError::Io(e.to_string()))?;
333 return Ok(selected);
334 }
335
336 writeln!(writer, "\nFound {} matching tokens:\n", results.len())
337 .map_err(|e| ScopeError::Io(e.to_string()))?;
338 writeln!(
339 writer,
340 "{:>3} {:>8} {:<22} {:<16} {:<12} {:>12} {:>12}",
341 "#", "Symbol", "Name", "Address", "Chain", "Price", "Liquidity"
342 )
343 .map_err(|e| ScopeError::Io(e.to_string()))?;
344 writeln!(writer, "{}", "─".repeat(98)).map_err(|e| ScopeError::Io(e.to_string()))?;
345
346 for (i, token) in results.iter().enumerate() {
347 let price = token
348 .price_usd
349 .map(|p| format!("${:.6}", p))
350 .unwrap_or_else(|| "N/A".to_string());
351
352 let liquidity = crate::display::format_large_number(token.liquidity_usd);
353 let addr = abbreviate_address(&token.address);
354
355 let name = if token.name.len() > 20 {
357 format!("{}...", &token.name[..17])
358 } else {
359 token.name.clone()
360 };
361
362 writeln!(
363 writer,
364 "{:>3} {:>8} {:<22} {:<16} {:<12} {:>12} {:>12}",
365 i + 1,
366 token.symbol,
367 name,
368 addr,
369 token.chain,
370 price,
371 liquidity
372 )
373 .map_err(|e| ScopeError::Io(e.to_string()))?;
374 }
375
376 writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
377 write!(writer, "Select token (1-{}): ", results.len())
378 .map_err(|e| ScopeError::Io(e.to_string()))?;
379 writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
380
381 let mut input = String::new();
382 reader
383 .read_line(&mut input)
384 .map_err(|e| ScopeError::Io(e.to_string()))?;
385
386 let selection: usize = input
387 .trim()
388 .parse()
389 .map_err(|_| ScopeError::Api("Invalid selection".to_string()))?;
390
391 if selection < 1 || selection > results.len() {
392 return Err(ScopeError::Api(format!(
393 "Selection must be between 1 and {}",
394 results.len()
395 )));
396 }
397
398 Ok(&results[selection - 1])
399}
400
401fn prompt_save_alias() -> bool {
403 let stdin = io::stdin();
404 let stdout = io::stdout();
405 prompt_save_alias_impl(&mut stdin.lock(), &mut stdout.lock())
406}
407
408fn prompt_save_alias_impl(reader: &mut impl BufRead, writer: &mut impl Write) -> bool {
410 if write!(writer, "Save this token for future use? [y/N]: ").is_err() {
411 return false;
412 }
413 if writer.flush().is_err() {
414 return false;
415 }
416
417 let mut input = String::new();
418 if reader.read_line(&mut input).is_err() {
419 return false;
420 }
421
422 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
423}
424
425pub async fn fetch_analytics_for_input(
431 token_input: &str,
432 chain: &str,
433 period: Period,
434 holders_limit: u32,
435 clients: &dyn ChainClientFactory,
436 spinner: Option<&crate::cli::progress::Spinner>,
437) -> Result<TokenAnalytics> {
438 let args = CrawlArgs {
439 token: token_input.to_string(),
440 chain: chain.to_string(),
441 period,
442 holders_limit,
443 format: OutputFormat::Table,
444 no_charts: true,
445 report: None,
446 yes: true,
447 save: false,
448 };
449 let mut aliases = TokenAliases::load();
450 let dex_client = clients.create_dex_client();
451 let resolved = resolve_token_input(&args, &mut aliases, dex_client.as_ref(), spinner).await?;
452 if let Some(sp) = spinner {
453 sp.set_message(format!(
454 "Fetching analytics for {} on {}...",
455 resolved.address, resolved.chain
456 ));
457 }
458 let mut analytics =
459 fetch_token_analytics(&resolved.address, &resolved.chain, &args, clients).await?;
460 if let Some((symbol, name)) = &resolved.alias_info
461 && (analytics.token.symbol == "UNKNOWN" || analytics.token.name == "Unknown Token")
462 {
463 analytics.token.symbol = symbol.clone();
464 analytics.token.name = name.clone();
465 }
466 Ok(analytics)
467}
468
469pub async fn run(
474 mut args: CrawlArgs,
475 config: &Config,
476 clients: &dyn ChainClientFactory,
477) -> Result<()> {
478 if let Some((address, chain)) =
480 crate::cli::address_book::resolve_address_book_input(&args.token, config)?
481 {
482 args.token = address;
483 if args.chain == "ethereum" {
484 args.chain = chain;
485 }
486 }
487
488 let mut aliases = TokenAliases::load();
490
491 let sp = crate::cli::progress::Spinner::new(&format!(
493 "Crawling token {} on {}...",
494 args.token, args.chain
495 ));
496
497 let dex_client = clients.create_dex_client();
499 let resolved = resolve_token_input(&args, &mut aliases, dex_client.as_ref(), Some(&sp)).await?;
500
501 tracing::info!(
502 token = %resolved.address,
503 chain = %resolved.chain,
504 period = ?args.period,
505 "Starting token crawl"
506 );
507
508 sp.set_message(format!(
509 "Fetching analytics for {} on {}...",
510 resolved.address, resolved.chain
511 ));
512
513 let mut analytics =
515 fetch_token_analytics(&resolved.address, &resolved.chain, &args, clients).await?;
516
517 sp.finish("Token data loaded.");
518
519 if (analytics.token.symbol == "UNKNOWN" || analytics.token.name == "Unknown Token")
521 && let Some((symbol, name)) = &resolved.alias_info
522 {
523 analytics.token.symbol = symbol.clone();
524 analytics.token.name = name.clone();
525 }
526
527 match args.format {
529 OutputFormat::Json => {
530 let json = serde_json::to_string_pretty(&analytics)?;
531 println!("{}", json);
532 }
533 OutputFormat::Csv => {
534 output_csv(&analytics)?;
535 }
536 OutputFormat::Table => {
537 output_table(&analytics, &args)?;
538 }
539 OutputFormat::Markdown => {
540 let md = report::generate_report(&analytics);
541 println!("{}", md);
542 }
543 }
544
545 if let Some(ref report_path) = args.report {
547 let markdown_report = report::generate_report(&analytics);
548 report::save_report(&markdown_report, report_path)?;
549 println!("\nReport saved to: {}", report_path.display());
550 }
551
552 Ok(())
553}
554
555async fn fetch_token_analytics(
557 token_address: &str,
558 chain: &str,
559 args: &CrawlArgs,
560 clients: &dyn ChainClientFactory,
561) -> Result<TokenAnalytics> {
562 let dex_client = clients.create_dex_client();
564
565 let dex_result = dex_client.get_token_data(chain, token_address).await;
567
568 match dex_result {
570 Ok(dex_data) => {
571 fetch_analytics_with_dex(token_address, chain, args, clients, dex_data).await
573 }
574 Err(ScopeError::NotFound(_)) => {
575 tracing::debug!("No DEX data, falling back to block explorer");
577 fetch_analytics_from_explorer(token_address, chain, args, clients).await
578 }
579 Err(e) => Err(e),
580 }
581}
582
583async fn fetch_analytics_with_dex(
585 token_address: &str,
586 chain: &str,
587 args: &CrawlArgs,
588 clients: &dyn ChainClientFactory,
589 dex_data: crate::chains::dex::DexTokenData,
590) -> Result<TokenAnalytics> {
591 let holders = fetch_holders(token_address, chain, args.holders_limit, clients).await?;
593
594 let token = Token {
596 contract_address: token_address.to_string(),
597 symbol: dex_data.symbol.clone(),
598 name: dex_data.name.clone(),
599 decimals: 18, };
601
602 let top_10_pct: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
604 let top_50_pct: f64 = holders.iter().take(50).map(|h| h.percentage).sum();
605 let top_100_pct: f64 = holders.iter().take(100).map(|h| h.percentage).sum();
606
607 let dex_pairs: Vec<DexPair> = dex_data.pairs;
609
610 let volume_7d = DexClient::estimate_7d_volume(dex_data.volume_24h);
612
613 let fetched_at = chrono::Utc::now().timestamp();
615
616 let token_age_hours = dex_data.earliest_pair_created_at.map(|created_at| {
619 let now = chrono::Utc::now().timestamp();
620 let created_at_secs = if created_at > 32503680000 {
622 created_at / 1000
623 } else {
624 created_at
625 };
626 let age_secs = now - created_at_secs;
627 if age_secs > 0 {
628 (age_secs as f64) / 3600.0
629 } else {
630 0.0 }
632 });
633
634 let socials: Vec<crate::chains::TokenSocial> = dex_data
636 .socials
637 .iter()
638 .map(|s| crate::chains::TokenSocial {
639 platform: s.platform.clone(),
640 url: s.url.clone(),
641 })
642 .collect();
643
644 Ok(TokenAnalytics {
645 token,
646 chain: chain.to_string(),
647 holders,
648 total_holders: 0, volume_24h: dex_data.volume_24h,
650 volume_7d,
651 price_usd: dex_data.price_usd,
652 price_change_24h: dex_data.price_change_24h,
653 price_change_7d: 0.0, liquidity_usd: dex_data.liquidity_usd,
655 market_cap: dex_data.market_cap,
656 fdv: dex_data.fdv,
657 total_supply: None,
658 circulating_supply: None,
659 price_history: dex_data.price_history,
660 volume_history: dex_data.volume_history,
661 holder_history: Vec::new(), dex_pairs,
663 fetched_at,
664 top_10_concentration: if top_10_pct > 0.0 {
665 Some(top_10_pct)
666 } else {
667 None
668 },
669 top_50_concentration: if top_50_pct > 0.0 {
670 Some(top_50_pct)
671 } else {
672 None
673 },
674 top_100_concentration: if top_100_pct > 0.0 {
675 Some(top_100_pct)
676 } else {
677 None
678 },
679 price_change_6h: dex_data.price_change_6h,
680 price_change_1h: dex_data.price_change_1h,
681 total_buys_24h: dex_data.total_buys_24h,
682 total_sells_24h: dex_data.total_sells_24h,
683 total_buys_6h: dex_data.total_buys_6h,
684 total_sells_6h: dex_data.total_sells_6h,
685 total_buys_1h: dex_data.total_buys_1h,
686 total_sells_1h: dex_data.total_sells_1h,
687 token_age_hours,
688 image_url: dex_data.image_url.clone(),
689 websites: dex_data.websites.clone(),
690 socials,
691 dexscreener_url: dex_data.dexscreener_url.clone(),
692 })
693}
694
695async fn fetch_analytics_from_explorer(
697 token_address: &str,
698 chain: &str,
699 args: &CrawlArgs,
700 clients: &dyn ChainClientFactory,
701) -> Result<TokenAnalytics> {
702 let has_explorer = matches!(
704 chain,
705 "ethereum" | "polygon" | "arbitrum" | "optimism" | "base" | "bsc" | "solana" | "tron"
706 );
707
708 if !has_explorer {
709 return Err(ScopeError::NotFound(format!(
710 "No DEX data found for token {} on {} and block explorer fallback not supported for this chain",
711 token_address, chain
712 )));
713 }
714
715 let client = clients.create_chain_client(chain)?;
717
718 let token = match client.get_token_info(token_address).await {
720 Ok(t) => t,
721 Err(e) => {
722 tracing::debug!("Failed to fetch token info: {}", e);
723 Token {
725 contract_address: token_address.to_string(),
726 symbol: "UNKNOWN".to_string(),
727 name: "Unknown Token".to_string(),
728 decimals: 18,
729 }
730 }
731 };
732
733 let holders = fetch_holders(token_address, chain, args.holders_limit, clients).await?;
735
736 let total_holders = match client.get_token_holder_count(token_address).await {
738 Ok(count) => count,
739 Err(e) => {
740 tracing::debug!("Failed to fetch holder count: {}", e);
741 0
742 }
743 };
744
745 let top_10_pct: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
747 let top_50_pct: f64 = holders.iter().take(50).map(|h| h.percentage).sum();
748 let top_100_pct: f64 = holders.iter().take(100).map(|h| h.percentage).sum();
749
750 let fetched_at = chrono::Utc::now().timestamp();
752
753 Ok(TokenAnalytics {
754 token,
755 chain: chain.to_string(),
756 holders,
757 total_holders,
758 volume_24h: 0.0,
759 volume_7d: 0.0,
760 price_usd: 0.0,
761 price_change_24h: 0.0,
762 price_change_7d: 0.0,
763 liquidity_usd: 0.0,
764 market_cap: None,
765 fdv: None,
766 total_supply: None,
767 circulating_supply: None,
768 price_history: Vec::new(),
769 volume_history: Vec::new(),
770 holder_history: Vec::new(),
771 dex_pairs: Vec::new(),
772 fetched_at,
773 top_10_concentration: if top_10_pct > 0.0 {
774 Some(top_10_pct)
775 } else {
776 None
777 },
778 top_50_concentration: if top_50_pct > 0.0 {
779 Some(top_50_pct)
780 } else {
781 None
782 },
783 top_100_concentration: if top_100_pct > 0.0 {
784 Some(top_100_pct)
785 } else {
786 None
787 },
788 price_change_6h: 0.0,
789 price_change_1h: 0.0,
790 total_buys_24h: 0,
791 total_sells_24h: 0,
792 total_buys_6h: 0,
793 total_sells_6h: 0,
794 total_buys_1h: 0,
795 total_sells_1h: 0,
796 token_age_hours: None,
797 image_url: None,
798 websites: Vec::new(),
799 socials: Vec::new(),
800 dexscreener_url: None,
801 })
802}
803
804async fn fetch_holders(
806 token_address: &str,
807 chain: &str,
808 limit: u32,
809 clients: &dyn ChainClientFactory,
810) -> Result<Vec<TokenHolder>> {
811 match chain {
813 "ethereum" | "polygon" | "arbitrum" | "optimism" | "base" | "bsc" | "solana" | "tron" => {
814 let client = clients.create_chain_client(chain)?;
815 match client.get_token_holders(token_address, limit).await {
816 Ok(holders) => Ok(holders),
817 Err(e) => {
818 tracing::debug!("Failed to fetch holders: {}", e);
819 Ok(Vec::new())
820 }
821 }
822 }
823 _ => {
824 tracing::info!("Holder data not available for chain: {}", chain);
825 Ok(Vec::new())
826 }
827 }
828}
829
830fn output_table(analytics: &TokenAnalytics, args: &CrawlArgs) -> Result<()> {
832 println!();
833
834 let has_dex_data = analytics.price_usd > 0.0;
836
837 if has_dex_data {
838 output_table_with_dex(analytics, args)
840 } else {
841 output_table_explorer_only(analytics)
843 }
844}
845
846fn output_table_with_dex(analytics: &TokenAnalytics, args: &CrawlArgs) -> Result<()> {
848 if !args.no_charts {
850 let dashboard = charts::render_analytics_dashboard(
851 &analytics.price_history,
852 &analytics.volume_history,
853 &analytics.holders,
854 &analytics.token.symbol,
855 &analytics.chain,
856 );
857 println!("{}", dashboard);
858 } else {
859 println!(
861 "Token: {} ({})",
862 analytics.token.name, analytics.token.symbol
863 );
864 println!("Chain: {}", analytics.chain);
865 println!("Contract: {}", analytics.token.contract_address);
866 println!();
867 }
868
869 println!("Key Metrics");
871 println!("{}", "=".repeat(50));
872 println!("Price: ${:.6}", analytics.price_usd);
873 println!("24h Change: {:+.2}%", analytics.price_change_24h);
874 println!(
875 "24h Volume: ${}",
876 crate::display::format_large_number(analytics.volume_24h)
877 );
878 println!(
879 "Liquidity: ${}",
880 crate::display::format_large_number(analytics.liquidity_usd)
881 );
882
883 if let Some(mc) = analytics.market_cap {
884 println!(
885 "Market Cap: ${}",
886 crate::display::format_large_number(mc)
887 );
888 }
889
890 if let Some(fdv) = analytics.fdv {
891 println!(
892 "FDV: ${}",
893 crate::display::format_large_number(fdv)
894 );
895 }
896
897 if !analytics.dex_pairs.is_empty() {
899 println!();
900 println!("Top Trading Pairs");
901 println!("{}", "=".repeat(50));
902
903 for (i, pair) in analytics.dex_pairs.iter().take(5).enumerate() {
904 println!(
905 "{}. {} {}/{} - ${} (${} liq)",
906 i + 1,
907 pair.dex_name,
908 pair.base_token,
909 pair.quote_token,
910 crate::display::format_large_number(pair.volume_24h),
911 crate::display::format_large_number(pair.liquidity_usd)
912 );
913 }
914 }
915
916 if let Some(top_10) = analytics.top_10_concentration {
918 println!();
919 println!("Holder Concentration");
920 println!("{}", "=".repeat(50));
921 println!("Top 10 holders: {:.1}% of supply", top_10);
922
923 if let Some(top_50) = analytics.top_50_concentration {
924 println!("Top 50 holders: {:.1}% of supply", top_50);
925 }
926 }
927
928 Ok(())
929}
930
931fn output_table_explorer_only(analytics: &TokenAnalytics) -> Result<()> {
933 println!("Token Info (Block Explorer Data)");
934 println!("{}", "=".repeat(60));
935 println!();
936
937 println!("Name: {}", analytics.token.name);
939 println!("Symbol: {}", analytics.token.symbol);
940 println!("Contract: {}", analytics.token.contract_address);
941 println!("Chain: {}", analytics.chain);
942 println!("Decimals: {}", analytics.token.decimals);
943
944 if analytics.total_holders > 0 {
945 println!("Total Holders: {}", analytics.total_holders);
946 }
947
948 if let Some(supply) = &analytics.total_supply {
949 println!("Total Supply: {}", supply);
950 }
951
952 println!();
954 println!("Note: No DEX trading data available for this token.");
955 println!(" Price, volume, and liquidity data require active DEX pairs.");
956
957 if !analytics.holders.is_empty() {
959 println!();
960 println!("Top Holders");
961 println!("{}", "=".repeat(60));
962 println!(
963 "{:>4} {:>10} {:>20} Address",
964 "Rank", "Percent", "Balance"
965 );
966 println!("{}", "-".repeat(80));
967
968 for holder in analytics.holders.iter().take(10) {
969 let addr_display = if holder.address.len() > 20 {
971 format!(
972 "{}...{}",
973 &holder.address[..10],
974 &holder.address[holder.address.len() - 8..]
975 )
976 } else {
977 holder.address.clone()
978 };
979
980 println!(
981 "{:>4} {:>9.2}% {:>20} {}",
982 holder.rank, holder.percentage, holder.formatted_balance, addr_display
983 );
984 }
985 }
986
987 if let Some(top_10) = analytics.top_10_concentration {
989 println!();
990 println!("Holder Concentration");
991 println!("{}", "=".repeat(60));
992 println!("Top 10 holders: {:.1}% of supply", top_10);
993
994 if let Some(top_50) = analytics.top_50_concentration {
995 println!("Top 50 holders: {:.1}% of supply", top_50);
996 }
997 }
998
999 Ok(())
1000}
1001
1002fn output_csv(analytics: &TokenAnalytics) -> Result<()> {
1004 println!("metric,value");
1006
1007 println!("symbol,{}", analytics.token.symbol);
1009 println!("name,{}", analytics.token.name);
1010 println!("chain,{}", analytics.chain);
1011 println!("contract,{}", analytics.token.contract_address);
1012
1013 println!("price_usd,{}", analytics.price_usd);
1015 println!("price_change_24h,{}", analytics.price_change_24h);
1016 println!("volume_24h,{}", analytics.volume_24h);
1017 println!("volume_7d,{}", analytics.volume_7d);
1018 println!("liquidity_usd,{}", analytics.liquidity_usd);
1019
1020 if let Some(mc) = analytics.market_cap {
1021 println!("market_cap,{}", mc);
1022 }
1023
1024 if let Some(fdv) = analytics.fdv {
1025 println!("fdv,{}", fdv);
1026 }
1027
1028 println!("total_holders,{}", analytics.total_holders);
1029
1030 if let Some(top_10) = analytics.top_10_concentration {
1031 println!("top_10_concentration,{}", top_10);
1032 }
1033
1034 if !analytics.holders.is_empty() {
1036 println!();
1037 println!("rank,address,balance,percentage");
1038 for holder in &analytics.holders {
1039 println!(
1040 "{},{},{},{}",
1041 holder.rank, holder.address, holder.balance, holder.percentage
1042 );
1043 }
1044 }
1045
1046 Ok(())
1047}
1048
1049fn abbreviate_address(addr: &str) -> String {
1051 if addr.len() > 16 {
1052 format!("{}...{}", &addr[..8], &addr[addr.len() - 6..])
1053 } else {
1054 addr.to_string()
1055 }
1056}
1057
1058#[cfg(test)]
1063mod tests {
1064 use super::*;
1065
1066 #[test]
1067 fn test_period_as_seconds() {
1068 assert_eq!(Period::Hour1.as_seconds(), 3600);
1069 assert_eq!(Period::Hour24.as_seconds(), 86400);
1070 assert_eq!(Period::Day7.as_seconds(), 604800);
1071 assert_eq!(Period::Day30.as_seconds(), 2592000);
1072 }
1073
1074 #[test]
1075 fn test_period_label() {
1076 assert_eq!(Period::Hour1.label(), "1 Hour");
1077 assert_eq!(Period::Hour24.label(), "24 Hours");
1078 assert_eq!(Period::Day7.label(), "7 Days");
1079 assert_eq!(Period::Day30.label(), "30 Days");
1080 }
1081
1082 #[test]
1083 fn test_format_large_number() {
1084 assert_eq!(crate::display::format_large_number(500.0), "500.00");
1085 assert_eq!(crate::display::format_large_number(1500.0), "1.50K");
1086 assert_eq!(crate::display::format_large_number(1500000.0), "1.50M");
1087 assert_eq!(crate::display::format_large_number(1500000000.0), "1.50B");
1088 }
1089
1090 #[test]
1091 fn test_period_default() {
1092 let period = Period::default();
1093 assert!(matches!(period, Period::Hour24));
1094 }
1095
1096 #[test]
1097 fn test_crawl_args_defaults() {
1098 use clap::Parser;
1099
1100 #[derive(Parser)]
1101 struct TestCli {
1102 #[command(flatten)]
1103 crawl: CrawlArgs,
1104 }
1105
1106 let cli = TestCli::try_parse_from(["test", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"])
1107 .unwrap();
1108
1109 assert_eq!(cli.crawl.chain, "ethereum");
1110 assert!(matches!(cli.crawl.period, Period::Hour24));
1111 assert_eq!(cli.crawl.holders_limit, 10);
1112 assert!(!cli.crawl.no_charts);
1113 assert!(cli.crawl.report.is_none());
1114 }
1115
1116 #[test]
1121 fn test_format_large_number_zero() {
1122 assert_eq!(crate::display::format_large_number(0.0), "0.00");
1123 }
1124
1125 #[test]
1126 fn test_format_large_number_small() {
1127 assert_eq!(crate::display::format_large_number(0.12), "0.12");
1128 }
1129
1130 #[test]
1131 fn test_format_large_number_boundary_k() {
1132 assert_eq!(crate::display::format_large_number(999.99), "999.99");
1133 assert_eq!(crate::display::format_large_number(1000.0), "1.00K");
1134 }
1135
1136 #[test]
1137 fn test_format_large_number_boundary_m() {
1138 assert_eq!(crate::display::format_large_number(999_999.0), "1000.00K");
1139 assert_eq!(crate::display::format_large_number(1_000_000.0), "1.00M");
1140 }
1141
1142 #[test]
1143 fn test_format_large_number_boundary_b() {
1144 assert_eq!(
1145 crate::display::format_large_number(999_999_999.0),
1146 "1000.00M"
1147 );
1148 assert_eq!(
1149 crate::display::format_large_number(1_000_000_000.0),
1150 "1.00B"
1151 );
1152 }
1153
1154 #[test]
1155 fn test_format_large_number_very_large() {
1156 let result = crate::display::format_large_number(1_500_000_000_000.0);
1157 assert!(result.contains("B"));
1158 }
1159
1160 #[test]
1165 fn test_period_seconds_all() {
1166 assert_eq!(Period::Hour1.as_seconds(), 3600);
1167 assert_eq!(Period::Hour24.as_seconds(), 86400);
1168 assert_eq!(Period::Day7.as_seconds(), 604800);
1169 assert_eq!(Period::Day30.as_seconds(), 2592000);
1170 }
1171
1172 #[test]
1173 fn test_period_labels_all() {
1174 assert_eq!(Period::Hour1.label(), "1 Hour");
1175 assert_eq!(Period::Hour24.label(), "24 Hours");
1176 assert_eq!(Period::Day7.label(), "7 Days");
1177 assert_eq!(Period::Day30.label(), "30 Days");
1178 }
1179
1180 use crate::chains::{
1185 DexPair, PricePoint, Token, TokenAnalytics, TokenHolder, TokenSearchResult, TokenSocial,
1186 };
1187
1188 fn make_test_analytics(with_dex: bool) -> TokenAnalytics {
1189 TokenAnalytics {
1190 token: Token {
1191 contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1192 symbol: "USDC".to_string(),
1193 name: "USD Coin".to_string(),
1194 decimals: 6,
1195 },
1196 chain: "ethereum".to_string(),
1197 holders: vec![
1198 TokenHolder {
1199 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
1200 balance: "1000000000000".to_string(),
1201 formatted_balance: "1,000,000".to_string(),
1202 percentage: 12.5,
1203 rank: 1,
1204 },
1205 TokenHolder {
1206 address: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(),
1207 balance: "500000000000".to_string(),
1208 formatted_balance: "500,000".to_string(),
1209 percentage: 6.25,
1210 rank: 2,
1211 },
1212 ],
1213 total_holders: 150_000,
1214 volume_24h: if with_dex { 5_000_000.0 } else { 0.0 },
1215 volume_7d: if with_dex { 25_000_000.0 } else { 0.0 },
1216 price_usd: if with_dex { 0.9999 } else { 0.0 },
1217 price_change_24h: if with_dex { -0.01 } else { 0.0 },
1218 price_change_7d: if with_dex { 0.02 } else { 0.0 },
1219 liquidity_usd: if with_dex { 100_000_000.0 } else { 0.0 },
1220 market_cap: if with_dex {
1221 Some(30_000_000_000.0)
1222 } else {
1223 None
1224 },
1225 fdv: if with_dex {
1226 Some(30_000_000_000.0)
1227 } else {
1228 None
1229 },
1230 total_supply: Some("30000000000".to_string()),
1231 circulating_supply: Some("28000000000".to_string()),
1232 price_history: vec![
1233 PricePoint {
1234 timestamp: 1700000000,
1235 price: 0.9998,
1236 },
1237 PricePoint {
1238 timestamp: 1700003600,
1239 price: 0.9999,
1240 },
1241 ],
1242 volume_history: vec![],
1243 holder_history: vec![],
1244 dex_pairs: if with_dex {
1245 vec![DexPair {
1246 dex_name: "Uniswap V3".to_string(),
1247 pair_address: "0xpair".to_string(),
1248 base_token: "USDC".to_string(),
1249 quote_token: "WETH".to_string(),
1250 price_usd: 0.9999,
1251 volume_24h: 5_000_000.0,
1252 liquidity_usd: 50_000_000.0,
1253 price_change_24h: -0.01,
1254 buys_24h: 1000,
1255 sells_24h: 900,
1256 buys_6h: 300,
1257 sells_6h: 250,
1258 buys_1h: 50,
1259 sells_1h: 45,
1260 pair_created_at: Some(1600000000),
1261 url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
1262 }]
1263 } else {
1264 vec![]
1265 },
1266 fetched_at: 1700003600,
1267 top_10_concentration: Some(35.5),
1268 top_50_concentration: Some(55.0),
1269 top_100_concentration: Some(65.0),
1270 price_change_6h: 0.01,
1271 price_change_1h: -0.005,
1272 total_buys_24h: 1000,
1273 total_sells_24h: 900,
1274 total_buys_6h: 300,
1275 total_sells_6h: 250,
1276 total_buys_1h: 50,
1277 total_sells_1h: 45,
1278 token_age_hours: Some(25000.0),
1279 image_url: None,
1280 websites: vec!["https://www.centre.io/usdc".to_string()],
1281 socials: vec![TokenSocial {
1282 platform: "twitter".to_string(),
1283 url: "https://twitter.com/circle".to_string(),
1284 }],
1285 dexscreener_url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
1286 }
1287 }
1288
1289 fn make_test_crawl_args() -> CrawlArgs {
1290 CrawlArgs {
1291 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1292 chain: "ethereum".to_string(),
1293 period: Period::Hour24,
1294 holders_limit: 10,
1295 format: OutputFormat::Table,
1296 no_charts: true,
1297 report: None,
1298 yes: false,
1299 save: false,
1300 }
1301 }
1302
1303 #[test]
1304 fn test_output_table_with_dex_data() {
1305 let analytics = make_test_analytics(true);
1306 let args = make_test_crawl_args();
1307 let result = output_table(&analytics, &args);
1308 assert!(result.is_ok());
1309 }
1310
1311 #[test]
1312 fn test_output_table_explorer_only() {
1313 let analytics = make_test_analytics(false);
1314 let args = make_test_crawl_args();
1315 let result = output_table(&analytics, &args);
1316 assert!(result.is_ok());
1317 }
1318
1319 #[test]
1320 fn test_output_table_no_holders() {
1321 let mut analytics = make_test_analytics(false);
1322 analytics.holders = vec![];
1323 analytics.total_holders = 0;
1324 analytics.top_10_concentration = None;
1325 analytics.top_50_concentration = None;
1326 let args = make_test_crawl_args();
1327 let result = output_table(&analytics, &args);
1328 assert!(result.is_ok());
1329 }
1330
1331 #[test]
1332 fn test_output_csv() {
1333 let analytics = make_test_analytics(true);
1334 let result = output_csv(&analytics);
1335 assert!(result.is_ok());
1336 }
1337
1338 #[test]
1339 fn test_output_csv_no_market_cap() {
1340 let mut analytics = make_test_analytics(true);
1341 analytics.market_cap = None;
1342 analytics.fdv = None;
1343 analytics.top_10_concentration = None;
1344 let result = output_csv(&analytics);
1345 assert!(result.is_ok());
1346 }
1347
1348 #[test]
1349 fn test_output_csv_no_holders() {
1350 let mut analytics = make_test_analytics(true);
1351 analytics.holders = vec![];
1352 let result = output_csv(&analytics);
1353 assert!(result.is_ok());
1354 }
1355
1356 #[test]
1357 fn test_output_table_with_dex_no_charts() {
1358 let analytics = make_test_analytics(true);
1359 let mut args = make_test_crawl_args();
1360 args.no_charts = true;
1361 let result = output_table_with_dex(&analytics, &args);
1362 assert!(result.is_ok());
1363 }
1364
1365 #[test]
1366 fn test_output_table_with_dex_no_market_cap() {
1367 let mut analytics = make_test_analytics(true);
1368 analytics.market_cap = None;
1369 analytics.fdv = None;
1370 analytics.top_10_concentration = None;
1371 let args = make_test_crawl_args();
1372 let result = output_table_with_dex(&analytics, &args);
1373 assert!(result.is_ok());
1374 }
1375
1376 #[test]
1377 fn test_output_table_explorer_with_concentration() {
1378 let mut analytics = make_test_analytics(false);
1379 analytics.top_10_concentration = Some(40.0);
1380 analytics.top_50_concentration = Some(60.0);
1381 let result = output_table_explorer_only(&analytics);
1382 assert!(result.is_ok());
1383 }
1384
1385 #[test]
1386 fn test_output_table_explorer_no_supply() {
1387 let mut analytics = make_test_analytics(false);
1388 analytics.total_supply = None;
1389 let result = output_table_explorer_only(&analytics);
1390 assert!(result.is_ok());
1391 }
1392
1393 #[test]
1394 fn test_output_table_with_dex_multiple_pairs() {
1395 let mut analytics = make_test_analytics(true);
1396 for i in 0..8 {
1397 analytics.dex_pairs.push(DexPair {
1398 dex_name: format!("DEX {}", i),
1399 pair_address: format!("0xpair{}", i),
1400 base_token: "USDC".to_string(),
1401 quote_token: "WETH".to_string(),
1402 price_usd: 0.9999,
1403 volume_24h: 1_000_000.0 - (i as f64 * 100_000.0),
1404 liquidity_usd: 10_000_000.0 - (i as f64 * 1_000_000.0),
1405 price_change_24h: 0.0,
1406 buys_24h: 100,
1407 sells_24h: 90,
1408 buys_6h: 30,
1409 sells_6h: 25,
1410 buys_1h: 5,
1411 sells_1h: 4,
1412 pair_created_at: None,
1413 url: None,
1414 });
1415 }
1416 let args = make_test_crawl_args();
1417 let result = output_table_with_dex(&analytics, &args);
1419 assert!(result.is_ok());
1420 }
1421
1422 #[test]
1427 fn test_crawl_args_with_report() {
1428 use clap::Parser;
1429
1430 #[derive(Parser)]
1431 struct TestCli {
1432 #[command(flatten)]
1433 crawl: CrawlArgs,
1434 }
1435
1436 let cli = TestCli::try_parse_from([
1437 "test",
1438 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1439 "--report",
1440 "output.md",
1441 ])
1442 .unwrap();
1443
1444 assert_eq!(
1445 cli.crawl.report,
1446 Some(std::path::PathBuf::from("output.md"))
1447 );
1448 }
1449
1450 #[test]
1451 fn test_crawl_args_with_chain_and_period() {
1452 use clap::Parser;
1453
1454 #[derive(Parser)]
1455 struct TestCli {
1456 #[command(flatten)]
1457 crawl: CrawlArgs,
1458 }
1459
1460 let cli = TestCli::try_parse_from([
1461 "test",
1462 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1463 "--chain",
1464 "polygon",
1465 "--period",
1466 "7d",
1467 "--no-charts",
1468 "--yes",
1469 "--save",
1470 ])
1471 .unwrap();
1472
1473 assert_eq!(cli.crawl.chain, "polygon");
1474 assert!(matches!(cli.crawl.period, Period::Day7));
1475 assert!(cli.crawl.no_charts);
1476 assert!(cli.crawl.yes);
1477 assert!(cli.crawl.save);
1478 }
1479
1480 #[test]
1481 fn test_crawl_args_all_periods() {
1482 use clap::Parser;
1483
1484 #[derive(Parser)]
1485 struct TestCli {
1486 #[command(flatten)]
1487 crawl: CrawlArgs,
1488 }
1489
1490 for (period_str, expected) in [
1491 ("1h", Period::Hour1),
1492 ("24h", Period::Hour24),
1493 ("7d", Period::Day7),
1494 ("30d", Period::Day30),
1495 ] {
1496 let cli = TestCli::try_parse_from(["test", "token", "--period", period_str]).unwrap();
1497 assert_eq!(cli.crawl.period.as_seconds(), expected.as_seconds());
1498 }
1499 }
1500
1501 #[test]
1506 fn test_analytics_json_serialization() {
1507 let analytics = make_test_analytics(true);
1508 let json = serde_json::to_string(&analytics).unwrap();
1509 assert!(json.contains("USDC"));
1510 assert!(json.contains("USD Coin"));
1511 assert!(json.contains("ethereum"));
1512 assert!(json.contains("0.9999"));
1513 }
1514
1515 #[test]
1516 fn test_analytics_json_no_optional_fields() {
1517 let mut analytics = make_test_analytics(false);
1518 analytics.market_cap = None;
1519 analytics.fdv = None;
1520 analytics.total_supply = None;
1521 analytics.top_10_concentration = None;
1522 analytics.top_50_concentration = None;
1523 analytics.top_100_concentration = None;
1524 analytics.token_age_hours = None;
1525 analytics.dexscreener_url = None;
1526 let json = serde_json::to_string(&analytics).unwrap();
1527 assert!(!json.contains("market_cap"));
1528 assert!(!json.contains("fdv"));
1529 }
1530
1531 use crate::chains::mocks::{MockClientFactory, MockDexSource};
1536
1537 fn mock_factory_for_crawl() -> MockClientFactory {
1538 let mut factory = MockClientFactory::new();
1539 factory.mock_dex = MockDexSource::new();
1541 factory
1542 }
1543
1544 #[tokio::test]
1545 async fn test_run_crawl_json_output() {
1546 let config = Config::default();
1547 let factory = mock_factory_for_crawl();
1548 let args = CrawlArgs {
1549 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1550 chain: "ethereum".to_string(),
1551 period: Period::Hour24,
1552 holders_limit: 5,
1553 format: OutputFormat::Json,
1554 no_charts: true,
1555 report: None,
1556 yes: true,
1557 save: false,
1558 };
1559 let result = super::run(args, &config, &factory).await;
1560 assert!(result.is_ok());
1561 }
1562
1563 #[tokio::test]
1564 async fn test_run_crawl_table_output() {
1565 let config = Config::default();
1566 let factory = mock_factory_for_crawl();
1567 let args = CrawlArgs {
1568 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1569 chain: "ethereum".to_string(),
1570 period: Period::Hour24,
1571 holders_limit: 5,
1572 format: OutputFormat::Table,
1573 no_charts: true,
1574 report: None,
1575 yes: true,
1576 save: false,
1577 };
1578 let result = super::run(args, &config, &factory).await;
1579 assert!(result.is_ok());
1580 }
1581
1582 #[tokio::test]
1583 async fn test_run_crawl_csv_output() {
1584 let config = Config::default();
1585 let factory = mock_factory_for_crawl();
1586 let args = CrawlArgs {
1587 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1588 chain: "ethereum".to_string(),
1589 period: Period::Hour24,
1590 holders_limit: 5,
1591 format: OutputFormat::Csv,
1592 no_charts: true,
1593 report: None,
1594 yes: true,
1595 save: false,
1596 };
1597 let result = super::run(args, &config, &factory).await;
1598 assert!(result.is_ok());
1599 }
1600
1601 #[tokio::test]
1602 async fn test_run_crawl_symbol_resolution_via_factory_dex() {
1603 let mut factory = MockClientFactory::new();
1605 factory.mock_dex.search_results = vec![TokenSearchResult {
1606 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1607 symbol: "MOCK".to_string(),
1608 name: "Mock Token".to_string(),
1609 chain: "ethereum".to_string(),
1610 price_usd: Some(1.0),
1611 volume_24h: 1_000_000.0,
1612 liquidity_usd: 5_000_000.0,
1613 market_cap: Some(100_000_000.0),
1614 }];
1615 if let Some(ref mut td) = factory.mock_dex.token_data {
1617 td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
1618 }
1619
1620 let config = Config::default();
1621 let args = CrawlArgs {
1622 token: "MOCK".to_string(),
1623 chain: "ethereum".to_string(),
1624 period: Period::Hour24,
1625 holders_limit: 5,
1626 format: OutputFormat::Json,
1627 no_charts: true,
1628 report: None,
1629 yes: true,
1630 save: false,
1631 };
1632 let result = super::run(args, &config, &factory).await;
1633 assert!(result.is_ok());
1634 }
1635
1636 #[tokio::test]
1637 async fn test_run_crawl_no_dex_data_evm() {
1638 let config = Config::default();
1639 let mut factory = MockClientFactory::new();
1640 factory.mock_dex.token_data = None; factory.mock_client.token_info = Some(Token {
1642 contract_address: "0xtoken".to_string(),
1643 symbol: "TEST".to_string(),
1644 name: "Test Token".to_string(),
1645 decimals: 18,
1646 });
1647 let args = CrawlArgs {
1648 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".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 };
1658 let result = super::run(args, &config, &factory).await;
1659 assert!(result.is_ok());
1660 }
1661
1662 #[tokio::test]
1663 async fn test_run_crawl_table_no_charts() {
1664 let config = Config::default();
1665 let factory = mock_factory_for_crawl();
1666 let args = CrawlArgs {
1667 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1668 chain: "ethereum".to_string(),
1669 period: Period::Hour24,
1670 holders_limit: 5,
1671 format: OutputFormat::Table,
1672 no_charts: true,
1673 report: None,
1674 yes: true,
1675 save: false,
1676 };
1677 let result = super::run(args, &config, &factory).await;
1678 assert!(result.is_ok());
1679 }
1680
1681 #[tokio::test]
1682 async fn test_run_crawl_with_charts() {
1683 let config = Config::default();
1684 let factory = mock_factory_for_crawl();
1685 let args = CrawlArgs {
1686 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1687 chain: "ethereum".to_string(),
1688 period: Period::Hour1,
1689 holders_limit: 5,
1690 format: OutputFormat::Table,
1691 no_charts: false, report: None,
1693 yes: true,
1694 save: false,
1695 };
1696 let result = super::run(args, &config, &factory).await;
1697 assert!(result.is_ok());
1698 }
1699
1700 #[tokio::test]
1701 async fn test_run_crawl_day7_period() {
1702 let config = Config::default();
1703 let factory = mock_factory_for_crawl();
1704 let args = CrawlArgs {
1705 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1706 chain: "ethereum".to_string(),
1707 period: Period::Day7,
1708 holders_limit: 5,
1709 format: OutputFormat::Table,
1710 no_charts: true,
1711 report: None,
1712 yes: true,
1713 save: false,
1714 };
1715 let result = super::run(args, &config, &factory).await;
1716 assert!(result.is_ok());
1717 }
1718
1719 #[tokio::test]
1720 async fn test_run_crawl_day30_period() {
1721 let config = Config::default();
1722 let factory = mock_factory_for_crawl();
1723 let args = CrawlArgs {
1724 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1725 chain: "ethereum".to_string(),
1726 period: Period::Day30,
1727 holders_limit: 5,
1728 format: OutputFormat::Table,
1729 no_charts: true,
1730 report: None,
1731 yes: true,
1732 save: false,
1733 };
1734 let result = super::run(args, &config, &factory).await;
1735 assert!(result.is_ok());
1736 }
1737
1738 #[test]
1739 fn test_output_table_with_dex_with_charts() {
1740 let analytics = make_test_analytics(true);
1741 let mut args = make_test_crawl_args();
1742 args.no_charts = false; let result = output_table_with_dex(&analytics, &args);
1744 assert!(result.is_ok());
1745 }
1746
1747 #[test]
1748 fn test_output_table_explorer_short_addresses() {
1749 let mut analytics = make_test_analytics(false);
1750 analytics.holders = vec![TokenHolder {
1751 address: "0xshort".to_string(), balance: "100".to_string(),
1753 formatted_balance: "100".to_string(),
1754 percentage: 1.0,
1755 rank: 1,
1756 }];
1757 let result = output_table_explorer_only(&analytics);
1758 assert!(result.is_ok());
1759 }
1760
1761 #[test]
1762 fn test_output_csv_with_all_fields() {
1763 let analytics = make_test_analytics(true);
1764 let result = output_csv(&analytics);
1765 assert!(result.is_ok());
1766 }
1767
1768 #[tokio::test]
1769 async fn test_run_crawl_with_report() {
1770 let config = Config::default();
1771 let factory = mock_factory_for_crawl();
1772 let tmp = tempfile::NamedTempFile::new().unwrap();
1773 let args = CrawlArgs {
1774 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1775 chain: "ethereum".to_string(),
1776 period: Period::Hour24,
1777 holders_limit: 5,
1778 format: OutputFormat::Table,
1779 no_charts: true,
1780 report: Some(tmp.path().to_path_buf()),
1781 yes: true,
1782 save: false,
1783 };
1784 let result = super::run(args, &config, &factory).await;
1785 assert!(result.is_ok());
1786 let content = std::fs::read_to_string(tmp.path()).unwrap();
1788 assert!(content.contains("Token Analysis Report"));
1789 }
1790
1791 #[test]
1796 fn test_output_table_explorer_long_address_truncation() {
1797 let mut analytics = make_test_analytics(false);
1798 analytics.holders = vec![TokenHolder {
1799 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
1800 balance: "1000000".to_string(),
1801 formatted_balance: "1,000,000".to_string(),
1802 percentage: 50.0,
1803 rank: 1,
1804 }];
1805 let result = output_table_explorer_only(&analytics);
1806 assert!(result.is_ok());
1807 }
1808
1809 #[test]
1810 fn test_output_table_with_dex_empty_pairs() {
1811 let mut analytics = make_test_analytics(true);
1812 analytics.dex_pairs = vec![];
1813 let args = make_test_crawl_args();
1814 let result = output_table_with_dex(&analytics, &args);
1815 assert!(result.is_ok());
1816 }
1817
1818 #[test]
1819 fn test_output_table_explorer_no_concentration() {
1820 let mut analytics = make_test_analytics(false);
1821 analytics.top_10_concentration = None;
1822 analytics.top_50_concentration = None;
1823 analytics.top_100_concentration = None;
1824 let result = output_table_explorer_only(&analytics);
1825 assert!(result.is_ok());
1826 }
1827
1828 #[test]
1829 fn test_output_table_with_dex_top_10_only() {
1830 let mut analytics = make_test_analytics(true);
1831 analytics.top_10_concentration = Some(25.0);
1832 analytics.top_50_concentration = None;
1833 analytics.top_100_concentration = None;
1834 let args = make_test_crawl_args();
1835 let result = output_table_with_dex(&analytics, &args);
1836 assert!(result.is_ok());
1837 }
1838
1839 #[test]
1840 fn test_output_table_with_dex_top_100_concentration() {
1841 let mut analytics = make_test_analytics(true);
1842 analytics.top_10_concentration = Some(20.0);
1843 analytics.top_50_concentration = Some(45.0);
1844 analytics.top_100_concentration = Some(65.0);
1845 let args = make_test_crawl_args();
1846 let result = output_table_with_dex(&analytics, &args);
1847 assert!(result.is_ok());
1848 }
1849
1850 #[test]
1851 fn test_output_csv_with_market_cap_and_fdv() {
1852 let mut analytics = make_test_analytics(true);
1853 analytics.market_cap = Some(1_000_000_000.0);
1854 analytics.fdv = Some(1_500_000_000.0);
1855 let result = output_csv(&analytics);
1856 assert!(result.is_ok());
1857 }
1858
1859 #[test]
1860 fn test_output_table_routing_has_dex_data() {
1861 let analytics = make_test_analytics(true);
1862 assert!(analytics.price_usd > 0.0);
1863 let args = make_test_crawl_args();
1864 let result = output_table(&analytics, &args);
1865 assert!(result.is_ok());
1866 }
1867
1868 #[test]
1869 fn test_output_table_routing_no_dex_data() {
1870 let analytics = make_test_analytics(false);
1871 assert_eq!(analytics.price_usd, 0.0);
1872 let args = make_test_crawl_args();
1873 let result = output_table(&analytics, &args);
1874 assert!(result.is_ok());
1875 }
1876
1877 #[test]
1878 fn test_format_large_number_negative() {
1879 let result = crate::display::format_large_number(-1_000_000.0);
1880 assert!(result.contains("M") || result.contains("-"));
1881 }
1882
1883 #[test]
1884 fn test_select_token_auto_select() {
1885 let results = vec![TokenSearchResult {
1886 address: "0xtoken".to_string(),
1887 symbol: "TKN".to_string(),
1888 name: "Test Token".to_string(),
1889 chain: "ethereum".to_string(),
1890 price_usd: Some(10.0),
1891 volume_24h: 100000.0,
1892 liquidity_usd: 500000.0,
1893 market_cap: Some(1000000.0),
1894 }];
1895 let selected = select_token(&results, true).unwrap();
1896 assert_eq!(selected.symbol, "TKN");
1897 }
1898
1899 #[test]
1900 fn test_select_token_single_result() {
1901 let results = vec![TokenSearchResult {
1902 address: "0xtoken".to_string(),
1903 symbol: "SINGLE".to_string(),
1904 name: "Single Token".to_string(),
1905 chain: "ethereum".to_string(),
1906 price_usd: None,
1907 volume_24h: 0.0,
1908 liquidity_usd: 0.0,
1909 market_cap: None,
1910 }];
1911 let selected = select_token(&results, false).unwrap();
1913 assert_eq!(selected.symbol, "SINGLE");
1914 }
1915
1916 #[test]
1917 fn test_output_table_with_dex_with_holders() {
1918 let mut analytics = make_test_analytics(true);
1919 analytics.holders = vec![
1920 TokenHolder {
1921 address: "0xholder1".to_string(),
1922 balance: "1000000".to_string(),
1923 formatted_balance: "1,000,000".to_string(),
1924 percentage: 30.0,
1925 rank: 1,
1926 },
1927 TokenHolder {
1928 address: "0xholder2".to_string(),
1929 balance: "500000".to_string(),
1930 formatted_balance: "500,000".to_string(),
1931 percentage: 15.0,
1932 rank: 2,
1933 },
1934 ];
1935 let args = make_test_crawl_args();
1936 let result = output_table_with_dex(&analytics, &args);
1937 assert!(result.is_ok());
1938 }
1939
1940 #[test]
1941 fn test_output_json() {
1942 let analytics = make_test_analytics(true);
1943 let result = serde_json::to_string_pretty(&analytics);
1944 assert!(result.is_ok());
1945 }
1946
1947 fn make_search_results() -> Vec<TokenSearchResult> {
1952 vec![
1953 TokenSearchResult {
1954 symbol: "USDC".to_string(),
1955 name: "USD Coin".to_string(),
1956 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1957 chain: "ethereum".to_string(),
1958 price_usd: Some(1.0),
1959 volume_24h: 1_000_000.0,
1960 liquidity_usd: 500_000_000.0,
1961 market_cap: Some(30_000_000_000.0),
1962 },
1963 TokenSearchResult {
1964 symbol: "USDC".to_string(),
1965 name: "USD Coin on Polygon".to_string(),
1966 address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
1967 chain: "polygon".to_string(),
1968 price_usd: Some(0.9999),
1969 volume_24h: 500_000.0,
1970 liquidity_usd: 100_000_000.0,
1971 market_cap: None,
1972 },
1973 TokenSearchResult {
1974 symbol: "USDC".to_string(),
1975 name: "Very Long Token Name That Should Be Truncated To Fit".to_string(),
1976 address: "0x1234567890abcdef".to_string(),
1977 chain: "arbitrum".to_string(),
1978 price_usd: None,
1979 volume_24h: 0.0,
1980 liquidity_usd: 50_000.0,
1981 market_cap: None,
1982 },
1983 ]
1984 }
1985
1986 #[test]
1987 fn test_select_token_impl_auto_select_multi() {
1988 let results = make_search_results();
1989 let mut writer = Vec::new();
1990 let mut reader = std::io::Cursor::new(b"" as &[u8]);
1991
1992 let selected = select_token_impl(&results, true, &mut reader, &mut writer).unwrap();
1993 assert_eq!(selected.symbol, "USDC");
1994 assert_eq!(selected.chain, "ethereum");
1995 let output = String::from_utf8(writer).unwrap();
1996 assert!(output.contains("Selected:"));
1997 }
1998
1999 #[test]
2000 fn test_select_token_impl_single_result() {
2001 let results = vec![make_search_results().remove(0)];
2002 let mut writer = Vec::new();
2003 let mut reader = std::io::Cursor::new(b"" as &[u8]);
2004
2005 let selected = select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
2006 assert_eq!(selected.symbol, "USDC");
2007 }
2008
2009 #[test]
2010 fn test_select_token_user_selects_second() {
2011 let results = make_search_results();
2012 let input = b"2\n";
2013 let mut reader = std::io::Cursor::new(&input[..]);
2014 let mut writer = Vec::new();
2015
2016 let selected = select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
2017 assert_eq!(selected.chain, "polygon");
2018 let output = String::from_utf8(writer).unwrap();
2019 assert!(output.contains("Found 3 matching tokens"));
2020 assert!(output.contains("USDC"));
2021 }
2022
2023 #[test]
2024 fn test_select_token_shows_address_column() {
2025 let results = make_search_results();
2026 let input = b"1\n";
2027 let mut reader = std::io::Cursor::new(&input[..]);
2028 let mut writer = Vec::new();
2029
2030 select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
2031 let output = String::from_utf8(writer).unwrap();
2032
2033 assert!(output.contains("Address"));
2035 assert!(output.contains("0xA0b869...06eB48"));
2037 assert!(output.contains("0x2791Bc...a84174"));
2038 }
2039
2040 #[test]
2041 fn test_abbreviate_address() {
2042 assert_eq!(
2043 abbreviate_address("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
2044 "0xA0b869...06eB48"
2045 );
2046 assert_eq!(abbreviate_address("0x1234abcd"), "0x1234abcd");
2048 }
2049
2050 #[test]
2051 fn test_select_token_user_selects_third() {
2052 let results = make_search_results();
2053 let input = b"3\n";
2054 let mut reader = std::io::Cursor::new(&input[..]);
2055 let mut writer = Vec::new();
2056
2057 let selected = select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
2058 assert_eq!(selected.chain, "arbitrum");
2059 let output = String::from_utf8(writer).unwrap();
2061 assert!(output.contains("..."));
2062 }
2063
2064 #[test]
2065 fn test_select_token_invalid_input() {
2066 let results = make_search_results();
2067 let input = b"abc\n";
2068 let mut reader = std::io::Cursor::new(&input[..]);
2069 let mut writer = Vec::new();
2070
2071 let result = select_token_impl(&results, false, &mut reader, &mut writer);
2072 assert!(result.is_err());
2073 assert!(
2074 result
2075 .unwrap_err()
2076 .to_string()
2077 .contains("Invalid selection")
2078 );
2079 }
2080
2081 #[test]
2082 fn test_select_token_out_of_range_zero() {
2083 let results = make_search_results();
2084 let input = b"0\n";
2085 let mut reader = std::io::Cursor::new(&input[..]);
2086 let mut writer = Vec::new();
2087
2088 let result = select_token_impl(&results, false, &mut reader, &mut writer);
2089 assert!(result.is_err());
2090 assert!(
2091 result
2092 .unwrap_err()
2093 .to_string()
2094 .contains("Selection must be between")
2095 );
2096 }
2097
2098 #[test]
2099 fn test_select_token_out_of_range_high() {
2100 let results = make_search_results();
2101 let input = b"99\n";
2102 let mut reader = std::io::Cursor::new(&input[..]);
2103 let mut writer = Vec::new();
2104
2105 let result = select_token_impl(&results, false, &mut reader, &mut writer);
2106 assert!(result.is_err());
2107 }
2108
2109 #[test]
2114 fn test_prompt_save_alias_yes() {
2115 let input = b"y\n";
2116 let mut reader = std::io::Cursor::new(&input[..]);
2117 let mut writer = Vec::new();
2118
2119 assert!(prompt_save_alias_impl(&mut reader, &mut writer));
2120 let output = String::from_utf8(writer).unwrap();
2121 assert!(output.contains("Save this token"));
2122 }
2123
2124 #[test]
2125 fn test_prompt_save_alias_yes_full() {
2126 let input = b"yes\n";
2127 let mut reader = std::io::Cursor::new(&input[..]);
2128 let mut writer = Vec::new();
2129
2130 assert!(prompt_save_alias_impl(&mut reader, &mut writer));
2131 }
2132
2133 #[test]
2134 fn test_prompt_save_alias_no() {
2135 let input = b"n\n";
2136 let mut reader = std::io::Cursor::new(&input[..]);
2137 let mut writer = Vec::new();
2138
2139 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2140 }
2141
2142 #[test]
2143 fn test_prompt_save_alias_empty() {
2144 let input = b"\n";
2145 let mut reader = std::io::Cursor::new(&input[..]);
2146 let mut writer = Vec::new();
2147
2148 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2149 }
2150
2151 #[test]
2156 fn test_output_csv_no_panic() {
2157 let analytics = create_test_analytics_minimal();
2158 let result = output_csv(&analytics);
2159 assert!(result.is_ok());
2160 }
2161
2162 #[test]
2163 fn test_output_table_no_dex_data() {
2164 let analytics = create_test_analytics_minimal();
2166 let args = CrawlArgs {
2167 token: "0xtest".to_string(),
2168 chain: "ethereum".to_string(),
2169 period: Period::Hour24,
2170 holders_limit: 10,
2171 format: OutputFormat::Table,
2172 no_charts: true,
2173 report: None,
2174 yes: false,
2175 save: false,
2176 };
2177 let result = output_table(&analytics, &args);
2178 assert!(result.is_ok());
2179 }
2180
2181 #[test]
2182 fn test_output_table_with_dex_data_no_charts() {
2183 let mut analytics = create_test_analytics_minimal();
2184 analytics.price_usd = 1.0;
2185 analytics.volume_24h = 1_000_000.0;
2186 analytics.liquidity_usd = 500_000.0;
2187 analytics.market_cap = Some(1_000_000_000.0);
2188 analytics.fdv = Some(2_000_000_000.0);
2189
2190 let args = CrawlArgs {
2191 token: "0xtest".to_string(),
2192 chain: "ethereum".to_string(),
2193 period: Period::Hour24,
2194 holders_limit: 10,
2195 format: OutputFormat::Table,
2196 no_charts: true,
2197 report: None,
2198 yes: false,
2199 save: false,
2200 };
2201 let result = output_table(&analytics, &args);
2202 assert!(result.is_ok());
2203 }
2204
2205 #[test]
2206 fn test_output_table_with_dex_data_and_charts() {
2207 let mut analytics = create_test_analytics_minimal();
2208 analytics.price_usd = 1.0;
2209 analytics.volume_24h = 1_000_000.0;
2210 analytics.liquidity_usd = 500_000.0;
2211 analytics.price_history = vec![
2212 crate::chains::PricePoint {
2213 timestamp: 1,
2214 price: 0.99,
2215 },
2216 crate::chains::PricePoint {
2217 timestamp: 2,
2218 price: 1.01,
2219 },
2220 ];
2221 analytics.volume_history = vec![
2222 crate::chains::VolumePoint {
2223 timestamp: 1,
2224 volume: 50000.0,
2225 },
2226 crate::chains::VolumePoint {
2227 timestamp: 2,
2228 volume: 60000.0,
2229 },
2230 ];
2231
2232 let args = CrawlArgs {
2233 token: "0xtest".to_string(),
2234 chain: "ethereum".to_string(),
2235 period: Period::Hour24,
2236 holders_limit: 10,
2237 format: OutputFormat::Table,
2238 no_charts: false,
2239 report: None,
2240 yes: false,
2241 save: false,
2242 };
2243 let result = output_table(&analytics, &args);
2244 assert!(result.is_ok());
2245 }
2246
2247 fn create_test_analytics_minimal() -> TokenAnalytics {
2248 TokenAnalytics {
2249 token: Token {
2250 contract_address: "0xtest".to_string(),
2251 symbol: "TEST".to_string(),
2252 name: "Test Token".to_string(),
2253 decimals: 18,
2254 },
2255 chain: "ethereum".to_string(),
2256 holders: Vec::new(),
2257 total_holders: 0,
2258 volume_24h: 0.0,
2259 volume_7d: 0.0,
2260 price_usd: 0.0,
2261 price_change_24h: 0.0,
2262 price_change_7d: 0.0,
2263 liquidity_usd: 0.0,
2264 market_cap: None,
2265 fdv: None,
2266 total_supply: None,
2267 circulating_supply: None,
2268 price_history: Vec::new(),
2269 volume_history: Vec::new(),
2270 holder_history: Vec::new(),
2271 dex_pairs: Vec::new(),
2272 fetched_at: 0,
2273 top_10_concentration: None,
2274 top_50_concentration: None,
2275 top_100_concentration: None,
2276 price_change_6h: 0.0,
2277 price_change_1h: 0.0,
2278 total_buys_24h: 0,
2279 total_sells_24h: 0,
2280 total_buys_6h: 0,
2281 total_sells_6h: 0,
2282 total_buys_1h: 0,
2283 total_sells_1h: 0,
2284 token_age_hours: None,
2285 image_url: None,
2286 websites: Vec::new(),
2287 socials: Vec::new(),
2288 dexscreener_url: None,
2289 }
2290 }
2291}