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