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