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)]
27pub struct DiscoverArgs {
28    /// Discovery source: profiles (featured), boosts (recent), top-boosts (most active)
29    #[arg(short, long, default_value = "profiles")]
30    pub source: DiscoverSource,
31
32    /// Filter by chain (e.g., ethereum, solana). Omit for all chains.
33    #[arg(short, long)]
34    pub chain: Option<String>,
35
36    /// Maximum number of tokens to show
37    #[arg(short, long, default_value = "15")]
38    pub limit: u32,
39
40    /// Output format
41    #[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
53/// Run the discover command.
54pub async fn run(args: DiscoverArgs, format: OutputFormat) -> Result<()> {
55    run_with_client(args, format, &DexClient::new()).await
56}
57
58/// Run the discover command with a provided DEX client (for testing).
59pub 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}