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 use crate::display::terminal as t;
118
119 let title = format!(
120 "{} ({})",
121 match args.source {
122 DiscoverSource::Profiles => "Featured Token Profiles",
123 DiscoverSource::Boosts => "Recently Boosted Tokens",
124 DiscoverSource::TopBoosts => "Top Boosted Tokens",
125 },
126 filtered.len()
127 );
128 println!("{}", t::section_header(&title));
129 println!("{}", t::kv_row("Results", &filtered.len().to_string()));
130
131 for (i, t) in filtered.iter().enumerate() {
132 let desc = t.description.as_deref().unwrap_or("-");
133 let row_text = format!(
134 "{} | {} | {}",
135 t.chain_id,
136 truncate_address(&t.token_address),
137 desc
138 );
139 println!("{}", t::numbered_row(i + 1, &row_text));
140 println!("{}", t::detail_row(&t.url));
141 }
142
143 println!("{}", t::section_footer());
144 }
145 OutputFormat::Csv => {
146 println!("chain,address,description,url");
147 for t in &filtered {
148 let desc = t
149 .description
150 .as_ref()
151 .map(|d| d.replace(',', ";").replace('\n', " "))
152 .unwrap_or_else(|| "-".to_string());
153 println!("{},{},\"{}\",{}", t.chain_id, t.token_address, desc, t.url);
154 }
155 }
156 }
157
158 Ok(())
159}
160
161fn truncate_address(addr: &str) -> String {
162 if addr.len() > 20 {
163 format!("{}...{}", &addr[..10], &addr[addr.len() - 8..])
164 } else {
165 addr.to_string()
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::chains::DexClient;
173 use crate::config::OutputFormat;
174
175 fn discover_json_body() -> String {
176 r#"[
177 {
178 "chainId": "ethereum",
179 "tokenAddress": "0x1234567890123456789012345678901234567890",
180 "url": "https://dexscreener.com/ethereum/0x1234",
181 "description": "A test token"
182 },
183 {
184 "chainId": "solana",
185 "tokenAddress": "So11111111111111111111111111111111111111112",
186 "url": "https://dexscreener.com/solana/So11",
187 "description": null
188 }
189 ]"#
190 .to_string()
191 }
192
193 #[tokio::test]
194 async fn test_discover_profiles_table() {
195 let mut server = mockito::Server::new_async().await;
196 let _mock = server
197 .mock("GET", "/token-profiles/latest/v1")
198 .with_status(200)
199 .with_header("content-type", "application/json")
200 .with_body(discover_json_body())
201 .create_async()
202 .await;
203
204 let client = DexClient::with_base_url(&server.url());
205 let args = DiscoverArgs {
206 source: DiscoverSource::Profiles,
207 chain: None,
208 limit: 15,
209 format: None,
210 };
211 let result = run_with_client(args, OutputFormat::Table, &client).await;
212 assert!(result.is_ok());
213 }
214
215 #[tokio::test]
216 async fn test_discover_boosts_with_chain_filter() {
217 let mut server = mockito::Server::new_async().await;
218 let _mock = server
219 .mock("GET", "/token-boosts/latest/v1")
220 .with_status(200)
221 .with_header("content-type", "application/json")
222 .with_body(discover_json_body())
223 .create_async()
224 .await;
225
226 let client = DexClient::with_base_url(&server.url());
227 let args = DiscoverArgs {
228 source: DiscoverSource::Boosts,
229 chain: Some("ethereum".to_string()),
230 limit: 5,
231 format: None,
232 };
233 let result = run_with_client(args, OutputFormat::Table, &client).await;
234 assert!(result.is_ok());
235 }
236
237 #[tokio::test]
238 async fn test_discover_top_boosts_json() {
239 let mut server = mockito::Server::new_async().await;
240 let _mock = server
241 .mock("GET", "/token-boosts/top/v1")
242 .with_status(200)
243 .with_header("content-type", "application/json")
244 .with_body(discover_json_body())
245 .create_async()
246 .await;
247
248 let client = DexClient::with_base_url(&server.url());
249 let args = DiscoverArgs {
250 source: DiscoverSource::TopBoosts,
251 chain: None,
252 limit: 10,
253 format: Some(OutputFormat::Json),
254 };
255 let result = run_with_client(args, OutputFormat::Json, &client).await;
256 assert!(result.is_ok());
257 }
258
259 #[tokio::test]
260 async fn test_discover_empty_response() {
261 let mut server = mockito::Server::new_async().await;
262 let _mock = server
263 .mock("GET", "/token-profiles/latest/v1")
264 .with_status(200)
265 .with_header("content-type", "application/json")
266 .with_body("[]")
267 .create_async()
268 .await;
269
270 let client = DexClient::with_base_url(&server.url());
271 let args = DiscoverArgs {
272 source: DiscoverSource::Profiles,
273 chain: None,
274 limit: 15,
275 format: None,
276 };
277 let result = run_with_client(args, OutputFormat::Table, &client).await;
278 assert!(result.is_ok());
279 }
280
281 #[tokio::test]
282 async fn test_discover_csv_format() {
283 let mut server = mockito::Server::new_async().await;
284 let _mock = server
285 .mock("GET", "/token-profiles/latest/v1")
286 .with_status(200)
287 .with_header("content-type", "application/json")
288 .with_body(discover_json_body())
289 .create_async()
290 .await;
291
292 let client = DexClient::with_base_url(&server.url());
293 let args = DiscoverArgs {
294 source: DiscoverSource::Profiles,
295 chain: None,
296 limit: 15,
297 format: Some(OutputFormat::Csv),
298 };
299 let result = run_with_client(args, OutputFormat::Csv, &client).await;
300 assert!(result.is_ok());
301 }
302
303 #[tokio::test]
304 async fn test_discover_api_error() {
305 let mut server = mockito::Server::new_async().await;
306 let _mock = server
307 .mock("GET", "/token-profiles/latest/v1")
308 .with_status(500)
309 .create_async()
310 .await;
311
312 let client = DexClient::with_base_url(&server.url());
313 let args = DiscoverArgs {
314 source: DiscoverSource::Profiles,
315 chain: None,
316 limit: 15,
317 format: None,
318 };
319 let result = run_with_client(args, OutputFormat::Table, &client).await;
320 assert!(result.is_err());
321 }
322
323 #[test]
324 fn test_truncate_address_short() {
325 assert_eq!(truncate_address("0x1234"), "0x1234");
326 }
327
328 #[test]
329 fn test_truncate_address_long() {
330 let addr = "0x1234567890123456789012345678901234567890";
331 assert_eq!(truncate_address(addr), "0x12345678...34567890");
332 }
333}