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 use crate::display::terminal as t;
890
891 if !args.no_charts {
893 let dashboard = charts::render_analytics_dashboard(
894 &analytics.price_history,
895 &analytics.volume_history,
896 &analytics.holders,
897 &analytics.token.symbol,
898 &analytics.chain,
899 );
900 println!("{}", dashboard);
901 } else {
902 println!(
904 "{}",
905 t::section_header(&format!(
906 "{} ({})",
907 analytics.token.name, analytics.token.symbol
908 ))
909 );
910 println!("{}", t::kv_row("Chain", &analytics.chain));
911 println!(
912 "{}",
913 t::kv_row("Contract", &analytics.token.contract_address)
914 );
915 println!("{}", t::blank_row());
916 }
917
918 println!("{}", t::subsection_header("Key Metrics"));
920 println!(
921 "{}",
922 t::kv_row("Price", &format!("${:.6}", analytics.price_usd))
923 );
924 println!(
925 "{}",
926 t::kv_row_delta(
927 "24h Change",
928 analytics.price_change_24h,
929 &format!("{:+.2}%", analytics.price_change_24h)
930 )
931 );
932 println!(
933 "{}",
934 t::kv_row(
935 "24h Volume",
936 &format!(
937 "${}",
938 crate::display::format_large_number(analytics.volume_24h)
939 )
940 )
941 );
942 println!(
943 "{}",
944 t::kv_row(
945 "Liquidity",
946 &format!(
947 "${}",
948 crate::display::format_large_number(analytics.liquidity_usd)
949 )
950 )
951 );
952
953 if let Some(mc) = analytics.market_cap {
954 println!(
955 "{}",
956 t::kv_row(
957 "Market Cap",
958 &format!("${}", crate::display::format_large_number(mc))
959 )
960 );
961 }
962
963 if let Some(fdv) = analytics.fdv {
964 println!(
965 "{}",
966 t::kv_row(
967 "FDV",
968 &format!("${}", crate::display::format_large_number(fdv))
969 )
970 );
971 }
972
973 if !analytics.dex_pairs.is_empty() {
975 println!("{}", t::blank_row());
976 println!("{}", t::subsection_header("Top Trading Pairs"));
977
978 for (i, pair) in analytics.dex_pairs.iter().take(5).enumerate() {
979 let pair_str = format!(
980 "{} {}/{} - ${} (${} liq)",
981 pair.dex_name,
982 pair.base_token,
983 pair.quote_token,
984 crate::display::format_large_number(pair.volume_24h),
985 crate::display::format_large_number(pair.liquidity_usd)
986 );
987 println!("{}", t::numbered_row(i + 1, &pair_str));
988 }
989 }
990
991 if let Some(top_10) = analytics.top_10_concentration {
993 println!("{}", t::blank_row());
994 println!("{}", t::subsection_header("Holder Concentration"));
995 println!(
996 "{}",
997 t::kv_row("Top 10 holders", &format!("{:.1}% of supply", top_10))
998 );
999
1000 if let Some(top_50) = analytics.top_50_concentration {
1001 println!(
1002 "{}",
1003 t::kv_row("Top 50 holders", &format!("{:.1}% of supply", top_50))
1004 );
1005 }
1006 }
1007
1008 if args.no_charts {
1009 println!("{}", t::section_footer());
1010 }
1011
1012 Ok(())
1013}
1014
1015fn output_table_explorer_only(analytics: &TokenAnalytics) -> Result<()> {
1017 use crate::display::terminal as t;
1018
1019 println!("{}", t::section_header("Token Info (Block Explorer)"));
1020
1021 println!("{}", t::kv_row("Name", &analytics.token.name));
1023 println!("{}", t::kv_row("Symbol", &analytics.token.symbol));
1024 println!(
1025 "{}",
1026 t::kv_row("Contract", &analytics.token.contract_address)
1027 );
1028 println!("{}", t::kv_row("Chain", &analytics.chain));
1029 println!(
1030 "{}",
1031 t::kv_row("Decimals", &analytics.token.decimals.to_string())
1032 );
1033
1034 if analytics.total_holders > 0 {
1035 println!(
1036 "{}",
1037 t::kv_row("Total Holders", &analytics.total_holders.to_string())
1038 );
1039 }
1040
1041 if let Some(supply) = &analytics.total_supply {
1042 println!("{}", t::kv_row("Total Supply", supply));
1043 }
1044
1045 println!("{}", t::blank_row());
1047 println!(
1048 "{}",
1049 t::info_row(
1050 "No DEX trading data available for this token. Price, volume, and liquidity data require active DEX pairs."
1051 )
1052 );
1053
1054 if !analytics.holders.is_empty() {
1056 println!("{}", t::blank_row());
1057 println!("{}", t::subsection_header("Top Holders"));
1058
1059 let cols = [
1060 t::Col {
1061 label: "Rank",
1062 width: 4,
1063 align: '>',
1064 },
1065 t::Col {
1066 label: "Percent",
1067 width: 10,
1068 align: '>',
1069 },
1070 t::Col {
1071 label: "Balance",
1072 width: 20,
1073 align: '>',
1074 },
1075 t::Col {
1076 label: "Address",
1077 width: 42,
1078 align: '<',
1079 },
1080 ];
1081 println!("{}", t::table_header(&cols));
1082
1083 for holder in analytics.holders.iter().take(10) {
1084 let addr_display = if holder.address.len() > 20 {
1086 format!(
1087 "{}...{}",
1088 &holder.address[..10],
1089 &holder.address[holder.address.len() - 8..]
1090 )
1091 } else {
1092 holder.address.clone()
1093 };
1094
1095 let rank_str = holder.rank.to_string();
1096 let percent_str = format!("{:.2}%", holder.percentage);
1097 let values = [
1098 rank_str.as_str(),
1099 percent_str.as_str(),
1100 holder.formatted_balance.as_str(),
1101 addr_display.as_str(),
1102 ];
1103 println!("{}", t::table_row(&cols, &values));
1104 }
1105 }
1106
1107 if let Some(top_10) = analytics.top_10_concentration {
1109 println!("{}", t::blank_row());
1110 println!("{}", t::subsection_header("Holder Concentration"));
1111 println!(
1112 "{}",
1113 t::kv_row("Top 10 holders", &format!("{:.1}% of supply", top_10))
1114 );
1115
1116 if let Some(top_50) = analytics.top_50_concentration {
1117 println!(
1118 "{}",
1119 t::kv_row("Top 50 holders", &format!("{:.1}% of supply", top_50))
1120 );
1121 }
1122 }
1123
1124 println!("{}", t::section_footer());
1125
1126 Ok(())
1127}
1128
1129fn output_csv(analytics: &TokenAnalytics) -> Result<()> {
1131 println!("metric,value");
1133
1134 println!("symbol,{}", analytics.token.symbol);
1136 println!("name,{}", analytics.token.name);
1137 println!("chain,{}", analytics.chain);
1138 println!("contract,{}", analytics.token.contract_address);
1139
1140 println!("price_usd,{}", analytics.price_usd);
1142 println!("price_change_24h,{}", analytics.price_change_24h);
1143 println!("volume_24h,{}", analytics.volume_24h);
1144 println!("volume_7d,{}", analytics.volume_7d);
1145 println!("liquidity_usd,{}", analytics.liquidity_usd);
1146
1147 if let Some(mc) = analytics.market_cap {
1148 println!("market_cap,{}", mc);
1149 }
1150
1151 if let Some(fdv) = analytics.fdv {
1152 println!("fdv,{}", fdv);
1153 }
1154
1155 println!("total_holders,{}", analytics.total_holders);
1156
1157 if let Some(top_10) = analytics.top_10_concentration {
1158 println!("top_10_concentration,{}", top_10);
1159 }
1160
1161 if !analytics.holders.is_empty() {
1163 println!();
1164 println!("rank,address,balance,percentage");
1165 for holder in &analytics.holders {
1166 println!(
1167 "{},{},{},{}",
1168 holder.rank, holder.address, holder.balance, holder.percentage
1169 );
1170 }
1171 }
1172
1173 Ok(())
1174}
1175
1176fn abbreviate_address(addr: &str) -> String {
1178 if addr.len() > 16 {
1179 format!("{}...{}", &addr[..8], &addr[addr.len() - 6..])
1180 } else {
1181 addr.to_string()
1182 }
1183}
1184
1185#[cfg(test)]
1190mod tests {
1191 use super::*;
1192
1193 #[test]
1194 fn test_period_as_seconds() {
1195 assert_eq!(Period::Hour1.as_seconds(), 3600);
1196 assert_eq!(Period::Hour24.as_seconds(), 86400);
1197 assert_eq!(Period::Day7.as_seconds(), 604800);
1198 assert_eq!(Period::Day30.as_seconds(), 2592000);
1199 }
1200
1201 #[test]
1202 fn test_period_label() {
1203 assert_eq!(Period::Hour1.label(), "1 Hour");
1204 assert_eq!(Period::Hour24.label(), "24 Hours");
1205 assert_eq!(Period::Day7.label(), "7 Days");
1206 assert_eq!(Period::Day30.label(), "30 Days");
1207 }
1208
1209 #[test]
1210 fn test_format_large_number() {
1211 assert_eq!(crate::display::format_large_number(500.0), "500.00");
1212 assert_eq!(crate::display::format_large_number(1500.0), "1.50K");
1213 assert_eq!(crate::display::format_large_number(1500000.0), "1.50M");
1214 assert_eq!(crate::display::format_large_number(1500000000.0), "1.50B");
1215 }
1216
1217 #[test]
1218 fn test_period_default() {
1219 let period = Period::default();
1220 assert!(matches!(period, Period::Hour24));
1221 }
1222
1223 #[test]
1224 fn test_crawl_args_defaults() {
1225 use clap::Parser;
1226
1227 #[derive(Parser)]
1228 struct TestCli {
1229 #[command(flatten)]
1230 crawl: CrawlArgs,
1231 }
1232
1233 let cli = TestCli::try_parse_from(["test", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"])
1234 .unwrap();
1235
1236 assert_eq!(cli.crawl.chain, "ethereum");
1237 assert!(matches!(cli.crawl.period, Period::Hour24));
1238 assert_eq!(cli.crawl.holders_limit, 10);
1239 assert!(!cli.crawl.no_charts);
1240 assert!(cli.crawl.report.is_none());
1241 }
1242
1243 #[test]
1248 fn test_format_large_number_zero() {
1249 assert_eq!(crate::display::format_large_number(0.0), "0.00");
1250 }
1251
1252 #[test]
1253 fn test_format_large_number_small() {
1254 assert_eq!(crate::display::format_large_number(0.12), "0.12");
1255 }
1256
1257 #[test]
1258 fn test_format_large_number_boundary_k() {
1259 assert_eq!(crate::display::format_large_number(999.99), "999.99");
1260 assert_eq!(crate::display::format_large_number(1000.0), "1.00K");
1261 }
1262
1263 #[test]
1264 fn test_format_large_number_boundary_m() {
1265 assert_eq!(crate::display::format_large_number(999_999.0), "1000.00K");
1266 assert_eq!(crate::display::format_large_number(1_000_000.0), "1.00M");
1267 }
1268
1269 #[test]
1270 fn test_format_large_number_boundary_b() {
1271 assert_eq!(
1272 crate::display::format_large_number(999_999_999.0),
1273 "1000.00M"
1274 );
1275 assert_eq!(
1276 crate::display::format_large_number(1_000_000_000.0),
1277 "1.00B"
1278 );
1279 }
1280
1281 #[test]
1282 fn test_format_large_number_very_large() {
1283 let result = crate::display::format_large_number(1_500_000_000_000.0);
1284 assert!(result.contains("B"));
1285 }
1286
1287 #[test]
1292 fn test_period_seconds_all() {
1293 assert_eq!(Period::Hour1.as_seconds(), 3600);
1294 assert_eq!(Period::Hour24.as_seconds(), 86400);
1295 assert_eq!(Period::Day7.as_seconds(), 604800);
1296 assert_eq!(Period::Day30.as_seconds(), 2592000);
1297 }
1298
1299 #[test]
1300 fn test_period_labels_all() {
1301 assert_eq!(Period::Hour1.label(), "1 Hour");
1302 assert_eq!(Period::Hour24.label(), "24 Hours");
1303 assert_eq!(Period::Day7.label(), "7 Days");
1304 assert_eq!(Period::Day30.label(), "30 Days");
1305 }
1306
1307 use crate::chains::{
1312 DexPair, PricePoint, Token, TokenAnalytics, TokenHolder, TokenSearchResult, TokenSocial,
1313 };
1314
1315 fn make_test_analytics(with_dex: bool) -> TokenAnalytics {
1316 TokenAnalytics {
1317 token: Token {
1318 contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1319 symbol: "USDC".to_string(),
1320 name: "USD Coin".to_string(),
1321 decimals: 6,
1322 },
1323 chain: "ethereum".to_string(),
1324 holders: vec![
1325 TokenHolder {
1326 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
1327 balance: "1000000000000".to_string(),
1328 formatted_balance: "1,000,000".to_string(),
1329 percentage: 12.5,
1330 rank: 1,
1331 },
1332 TokenHolder {
1333 address: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(),
1334 balance: "500000000000".to_string(),
1335 formatted_balance: "500,000".to_string(),
1336 percentage: 6.25,
1337 rank: 2,
1338 },
1339 ],
1340 total_holders: 150_000,
1341 volume_24h: if with_dex { 5_000_000.0 } else { 0.0 },
1342 volume_7d: if with_dex { 25_000_000.0 } else { 0.0 },
1343 price_usd: if with_dex { 0.9999 } else { 0.0 },
1344 price_change_24h: if with_dex { -0.01 } else { 0.0 },
1345 price_change_7d: if with_dex { 0.02 } else { 0.0 },
1346 liquidity_usd: if with_dex { 100_000_000.0 } else { 0.0 },
1347 market_cap: if with_dex {
1348 Some(30_000_000_000.0)
1349 } else {
1350 None
1351 },
1352 fdv: if with_dex {
1353 Some(30_000_000_000.0)
1354 } else {
1355 None
1356 },
1357 total_supply: Some("30000000000".to_string()),
1358 circulating_supply: Some("28000000000".to_string()),
1359 price_history: vec![
1360 PricePoint {
1361 timestamp: 1700000000,
1362 price: 0.9998,
1363 },
1364 PricePoint {
1365 timestamp: 1700003600,
1366 price: 0.9999,
1367 },
1368 ],
1369 volume_history: vec![],
1370 holder_history: vec![],
1371 dex_pairs: if with_dex {
1372 vec![DexPair {
1373 dex_name: "Uniswap V3".to_string(),
1374 pair_address: "0xpair".to_string(),
1375 base_token: "USDC".to_string(),
1376 quote_token: "WETH".to_string(),
1377 price_usd: 0.9999,
1378 volume_24h: 5_000_000.0,
1379 liquidity_usd: 50_000_000.0,
1380 price_change_24h: -0.01,
1381 buys_24h: 1000,
1382 sells_24h: 900,
1383 buys_6h: 300,
1384 sells_6h: 250,
1385 buys_1h: 50,
1386 sells_1h: 45,
1387 pair_created_at: Some(1600000000),
1388 url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
1389 }]
1390 } else {
1391 vec![]
1392 },
1393 fetched_at: 1700003600,
1394 top_10_concentration: Some(35.5),
1395 top_50_concentration: Some(55.0),
1396 top_100_concentration: Some(65.0),
1397 price_change_6h: 0.01,
1398 price_change_1h: -0.005,
1399 total_buys_24h: 1000,
1400 total_sells_24h: 900,
1401 total_buys_6h: 300,
1402 total_sells_6h: 250,
1403 total_buys_1h: 50,
1404 total_sells_1h: 45,
1405 token_age_hours: Some(25000.0),
1406 image_url: None,
1407 websites: vec!["https://www.centre.io/usdc".to_string()],
1408 socials: vec![TokenSocial {
1409 platform: "twitter".to_string(),
1410 url: "https://twitter.com/circle".to_string(),
1411 }],
1412 dexscreener_url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
1413 }
1414 }
1415
1416 fn make_test_crawl_args() -> CrawlArgs {
1417 CrawlArgs {
1418 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1419 chain: "ethereum".to_string(),
1420 period: Period::Hour24,
1421 holders_limit: 10,
1422 format: OutputFormat::Table,
1423 no_charts: true,
1424 report: None,
1425 yes: false,
1426 save: false,
1427 }
1428 }
1429
1430 #[test]
1431 fn test_output_table_with_dex_data() {
1432 let analytics = make_test_analytics(true);
1433 let args = make_test_crawl_args();
1434 let result = output_table(&analytics, &args);
1435 assert!(result.is_ok());
1436 }
1437
1438 #[test]
1439 fn test_output_table_explorer_only() {
1440 let analytics = make_test_analytics(false);
1441 let args = make_test_crawl_args();
1442 let result = output_table(&analytics, &args);
1443 assert!(result.is_ok());
1444 }
1445
1446 #[test]
1447 fn test_output_table_no_holders() {
1448 let mut analytics = make_test_analytics(false);
1449 analytics.holders = vec![];
1450 analytics.total_holders = 0;
1451 analytics.top_10_concentration = None;
1452 analytics.top_50_concentration = None;
1453 let args = make_test_crawl_args();
1454 let result = output_table(&analytics, &args);
1455 assert!(result.is_ok());
1456 }
1457
1458 #[test]
1459 fn test_output_csv() {
1460 let analytics = make_test_analytics(true);
1461 let result = output_csv(&analytics);
1462 assert!(result.is_ok());
1463 }
1464
1465 #[test]
1466 fn test_output_csv_no_market_cap() {
1467 let mut analytics = make_test_analytics(true);
1468 analytics.market_cap = None;
1469 analytics.fdv = None;
1470 analytics.top_10_concentration = None;
1471 let result = output_csv(&analytics);
1472 assert!(result.is_ok());
1473 }
1474
1475 #[test]
1476 fn test_output_csv_no_holders() {
1477 let mut analytics = make_test_analytics(true);
1478 analytics.holders = vec![];
1479 let result = output_csv(&analytics);
1480 assert!(result.is_ok());
1481 }
1482
1483 #[test]
1484 fn test_output_table_with_dex_no_charts() {
1485 let analytics = make_test_analytics(true);
1486 let mut args = make_test_crawl_args();
1487 args.no_charts = true;
1488 let result = output_table_with_dex(&analytics, &args);
1489 assert!(result.is_ok());
1490 }
1491
1492 #[test]
1493 fn test_output_table_with_dex_no_market_cap() {
1494 let mut analytics = make_test_analytics(true);
1495 analytics.market_cap = None;
1496 analytics.fdv = None;
1497 analytics.top_10_concentration = None;
1498 let args = make_test_crawl_args();
1499 let result = output_table_with_dex(&analytics, &args);
1500 assert!(result.is_ok());
1501 }
1502
1503 #[test]
1504 fn test_output_table_explorer_with_concentration() {
1505 let mut analytics = make_test_analytics(false);
1506 analytics.top_10_concentration = Some(40.0);
1507 analytics.top_50_concentration = Some(60.0);
1508 let result = output_table_explorer_only(&analytics);
1509 assert!(result.is_ok());
1510 }
1511
1512 #[test]
1513 fn test_output_table_explorer_no_supply() {
1514 let mut analytics = make_test_analytics(false);
1515 analytics.total_supply = None;
1516 let result = output_table_explorer_only(&analytics);
1517 assert!(result.is_ok());
1518 }
1519
1520 #[test]
1521 fn test_output_table_explorer_with_supply_and_holders() {
1522 let mut analytics = make_test_analytics(false);
1523 analytics.total_supply = Some("1000000000".to_string());
1524 analytics.total_holders = 50_000;
1525 let result = output_table_explorer_only(&analytics);
1526 assert!(result.is_ok());
1527 }
1528
1529 #[test]
1530 fn test_output_table_with_dex_multiple_pairs() {
1531 let mut analytics = make_test_analytics(true);
1532 for i in 0..8 {
1533 analytics.dex_pairs.push(DexPair {
1534 dex_name: format!("DEX {}", i),
1535 pair_address: format!("0xpair{}", i),
1536 base_token: "USDC".to_string(),
1537 quote_token: "WETH".to_string(),
1538 price_usd: 0.9999,
1539 volume_24h: 1_000_000.0 - (i as f64 * 100_000.0),
1540 liquidity_usd: 10_000_000.0 - (i as f64 * 1_000_000.0),
1541 price_change_24h: 0.0,
1542 buys_24h: 100,
1543 sells_24h: 90,
1544 buys_6h: 30,
1545 sells_6h: 25,
1546 buys_1h: 5,
1547 sells_1h: 4,
1548 pair_created_at: None,
1549 url: None,
1550 });
1551 }
1552 let args = make_test_crawl_args();
1553 let result = output_table_with_dex(&analytics, &args);
1555 assert!(result.is_ok());
1556 }
1557
1558 #[test]
1563 fn test_crawl_args_with_report() {
1564 use clap::Parser;
1565
1566 #[derive(Parser)]
1567 struct TestCli {
1568 #[command(flatten)]
1569 crawl: CrawlArgs,
1570 }
1571
1572 let cli = TestCli::try_parse_from([
1573 "test",
1574 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1575 "--report",
1576 "output.md",
1577 ])
1578 .unwrap();
1579
1580 assert_eq!(
1581 cli.crawl.report,
1582 Some(std::path::PathBuf::from("output.md"))
1583 );
1584 }
1585
1586 #[test]
1587 fn test_crawl_args_with_chain_and_period() {
1588 use clap::Parser;
1589
1590 #[derive(Parser)]
1591 struct TestCli {
1592 #[command(flatten)]
1593 crawl: CrawlArgs,
1594 }
1595
1596 let cli = TestCli::try_parse_from([
1597 "test",
1598 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1599 "--chain",
1600 "polygon",
1601 "--period",
1602 "7d",
1603 "--no-charts",
1604 "--yes",
1605 "--save",
1606 ])
1607 .unwrap();
1608
1609 assert_eq!(cli.crawl.chain, "polygon");
1610 assert!(matches!(cli.crawl.period, Period::Day7));
1611 assert!(cli.crawl.no_charts);
1612 assert!(cli.crawl.yes);
1613 assert!(cli.crawl.save);
1614 }
1615
1616 #[test]
1617 fn test_crawl_args_all_periods() {
1618 use clap::Parser;
1619
1620 #[derive(Parser)]
1621 struct TestCli {
1622 #[command(flatten)]
1623 crawl: CrawlArgs,
1624 }
1625
1626 for (period_str, expected) in [
1627 ("1h", Period::Hour1),
1628 ("24h", Period::Hour24),
1629 ("7d", Period::Day7),
1630 ("30d", Period::Day30),
1631 ] {
1632 let cli = TestCli::try_parse_from(["test", "token", "--period", period_str]).unwrap();
1633 assert_eq!(cli.crawl.period.as_seconds(), expected.as_seconds());
1634 }
1635 }
1636
1637 #[test]
1642 fn test_analytics_json_serialization() {
1643 let analytics = make_test_analytics(true);
1644 let json = serde_json::to_string(&analytics).unwrap();
1645 assert!(json.contains("USDC"));
1646 assert!(json.contains("USD Coin"));
1647 assert!(json.contains("ethereum"));
1648 assert!(json.contains("0.9999"));
1649 }
1650
1651 #[test]
1652 fn test_analytics_json_no_optional_fields() {
1653 let mut analytics = make_test_analytics(false);
1654 analytics.market_cap = None;
1655 analytics.fdv = None;
1656 analytics.total_supply = None;
1657 analytics.top_10_concentration = None;
1658 analytics.top_50_concentration = None;
1659 analytics.top_100_concentration = None;
1660 analytics.token_age_hours = None;
1661 analytics.dexscreener_url = None;
1662 let json = serde_json::to_string(&analytics).unwrap();
1663 assert!(!json.contains("market_cap"));
1664 assert!(!json.contains("fdv"));
1665 }
1666
1667 use crate::chains::mocks::{MockClientFactory, MockDexSource};
1672
1673 fn mock_factory_for_crawl() -> MockClientFactory {
1674 let mut factory = MockClientFactory::new();
1675 factory.mock_dex = MockDexSource::new();
1677 factory
1678 }
1679
1680 #[tokio::test]
1681 async fn test_run_crawl_json_output() {
1682 let config = Config::default();
1683 let factory = mock_factory_for_crawl();
1684 let args = CrawlArgs {
1685 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1686 chain: "ethereum".to_string(),
1687 period: Period::Hour24,
1688 holders_limit: 5,
1689 format: OutputFormat::Json,
1690 no_charts: true,
1691 report: None,
1692 yes: true,
1693 save: false,
1694 };
1695 let result = super::run(args, &config, &factory).await;
1696 assert!(result.is_ok());
1697 }
1698
1699 #[tokio::test]
1700 async fn test_run_crawl_table_output() {
1701 let config = Config::default();
1702 let factory = mock_factory_for_crawl();
1703 let args = CrawlArgs {
1704 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1705 chain: "ethereum".to_string(),
1706 period: Period::Hour24,
1707 holders_limit: 5,
1708 format: OutputFormat::Table,
1709 no_charts: true,
1710 report: None,
1711 yes: true,
1712 save: false,
1713 };
1714 let result = super::run(args, &config, &factory).await;
1715 assert!(result.is_ok());
1716 }
1717
1718 #[tokio::test]
1719 async fn test_run_crawl_csv_output() {
1720 let config = Config::default();
1721 let factory = mock_factory_for_crawl();
1722 let args = CrawlArgs {
1723 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1724 chain: "ethereum".to_string(),
1725 period: Period::Hour24,
1726 holders_limit: 5,
1727 format: OutputFormat::Csv,
1728 no_charts: true,
1729 report: None,
1730 yes: true,
1731 save: false,
1732 };
1733 let result = super::run(args, &config, &factory).await;
1734 assert!(result.is_ok());
1735 }
1736
1737 #[tokio::test]
1738 async fn test_run_crawl_symbol_resolution_via_factory_dex() {
1739 let mut factory = MockClientFactory::new();
1741 factory.mock_dex.search_results = vec![TokenSearchResult {
1742 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1743 symbol: "MOCK".to_string(),
1744 name: "Mock Token".to_string(),
1745 chain: "ethereum".to_string(),
1746 price_usd: Some(1.0),
1747 volume_24h: 1_000_000.0,
1748 liquidity_usd: 5_000_000.0,
1749 market_cap: Some(100_000_000.0),
1750 }];
1751 if let Some(ref mut td) = factory.mock_dex.token_data {
1753 td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
1754 }
1755
1756 let config = Config::default();
1757 let args = CrawlArgs {
1758 token: "MOCK".to_string(),
1759 chain: "ethereum".to_string(),
1760 period: Period::Hour24,
1761 holders_limit: 5,
1762 format: OutputFormat::Json,
1763 no_charts: true,
1764 report: None,
1765 yes: true,
1766 save: false,
1767 };
1768 let result = super::run(args, &config, &factory).await;
1769 assert!(result.is_ok());
1770 }
1771
1772 #[tokio::test]
1773 async fn test_run_crawl_no_dex_data_evm() {
1774 let config = Config::default();
1775 let mut factory = MockClientFactory::new();
1776 factory.mock_dex.token_data = None; factory.mock_client.token_info = Some(Token {
1778 contract_address: "0xtoken".to_string(),
1779 symbol: "TEST".to_string(),
1780 name: "Test Token".to_string(),
1781 decimals: 18,
1782 });
1783 let args = CrawlArgs {
1784 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1785 chain: "ethereum".to_string(),
1786 period: Period::Hour24,
1787 holders_limit: 5,
1788 format: OutputFormat::Json,
1789 no_charts: true,
1790 report: None,
1791 yes: true,
1792 save: false,
1793 };
1794 let result = super::run(args, &config, &factory).await;
1795 assert!(result.is_ok());
1796 }
1797
1798 #[tokio::test]
1799 async fn test_run_crawl_table_no_charts() {
1800 let config = Config::default();
1801 let factory = mock_factory_for_crawl();
1802 let args = CrawlArgs {
1803 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1804 chain: "ethereum".to_string(),
1805 period: Period::Hour24,
1806 holders_limit: 5,
1807 format: OutputFormat::Table,
1808 no_charts: true,
1809 report: None,
1810 yes: true,
1811 save: false,
1812 };
1813 let result = super::run(args, &config, &factory).await;
1814 assert!(result.is_ok());
1815 }
1816
1817 #[tokio::test]
1818 async fn test_run_crawl_with_charts() {
1819 let config = Config::default();
1820 let factory = mock_factory_for_crawl();
1821 let args = CrawlArgs {
1822 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1823 chain: "ethereum".to_string(),
1824 period: Period::Hour1,
1825 holders_limit: 5,
1826 format: OutputFormat::Table,
1827 no_charts: false, report: None,
1829 yes: true,
1830 save: false,
1831 };
1832 let result = super::run(args, &config, &factory).await;
1833 assert!(result.is_ok());
1834 }
1835
1836 #[tokio::test]
1837 async fn test_run_crawl_day7_period() {
1838 let config = Config::default();
1839 let factory = mock_factory_for_crawl();
1840 let args = CrawlArgs {
1841 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1842 chain: "ethereum".to_string(),
1843 period: Period::Day7,
1844 holders_limit: 5,
1845 format: OutputFormat::Table,
1846 no_charts: true,
1847 report: None,
1848 yes: true,
1849 save: false,
1850 };
1851 let result = super::run(args, &config, &factory).await;
1852 assert!(result.is_ok());
1853 }
1854
1855 #[tokio::test]
1856 async fn test_run_crawl_markdown_output() {
1857 let config = Config::default();
1858 let factory = mock_factory_for_crawl();
1859 let args = CrawlArgs {
1860 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1861 chain: "ethereum".to_string(),
1862 period: Period::Hour24,
1863 holders_limit: 5,
1864 format: OutputFormat::Markdown,
1865 no_charts: true,
1866 report: None,
1867 yes: true,
1868 save: false,
1869 };
1870 let result = super::run(args, &config, &factory).await;
1871 assert!(result.is_ok());
1872 }
1873
1874 #[tokio::test]
1875 async fn test_run_crawl_unsupported_chain_no_dex() {
1876 let mut factory = MockClientFactory::new();
1878 factory.mock_dex.token_data = None;
1879 factory.mock_dex.search_results = vec![];
1880
1881 let config = Config::default();
1882 let args = CrawlArgs {
1883 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1884 chain: "avalanche".to_string(), period: Period::Hour24,
1886 holders_limit: 5,
1887 format: OutputFormat::Json,
1888 no_charts: true,
1889 report: None,
1890 yes: true,
1891 save: false,
1892 };
1893 let result = super::run(args, &config, &factory).await;
1894 assert!(result.is_err());
1895 let err_str = result.unwrap_err().to_string();
1896 assert!(
1897 err_str.contains("avalanche")
1898 || err_str.contains("block explorer")
1899 || err_str.contains("No DEX"),
1900 "Expected error about unsupported chain, got: {}",
1901 err_str
1902 );
1903 }
1904
1905 #[tokio::test]
1906 async fn test_run_crawl_day30_period() {
1907 let config = Config::default();
1908 let factory = mock_factory_for_crawl();
1909 let args = CrawlArgs {
1910 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1911 chain: "ethereum".to_string(),
1912 period: Period::Day30,
1913 holders_limit: 5,
1914 format: OutputFormat::Table,
1915 no_charts: true,
1916 report: None,
1917 yes: true,
1918 save: false,
1919 };
1920 let result = super::run(args, &config, &factory).await;
1921 assert!(result.is_ok());
1922 }
1923
1924 #[test]
1925 fn test_output_table_with_dex_with_charts() {
1926 let analytics = make_test_analytics(true);
1927 let mut args = make_test_crawl_args();
1928 args.no_charts = false; let result = output_table_with_dex(&analytics, &args);
1930 assert!(result.is_ok());
1931 }
1932
1933 #[test]
1934 fn test_output_table_explorer_short_addresses() {
1935 let mut analytics = make_test_analytics(false);
1936 analytics.holders = vec![TokenHolder {
1937 address: "0xshort".to_string(), balance: "100".to_string(),
1939 formatted_balance: "100".to_string(),
1940 percentage: 1.0,
1941 rank: 1,
1942 }];
1943 let result = output_table_explorer_only(&analytics);
1944 assert!(result.is_ok());
1945 }
1946
1947 #[test]
1948 fn test_output_csv_with_all_fields() {
1949 let analytics = make_test_analytics(true);
1950 let result = output_csv(&analytics);
1951 assert!(result.is_ok());
1952 }
1953
1954 #[tokio::test]
1955 async fn test_fetch_analytics_for_input() {
1956 let factory = mock_factory_for_crawl();
1957 let result = super::fetch_analytics_for_input(
1958 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1959 "ethereum",
1960 Period::Hour24,
1961 5,
1962 &factory,
1963 None,
1964 )
1965 .await;
1966 assert!(result.is_ok());
1967 let analytics = result.unwrap();
1968 assert_eq!(analytics.chain, "ethereum");
1969 assert!(!analytics.token.contract_address.is_empty());
1970 }
1971
1972 #[tokio::test]
1973 async fn test_run_crawl_chain_without_holder_support() {
1974 let mut factory = mock_factory_for_crawl();
1976 if let Some(ref mut td) = factory.mock_dex.token_data {
1977 td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
1978 }
1979 let config = Config::default();
1980 let args = CrawlArgs {
1981 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1982 chain: "fantom".to_string(),
1983 period: Period::Hour24,
1984 holders_limit: 5,
1985 format: OutputFormat::Json,
1986 no_charts: true,
1987 report: None,
1988 yes: true,
1989 save: false,
1990 };
1991 let result = super::run(args, &config, &factory).await;
1992 assert!(result.is_ok());
1993 }
1994
1995 #[tokio::test]
1996 async fn test_run_crawl_with_report() {
1997 let config = Config::default();
1998 let factory = mock_factory_for_crawl();
1999 let tmp = tempfile::NamedTempFile::new().unwrap();
2000 let args = CrawlArgs {
2001 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
2002 chain: "ethereum".to_string(),
2003 period: Period::Hour24,
2004 holders_limit: 5,
2005 format: OutputFormat::Table,
2006 no_charts: true,
2007 report: Some(tmp.path().to_path_buf()),
2008 yes: true,
2009 save: false,
2010 };
2011 let result = super::run(args, &config, &factory).await;
2012 assert!(result.is_ok());
2013 let content = std::fs::read_to_string(tmp.path()).unwrap();
2015 assert!(content.contains("Token Analysis Report"));
2016 }
2017
2018 #[test]
2023 fn test_output_table_explorer_long_address_truncation() {
2024 let mut analytics = make_test_analytics(false);
2025 analytics.holders = vec![TokenHolder {
2026 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
2027 balance: "1000000".to_string(),
2028 formatted_balance: "1,000,000".to_string(),
2029 percentage: 50.0,
2030 rank: 1,
2031 }];
2032 let result = output_table_explorer_only(&analytics);
2033 assert!(result.is_ok());
2034 }
2035
2036 #[test]
2037 fn test_output_table_with_dex_empty_pairs() {
2038 let mut analytics = make_test_analytics(true);
2039 analytics.dex_pairs = vec![];
2040 let args = make_test_crawl_args();
2041 let result = output_table_with_dex(&analytics, &args);
2042 assert!(result.is_ok());
2043 }
2044
2045 #[test]
2046 fn test_output_table_explorer_no_concentration() {
2047 let mut analytics = make_test_analytics(false);
2048 analytics.top_10_concentration = None;
2049 analytics.top_50_concentration = None;
2050 analytics.top_100_concentration = None;
2051 let result = output_table_explorer_only(&analytics);
2052 assert!(result.is_ok());
2053 }
2054
2055 #[test]
2056 fn test_output_table_with_dex_top_10_only() {
2057 let mut analytics = make_test_analytics(true);
2058 analytics.top_10_concentration = Some(25.0);
2059 analytics.top_50_concentration = None;
2060 analytics.top_100_concentration = None;
2061 let args = make_test_crawl_args();
2062 let result = output_table_with_dex(&analytics, &args);
2063 assert!(result.is_ok());
2064 }
2065
2066 #[test]
2067 fn test_output_table_with_dex_top_100_concentration() {
2068 let mut analytics = make_test_analytics(true);
2069 analytics.top_10_concentration = Some(20.0);
2070 analytics.top_50_concentration = Some(45.0);
2071 analytics.top_100_concentration = Some(65.0);
2072 let args = make_test_crawl_args();
2073 let result = output_table_with_dex(&analytics, &args);
2074 assert!(result.is_ok());
2075 }
2076
2077 #[test]
2078 fn test_output_csv_with_market_cap_and_fdv() {
2079 let mut analytics = make_test_analytics(true);
2080 analytics.market_cap = Some(1_000_000_000.0);
2081 analytics.fdv = Some(1_500_000_000.0);
2082 let result = output_csv(&analytics);
2083 assert!(result.is_ok());
2084 }
2085
2086 #[test]
2087 fn test_output_table_routing_has_dex_data() {
2088 let analytics = make_test_analytics(true);
2089 assert!(analytics.price_usd > 0.0);
2090 let args = make_test_crawl_args();
2091 let result = output_table(&analytics, &args);
2092 assert!(result.is_ok());
2093 }
2094
2095 #[test]
2096 fn test_output_table_routing_no_dex_data() {
2097 let analytics = make_test_analytics(false);
2098 assert_eq!(analytics.price_usd, 0.0);
2099 let args = make_test_crawl_args();
2100 let result = output_table(&analytics, &args);
2101 assert!(result.is_ok());
2102 }
2103
2104 #[test]
2105 fn test_format_large_number_negative() {
2106 let result = crate::display::format_large_number(-1_000_000.0);
2107 assert!(result.contains("M") || result.contains("-"));
2108 }
2109
2110 #[test]
2111 fn test_select_token_auto_select() {
2112 let results = vec![TokenSearchResult {
2113 address: "0xtoken".to_string(),
2114 symbol: "TKN".to_string(),
2115 name: "Test Token".to_string(),
2116 chain: "ethereum".to_string(),
2117 price_usd: Some(10.0),
2118 volume_24h: 100000.0,
2119 liquidity_usd: 500000.0,
2120 market_cap: Some(1000000.0),
2121 }];
2122 let selected = select_token(&results, true).unwrap();
2123 assert_eq!(selected.symbol, "TKN");
2124 }
2125
2126 #[test]
2127 fn test_select_token_single_result() {
2128 let results = vec![TokenSearchResult {
2129 address: "0xtoken".to_string(),
2130 symbol: "SINGLE".to_string(),
2131 name: "Single Token".to_string(),
2132 chain: "ethereum".to_string(),
2133 price_usd: None,
2134 volume_24h: 0.0,
2135 liquidity_usd: 0.0,
2136 market_cap: None,
2137 }];
2138 let selected = select_token(&results, false).unwrap();
2140 assert_eq!(selected.symbol, "SINGLE");
2141 }
2142
2143 #[test]
2144 fn test_output_table_with_dex_with_holders() {
2145 let mut analytics = make_test_analytics(true);
2146 analytics.holders = vec![
2147 TokenHolder {
2148 address: "0xholder1".to_string(),
2149 balance: "1000000".to_string(),
2150 formatted_balance: "1,000,000".to_string(),
2151 percentage: 30.0,
2152 rank: 1,
2153 },
2154 TokenHolder {
2155 address: "0xholder2".to_string(),
2156 balance: "500000".to_string(),
2157 formatted_balance: "500,000".to_string(),
2158 percentage: 15.0,
2159 rank: 2,
2160 },
2161 ];
2162 let args = make_test_crawl_args();
2163 let result = output_table_with_dex(&analytics, &args);
2164 assert!(result.is_ok());
2165 }
2166
2167 #[test]
2168 fn test_output_json() {
2169 let analytics = make_test_analytics(true);
2170 let result = serde_json::to_string_pretty(&analytics);
2171 assert!(result.is_ok());
2172 }
2173
2174 fn make_search_results() -> Vec<TokenSearchResult> {
2179 vec![
2180 TokenSearchResult {
2181 symbol: "USDC".to_string(),
2182 name: "USD Coin".to_string(),
2183 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
2184 chain: "ethereum".to_string(),
2185 price_usd: Some(1.0),
2186 volume_24h: 1_000_000.0,
2187 liquidity_usd: 500_000_000.0,
2188 market_cap: Some(30_000_000_000.0),
2189 },
2190 TokenSearchResult {
2191 symbol: "USDC".to_string(),
2192 name: "USD Coin on Polygon".to_string(),
2193 address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
2194 chain: "polygon".to_string(),
2195 price_usd: Some(0.9999),
2196 volume_24h: 500_000.0,
2197 liquidity_usd: 100_000_000.0,
2198 market_cap: None,
2199 },
2200 TokenSearchResult {
2201 symbol: "USDC".to_string(),
2202 name: "Very Long Token Name That Should Be Truncated To Fit".to_string(),
2203 address: "0x1234567890abcdef".to_string(),
2204 chain: "arbitrum".to_string(),
2205 price_usd: None,
2206 volume_24h: 0.0,
2207 liquidity_usd: 50_000.0,
2208 market_cap: None,
2209 },
2210 ]
2211 }
2212
2213 #[test]
2214 fn test_select_token_impl_auto_select_multi() {
2215 let results = make_search_results();
2216 let mut writer = Vec::new();
2217 let mut reader = std::io::Cursor::new(b"" as &[u8]);
2218
2219 let selected = select_token_impl(&results, true, &mut reader, &mut writer).unwrap();
2220 assert_eq!(selected.symbol, "USDC");
2221 assert_eq!(selected.chain, "ethereum");
2222 let output = String::from_utf8(writer).unwrap();
2223 assert!(output.contains("Selected:"));
2224 }
2225
2226 #[test]
2227 fn test_select_token_impl_single_result() {
2228 let results = vec![make_search_results().remove(0)];
2229 let mut writer = Vec::new();
2230 let mut reader = std::io::Cursor::new(b"" as &[u8]);
2231
2232 let selected = select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
2233 assert_eq!(selected.symbol, "USDC");
2234 }
2235
2236 #[test]
2237 fn test_select_token_user_selects_second() {
2238 let results = make_search_results();
2239 let input = b"2\n";
2240 let mut reader = std::io::Cursor::new(&input[..]);
2241 let mut writer = Vec::new();
2242
2243 let selected = select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
2244 assert_eq!(selected.chain, "polygon");
2245 let output = String::from_utf8(writer).unwrap();
2246 assert!(output.contains("Found 3 matching tokens"));
2247 assert!(output.contains("USDC"));
2248 }
2249
2250 #[test]
2251 fn test_select_token_shows_address_column() {
2252 let results = make_search_results();
2253 let input = b"1\n";
2254 let mut reader = std::io::Cursor::new(&input[..]);
2255 let mut writer = Vec::new();
2256
2257 select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
2258 let output = String::from_utf8(writer).unwrap();
2259
2260 assert!(output.contains("Address"));
2262 assert!(output.contains("0xA0b869...06eB48"));
2264 assert!(output.contains("0x2791Bc...a84174"));
2265 }
2266
2267 #[test]
2268 fn test_abbreviate_address() {
2269 assert_eq!(
2270 abbreviate_address("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
2271 "0xA0b869...06eB48"
2272 );
2273 assert_eq!(abbreviate_address("0x1234abcd"), "0x1234abcd");
2275 }
2276
2277 #[test]
2278 fn test_abbreviate_address_boundary_16_chars() {
2279 let addr = "0x1234567890abcd";
2281 assert_eq!(abbreviate_address(addr), addr);
2282 }
2283
2284 #[test]
2285 fn test_abbreviate_address_boundary_17_chars() {
2286 let addr = "0x1234567890abcdef1";
2288 let result = abbreviate_address(addr);
2289 assert!(result.contains("..."));
2290 assert_eq!(&result[..8], "0x123456");
2291 assert_eq!(&result[result.len() - 6..], "bcdef1");
2293 }
2294
2295 #[test]
2296 fn test_abbreviate_address_empty() {
2297 assert_eq!(abbreviate_address(""), "");
2298 }
2299
2300 #[test]
2301 fn test_select_token_user_selects_third() {
2302 let results = make_search_results();
2303 let input = b"3\n";
2304 let mut reader = std::io::Cursor::new(&input[..]);
2305 let mut writer = Vec::new();
2306
2307 let selected = select_token_impl(&results, false, &mut reader, &mut writer).unwrap();
2308 assert_eq!(selected.chain, "arbitrum");
2309 let output = String::from_utf8(writer).unwrap();
2311 assert!(output.contains("..."));
2312 }
2313
2314 #[test]
2315 fn test_select_token_invalid_input() {
2316 let results = make_search_results();
2317 let input = b"abc\n";
2318 let mut reader = std::io::Cursor::new(&input[..]);
2319 let mut writer = Vec::new();
2320
2321 let result = select_token_impl(&results, false, &mut reader, &mut writer);
2322 assert!(result.is_err());
2323 assert!(
2324 result
2325 .unwrap_err()
2326 .to_string()
2327 .contains("Invalid selection")
2328 );
2329 }
2330
2331 #[test]
2332 fn test_select_token_out_of_range_zero() {
2333 let results = make_search_results();
2334 let input = b"0\n";
2335 let mut reader = std::io::Cursor::new(&input[..]);
2336 let mut writer = Vec::new();
2337
2338 let result = select_token_impl(&results, false, &mut reader, &mut writer);
2339 assert!(result.is_err());
2340 assert!(
2341 result
2342 .unwrap_err()
2343 .to_string()
2344 .contains("Selection must be between")
2345 );
2346 }
2347
2348 #[test]
2349 fn test_select_token_out_of_range_high() {
2350 let results = make_search_results();
2351 let input = b"99\n";
2352 let mut reader = std::io::Cursor::new(&input[..]);
2353 let mut writer = Vec::new();
2354
2355 let result = select_token_impl(&results, false, &mut reader, &mut writer);
2356 assert!(result.is_err());
2357 }
2358
2359 #[test]
2364 fn test_prompt_save_alias_yes() {
2365 let input = b"y\n";
2366 let mut reader = std::io::Cursor::new(&input[..]);
2367 let mut writer = Vec::new();
2368
2369 assert!(prompt_save_alias_impl(&mut reader, &mut writer));
2370 let output = String::from_utf8(writer).unwrap();
2371 assert!(output.contains("Save this token"));
2372 }
2373
2374 #[test]
2375 fn test_prompt_save_alias_yes_full() {
2376 let input = b"yes\n";
2377 let mut reader = std::io::Cursor::new(&input[..]);
2378 let mut writer = Vec::new();
2379
2380 assert!(prompt_save_alias_impl(&mut reader, &mut writer));
2381 }
2382
2383 #[test]
2384 fn test_prompt_save_alias_no() {
2385 let input = b"n\n";
2386 let mut reader = std::io::Cursor::new(&input[..]);
2387 let mut writer = Vec::new();
2388
2389 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2390 }
2391
2392 #[test]
2393 fn test_prompt_save_alias_empty() {
2394 let input = b"\n";
2395 let mut reader = std::io::Cursor::new(&input[..]);
2396 let mut writer = Vec::new();
2397
2398 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2399 }
2400
2401 #[test]
2402 fn test_prompt_save_alias_uppercase_y() {
2403 let input = b"Y\n";
2404 let mut reader = std::io::Cursor::new(&input[..]);
2405 let mut writer = Vec::new();
2406
2407 assert!(prompt_save_alias_impl(&mut reader, &mut writer));
2408 }
2409
2410 #[test]
2411 fn test_prompt_save_alias_uppercase_yes() {
2412 let input = b"YES\n";
2413 let mut reader = std::io::Cursor::new(&input[..]);
2414 let mut writer = Vec::new();
2415
2416 assert!(prompt_save_alias_impl(&mut reader, &mut writer));
2417 }
2418
2419 #[test]
2420 fn test_prompt_save_alias_other_input() {
2421 let input = b"maybe\n";
2422 let mut reader = std::io::Cursor::new(&input[..]);
2423 let mut writer = Vec::new();
2424
2425 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2426 }
2427
2428 struct FailingWriter;
2430 impl std::io::Write for FailingWriter {
2431 fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
2432 Err(std::io::Error::other("write failed"))
2433 }
2434 fn flush(&mut self) -> std::io::Result<()> {
2435 Err(std::io::Error::other("flush failed"))
2436 }
2437 }
2438
2439 struct FailingReader;
2441 impl std::io::Read for FailingReader {
2442 fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
2443 Err(std::io::Error::other("read failed"))
2444 }
2445 }
2446 impl std::io::BufRead for FailingReader {
2447 fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
2448 Err(std::io::Error::other("read failed"))
2449 }
2450 fn consume(&mut self, _amt: usize) {}
2451 }
2452
2453 #[test]
2454 fn test_prompt_save_alias_impl_write_fails() {
2455 let input = b"y\n";
2456 let mut reader = std::io::Cursor::new(&input[..]);
2457 let mut writer = FailingWriter;
2458
2459 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2460 }
2461
2462 #[test]
2463 fn test_prompt_save_alias_impl_flush_fails() {
2464 struct WriteOkFlushFail;
2466 impl std::io::Write for WriteOkFlushFail {
2467 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2468 Ok(buf.len())
2469 }
2470 fn flush(&mut self) -> std::io::Result<()> {
2471 Err(std::io::Error::other("flush failed"))
2472 }
2473 }
2474 let input = b"y\n";
2475 let mut reader = std::io::Cursor::new(&input[..]);
2476 let mut writer = WriteOkFlushFail;
2477
2478 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2479 }
2480
2481 #[test]
2482 fn test_prompt_save_alias_impl_read_fails() {
2483 let mut reader = FailingReader;
2484 let mut writer = Vec::new();
2485
2486 assert!(!prompt_save_alias_impl(&mut reader, &mut writer));
2487 }
2488
2489 #[test]
2494 fn test_output_csv_no_panic() {
2495 let analytics = create_test_analytics_minimal();
2496 let result = output_csv(&analytics);
2497 assert!(result.is_ok());
2498 }
2499
2500 #[test]
2501 fn test_output_table_no_dex_data() {
2502 let analytics = create_test_analytics_minimal();
2504 let args = CrawlArgs {
2505 token: "0xtest".to_string(),
2506 chain: "ethereum".to_string(),
2507 period: Period::Hour24,
2508 holders_limit: 10,
2509 format: OutputFormat::Table,
2510 no_charts: true,
2511 report: None,
2512 yes: false,
2513 save: false,
2514 };
2515 let result = output_table(&analytics, &args);
2516 assert!(result.is_ok());
2517 }
2518
2519 #[test]
2520 fn test_output_table_with_dex_data_no_charts() {
2521 let mut analytics = create_test_analytics_minimal();
2522 analytics.price_usd = 1.0;
2523 analytics.volume_24h = 1_000_000.0;
2524 analytics.liquidity_usd = 500_000.0;
2525 analytics.market_cap = Some(1_000_000_000.0);
2526 analytics.fdv = Some(2_000_000_000.0);
2527
2528 let args = CrawlArgs {
2529 token: "0xtest".to_string(),
2530 chain: "ethereum".to_string(),
2531 period: Period::Hour24,
2532 holders_limit: 10,
2533 format: OutputFormat::Table,
2534 no_charts: true,
2535 report: None,
2536 yes: false,
2537 save: false,
2538 };
2539 let result = output_table(&analytics, &args);
2540 assert!(result.is_ok());
2541 }
2542
2543 #[test]
2544 fn test_output_table_with_dex_data_and_charts() {
2545 let mut analytics = create_test_analytics_minimal();
2546 analytics.price_usd = 1.0;
2547 analytics.volume_24h = 1_000_000.0;
2548 analytics.liquidity_usd = 500_000.0;
2549 analytics.price_history = vec![
2550 crate::chains::PricePoint {
2551 timestamp: 1,
2552 price: 0.99,
2553 },
2554 crate::chains::PricePoint {
2555 timestamp: 2,
2556 price: 1.01,
2557 },
2558 ];
2559 analytics.volume_history = vec![
2560 crate::chains::VolumePoint {
2561 timestamp: 1,
2562 volume: 50000.0,
2563 },
2564 crate::chains::VolumePoint {
2565 timestamp: 2,
2566 volume: 60000.0,
2567 },
2568 ];
2569
2570 let args = CrawlArgs {
2571 token: "0xtest".to_string(),
2572 chain: "ethereum".to_string(),
2573 period: Period::Hour24,
2574 holders_limit: 10,
2575 format: OutputFormat::Table,
2576 no_charts: false,
2577 report: None,
2578 yes: false,
2579 save: false,
2580 };
2581 let result = output_table(&analytics, &args);
2582 assert!(result.is_ok());
2583 }
2584
2585 fn create_test_analytics_minimal() -> TokenAnalytics {
2586 TokenAnalytics {
2587 token: Token {
2588 contract_address: "0xtest".to_string(),
2589 symbol: "TEST".to_string(),
2590 name: "Test Token".to_string(),
2591 decimals: 18,
2592 },
2593 chain: "ethereum".to_string(),
2594 holders: Vec::new(),
2595 total_holders: 0,
2596 volume_24h: 0.0,
2597 volume_7d: 0.0,
2598 price_usd: 0.0,
2599 price_change_24h: 0.0,
2600 price_change_7d: 0.0,
2601 liquidity_usd: 0.0,
2602 market_cap: None,
2603 fdv: None,
2604 total_supply: None,
2605 circulating_supply: None,
2606 price_history: Vec::new(),
2607 volume_history: Vec::new(),
2608 holder_history: Vec::new(),
2609 dex_pairs: Vec::new(),
2610 fetched_at: 0,
2611 top_10_concentration: None,
2612 top_50_concentration: None,
2613 top_100_concentration: None,
2614 price_change_6h: 0.0,
2615 price_change_1h: 0.0,
2616 total_buys_24h: 0,
2617 total_sells_24h: 0,
2618 total_buys_6h: 0,
2619 total_sells_6h: 0,
2620 total_buys_1h: 0,
2621 total_sells_1h: 0,
2622 token_age_hours: None,
2623 image_url: None,
2624 websites: Vec::new(),
2625 socials: Vec::new(),
2626 dexscreener_url: None,
2627 }
2628 }
2629}