Skip to main content

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