Skip to main content

scope/cli/
crawl.rs

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