Skip to main content

gor/cmd/
search.rs

1//! Implementation of the `gor search` subcommand.
2//!
3//! Provides search functionality for repositories, code, issues, and commits.
4
5#![allow(clippy::print_stdout)]
6
7use crate::cli::SearchCommand;
8use crate::client::Client;
9use crate::output::{format_count, format_date, print_json};
10use anyhow::Context;
11use std::fmt::Write as FmtWrite;
12
13/// Run the `gor search` subcommand.
14///
15/// # Errors
16///
17/// Returns an error if the command execution fails.
18pub fn run(cmd: SearchCommand) -> anyhow::Result<()> {
19    match cmd {
20        SearchCommand::Repos {
21            query,
22            language,
23            topic,
24            stars,
25            sort,
26            order,
27            limit,
28            json,
29            web,
30            hostname,
31        } => search_repos(
32            &query.join(" "),
33            language.as_deref(),
34            topic.as_deref(),
35            stars.as_deref(),
36            &sort,
37            &order,
38            limit,
39            json,
40            web,
41            hostname.as_deref(),
42        ),
43        SearchCommand::Code {
44            query,
45            language,
46            repo,
47            limit,
48            json,
49            web,
50            hostname,
51        } => search_code(
52            &query.join(" "),
53            language.as_deref(),
54            repo.as_deref(),
55            limit,
56            json,
57            web,
58            hostname.as_deref(),
59        ),
60        SearchCommand::Issues {
61            query,
62            r#type,
63            state,
64            labels,
65            limit,
66            json,
67            web,
68            hostname,
69        } => search_issues(
70            &query.join(" "),
71            r#type.as_deref(),
72            state.as_deref(),
73            labels.as_deref(),
74            limit,
75            json,
76            web,
77            hostname.as_deref(),
78        ),
79        SearchCommand::Commits {
80            query,
81            author,
82            repo,
83            limit,
84            json,
85            web,
86            hostname,
87        } => search_commits(
88            &query.join(" "),
89            author.as_deref(),
90            repo.as_deref(),
91            limit,
92            json,
93            web,
94            hostname.as_deref(),
95        ),
96    }
97}
98
99fn build_query(base: &str, qualifiers: &[(&str, &str)]) -> String {
100    let mut q = base.to_string();
101    for (key, value) in qualifiers {
102        let _ = write!(q, " {key}:{value}");
103    }
104    q
105}
106
107#[allow(clippy::too_many_arguments)]
108fn search_repos(
109    query: &str,
110    language: Option<&str>,
111    _topic: Option<&str>,
112    stars: Option<&str>,
113    sort: &str,
114    order: &str,
115    limit: u32,
116    json: Option<Vec<String>>,
117    web: bool,
118    hostname: Option<&str>,
119) -> anyhow::Result<()> {
120    let host = hostname.unwrap_or("github.com");
121
122    if web {
123        let encoded = urlencoding(query);
124        let web_url = format!("https://{host}/search?q={encoded}&type=repositories");
125        open_in_browser(&web_url);
126        return Ok(());
127    }
128
129    let client = Client::new(host).context("failed to create HTTP client")?;
130
131    let mut qualifiers: Vec<(&str, &str)> = Vec::new();
132    if let Some(l) = language {
133        qualifiers.push(("language", l));
134    }
135    if let Some(s) = stars {
136        qualifiers.push(("stars", s));
137    }
138    let q = build_query(query, &qualifiers);
139
140    let path = format!(
141        "/search/repositories?q={}&sort={sort}&order={order}&per_page={}",
142        urlencoding(&q),
143        limit.min(100)
144    );
145    let response = client.get(&path).context("failed to search repositories")?;
146    let status = response.status();
147    if !status.is_success() {
148        anyhow::bail!("search failed: HTTP {status}");
149    }
150
151    let data: serde_json::Value = response.json().context("failed to parse search response")?;
152    let items: Vec<serde_json::Value> = data["items"].as_array().cloned().unwrap_or_default();
153
154    if let Some(fields) = json {
155        let fields_ref: Option<&[String]> = if fields.is_empty() {
156            None
157        } else {
158            Some(&fields)
159        };
160        print_json(&items, fields_ref);
161        return Ok(());
162    }
163
164    if items.is_empty() {
165        println!("No repositories found.");
166        return Ok(());
167    }
168
169    println!(
170        "{:<40}  {:<50}  {:<8}  {:<12}  {:<16}",
171        "NAME", "DESCRIPTION", "STARS", "LANGUAGE", "UPDATED"
172    );
173    for item in &items {
174        let full_name = item["full_name"].as_str().unwrap_or("—");
175        let desc = item["description"].as_str().unwrap_or("—");
176        let stars_count = item["stargazers_count"].as_u64().unwrap_or(0);
177        let lang = item["language"].as_str().unwrap_or("—");
178        let updated = item["updated_at"]
179            .as_str()
180            .map_or_else(|| "—".to_string(), format_date);
181
182        let name_truncated = crate::cmd::util::truncate(full_name, 40);
183        let desc_truncated = crate::cmd::util::truncate(desc, 50);
184
185        println!(
186            "{name_truncated:<40}  {desc_truncated:<50}  {:<8}  {lang:<12}  {updated:<16}",
187            format_count(stars_count)
188        );
189    }
190    Ok(())
191}
192
193fn search_code(
194    query: &str,
195    language: Option<&str>,
196    repo: Option<&str>,
197    limit: u32,
198    json: Option<Vec<String>>,
199    web: bool,
200    hostname: Option<&str>,
201) -> anyhow::Result<()> {
202    let host = hostname.unwrap_or("github.com");
203
204    if web {
205        let encoded = urlencoding(query);
206        let web_url = format!("https://{host}/search?q={encoded}&type=code");
207        open_in_browser(&web_url);
208        return Ok(());
209    }
210
211    let client = Client::new(host).context("failed to create HTTP client")?;
212
213    let mut qualifiers: Vec<(&str, &str)> = Vec::new();
214    if let Some(l) = language {
215        qualifiers.push(("language", l));
216    }
217    if let Some(r) = repo {
218        qualifiers.push(("repo", r));
219    }
220    let q = build_query(query, &qualifiers);
221
222    let path = format!(
223        "/search/code?q={}&per_page={}",
224        urlencoding(&q),
225        limit.min(100)
226    );
227    let response = client.get(&path).context("failed to search code")?;
228    let status = response.status();
229    if !status.is_success() {
230        anyhow::bail!("search failed: HTTP {status}");
231    }
232
233    let data: serde_json::Value = response.json().context("failed to parse search response")?;
234    let items: Vec<serde_json::Value> = data["items"].as_array().cloned().unwrap_or_default();
235
236    if let Some(fields) = json {
237        let fields_ref: Option<&[String]> = if fields.is_empty() {
238            None
239        } else {
240            Some(&fields)
241        };
242        print_json(&items, fields_ref);
243        return Ok(());
244    }
245
246    if items.is_empty() {
247        println!("No code results found.");
248        return Ok(());
249    }
250
251    for item in &items {
252        let repo_name = item["repository"]["full_name"].as_str().unwrap_or("—");
253        let path = item["path"].as_str().unwrap_or("—");
254        let html_url = item["html_url"].as_str().unwrap_or("—");
255        println!("{repo_name}/{path}");
256        println!("  {html_url}");
257    }
258    Ok(())
259}
260
261#[allow(clippy::too_many_arguments)]
262fn search_issues(
263    query: &str,
264    r#type: Option<&str>,
265    state: Option<&str>,
266    labels: Option<&str>,
267    limit: u32,
268    json: Option<Vec<String>>,
269    web: bool,
270    hostname: Option<&str>,
271) -> anyhow::Result<()> {
272    let host = hostname.unwrap_or("github.com");
273
274    if web {
275        let encoded = urlencoding(query);
276        let web_url = format!("https://{host}/search?q={encoded}&type=issues");
277        open_in_browser(&web_url);
278        return Ok(());
279    }
280
281    let client = Client::new(host).context("failed to create HTTP client")?;
282
283    let mut qualifiers: Vec<(&str, &str)> = Vec::new();
284    if let Some(t) = r#type {
285        qualifiers.push(("type", t));
286    }
287    if let Some(s) = state {
288        qualifiers.push(("state", s));
289    }
290    if let Some(l) = labels {
291        qualifiers.push(("label", l));
292    }
293    let q = build_query(query, &qualifiers);
294
295    let path = format!(
296        "/search/issues?q={}&per_page={}",
297        urlencoding(&q),
298        limit.min(100)
299    );
300    let response = client.get(&path).context("failed to search issues")?;
301    let status = response.status();
302    if !status.is_success() {
303        anyhow::bail!("search failed: HTTP {status}");
304    }
305
306    let data: serde_json::Value = response.json().context("failed to parse search response")?;
307    let items: Vec<serde_json::Value> = data["items"].as_array().cloned().unwrap_or_default();
308
309    if let Some(fields) = json {
310        let fields_ref: Option<&[String]> = if fields.is_empty() {
311            None
312        } else {
313            Some(&fields)
314        };
315        print_json(&items, fields_ref);
316        return Ok(());
317    }
318
319    if items.is_empty() {
320        println!("No issues found.");
321        return Ok(());
322    }
323
324    for item in &items {
325        let number = item["number"].as_u64().unwrap_or(0);
326        let title = item["title"].as_str().unwrap_or("—");
327        let state_str = item["state"].as_str().unwrap_or("—");
328        let html_url = item["html_url"].as_str().unwrap_or("—");
329        println!("#{number} [{state_str}] {title}");
330        println!("  {html_url}");
331    }
332    Ok(())
333}
334
335fn search_commits(
336    query: &str,
337    author: Option<&str>,
338    repo: Option<&str>,
339    limit: u32,
340    json: Option<Vec<String>>,
341    web: bool,
342    hostname: Option<&str>,
343) -> anyhow::Result<()> {
344    let host = hostname.unwrap_or("github.com");
345
346    if web {
347        let encoded = urlencoding(query);
348        let web_url = format!("https://{host}/search?q={encoded}&type=commits");
349        open_in_browser(&web_url);
350        return Ok(());
351    }
352
353    let client = Client::new(host).context("failed to create HTTP client")?;
354
355    let mut qualifiers: Vec<(&str, &str)> = Vec::new();
356    if let Some(a) = author {
357        qualifiers.push(("author", a));
358    }
359    if let Some(r) = repo {
360        qualifiers.push(("repo", r));
361    }
362    let q = build_query(query, &qualifiers);
363
364    let path = format!(
365        "/search/commits?q={}&per_page={}",
366        urlencoding(&q),
367        limit.min(100)
368    );
369    let response = client.get(&path).context("failed to search commits")?;
370    let status = response.status();
371    if !status.is_success() {
372        anyhow::bail!("search failed: HTTP {status}");
373    }
374
375    let data: serde_json::Value = response.json().context("failed to parse search response")?;
376    let items: Vec<serde_json::Value> = data["items"].as_array().cloned().unwrap_or_default();
377
378    if let Some(fields) = json {
379        let fields_ref: Option<&[String]> = if fields.is_empty() {
380            None
381        } else {
382            Some(&fields)
383        };
384        print_json(&items, fields_ref);
385        return Ok(());
386    }
387
388    if items.is_empty() {
389        println!("No commits found.");
390        return Ok(());
391    }
392
393    for item in &items {
394        let sha = item["sha"].as_str().unwrap_or("—");
395        let short_sha = &sha[..sha.len().min(7)];
396        let message = item["commit"]["message"].as_str().unwrap_or("—");
397        let first_line = message.lines().next().unwrap_or("—");
398        let author_login = item["author"]["login"].as_str().unwrap_or("—");
399        let html_url = item["html_url"].as_str().unwrap_or("—");
400        println!("{short_sha} {first_line}");
401        println!("  {author_login} — {html_url}");
402    }
403    Ok(())
404}
405
406fn urlencoding(s: &str) -> String {
407    s.replace(' ', "+")
408        .replace('#', "%23")
409        .replace('&', "%26")
410        .replace('?', "%3F")
411}
412
413fn open_in_browser(url: &str) {
414    #[cfg(target_os = "linux")]
415    {
416        let _ = std::process::Command::new("xdg-open").arg(url).spawn();
417    }
418    #[cfg(target_os = "macos")]
419    {
420        let _ = std::process::Command::new("open").arg(url).spawn();
421    }
422    #[cfg(target_os = "windows")]
423    {
424        let _ = std::process::Command::new("cmd")
425            .args(["/c", "start", url])
426            .spawn();
427    }
428}