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