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