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 println!(
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 println!("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::warn!("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 args: CrawlArgs,
383 _config: &Config,
384 clients: &dyn ChainClientFactory,
385) -> Result<()> {
386 let mut aliases = TokenAliases::load();
388
389 let dex_client = clients.create_dex_client();
391 let resolved = resolve_token_input(&args, &mut aliases, dex_client.as_ref()).await?;
392
393 tracing::info!(
394 token = %resolved.address,
395 chain = %resolved.chain,
396 period = ?args.period,
397 "Starting token crawl"
398 );
399
400 let sp = crate::cli::progress::Spinner::new(&format!(
401 "Crawling token {} on {}...",
402 resolved.address, resolved.chain
403 ));
404
405 let mut analytics =
407 fetch_token_analytics(&resolved.address, &resolved.chain, &args, clients).await?;
408
409 sp.finish("Token data loaded.");
410
411 if (analytics.token.symbol == "UNKNOWN" || analytics.token.name == "Unknown Token")
413 && let Some((symbol, name)) = &resolved.alias_info
414 {
415 analytics.token.symbol = symbol.clone();
416 analytics.token.name = name.clone();
417 }
418
419 match args.format {
421 OutputFormat::Json => {
422 let json = serde_json::to_string_pretty(&analytics)?;
423 println!("{}", json);
424 }
425 OutputFormat::Csv => {
426 output_csv(&analytics)?;
427 }
428 OutputFormat::Table => {
429 output_table(&analytics, &args)?;
430 }
431 OutputFormat::Markdown => {
432 let md = report::generate_report(&analytics);
433 println!("{}", md);
434 }
435 }
436
437 if let Some(ref report_path) = args.report {
439 let markdown_report = report::generate_report(&analytics);
440 report::save_report(&markdown_report, report_path)?;
441 println!("\nReport saved to: {}", report_path.display());
442 }
443
444 Ok(())
445}
446
447async fn fetch_token_analytics(
449 token_address: &str,
450 chain: &str,
451 args: &CrawlArgs,
452 clients: &dyn ChainClientFactory,
453) -> Result<TokenAnalytics> {
454 let dex_client = clients.create_dex_client();
456
457 println!(" Fetching DEX data...");
459 let dex_result = dex_client.get_token_data(chain, token_address).await;
460
461 match dex_result {
463 Ok(dex_data) => {
464 fetch_analytics_with_dex(token_address, chain, args, clients, dex_data).await
466 }
467 Err(ScopeError::NotFound(_)) => {
468 println!(" No DEX data found, fetching from block explorer...");
470 fetch_analytics_from_explorer(token_address, chain, args, clients).await
471 }
472 Err(e) => Err(e),
473 }
474}
475
476async fn fetch_analytics_with_dex(
478 token_address: &str,
479 chain: &str,
480 args: &CrawlArgs,
481 clients: &dyn ChainClientFactory,
482 dex_data: crate::chains::dex::DexTokenData,
483) -> Result<TokenAnalytics> {
484 println!(" Fetching holder data...");
486 let holders = fetch_holders(token_address, chain, args.holders_limit, clients).await?;
487
488 let token = Token {
490 contract_address: token_address.to_string(),
491 symbol: dex_data.symbol.clone(),
492 name: dex_data.name.clone(),
493 decimals: 18, };
495
496 let top_10_pct: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
498 let top_50_pct: f64 = holders.iter().take(50).map(|h| h.percentage).sum();
499 let top_100_pct: f64 = holders.iter().take(100).map(|h| h.percentage).sum();
500
501 let dex_pairs: Vec<DexPair> = dex_data.pairs;
503
504 let volume_7d = DexClient::estimate_7d_volume(dex_data.volume_24h);
506
507 let fetched_at = chrono::Utc::now().timestamp();
509
510 let token_age_hours = dex_data.earliest_pair_created_at.map(|created_at| {
513 let now = chrono::Utc::now().timestamp();
514 let created_at_secs = if created_at > 32503680000 {
516 created_at / 1000
517 } else {
518 created_at
519 };
520 let age_secs = now - created_at_secs;
521 if age_secs > 0 {
522 (age_secs as f64) / 3600.0
523 } else {
524 0.0 }
526 });
527
528 let socials: Vec<crate::chains::TokenSocial> = dex_data
530 .socials
531 .iter()
532 .map(|s| crate::chains::TokenSocial {
533 platform: s.platform.clone(),
534 url: s.url.clone(),
535 })
536 .collect();
537
538 Ok(TokenAnalytics {
539 token,
540 chain: chain.to_string(),
541 holders,
542 total_holders: 0, volume_24h: dex_data.volume_24h,
544 volume_7d,
545 price_usd: dex_data.price_usd,
546 price_change_24h: dex_data.price_change_24h,
547 price_change_7d: 0.0, liquidity_usd: dex_data.liquidity_usd,
549 market_cap: dex_data.market_cap,
550 fdv: dex_data.fdv,
551 total_supply: None,
552 circulating_supply: None,
553 price_history: dex_data.price_history,
554 volume_history: dex_data.volume_history,
555 holder_history: Vec::new(), dex_pairs,
557 fetched_at,
558 top_10_concentration: if top_10_pct > 0.0 {
559 Some(top_10_pct)
560 } else {
561 None
562 },
563 top_50_concentration: if top_50_pct > 0.0 {
564 Some(top_50_pct)
565 } else {
566 None
567 },
568 top_100_concentration: if top_100_pct > 0.0 {
569 Some(top_100_pct)
570 } else {
571 None
572 },
573 price_change_6h: dex_data.price_change_6h,
574 price_change_1h: dex_data.price_change_1h,
575 total_buys_24h: dex_data.total_buys_24h,
576 total_sells_24h: dex_data.total_sells_24h,
577 total_buys_6h: dex_data.total_buys_6h,
578 total_sells_6h: dex_data.total_sells_6h,
579 total_buys_1h: dex_data.total_buys_1h,
580 total_sells_1h: dex_data.total_sells_1h,
581 token_age_hours,
582 image_url: dex_data.image_url.clone(),
583 websites: dex_data.websites.clone(),
584 socials,
585 dexscreener_url: dex_data.dexscreener_url.clone(),
586 })
587}
588
589async fn fetch_analytics_from_explorer(
591 token_address: &str,
592 chain: &str,
593 args: &CrawlArgs,
594 clients: &dyn ChainClientFactory,
595) -> Result<TokenAnalytics> {
596 let has_explorer = matches!(
598 chain,
599 "ethereum" | "polygon" | "arbitrum" | "optimism" | "base" | "bsc" | "solana" | "tron"
600 );
601
602 if !has_explorer {
603 return Err(ScopeError::NotFound(format!(
604 "No DEX data found for token {} on {} and block explorer fallback not supported for this chain",
605 token_address, chain
606 )));
607 }
608
609 let client = clients.create_chain_client(chain)?;
611
612 println!(" Fetching token info...");
614 let token = match client.get_token_info(token_address).await {
615 Ok(t) => t,
616 Err(e) => {
617 tracing::warn!("Failed to fetch token info: {}", e);
618 Token {
620 contract_address: token_address.to_string(),
621 symbol: "UNKNOWN".to_string(),
622 name: "Unknown Token".to_string(),
623 decimals: 18,
624 }
625 }
626 };
627
628 println!(" Fetching holder data...");
630 let holders = fetch_holders(token_address, chain, args.holders_limit, clients).await?;
631
632 println!(" Fetching holder count...");
634 let total_holders = match client.get_token_holder_count(token_address).await {
635 Ok(count) => count,
636 Err(e) => {
637 tracing::warn!("Failed to fetch holder count: {}", e);
638 0
639 }
640 };
641
642 let top_10_pct: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
644 let top_50_pct: f64 = holders.iter().take(50).map(|h| h.percentage).sum();
645 let top_100_pct: f64 = holders.iter().take(100).map(|h| h.percentage).sum();
646
647 let fetched_at = chrono::Utc::now().timestamp();
649
650 Ok(TokenAnalytics {
651 token,
652 chain: chain.to_string(),
653 holders,
654 total_holders,
655 volume_24h: 0.0,
656 volume_7d: 0.0,
657 price_usd: 0.0,
658 price_change_24h: 0.0,
659 price_change_7d: 0.0,
660 liquidity_usd: 0.0,
661 market_cap: None,
662 fdv: None,
663 total_supply: None,
664 circulating_supply: None,
665 price_history: Vec::new(),
666 volume_history: Vec::new(),
667 holder_history: Vec::new(),
668 dex_pairs: Vec::new(),
669 fetched_at,
670 top_10_concentration: if top_10_pct > 0.0 {
671 Some(top_10_pct)
672 } else {
673 None
674 },
675 top_50_concentration: if top_50_pct > 0.0 {
676 Some(top_50_pct)
677 } else {
678 None
679 },
680 top_100_concentration: if top_100_pct > 0.0 {
681 Some(top_100_pct)
682 } else {
683 None
684 },
685 price_change_6h: 0.0,
686 price_change_1h: 0.0,
687 total_buys_24h: 0,
688 total_sells_24h: 0,
689 total_buys_6h: 0,
690 total_sells_6h: 0,
691 total_buys_1h: 0,
692 total_sells_1h: 0,
693 token_age_hours: None,
694 image_url: None,
695 websites: Vec::new(),
696 socials: Vec::new(),
697 dexscreener_url: None,
698 })
699}
700
701async fn fetch_holders(
703 token_address: &str,
704 chain: &str,
705 limit: u32,
706 clients: &dyn ChainClientFactory,
707) -> Result<Vec<TokenHolder>> {
708 match chain {
710 "ethereum" | "polygon" | "arbitrum" | "optimism" | "base" | "bsc" | "solana" | "tron" => {
711 let client = clients.create_chain_client(chain)?;
712 match client.get_token_holders(token_address, limit).await {
713 Ok(holders) => Ok(holders),
714 Err(e) => {
715 tracing::warn!("Failed to fetch holders: {}", e);
716 Ok(Vec::new())
717 }
718 }
719 }
720 _ => {
721 tracing::info!("Holder data not available for chain: {}", chain);
722 Ok(Vec::new())
723 }
724 }
725}
726
727fn output_table(analytics: &TokenAnalytics, args: &CrawlArgs) -> Result<()> {
729 println!();
730
731 let has_dex_data = analytics.price_usd > 0.0;
733
734 if has_dex_data {
735 output_table_with_dex(analytics, args)
737 } else {
738 output_table_explorer_only(analytics)
740 }
741}
742
743fn output_table_with_dex(analytics: &TokenAnalytics, args: &CrawlArgs) -> Result<()> {
745 if !args.no_charts {
747 let dashboard = charts::render_analytics_dashboard(
748 &analytics.price_history,
749 &analytics.volume_history,
750 &analytics.holders,
751 &analytics.token.symbol,
752 &analytics.chain,
753 );
754 println!("{}", dashboard);
755 } else {
756 println!(
758 "Token: {} ({})",
759 analytics.token.name, analytics.token.symbol
760 );
761 println!("Chain: {}", analytics.chain);
762 println!("Contract: {}", analytics.token.contract_address);
763 println!();
764 }
765
766 println!("Key Metrics");
768 println!("{}", "=".repeat(50));
769 println!("Price: ${:.6}", analytics.price_usd);
770 println!("24h Change: {:+.2}%", analytics.price_change_24h);
771 println!(
772 "24h Volume: ${}",
773 crate::display::format_large_number(analytics.volume_24h)
774 );
775 println!(
776 "Liquidity: ${}",
777 crate::display::format_large_number(analytics.liquidity_usd)
778 );
779
780 if let Some(mc) = analytics.market_cap {
781 println!(
782 "Market Cap: ${}",
783 crate::display::format_large_number(mc)
784 );
785 }
786
787 if let Some(fdv) = analytics.fdv {
788 println!(
789 "FDV: ${}",
790 crate::display::format_large_number(fdv)
791 );
792 }
793
794 if !analytics.dex_pairs.is_empty() {
796 println!();
797 println!("Top Trading Pairs");
798 println!("{}", "=".repeat(50));
799
800 for (i, pair) in analytics.dex_pairs.iter().take(5).enumerate() {
801 println!(
802 "{}. {} {}/{} - ${} (${} liq)",
803 i + 1,
804 pair.dex_name,
805 pair.base_token,
806 pair.quote_token,
807 crate::display::format_large_number(pair.volume_24h),
808 crate::display::format_large_number(pair.liquidity_usd)
809 );
810 }
811 }
812
813 if let Some(top_10) = analytics.top_10_concentration {
815 println!();
816 println!("Holder Concentration");
817 println!("{}", "=".repeat(50));
818 println!("Top 10 holders: {:.1}% of supply", top_10);
819
820 if let Some(top_50) = analytics.top_50_concentration {
821 println!("Top 50 holders: {:.1}% of supply", top_50);
822 }
823 }
824
825 Ok(())
826}
827
828fn output_table_explorer_only(analytics: &TokenAnalytics) -> Result<()> {
830 println!("Token Info (Block Explorer Data)");
831 println!("{}", "=".repeat(60));
832 println!();
833
834 println!("Name: {}", analytics.token.name);
836 println!("Symbol: {}", analytics.token.symbol);
837 println!("Contract: {}", analytics.token.contract_address);
838 println!("Chain: {}", analytics.chain);
839 println!("Decimals: {}", analytics.token.decimals);
840
841 if analytics.total_holders > 0 {
842 println!("Total Holders: {}", analytics.total_holders);
843 }
844
845 if let Some(supply) = &analytics.total_supply {
846 println!("Total Supply: {}", supply);
847 }
848
849 println!();
851 println!("Note: No DEX trading data available for this token.");
852 println!(" Price, volume, and liquidity data require active DEX pairs.");
853
854 if !analytics.holders.is_empty() {
856 println!();
857 println!("Top Holders");
858 println!("{}", "=".repeat(60));
859 println!(
860 "{:>4} {:>10} {:>20} Address",
861 "Rank", "Percent", "Balance"
862 );
863 println!("{}", "-".repeat(80));
864
865 for holder in analytics.holders.iter().take(10) {
866 let addr_display = if holder.address.len() > 20 {
868 format!(
869 "{}...{}",
870 &holder.address[..10],
871 &holder.address[holder.address.len() - 8..]
872 )
873 } else {
874 holder.address.clone()
875 };
876
877 println!(
878 "{:>4} {:>9.2}% {:>20} {}",
879 holder.rank, holder.percentage, holder.formatted_balance, addr_display
880 );
881 }
882 }
883
884 if let Some(top_10) = analytics.top_10_concentration {
886 println!();
887 println!("Holder Concentration");
888 println!("{}", "=".repeat(60));
889 println!("Top 10 holders: {:.1}% of supply", top_10);
890
891 if let Some(top_50) = analytics.top_50_concentration {
892 println!("Top 50 holders: {:.1}% of supply", top_50);
893 }
894 }
895
896 Ok(())
897}
898
899fn output_csv(analytics: &TokenAnalytics) -> Result<()> {
901 println!("metric,value");
903
904 println!("symbol,{}", analytics.token.symbol);
906 println!("name,{}", analytics.token.name);
907 println!("chain,{}", analytics.chain);
908 println!("contract,{}", analytics.token.contract_address);
909
910 println!("price_usd,{}", analytics.price_usd);
912 println!("price_change_24h,{}", analytics.price_change_24h);
913 println!("volume_24h,{}", analytics.volume_24h);
914 println!("volume_7d,{}", analytics.volume_7d);
915 println!("liquidity_usd,{}", analytics.liquidity_usd);
916
917 if let Some(mc) = analytics.market_cap {
918 println!("market_cap,{}", mc);
919 }
920
921 if let Some(fdv) = analytics.fdv {
922 println!("fdv,{}", fdv);
923 }
924
925 println!("total_holders,{}", analytics.total_holders);
926
927 if let Some(top_10) = analytics.top_10_concentration {
928 println!("top_10_concentration,{}", top_10);
929 }
930
931 if !analytics.holders.is_empty() {
933 println!();
934 println!("rank,address,balance,percentage");
935 for holder in &analytics.holders {
936 println!(
937 "{},{},{},{}",
938 holder.rank, holder.address, holder.balance, holder.percentage
939 );
940 }
941 }
942
943 Ok(())
944}
945
946fn abbreviate_address(addr: &str) -> String {
948 if addr.len() > 16 {
949 format!("{}...{}", &addr[..8], &addr[addr.len() - 6..])
950 } else {
951 addr.to_string()
952 }
953}
954
955#[cfg(test)]
960mod tests {
961 use super::*;
962
963 #[test]
964 fn test_period_as_seconds() {
965 assert_eq!(Period::Hour1.as_seconds(), 3600);
966 assert_eq!(Period::Hour24.as_seconds(), 86400);
967 assert_eq!(Period::Day7.as_seconds(), 604800);
968 assert_eq!(Period::Day30.as_seconds(), 2592000);
969 }
970
971 #[test]
972 fn test_period_label() {
973 assert_eq!(Period::Hour1.label(), "1 Hour");
974 assert_eq!(Period::Hour24.label(), "24 Hours");
975 assert_eq!(Period::Day7.label(), "7 Days");
976 assert_eq!(Period::Day30.label(), "30 Days");
977 }
978
979 #[test]
980 fn test_format_large_number() {
981 assert_eq!(crate::display::format_large_number(500.0), "500.00");
982 assert_eq!(crate::display::format_large_number(1500.0), "1.50K");
983 assert_eq!(crate::display::format_large_number(1500000.0), "1.50M");
984 assert_eq!(crate::display::format_large_number(1500000000.0), "1.50B");
985 }
986
987 #[test]
988 fn test_period_default() {
989 let period = Period::default();
990 assert!(matches!(period, Period::Hour24));
991 }
992
993 #[test]
994 fn test_crawl_args_defaults() {
995 use clap::Parser;
996
997 #[derive(Parser)]
998 struct TestCli {
999 #[command(flatten)]
1000 crawl: CrawlArgs,
1001 }
1002
1003 let cli = TestCli::try_parse_from(["test", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"])
1004 .unwrap();
1005
1006 assert_eq!(cli.crawl.chain, "ethereum");
1007 assert!(matches!(cli.crawl.period, Period::Hour24));
1008 assert_eq!(cli.crawl.holders_limit, 10);
1009 assert!(!cli.crawl.no_charts);
1010 assert!(cli.crawl.report.is_none());
1011 }
1012
1013 #[test]
1018 fn test_format_large_number_zero() {
1019 assert_eq!(crate::display::format_large_number(0.0), "0.00");
1020 }
1021
1022 #[test]
1023 fn test_format_large_number_small() {
1024 assert_eq!(crate::display::format_large_number(0.12), "0.12");
1025 }
1026
1027 #[test]
1028 fn test_format_large_number_boundary_k() {
1029 assert_eq!(crate::display::format_large_number(999.99), "999.99");
1030 assert_eq!(crate::display::format_large_number(1000.0), "1.00K");
1031 }
1032
1033 #[test]
1034 fn test_format_large_number_boundary_m() {
1035 assert_eq!(crate::display::format_large_number(999_999.0), "1000.00K");
1036 assert_eq!(crate::display::format_large_number(1_000_000.0), "1.00M");
1037 }
1038
1039 #[test]
1040 fn test_format_large_number_boundary_b() {
1041 assert_eq!(
1042 crate::display::format_large_number(999_999_999.0),
1043 "1000.00M"
1044 );
1045 assert_eq!(
1046 crate::display::format_large_number(1_000_000_000.0),
1047 "1.00B"
1048 );
1049 }
1050
1051 #[test]
1052 fn test_format_large_number_very_large() {
1053 let result = crate::display::format_large_number(1_500_000_000_000.0);
1054 assert!(result.contains("B"));
1055 }
1056
1057 #[test]
1062 fn test_period_seconds_all() {
1063 assert_eq!(Period::Hour1.as_seconds(), 3600);
1064 assert_eq!(Period::Hour24.as_seconds(), 86400);
1065 assert_eq!(Period::Day7.as_seconds(), 604800);
1066 assert_eq!(Period::Day30.as_seconds(), 2592000);
1067 }
1068
1069 #[test]
1070 fn test_period_labels_all() {
1071 assert_eq!(Period::Hour1.label(), "1 Hour");
1072 assert_eq!(Period::Hour24.label(), "24 Hours");
1073 assert_eq!(Period::Day7.label(), "7 Days");
1074 assert_eq!(Period::Day30.label(), "30 Days");
1075 }
1076
1077 use crate::chains::{
1082 DexPair, PricePoint, Token, TokenAnalytics, TokenHolder, TokenSearchResult, TokenSocial,
1083 };
1084
1085 fn make_test_analytics(with_dex: bool) -> TokenAnalytics {
1086 TokenAnalytics {
1087 token: Token {
1088 contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1089 symbol: "USDC".to_string(),
1090 name: "USD Coin".to_string(),
1091 decimals: 6,
1092 },
1093 chain: "ethereum".to_string(),
1094 holders: vec![
1095 TokenHolder {
1096 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
1097 balance: "1000000000000".to_string(),
1098 formatted_balance: "1,000,000".to_string(),
1099 percentage: 12.5,
1100 rank: 1,
1101 },
1102 TokenHolder {
1103 address: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(),
1104 balance: "500000000000".to_string(),
1105 formatted_balance: "500,000".to_string(),
1106 percentage: 6.25,
1107 rank: 2,
1108 },
1109 ],
1110 total_holders: 150_000,
1111 volume_24h: if with_dex { 5_000_000.0 } else { 0.0 },
1112 volume_7d: if with_dex { 25_000_000.0 } else { 0.0 },
1113 price_usd: if with_dex { 0.9999 } else { 0.0 },
1114 price_change_24h: if with_dex { -0.01 } else { 0.0 },
1115 price_change_7d: if with_dex { 0.02 } else { 0.0 },
1116 liquidity_usd: if with_dex { 100_000_000.0 } else { 0.0 },
1117 market_cap: if with_dex {
1118 Some(30_000_000_000.0)
1119 } else {
1120 None
1121 },
1122 fdv: if with_dex {
1123 Some(30_000_000_000.0)
1124 } else {
1125 None
1126 },
1127 total_supply: Some("30000000000".to_string()),
1128 circulating_supply: Some("28000000000".to_string()),
1129 price_history: vec![
1130 PricePoint {
1131 timestamp: 1700000000,
1132 price: 0.9998,
1133 },
1134 PricePoint {
1135 timestamp: 1700003600,
1136 price: 0.9999,
1137 },
1138 ],
1139 volume_history: vec![],
1140 holder_history: vec![],
1141 dex_pairs: if with_dex {
1142 vec![DexPair {
1143 dex_name: "Uniswap V3".to_string(),
1144 pair_address: "0xpair".to_string(),
1145 base_token: "USDC".to_string(),
1146 quote_token: "WETH".to_string(),
1147 price_usd: 0.9999,
1148 volume_24h: 5_000_000.0,
1149 liquidity_usd: 50_000_000.0,
1150 price_change_24h: -0.01,
1151 buys_24h: 1000,
1152 sells_24h: 900,
1153 buys_6h: 300,
1154 sells_6h: 250,
1155 buys_1h: 50,
1156 sells_1h: 45,
1157 pair_created_at: Some(1600000000),
1158 url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
1159 }]
1160 } else {
1161 vec![]
1162 },
1163 fetched_at: 1700003600,
1164 top_10_concentration: Some(35.5),
1165 top_50_concentration: Some(55.0),
1166 top_100_concentration: Some(65.0),
1167 price_change_6h: 0.01,
1168 price_change_1h: -0.005,
1169 total_buys_24h: 1000,
1170 total_sells_24h: 900,
1171 total_buys_6h: 300,
1172 total_sells_6h: 250,
1173 total_buys_1h: 50,
1174 total_sells_1h: 45,
1175 token_age_hours: Some(25000.0),
1176 image_url: None,
1177 websites: vec!["https://www.centre.io/usdc".to_string()],
1178 socials: vec![TokenSocial {
1179 platform: "twitter".to_string(),
1180 url: "https://twitter.com/circle".to_string(),
1181 }],
1182 dexscreener_url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
1183 }
1184 }
1185
1186 fn make_test_crawl_args() -> CrawlArgs {
1187 CrawlArgs {
1188 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1189 chain: "ethereum".to_string(),
1190 period: Period::Hour24,
1191 holders_limit: 10,
1192 format: OutputFormat::Table,
1193 no_charts: true,
1194 report: None,
1195 yes: false,
1196 save: false,
1197 }
1198 }
1199
1200 #[test]
1201 fn test_output_table_with_dex_data() {
1202 let analytics = make_test_analytics(true);
1203 let args = make_test_crawl_args();
1204 let result = output_table(&analytics, &args);
1205 assert!(result.is_ok());
1206 }
1207
1208 #[test]
1209 fn test_output_table_explorer_only() {
1210 let analytics = make_test_analytics(false);
1211 let args = make_test_crawl_args();
1212 let result = output_table(&analytics, &args);
1213 assert!(result.is_ok());
1214 }
1215
1216 #[test]
1217 fn test_output_table_no_holders() {
1218 let mut analytics = make_test_analytics(false);
1219 analytics.holders = vec![];
1220 analytics.total_holders = 0;
1221 analytics.top_10_concentration = None;
1222 analytics.top_50_concentration = None;
1223 let args = make_test_crawl_args();
1224 let result = output_table(&analytics, &args);
1225 assert!(result.is_ok());
1226 }
1227
1228 #[test]
1229 fn test_output_csv() {
1230 let analytics = make_test_analytics(true);
1231 let result = output_csv(&analytics);
1232 assert!(result.is_ok());
1233 }
1234
1235 #[test]
1236 fn test_output_csv_no_market_cap() {
1237 let mut analytics = make_test_analytics(true);
1238 analytics.market_cap = None;
1239 analytics.fdv = None;
1240 analytics.top_10_concentration = None;
1241 let result = output_csv(&analytics);
1242 assert!(result.is_ok());
1243 }
1244
1245 #[test]
1246 fn test_output_csv_no_holders() {
1247 let mut analytics = make_test_analytics(true);
1248 analytics.holders = vec![];
1249 let result = output_csv(&analytics);
1250 assert!(result.is_ok());
1251 }
1252
1253 #[test]
1254 fn test_output_table_with_dex_no_charts() {
1255 let analytics = make_test_analytics(true);
1256 let mut args = make_test_crawl_args();
1257 args.no_charts = true;
1258 let result = output_table_with_dex(&analytics, &args);
1259 assert!(result.is_ok());
1260 }
1261
1262 #[test]
1263 fn test_output_table_with_dex_no_market_cap() {
1264 let mut analytics = make_test_analytics(true);
1265 analytics.market_cap = None;
1266 analytics.fdv = None;
1267 analytics.top_10_concentration = None;
1268 let args = make_test_crawl_args();
1269 let result = output_table_with_dex(&analytics, &args);
1270 assert!(result.is_ok());
1271 }
1272
1273 #[test]
1274 fn test_output_table_explorer_with_concentration() {
1275 let mut analytics = make_test_analytics(false);
1276 analytics.top_10_concentration = Some(40.0);
1277 analytics.top_50_concentration = Some(60.0);
1278 let result = output_table_explorer_only(&analytics);
1279 assert!(result.is_ok());
1280 }
1281
1282 #[test]
1283 fn test_output_table_explorer_no_supply() {
1284 let mut analytics = make_test_analytics(false);
1285 analytics.total_supply = None;
1286 let result = output_table_explorer_only(&analytics);
1287 assert!(result.is_ok());
1288 }
1289
1290 #[test]
1291 fn test_output_table_with_dex_multiple_pairs() {
1292 let mut analytics = make_test_analytics(true);
1293 for i in 0..8 {
1294 analytics.dex_pairs.push(DexPair {
1295 dex_name: format!("DEX {}", i),
1296 pair_address: format!("0xpair{}", i),
1297 base_token: "USDC".to_string(),
1298 quote_token: "WETH".to_string(),
1299 price_usd: 0.9999,
1300 volume_24h: 1_000_000.0 - (i as f64 * 100_000.0),
1301 liquidity_usd: 10_000_000.0 - (i as f64 * 1_000_000.0),
1302 price_change_24h: 0.0,
1303 buys_24h: 100,
1304 sells_24h: 90,
1305 buys_6h: 30,
1306 sells_6h: 25,
1307 buys_1h: 5,
1308 sells_1h: 4,
1309 pair_created_at: None,
1310 url: None,
1311 });
1312 }
1313 let args = make_test_crawl_args();
1314 let result = output_table_with_dex(&analytics, &args);
1316 assert!(result.is_ok());
1317 }
1318
1319 #[test]
1324 fn test_crawl_args_with_report() {
1325 use clap::Parser;
1326
1327 #[derive(Parser)]
1328 struct TestCli {
1329 #[command(flatten)]
1330 crawl: CrawlArgs,
1331 }
1332
1333 let cli = TestCli::try_parse_from([
1334 "test",
1335 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1336 "--report",
1337 "output.md",
1338 ])
1339 .unwrap();
1340
1341 assert_eq!(
1342 cli.crawl.report,
1343 Some(std::path::PathBuf::from("output.md"))
1344 );
1345 }
1346
1347 #[test]
1348 fn test_crawl_args_with_chain_and_period() {
1349 use clap::Parser;
1350
1351 #[derive(Parser)]
1352 struct TestCli {
1353 #[command(flatten)]
1354 crawl: CrawlArgs,
1355 }
1356
1357 let cli = TestCli::try_parse_from([
1358 "test",
1359 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1360 "--chain",
1361 "polygon",
1362 "--period",
1363 "7d",
1364 "--no-charts",
1365 "--yes",
1366 "--save",
1367 ])
1368 .unwrap();
1369
1370 assert_eq!(cli.crawl.chain, "polygon");
1371 assert!(matches!(cli.crawl.period, Period::Day7));
1372 assert!(cli.crawl.no_charts);
1373 assert!(cli.crawl.yes);
1374 assert!(cli.crawl.save);
1375 }
1376
1377 #[test]
1378 fn test_crawl_args_all_periods() {
1379 use clap::Parser;
1380
1381 #[derive(Parser)]
1382 struct TestCli {
1383 #[command(flatten)]
1384 crawl: CrawlArgs,
1385 }
1386
1387 for (period_str, expected) in [
1388 ("1h", Period::Hour1),
1389 ("24h", Period::Hour24),
1390 ("7d", Period::Day7),
1391 ("30d", Period::Day30),
1392 ] {
1393 let cli = TestCli::try_parse_from(["test", "token", "--period", period_str]).unwrap();
1394 assert_eq!(cli.crawl.period.as_seconds(), expected.as_seconds());
1395 }
1396 }
1397
1398 #[test]
1403 fn test_analytics_json_serialization() {
1404 let analytics = make_test_analytics(true);
1405 let json = serde_json::to_string(&analytics).unwrap();
1406 assert!(json.contains("USDC"));
1407 assert!(json.contains("USD Coin"));
1408 assert!(json.contains("ethereum"));
1409 assert!(json.contains("0.9999"));
1410 }
1411
1412 #[test]
1413 fn test_analytics_json_no_optional_fields() {
1414 let mut analytics = make_test_analytics(false);
1415 analytics.market_cap = None;
1416 analytics.fdv = None;
1417 analytics.total_supply = None;
1418 analytics.top_10_concentration = None;
1419 analytics.top_50_concentration = None;
1420 analytics.top_100_concentration = None;
1421 analytics.token_age_hours = None;
1422 analytics.dexscreener_url = None;
1423 let json = serde_json::to_string(&analytics).unwrap();
1424 assert!(!json.contains("market_cap"));
1425 assert!(!json.contains("fdv"));
1426 }
1427
1428 use crate::chains::mocks::{MockClientFactory, MockDexSource};
1433
1434 fn mock_factory_for_crawl() -> MockClientFactory {
1435 let mut factory = MockClientFactory::new();
1436 factory.mock_dex = MockDexSource::new();
1438 factory
1439 }
1440
1441 #[tokio::test]
1442 async fn test_run_crawl_json_output() {
1443 let config = Config::default();
1444 let factory = mock_factory_for_crawl();
1445 let args = CrawlArgs {
1446 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1447 chain: "ethereum".to_string(),
1448 period: Period::Hour24,
1449 holders_limit: 5,
1450 format: OutputFormat::Json,
1451 no_charts: true,
1452 report: None,
1453 yes: true,
1454 save: false,
1455 };
1456 let result = super::run(args, &config, &factory).await;
1457 assert!(result.is_ok());
1458 }
1459
1460 #[tokio::test]
1461 async fn test_run_crawl_table_output() {
1462 let config = Config::default();
1463 let factory = mock_factory_for_crawl();
1464 let args = CrawlArgs {
1465 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1466 chain: "ethereum".to_string(),
1467 period: Period::Hour24,
1468 holders_limit: 5,
1469 format: OutputFormat::Table,
1470 no_charts: true,
1471 report: None,
1472 yes: true,
1473 save: false,
1474 };
1475 let result = super::run(args, &config, &factory).await;
1476 assert!(result.is_ok());
1477 }
1478
1479 #[tokio::test]
1480 async fn test_run_crawl_csv_output() {
1481 let config = Config::default();
1482 let factory = mock_factory_for_crawl();
1483 let args = CrawlArgs {
1484 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1485 chain: "ethereum".to_string(),
1486 period: Period::Hour24,
1487 holders_limit: 5,
1488 format: OutputFormat::Csv,
1489 no_charts: true,
1490 report: None,
1491 yes: true,
1492 save: false,
1493 };
1494 let result = super::run(args, &config, &factory).await;
1495 assert!(result.is_ok());
1496 }
1497
1498 #[tokio::test]
1499 async fn test_run_crawl_symbol_resolution_via_factory_dex() {
1500 let mut factory = MockClientFactory::new();
1502 factory.mock_dex.search_results = vec![TokenSearchResult {
1503 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1504 symbol: "MOCK".to_string(),
1505 name: "Mock Token".to_string(),
1506 chain: "ethereum".to_string(),
1507 price_usd: Some(1.0),
1508 volume_24h: 1_000_000.0,
1509 liquidity_usd: 5_000_000.0,
1510 market_cap: Some(100_000_000.0),
1511 }];
1512 if let Some(ref mut td) = factory.mock_dex.token_data {
1514 td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
1515 }
1516
1517 let config = Config::default();
1518 let args = CrawlArgs {
1519 token: "MOCK".to_string(),
1520 chain: "ethereum".to_string(),
1521 period: Period::Hour24,
1522 holders_limit: 5,
1523 format: OutputFormat::Json,
1524 no_charts: true,
1525 report: None,
1526 yes: true,
1527 save: false,
1528 };
1529 let result = super::run(args, &config, &factory).await;
1530 assert!(result.is_ok());
1531 }
1532
1533 #[tokio::test]
1534 async fn test_run_crawl_no_dex_data_evm() {
1535 let config = Config::default();
1536 let mut factory = MockClientFactory::new();
1537 factory.mock_dex.token_data = None; factory.mock_client.token_info = Some(Token {
1539 contract_address: "0xtoken".to_string(),
1540 symbol: "TEST".to_string(),
1541 name: "Test Token".to_string(),
1542 decimals: 18,
1543 });
1544 let args = CrawlArgs {
1545 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1546 chain: "ethereum".to_string(),
1547 period: Period::Hour24,
1548 holders_limit: 5,
1549 format: OutputFormat::Json,
1550 no_charts: true,
1551 report: None,
1552 yes: true,
1553 save: false,
1554 };
1555 let result = super::run(args, &config, &factory).await;
1556 assert!(result.is_ok());
1557 }
1558
1559 #[tokio::test]
1560 async fn test_run_crawl_table_no_charts() {
1561 let config = Config::default();
1562 let factory = mock_factory_for_crawl();
1563 let args = CrawlArgs {
1564 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1565 chain: "ethereum".to_string(),
1566 period: Period::Hour24,
1567 holders_limit: 5,
1568 format: OutputFormat::Table,
1569 no_charts: true,
1570 report: None,
1571 yes: true,
1572 save: false,
1573 };
1574 let result = super::run(args, &config, &factory).await;
1575 assert!(result.is_ok());
1576 }
1577
1578 #[tokio::test]
1579 async fn test_run_crawl_with_charts() {
1580 let config = Config::default();
1581 let factory = mock_factory_for_crawl();
1582 let args = CrawlArgs {
1583 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1584 chain: "ethereum".to_string(),
1585 period: Period::Hour1,
1586 holders_limit: 5,
1587 format: OutputFormat::Table,
1588 no_charts: false, report: None,
1590 yes: true,
1591 save: false,
1592 };
1593 let result = super::run(args, &config, &factory).await;
1594 assert!(result.is_ok());
1595 }
1596
1597 #[tokio::test]
1598 async fn test_run_crawl_day7_period() {
1599 let config = Config::default();
1600 let factory = mock_factory_for_crawl();
1601 let args = CrawlArgs {
1602 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1603 chain: "ethereum".to_string(),
1604 period: Period::Day7,
1605 holders_limit: 5,
1606 format: OutputFormat::Table,
1607 no_charts: true,
1608 report: None,
1609 yes: true,
1610 save: false,
1611 };
1612 let result = super::run(args, &config, &factory).await;
1613 assert!(result.is_ok());
1614 }
1615
1616 #[tokio::test]
1617 async fn test_run_crawl_day30_period() {
1618 let config = Config::default();
1619 let factory = mock_factory_for_crawl();
1620 let args = CrawlArgs {
1621 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1622 chain: "ethereum".to_string(),
1623 period: Period::Day30,
1624 holders_limit: 5,
1625 format: OutputFormat::Table,
1626 no_charts: true,
1627 report: None,
1628 yes: true,
1629 save: false,
1630 };
1631 let result = super::run(args, &config, &factory).await;
1632 assert!(result.is_ok());
1633 }
1634
1635 #[test]
1636 fn test_output_table_with_dex_with_charts() {
1637 let analytics = make_test_analytics(true);
1638 let mut args = make_test_crawl_args();
1639 args.no_charts = false; let result = output_table_with_dex(&analytics, &args);
1641 assert!(result.is_ok());
1642 }
1643
1644 #[test]
1645 fn test_output_table_explorer_short_addresses() {
1646 let mut analytics = make_test_analytics(false);
1647 analytics.holders = vec![TokenHolder {
1648 address: "0xshort".to_string(), balance: "100".to_string(),
1650 formatted_balance: "100".to_string(),
1651 percentage: 1.0,
1652 rank: 1,
1653 }];
1654 let result = output_table_explorer_only(&analytics);
1655 assert!(result.is_ok());
1656 }
1657
1658 #[test]
1659 fn test_output_csv_with_all_fields() {
1660 let analytics = make_test_analytics(true);
1661 let result = output_csv(&analytics);
1662 assert!(result.is_ok());
1663 }
1664
1665 #[tokio::test]
1666 async fn test_run_crawl_with_report() {
1667 let config = Config::default();
1668 let factory = mock_factory_for_crawl();
1669 let tmp = tempfile::NamedTempFile::new().unwrap();
1670 let args = CrawlArgs {
1671 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1672 chain: "ethereum".to_string(),
1673 period: Period::Hour24,
1674 holders_limit: 5,
1675 format: OutputFormat::Table,
1676 no_charts: true,
1677 report: Some(tmp.path().to_path_buf()),
1678 yes: true,
1679 save: false,
1680 };
1681 let result = super::run(args, &config, &factory).await;
1682 assert!(result.is_ok());
1683 let content = std::fs::read_to_string(tmp.path()).unwrap();
1685 assert!(content.contains("Token Analysis Report"));
1686 }
1687
1688 #[test]
1693 fn test_output_table_explorer_long_address_truncation() {
1694 let mut analytics = make_test_analytics(false);
1695 analytics.holders = vec![TokenHolder {
1696 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
1697 balance: "1000000".to_string(),
1698 formatted_balance: "1,000,000".to_string(),
1699 percentage: 50.0,
1700 rank: 1,
1701 }];
1702 let result = output_table_explorer_only(&analytics);
1703 assert!(result.is_ok());
1704 }
1705
1706 #[test]
1707 fn test_output_table_with_dex_empty_pairs() {
1708 let mut analytics = make_test_analytics(true);
1709 analytics.dex_pairs = vec![];
1710 let args = make_test_crawl_args();
1711 let result = output_table_with_dex(&analytics, &args);
1712 assert!(result.is_ok());
1713 }
1714
1715 #[test]
1716 fn test_output_table_explorer_no_concentration() {
1717 let mut analytics = make_test_analytics(false);
1718 analytics.top_10_concentration = None;
1719 analytics.top_50_concentration = None;
1720 analytics.top_100_concentration = None;
1721 let result = output_table_explorer_only(&analytics);
1722 assert!(result.is_ok());
1723 }
1724
1725 #[test]
1726 fn test_output_table_with_dex_top_10_only() {
1727 let mut analytics = make_test_analytics(true);
1728 analytics.top_10_concentration = Some(25.0);
1729 analytics.top_50_concentration = None;
1730 analytics.top_100_concentration = None;
1731 let args = make_test_crawl_args();
1732 let result = output_table_with_dex(&analytics, &args);
1733 assert!(result.is_ok());
1734 }
1735
1736 #[test]
1737 fn test_output_table_with_dex_top_100_concentration() {
1738 let mut analytics = make_test_analytics(true);
1739 analytics.top_10_concentration = Some(20.0);
1740 analytics.top_50_concentration = Some(45.0);
1741 analytics.top_100_concentration = Some(65.0);
1742 let args = make_test_crawl_args();
1743 let result = output_table_with_dex(&analytics, &args);
1744 assert!(result.is_ok());
1745 }
1746
1747 #[test]
1748 fn test_output_csv_with_market_cap_and_fdv() {
1749 let mut analytics = make_test_analytics(true);
1750 analytics.market_cap = Some(1_000_000_000.0);
1751 analytics.fdv = Some(1_500_000_000.0);
1752 let result = output_csv(&analytics);
1753 assert!(result.is_ok());
1754 }
1755
1756 #[test]
1757 fn test_output_table_routing_has_dex_data() {
1758 let analytics = make_test_analytics(true);
1759 assert!(analytics.price_usd > 0.0);
1760 let args = make_test_crawl_args();
1761 let result = output_table(&analytics, &args);
1762 assert!(result.is_ok());
1763 }
1764
1765 #[test]
1766 fn test_output_table_routing_no_dex_data() {
1767 let analytics = make_test_analytics(false);
1768 assert_eq!(analytics.price_usd, 0.0);
1769 let args = make_test_crawl_args();
1770 let result = output_table(&analytics, &args);
1771 assert!(result.is_ok());
1772 }
1773
1774 #[test]
1775 fn test_format_large_number_negative() {
1776 let result = crate::display::format_large_number(-1_000_000.0);
1777 assert!(result.contains("M") || result.contains("-"));
1778 }
1779
1780 #[test]
1781 fn test_select_token_auto_select() {
1782 let results = vec![TokenSearchResult {
1783 address: "0xtoken".to_string(),
1784 symbol: "TKN".to_string(),
1785 name: "Test Token".to_string(),
1786 chain: "ethereum".to_string(),
1787 price_usd: Some(10.0),
1788 volume_24h: 100000.0,
1789 liquidity_usd: 500000.0,
1790 market_cap: Some(1000000.0),
1791 }];
1792 let selected = select_token(&results, true).unwrap();
1793 assert_eq!(selected.symbol, "TKN");
1794 }
1795
1796 #[test]
1797 fn test_select_token_single_result() {
1798 let results = vec![TokenSearchResult {
1799 address: "0xtoken".to_string(),
1800 symbol: "SINGLE".to_string(),
1801 name: "Single Token".to_string(),
1802 chain: "ethereum".to_string(),
1803 price_usd: None,
1804 volume_24h: 0.0,
1805 liquidity_usd: 0.0,
1806 market_cap: None,
1807 }];
1808 let selected = select_token(&results, false).unwrap();
1810 assert_eq!(selected.symbol, "SINGLE");
1811 }
1812
1813 #[test]
1814 fn test_output_table_with_dex_with_holders() {
1815 let mut analytics = make_test_analytics(true);
1816 analytics.holders = vec![
1817 TokenHolder {
1818 address: "0xholder1".to_string(),
1819 balance: "1000000".to_string(),
1820 formatted_balance: "1,000,000".to_string(),
1821 percentage: 30.0,
1822 rank: 1,
1823 },
1824 TokenHolder {
1825 address: "0xholder2".to_string(),
1826 balance: "500000".to_string(),
1827 formatted_balance: "500,000".to_string(),
1828 percentage: 15.0,
1829 rank: 2,
1830 },
1831 ];
1832 let args = make_test_crawl_args();
1833 let result = output_table_with_dex(&analytics, &args);
1834 assert!(result.is_ok());
1835 }
1836
1837 #[test]
1838 fn test_output_json() {
1839 let analytics = make_test_analytics(true);
1840 let result = serde_json::to_string_pretty(&analytics);
1841 assert!(result.is_ok());
1842 }
1843
1844 fn make_search_results() -> Vec<TokenSearchResult> {
1849 vec![
1850 TokenSearchResult {
1851 symbol: "USDC".to_string(),
1852 name: "USD Coin".to_string(),
1853 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1854 chain: "ethereum".to_string(),
1855 price_usd: Some(1.0),
1856 volume_24h: 1_000_000.0,
1857 liquidity_usd: 500_000_000.0,
1858 market_cap: Some(30_000_000_000.0),
1859 },
1860 TokenSearchResult {
1861 symbol: "USDC".to_string(),
1862 name: "USD Coin on Polygon".to_string(),
1863 address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
1864 chain: "polygon".to_string(),
1865 price_usd: Some(0.9999),
1866 volume_24h: 500_000.0,
1867 liquidity_usd: 100_000_000.0,
1868 market_cap: None,
1869 },
1870 TokenSearchResult {
1871 symbol: "USDC".to_string(),
1872 name: "Very Long Token Name That Should Be Truncated To Fit".to_string(),
1873 address: "0x1234567890abcdef".to_string(),
1874 chain: "arbitrum".to_string(),
1875 price_usd: None,
1876 volume_24h: 0.0,
1877 liquidity_usd: 50_000.0,
1878 market_cap: None,
1879 },
1880 ]
1881 }
1882
1883 #[test]
1884 fn test_select_token_impl_auto_select_multi() {
1885 let results = make_search_results();
1886 let mut writer = Vec::new();
1887 let mut reader = std::io::Cursor::new(b"" as &[u8]);
1888
1889 let selected = select_token_impl(&results, true, &mut reader, &mut writer).unwrap();
1890 assert_eq!(selected.symbol, "USDC");
1891 assert_eq!(selected.chain, "ethereum");
1892 let output = String::from_utf8(writer).unwrap();
1893 assert!(output.contains("Selected:"));
1894 }
1895
1896 #[test]
1897 fn test_select_token_impl_single_result() {
1898 let results = vec![make_search_results().remove(0)];
1899 let mut writer = Vec::new();
1900 let mut reader = std::io::Cursor::new(b"" as &[u8]);
1901
1902 let selected = select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
1903 assert_eq!(selected.symbol, "USDC");
1904 }
1905
1906 #[test]
1907 fn test_select_token_user_selects_second() {
1908 let results = make_search_results();
1909 let input = b"2\n";
1910 let mut reader = std::io::Cursor::new(&input[..]);
1911 let mut writer = Vec::new();
1912
1913 let selected = select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
1914 assert_eq!(selected.chain, "polygon");
1915 let output = String::from_utf8(writer).unwrap();
1916 assert!(output.contains("Found 3 matching tokens"));
1917 assert!(output.contains("USDC"));
1918 }
1919
1920 #[test]
1921 fn test_select_token_shows_address_column() {
1922 let results = make_search_results();
1923 let input = b"1\n";
1924 let mut reader = std::io::Cursor::new(&input[..]);
1925 let mut writer = Vec::new();
1926
1927 select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
1928 let output = String::from_utf8(writer).unwrap();
1929
1930 assert!(output.contains("Address"));
1932 assert!(output.contains("0xA0b869...06eB48"));
1934 assert!(output.contains("0x2791Bc...a84174"));
1935 }
1936
1937 #[test]
1938 fn test_abbreviate_address() {
1939 assert_eq!(
1940 abbreviate_address("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
1941 "0xA0b869...06eB48"
1942 );
1943 assert_eq!(abbreviate_address("0x1234abcd"), "0x1234abcd");
1945 }
1946
1947 #[test]
1948 fn test_select_token_user_selects_third() {
1949 let results = make_search_results();
1950 let input = b"3\n";
1951 let mut reader = std::io::Cursor::new(&input[..]);
1952 let mut writer = Vec::new();
1953
1954 let selected = select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
1955 assert_eq!(selected.chain, "arbitrum");
1956 let output = String::from_utf8(writer).unwrap();
1958 assert!(output.contains("..."));
1959 }
1960
1961 #[test]
1962 fn test_select_token_invalid_input() {
1963 let results = make_search_results();
1964 let input = b"abc\n";
1965 let mut reader = std::io::Cursor::new(&input[..]);
1966 let mut writer = Vec::new();
1967
1968 let result = select_token_impl(&results, false, &mut reader, &mut writer);
1969 assert!(result.is_err());
1970 assert!(
1971 result
1972 .unwrap_err()
1973 .to_string()
1974 .contains("Invalid selection")
1975 );
1976 }
1977
1978 #[test]
1979 fn test_select_token_out_of_range_zero() {
1980 let results = make_search_results();
1981 let input = b"0\n";
1982 let mut reader = std::io::Cursor::new(&input[..]);
1983 let mut writer = Vec::new();
1984
1985 let result = select_token_impl(&results, false, &mut reader, &mut writer);
1986 assert!(result.is_err());
1987 assert!(
1988 result
1989 .unwrap_err()
1990 .to_string()
1991 .contains("Selection must be between")
1992 );
1993 }
1994
1995 #[test]
1996 fn test_select_token_out_of_range_high() {
1997 let results = make_search_results();
1998 let input = b"99\n";
1999 let mut reader = std::io::Cursor::new(&input[..]);
2000 let mut writer = Vec::new();
2001
2002 let result = select_token_impl(&results, false, &mut reader, &mut writer);
2003 assert!(result.is_err());
2004 }
2005
2006 #[test]
2011 fn test_prompt_save_alias_yes() {
2012 let input = b"y\n";
2013 let mut reader = std::io::Cursor::new(&input[..]);
2014 let mut writer = Vec::new();
2015
2016 assert!(prompt_save_alias_impl(&mut reader, &mut writer));
2017 let output = String::from_utf8(writer).unwrap();
2018 assert!(output.contains("Save this token"));
2019 }
2020
2021 #[test]
2022 fn test_prompt_save_alias_yes_full() {
2023 let input = b"yes\n";
2024 let mut reader = std::io::Cursor::new(&input[..]);
2025 let mut writer = Vec::new();
2026
2027 assert!(prompt_save_alias_impl(&mut reader, &mut writer));
2028 }
2029
2030 #[test]
2031 fn test_prompt_save_alias_no() {
2032 let input = b"n\n";
2033 let mut reader = std::io::Cursor::new(&input[..]);
2034 let mut writer = Vec::new();
2035
2036 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2037 }
2038
2039 #[test]
2040 fn test_prompt_save_alias_empty() {
2041 let input = b"\n";
2042 let mut reader = std::io::Cursor::new(&input[..]);
2043 let mut writer = Vec::new();
2044
2045 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2046 }
2047
2048 #[test]
2053 fn test_output_csv_no_panic() {
2054 let analytics = create_test_analytics_minimal();
2055 let result = output_csv(&analytics);
2056 assert!(result.is_ok());
2057 }
2058
2059 #[test]
2060 fn test_output_table_no_dex_data() {
2061 let analytics = create_test_analytics_minimal();
2063 let args = CrawlArgs {
2064 token: "0xtest".to_string(),
2065 chain: "ethereum".to_string(),
2066 period: Period::Hour24,
2067 holders_limit: 10,
2068 format: OutputFormat::Table,
2069 no_charts: true,
2070 report: None,
2071 yes: false,
2072 save: false,
2073 };
2074 let result = output_table(&analytics, &args);
2075 assert!(result.is_ok());
2076 }
2077
2078 #[test]
2079 fn test_output_table_with_dex_data_no_charts() {
2080 let mut analytics = create_test_analytics_minimal();
2081 analytics.price_usd = 1.0;
2082 analytics.volume_24h = 1_000_000.0;
2083 analytics.liquidity_usd = 500_000.0;
2084 analytics.market_cap = Some(1_000_000_000.0);
2085 analytics.fdv = Some(2_000_000_000.0);
2086
2087 let args = CrawlArgs {
2088 token: "0xtest".to_string(),
2089 chain: "ethereum".to_string(),
2090 period: Period::Hour24,
2091 holders_limit: 10,
2092 format: OutputFormat::Table,
2093 no_charts: true,
2094 report: None,
2095 yes: false,
2096 save: false,
2097 };
2098 let result = output_table(&analytics, &args);
2099 assert!(result.is_ok());
2100 }
2101
2102 #[test]
2103 fn test_output_table_with_dex_data_and_charts() {
2104 let mut analytics = create_test_analytics_minimal();
2105 analytics.price_usd = 1.0;
2106 analytics.volume_24h = 1_000_000.0;
2107 analytics.liquidity_usd = 500_000.0;
2108 analytics.price_history = vec![
2109 crate::chains::PricePoint {
2110 timestamp: 1,
2111 price: 0.99,
2112 },
2113 crate::chains::PricePoint {
2114 timestamp: 2,
2115 price: 1.01,
2116 },
2117 ];
2118 analytics.volume_history = vec![
2119 crate::chains::VolumePoint {
2120 timestamp: 1,
2121 volume: 50000.0,
2122 },
2123 crate::chains::VolumePoint {
2124 timestamp: 2,
2125 volume: 60000.0,
2126 },
2127 ];
2128
2129 let args = CrawlArgs {
2130 token: "0xtest".to_string(),
2131 chain: "ethereum".to_string(),
2132 period: Period::Hour24,
2133 holders_limit: 10,
2134 format: OutputFormat::Table,
2135 no_charts: false,
2136 report: None,
2137 yes: false,
2138 save: false,
2139 };
2140 let result = output_table(&analytics, &args);
2141 assert!(result.is_ok());
2142 }
2143
2144 fn create_test_analytics_minimal() -> TokenAnalytics {
2145 TokenAnalytics {
2146 token: Token {
2147 contract_address: "0xtest".to_string(),
2148 symbol: "TEST".to_string(),
2149 name: "Test Token".to_string(),
2150 decimals: 18,
2151 },
2152 chain: "ethereum".to_string(),
2153 holders: Vec::new(),
2154 total_holders: 0,
2155 volume_24h: 0.0,
2156 volume_7d: 0.0,
2157 price_usd: 0.0,
2158 price_change_24h: 0.0,
2159 price_change_7d: 0.0,
2160 liquidity_usd: 0.0,
2161 market_cap: None,
2162 fdv: None,
2163 total_supply: None,
2164 circulating_supply: None,
2165 price_history: Vec::new(),
2166 volume_history: Vec::new(),
2167 holder_history: Vec::new(),
2168 dex_pairs: Vec::new(),
2169 fetched_at: 0,
2170 top_10_concentration: None,
2171 top_50_concentration: None,
2172 top_100_concentration: None,
2173 price_change_6h: 0.0,
2174 price_change_1h: 0.0,
2175 total_buys_24h: 0,
2176 total_sells_24h: 0,
2177 total_buys_6h: 0,
2178 total_sells_6h: 0,
2179 total_buys_1h: 0,
2180 total_sells_1h: 0,
2181 token_age_hours: None,
2182 image_url: None,
2183 websites: Vec::new(),
2184 socials: Vec::new(),
2185 dexscreener_url: None,
2186 }
2187 }
2188}