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