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