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::market::{ExchangeClient, VenueRegistry};
35use crate::tokens::TokenAliases;
36use clap::Args;
37use std::io::{self, BufRead, Write};
38use std::path::PathBuf;
39
40#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
42pub enum Period {
43 #[value(name = "1h")]
45 Hour1,
46 #[default]
48 #[value(name = "24h")]
49 Hour24,
50 #[value(name = "7d")]
52 Day7,
53 #[value(name = "30d")]
55 Day30,
56}
57
58impl Period {
59 pub fn as_seconds(&self) -> i64 {
61 match self {
62 Period::Hour1 => 3600,
63 Period::Hour24 => 86400,
64 Period::Day7 => 604800,
65 Period::Day30 => 2592000,
66 }
67 }
68
69 pub fn label(&self) -> &'static str {
71 match self {
72 Period::Hour1 => "1 Hour",
73 Period::Hour24 => "24 Hours",
74 Period::Day7 => "7 Days",
75 Period::Day30 => "30 Days",
76 }
77 }
78}
79
80#[derive(Debug, Args)]
82#[command(
83 after_help = "\x1b[1mExamples:\x1b[0m
84 scope crawl USDC
85 scope crawl @usdc-token \x1b[2m# address book shortcut\x1b[0m
86 scope crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --chain ethereum
87 scope crawl USDC --period 7d --report usdc_report.md
88 scope crawl PEPE --format json --no-charts",
89 after_long_help = "\x1b[1mExamples:\x1b[0m
90
91 \x1b[1m$ scope crawl USDC\x1b[0m
92
93 Key Metrics
94 ==================================================
95 Price: $0.999900
96 24h Change: -0.01%
97 24h Volume: $5.00M
98 Liquidity: $100.00M
99 Market Cap: $30.00B
100 FDV: $30.00B
101
102 Top Trading Pairs
103 ==================================================
104 1. Uniswap V3 USDC/WETH - $5.00M ($50.00M liq)
105 2. Uniswap V2 USDC/USDT - $2.50M ($25.00M liq)
106 ...
107
108 \x1b[1m$ scope crawl PEPE --period 7d --no-charts\x1b[0m
109
110 Key Metrics
111 ==================================================
112 Price: $0.000012
113 24h Change: +8.50%
114 24h Volume: $120.00M
115 Liquidity: $45.00M
116 Market Cap: $5.00B
117 ...
118
119 \x1b[1m$ scope crawl USDC --report usdc.md\x1b[0m
120
121 Key Metrics
122 ==================================================
123 Price: $0.999900
124 ...
125 Report saved to usdc.md"
126)]
127pub struct CrawlArgs {
128 pub token: String,
135
136 #[arg(short, long, default_value = "ethereum")]
141 pub chain: String,
142
143 #[arg(short, long, default_value = "24h")]
145 pub period: Period,
146
147 #[arg(long, default_value = "10")]
149 pub holders_limit: u32,
150
151 #[arg(short, long, default_value = "table")]
153 pub format: OutputFormat,
154
155 #[arg(long)]
157 pub no_charts: bool,
158
159 #[arg(long, value_name = "PATH")]
161 pub report: Option<PathBuf>,
162
163 #[arg(long)]
165 pub yes: bool,
166
167 #[arg(long)]
169 pub save: bool,
170}
171
172#[derive(Debug, Clone)]
174struct ResolvedToken {
175 address: String,
176 chain: String,
177 alias_info: Option<(String, String)>,
179}
180
181async fn try_cex_fallback(symbol: &str, chain: &str) -> Option<TokenSearchResult> {
193 let registry = VenueRegistry::load().ok()?;
194 let venue_id = "binance";
195 let descriptor = registry.get(venue_id)?;
196 let client = ExchangeClient::from_descriptor(&descriptor.clone());
197 let pair = client.format_pair(&format!("{}USDT", symbol.to_uppercase()));
198 let ticker = client.fetch_ticker(&pair).await.ok()?;
199 let price = ticker.last_price.unwrap_or(0.0);
200 Some(TokenSearchResult {
201 address: String::new(), symbol: symbol.to_uppercase(),
203 name: symbol.to_uppercase(),
204 chain: chain.to_string(),
205 price_usd: Some(price),
206 volume_24h: ticker.volume_24h.unwrap_or(0.0),
207 liquidity_usd: 0.0,
208 market_cap: None,
209 })
210}
211
212async fn resolve_token_input(
218 args: &CrawlArgs,
219 aliases: &mut TokenAliases,
220 dex_client: &dyn DexDataSource,
221 spinner: Option<&crate::cli::progress::Spinner>,
222) -> Result<ResolvedToken> {
223 let input = args.token.trim();
224
225 if TokenAliases::is_address(input) {
227 let chain = if args.chain == "ethereum" {
228 infer_chain_from_address(input)
229 .unwrap_or("ethereum")
230 .to_string()
231 } else {
232 args.chain.clone()
233 };
234 return Ok(ResolvedToken {
235 address: input.to_string(),
236 chain,
237 alias_info: None,
238 });
239 }
240
241 let chain_filter = if args.chain != "ethereum" {
243 Some(args.chain.as_str())
244 } else {
245 None
246 };
247
248 if let Some(token_info) = aliases.get(input, chain_filter) {
249 let msg = format!(
250 "Using saved token: {} ({}) on {}",
251 token_info.symbol, token_info.name, token_info.chain
252 );
253 if let Some(sp) = spinner {
254 sp.set_message(msg);
255 } else {
256 eprintln!(" {}", msg);
257 }
258 return Ok(ResolvedToken {
259 address: token_info.address.clone(),
260 chain: token_info.chain.clone(),
261 alias_info: Some((token_info.symbol.clone(), token_info.name.clone())),
262 });
263 }
264
265 let search_msg = format!("Searching for '{}'...", input);
267 if let Some(sp) = spinner {
268 sp.set_message(search_msg);
269 } else {
270 eprintln!(" {}", search_msg);
271 }
272
273 let mut search_results = dex_client.search_tokens(input, chain_filter).await?;
274
275 if search_results.is_empty()
277 && let Some(fallback) = try_cex_fallback(input, &args.chain).await
278 {
279 let msg = format!(
280 "Not found on DexScreener; found {} on {} (CEX)",
281 fallback.symbol, fallback.chain
282 );
283 if let Some(sp) = spinner {
284 sp.println(&msg);
285 } else {
286 eprintln!(" {}", msg);
287 }
288 search_results.push(fallback);
289 }
290
291 if search_results.is_empty() {
292 return Err(ScopeError::NotFound(format!(
293 "No token found matching '{}' on {} (checked DexScreener and CEX venues)",
294 input, args.chain
295 )));
296 }
297
298 let selected = if let Some(sp) = spinner {
302 if search_results.len() == 1 || args.yes {
303 let sel = &search_results[0];
305 sp.set_message(format!(
306 "Selected: {} ({}) on {} - ${:.6}",
307 sel.symbol,
308 sel.name,
309 sel.chain,
310 sel.price_usd.unwrap_or(0.0)
311 ));
312 sel
313 } else {
314 let result = sp.suspend(|| select_token(&search_results, args.yes));
316 result?
317 }
318 } else {
319 select_token(&search_results, args.yes)?
320 };
321
322 if args.save || (!args.yes && prompt_save_alias()) {
324 aliases.add(
325 &selected.symbol,
326 &selected.chain,
327 &selected.address,
328 &selected.name,
329 );
330 if let Err(e) = aliases.save() {
331 tracing::debug!("Failed to save token alias: {}", e);
332 } else if let Some(sp) = spinner {
333 sp.println(&format!(
334 "Saved {} as alias for future use.",
335 selected.symbol
336 ));
337 } else {
338 println!("Saved {} as alias for future use.", selected.symbol);
339 }
340 }
341
342 Ok(ResolvedToken {
343 address: selected.address.clone(),
344 chain: selected.chain.clone(),
345 alias_info: Some((selected.symbol.clone(), selected.name.clone())),
346 })
347}
348
349fn select_token(results: &[TokenSearchResult], auto_select: bool) -> Result<&TokenSearchResult> {
351 let stdin = io::stdin();
352 let stdout = io::stdout();
353 select_token_impl(results, auto_select, &mut stdin.lock(), &mut stdout.lock())
354}
355
356fn select_token_impl<'a>(
358 results: &'a [TokenSearchResult],
359 auto_select: bool,
360 reader: &mut impl BufRead,
361 writer: &mut impl Write,
362) -> Result<&'a TokenSearchResult> {
363 if results.len() == 1 || auto_select {
364 let selected = &results[0];
365 writeln!(
366 writer,
367 "Selected: {} ({}) on {} - ${:.6}",
368 selected.symbol,
369 selected.name,
370 selected.chain,
371 selected.price_usd.unwrap_or(0.0)
372 )
373 .map_err(|e| ScopeError::Io(e.to_string()))?;
374 return Ok(selected);
375 }
376
377 writeln!(writer, "\nFound {} matching tokens:\n", results.len())
378 .map_err(|e| ScopeError::Io(e.to_string()))?;
379 writeln!(
380 writer,
381 "{:>3} {:>8} {:<22} {:<16} {:<12} {:>12} {:>12}",
382 "#", "Symbol", "Name", "Address", "Chain", "Price", "Liquidity"
383 )
384 .map_err(|e| ScopeError::Io(e.to_string()))?;
385 writeln!(writer, "{}", "─".repeat(98)).map_err(|e| ScopeError::Io(e.to_string()))?;
386
387 for (i, token) in results.iter().enumerate() {
388 let price = token
389 .price_usd
390 .map(|p| format!("${:.6}", p))
391 .unwrap_or_else(|| "N/A".to_string());
392
393 let liquidity = crate::display::format_large_number(token.liquidity_usd);
394 let addr = abbreviate_address(&token.address);
395
396 let name = if token.name.len() > 20 {
398 format!("{}...", &token.name[..17])
399 } else {
400 token.name.clone()
401 };
402
403 writeln!(
404 writer,
405 "{:>3} {:>8} {:<22} {:<16} {:<12} {:>12} {:>12}",
406 i + 1,
407 token.symbol,
408 name,
409 addr,
410 token.chain,
411 price,
412 liquidity
413 )
414 .map_err(|e| ScopeError::Io(e.to_string()))?;
415 }
416
417 writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
418 write!(writer, "Select token (1-{}): ", results.len())
419 .map_err(|e| ScopeError::Io(e.to_string()))?;
420 writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
421
422 let mut input = String::new();
423 reader
424 .read_line(&mut input)
425 .map_err(|e| ScopeError::Io(e.to_string()))?;
426
427 let selection: usize = input
428 .trim()
429 .parse()
430 .map_err(|_| ScopeError::Api("Invalid selection".to_string()))?;
431
432 if selection < 1 || selection > results.len() {
433 return Err(ScopeError::Api(format!(
434 "Selection must be between 1 and {}",
435 results.len()
436 )));
437 }
438
439 Ok(&results[selection - 1])
440}
441
442fn prompt_save_alias() -> bool {
444 let stdin = io::stdin();
445 let stdout = io::stdout();
446 prompt_save_alias_impl(&mut stdin.lock(), &mut stdout.lock())
447}
448
449fn prompt_save_alias_impl(reader: &mut impl BufRead, writer: &mut impl Write) -> bool {
451 if write!(writer, "Save this token for future use? [y/N]: ").is_err() {
452 return false;
453 }
454 if writer.flush().is_err() {
455 return false;
456 }
457
458 let mut input = String::new();
459 if reader.read_line(&mut input).is_err() {
460 return false;
461 }
462
463 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
464}
465
466pub async fn fetch_analytics_for_input(
472 token_input: &str,
473 chain: &str,
474 period: Period,
475 holders_limit: u32,
476 clients: &dyn ChainClientFactory,
477 spinner: Option<&crate::cli::progress::Spinner>,
478) -> Result<TokenAnalytics> {
479 let args = CrawlArgs {
480 token: token_input.to_string(),
481 chain: chain.to_string(),
482 period,
483 holders_limit,
484 format: OutputFormat::Table,
485 no_charts: true,
486 report: None,
487 yes: true,
488 save: false,
489 };
490 let mut aliases = TokenAliases::load();
491 let dex_client = clients.create_dex_client();
492 let resolved = resolve_token_input(&args, &mut aliases, dex_client.as_ref(), spinner).await?;
493 if let Some(sp) = spinner {
494 sp.set_message(format!(
495 "Fetching analytics for {} on {}...",
496 resolved.address, resolved.chain
497 ));
498 }
499 let mut analytics =
500 fetch_token_analytics(&resolved.address, &resolved.chain, &args, clients).await?;
501 if let Some((symbol, name)) = &resolved.alias_info
502 && (analytics.token.symbol == "UNKNOWN" || analytics.token.name == "Unknown Token")
503 {
504 analytics.token.symbol = symbol.clone();
505 analytics.token.name = name.clone();
506 }
507 Ok(analytics)
508}
509
510pub async fn run(
515 mut args: CrawlArgs,
516 config: &Config,
517 clients: &dyn ChainClientFactory,
518) -> Result<()> {
519 if let Some((address, chain)) =
521 crate::cli::address_book::resolve_address_book_input(&args.token, config)?
522 {
523 args.token = address;
524 if args.chain == "ethereum" {
525 args.chain = chain;
526 }
527 }
528
529 let mut aliases = TokenAliases::load();
531
532 let sp = crate::cli::progress::Spinner::new(&format!(
534 "Crawling token {} on {}...",
535 args.token, args.chain
536 ));
537
538 let dex_client = clients.create_dex_client();
540 let resolved = resolve_token_input(&args, &mut aliases, dex_client.as_ref(), Some(&sp)).await?;
541
542 tracing::info!(
543 token = %resolved.address,
544 chain = %resolved.chain,
545 period = ?args.period,
546 "Starting token crawl"
547 );
548
549 sp.set_message(format!(
550 "Fetching analytics for {} on {}...",
551 resolved.address, resolved.chain
552 ));
553
554 let mut analytics =
556 fetch_token_analytics(&resolved.address, &resolved.chain, &args, clients).await?;
557
558 sp.finish("Token data loaded.");
559
560 if (analytics.token.symbol == "UNKNOWN" || analytics.token.name == "Unknown Token")
562 && let Some((symbol, name)) = &resolved.alias_info
563 {
564 analytics.token.symbol = symbol.clone();
565 analytics.token.name = name.clone();
566 }
567
568 match args.format {
570 OutputFormat::Json => {
571 let json = serde_json::to_string_pretty(&analytics)?;
572 println!("{}", json);
573 }
574 OutputFormat::Csv => {
575 output_csv(&analytics)?;
576 }
577 OutputFormat::Table => {
578 output_table(&analytics, &args)?;
579 }
580 OutputFormat::Markdown => {
581 let md = report::generate_report(&analytics);
582 println!("{}", md);
583 }
584 }
585
586 if let Some(ref report_path) = args.report {
588 let markdown_report = report::generate_report(&analytics);
589 report::save_report(&markdown_report, report_path)?;
590 println!("\nReport saved to: {}", report_path.display());
591 }
592
593 Ok(())
594}
595
596async fn fetch_token_analytics(
598 token_address: &str,
599 chain: &str,
600 args: &CrawlArgs,
601 clients: &dyn ChainClientFactory,
602) -> Result<TokenAnalytics> {
603 let dex_client = clients.create_dex_client();
605
606 let dex_result = dex_client.get_token_data(chain, token_address).await;
608
609 match dex_result {
611 Ok(dex_data) => {
612 fetch_analytics_with_dex(token_address, chain, args, clients, dex_data).await
614 }
615 Err(ScopeError::NotFound(_)) => {
616 tracing::debug!("No DEX data, falling back to block explorer");
618 fetch_analytics_from_explorer(token_address, chain, args, clients).await
619 }
620 Err(e) => Err(e),
621 }
622}
623
624async fn fetch_analytics_with_dex(
626 token_address: &str,
627 chain: &str,
628 args: &CrawlArgs,
629 clients: &dyn ChainClientFactory,
630 dex_data: crate::chains::dex::DexTokenData,
631) -> Result<TokenAnalytics> {
632 let holders = fetch_holders(token_address, chain, args.holders_limit, clients).await?;
634
635 let token = Token {
637 contract_address: token_address.to_string(),
638 symbol: dex_data.symbol.clone(),
639 name: dex_data.name.clone(),
640 decimals: 18, };
642
643 let top_10_pct: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
645 let top_50_pct: f64 = holders.iter().take(50).map(|h| h.percentage).sum();
646 let top_100_pct: f64 = holders.iter().take(100).map(|h| h.percentage).sum();
647
648 let dex_pairs: Vec<DexPair> = dex_data.pairs;
650
651 let volume_7d = DexClient::estimate_7d_volume(dex_data.volume_24h);
653
654 let fetched_at = chrono::Utc::now().timestamp();
656
657 let token_age_hours = dex_data.earliest_pair_created_at.map(|created_at| {
660 let now = chrono::Utc::now().timestamp();
661 let created_at_secs = if created_at > 32503680000 {
663 created_at / 1000
664 } else {
665 created_at
666 };
667 let age_secs = now - created_at_secs;
668 if age_secs > 0 {
669 (age_secs as f64) / 3600.0
670 } else {
671 0.0 }
673 });
674
675 let socials: Vec<crate::chains::TokenSocial> = dex_data
677 .socials
678 .iter()
679 .map(|s| crate::chains::TokenSocial {
680 platform: s.platform.clone(),
681 url: s.url.clone(),
682 })
683 .collect();
684
685 Ok(TokenAnalytics {
686 token,
687 chain: chain.to_string(),
688 holders,
689 total_holders: 0, volume_24h: dex_data.volume_24h,
691 volume_7d,
692 price_usd: dex_data.price_usd,
693 price_change_24h: dex_data.price_change_24h,
694 price_change_7d: 0.0, liquidity_usd: dex_data.liquidity_usd,
696 market_cap: dex_data.market_cap,
697 fdv: dex_data.fdv,
698 total_supply: None,
699 circulating_supply: None,
700 price_history: dex_data.price_history,
701 volume_history: dex_data.volume_history,
702 holder_history: Vec::new(), dex_pairs,
704 fetched_at,
705 top_10_concentration: if top_10_pct > 0.0 {
706 Some(top_10_pct)
707 } else {
708 None
709 },
710 top_50_concentration: if top_50_pct > 0.0 {
711 Some(top_50_pct)
712 } else {
713 None
714 },
715 top_100_concentration: if top_100_pct > 0.0 {
716 Some(top_100_pct)
717 } else {
718 None
719 },
720 price_change_6h: dex_data.price_change_6h,
721 price_change_1h: dex_data.price_change_1h,
722 total_buys_24h: dex_data.total_buys_24h,
723 total_sells_24h: dex_data.total_sells_24h,
724 total_buys_6h: dex_data.total_buys_6h,
725 total_sells_6h: dex_data.total_sells_6h,
726 total_buys_1h: dex_data.total_buys_1h,
727 total_sells_1h: dex_data.total_sells_1h,
728 token_age_hours,
729 image_url: dex_data.image_url.clone(),
730 websites: dex_data.websites.clone(),
731 socials,
732 dexscreener_url: dex_data.dexscreener_url.clone(),
733 })
734}
735
736async fn fetch_analytics_from_explorer(
738 token_address: &str,
739 chain: &str,
740 args: &CrawlArgs,
741 clients: &dyn ChainClientFactory,
742) -> Result<TokenAnalytics> {
743 let has_explorer = matches!(
745 chain,
746 "ethereum" | "polygon" | "arbitrum" | "optimism" | "base" | "bsc" | "solana" | "tron"
747 );
748
749 if !has_explorer {
750 return Err(ScopeError::NotFound(format!(
751 "No DEX data found for token {} on {} and block explorer fallback not supported for this chain",
752 token_address, chain
753 )));
754 }
755
756 let client = clients.create_chain_client(chain)?;
758
759 let token = match client.get_token_info(token_address).await {
761 Ok(t) => t,
762 Err(e) => {
763 tracing::debug!("Failed to fetch token info: {}", e);
764 Token {
766 contract_address: token_address.to_string(),
767 symbol: "UNKNOWN".to_string(),
768 name: "Unknown Token".to_string(),
769 decimals: 18,
770 }
771 }
772 };
773
774 let holders = fetch_holders(token_address, chain, args.holders_limit, clients).await?;
776
777 let total_holders = match client.get_token_holder_count(token_address).await {
779 Ok(count) => count,
780 Err(e) => {
781 tracing::debug!("Failed to fetch holder count: {}", e);
782 0
783 }
784 };
785
786 let top_10_pct: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
788 let top_50_pct: f64 = holders.iter().take(50).map(|h| h.percentage).sum();
789 let top_100_pct: f64 = holders.iter().take(100).map(|h| h.percentage).sum();
790
791 let fetched_at = chrono::Utc::now().timestamp();
793
794 Ok(TokenAnalytics {
795 token,
796 chain: chain.to_string(),
797 holders,
798 total_holders,
799 volume_24h: 0.0,
800 volume_7d: 0.0,
801 price_usd: 0.0,
802 price_change_24h: 0.0,
803 price_change_7d: 0.0,
804 liquidity_usd: 0.0,
805 market_cap: None,
806 fdv: None,
807 total_supply: None,
808 circulating_supply: None,
809 price_history: Vec::new(),
810 volume_history: Vec::new(),
811 holder_history: Vec::new(),
812 dex_pairs: Vec::new(),
813 fetched_at,
814 top_10_concentration: if top_10_pct > 0.0 {
815 Some(top_10_pct)
816 } else {
817 None
818 },
819 top_50_concentration: if top_50_pct > 0.0 {
820 Some(top_50_pct)
821 } else {
822 None
823 },
824 top_100_concentration: if top_100_pct > 0.0 {
825 Some(top_100_pct)
826 } else {
827 None
828 },
829 price_change_6h: 0.0,
830 price_change_1h: 0.0,
831 total_buys_24h: 0,
832 total_sells_24h: 0,
833 total_buys_6h: 0,
834 total_sells_6h: 0,
835 total_buys_1h: 0,
836 total_sells_1h: 0,
837 token_age_hours: None,
838 image_url: None,
839 websites: Vec::new(),
840 socials: Vec::new(),
841 dexscreener_url: None,
842 })
843}
844
845async fn fetch_holders(
847 token_address: &str,
848 chain: &str,
849 limit: u32,
850 clients: &dyn ChainClientFactory,
851) -> Result<Vec<TokenHolder>> {
852 match chain {
854 "ethereum" | "polygon" | "arbitrum" | "optimism" | "base" | "bsc" | "solana" | "tron" => {
855 let client = clients.create_chain_client(chain)?;
856 match client.get_token_holders(token_address, limit).await {
857 Ok(holders) => Ok(holders),
858 Err(e) => {
859 tracing::debug!("Failed to fetch holders: {}", e);
860 Ok(Vec::new())
861 }
862 }
863 }
864 _ => {
865 tracing::info!("Holder data not available for chain: {}", chain);
866 Ok(Vec::new())
867 }
868 }
869}
870
871fn output_table(analytics: &TokenAnalytics, args: &CrawlArgs) -> Result<()> {
873 println!();
874
875 let has_dex_data = analytics.price_usd > 0.0;
877
878 if has_dex_data {
879 output_table_with_dex(analytics, args)
881 } else {
882 output_table_explorer_only(analytics)
884 }
885}
886
887fn output_table_with_dex(analytics: &TokenAnalytics, args: &CrawlArgs) -> Result<()> {
889 if !args.no_charts {
891 let dashboard = charts::render_analytics_dashboard(
892 &analytics.price_history,
893 &analytics.volume_history,
894 &analytics.holders,
895 &analytics.token.symbol,
896 &analytics.chain,
897 );
898 println!("{}", dashboard);
899 } else {
900 println!(
902 "Token: {} ({})",
903 analytics.token.name, analytics.token.symbol
904 );
905 println!("Chain: {}", analytics.chain);
906 println!("Contract: {}", analytics.token.contract_address);
907 println!();
908 }
909
910 println!("Key Metrics");
912 println!("{}", "=".repeat(50));
913 println!("Price: ${:.6}", analytics.price_usd);
914 println!("24h Change: {:+.2}%", analytics.price_change_24h);
915 println!(
916 "24h Volume: ${}",
917 crate::display::format_large_number(analytics.volume_24h)
918 );
919 println!(
920 "Liquidity: ${}",
921 crate::display::format_large_number(analytics.liquidity_usd)
922 );
923
924 if let Some(mc) = analytics.market_cap {
925 println!(
926 "Market Cap: ${}",
927 crate::display::format_large_number(mc)
928 );
929 }
930
931 if let Some(fdv) = analytics.fdv {
932 println!(
933 "FDV: ${}",
934 crate::display::format_large_number(fdv)
935 );
936 }
937
938 if !analytics.dex_pairs.is_empty() {
940 println!();
941 println!("Top Trading Pairs");
942 println!("{}", "=".repeat(50));
943
944 for (i, pair) in analytics.dex_pairs.iter().take(5).enumerate() {
945 println!(
946 "{}. {} {}/{} - ${} (${} liq)",
947 i + 1,
948 pair.dex_name,
949 pair.base_token,
950 pair.quote_token,
951 crate::display::format_large_number(pair.volume_24h),
952 crate::display::format_large_number(pair.liquidity_usd)
953 );
954 }
955 }
956
957 if let Some(top_10) = analytics.top_10_concentration {
959 println!();
960 println!("Holder Concentration");
961 println!("{}", "=".repeat(50));
962 println!("Top 10 holders: {:.1}% of supply", top_10);
963
964 if let Some(top_50) = analytics.top_50_concentration {
965 println!("Top 50 holders: {:.1}% of supply", top_50);
966 }
967 }
968
969 Ok(())
970}
971
972fn output_table_explorer_only(analytics: &TokenAnalytics) -> Result<()> {
974 println!("Token Info (Block Explorer Data)");
975 println!("{}", "=".repeat(60));
976 println!();
977
978 println!("Name: {}", analytics.token.name);
980 println!("Symbol: {}", analytics.token.symbol);
981 println!("Contract: {}", analytics.token.contract_address);
982 println!("Chain: {}", analytics.chain);
983 println!("Decimals: {}", analytics.token.decimals);
984
985 if analytics.total_holders > 0 {
986 println!("Total Holders: {}", analytics.total_holders);
987 }
988
989 if let Some(supply) = &analytics.total_supply {
990 println!("Total Supply: {}", supply);
991 }
992
993 println!();
995 println!("Note: No DEX trading data available for this token.");
996 println!(" Price, volume, and liquidity data require active DEX pairs.");
997
998 if !analytics.holders.is_empty() {
1000 println!();
1001 println!("Top Holders");
1002 println!("{}", "=".repeat(60));
1003 println!(
1004 "{:>4} {:>10} {:>20} Address",
1005 "Rank", "Percent", "Balance"
1006 );
1007 println!("{}", "-".repeat(80));
1008
1009 for holder in analytics.holders.iter().take(10) {
1010 let addr_display = if holder.address.len() > 20 {
1012 format!(
1013 "{}...{}",
1014 &holder.address[..10],
1015 &holder.address[holder.address.len() - 8..]
1016 )
1017 } else {
1018 holder.address.clone()
1019 };
1020
1021 println!(
1022 "{:>4} {:>9.2}% {:>20} {}",
1023 holder.rank, holder.percentage, holder.formatted_balance, addr_display
1024 );
1025 }
1026 }
1027
1028 if let Some(top_10) = analytics.top_10_concentration {
1030 println!();
1031 println!("Holder Concentration");
1032 println!("{}", "=".repeat(60));
1033 println!("Top 10 holders: {:.1}% of supply", top_10);
1034
1035 if let Some(top_50) = analytics.top_50_concentration {
1036 println!("Top 50 holders: {:.1}% of supply", top_50);
1037 }
1038 }
1039
1040 Ok(())
1041}
1042
1043fn output_csv(analytics: &TokenAnalytics) -> Result<()> {
1045 println!("metric,value");
1047
1048 println!("symbol,{}", analytics.token.symbol);
1050 println!("name,{}", analytics.token.name);
1051 println!("chain,{}", analytics.chain);
1052 println!("contract,{}", analytics.token.contract_address);
1053
1054 println!("price_usd,{}", analytics.price_usd);
1056 println!("price_change_24h,{}", analytics.price_change_24h);
1057 println!("volume_24h,{}", analytics.volume_24h);
1058 println!("volume_7d,{}", analytics.volume_7d);
1059 println!("liquidity_usd,{}", analytics.liquidity_usd);
1060
1061 if let Some(mc) = analytics.market_cap {
1062 println!("market_cap,{}", mc);
1063 }
1064
1065 if let Some(fdv) = analytics.fdv {
1066 println!("fdv,{}", fdv);
1067 }
1068
1069 println!("total_holders,{}", analytics.total_holders);
1070
1071 if let Some(top_10) = analytics.top_10_concentration {
1072 println!("top_10_concentration,{}", top_10);
1073 }
1074
1075 if !analytics.holders.is_empty() {
1077 println!();
1078 println!("rank,address,balance,percentage");
1079 for holder in &analytics.holders {
1080 println!(
1081 "{},{},{},{}",
1082 holder.rank, holder.address, holder.balance, holder.percentage
1083 );
1084 }
1085 }
1086
1087 Ok(())
1088}
1089
1090fn abbreviate_address(addr: &str) -> String {
1092 if addr.len() > 16 {
1093 format!("{}...{}", &addr[..8], &addr[addr.len() - 6..])
1094 } else {
1095 addr.to_string()
1096 }
1097}
1098
1099#[cfg(test)]
1104mod tests {
1105 use super::*;
1106
1107 #[test]
1108 fn test_period_as_seconds() {
1109 assert_eq!(Period::Hour1.as_seconds(), 3600);
1110 assert_eq!(Period::Hour24.as_seconds(), 86400);
1111 assert_eq!(Period::Day7.as_seconds(), 604800);
1112 assert_eq!(Period::Day30.as_seconds(), 2592000);
1113 }
1114
1115 #[test]
1116 fn test_period_label() {
1117 assert_eq!(Period::Hour1.label(), "1 Hour");
1118 assert_eq!(Period::Hour24.label(), "24 Hours");
1119 assert_eq!(Period::Day7.label(), "7 Days");
1120 assert_eq!(Period::Day30.label(), "30 Days");
1121 }
1122
1123 #[test]
1124 fn test_format_large_number() {
1125 assert_eq!(crate::display::format_large_number(500.0), "500.00");
1126 assert_eq!(crate::display::format_large_number(1500.0), "1.50K");
1127 assert_eq!(crate::display::format_large_number(1500000.0), "1.50M");
1128 assert_eq!(crate::display::format_large_number(1500000000.0), "1.50B");
1129 }
1130
1131 #[test]
1132 fn test_period_default() {
1133 let period = Period::default();
1134 assert!(matches!(period, Period::Hour24));
1135 }
1136
1137 #[test]
1138 fn test_crawl_args_defaults() {
1139 use clap::Parser;
1140
1141 #[derive(Parser)]
1142 struct TestCli {
1143 #[command(flatten)]
1144 crawl: CrawlArgs,
1145 }
1146
1147 let cli = TestCli::try_parse_from(["test", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"])
1148 .unwrap();
1149
1150 assert_eq!(cli.crawl.chain, "ethereum");
1151 assert!(matches!(cli.crawl.period, Period::Hour24));
1152 assert_eq!(cli.crawl.holders_limit, 10);
1153 assert!(!cli.crawl.no_charts);
1154 assert!(cli.crawl.report.is_none());
1155 }
1156
1157 #[test]
1162 fn test_format_large_number_zero() {
1163 assert_eq!(crate::display::format_large_number(0.0), "0.00");
1164 }
1165
1166 #[test]
1167 fn test_format_large_number_small() {
1168 assert_eq!(crate::display::format_large_number(0.12), "0.12");
1169 }
1170
1171 #[test]
1172 fn test_format_large_number_boundary_k() {
1173 assert_eq!(crate::display::format_large_number(999.99), "999.99");
1174 assert_eq!(crate::display::format_large_number(1000.0), "1.00K");
1175 }
1176
1177 #[test]
1178 fn test_format_large_number_boundary_m() {
1179 assert_eq!(crate::display::format_large_number(999_999.0), "1000.00K");
1180 assert_eq!(crate::display::format_large_number(1_000_000.0), "1.00M");
1181 }
1182
1183 #[test]
1184 fn test_format_large_number_boundary_b() {
1185 assert_eq!(
1186 crate::display::format_large_number(999_999_999.0),
1187 "1000.00M"
1188 );
1189 assert_eq!(
1190 crate::display::format_large_number(1_000_000_000.0),
1191 "1.00B"
1192 );
1193 }
1194
1195 #[test]
1196 fn test_format_large_number_very_large() {
1197 let result = crate::display::format_large_number(1_500_000_000_000.0);
1198 assert!(result.contains("B"));
1199 }
1200
1201 #[test]
1206 fn test_period_seconds_all() {
1207 assert_eq!(Period::Hour1.as_seconds(), 3600);
1208 assert_eq!(Period::Hour24.as_seconds(), 86400);
1209 assert_eq!(Period::Day7.as_seconds(), 604800);
1210 assert_eq!(Period::Day30.as_seconds(), 2592000);
1211 }
1212
1213 #[test]
1214 fn test_period_labels_all() {
1215 assert_eq!(Period::Hour1.label(), "1 Hour");
1216 assert_eq!(Period::Hour24.label(), "24 Hours");
1217 assert_eq!(Period::Day7.label(), "7 Days");
1218 assert_eq!(Period::Day30.label(), "30 Days");
1219 }
1220
1221 use crate::chains::{
1226 DexPair, PricePoint, Token, TokenAnalytics, TokenHolder, TokenSearchResult, TokenSocial,
1227 };
1228
1229 fn make_test_analytics(with_dex: bool) -> TokenAnalytics {
1230 TokenAnalytics {
1231 token: Token {
1232 contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1233 symbol: "USDC".to_string(),
1234 name: "USD Coin".to_string(),
1235 decimals: 6,
1236 },
1237 chain: "ethereum".to_string(),
1238 holders: vec![
1239 TokenHolder {
1240 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
1241 balance: "1000000000000".to_string(),
1242 formatted_balance: "1,000,000".to_string(),
1243 percentage: 12.5,
1244 rank: 1,
1245 },
1246 TokenHolder {
1247 address: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(),
1248 balance: "500000000000".to_string(),
1249 formatted_balance: "500,000".to_string(),
1250 percentage: 6.25,
1251 rank: 2,
1252 },
1253 ],
1254 total_holders: 150_000,
1255 volume_24h: if with_dex { 5_000_000.0 } else { 0.0 },
1256 volume_7d: if with_dex { 25_000_000.0 } else { 0.0 },
1257 price_usd: if with_dex { 0.9999 } else { 0.0 },
1258 price_change_24h: if with_dex { -0.01 } else { 0.0 },
1259 price_change_7d: if with_dex { 0.02 } else { 0.0 },
1260 liquidity_usd: if with_dex { 100_000_000.0 } else { 0.0 },
1261 market_cap: if with_dex {
1262 Some(30_000_000_000.0)
1263 } else {
1264 None
1265 },
1266 fdv: if with_dex {
1267 Some(30_000_000_000.0)
1268 } else {
1269 None
1270 },
1271 total_supply: Some("30000000000".to_string()),
1272 circulating_supply: Some("28000000000".to_string()),
1273 price_history: vec![
1274 PricePoint {
1275 timestamp: 1700000000,
1276 price: 0.9998,
1277 },
1278 PricePoint {
1279 timestamp: 1700003600,
1280 price: 0.9999,
1281 },
1282 ],
1283 volume_history: vec![],
1284 holder_history: vec![],
1285 dex_pairs: if with_dex {
1286 vec![DexPair {
1287 dex_name: "Uniswap V3".to_string(),
1288 pair_address: "0xpair".to_string(),
1289 base_token: "USDC".to_string(),
1290 quote_token: "WETH".to_string(),
1291 price_usd: 0.9999,
1292 volume_24h: 5_000_000.0,
1293 liquidity_usd: 50_000_000.0,
1294 price_change_24h: -0.01,
1295 buys_24h: 1000,
1296 sells_24h: 900,
1297 buys_6h: 300,
1298 sells_6h: 250,
1299 buys_1h: 50,
1300 sells_1h: 45,
1301 pair_created_at: Some(1600000000),
1302 url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
1303 }]
1304 } else {
1305 vec![]
1306 },
1307 fetched_at: 1700003600,
1308 top_10_concentration: Some(35.5),
1309 top_50_concentration: Some(55.0),
1310 top_100_concentration: Some(65.0),
1311 price_change_6h: 0.01,
1312 price_change_1h: -0.005,
1313 total_buys_24h: 1000,
1314 total_sells_24h: 900,
1315 total_buys_6h: 300,
1316 total_sells_6h: 250,
1317 total_buys_1h: 50,
1318 total_sells_1h: 45,
1319 token_age_hours: Some(25000.0),
1320 image_url: None,
1321 websites: vec!["https://www.centre.io/usdc".to_string()],
1322 socials: vec![TokenSocial {
1323 platform: "twitter".to_string(),
1324 url: "https://twitter.com/circle".to_string(),
1325 }],
1326 dexscreener_url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
1327 }
1328 }
1329
1330 fn make_test_crawl_args() -> CrawlArgs {
1331 CrawlArgs {
1332 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1333 chain: "ethereum".to_string(),
1334 period: Period::Hour24,
1335 holders_limit: 10,
1336 format: OutputFormat::Table,
1337 no_charts: true,
1338 report: None,
1339 yes: false,
1340 save: false,
1341 }
1342 }
1343
1344 #[test]
1345 fn test_output_table_with_dex_data() {
1346 let analytics = make_test_analytics(true);
1347 let args = make_test_crawl_args();
1348 let result = output_table(&analytics, &args);
1349 assert!(result.is_ok());
1350 }
1351
1352 #[test]
1353 fn test_output_table_explorer_only() {
1354 let analytics = make_test_analytics(false);
1355 let args = make_test_crawl_args();
1356 let result = output_table(&analytics, &args);
1357 assert!(result.is_ok());
1358 }
1359
1360 #[test]
1361 fn test_output_table_no_holders() {
1362 let mut analytics = make_test_analytics(false);
1363 analytics.holders = vec![];
1364 analytics.total_holders = 0;
1365 analytics.top_10_concentration = None;
1366 analytics.top_50_concentration = None;
1367 let args = make_test_crawl_args();
1368 let result = output_table(&analytics, &args);
1369 assert!(result.is_ok());
1370 }
1371
1372 #[test]
1373 fn test_output_csv() {
1374 let analytics = make_test_analytics(true);
1375 let result = output_csv(&analytics);
1376 assert!(result.is_ok());
1377 }
1378
1379 #[test]
1380 fn test_output_csv_no_market_cap() {
1381 let mut analytics = make_test_analytics(true);
1382 analytics.market_cap = None;
1383 analytics.fdv = None;
1384 analytics.top_10_concentration = None;
1385 let result = output_csv(&analytics);
1386 assert!(result.is_ok());
1387 }
1388
1389 #[test]
1390 fn test_output_csv_no_holders() {
1391 let mut analytics = make_test_analytics(true);
1392 analytics.holders = vec![];
1393 let result = output_csv(&analytics);
1394 assert!(result.is_ok());
1395 }
1396
1397 #[test]
1398 fn test_output_table_with_dex_no_charts() {
1399 let analytics = make_test_analytics(true);
1400 let mut args = make_test_crawl_args();
1401 args.no_charts = true;
1402 let result = output_table_with_dex(&analytics, &args);
1403 assert!(result.is_ok());
1404 }
1405
1406 #[test]
1407 fn test_output_table_with_dex_no_market_cap() {
1408 let mut analytics = make_test_analytics(true);
1409 analytics.market_cap = None;
1410 analytics.fdv = None;
1411 analytics.top_10_concentration = None;
1412 let args = make_test_crawl_args();
1413 let result = output_table_with_dex(&analytics, &args);
1414 assert!(result.is_ok());
1415 }
1416
1417 #[test]
1418 fn test_output_table_explorer_with_concentration() {
1419 let mut analytics = make_test_analytics(false);
1420 analytics.top_10_concentration = Some(40.0);
1421 analytics.top_50_concentration = Some(60.0);
1422 let result = output_table_explorer_only(&analytics);
1423 assert!(result.is_ok());
1424 }
1425
1426 #[test]
1427 fn test_output_table_explorer_no_supply() {
1428 let mut analytics = make_test_analytics(false);
1429 analytics.total_supply = None;
1430 let result = output_table_explorer_only(&analytics);
1431 assert!(result.is_ok());
1432 }
1433
1434 #[test]
1435 fn test_output_table_explorer_with_supply_and_holders() {
1436 let mut analytics = make_test_analytics(false);
1437 analytics.total_supply = Some("1000000000".to_string());
1438 analytics.total_holders = 50_000;
1439 let result = output_table_explorer_only(&analytics);
1440 assert!(result.is_ok());
1441 }
1442
1443 #[test]
1444 fn test_output_table_with_dex_multiple_pairs() {
1445 let mut analytics = make_test_analytics(true);
1446 for i in 0..8 {
1447 analytics.dex_pairs.push(DexPair {
1448 dex_name: format!("DEX {}", i),
1449 pair_address: format!("0xpair{}", i),
1450 base_token: "USDC".to_string(),
1451 quote_token: "WETH".to_string(),
1452 price_usd: 0.9999,
1453 volume_24h: 1_000_000.0 - (i as f64 * 100_000.0),
1454 liquidity_usd: 10_000_000.0 - (i as f64 * 1_000_000.0),
1455 price_change_24h: 0.0,
1456 buys_24h: 100,
1457 sells_24h: 90,
1458 buys_6h: 30,
1459 sells_6h: 25,
1460 buys_1h: 5,
1461 sells_1h: 4,
1462 pair_created_at: None,
1463 url: None,
1464 });
1465 }
1466 let args = make_test_crawl_args();
1467 let result = output_table_with_dex(&analytics, &args);
1469 assert!(result.is_ok());
1470 }
1471
1472 #[test]
1477 fn test_crawl_args_with_report() {
1478 use clap::Parser;
1479
1480 #[derive(Parser)]
1481 struct TestCli {
1482 #[command(flatten)]
1483 crawl: CrawlArgs,
1484 }
1485
1486 let cli = TestCli::try_parse_from([
1487 "test",
1488 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1489 "--report",
1490 "output.md",
1491 ])
1492 .unwrap();
1493
1494 assert_eq!(
1495 cli.crawl.report,
1496 Some(std::path::PathBuf::from("output.md"))
1497 );
1498 }
1499
1500 #[test]
1501 fn test_crawl_args_with_chain_and_period() {
1502 use clap::Parser;
1503
1504 #[derive(Parser)]
1505 struct TestCli {
1506 #[command(flatten)]
1507 crawl: CrawlArgs,
1508 }
1509
1510 let cli = TestCli::try_parse_from([
1511 "test",
1512 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1513 "--chain",
1514 "polygon",
1515 "--period",
1516 "7d",
1517 "--no-charts",
1518 "--yes",
1519 "--save",
1520 ])
1521 .unwrap();
1522
1523 assert_eq!(cli.crawl.chain, "polygon");
1524 assert!(matches!(cli.crawl.period, Period::Day7));
1525 assert!(cli.crawl.no_charts);
1526 assert!(cli.crawl.yes);
1527 assert!(cli.crawl.save);
1528 }
1529
1530 #[test]
1531 fn test_crawl_args_all_periods() {
1532 use clap::Parser;
1533
1534 #[derive(Parser)]
1535 struct TestCli {
1536 #[command(flatten)]
1537 crawl: CrawlArgs,
1538 }
1539
1540 for (period_str, expected) in [
1541 ("1h", Period::Hour1),
1542 ("24h", Period::Hour24),
1543 ("7d", Period::Day7),
1544 ("30d", Period::Day30),
1545 ] {
1546 let cli = TestCli::try_parse_from(["test", "token", "--period", period_str]).unwrap();
1547 assert_eq!(cli.crawl.period.as_seconds(), expected.as_seconds());
1548 }
1549 }
1550
1551 #[test]
1556 fn test_analytics_json_serialization() {
1557 let analytics = make_test_analytics(true);
1558 let json = serde_json::to_string(&analytics).unwrap();
1559 assert!(json.contains("USDC"));
1560 assert!(json.contains("USD Coin"));
1561 assert!(json.contains("ethereum"));
1562 assert!(json.contains("0.9999"));
1563 }
1564
1565 #[test]
1566 fn test_analytics_json_no_optional_fields() {
1567 let mut analytics = make_test_analytics(false);
1568 analytics.market_cap = None;
1569 analytics.fdv = None;
1570 analytics.total_supply = None;
1571 analytics.top_10_concentration = None;
1572 analytics.top_50_concentration = None;
1573 analytics.top_100_concentration = None;
1574 analytics.token_age_hours = None;
1575 analytics.dexscreener_url = None;
1576 let json = serde_json::to_string(&analytics).unwrap();
1577 assert!(!json.contains("market_cap"));
1578 assert!(!json.contains("fdv"));
1579 }
1580
1581 use crate::chains::mocks::{MockClientFactory, MockDexSource};
1586
1587 fn mock_factory_for_crawl() -> MockClientFactory {
1588 let mut factory = MockClientFactory::new();
1589 factory.mock_dex = MockDexSource::new();
1591 factory
1592 }
1593
1594 #[tokio::test]
1595 async fn test_run_crawl_json_output() {
1596 let config = Config::default();
1597 let factory = mock_factory_for_crawl();
1598 let args = CrawlArgs {
1599 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1600 chain: "ethereum".to_string(),
1601 period: Period::Hour24,
1602 holders_limit: 5,
1603 format: OutputFormat::Json,
1604 no_charts: true,
1605 report: None,
1606 yes: true,
1607 save: false,
1608 };
1609 let result = super::run(args, &config, &factory).await;
1610 assert!(result.is_ok());
1611 }
1612
1613 #[tokio::test]
1614 async fn test_run_crawl_table_output() {
1615 let config = Config::default();
1616 let factory = mock_factory_for_crawl();
1617 let args = CrawlArgs {
1618 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1619 chain: "ethereum".to_string(),
1620 period: Period::Hour24,
1621 holders_limit: 5,
1622 format: OutputFormat::Table,
1623 no_charts: true,
1624 report: None,
1625 yes: true,
1626 save: false,
1627 };
1628 let result = super::run(args, &config, &factory).await;
1629 assert!(result.is_ok());
1630 }
1631
1632 #[tokio::test]
1633 async fn test_run_crawl_csv_output() {
1634 let config = Config::default();
1635 let factory = mock_factory_for_crawl();
1636 let args = CrawlArgs {
1637 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1638 chain: "ethereum".to_string(),
1639 period: Period::Hour24,
1640 holders_limit: 5,
1641 format: OutputFormat::Csv,
1642 no_charts: true,
1643 report: None,
1644 yes: true,
1645 save: false,
1646 };
1647 let result = super::run(args, &config, &factory).await;
1648 assert!(result.is_ok());
1649 }
1650
1651 #[tokio::test]
1652 async fn test_run_crawl_symbol_resolution_via_factory_dex() {
1653 let mut factory = MockClientFactory::new();
1655 factory.mock_dex.search_results = vec![TokenSearchResult {
1656 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1657 symbol: "MOCK".to_string(),
1658 name: "Mock Token".to_string(),
1659 chain: "ethereum".to_string(),
1660 price_usd: Some(1.0),
1661 volume_24h: 1_000_000.0,
1662 liquidity_usd: 5_000_000.0,
1663 market_cap: Some(100_000_000.0),
1664 }];
1665 if let Some(ref mut td) = factory.mock_dex.token_data {
1667 td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
1668 }
1669
1670 let config = Config::default();
1671 let args = CrawlArgs {
1672 token: "MOCK".to_string(),
1673 chain: "ethereum".to_string(),
1674 period: Period::Hour24,
1675 holders_limit: 5,
1676 format: OutputFormat::Json,
1677 no_charts: true,
1678 report: None,
1679 yes: true,
1680 save: false,
1681 };
1682 let result = super::run(args, &config, &factory).await;
1683 assert!(result.is_ok());
1684 }
1685
1686 #[tokio::test]
1687 async fn test_run_crawl_no_dex_data_evm() {
1688 let config = Config::default();
1689 let mut factory = MockClientFactory::new();
1690 factory.mock_dex.token_data = None; factory.mock_client.token_info = Some(Token {
1692 contract_address: "0xtoken".to_string(),
1693 symbol: "TEST".to_string(),
1694 name: "Test Token".to_string(),
1695 decimals: 18,
1696 });
1697 let args = CrawlArgs {
1698 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1699 chain: "ethereum".to_string(),
1700 period: Period::Hour24,
1701 holders_limit: 5,
1702 format: OutputFormat::Json,
1703 no_charts: true,
1704 report: None,
1705 yes: true,
1706 save: false,
1707 };
1708 let result = super::run(args, &config, &factory).await;
1709 assert!(result.is_ok());
1710 }
1711
1712 #[tokio::test]
1713 async fn test_run_crawl_table_no_charts() {
1714 let config = Config::default();
1715 let factory = mock_factory_for_crawl();
1716 let args = CrawlArgs {
1717 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1718 chain: "ethereum".to_string(),
1719 period: Period::Hour24,
1720 holders_limit: 5,
1721 format: OutputFormat::Table,
1722 no_charts: true,
1723 report: None,
1724 yes: true,
1725 save: false,
1726 };
1727 let result = super::run(args, &config, &factory).await;
1728 assert!(result.is_ok());
1729 }
1730
1731 #[tokio::test]
1732 async fn test_run_crawl_with_charts() {
1733 let config = Config::default();
1734 let factory = mock_factory_for_crawl();
1735 let args = CrawlArgs {
1736 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1737 chain: "ethereum".to_string(),
1738 period: Period::Hour1,
1739 holders_limit: 5,
1740 format: OutputFormat::Table,
1741 no_charts: false, report: None,
1743 yes: true,
1744 save: false,
1745 };
1746 let result = super::run(args, &config, &factory).await;
1747 assert!(result.is_ok());
1748 }
1749
1750 #[tokio::test]
1751 async fn test_run_crawl_day7_period() {
1752 let config = Config::default();
1753 let factory = mock_factory_for_crawl();
1754 let args = CrawlArgs {
1755 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1756 chain: "ethereum".to_string(),
1757 period: Period::Day7,
1758 holders_limit: 5,
1759 format: OutputFormat::Table,
1760 no_charts: true,
1761 report: None,
1762 yes: true,
1763 save: false,
1764 };
1765 let result = super::run(args, &config, &factory).await;
1766 assert!(result.is_ok());
1767 }
1768
1769 #[tokio::test]
1770 async fn test_run_crawl_markdown_output() {
1771 let config = Config::default();
1772 let factory = mock_factory_for_crawl();
1773 let args = CrawlArgs {
1774 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1775 chain: "ethereum".to_string(),
1776 period: Period::Hour24,
1777 holders_limit: 5,
1778 format: OutputFormat::Markdown,
1779 no_charts: true,
1780 report: None,
1781 yes: true,
1782 save: false,
1783 };
1784 let result = super::run(args, &config, &factory).await;
1785 assert!(result.is_ok());
1786 }
1787
1788 #[tokio::test]
1789 async fn test_run_crawl_unsupported_chain_no_dex() {
1790 let mut factory = MockClientFactory::new();
1792 factory.mock_dex.token_data = None;
1793 factory.mock_dex.search_results = vec![];
1794
1795 let config = Config::default();
1796 let args = CrawlArgs {
1797 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1798 chain: "avalanche".to_string(), period: Period::Hour24,
1800 holders_limit: 5,
1801 format: OutputFormat::Json,
1802 no_charts: true,
1803 report: None,
1804 yes: true,
1805 save: false,
1806 };
1807 let result = super::run(args, &config, &factory).await;
1808 assert!(result.is_err());
1809 let err_str = result.unwrap_err().to_string();
1810 assert!(
1811 err_str.contains("avalanche")
1812 || err_str.contains("block explorer")
1813 || err_str.contains("No DEX"),
1814 "Expected error about unsupported chain, got: {}",
1815 err_str
1816 );
1817 }
1818
1819 #[tokio::test]
1820 async fn test_run_crawl_day30_period() {
1821 let config = Config::default();
1822 let factory = mock_factory_for_crawl();
1823 let args = CrawlArgs {
1824 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1825 chain: "ethereum".to_string(),
1826 period: Period::Day30,
1827 holders_limit: 5,
1828 format: OutputFormat::Table,
1829 no_charts: true,
1830 report: None,
1831 yes: true,
1832 save: false,
1833 };
1834 let result = super::run(args, &config, &factory).await;
1835 assert!(result.is_ok());
1836 }
1837
1838 #[test]
1839 fn test_output_table_with_dex_with_charts() {
1840 let analytics = make_test_analytics(true);
1841 let mut args = make_test_crawl_args();
1842 args.no_charts = false; let result = output_table_with_dex(&analytics, &args);
1844 assert!(result.is_ok());
1845 }
1846
1847 #[test]
1848 fn test_output_table_explorer_short_addresses() {
1849 let mut analytics = make_test_analytics(false);
1850 analytics.holders = vec![TokenHolder {
1851 address: "0xshort".to_string(), balance: "100".to_string(),
1853 formatted_balance: "100".to_string(),
1854 percentage: 1.0,
1855 rank: 1,
1856 }];
1857 let result = output_table_explorer_only(&analytics);
1858 assert!(result.is_ok());
1859 }
1860
1861 #[test]
1862 fn test_output_csv_with_all_fields() {
1863 let analytics = make_test_analytics(true);
1864 let result = output_csv(&analytics);
1865 assert!(result.is_ok());
1866 }
1867
1868 #[tokio::test]
1869 async fn test_fetch_analytics_for_input() {
1870 let factory = mock_factory_for_crawl();
1871 let result = super::fetch_analytics_for_input(
1872 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1873 "ethereum",
1874 Period::Hour24,
1875 5,
1876 &factory,
1877 None,
1878 )
1879 .await;
1880 assert!(result.is_ok());
1881 let analytics = result.unwrap();
1882 assert_eq!(analytics.chain, "ethereum");
1883 assert!(!analytics.token.contract_address.is_empty());
1884 }
1885
1886 #[tokio::test]
1887 async fn test_run_crawl_chain_without_holder_support() {
1888 let mut factory = mock_factory_for_crawl();
1890 if let Some(ref mut td) = factory.mock_dex.token_data {
1891 td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
1892 }
1893 let config = Config::default();
1894 let args = CrawlArgs {
1895 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1896 chain: "fantom".to_string(),
1897 period: Period::Hour24,
1898 holders_limit: 5,
1899 format: OutputFormat::Json,
1900 no_charts: true,
1901 report: None,
1902 yes: true,
1903 save: false,
1904 };
1905 let result = super::run(args, &config, &factory).await;
1906 assert!(result.is_ok());
1907 }
1908
1909 #[tokio::test]
1910 async fn test_run_crawl_with_report() {
1911 let config = Config::default();
1912 let factory = mock_factory_for_crawl();
1913 let tmp = tempfile::NamedTempFile::new().unwrap();
1914 let args = CrawlArgs {
1915 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1916 chain: "ethereum".to_string(),
1917 period: Period::Hour24,
1918 holders_limit: 5,
1919 format: OutputFormat::Table,
1920 no_charts: true,
1921 report: Some(tmp.path().to_path_buf()),
1922 yes: true,
1923 save: false,
1924 };
1925 let result = super::run(args, &config, &factory).await;
1926 assert!(result.is_ok());
1927 let content = std::fs::read_to_string(tmp.path()).unwrap();
1929 assert!(content.contains("Token Analysis Report"));
1930 }
1931
1932 #[test]
1937 fn test_output_table_explorer_long_address_truncation() {
1938 let mut analytics = make_test_analytics(false);
1939 analytics.holders = vec![TokenHolder {
1940 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
1941 balance: "1000000".to_string(),
1942 formatted_balance: "1,000,000".to_string(),
1943 percentage: 50.0,
1944 rank: 1,
1945 }];
1946 let result = output_table_explorer_only(&analytics);
1947 assert!(result.is_ok());
1948 }
1949
1950 #[test]
1951 fn test_output_table_with_dex_empty_pairs() {
1952 let mut analytics = make_test_analytics(true);
1953 analytics.dex_pairs = vec![];
1954 let args = make_test_crawl_args();
1955 let result = output_table_with_dex(&analytics, &args);
1956 assert!(result.is_ok());
1957 }
1958
1959 #[test]
1960 fn test_output_table_explorer_no_concentration() {
1961 let mut analytics = make_test_analytics(false);
1962 analytics.top_10_concentration = None;
1963 analytics.top_50_concentration = None;
1964 analytics.top_100_concentration = None;
1965 let result = output_table_explorer_only(&analytics);
1966 assert!(result.is_ok());
1967 }
1968
1969 #[test]
1970 fn test_output_table_with_dex_top_10_only() {
1971 let mut analytics = make_test_analytics(true);
1972 analytics.top_10_concentration = Some(25.0);
1973 analytics.top_50_concentration = None;
1974 analytics.top_100_concentration = None;
1975 let args = make_test_crawl_args();
1976 let result = output_table_with_dex(&analytics, &args);
1977 assert!(result.is_ok());
1978 }
1979
1980 #[test]
1981 fn test_output_table_with_dex_top_100_concentration() {
1982 let mut analytics = make_test_analytics(true);
1983 analytics.top_10_concentration = Some(20.0);
1984 analytics.top_50_concentration = Some(45.0);
1985 analytics.top_100_concentration = Some(65.0);
1986 let args = make_test_crawl_args();
1987 let result = output_table_with_dex(&analytics, &args);
1988 assert!(result.is_ok());
1989 }
1990
1991 #[test]
1992 fn test_output_csv_with_market_cap_and_fdv() {
1993 let mut analytics = make_test_analytics(true);
1994 analytics.market_cap = Some(1_000_000_000.0);
1995 analytics.fdv = Some(1_500_000_000.0);
1996 let result = output_csv(&analytics);
1997 assert!(result.is_ok());
1998 }
1999
2000 #[test]
2001 fn test_output_table_routing_has_dex_data() {
2002 let analytics = make_test_analytics(true);
2003 assert!(analytics.price_usd > 0.0);
2004 let args = make_test_crawl_args();
2005 let result = output_table(&analytics, &args);
2006 assert!(result.is_ok());
2007 }
2008
2009 #[test]
2010 fn test_output_table_routing_no_dex_data() {
2011 let analytics = make_test_analytics(false);
2012 assert_eq!(analytics.price_usd, 0.0);
2013 let args = make_test_crawl_args();
2014 let result = output_table(&analytics, &args);
2015 assert!(result.is_ok());
2016 }
2017
2018 #[test]
2019 fn test_format_large_number_negative() {
2020 let result = crate::display::format_large_number(-1_000_000.0);
2021 assert!(result.contains("M") || result.contains("-"));
2022 }
2023
2024 #[test]
2025 fn test_select_token_auto_select() {
2026 let results = vec![TokenSearchResult {
2027 address: "0xtoken".to_string(),
2028 symbol: "TKN".to_string(),
2029 name: "Test Token".to_string(),
2030 chain: "ethereum".to_string(),
2031 price_usd: Some(10.0),
2032 volume_24h: 100000.0,
2033 liquidity_usd: 500000.0,
2034 market_cap: Some(1000000.0),
2035 }];
2036 let selected = select_token(&results, true).unwrap();
2037 assert_eq!(selected.symbol, "TKN");
2038 }
2039
2040 #[test]
2041 fn test_select_token_single_result() {
2042 let results = vec![TokenSearchResult {
2043 address: "0xtoken".to_string(),
2044 symbol: "SINGLE".to_string(),
2045 name: "Single Token".to_string(),
2046 chain: "ethereum".to_string(),
2047 price_usd: None,
2048 volume_24h: 0.0,
2049 liquidity_usd: 0.0,
2050 market_cap: None,
2051 }];
2052 let selected = select_token(&results, false).unwrap();
2054 assert_eq!(selected.symbol, "SINGLE");
2055 }
2056
2057 #[test]
2058 fn test_output_table_with_dex_with_holders() {
2059 let mut analytics = make_test_analytics(true);
2060 analytics.holders = vec![
2061 TokenHolder {
2062 address: "0xholder1".to_string(),
2063 balance: "1000000".to_string(),
2064 formatted_balance: "1,000,000".to_string(),
2065 percentage: 30.0,
2066 rank: 1,
2067 },
2068 TokenHolder {
2069 address: "0xholder2".to_string(),
2070 balance: "500000".to_string(),
2071 formatted_balance: "500,000".to_string(),
2072 percentage: 15.0,
2073 rank: 2,
2074 },
2075 ];
2076 let args = make_test_crawl_args();
2077 let result = output_table_with_dex(&analytics, &args);
2078 assert!(result.is_ok());
2079 }
2080
2081 #[test]
2082 fn test_output_json() {
2083 let analytics = make_test_analytics(true);
2084 let result = serde_json::to_string_pretty(&analytics);
2085 assert!(result.is_ok());
2086 }
2087
2088 fn make_search_results() -> Vec<TokenSearchResult> {
2093 vec![
2094 TokenSearchResult {
2095 symbol: "USDC".to_string(),
2096 name: "USD Coin".to_string(),
2097 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
2098 chain: "ethereum".to_string(),
2099 price_usd: Some(1.0),
2100 volume_24h: 1_000_000.0,
2101 liquidity_usd: 500_000_000.0,
2102 market_cap: Some(30_000_000_000.0),
2103 },
2104 TokenSearchResult {
2105 symbol: "USDC".to_string(),
2106 name: "USD Coin on Polygon".to_string(),
2107 address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
2108 chain: "polygon".to_string(),
2109 price_usd: Some(0.9999),
2110 volume_24h: 500_000.0,
2111 liquidity_usd: 100_000_000.0,
2112 market_cap: None,
2113 },
2114 TokenSearchResult {
2115 symbol: "USDC".to_string(),
2116 name: "Very Long Token Name That Should Be Truncated To Fit".to_string(),
2117 address: "0x1234567890abcdef".to_string(),
2118 chain: "arbitrum".to_string(),
2119 price_usd: None,
2120 volume_24h: 0.0,
2121 liquidity_usd: 50_000.0,
2122 market_cap: None,
2123 },
2124 ]
2125 }
2126
2127 #[test]
2128 fn test_select_token_impl_auto_select_multi() {
2129 let results = make_search_results();
2130 let mut writer = Vec::new();
2131 let mut reader = std::io::Cursor::new(b"" as &[u8]);
2132
2133 let selected = select_token_impl(&results, true, &mut reader, &mut writer).unwrap();
2134 assert_eq!(selected.symbol, "USDC");
2135 assert_eq!(selected.chain, "ethereum");
2136 let output = String::from_utf8(writer).unwrap();
2137 assert!(output.contains("Selected:"));
2138 }
2139
2140 #[test]
2141 fn test_select_token_impl_single_result() {
2142 let results = vec![make_search_results().remove(0)];
2143 let mut writer = Vec::new();
2144 let mut reader = std::io::Cursor::new(b"" as &[u8]);
2145
2146 let selected = select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
2147 assert_eq!(selected.symbol, "USDC");
2148 }
2149
2150 #[test]
2151 fn test_select_token_user_selects_second() {
2152 let results = make_search_results();
2153 let input = b"2\n";
2154 let mut reader = std::io::Cursor::new(&input[..]);
2155 let mut writer = Vec::new();
2156
2157 let selected = select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
2158 assert_eq!(selected.chain, "polygon");
2159 let output = String::from_utf8(writer).unwrap();
2160 assert!(output.contains("Found 3 matching tokens"));
2161 assert!(output.contains("USDC"));
2162 }
2163
2164 #[test]
2165 fn test_select_token_shows_address_column() {
2166 let results = make_search_results();
2167 let input = b"1\n";
2168 let mut reader = std::io::Cursor::new(&input[..]);
2169 let mut writer = Vec::new();
2170
2171 select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
2172 let output = String::from_utf8(writer).unwrap();
2173
2174 assert!(output.contains("Address"));
2176 assert!(output.contains("0xA0b869...06eB48"));
2178 assert!(output.contains("0x2791Bc...a84174"));
2179 }
2180
2181 #[test]
2182 fn test_abbreviate_address() {
2183 assert_eq!(
2184 abbreviate_address("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
2185 "0xA0b869...06eB48"
2186 );
2187 assert_eq!(abbreviate_address("0x1234abcd"), "0x1234abcd");
2189 }
2190
2191 #[test]
2192 fn test_abbreviate_address_boundary_16_chars() {
2193 let addr = "0x1234567890abcd";
2195 assert_eq!(abbreviate_address(addr), addr);
2196 }
2197
2198 #[test]
2199 fn test_abbreviate_address_boundary_17_chars() {
2200 let addr = "0x1234567890abcdef1";
2202 let result = abbreviate_address(addr);
2203 assert!(result.contains("..."));
2204 assert_eq!(&result[..8], "0x123456");
2205 assert_eq!(&result[result.len() - 6..], "bcdef1");
2207 }
2208
2209 #[test]
2210 fn test_abbreviate_address_empty() {
2211 assert_eq!(abbreviate_address(""), "");
2212 }
2213
2214 #[test]
2215 fn test_select_token_user_selects_third() {
2216 let results = make_search_results();
2217 let input = b"3\n";
2218 let mut reader = std::io::Cursor::new(&input[..]);
2219 let mut writer = Vec::new();
2220
2221 let selected = select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
2222 assert_eq!(selected.chain, "arbitrum");
2223 let output = String::from_utf8(writer).unwrap();
2225 assert!(output.contains("..."));
2226 }
2227
2228 #[test]
2229 fn test_select_token_invalid_input() {
2230 let results = make_search_results();
2231 let input = b"abc\n";
2232 let mut reader = std::io::Cursor::new(&input[..]);
2233 let mut writer = Vec::new();
2234
2235 let result = select_token_impl(&results, false, &mut reader, &mut writer);
2236 assert!(result.is_err());
2237 assert!(
2238 result
2239 .unwrap_err()
2240 .to_string()
2241 .contains("Invalid selection")
2242 );
2243 }
2244
2245 #[test]
2246 fn test_select_token_out_of_range_zero() {
2247 let results = make_search_results();
2248 let input = b"0\n";
2249 let mut reader = std::io::Cursor::new(&input[..]);
2250 let mut writer = Vec::new();
2251
2252 let result = select_token_impl(&results, false, &mut reader, &mut writer);
2253 assert!(result.is_err());
2254 assert!(
2255 result
2256 .unwrap_err()
2257 .to_string()
2258 .contains("Selection must be between")
2259 );
2260 }
2261
2262 #[test]
2263 fn test_select_token_out_of_range_high() {
2264 let results = make_search_results();
2265 let input = b"99\n";
2266 let mut reader = std::io::Cursor::new(&input[..]);
2267 let mut writer = Vec::new();
2268
2269 let result = select_token_impl(&results, false, &mut reader, &mut writer);
2270 assert!(result.is_err());
2271 }
2272
2273 #[test]
2278 fn test_prompt_save_alias_yes() {
2279 let input = b"y\n";
2280 let mut reader = std::io::Cursor::new(&input[..]);
2281 let mut writer = Vec::new();
2282
2283 assert!(prompt_save_alias_impl(&mut reader, &mut writer));
2284 let output = String::from_utf8(writer).unwrap();
2285 assert!(output.contains("Save this token"));
2286 }
2287
2288 #[test]
2289 fn test_prompt_save_alias_yes_full() {
2290 let input = b"yes\n";
2291 let mut reader = std::io::Cursor::new(&input[..]);
2292 let mut writer = Vec::new();
2293
2294 assert!(prompt_save_alias_impl(&mut reader, &mut writer));
2295 }
2296
2297 #[test]
2298 fn test_prompt_save_alias_no() {
2299 let input = b"n\n";
2300 let mut reader = std::io::Cursor::new(&input[..]);
2301 let mut writer = Vec::new();
2302
2303 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2304 }
2305
2306 #[test]
2307 fn test_prompt_save_alias_empty() {
2308 let input = b"\n";
2309 let mut reader = std::io::Cursor::new(&input[..]);
2310 let mut writer = Vec::new();
2311
2312 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2313 }
2314
2315 #[test]
2316 fn test_prompt_save_alias_uppercase_y() {
2317 let input = b"Y\n";
2318 let mut reader = std::io::Cursor::new(&input[..]);
2319 let mut writer = Vec::new();
2320
2321 assert!(prompt_save_alias_impl(&mut reader, &mut writer));
2322 }
2323
2324 #[test]
2325 fn test_prompt_save_alias_uppercase_yes() {
2326 let input = b"YES\n";
2327 let mut reader = std::io::Cursor::new(&input[..]);
2328 let mut writer = Vec::new();
2329
2330 assert!(prompt_save_alias_impl(&mut reader, &mut writer));
2331 }
2332
2333 #[test]
2334 fn test_prompt_save_alias_other_input() {
2335 let input = b"maybe\n";
2336 let mut reader = std::io::Cursor::new(&input[..]);
2337 let mut writer = Vec::new();
2338
2339 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2340 }
2341
2342 struct FailingWriter;
2344 impl std::io::Write for FailingWriter {
2345 fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
2346 Err(std::io::Error::other("write failed"))
2347 }
2348 fn flush(&mut self) -> std::io::Result<()> {
2349 Err(std::io::Error::other("flush failed"))
2350 }
2351 }
2352
2353 struct FailingReader;
2355 impl std::io::Read for FailingReader {
2356 fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
2357 Err(std::io::Error::other("read failed"))
2358 }
2359 }
2360 impl std::io::BufRead for FailingReader {
2361 fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
2362 Err(std::io::Error::other("read failed"))
2363 }
2364 fn consume(&mut self, _amt: usize) {}
2365 }
2366
2367 #[test]
2368 fn test_prompt_save_alias_impl_write_fails() {
2369 let input = b"y\n";
2370 let mut reader = std::io::Cursor::new(&input[..]);
2371 let mut writer = FailingWriter;
2372
2373 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2374 }
2375
2376 #[test]
2377 fn test_prompt_save_alias_impl_flush_fails() {
2378 struct WriteOkFlushFail;
2380 impl std::io::Write for WriteOkFlushFail {
2381 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2382 Ok(buf.len())
2383 }
2384 fn flush(&mut self) -> std::io::Result<()> {
2385 Err(std::io::Error::other("flush failed"))
2386 }
2387 }
2388 let input = b"y\n";
2389 let mut reader = std::io::Cursor::new(&input[..]);
2390 let mut writer = WriteOkFlushFail;
2391
2392 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2393 }
2394
2395 #[test]
2396 fn test_prompt_save_alias_impl_read_fails() {
2397 let mut reader = FailingReader;
2398 let mut writer = Vec::new();
2399
2400 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2401 }
2402
2403 #[test]
2408 fn test_output_csv_no_panic() {
2409 let analytics = create_test_analytics_minimal();
2410 let result = output_csv(&analytics);
2411 assert!(result.is_ok());
2412 }
2413
2414 #[test]
2415 fn test_output_table_no_dex_data() {
2416 let analytics = create_test_analytics_minimal();
2418 let args = CrawlArgs {
2419 token: "0xtest".to_string(),
2420 chain: "ethereum".to_string(),
2421 period: Period::Hour24,
2422 holders_limit: 10,
2423 format: OutputFormat::Table,
2424 no_charts: true,
2425 report: None,
2426 yes: false,
2427 save: false,
2428 };
2429 let result = output_table(&analytics, &args);
2430 assert!(result.is_ok());
2431 }
2432
2433 #[test]
2434 fn test_output_table_with_dex_data_no_charts() {
2435 let mut analytics = create_test_analytics_minimal();
2436 analytics.price_usd = 1.0;
2437 analytics.volume_24h = 1_000_000.0;
2438 analytics.liquidity_usd = 500_000.0;
2439 analytics.market_cap = Some(1_000_000_000.0);
2440 analytics.fdv = Some(2_000_000_000.0);
2441
2442 let args = CrawlArgs {
2443 token: "0xtest".to_string(),
2444 chain: "ethereum".to_string(),
2445 period: Period::Hour24,
2446 holders_limit: 10,
2447 format: OutputFormat::Table,
2448 no_charts: true,
2449 report: None,
2450 yes: false,
2451 save: false,
2452 };
2453 let result = output_table(&analytics, &args);
2454 assert!(result.is_ok());
2455 }
2456
2457 #[test]
2458 fn test_output_table_with_dex_data_and_charts() {
2459 let mut analytics = create_test_analytics_minimal();
2460 analytics.price_usd = 1.0;
2461 analytics.volume_24h = 1_000_000.0;
2462 analytics.liquidity_usd = 500_000.0;
2463 analytics.price_history = vec![
2464 crate::chains::PricePoint {
2465 timestamp: 1,
2466 price: 0.99,
2467 },
2468 crate::chains::PricePoint {
2469 timestamp: 2,
2470 price: 1.01,
2471 },
2472 ];
2473 analytics.volume_history = vec![
2474 crate::chains::VolumePoint {
2475 timestamp: 1,
2476 volume: 50000.0,
2477 },
2478 crate::chains::VolumePoint {
2479 timestamp: 2,
2480 volume: 60000.0,
2481 },
2482 ];
2483
2484 let args = CrawlArgs {
2485 token: "0xtest".to_string(),
2486 chain: "ethereum".to_string(),
2487 period: Period::Hour24,
2488 holders_limit: 10,
2489 format: OutputFormat::Table,
2490 no_charts: false,
2491 report: None,
2492 yes: false,
2493 save: false,
2494 };
2495 let result = output_table(&analytics, &args);
2496 assert!(result.is_ok());
2497 }
2498
2499 fn create_test_analytics_minimal() -> TokenAnalytics {
2500 TokenAnalytics {
2501 token: Token {
2502 contract_address: "0xtest".to_string(),
2503 symbol: "TEST".to_string(),
2504 name: "Test Token".to_string(),
2505 decimals: 18,
2506 },
2507 chain: "ethereum".to_string(),
2508 holders: Vec::new(),
2509 total_holders: 0,
2510 volume_24h: 0.0,
2511 volume_7d: 0.0,
2512 price_usd: 0.0,
2513 price_change_24h: 0.0,
2514 price_change_7d: 0.0,
2515 liquidity_usd: 0.0,
2516 market_cap: None,
2517 fdv: None,
2518 total_supply: None,
2519 circulating_supply: None,
2520 price_history: Vec::new(),
2521 volume_history: Vec::new(),
2522 holder_history: Vec::new(),
2523 dex_pairs: Vec::new(),
2524 fetched_at: 0,
2525 top_10_concentration: None,
2526 top_50_concentration: None,
2527 top_100_concentration: None,
2528 price_change_6h: 0.0,
2529 price_change_1h: 0.0,
2530 total_buys_24h: 0,
2531 total_sells_24h: 0,
2532 total_buys_6h: 0,
2533 total_sells_6h: 0,
2534 total_buys_1h: 0,
2535 total_sells_1h: 0,
2536 token_age_hours: None,
2537 image_url: None,
2538 websites: Vec::new(),
2539 socials: Vec::new(),
2540 dexscreener_url: None,
2541 }
2542 }
2543}