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