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