1#![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
13pub 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}