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