Skip to main content

scope_cli/cli/
discover.rs

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