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