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