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