1use crate::cli::output::Output;
6use crate::outln;
7use clap::{Args, ValueEnum};
8use scope::chains::{DexClient, DiscoverToken};
9use scope::config::OutputFormat;
10use scope::error::Result;
11use serde::Serialize;
12
13#[derive(Debug, Clone, Copy, ValueEnum, Default)]
15pub enum DiscoverSource {
16 #[default]
18 Profiles,
19
20 Boosts,
22
23 TopBoosts,
25}
26
27#[derive(Debug, Args)]
29#[command(after_help = "\x1b[1mExamples:\x1b[0m
30 scope discover
31 scope discover --source boosts --chain solana
32 scope discover --source top-boosts --limit 30 --format json")]
33pub struct DiscoverArgs {
34 #[arg(short, long, default_value = "profiles")]
36 pub source: DiscoverSource,
37
38 #[arg(short, long)]
40 pub chain: Option<String>,
41
42 #[arg(short, long, default_value = "15")]
44 pub limit: u32,
45
46 #[arg(short, long)]
48 pub format: Option<OutputFormat>,
49}
50
51#[derive(Serialize)]
52struct DiscoverRow {
53 chain: String,
54 address: String,
55 description: Option<String>,
56 url: String,
57}
58
59pub async fn run(args: DiscoverArgs, format: OutputFormat, out: &Output) -> Result<()> {
61 run_with_client(args, format, &DexClient::new(), out).await
62}
63
64pub async fn run_with_client(
66 args: DiscoverArgs,
67 format: OutputFormat,
68 client: &DexClient,
69 out: &Output,
70) -> Result<()> {
71 let sp = crate::cli::progress::Spinner::new(
72 &format!(
73 "Discovering {} tokens...",
74 match args.source {
75 DiscoverSource::Profiles => "featured",
76 DiscoverSource::Boosts => "boosted",
77 DiscoverSource::TopBoosts => "top boosted",
78 }
79 ),
80 out,
81 );
82
83 let tokens = match args.source {
84 DiscoverSource::Profiles => client.get_token_profiles().await?,
85 DiscoverSource::Boosts => client.get_token_boosts().await?,
86 DiscoverSource::TopBoosts => client.get_token_boosts_top().await?,
87 };
88
89 sp.finish("Tokens loaded.");
90
91 let filtered: Vec<DiscoverToken> = if let Some(ref chain) = args.chain {
92 let c = chain.to_lowercase();
93 tokens
94 .into_iter()
95 .filter(|t| t.chain_id.to_lowercase() == c)
96 .take(args.limit as usize)
97 .collect()
98 } else {
99 tokens.into_iter().take(args.limit as usize).collect()
100 };
101
102 if filtered.is_empty() {
103 outln!(out, "No tokens found.")?;
104 return Ok(());
105 }
106
107 let output_format = args.format.unwrap_or(format);
108
109 match output_format {
110 OutputFormat::Json => {
111 let rows: Vec<DiscoverRow> = filtered
112 .iter()
113 .map(|t| DiscoverRow {
114 chain: t.chain_id.clone(),
115 address: t.token_address.clone(),
116 description: t.description.clone(),
117 url: t.url.clone(),
118 })
119 .collect();
120 outln!(out, "{}", serde_json::to_string_pretty(&rows)?)?;
121 }
122 OutputFormat::Table | OutputFormat::Markdown => {
123 use scope::display::terminal as t;
124
125 let title = format!(
126 "{} ({})",
127 match args.source {
128 DiscoverSource::Profiles => "Featured Token Profiles",
129 DiscoverSource::Boosts => "Recently Boosted Tokens",
130 DiscoverSource::TopBoosts => "Top Boosted Tokens",
131 },
132 filtered.len()
133 );
134 outln!(out, "{}", t::section_header(&title))?;
135 outln!(out, "{}", t::kv_row("Results", &filtered.len().to_string()))?;
136
137 for (i, t) in filtered.iter().enumerate() {
138 let desc = t.description.as_deref().unwrap_or("-");
139 let row_text = format!(
140 "{} | {} | {}",
141 t.chain_id,
142 scope::display::text::abbreviate(&t.token_address),
143 desc
144 );
145 outln!(out, "{}", t::numbered_row(i + 1, &row_text))?;
146 outln!(out, "{}", t::detail_row(&t.url))?;
147 }
148
149 outln!(out, "{}", t::section_footer())?;
150 }
151 OutputFormat::Csv => {
152 outln!(out, "chain,address,description,url")?;
153 for t in &filtered {
154 let desc = t
155 .description
156 .as_ref()
157 .map(|d| d.replace(',', ";").replace('\n', " "))
158 .unwrap_or_else(|| "-".to_string());
159 outln!(
160 out,
161 "{},{},\"{}\",{}",
162 t.chain_id,
163 t.token_address,
164 desc,
165 t.url
166 )?;
167 }
168 }
169 }
170
171 Ok(())
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use scope::chains::DexClient;
178 use scope::config::OutputFormat;
179
180 fn quiet() -> crate::cli::output::Output {
181 crate::cli::output::Output::capture().0
182 }
183
184 fn discover_json_body() -> String {
185 r#"[
186 {
187 "chainId": "ethereum",
188 "tokenAddress": "0x1234567890123456789012345678901234567890",
189 "url": "https://dexscreener.com/ethereum/0x1234",
190 "description": "A test token"
191 },
192 {
193 "chainId": "solana",
194 "tokenAddress": "So11111111111111111111111111111111111111112",
195 "url": "https://dexscreener.com/solana/So11",
196 "description": null
197 }
198 ]"#
199 .to_string()
200 }
201
202 #[tokio::test]
203 async fn test_discover_profiles_table() {
204 let mut server = mockito::Server::new_async().await;
205 let _mock = server
206 .mock("GET", "/token-profiles/latest/v1")
207 .with_status(200)
208 .with_header("content-type", "application/json")
209 .with_body(discover_json_body())
210 .create_async()
211 .await;
212
213 let client = DexClient::with_base_url(&server.url());
214 let args = DiscoverArgs {
215 source: DiscoverSource::Profiles,
216 chain: None,
217 limit: 15,
218 format: None,
219 };
220 let result = run_with_client(args, OutputFormat::Table, &client, &quiet()).await;
221 assert!(result.is_ok());
222 }
223
224 #[tokio::test]
225 async fn test_discover_boosts_with_chain_filter() {
226 let mut server = mockito::Server::new_async().await;
227 let _mock = server
228 .mock("GET", "/token-boosts/latest/v1")
229 .with_status(200)
230 .with_header("content-type", "application/json")
231 .with_body(discover_json_body())
232 .create_async()
233 .await;
234
235 let client = DexClient::with_base_url(&server.url());
236 let args = DiscoverArgs {
237 source: DiscoverSource::Boosts,
238 chain: Some("ethereum".to_string()),
239 limit: 5,
240 format: None,
241 };
242 let result = run_with_client(args, OutputFormat::Table, &client, &quiet()).await;
243 assert!(result.is_ok());
244 }
245
246 #[tokio::test]
247 async fn test_discover_top_boosts_json() {
248 let mut server = mockito::Server::new_async().await;
249 let _mock = server
250 .mock("GET", "/token-boosts/top/v1")
251 .with_status(200)
252 .with_header("content-type", "application/json")
253 .with_body(discover_json_body())
254 .create_async()
255 .await;
256
257 let client = DexClient::with_base_url(&server.url());
258 let args = DiscoverArgs {
259 source: DiscoverSource::TopBoosts,
260 chain: None,
261 limit: 10,
262 format: Some(OutputFormat::Json),
263 };
264 let result = run_with_client(args, OutputFormat::Json, &client, &quiet()).await;
265 assert!(result.is_ok());
266 }
267
268 #[tokio::test]
269 async fn test_discover_empty_response() {
270 let mut server = mockito::Server::new_async().await;
271 let _mock = server
272 .mock("GET", "/token-profiles/latest/v1")
273 .with_status(200)
274 .with_header("content-type", "application/json")
275 .with_body("[]")
276 .create_async()
277 .await;
278
279 let client = DexClient::with_base_url(&server.url());
280 let args = DiscoverArgs {
281 source: DiscoverSource::Profiles,
282 chain: None,
283 limit: 15,
284 format: None,
285 };
286 let result = run_with_client(args, OutputFormat::Table, &client, &quiet()).await;
287 assert!(result.is_ok());
288 }
289
290 #[tokio::test]
291 async fn test_discover_csv_format() {
292 let mut server = mockito::Server::new_async().await;
293 let _mock = server
294 .mock("GET", "/token-profiles/latest/v1")
295 .with_status(200)
296 .with_header("content-type", "application/json")
297 .with_body(discover_json_body())
298 .create_async()
299 .await;
300
301 let client = DexClient::with_base_url(&server.url());
302 let args = DiscoverArgs {
303 source: DiscoverSource::Profiles,
304 chain: None,
305 limit: 15,
306 format: Some(OutputFormat::Csv),
307 };
308 let result = run_with_client(args, OutputFormat::Csv, &client, &quiet()).await;
309 assert!(result.is_ok());
310 }
311
312 #[tokio::test]
313 async fn test_discover_api_error() {
314 let mut server = mockito::Server::new_async().await;
315 let _mock = server
316 .mock("GET", "/token-profiles/latest/v1")
317 .with_status(500)
318 .create_async()
319 .await;
320
321 let client = DexClient::with_base_url(&server.url());
322 let args = DiscoverArgs {
323 source: DiscoverSource::Profiles,
324 chain: None,
325 limit: 15,
326 format: None,
327 };
328 let result = run_with_client(args, OutputFormat::Table, &client, &quiet()).await;
329 assert!(result.is_err());
330 }
331}