1#![allow(clippy::print_stdout)]
6
7use crate::cli::GistCommand;
8use crate::client::Client;
9use crate::output::print_json;
10use anyhow::Context;
11use std::fs;
12
13pub fn run(cmd: GistCommand) -> anyhow::Result<()> {
19 match cmd {
20 GistCommand::List {
21 public,
22 secret,
23 user,
24 limit,
25 json,
26 hostname,
27 } => list(
28 public,
29 secret,
30 user.as_deref(),
31 limit,
32 json,
33 hostname.as_deref(),
34 ),
35 GistCommand::Create {
36 files,
37 desc,
38 public,
39 filename,
40 web,
41 hostname,
42 } => create(
43 &files,
44 desc.as_deref(),
45 public,
46 filename.as_deref(),
47 web,
48 hostname.as_deref(),
49 ),
50 GistCommand::View {
51 gist_id,
52 raw,
53 filename,
54 web,
55 json,
56 hostname,
57 } => view(
58 &gist_id,
59 raw,
60 filename.as_deref(),
61 web,
62 json,
63 hostname.as_deref(),
64 ),
65 GistCommand::Edit {
66 gist_id,
67 desc,
68 add,
69 filename,
70 hostname,
71 } => edit(
72 &gist_id,
73 desc.as_deref(),
74 &add,
75 filename.as_deref(),
76 hostname.as_deref(),
77 ),
78 GistCommand::Delete { gist_id, hostname } => delete(&gist_id, hostname.as_deref()),
79 }
80}
81
82fn list(
90 public: bool,
91 secret: bool,
92 user: Option<&str>,
93 limit: u32,
94 json: Option<Vec<String>>,
95 hostname: Option<&str>,
96) -> anyhow::Result<()> {
97 let host = hostname.unwrap_or("github.com");
98 let client = Client::new(host).context("failed to create HTTP client")?;
99
100 let path = user.map_or_else(
101 || format!("/gists?per_page={}", limit.min(100)),
102 |u| format!("/users/{u}/gists?per_page={}", limit.min(100)),
103 );
104
105 let response = client.get(&path).context("failed to fetch gists")?;
106
107 let status = response.status();
108 if !status.is_success() {
109 anyhow::bail!("failed to list gists: HTTP {status}");
110 }
111
112 let mut gists: Vec<serde_json::Value> =
113 response.json().context("failed to parse gists response")?;
114
115 gists.retain(|g| {
117 let is_public = g["public"].as_bool().unwrap_or(false);
118 if public && !secret {
119 is_public
120 } else if secret && !public {
121 !is_public
122 } else {
123 true
124 }
125 });
126
127 gists.truncate(limit as usize);
128
129 if let Some(fields) = json {
130 let fields_ref: Option<&[String]> = if fields.is_empty() {
131 None
132 } else {
133 Some(&fields)
134 };
135 print_json(&gists, fields_ref);
136 return Ok(());
137 }
138
139 print_gist_table(&gists);
140 Ok(())
141}
142
143fn create(
151 files: &[String],
152 desc: Option<&str>,
153 public: bool,
154 filename: Option<&str>,
155 web: bool,
156 hostname: Option<&str>,
157) -> anyhow::Result<()> {
158 if files.is_empty() {
159 anyhow::bail!("no files specified for gist creation");
160 }
161
162 let host = hostname.unwrap_or("github.com");
163 let client = Client::new(host).context("failed to create HTTP client")?;
164
165 let mut files_map = serde_json::Map::new();
166 for (i, file) in files.iter().enumerate() {
167 let content =
168 fs::read_to_string(file).with_context(|| format!("failed to read file: {file}"))?;
169 let gist_filename = if files.len() == 1 {
170 filename.unwrap_or(file).to_string()
171 } else {
172 file.clone()
173 };
174 let file_entry = serde_json::json!({ "content": content });
175 files_map.insert(gist_filename, file_entry);
176 if files.len() > 1 && i == 0 {
177 }
179 }
180
181 let mut body_map = serde_json::Map::new();
182 body_map.insert("public".to_string(), serde_json::Value::Bool(public));
183 body_map.insert("files".to_string(), serde_json::Value::Object(files_map));
184 if let Some(d) = desc {
185 body_map.insert(
186 "description".to_string(),
187 serde_json::Value::String(d.to_string()),
188 );
189 }
190
191 let body_value = serde_json::Value::Object(body_map);
192 let response = client
193 .post("/gists", &body_value)
194 .context("failed to create gist")?;
195
196 let status = response.status();
197 if !status.is_success() {
198 let err_body: serde_json::Value = response.json().unwrap_or_default();
199 let msg = err_body["message"].as_str().unwrap_or("creation failed");
200 anyhow::bail!("failed to create gist: {msg}");
201 }
202
203 let gist: serde_json::Value = response.json().context("failed to parse gist response")?;
204 let gist_url = gist["html_url"].as_str().unwrap_or("");
205
206 if web && !gist_url.is_empty() {
207 open_in_browser(gist_url);
208 }
209
210 println!("{gist_url}");
211 Ok(())
212}
213
214fn view(
222 gist_id: &str,
223 raw: bool,
224 filename: Option<&str>,
225 web: bool,
226 json: Option<Vec<String>>,
227 hostname: Option<&str>,
228) -> anyhow::Result<()> {
229 let host = hostname.unwrap_or("github.com");
230 let client = Client::new(host).context("failed to create HTTP client")?;
231
232 let path = format!("/gists/{gist_id}");
233 let response = client.get(&path).context("failed to fetch gist")?;
234
235 let status = response.status();
236 if !status.is_success() {
237 let err_body: serde_json::Value = response.json().unwrap_or_default();
238 let msg = err_body["message"].as_str().unwrap_or("view failed");
239 anyhow::bail!("failed to view gist: {msg}");
240 }
241
242 let gist: serde_json::Value = response.json().context("failed to parse gist response")?;
243
244 if web {
246 if let Some(url) = gist["html_url"].as_str() {
247 open_in_browser(url);
248 return Ok(());
249 }
250 }
251
252 if raw {
254 let files = gist["files"]
255 .as_object()
256 .ok_or_else(|| anyhow::anyhow!("gist has no files"))?;
257
258 let selected = if let Some(name) = filename {
259 files
260 .get(name)
261 .ok_or_else(|| anyhow::anyhow!("file '{name}' not found in gist"))?
262 } else {
263 files
264 .values()
265 .next()
266 .ok_or_else(|| anyhow::anyhow!("gist has no files"))?
267 };
268
269 let content = selected["content"].as_str().unwrap_or("");
270 print!("{content}");
271 return Ok(());
272 }
273
274 if let Some(fields) = json {
276 let fields_ref: Option<&[String]> = if fields.is_empty() {
277 None
278 } else {
279 Some(&fields)
280 };
281 print_json(&gist, fields_ref);
282 return Ok(());
283 }
284
285 let description = gist["description"].as_str().unwrap_or("No description");
287 println!("Description: {description}");
288 println!("Files:");
289
290 let files = gist["files"]
291 .as_object()
292 .ok_or_else(|| anyhow::anyhow!("gist has no files"))?;
293 for (name, file_info) in files {
294 let language = file_info["language"].as_str().unwrap_or("Unknown");
295 let content = file_info["content"].as_str().unwrap_or("");
296 println!("\n {name} ({language}):");
297 for line in content.lines() {
298 println!(" {line}");
299 }
300 }
301
302 Ok(())
303}
304
305fn edit(
313 gist_id: &str,
314 desc: Option<&str>,
315 add: &[String],
316 filename: Option<&str>,
317 hostname: Option<&str>,
318) -> anyhow::Result<()> {
319 let host = hostname.unwrap_or("github.com");
320 let client = Client::new(host).context("failed to create HTTP client")?;
321
322 let mut body_map = serde_json::Map::new();
323
324 if let Some(d) = desc {
325 body_map.insert(
326 "description".to_string(),
327 serde_json::Value::String(d.to_string()),
328 );
329 }
330
331 if !add.is_empty() {
333 let mut files_map = serde_json::Map::new();
334 for entry in add {
335 if let Some((key, value)) = entry.split_once('=') {
336 let content = fs::read_to_string(value).unwrap_or_else(|_| value.to_string());
338 files_map.insert(key.to_string(), serde_json::json!({"content": content}));
339 } else {
340 let content = fs::read_to_string(entry)
341 .with_context(|| format!("failed to read file: {entry}"))?;
342 files_map.insert(entry.clone(), serde_json::json!({"content": content}));
343 }
344 }
345 body_map.insert("files".to_string(), serde_json::Value::Object(files_map));
346 }
347
348 if let Some(fn_rename) = filename {
350 if let Some((old_name, new_name)) = fn_rename.split_once(':') {
351 let mut files_map = serde_json::Map::new();
352 let mut new_file_map = serde_json::Map::new();
353 new_file_map.insert(
354 "filename".to_string(),
355 serde_json::Value::String(new_name.to_string()),
356 );
357 files_map.insert(
358 old_name.to_string(),
359 serde_json::Value::Object(new_file_map),
360 );
361 body_map.insert("files".to_string(), serde_json::Value::Object(files_map));
362 } else {
363 anyhow::bail!("invalid rename format: '{fn_rename}' (expected old:new)");
364 }
365 }
366
367 let body_value = serde_json::Value::Object(body_map);
368 let body_bytes = serde_json::to_vec(&body_value).context("failed to serialize body")?;
369 let path = format!("/gists/{gist_id}");
370 let response = client
371 .request("PATCH", &path, &[], Some(body_bytes))
372 .context("failed to edit gist")?;
373
374 let status = response.status();
375 if !status.is_success() {
376 let err_body: serde_json::Value = response.json().unwrap_or_default();
377 let msg = err_body["message"].as_str().unwrap_or("edit failed");
378 anyhow::bail!("failed to edit gist: {msg}");
379 }
380
381 let gist: serde_json::Value = response.json().context("failed to parse gist response")?;
382 let gist_url = gist["html_url"].as_str().unwrap_or("");
383 println!("{gist_url}");
384 Ok(())
385}
386
387fn delete(gist_id: &str, hostname: Option<&str>) -> anyhow::Result<()> {
395 let host = hostname.unwrap_or("github.com");
396 let client = Client::new(host).context("failed to create HTTP client")?;
397
398 let path = format!("/gists/{gist_id}");
399 let response = client
400 .request("DELETE", &path, &[], None)
401 .context("failed to delete gist")?;
402
403 let status = response.status();
404 if !status.is_success() {
405 let err_body: serde_json::Value = response.json().unwrap_or_default();
406 let msg = err_body["message"].as_str().unwrap_or("delete failed");
407 anyhow::bail!("failed to delete gist: {msg}");
408 }
409
410 println!("Gist '{gist_id}' deleted.");
411 Ok(())
412}
413
414fn print_gist_table(gists: &[serde_json::Value]) {
416 if gists.is_empty() {
417 println!("No gists found.");
418 return;
419 }
420
421 let id_width = 16;
422 let desc_width = 40;
423 let files_width = 8;
424 let date_width = 16;
425
426 println!(
427 "{:<id_width$} {:<desc_width$} {:>files_width$} {:>date_width$}",
428 "ID", "DESCRIPTION", "FILES", "UPDATED",
429 );
430
431 for gist in gists {
432 let gist_id = gist["id"].as_str().unwrap_or("—");
433 let description = gist["description"].as_str().unwrap_or("—");
434 let file_count = gist["files"].as_object().map_or(0, serde_json::Map::len);
435 let updated = gist["updated_at"]
436 .as_str()
437 .map_or_else(|| "—".to_string(), crate::output::format_date);
438
439 let desc_truncated = crate::cmd::util::truncate(description, desc_width);
440
441 println!(
442 "{gist_id:<id_width$} {desc_truncated:<desc_width$} {file_count:>files_width$} {updated:>date_width$}",
443 );
444 }
445}
446
447fn open_in_browser(url: &str) {
449 #[cfg(target_os = "linux")]
450 {
451 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
452 }
453 #[cfg(target_os = "macos")]
454 {
455 let _ = std::process::Command::new("open").arg(url).spawn();
456 }
457 #[cfg(target_os = "windows")]
458 {
459 let _ = std::process::Command::new("cmd")
460 .args(["/c", "start", url])
461 .spawn();
462 }
463 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
464 {
465 println!("Open {url} in your browser");
466 }
467}
468
469#[cfg(test)]
470#[allow(clippy::expect_used)]
471mod tests {
472 use super::*;
473 use serde_json::json;
474
475 #[test]
476 fn print_gist_table_basic() {
477 let gists = vec![json!({
478 "id": "abc123",
479 "description": "My first gist",
480 "files": { "hello.py": { "filename": "hello.py" } },
481 "updated_at": "2024-01-15T10:30:00Z",
482 "public": false
483 })];
484 print_gist_table(&gists);
485 }
486
487 #[test]
488 fn print_gist_table_empty() {
489 let gists: Vec<serde_json::Value> = vec![];
490 print_gist_table(&gists);
491 }
492
493 #[test]
494 fn print_gist_table_multiple() {
495 let gists = vec![
496 json!({
497 "id": "abc123",
498 "description": "My first gist",
499 "files": { "hello.py": {} },
500 "updated_at": "2024-01-15T10:30:00Z",
501 "public": true
502 }),
503 json!({
504 "id": "def456",
505 "description": null,
506 "files": { "a.rs": {}, "b.rs": {}, "c.rs": {} },
507 "updated_at": "2024-03-01T00:00:00Z",
508 "public": false
509 }),
510 ];
511 print_gist_table(&gists);
512 }
513
514 #[test]
515 fn open_in_browser_does_not_panic() {
516 open_in_browser("https://gist.github.com/abc123");
517 }
518}