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