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