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