use crate::cli::output::Output;
use crate::cli::progress::{self, Spinner};
use crate::cli::token_pick;
use crate::outln;
use clap::Args;
use scope::chains::{
ChainClientFactory, DexClient, DexDataSource, DexPair, Token, TokenAnalytics, TokenHolder,
TokenSearchResult, infer_chain_from_address,
};
use scope::config::{Config, OutputFormat};
use scope::display::{charts, report};
use scope::error::{Result, ScopeError};
use scope::tokens::TokenAliases;
use std::io::BufRead;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
pub enum Period {
#[value(name = "1h")]
Hour1,
#[default]
#[value(name = "24h")]
Hour24,
#[value(name = "7d")]
Day7,
#[value(name = "30d")]
Day30,
}
impl Period {
pub fn as_seconds(&self) -> i64 {
match self {
Period::Hour1 => 3600,
Period::Hour24 => 86400,
Period::Day7 => 604800,
Period::Day30 => 2592000,
}
}
pub fn label(&self) -> &'static str {
match self {
Period::Hour1 => "1 Hour",
Period::Hour24 => "24 Hours",
Period::Day7 => "7 Days",
Period::Day30 => "30 Days",
}
}
}
#[derive(Debug, Args)]
#[command(
after_help = "\x1b[1mExamples:\x1b[0m
scope crawl USDC
scope crawl @usdc-token \x1b[2m# address book shortcut\x1b[0m
scope crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --chain ethereum
scope crawl USDC --period 7d --report usdc_report.md
scope crawl PEPE --format json --no-charts",
after_long_help = "\x1b[1mExamples:\x1b[0m
\x1b[1m$ scope crawl USDC\x1b[0m
Key Metrics
==================================================
Price: $0.999900
24h Change: -0.01%
24h Volume: $5.00M
Liquidity: $100.00M
Market Cap: $30.00B
FDV: $30.00B
Top Trading Pairs
==================================================
1. Uniswap V3 USDC/WETH - $5.00M ($50.00M liq)
2. Uniswap V2 USDC/USDT - $2.50M ($25.00M liq)
...
\x1b[1m$ scope crawl PEPE --period 7d --no-charts\x1b[0m
Key Metrics
==================================================
Price: $0.000012
24h Change: +8.50%
24h Volume: $120.00M
Liquidity: $45.00M
Market Cap: $5.00B
...
\x1b[1m$ scope crawl USDC --report usdc.md\x1b[0m
Key Metrics
==================================================
Price: $0.999900
...
Report saved to usdc.md"
)]
pub struct CrawlArgs {
pub token: String,
#[arg(short, long, default_value = scope::chains::DEFAULT_CHAIN)]
pub chain: String,
#[arg(short, long, default_value = "24h")]
pub period: Period,
#[arg(long, default_value = "10")]
pub holders_limit: u32,
#[arg(short, long, default_value = "table")]
pub format: OutputFormat,
#[arg(long)]
pub no_charts: bool,
#[arg(long, value_name = "PATH")]
pub report: Option<PathBuf>,
#[arg(long)]
pub yes: bool,
#[arg(long)]
pub save: bool,
}
#[derive(Debug, Clone)]
struct ResolvedToken {
address: String,
chain: String,
alias_info: Option<(String, String)>,
}
async fn resolve_token_input(
args: &CrawlArgs,
aliases: &mut TokenAliases,
dex_client: &dyn DexDataSource,
spinner: Option<&Spinner>,
out: &Output,
) -> Result<ResolvedToken> {
let input = args.token.trim();
if TokenAliases::is_address(input) {
let chain = if args.chain == "ethereum" {
infer_chain_from_address(input)
.unwrap_or("ethereum")
.to_string()
} else {
args.chain.clone()
};
return Ok(ResolvedToken {
address: input.to_string(),
chain,
alias_info: None,
});
}
let chain_filter = if args.chain != "ethereum" {
Some(args.chain.as_str())
} else {
None
};
if let Some(token_info) = aliases.get(input, chain_filter) {
let msg = format!(
"Using saved token: {} ({}) on {}",
token_info.symbol, token_info.name, token_info.chain
);
progress::status(spinner, out, &msg);
return Ok(ResolvedToken {
address: token_info.address.clone(),
chain: token_info.chain.clone(),
alias_info: Some((token_info.symbol.clone(), token_info.name.clone())),
});
}
progress::status(spinner, out, &format!("Searching for '{}'...", input));
let mut search_results = dex_client.search_tokens(input, chain_filter).await?;
if search_results.is_empty()
&& let Some(fallback) = token_pick::cex_fallback(input, &args.chain, spinner, out).await
{
search_results.push(fallback);
}
if search_results.is_empty() {
return Err(ScopeError::NotFound(format!(
"No token found matching '{}' on {} (checked DexScreener and CEX venues)",
input, args.chain
)));
}
let selected = select_token(&search_results, args.yes, spinner, out)?;
if args.save || (!args.yes && prompt_save_alias(out)) {
aliases.add(
&selected.symbol,
&selected.chain,
&selected.address,
&selected.name,
);
if let Err(e) = aliases.save() {
tracing::debug!("Failed to save token alias: {}", e);
} else {
let saved = format!("Saved {} as alias for future use.", selected.symbol);
progress::notice(spinner, out, &saved);
}
}
Ok(ResolvedToken {
address: selected.address.clone(),
chain: selected.chain.clone(),
alias_info: Some((selected.symbol.clone(), selected.name.clone())),
})
}
fn select_token<'a>(
results: &'a [TokenSearchResult],
auto_select: bool,
spinner: Option<&Spinner>,
out: &Output,
) -> Result<&'a TokenSearchResult> {
select_token_impl(
results,
auto_select,
&mut crate::cli::prompt::stdin(),
spinner,
out,
)
}
fn select_token_impl<'a>(
results: &'a [TokenSearchResult],
auto_select: bool,
reader: &mut impl BufRead,
spinner: Option<&Spinner>,
out: &Output,
) -> Result<&'a TokenSearchResult> {
if results.len() == 1 || auto_select {
let selected = &results[0];
token_pick::announce(spinner, out, selected);
return Ok(selected);
}
token_pick::pick_token(reader, spinner, out, results)
}
fn prompt_save_alias(out: &Output) -> bool {
prompt_save_alias_impl(&mut crate::cli::prompt::stdin(), out)
}
fn prompt_save_alias_impl(reader: &mut impl BufRead, out: &Output) -> bool {
crate::cli::prompt::ask(reader, out, "Save this token for future use? [y/N]: ")
.is_ok_and(|input| matches!(input.trim().to_lowercase().as_str(), "y" | "yes"))
}
pub async fn fetch_analytics_for_input(
token_input: &str,
chain: &str,
period: Period,
holders_limit: u32,
clients: &dyn ChainClientFactory,
spinner: Option<&crate::cli::progress::Spinner>,
out: &Output,
) -> Result<TokenAnalytics> {
let args = CrawlArgs {
token: token_input.to_string(),
chain: chain.to_string(),
period,
holders_limit,
format: OutputFormat::Table,
no_charts: true,
report: None,
yes: true,
save: false,
};
let mut aliases = TokenAliases::load();
let dex_client = clients.create_dex_client();
let resolved =
resolve_token_input(&args, &mut aliases, dex_client.as_ref(), spinner, out).await?;
let fetching = format!(
"Fetching analytics for {} on {}...",
resolved.address, resolved.chain
);
progress::status(spinner, out, &fetching);
let mut analytics =
fetch_token_analytics(&resolved.address, &resolved.chain, &args, clients).await?;
if let Some((symbol, name)) = &resolved.alias_info
&& (analytics.token.symbol == "UNKNOWN" || analytics.token.name == "Unknown Token")
{
analytics.token.symbol = symbol.clone();
analytics.token.name = name.clone();
}
Ok(analytics)
}
pub async fn run(
mut args: CrawlArgs,
config: &Config,
clients: &dyn ChainClientFactory,
out: &Output,
) -> Result<()> {
if let Some((address, chain)) =
crate::cli::address_book::resolve_address_book_input(&args.token, config)?
{
args.token = address;
if args.chain == "ethereum" {
args.chain = chain;
}
}
let mut aliases = TokenAliases::load();
let sp = crate::cli::progress::Spinner::new(
&format!("Crawling token {} on {}...", args.token, args.chain),
out,
);
let dex_client = clients.create_dex_client();
let resolved =
resolve_token_input(&args, &mut aliases, dex_client.as_ref(), Some(&sp), out).await?;
tracing::info!(
token = %resolved.address,
chain = %resolved.chain,
period = ?args.period,
"Starting token crawl"
);
sp.set_message(format!(
"Fetching analytics for {} on {}...",
resolved.address, resolved.chain
));
let mut analytics =
fetch_token_analytics(&resolved.address, &resolved.chain, &args, clients).await?;
sp.finish("Token data loaded.");
if (analytics.token.symbol == "UNKNOWN" || analytics.token.name == "Unknown Token")
&& let Some((symbol, name)) = &resolved.alias_info
{
analytics.token.symbol = symbol.clone();
analytics.token.name = name.clone();
}
match args.format {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&analytics)?;
outln!(out, "{}", json)?;
}
OutputFormat::Csv => {
output_csv(&analytics, out)?;
}
OutputFormat::Table => {
output_table(&analytics, &args, out)?;
}
OutputFormat::Markdown => {
let md = report::generate_report(&analytics);
outln!(out, "{}", md)?;
}
}
if let Some(ref report_path) = args.report {
let markdown_report = report::generate_report(&analytics);
report::save_report(&markdown_report, report_path)?;
out.note_saved("Report", report_path.display());
}
Ok(())
}
async fn fetch_token_analytics(
token_address: &str,
chain: &str,
args: &CrawlArgs,
clients: &dyn ChainClientFactory,
) -> Result<TokenAnalytics> {
let dex_client = clients.create_dex_client();
let dex_result = dex_client.get_token_data(chain, token_address).await;
match dex_result {
Ok(dex_data) => {
fetch_analytics_with_dex(token_address, chain, args, clients, dex_data).await
}
Err(ScopeError::NotFound(_)) => {
tracing::debug!("No DEX data, falling back to block explorer");
fetch_analytics_from_explorer(token_address, chain, args, clients).await
}
Err(e) => Err(e),
}
}
async fn fetch_analytics_with_dex(
token_address: &str,
chain: &str,
args: &CrawlArgs,
clients: &dyn ChainClientFactory,
dex_data: scope::chains::dex::DexTokenData,
) -> Result<TokenAnalytics> {
let holders = fetch_holders(token_address, chain, args.holders_limit, clients).await?;
let token = Token {
contract_address: token_address.to_string(),
symbol: dex_data.symbol.clone(),
name: dex_data.name.clone(),
decimals: 18, };
let top_10_pct: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
let top_50_pct: f64 = holders.iter().take(50).map(|h| h.percentage).sum();
let top_100_pct: f64 = holders.iter().take(100).map(|h| h.percentage).sum();
let dex_pairs: Vec<DexPair> = dex_data.pairs;
let volume_7d = DexClient::estimate_7d_volume(dex_data.volume_24h);
let fetched_at = chrono::Utc::now().timestamp();
let token_age_hours = dex_data.earliest_pair_created_at.map(|created_at| {
let now = chrono::Utc::now().timestamp();
let created_at_secs = if created_at > 32503680000 {
created_at / 1000
} else {
created_at
};
let age_secs = now - created_at_secs;
if age_secs > 0 {
(age_secs as f64) / 3600.0
} else {
0.0 }
});
let socials: Vec<scope::chains::TokenSocial> = dex_data
.socials
.iter()
.map(|s| scope::chains::TokenSocial {
platform: s.platform.clone(),
url: s.url.clone(),
})
.collect();
Ok(TokenAnalytics {
token,
chain: chain.to_string(),
holders,
total_holders: 0, volume_24h: dex_data.volume_24h,
volume_7d,
price_usd: dex_data.price_usd,
price_change_24h: dex_data.price_change_24h,
price_change_7d: 0.0, liquidity_usd: dex_data.liquidity_usd,
market_cap: dex_data.market_cap,
fdv: dex_data.fdv,
total_supply: None,
circulating_supply: None,
price_history: dex_data.price_history,
volume_history: dex_data.volume_history,
holder_history: Vec::new(), dex_pairs,
fetched_at,
top_10_concentration: if top_10_pct > 0.0 {
Some(top_10_pct)
} else {
None
},
top_50_concentration: if top_50_pct > 0.0 {
Some(top_50_pct)
} else {
None
},
top_100_concentration: if top_100_pct > 0.0 {
Some(top_100_pct)
} else {
None
},
price_change_6h: dex_data.price_change_6h,
price_change_1h: dex_data.price_change_1h,
total_buys_24h: dex_data.total_buys_24h,
total_sells_24h: dex_data.total_sells_24h,
total_buys_6h: dex_data.total_buys_6h,
total_sells_6h: dex_data.total_sells_6h,
total_buys_1h: dex_data.total_buys_1h,
total_sells_1h: dex_data.total_sells_1h,
token_age_hours,
image_url: dex_data.image_url.clone(),
websites: dex_data.websites.clone(),
socials,
dexscreener_url: dex_data.dexscreener_url.clone(),
})
}
async fn fetch_analytics_from_explorer(
token_address: &str,
chain: &str,
args: &CrawlArgs,
clients: &dyn ChainClientFactory,
) -> Result<TokenAnalytics> {
let has_explorer = scope::chains::registry::has_explorer(chain);
if !has_explorer {
return Err(ScopeError::NotFound(format!(
"No DEX data found for token {} on {} and block explorer fallback not supported for this chain",
token_address, chain
)));
}
let client = clients.create_chain_client(chain)?;
let token = match client.get_token_info(token_address).await {
Ok(t) => t,
Err(e) => {
tracing::debug!("Failed to fetch token info: {}", e);
Token {
contract_address: token_address.to_string(),
symbol: "UNKNOWN".to_string(),
name: "Unknown Token".to_string(),
decimals: 18,
}
}
};
let holders = fetch_holders(token_address, chain, args.holders_limit, clients).await?;
let total_holders = match client.get_token_holder_count(token_address).await {
Ok(count) => count,
Err(e) => {
tracing::debug!("Failed to fetch holder count: {}", e);
0
}
};
let top_10_pct: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
let top_50_pct: f64 = holders.iter().take(50).map(|h| h.percentage).sum();
let top_100_pct: f64 = holders.iter().take(100).map(|h| h.percentage).sum();
let fetched_at = chrono::Utc::now().timestamp();
Ok(TokenAnalytics {
token,
chain: chain.to_string(),
holders,
total_holders,
volume_24h: 0.0,
volume_7d: 0.0,
price_usd: 0.0,
price_change_24h: 0.0,
price_change_7d: 0.0,
liquidity_usd: 0.0,
market_cap: None,
fdv: None,
total_supply: None,
circulating_supply: None,
price_history: Vec::new(),
volume_history: Vec::new(),
holder_history: Vec::new(),
dex_pairs: Vec::new(),
fetched_at,
top_10_concentration: if top_10_pct > 0.0 {
Some(top_10_pct)
} else {
None
},
top_50_concentration: if top_50_pct > 0.0 {
Some(top_50_pct)
} else {
None
},
top_100_concentration: if top_100_pct > 0.0 {
Some(top_100_pct)
} else {
None
},
price_change_6h: 0.0,
price_change_1h: 0.0,
total_buys_24h: 0,
total_sells_24h: 0,
total_buys_6h: 0,
total_sells_6h: 0,
total_buys_1h: 0,
total_sells_1h: 0,
token_age_hours: None,
image_url: None,
websites: Vec::new(),
socials: Vec::new(),
dexscreener_url: None,
})
}
async fn fetch_holders(
token_address: &str,
chain: &str,
limit: u32,
clients: &dyn ChainClientFactory,
) -> Result<Vec<TokenHolder>> {
match scope::chains::registry::has_explorer(chain) {
true => {
let client = clients.create_chain_client(chain)?;
match client.get_token_holders(token_address, limit).await {
Ok(holders) => Ok(holders),
Err(e) => {
tracing::debug!("Failed to fetch holders: {}", e);
Ok(Vec::new())
}
}
}
false => {
tracing::info!("Holder data not available for chain: {}", chain);
Ok(Vec::new())
}
}
}
fn output_table(analytics: &TokenAnalytics, args: &CrawlArgs, out: &Output) -> Result<()> {
outln!(out)?;
let has_dex_data = analytics.price_usd > 0.0;
if has_dex_data {
output_table_with_dex(analytics, args, out)
} else {
output_table_explorer_only(analytics, out)
}
}
fn output_table_with_dex(analytics: &TokenAnalytics, args: &CrawlArgs, out: &Output) -> Result<()> {
use scope::display::terminal as t;
if !args.no_charts {
let dashboard = charts::render_analytics_dashboard(
&analytics.price_history,
&analytics.volume_history,
&analytics.holders,
&analytics.token.symbol,
&analytics.chain,
);
outln!(out, "{}", dashboard)?;
} else {
outln!(
out,
"{}",
t::section_header(&format!(
"{} ({})",
analytics.token.name, analytics.token.symbol
))
)?;
outln!(out, "{}", t::kv_row("Chain", &analytics.chain))?;
outln!(
out,
"{}",
t::kv_row("Contract", &analytics.token.contract_address)
)?;
outln!(out, "{}", t::blank_row())?;
}
outln!(out, "{}", t::subsection_header("Key Metrics"))?;
outln!(
out,
"{}",
t::kv_row(
"Price",
&scope::display::format::format_price_usd(analytics.price_usd)
)
)?;
outln!(
out,
"{}",
t::kv_row_delta(
"24h Change",
analytics.price_change_24h,
&format!("{:+.2}%", analytics.price_change_24h)
)
)?;
outln!(
out,
"{}",
t::kv_row(
"24h Volume",
&scope::display::format_usd(analytics.volume_24h)
)
)?;
outln!(
out,
"{}",
t::kv_row(
"Liquidity",
&scope::display::format_usd(analytics.liquidity_usd)
)
)?;
if let Some(mc) = analytics.market_cap {
outln!(
out,
"{}",
t::kv_row("Market Cap", &scope::display::format_usd(mc))
)?;
}
if let Some(fdv) = analytics.fdv {
outln!(
out,
"{}",
t::kv_row("FDV", &scope::display::format_usd(fdv))
)?;
}
if !analytics.dex_pairs.is_empty() {
outln!(out, "{}", t::blank_row())?;
outln!(out, "{}", t::subsection_header("Top Trading Pairs"))?;
for (i, pair) in analytics.dex_pairs.iter().take(5).enumerate() {
let pair_str = format!(
"{} {}/{} - {} ({} liq)",
pair.dex_name,
pair.base_token,
pair.quote_token,
scope::display::format_usd(pair.volume_24h),
scope::display::format_usd(pair.liquidity_usd)
);
outln!(out, "{}", t::numbered_row(i + 1, &pair_str))?;
}
}
if let Some(top_10) = analytics.top_10_concentration {
outln!(out, "{}", t::blank_row())?;
outln!(out, "{}", t::subsection_header("Holder Concentration"))?;
outln!(
out,
"{}",
t::kv_row("Top 10 holders", &format!("{:.1}% of supply", top_10))
)?;
if let Some(top_50) = analytics.top_50_concentration {
outln!(
out,
"{}",
t::kv_row("Top 50 holders", &format!("{:.1}% of supply", top_50))
)?;
}
}
if args.no_charts {
outln!(out, "{}", t::section_footer())?;
}
Ok(())
}
fn output_table_explorer_only(analytics: &TokenAnalytics, out: &Output) -> Result<()> {
use scope::display::terminal as t;
outln!(out, "{}", t::section_header("Token Info (Block Explorer)"))?;
outln!(out, "{}", t::kv_row("Name", &analytics.token.name))?;
outln!(out, "{}", t::kv_row("Symbol", &analytics.token.symbol))?;
outln!(
out,
"{}",
t::kv_row("Contract", &analytics.token.contract_address)
)?;
outln!(out, "{}", t::kv_row("Chain", &analytics.chain))?;
outln!(
out,
"{}",
t::kv_row("Decimals", &analytics.token.decimals.to_string())
)?;
if analytics.total_holders > 0 {
outln!(
out,
"{}",
t::kv_row("Total Holders", &analytics.total_holders.to_string())
)?;
}
if let Some(supply) = &analytics.total_supply {
outln!(out, "{}", t::kv_row("Total Supply", supply))?;
}
outln!(out, "{}", t::blank_row())?;
outln!(
out,
"{}",
t::info_row(
"No DEX trading data available for this token. Price, volume, and liquidity data require active DEX pairs."
)
)?;
if !analytics.holders.is_empty() {
outln!(out, "{}", t::blank_row())?;
outln!(out, "{}", t::subsection_header("Top Holders"))?;
let cols = [
t::Col {
label: "Rank",
width: 4,
align: '>',
},
t::Col {
label: "Percent",
width: 10,
align: '>',
},
t::Col {
label: "Balance",
width: 20,
align: '>',
},
t::Col {
label: "Address",
width: 42,
align: '<',
},
];
outln!(out, "{}", t::table_header(&cols))?;
for holder in analytics.holders.iter().take(10) {
let addr_display = scope::display::text::abbreviate(&holder.address);
let rank_str = holder.rank.to_string();
let percent_str = format!("{:.2}%", holder.percentage);
let values = [
rank_str.as_str(),
percent_str.as_str(),
holder.formatted_balance.as_str(),
addr_display.as_str(),
];
outln!(out, "{}", t::table_row(&cols, &values))?;
}
}
if let Some(top_10) = analytics.top_10_concentration {
outln!(out, "{}", t::blank_row())?;
outln!(out, "{}", t::subsection_header("Holder Concentration"))?;
outln!(
out,
"{}",
t::kv_row("Top 10 holders", &format!("{:.1}% of supply", top_10))
)?;
if let Some(top_50) = analytics.top_50_concentration {
outln!(
out,
"{}",
t::kv_row("Top 50 holders", &format!("{:.1}% of supply", top_50))
)?;
}
}
outln!(out, "{}", t::section_footer())?;
Ok(())
}
fn output_csv(analytics: &TokenAnalytics, out: &Output) -> Result<()> {
outln!(out, "metric,value")?;
outln!(out, "symbol,{}", analytics.token.symbol)?;
outln!(out, "name,{}", analytics.token.name)?;
outln!(out, "chain,{}", analytics.chain)?;
outln!(out, "contract,{}", analytics.token.contract_address)?;
outln!(out, "price_usd,{}", analytics.price_usd)?;
outln!(out, "price_change_24h,{}", analytics.price_change_24h)?;
outln!(out, "volume_24h,{}", analytics.volume_24h)?;
outln!(out, "volume_7d,{}", analytics.volume_7d)?;
outln!(out, "liquidity_usd,{}", analytics.liquidity_usd)?;
if let Some(mc) = analytics.market_cap {
outln!(out, "market_cap,{}", mc)?;
}
if let Some(fdv) = analytics.fdv {
outln!(out, "fdv,{}", fdv)?;
}
outln!(out, "total_holders,{}", analytics.total_holders)?;
if let Some(top_10) = analytics.top_10_concentration {
outln!(out, "top_10_concentration,{}", top_10)?;
}
if !analytics.holders.is_empty() {
outln!(out)?;
outln!(out, "rank,address,balance,percentage")?;
for holder in &analytics.holders {
outln!(
out,
"{},{},{},{}",
holder.rank,
holder.address,
holder.balance,
holder.percentage
)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn quiet() -> crate::cli::output::Output {
crate::cli::output::Output::capture().0
}
#[test]
fn test_period_as_seconds() {
assert_eq!(Period::Hour1.as_seconds(), 3600);
assert_eq!(Period::Hour24.as_seconds(), 86400);
assert_eq!(Period::Day7.as_seconds(), 604800);
assert_eq!(Period::Day30.as_seconds(), 2592000);
}
#[test]
fn test_period_label() {
assert_eq!(Period::Hour1.label(), "1 Hour");
assert_eq!(Period::Hour24.label(), "24 Hours");
assert_eq!(Period::Day7.label(), "7 Days");
assert_eq!(Period::Day30.label(), "30 Days");
}
#[test]
fn test_format_large_number() {
assert_eq!(scope::display::format_large_number(500.0), "500.00");
assert_eq!(scope::display::format_large_number(1500.0), "1.50K");
assert_eq!(scope::display::format_large_number(1500000.0), "1.50M");
assert_eq!(scope::display::format_large_number(1500000000.0), "1.50B");
}
#[test]
fn test_period_default() {
let period = Period::default();
assert!(matches!(period, Period::Hour24));
}
#[test]
fn test_crawl_args_defaults() {
use clap::Parser;
#[derive(Parser)]
struct TestCli {
#[command(flatten)]
crawl: CrawlArgs,
}
let cli = TestCli::try_parse_from(["test", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"])
.unwrap();
assert_eq!(cli.crawl.chain, "ethereum");
assert!(matches!(cli.crawl.period, Period::Hour24));
assert_eq!(cli.crawl.holders_limit, 10);
assert!(!cli.crawl.no_charts);
assert!(cli.crawl.report.is_none());
}
#[test]
fn test_format_large_number_zero() {
assert_eq!(scope::display::format_large_number(0.0), "0.00");
}
#[test]
fn test_format_large_number_small() {
assert_eq!(scope::display::format_large_number(0.12), "0.12");
}
#[test]
fn test_format_large_number_boundary_k() {
assert_eq!(scope::display::format_large_number(999.99), "999.99");
assert_eq!(scope::display::format_large_number(1000.0), "1.00K");
}
#[test]
fn test_format_large_number_boundary_m() {
assert_eq!(scope::display::format_large_number(999_999.0), "1000.00K");
assert_eq!(scope::display::format_large_number(1_000_000.0), "1.00M");
}
#[test]
fn test_format_large_number_boundary_b() {
assert_eq!(
scope::display::format_large_number(999_999_999.0),
"1000.00M"
);
assert_eq!(
scope::display::format_large_number(1_000_000_000.0),
"1.00B"
);
}
#[test]
fn test_format_large_number_very_large() {
let result = scope::display::format_large_number(1_500_000_000_000.0);
assert!(result.contains("B"));
}
#[test]
fn test_period_seconds_all() {
assert_eq!(Period::Hour1.as_seconds(), 3600);
assert_eq!(Period::Hour24.as_seconds(), 86400);
assert_eq!(Period::Day7.as_seconds(), 604800);
assert_eq!(Period::Day30.as_seconds(), 2592000);
}
#[test]
fn test_period_labels_all() {
assert_eq!(Period::Hour1.label(), "1 Hour");
assert_eq!(Period::Hour24.label(), "24 Hours");
assert_eq!(Period::Day7.label(), "7 Days");
assert_eq!(Period::Day30.label(), "30 Days");
}
use scope::chains::{
DexPair, PricePoint, Token, TokenAnalytics, TokenHolder, TokenSearchResult, TokenSocial,
};
fn make_test_analytics(with_dex: bool) -> TokenAnalytics {
TokenAnalytics {
token: Token {
contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
symbol: "USDC".to_string(),
name: "USD Coin".to_string(),
decimals: 6,
},
chain: "ethereum".to_string(),
holders: vec![
TokenHolder {
address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
balance: "1000000000000".to_string(),
formatted_balance: "1,000,000".to_string(),
percentage: 12.5,
rank: 1,
},
TokenHolder {
address: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(),
balance: "500000000000".to_string(),
formatted_balance: "500,000".to_string(),
percentage: 6.25,
rank: 2,
},
],
total_holders: 150_000,
volume_24h: if with_dex { 5_000_000.0 } else { 0.0 },
volume_7d: if with_dex { 25_000_000.0 } else { 0.0 },
price_usd: if with_dex { 0.9999 } else { 0.0 },
price_change_24h: if with_dex { -0.01 } else { 0.0 },
price_change_7d: if with_dex { 0.02 } else { 0.0 },
liquidity_usd: if with_dex { 100_000_000.0 } else { 0.0 },
market_cap: if with_dex {
Some(30_000_000_000.0)
} else {
None
},
fdv: if with_dex {
Some(30_000_000_000.0)
} else {
None
},
total_supply: Some("30000000000".to_string()),
circulating_supply: Some("28000000000".to_string()),
price_history: vec![
PricePoint {
timestamp: 1700000000,
price: 0.9998,
},
PricePoint {
timestamp: 1700003600,
price: 0.9999,
},
],
volume_history: vec![],
holder_history: vec![],
dex_pairs: if with_dex {
vec![DexPair {
dex_name: "Uniswap V3".to_string(),
pair_address: "0xpair".to_string(),
base_token: "USDC".to_string(),
quote_token: "WETH".to_string(),
price_usd: 0.9999,
volume_24h: 5_000_000.0,
liquidity_usd: 50_000_000.0,
liquidity_base: None,
price_change_24h: -0.01,
buys_24h: 1000,
sells_24h: 900,
buys_6h: 300,
sells_6h: 250,
buys_1h: 50,
sells_1h: 45,
pair_created_at: Some(1600000000),
url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
}]
} else {
vec![]
},
fetched_at: 1700003600,
top_10_concentration: Some(35.5),
top_50_concentration: Some(55.0),
top_100_concentration: Some(65.0),
price_change_6h: 0.01,
price_change_1h: -0.005,
total_buys_24h: 1000,
total_sells_24h: 900,
total_buys_6h: 300,
total_sells_6h: 250,
total_buys_1h: 50,
total_sells_1h: 45,
token_age_hours: Some(25000.0),
image_url: None,
websites: vec!["https://www.centre.io/usdc".to_string()],
socials: vec![TokenSocial {
platform: "twitter".to_string(),
url: "https://twitter.com/circle".to_string(),
}],
dexscreener_url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
}
}
fn make_test_crawl_args() -> CrawlArgs {
CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 10,
format: OutputFormat::Table,
no_charts: true,
report: None,
yes: false,
save: false,
}
}
#[test]
fn test_output_table_with_dex_data() {
let analytics = make_test_analytics(true);
let args = make_test_crawl_args();
let result = output_table(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_explorer_only() {
let analytics = make_test_analytics(false);
let args = make_test_crawl_args();
let result = output_table(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_no_holders() {
let mut analytics = make_test_analytics(false);
analytics.holders = vec![];
analytics.total_holders = 0;
analytics.top_10_concentration = None;
analytics.top_50_concentration = None;
let args = make_test_crawl_args();
let result = output_table(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_csv() {
let analytics = make_test_analytics(true);
let result = output_csv(&analytics, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_csv_no_market_cap() {
let mut analytics = make_test_analytics(true);
analytics.market_cap = None;
analytics.fdv = None;
analytics.top_10_concentration = None;
let result = output_csv(&analytics, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_csv_no_holders() {
let mut analytics = make_test_analytics(true);
analytics.holders = vec![];
let result = output_csv(&analytics, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_with_dex_no_charts() {
let analytics = make_test_analytics(true);
let mut args = make_test_crawl_args();
args.no_charts = true;
let result = output_table_with_dex(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_with_dex_no_market_cap() {
let mut analytics = make_test_analytics(true);
analytics.market_cap = None;
analytics.fdv = None;
analytics.top_10_concentration = None;
let args = make_test_crawl_args();
let result = output_table_with_dex(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_explorer_with_concentration() {
let mut analytics = make_test_analytics(false);
analytics.top_10_concentration = Some(40.0);
analytics.top_50_concentration = Some(60.0);
let result = output_table_explorer_only(&analytics, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_explorer_no_supply() {
let mut analytics = make_test_analytics(false);
analytics.total_supply = None;
let result = output_table_explorer_only(&analytics, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_explorer_with_supply_and_holders() {
let mut analytics = make_test_analytics(false);
analytics.total_supply = Some("1000000000".to_string());
analytics.total_holders = 50_000;
let result = output_table_explorer_only(&analytics, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_with_dex_multiple_pairs() {
let mut analytics = make_test_analytics(true);
for i in 0..8 {
analytics.dex_pairs.push(DexPair {
dex_name: format!("DEX {}", i),
pair_address: format!("0xpair{}", i),
base_token: "USDC".to_string(),
quote_token: "WETH".to_string(),
price_usd: 0.9999,
volume_24h: 1_000_000.0 - (i as f64 * 100_000.0),
liquidity_usd: 10_000_000.0 - (i as f64 * 1_000_000.0),
liquidity_base: None,
price_change_24h: 0.0,
buys_24h: 100,
sells_24h: 90,
buys_6h: 30,
sells_6h: 25,
buys_1h: 5,
sells_1h: 4,
pair_created_at: None,
url: None,
});
}
let args = make_test_crawl_args();
let result = output_table_with_dex(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_crawl_args_with_report() {
use clap::Parser;
#[derive(Parser)]
struct TestCli {
#[command(flatten)]
crawl: CrawlArgs,
}
let cli = TestCli::try_parse_from([
"test",
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"--report",
"output.md",
])
.unwrap();
assert_eq!(
cli.crawl.report,
Some(std::path::PathBuf::from("output.md"))
);
}
#[test]
fn test_crawl_args_with_chain_and_period() {
use clap::Parser;
#[derive(Parser)]
struct TestCli {
#[command(flatten)]
crawl: CrawlArgs,
}
let cli = TestCli::try_parse_from([
"test",
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"--chain",
"polygon",
"--period",
"7d",
"--no-charts",
"--yes",
"--save",
])
.unwrap();
assert_eq!(cli.crawl.chain, "polygon");
assert!(matches!(cli.crawl.period, Period::Day7));
assert!(cli.crawl.no_charts);
assert!(cli.crawl.yes);
assert!(cli.crawl.save);
}
#[test]
fn test_crawl_args_all_periods() {
use clap::Parser;
#[derive(Parser)]
struct TestCli {
#[command(flatten)]
crawl: CrawlArgs,
}
for (period_str, expected) in [
("1h", Period::Hour1),
("24h", Period::Hour24),
("7d", Period::Day7),
("30d", Period::Day30),
] {
let cli = TestCli::try_parse_from(["test", "token", "--period", period_str]).unwrap();
assert_eq!(cli.crawl.period.as_seconds(), expected.as_seconds());
}
}
#[test]
fn test_analytics_json_serialization() {
let analytics = make_test_analytics(true);
let json = serde_json::to_string(&analytics).unwrap();
assert!(json.contains("USDC"));
assert!(json.contains("USD Coin"));
assert!(json.contains("ethereum"));
assert!(json.contains("0.9999"));
}
#[test]
fn test_analytics_json_no_optional_fields() {
let mut analytics = make_test_analytics(false);
analytics.market_cap = None;
analytics.fdv = None;
analytics.total_supply = None;
analytics.top_10_concentration = None;
analytics.top_50_concentration = None;
analytics.top_100_concentration = None;
analytics.token_age_hours = None;
analytics.dexscreener_url = None;
let json = serde_json::to_string(&analytics).unwrap();
assert!(!json.contains("market_cap"));
assert!(!json.contains("fdv"));
}
use scope::chains::mocks::{MockClientFactory, MockDexSource};
fn mock_factory_for_crawl() -> MockClientFactory {
let mut factory = MockClientFactory::new();
factory.mock_dex = MockDexSource::new();
factory
}
#[tokio::test]
async fn test_run_crawl_json_output() {
let config = Config::default();
let factory = mock_factory_for_crawl();
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 5,
format: OutputFormat::Json,
no_charts: true,
report: None,
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_crawl_table_output() {
let config = Config::default();
let factory = mock_factory_for_crawl();
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 5,
format: OutputFormat::Table,
no_charts: true,
report: None,
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_crawl_csv_output() {
let config = Config::default();
let factory = mock_factory_for_crawl();
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 5,
format: OutputFormat::Csv,
no_charts: true,
report: None,
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_json_with_a_report_file_keeps_stdout_pure_json() {
let dir = tempfile::tempdir().unwrap();
let report_path = dir.path().join("r.md");
let factory = MockClientFactory::new();
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 5,
format: OutputFormat::Json,
no_charts: true,
report: Some(report_path.clone()),
yes: true,
save: false,
};
let (out, cap) = crate::cli::output::Output::capture();
super::run(args, &Config::default(), &factory, &out)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&cap.out())
.unwrap_or_else(|e| panic!("stdout is not JSON ({e}): {}", cap.out()));
assert!(json.is_object());
assert!(report_path.exists());
assert!(cap.err().contains("Report saved to: "), "{}", cap.err());
}
#[tokio::test]
async fn test_run_crawl_symbol_resolution_via_factory_dex() {
let mut factory = MockClientFactory::new();
factory.mock_dex.search_results = vec![TokenSearchResult {
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
symbol: "MOCK".to_string(),
name: "Mock Token".to_string(),
chain: "ethereum".to_string(),
price_usd: Some(1.0),
volume_24h: 1_000_000.0,
liquidity_usd: 5_000_000.0,
market_cap: Some(100_000_000.0),
}];
if let Some(ref mut td) = factory.mock_dex.token_data {
td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
}
let config = Config::default();
let args = CrawlArgs {
token: "MOCK".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 5,
format: OutputFormat::Json,
no_charts: true,
report: None,
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_crawl_no_dex_data_evm() {
let config = Config::default();
let mut factory = MockClientFactory::new();
factory.mock_dex.token_data = None; factory.mock_client.token_info = Some(Token {
contract_address: "0xtoken".to_string(),
symbol: "TEST".to_string(),
name: "Test Token".to_string(),
decimals: 18,
});
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 5,
format: OutputFormat::Json,
no_charts: true,
report: None,
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_crawl_table_no_charts() {
let config = Config::default();
let factory = mock_factory_for_crawl();
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 5,
format: OutputFormat::Table,
no_charts: true,
report: None,
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_crawl_with_charts() {
let config = Config::default();
let factory = mock_factory_for_crawl();
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour1,
holders_limit: 5,
format: OutputFormat::Table,
no_charts: false, report: None,
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_crawl_day7_period() {
let config = Config::default();
let factory = mock_factory_for_crawl();
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
period: Period::Day7,
holders_limit: 5,
format: OutputFormat::Table,
no_charts: true,
report: None,
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_crawl_markdown_output() {
let config = Config::default();
let factory = mock_factory_for_crawl();
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 5,
format: OutputFormat::Markdown,
no_charts: true,
report: None,
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_crawl_unsupported_chain_no_dex() {
let mut factory = MockClientFactory::new();
factory.mock_dex.token_data = None;
factory.mock_dex.search_results = vec![];
let config = Config::default();
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "avalanche".to_string(), period: Period::Hour24,
holders_limit: 5,
format: OutputFormat::Json,
no_charts: true,
report: None,
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("avalanche")
|| err_str.contains("block explorer")
|| err_str.contains("No DEX"),
"Expected error about unsupported chain, got: {}",
err_str
);
}
#[tokio::test]
async fn test_run_crawl_day30_period() {
let config = Config::default();
let factory = mock_factory_for_crawl();
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
period: Period::Day30,
holders_limit: 5,
format: OutputFormat::Table,
no_charts: true,
report: None,
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_ok());
}
#[test]
fn test_output_table_with_dex_with_charts() {
let analytics = make_test_analytics(true);
let mut args = make_test_crawl_args();
args.no_charts = false; let result = output_table_with_dex(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_explorer_short_addresses() {
let mut analytics = make_test_analytics(false);
analytics.holders = vec![TokenHolder {
address: "0xshort".to_string(), balance: "100".to_string(),
formatted_balance: "100".to_string(),
percentage: 1.0,
rank: 1,
}];
let result = output_table_explorer_only(&analytics, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_csv_with_all_fields() {
let analytics = make_test_analytics(true);
let result = output_csv(&analytics, &quiet());
assert!(result.is_ok());
}
#[tokio::test]
async fn test_fetch_analytics_for_input() {
let factory = mock_factory_for_crawl();
let result = super::fetch_analytics_for_input(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"ethereum",
Period::Hour24,
5,
&factory,
None,
&quiet(),
)
.await;
assert!(result.is_ok());
let analytics = result.unwrap();
assert_eq!(analytics.chain, "ethereum");
assert!(!analytics.token.contract_address.is_empty());
}
#[tokio::test]
async fn test_run_crawl_chain_without_holder_support() {
let mut factory = mock_factory_for_crawl();
if let Some(ref mut td) = factory.mock_dex.token_data {
td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
}
let config = Config::default();
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "fantom".to_string(),
period: Period::Hour24,
holders_limit: 5,
format: OutputFormat::Json,
no_charts: true,
report: None,
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_crawl_with_report() {
let config = Config::default();
let factory = mock_factory_for_crawl();
let tmp = tempfile::NamedTempFile::new().unwrap();
let args = CrawlArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 5,
format: OutputFormat::Table,
no_charts: true,
report: Some(tmp.path().to_path_buf()),
yes: true,
save: false,
};
let result = super::run(args, &config, &factory, &quiet()).await;
assert!(result.is_ok());
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("Token Analysis Report"));
}
#[test]
fn test_output_table_explorer_long_address_truncation() {
let mut analytics = make_test_analytics(false);
analytics.holders = vec![TokenHolder {
address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
balance: "1000000".to_string(),
formatted_balance: "1,000,000".to_string(),
percentage: 50.0,
rank: 1,
}];
let result = output_table_explorer_only(&analytics, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_with_dex_empty_pairs() {
let mut analytics = make_test_analytics(true);
analytics.dex_pairs = vec![];
let args = make_test_crawl_args();
let result = output_table_with_dex(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_explorer_no_concentration() {
let mut analytics = make_test_analytics(false);
analytics.top_10_concentration = None;
analytics.top_50_concentration = None;
analytics.top_100_concentration = None;
let result = output_table_explorer_only(&analytics, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_with_dex_top_10_only() {
let mut analytics = make_test_analytics(true);
analytics.top_10_concentration = Some(25.0);
analytics.top_50_concentration = None;
analytics.top_100_concentration = None;
let args = make_test_crawl_args();
let result = output_table_with_dex(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_with_dex_top_100_concentration() {
let mut analytics = make_test_analytics(true);
analytics.top_10_concentration = Some(20.0);
analytics.top_50_concentration = Some(45.0);
analytics.top_100_concentration = Some(65.0);
let args = make_test_crawl_args();
let result = output_table_with_dex(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_csv_with_market_cap_and_fdv() {
let mut analytics = make_test_analytics(true);
analytics.market_cap = Some(1_000_000_000.0);
analytics.fdv = Some(1_500_000_000.0);
let result = output_csv(&analytics, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_routing_has_dex_data() {
let analytics = make_test_analytics(true);
assert!(analytics.price_usd > 0.0);
let args = make_test_crawl_args();
let result = output_table(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_routing_no_dex_data() {
let analytics = make_test_analytics(false);
assert_eq!(analytics.price_usd, 0.0);
let args = make_test_crawl_args();
let result = output_table(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_format_large_number_negative() {
let result = scope::display::format_large_number(-1_000_000.0);
assert!(result.contains("M") || result.contains("-"));
}
#[test]
fn test_select_token_auto_select() {
let results = vec![TokenSearchResult {
address: "0xtoken".to_string(),
symbol: "TKN".to_string(),
name: "Test Token".to_string(),
chain: "ethereum".to_string(),
price_usd: Some(10.0),
volume_24h: 100000.0,
liquidity_usd: 500000.0,
market_cap: Some(1000000.0),
}];
let selected = select_token(&results, true, None, &quiet()).unwrap();
assert_eq!(selected.symbol, "TKN");
}
#[test]
fn test_select_token_single_result() {
let results = vec![TokenSearchResult {
address: "0xtoken".to_string(),
symbol: "SINGLE".to_string(),
name: "Single Token".to_string(),
chain: "ethereum".to_string(),
price_usd: None,
volume_24h: 0.0,
liquidity_usd: 0.0,
market_cap: None,
}];
let selected = select_token(&results, false, None, &quiet()).unwrap();
assert_eq!(selected.symbol, "SINGLE");
}
#[test]
fn test_output_table_with_dex_with_holders() {
let mut analytics = make_test_analytics(true);
analytics.holders = vec![
TokenHolder {
address: "0xholder1".to_string(),
balance: "1000000".to_string(),
formatted_balance: "1,000,000".to_string(),
percentage: 30.0,
rank: 1,
},
TokenHolder {
address: "0xholder2".to_string(),
balance: "500000".to_string(),
formatted_balance: "500,000".to_string(),
percentage: 15.0,
rank: 2,
},
];
let args = make_test_crawl_args();
let result = output_table_with_dex(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_json() {
let analytics = make_test_analytics(true);
let result = serde_json::to_string_pretty(&analytics);
assert!(result.is_ok());
}
fn make_search_results() -> Vec<TokenSearchResult> {
vec![
TokenSearchResult {
symbol: "USDC".to_string(),
name: "USD Coin".to_string(),
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
price_usd: Some(1.0),
volume_24h: 1_000_000.0,
liquidity_usd: 500_000_000.0,
market_cap: Some(30_000_000_000.0),
},
TokenSearchResult {
symbol: "USDC".to_string(),
name: "USD Coin on Polygon".to_string(),
address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
chain: "polygon".to_string(),
price_usd: Some(0.9999),
volume_24h: 500_000.0,
liquidity_usd: 100_000_000.0,
market_cap: None,
},
TokenSearchResult {
symbol: "USDC".to_string(),
name: "Very Long Token Name That Should Be Truncated To Fit".to_string(),
address: "0x1234567890abcdef".to_string(),
chain: "arbitrum".to_string(),
price_usd: None,
volume_24h: 0.0,
liquidity_usd: 50_000.0,
market_cap: None,
},
]
}
#[test]
fn test_select_token_impl_auto_select_multi() {
let results = make_search_results();
let (out, cap) = crate::cli::output::Output::capture();
let mut reader = std::io::Cursor::new(b"" as &[u8]);
let selected = select_token_impl(&results, true, &mut reader, None, &out).unwrap();
assert_eq!(selected.symbol, "USDC");
assert_eq!(selected.chain, "ethereum");
let output = cap.err();
assert!(output.contains("Selected:"));
}
#[test]
fn test_select_token_impl_single_result() {
let results = vec![make_search_results().remove(0)];
let out = crate::cli::output::Output::capture().0;
let mut reader = std::io::Cursor::new(b"" as &[u8]);
let selected = select_token_impl(&results, false, &mut reader, None, &out).unwrap();
assert_eq!(selected.symbol, "USDC");
}
#[test]
fn test_select_token_user_selects_second() {
let results = make_search_results();
let input = b"2\n";
let mut reader = std::io::Cursor::new(&input[..]);
let (out, cap) = crate::cli::output::Output::capture();
let selected = select_token_impl(&results, false, &mut reader, None, &out).unwrap();
assert_eq!(selected.chain, "polygon");
let output = cap.err();
assert!(output.contains("Found 3 matching tokens"));
assert!(output.contains("USDC"));
}
#[test]
fn test_select_token_shows_address_column() {
let results = make_search_results();
let input = b"1\n";
let mut reader = std::io::Cursor::new(&input[..]);
let (out, cap) = crate::cli::output::Output::capture();
select_token_impl(&results, false, &mut reader, None, &out).unwrap();
let output = cap.err();
assert!(output.contains("Address"));
assert!(output.contains("0xA0b8…eB48"));
assert!(output.contains("0x2791…4174"));
}
#[test]
fn test_select_token_user_selects_third() {
let results = make_search_results();
let input = b"3\n";
let mut reader = std::io::Cursor::new(&input[..]);
let (out, cap) = crate::cli::output::Output::capture();
let selected = select_token_impl(&results, false, &mut reader, None, &out).unwrap();
assert_eq!(selected.chain, "arbitrum");
let output = cap.err();
let row = output
.lines()
.find(|l| l.trim_start().starts_with("3 "))
.unwrap();
assert!(row.contains("Very Long Token Nam… "), "{row}");
}
#[test]
fn test_select_token_invalid_input() {
let results = make_search_results();
let input = b"abc\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, false, &mut reader, None, &out);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid selection")
);
}
#[test]
fn test_select_token_out_of_range_zero() {
let results = make_search_results();
let input = b"0\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, false, &mut reader, None, &out);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Selection must be between")
);
}
#[test]
fn test_select_token_out_of_range_high() {
let results = make_search_results();
let input = b"99\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, false, &mut reader, None, &out);
assert!(result.is_err());
}
#[test]
fn test_prompt_save_alias_yes() {
let input = b"y\n";
let mut reader = std::io::Cursor::new(&input[..]);
let (out, cap) = crate::cli::output::Output::capture();
assert!(prompt_save_alias_impl(&mut reader, &out));
let output = cap.err();
assert!(output.contains("Save this token"));
}
#[test]
fn test_prompt_save_alias_yes_full() {
let input = b"yes\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
assert!(prompt_save_alias_impl(&mut reader, &out));
}
#[test]
fn test_prompt_save_alias_no() {
let input = b"n\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
assert!(!prompt_save_alias_impl(&mut reader, &out));
}
#[test]
fn test_prompt_save_alias_empty() {
let input = b"\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
assert!(!prompt_save_alias_impl(&mut reader, &out));
}
#[test]
fn test_prompt_save_alias_uppercase_y() {
let input = b"Y\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
assert!(prompt_save_alias_impl(&mut reader, &out));
}
#[test]
fn test_prompt_save_alias_uppercase_yes() {
let input = b"YES\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
assert!(prompt_save_alias_impl(&mut reader, &out));
}
#[test]
fn test_prompt_save_alias_other_input() {
let input = b"maybe\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
assert!(!prompt_save_alias_impl(&mut reader, &out));
}
struct FailingWriter;
impl std::io::Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("write failed"))
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::Error::other("flush failed"))
}
}
struct FailingReader;
impl std::io::Read for FailingReader {
fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("read failed"))
}
}
impl std::io::BufRead for FailingReader {
fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
Err(std::io::Error::other("read failed"))
}
fn consume(&mut self, _amt: usize) {}
}
#[test]
fn test_prompt_save_alias_impl_write_fails() {
let input = b"y\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::from_writers(Vec::new(), FailingWriter);
assert!(!prompt_save_alias_impl(&mut reader, &out));
}
#[test]
fn test_prompt_save_alias_impl_read_fails() {
let mut reader = FailingReader;
let out = crate::cli::output::Output::capture().0;
assert!(!prompt_save_alias_impl(&mut reader, &out));
}
#[test]
fn test_output_csv_no_panic() {
let analytics = create_test_analytics_minimal();
let result = output_csv(&analytics, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_no_dex_data() {
let analytics = create_test_analytics_minimal();
let args = CrawlArgs {
token: "0xtest".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 10,
format: OutputFormat::Table,
no_charts: true,
report: None,
yes: false,
save: false,
};
let result = output_table(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_with_dex_data_no_charts() {
let mut analytics = create_test_analytics_minimal();
analytics.price_usd = 1.0;
analytics.volume_24h = 1_000_000.0;
analytics.liquidity_usd = 500_000.0;
analytics.market_cap = Some(1_000_000_000.0);
analytics.fdv = Some(2_000_000_000.0);
let args = CrawlArgs {
token: "0xtest".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 10,
format: OutputFormat::Table,
no_charts: true,
report: None,
yes: false,
save: false,
};
let result = output_table(&analytics, &args, &quiet());
assert!(result.is_ok());
}
#[test]
fn test_output_table_with_dex_data_and_charts() {
let mut analytics = create_test_analytics_minimal();
analytics.price_usd = 1.0;
analytics.volume_24h = 1_000_000.0;
analytics.liquidity_usd = 500_000.0;
analytics.price_history = vec![
scope::chains::PricePoint {
timestamp: 1,
price: 0.99,
},
scope::chains::PricePoint {
timestamp: 2,
price: 1.01,
},
];
analytics.volume_history = vec![
scope::chains::VolumePoint {
timestamp: 1,
volume: 50000.0,
},
scope::chains::VolumePoint {
timestamp: 2,
volume: 60000.0,
},
];
let args = CrawlArgs {
token: "0xtest".to_string(),
chain: "ethereum".to_string(),
period: Period::Hour24,
holders_limit: 10,
format: OutputFormat::Table,
no_charts: false,
report: None,
yes: false,
save: false,
};
let result = output_table(&analytics, &args, &quiet());
assert!(result.is_ok());
}
fn create_test_analytics_minimal() -> TokenAnalytics {
TokenAnalytics {
token: Token {
contract_address: "0xtest".to_string(),
symbol: "TEST".to_string(),
name: "Test Token".to_string(),
decimals: 18,
},
chain: "ethereum".to_string(),
holders: Vec::new(),
total_holders: 0,
volume_24h: 0.0,
volume_7d: 0.0,
price_usd: 0.0,
price_change_24h: 0.0,
price_change_7d: 0.0,
liquidity_usd: 0.0,
market_cap: None,
fdv: None,
total_supply: None,
circulating_supply: None,
price_history: Vec::new(),
volume_history: Vec::new(),
holder_history: Vec::new(),
dex_pairs: Vec::new(),
fetched_at: 0,
top_10_concentration: None,
top_50_concentration: None,
top_100_concentration: None,
price_change_6h: 0.0,
price_change_1h: 0.0,
total_buys_24h: 0,
total_sells_24h: 0,
total_buys_6h: 0,
total_sells_6h: 0,
total_buys_1h: 0,
total_sells_1h: 0,
token_age_hours: None,
image_url: None,
websites: Vec::new(),
socials: Vec::new(),
dexscreener_url: None,
}
}
}