Skip to main content

gor/cmd/
gist.rs

1//! Implementation of the `gor gist` subcommand.
2//!
3//! Provides gist listing and creation commands.
4
5#![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
13/// Run the `gor gist` subcommand.
14///
15/// # Errors
16///
17/// Returns an error if the command execution fails.
18pub 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
82/// Execute `gor gist list`.
83///
84/// Lists gists for the authenticated user or a specific user.
85///
86/// # Errors
87///
88/// Returns an error if the API request fails.
89fn 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    // Filter by visibility
116    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
143/// Execute `gor gist create`.
144///
145/// Creates a new gist from one or more files.
146///
147/// # Errors
148///
149/// Returns an error if the file cannot be read or the API request fails.
150fn 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            // Only the first file gets the custom filename
178        }
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
214/// Execute `gor gist view`.
215///
216/// Views a gist's content and metadata.
217///
218/// # Errors
219///
220/// Returns an error if the API request fails.
221fn 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    // --web / -w: open in browser
245    if web {
246        if let Some(url) = gist["html_url"].as_str() {
247            open_in_browser(url);
248            return Ok(());
249        }
250    }
251
252    // --raw: output raw content of a specific file
253    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    // --json: output as JSON
275    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    // Default: print description and files
286    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
305/// Execute `gor gist edit`.
306///
307/// Edits an existing gist by updating its description, adding files, or renaming files.
308///
309/// # Errors
310///
311/// Returns an error if the API request fails.
312fn 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    // Handle --add: add files from file paths or inline content
332    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                // If the value looks like a file path, read it
337                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    // Handle --filename: rename a file (old:new)
349    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
387/// Execute `gor gist delete`.
388///
389/// Deletes a gist by ID.
390///
391/// # Errors
392///
393/// Returns an error if the API request fails.
394fn 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
414/// Print a formatted gist list table.
415fn 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
447/// Open a URL in the default browser.
448fn 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}