Skip to main content

scope/cli/
discover.rs

1//! # Discover Command
2//!
3//! Browse trending and boosted tokens from DexScreener.
4
5use crate::chains::{DexClient, DiscoverToken};
6use crate::config::OutputFormat;
7use crate::error::Result;
8use clap::{Args, ValueEnum};
9use serde::Serialize;
10
11/// Source for token discovery.
12#[derive(Debug, Clone, Copy, ValueEnum, Default)]
13pub enum DiscoverSource {
14    /// Featured token profiles
15    #[default]
16    Profiles,
17
18    /// Recently boosted tokens
19    Boosts,
20
21    /// Top boosted tokens (most active)
22    TopBoosts,
23}
24
25/// Arguments for the discover command.
26#[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    /// Discovery source: profiles (featured), boosts (recent), top-boosts (most active)
33    #[arg(short, long, default_value = "profiles")]
34    pub source: DiscoverSource,
35
36    /// Filter by chain (e.g., ethereum, solana). Omit for all chains.
37    #[arg(short, long)]
38    pub chain: Option<String>,
39
40    /// Maximum number of tokens to show
41    #[arg(short, long, default_value = "15")]
42    pub limit: u32,
43
44    /// Output format
45    #[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
57/// Run the discover command.
58pub async fn run(args: DiscoverArgs, format: OutputFormat) -> Result<()> {
59    run_with_client(args, format, &DexClient::new()).await
60}
61
62/// Run the discover command with a provided DEX client (for testing).
63pub 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}