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            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}