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