use crate::cli::output::Output;
use crate::outln;
use clap::{Args, ValueEnum};
use scope::chains::{DexClient, DiscoverToken};
use scope::config::OutputFormat;
use scope::error::Result;
use serde::Serialize;
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
pub enum DiscoverSource {
#[default]
Profiles,
Boosts,
TopBoosts,
}
#[derive(Debug, Args)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
scope discover
scope discover --source boosts --chain solana
scope discover --source top-boosts --limit 30 --format json")]
pub struct DiscoverArgs {
#[arg(short, long, default_value = "profiles")]
pub source: DiscoverSource,
#[arg(short, long)]
pub chain: Option<String>,
#[arg(short, long, default_value = "15")]
pub limit: u32,
#[arg(short, long)]
pub format: Option<OutputFormat>,
}
#[derive(Serialize)]
struct DiscoverRow {
chain: String,
address: String,
description: Option<String>,
url: String,
}
pub async fn run(args: DiscoverArgs, format: OutputFormat, out: &Output) -> Result<()> {
run_with_client(args, format, &DexClient::new(), out).await
}
pub async fn run_with_client(
args: DiscoverArgs,
format: OutputFormat,
client: &DexClient,
out: &Output,
) -> Result<()> {
let sp = crate::cli::progress::Spinner::new(
&format!(
"Discovering {} tokens...",
match args.source {
DiscoverSource::Profiles => "featured",
DiscoverSource::Boosts => "boosted",
DiscoverSource::TopBoosts => "top boosted",
}
),
out,
);
let tokens = match args.source {
DiscoverSource::Profiles => client.get_token_profiles().await?,
DiscoverSource::Boosts => client.get_token_boosts().await?,
DiscoverSource::TopBoosts => client.get_token_boosts_top().await?,
};
sp.finish("Tokens loaded.");
let filtered: Vec<DiscoverToken> = if let Some(ref chain) = args.chain {
let c = chain.to_lowercase();
tokens
.into_iter()
.filter(|t| t.chain_id.to_lowercase() == c)
.take(args.limit as usize)
.collect()
} else {
tokens.into_iter().take(args.limit as usize).collect()
};
if filtered.is_empty() {
outln!(out, "No tokens found.")?;
return Ok(());
}
let output_format = args.format.unwrap_or(format);
match output_format {
OutputFormat::Json => {
let rows: Vec<DiscoverRow> = filtered
.iter()
.map(|t| DiscoverRow {
chain: t.chain_id.clone(),
address: t.token_address.clone(),
description: t.description.clone(),
url: t.url.clone(),
})
.collect();
outln!(out, "{}", serde_json::to_string_pretty(&rows)?)?;
}
OutputFormat::Table | OutputFormat::Markdown => {
use scope::display::terminal as t;
let title = format!(
"{} ({})",
match args.source {
DiscoverSource::Profiles => "Featured Token Profiles",
DiscoverSource::Boosts => "Recently Boosted Tokens",
DiscoverSource::TopBoosts => "Top Boosted Tokens",
},
filtered.len()
);
outln!(out, "{}", t::section_header(&title))?;
outln!(out, "{}", t::kv_row("Results", &filtered.len().to_string()))?;
for (i, t) in filtered.iter().enumerate() {
let desc = t.description.as_deref().unwrap_or("-");
let row_text = format!(
"{} | {} | {}",
t.chain_id,
scope::display::text::abbreviate(&t.token_address),
desc
);
outln!(out, "{}", t::numbered_row(i + 1, &row_text))?;
outln!(out, "{}", t::detail_row(&t.url))?;
}
outln!(out, "{}", t::section_footer())?;
}
OutputFormat::Csv => {
outln!(out, "chain,address,description,url")?;
for t in &filtered {
let desc = t
.description
.as_ref()
.map(|d| d.replace(',', ";").replace('\n', " "))
.unwrap_or_else(|| "-".to_string());
outln!(
out,
"{},{},\"{}\",{}",
t.chain_id,
t.token_address,
desc,
t.url
)?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use scope::chains::DexClient;
use scope::config::OutputFormat;
fn quiet() -> crate::cli::output::Output {
crate::cli::output::Output::capture().0
}
fn discover_json_body() -> String {
r#"[
{
"chainId": "ethereum",
"tokenAddress": "0x1234567890123456789012345678901234567890",
"url": "https://dexscreener.com/ethereum/0x1234",
"description": "A test token"
},
{
"chainId": "solana",
"tokenAddress": "So11111111111111111111111111111111111111112",
"url": "https://dexscreener.com/solana/So11",
"description": null
}
]"#
.to_string()
}
#[tokio::test]
async fn test_discover_profiles_table() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/token-profiles/latest/v1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(discover_json_body())
.create_async()
.await;
let client = DexClient::with_base_url(&server.url());
let args = DiscoverArgs {
source: DiscoverSource::Profiles,
chain: None,
limit: 15,
format: None,
};
let result = run_with_client(args, OutputFormat::Table, &client, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_discover_boosts_with_chain_filter() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/token-boosts/latest/v1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(discover_json_body())
.create_async()
.await;
let client = DexClient::with_base_url(&server.url());
let args = DiscoverArgs {
source: DiscoverSource::Boosts,
chain: Some("ethereum".to_string()),
limit: 5,
format: None,
};
let result = run_with_client(args, OutputFormat::Table, &client, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_discover_top_boosts_json() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/token-boosts/top/v1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(discover_json_body())
.create_async()
.await;
let client = DexClient::with_base_url(&server.url());
let args = DiscoverArgs {
source: DiscoverSource::TopBoosts,
chain: None,
limit: 10,
format: Some(OutputFormat::Json),
};
let result = run_with_client(args, OutputFormat::Json, &client, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_discover_empty_response() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/token-profiles/latest/v1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body("[]")
.create_async()
.await;
let client = DexClient::with_base_url(&server.url());
let args = DiscoverArgs {
source: DiscoverSource::Profiles,
chain: None,
limit: 15,
format: None,
};
let result = run_with_client(args, OutputFormat::Table, &client, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_discover_csv_format() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/token-profiles/latest/v1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(discover_json_body())
.create_async()
.await;
let client = DexClient::with_base_url(&server.url());
let args = DiscoverArgs {
source: DiscoverSource::Profiles,
chain: None,
limit: 15,
format: Some(OutputFormat::Csv),
};
let result = run_with_client(args, OutputFormat::Csv, &client, &quiet()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_discover_api_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/token-profiles/latest/v1")
.with_status(500)
.create_async()
.await;
let client = DexClient::with_base_url(&server.url());
let args = DiscoverArgs {
source: DiscoverSource::Profiles,
chain: None,
limit: 15,
format: None,
};
let result = run_with_client(args, OutputFormat::Table, &client, &quiet()).await;
assert!(result.is_err());
}
}