1#![allow(clippy::print_stdout)]
7
8use crate::cli::ReleaseCommand;
9use crate::client::Client;
10use crate::output::{format_date, print_json};
11use crate::repository::{detect_remote, parse_repo_spec};
12use anyhow::Context;
13use std::io::Write;
14
15pub fn run(cmd: ReleaseCommand) -> anyhow::Result<()> {
21 match cmd {
22 ReleaseCommand::List {
23 repo,
24 limit,
25 exclude_drafts,
26 exclude_prereleases,
27 json,
28 hostname,
29 } => list(
30 repo.as_deref(),
31 limit,
32 exclude_drafts,
33 exclude_prereleases,
34 json,
35 hostname.as_deref(),
36 ),
37 ReleaseCommand::View {
38 release,
39 repo,
40 web,
41 json,
42 hostname,
43 } => view(&release, repo.as_deref(), web, json, hostname.as_deref()),
44 ReleaseCommand::Delete {
45 release,
46 repo,
47 yes,
48 skip_tag,
49 hostname,
50 } => delete(
51 &release,
52 repo.as_deref(),
53 yes,
54 skip_tag,
55 hostname.as_deref(),
56 ),
57 ReleaseCommand::Edit {
58 release,
59 repo,
60 title,
61 notes,
62 notes_file,
63 draft,
64 prerelease,
65 tag,
66 target,
67 hostname,
68 } => edit(
69 &release,
70 repo.as_deref(),
71 title.as_deref(),
72 notes.as_deref(),
73 notes_file.as_deref(),
74 draft,
75 prerelease,
76 tag.as_deref(),
77 target.as_deref(),
78 hostname.as_deref(),
79 ),
80 ReleaseCommand::Upload {
81 release,
82 files,
83 repo,
84 name,
85 mime_type,
86 hostname,
87 } => upload(
88 &release,
89 &files,
90 repo.as_deref(),
91 name.as_deref(),
92 mime_type.as_deref(),
93 hostname.as_deref(),
94 ),
95 ReleaseCommand::Create {
96 tag,
97 repo,
98 title,
99 notes,
100 notes_file,
101 draft,
102 prerelease,
103 target,
104 discussion_category,
105 hostname,
106 } => create_release(
107 &tag,
108 repo.as_deref(),
109 title.as_deref(),
110 notes.as_deref(),
111 notes_file.as_deref(),
112 draft,
113 prerelease,
114 target.as_deref(),
115 discussion_category.as_deref(),
116 hostname.as_deref(),
117 ),
118 ReleaseCommand::Download {
119 release,
120 repo,
121 patterns,
122 dir,
123 skip_existing,
124 hostname,
125 } => download(
126 &release,
127 repo.as_deref(),
128 &patterns,
129 &dir,
130 skip_existing,
131 hostname.as_deref(),
132 ),
133 ReleaseCommand::DeleteAsset {
134 asset_id,
135 repo,
136 yes,
137 hostname,
138 } => delete_asset(asset_id, repo.as_deref(), yes, hostname.as_deref()),
139 }
140}
141
142fn list(
151 repo: Option<&str>,
152 limit: u32,
153 exclude_drafts: bool,
154 exclude_prereleases: bool,
155 json: Option<Vec<String>>,
156 hostname: Option<&str>,
157) -> anyhow::Result<()> {
158 let spec = match repo {
159 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
160 None => detect_remote().ok_or_else(|| {
161 anyhow::anyhow!(
162 "could not detect repository from current directory; specify OWNER/REPO with --repo"
163 )
164 })?,
165 };
166
167 let host = hostname.unwrap_or("github.com");
168 let client = Client::new(host).context("failed to create HTTP client")?;
169
170 let path = format!(
171 "/repos/{}/{}/releases?per_page={}",
172 spec.owner,
173 spec.repo,
174 limit.min(100)
175 );
176 let response = client.get(&path).context("failed to fetch releases")?;
177
178 let status = response.status();
179 if status == reqwest::StatusCode::NOT_FOUND {
180 anyhow::bail!("repository '{spec}' not found");
181 }
182 if !status.is_success() {
183 anyhow::bail!("failed to list releases for '{spec}': HTTP {status}");
184 }
185
186 let mut releases: Vec<serde_json::Value> = response
187 .json()
188 .context("failed to parse releases response")?;
189
190 if exclude_drafts {
192 releases.retain(|r| !r["draft"].as_bool().unwrap_or(false));
193 }
194 if exclude_prereleases {
195 releases.retain(|r| !r["prerelease"].as_bool().unwrap_or(false));
196 }
197
198 releases.truncate(limit as usize);
200
201 if let Some(fields) = json {
203 let fields_ref: Option<&[String]> = if fields.is_empty() {
204 None
205 } else {
206 Some(&fields)
207 };
208 print_json(&releases, fields_ref);
209 return Ok(());
210 }
211
212 print_release_table(&releases);
214 Ok(())
215}
216
217fn print_release_table(releases: &[serde_json::Value]) {
221 if releases.is_empty() {
222 println!("No releases found.");
223 return;
224 }
225
226 let tag_width = 20;
227 let name_width = 40;
228 let date_width = 16;
229 let status_width = 12;
230
231 println!(
232 "{:<tag_width$} {:<name_width$} {:<date_width$} {:<status_width$}",
233 "TAG", "NAME", "PUBLISHED", "STATUS",
234 );
235
236 for release in releases {
237 let tag = release["tag_name"].as_str().unwrap_or("—");
238 let name = release["name"].as_str().unwrap_or("—");
239 let published = release["published_at"]
240 .as_str()
241 .map_or_else(|| "—".to_string(), format_date);
242 let is_draft = release["draft"].as_bool().unwrap_or(false);
243 let is_prerelease = release["prerelease"].as_bool().unwrap_or(false);
244 let status = if is_draft {
245 "Draft"
246 } else if is_prerelease {
247 "Pre-release"
248 } else {
249 "Latest"
250 };
251
252 let tag_truncated = crate::cmd::util::truncate(tag, tag_width);
253 let name_truncated = crate::cmd::util::truncate(name, name_width);
254
255 println!(
256 "{tag_truncated:<tag_width$} {name_truncated:<name_width$} {published:<date_width$} {status:<status_width$}",
257 );
258 }
259}
260
261fn view(
271 release: &str,
272 repo: Option<&str>,
273 web: bool,
274 json: Option<Vec<String>>,
275 hostname: Option<&str>,
276) -> anyhow::Result<()> {
277 let spec = match repo {
278 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
279 None => detect_remote().ok_or_else(|| {
280 anyhow::anyhow!(
281 "could not detect repository from current directory; specify OWNER/REPO with --repo"
282 )
283 })?,
284 };
285
286 let host = hostname.unwrap_or("github.com");
287
288 if web {
290 let web_url = format!(
291 "https://{host}/{}/{}/releases/tag/{release}",
292 spec.owner, spec.repo
293 );
294 open_in_browser(&web_url);
295 return Ok(());
296 }
297
298 let client = Client::new(host).context("failed to create HTTP client")?;
299
300 let tag_path = format!(
302 "/repos/{}/{}/releases/tags/{release}",
303 spec.owner, spec.repo
304 );
305 let response = client.get(&tag_path);
306
307 let release_data: serde_json::Value = match response {
308 Ok(resp) if resp.status().is_success() => {
309 resp.json().context("failed to parse release response")?
310 }
311 _ => {
312 let id: u64 = release
314 .parse()
315 .context("release not found by tag; provide a valid numeric release ID")?;
316 let id_path = format!("/repos/{}/{}/releases/{id}", spec.owner, spec.repo);
317 let resp = client.get(&id_path).context("failed to fetch release")?;
318 let status = resp.status();
319 if status == reqwest::StatusCode::NOT_FOUND {
320 anyhow::bail!("release '{release}' not found in '{spec}'");
321 }
322 if !status.is_success() {
323 anyhow::bail!("failed to view release '{release}': HTTP {status}");
324 }
325 resp.json().context("failed to parse release response")?
326 }
327 };
328
329 if let Some(fields) = json {
331 let fields_ref: Option<&[String]> = if fields.is_empty() {
332 None
333 } else {
334 Some(&fields)
335 };
336 print_json(&release_data, fields_ref);
337 return Ok(());
338 }
339
340 print_release_view(&release_data);
342 Ok(())
343}
344
345fn delete(
355 release: &str,
356 repo: Option<&str>,
357 yes: bool,
358 skip_tag: bool,
359 hostname: Option<&str>,
360) -> anyhow::Result<()> {
361 let spec = match repo {
362 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
363 None => detect_remote().ok_or_else(|| {
364 anyhow::anyhow!(
365 "could not detect repository from current directory; specify OWNER/REPO with --repo"
366 )
367 })?,
368 };
369
370 let host = hostname.unwrap_or("github.com");
371 let client = Client::new(host).context("failed to create HTTP client")?;
372
373 let (release_id, tag_name) = resolve_release(&client, &spec.owner, &spec.repo, release)?;
375
376 if !yes {
378 print!("Delete release '{tag_name}' (ID: {release_id}) from '{spec}'? [y/N] ");
379 std::io::stdout().flush().ok();
380 let mut input = String::new();
381 std::io::stdin().read_line(&mut input).ok();
382 let trimmed = input.trim().to_lowercase();
383 if trimmed != "y" && trimmed != "yes" {
384 println!("Cancelled.");
385 return Ok(());
386 }
387 }
388
389 let mut path = format!("/repos/{}/{}/releases/{release_id}", spec.owner, spec.repo);
391 if skip_tag {
392 path.push_str("?skip_tag=true");
393 }
394
395 let response = client
396 .request("DELETE", &path, &[], None)
397 .context("failed to delete release")?;
398 let status = response.status();
399
400 if status == reqwest::StatusCode::NOT_FOUND {
401 anyhow::bail!("release '{release}' not found in '{spec}'");
402 }
403 if status == reqwest::StatusCode::NO_CONTENT {
404 let tag_msg = if skip_tag { " (git tag preserved)" } else { "" };
405 println!("✓ Deleted release '{tag_name}'{tag_msg}");
406 return Ok(());
407 }
408 if !status.is_success() {
409 anyhow::bail!("failed to delete release '{release}': HTTP {status}");
410 }
411
412 Ok(())
413}
414
415fn resolve_release(
419 client: &Client,
420 owner: &str,
421 repo: &str,
422 release: &str,
423) -> anyhow::Result<(u64, String)> {
424 let tag_path = format!("/repos/{owner}/{repo}/releases/tags/{release}");
426 if let Ok(resp) = client.get(&tag_path) {
427 if resp.status().is_success() {
428 let data: serde_json::Value =
429 resp.json().context("failed to parse release response")?;
430 let id = data["id"]
431 .as_u64()
432 .context("release response missing 'id' field")?;
433 let tag = data["tag_name"].as_str().unwrap_or(release).to_string();
434 return Ok((id, tag));
435 }
436 }
437
438 let id: u64 = release
440 .parse()
441 .context("release not found by tag; provide a valid numeric release ID")?;
442 let id_path = format!("/repos/{owner}/{repo}/releases/{id}");
443 let resp = client.get(&id_path).context("failed to fetch release")?;
444 let status = resp.status();
445 if status == reqwest::StatusCode::NOT_FOUND {
446 anyhow::bail!("release '{release}' not found in '{owner}/{repo}'");
447 }
448 if !status.is_success() {
449 anyhow::bail!("failed to resolve release '{release}': HTTP {status}");
450 }
451 let data: serde_json::Value = resp.json().context("failed to parse release response")?;
452 let tag = data["tag_name"].as_str().unwrap_or(release).to_string();
453 Ok((id, tag))
454}
455
456#[allow(clippy::too_many_arguments)]
466fn edit(
467 release: &str,
468 repo: Option<&str>,
469 title: Option<&str>,
470 notes: Option<&str>,
471 notes_file: Option<&str>,
472 draft: Option<bool>,
473 prerelease: Option<bool>,
474 tag: Option<&str>,
475 target: Option<&str>,
476 hostname: Option<&str>,
477) -> anyhow::Result<()> {
478 let spec = match repo {
479 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
480 None => detect_remote().ok_or_else(|| {
481 anyhow::anyhow!(
482 "could not detect repository from current directory; specify OWNER/REPO with --repo"
483 )
484 })?,
485 };
486
487 let host = hostname.unwrap_or("github.com");
488 let client = Client::new(host).context("failed to create HTTP client")?;
489
490 let (release_id, _tag_name) = resolve_release(&client, &spec.owner, &spec.repo, release)?;
492
493 let body = if let Some(file) = notes_file {
495 let content = if file == "-" {
496 use std::io::Read;
497 let mut buf = String::new();
498 std::io::stdin()
499 .read_to_string(&mut buf)
500 .context("failed to read notes from stdin")?;
501 buf
502 } else {
503 std::fs::read_to_string(file)
504 .with_context(|| format!("failed to read notes file: {file}"))?
505 };
506 Some(content)
507 } else {
508 notes.map(String::from)
509 };
510
511 let mut patch = serde_json::Map::new();
513 if let Some(t) = title {
514 patch.insert("name".to_string(), serde_json::Value::String(t.to_string()));
515 }
516 if let Some(ref b) = body {
517 patch.insert("body".to_string(), serde_json::Value::String(b.clone()));
518 }
519 if let Some(d) = draft {
520 patch.insert("draft".to_string(), serde_json::Value::Bool(d));
521 }
522 if let Some(p) = prerelease {
523 patch.insert("prerelease".to_string(), serde_json::Value::Bool(p));
524 }
525 if let Some(t) = tag {
526 patch.insert(
527 "tag_name".to_string(),
528 serde_json::Value::String(t.to_string()),
529 );
530 }
531 if let Some(t) = target {
532 patch.insert(
533 "target_commitish".to_string(),
534 serde_json::Value::String(t.to_string()),
535 );
536 }
537
538 if patch.is_empty() {
539 anyhow::bail!(
540 "no changes specified; use --title, --notes, --draft, --prerelease, --tag, or --target"
541 );
542 }
543
544 let api_path = format!("/repos/{}/{}/releases/{release_id}", spec.owner, spec.repo);
545 let response = client
546 .request("PATCH", &api_path, &[], Some(serde_json::to_vec(&patch)?))
547 .context("failed to edit release")?;
548
549 let status = response.status();
550 if status == reqwest::StatusCode::NOT_FOUND {
551 anyhow::bail!("release '{release}' not found in '{spec}'");
552 }
553 if !status.is_success() {
554 anyhow::bail!("failed to edit release '{release}': HTTP {status}");
555 }
556
557 let updated: serde_json::Value = response.json().context("failed to parse response")?;
558 let html_url = updated["html_url"].as_str().unwrap_or("(unknown URL)");
559 println!("✓ Release updated: {html_url}");
560 Ok(())
561}
562
563fn upload(
573 release: &str,
574 files: &[String],
575 repo: Option<&str>,
576 name: Option<&str>,
577 mime_type: Option<&str>,
578 hostname: Option<&str>,
579) -> anyhow::Result<()> {
580 if files.is_empty() {
581 anyhow::bail!("no files specified for upload");
582 }
583
584 if name.is_some() && files.len() > 1 {
585 anyhow::bail!("--name can only be used with a single file upload");
586 }
587
588 let spec = match repo {
589 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
590 None => detect_remote().ok_or_else(|| {
591 anyhow::anyhow!(
592 "could not detect repository from current directory; specify OWNER/REPO with --repo"
593 )
594 })?,
595 };
596
597 let host = hostname.unwrap_or("github.com");
598 let client = Client::new(host).context("failed to create HTTP client")?;
599
600 let (release_id, tag_name) = resolve_release(&client, &spec.owner, &spec.repo, release)?;
602
603 let get_path = format!("/repos/{}/{}/releases/{release_id}", spec.owner, spec.repo);
605 let resp = client.get(&get_path).context("failed to fetch release")?;
606 let release_data: serde_json::Value =
607 resp.json().context("failed to parse release response")?;
608 let upload_url_template = release_data["upload_url"]
609 .as_str()
610 .context("release response missing 'upload_url'")?;
611
612 for file_path in files {
613 let file_name = name.unwrap_or_else(|| {
614 std::path::Path::new(file_path)
615 .file_name()
616 .and_then(|n| n.to_str())
617 .unwrap_or(file_path)
618 });
619
620 let file_data = std::fs::read(file_path)
622 .with_context(|| format!("failed to read file: {file_path}"))?;
623 let file_size = file_data.len();
624
625 let content_type = mime_type.map_or_else(|| detect_mime_type(file_name), String::from);
627
628 let upload_url =
630 upload_url_template.replace("{?name,label}", &format!("?name={file_name}"));
631
632 print!(
634 "Uploading {file_name} ({}) ... ",
635 format_size(file_size as u64)
636 );
637 std::io::stdout().flush().ok();
638
639 let response = client
640 .upload_asset(&upload_url, &file_data, &content_type)
641 .with_context(|| format!("failed to upload {file_name}"))?;
642
643 let status = response.status();
644 if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
645 anyhow::bail!(
646 "asset '{file_name}' already exists on release '{tag_name}'; use a different name or delete the existing asset first"
647 );
648 }
649 if !status.is_success() {
650 let err_body: serde_json::Value = response.json().unwrap_or_default();
651 let msg = err_body["message"].as_str().unwrap_or("upload failed");
652 anyhow::bail!("failed to upload '{file_name}': {msg}");
653 }
654
655 let asset: serde_json::Value = response.json().context("failed to parse asset response")?;
656 let asset_url = asset["browser_download_url"]
657 .as_str()
658 .unwrap_or("(unknown URL)");
659
660 println!("done");
661 println!("{asset_url}");
662 }
663
664 Ok(())
665}
666
667fn detect_mime_type(filename: &str) -> String {
669 let ext = std::path::Path::new(filename)
670 .extension()
671 .and_then(|e| e.to_str())
672 .unwrap_or("")
673 .to_lowercase();
674
675 match ext.as_str() {
676 "zip" => "application/zip".to_string(),
677 "tar" => "application/x-tar".to_string(),
678 "gz" | "tgz" => "application/gzip".to_string(),
679 "bz2" => "application/x-bzip2".to_string(),
680 "xz" => "application/x-xz".to_string(),
681 "dmg" => "application/x-apple-diskimage".to_string(),
682 "deb" => "application/vnd.debian.binary-package".to_string(),
683 "rpm" => "application/x-rpm".to_string(),
684 "msi" => "application/x-msi".to_string(),
685 "exe" => "application/x-msdownload".to_string(),
686 "apk" => "application/vnd.android.package-archive".to_string(),
687 "jar" => "application/java-archive".to_string(),
688 "txt" => "text/plain".to_string(),
689 "md" => "text/markdown".to_string(),
690 "json" => "application/json".to_string(),
691 "xml" => "application/xml".to_string(),
692 "html" => "text/html".to_string(),
693 "css" => "text/css".to_string(),
694 "js" => "application/javascript".to_string(),
695 "wasm" => "application/wasm".to_string(),
696 "png" => "image/png".to_string(),
697 "jpg" | "jpeg" => "image/jpeg".to_string(),
698 "gif" => "image/gif".to_string(),
699 "svg" => "image/svg+xml".to_string(),
700 "ico" => "image/x-icon".to_string(),
701 "pdf" => "application/pdf".to_string(),
702 _ => "application/octet-stream".to_string(),
703 }
704}
705
706#[allow(clippy::too_many_arguments)]
716fn create_release(
717 tag: &str,
718 repo: Option<&str>,
719 title: Option<&str>,
720 notes: Option<&str>,
721 notes_file: Option<&str>,
722 draft: bool,
723 prerelease: bool,
724 target: Option<&str>,
725 discussion_category: Option<&str>,
726 hostname: Option<&str>,
727) -> anyhow::Result<()> {
728 let spec = match repo {
729 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
730 None => detect_remote().ok_or_else(|| {
731 anyhow::anyhow!(
732 "could not detect repository from current directory; specify OWNER/REPO with --repo"
733 )
734 })?,
735 };
736
737 let host = hostname.unwrap_or("github.com");
738 let client = Client::new(host).context("failed to create HTTP client")?;
739
740 let body = if let Some(file) = notes_file {
742 let content = if file == "-" {
743 use std::io::Read;
744 let mut buf = String::new();
745 std::io::stdin()
746 .read_to_string(&mut buf)
747 .context("failed to read notes from stdin")?;
748 buf
749 } else {
750 std::fs::read_to_string(file)
751 .with_context(|| format!("failed to read notes file: {file}"))?
752 };
753 Some(content)
754 } else {
755 notes.map(String::from)
756 };
757
758 let mut body_map = serde_json::Map::new();
760 body_map.insert(
761 "tag_name".to_string(),
762 serde_json::Value::String(tag.to_string()),
763 );
764 body_map.insert(
765 "name".to_string(),
766 serde_json::Value::String(title.unwrap_or(tag).to_string()),
767 );
768 if let Some(ref b) = body {
769 body_map.insert("body".to_string(), serde_json::Value::String(b.clone()));
770 }
771 if draft {
772 body_map.insert("draft".to_string(), serde_json::Value::Bool(true));
773 }
774 if prerelease {
775 body_map.insert("prerelease".to_string(), serde_json::Value::Bool(true));
776 }
777 if let Some(t) = target {
778 body_map.insert(
779 "target_commitish".to_string(),
780 serde_json::Value::String(t.to_string()),
781 );
782 }
783 if let Some(c) = discussion_category {
784 body_map.insert(
785 "discussion_category_name".to_string(),
786 serde_json::Value::String(c.to_string()),
787 );
788 }
789
790 let path = format!("/repos/{}/{}/releases", spec.owner, spec.repo);
791 let body_value = serde_json::Value::Object(body_map);
792 let response = client
793 .post(&path, &body_value)
794 .context("failed to create release")?;
795
796 let status = response.status();
797 if status == reqwest::StatusCode::NOT_FOUND {
798 anyhow::bail!("repository '{spec}' not found");
799 }
800 if !status.is_success() {
801 let err_body: serde_json::Value = response.json().unwrap_or_default();
802 let msg = err_body["message"].as_str().unwrap_or("creation failed");
803 anyhow::bail!("failed to create release: {msg}");
804 }
805
806 let release_data: serde_json::Value = response
807 .json()
808 .context("failed to parse release response")?;
809 let html_url = release_data["html_url"].as_str().unwrap_or("(unknown URL)");
810 println!("{html_url}");
811 Ok(())
812}
813
814fn download(
824 release: &str,
825 repo: Option<&str>,
826 patterns: &[String],
827 dir: &str,
828 skip_existing: bool,
829 hostname: Option<&str>,
830) -> anyhow::Result<()> {
831 let spec = match repo {
832 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
833 None => detect_remote().ok_or_else(|| {
834 anyhow::anyhow!(
835 "could not detect repository from current directory; specify OWNER/REPO with --repo"
836 )
837 })?,
838 };
839
840 let host = hostname.unwrap_or("github.com");
841 let client = Client::new(host).context("failed to create HTTP client")?;
842
843 let (release_id, _tag_name) = resolve_release(&client, &spec.owner, &spec.repo, release)?;
845
846 let get_path = format!("/repos/{}/{}/releases/{release_id}", spec.owner, spec.repo);
848 let resp = client.get(&get_path).context("failed to fetch release")?;
849 let release_data: serde_json::Value =
850 resp.json().context("failed to parse release response")?;
851
852 let assets = release_data["assets"]
853 .as_array()
854 .map_or(&[] as &[serde_json::Value], |a| a);
855
856 let filtered: Vec<&serde_json::Value> = if patterns.is_empty() {
858 assets.iter().collect()
859 } else {
860 assets
861 .iter()
862 .filter(|asset| {
863 let name = asset["name"].as_str().unwrap_or("");
864 patterns.iter().any(|p| glob_match(p, name))
865 })
866 .collect()
867 };
868
869 if filtered.is_empty() {
870 if patterns.is_empty() {
871 anyhow::bail!("release has no assets to download");
872 }
873 anyhow::bail!("no assets match the given pattern(s)");
874 }
875
876 std::fs::create_dir_all(dir)
878 .with_context(|| format!("failed to create output directory: {dir}"))?;
879
880 for asset in &filtered {
881 let asset_name = asset["name"].as_str().unwrap_or("unknown");
882 let asset_url = asset["browser_download_url"]
883 .as_str()
884 .context("asset missing download URL")?;
885 let asset_size = asset["size"].as_u64().unwrap_or(0);
886
887 let output_path = std::path::Path::new(dir).join(asset_name);
888
889 if skip_existing && output_path.exists() {
891 println!("Skipping {asset_name} (already exists)");
892 continue;
893 }
894
895 print!(
897 "Downloading {asset_name} ({}) ... ",
898 format_size(asset_size)
899 );
900 std::io::stdout().flush().ok();
901
902 let response = client
903 .get_absolute(asset_url)
904 .with_context(|| format!("failed to download {asset_name}"))?;
905
906 let status = response.status();
907 if !status.is_success() {
908 anyhow::bail!("failed to download '{asset_name}': HTTP {status}");
909 }
910
911 let data = response
912 .bytes()
913 .with_context(|| format!("failed to read response body for {asset_name}"))?;
914
915 std::fs::write(&output_path, &data)
916 .with_context(|| format!("failed to write {}", output_path.display()))?;
917
918 println!("done");
919 println!("{}", output_path.display());
920 }
921
922 Ok(())
923}
924
925fn delete_asset(
933 asset_id: u64,
934 repo: Option<&str>,
935 yes: bool,
936 hostname: Option<&str>,
937) -> anyhow::Result<()> {
938 if !yes {
939 use std::io::Write;
940 print!("Are you sure you want to delete asset {asset_id}? [y/N] ");
941 std::io::stdout().flush().ok();
942
943 let mut input = String::new();
944 std::io::stdin()
945 .read_line(&mut input)
946 .context("failed to read input")?;
947 let input = input.trim().to_lowercase();
948 if input != "y" && input != "yes" {
949 println!("Cancelled.");
950 return Ok(());
951 }
952 }
953
954 let host = hostname.unwrap_or("github.com");
955 let client = Client::new(host).context("failed to create HTTP client")?;
956
957 let spec = if let Some(r) = repo {
958 parse_repo_spec(r).with_context(|| format!("invalid repository: {r}"))?
959 } else {
960 detect_remote().context("could not detect repository from git remote")?
961 };
962
963 let path = format!(
964 "/repos/{}/{}/releases/assets/{asset_id}",
965 spec.owner, spec.repo
966 );
967
968 let response = client
969 .request("DELETE", &path, &[], None)
970 .context("failed to delete asset")?;
971
972 let status = response.status();
973 if !status.is_success() {
974 let err_body: serde_json::Value = response.json().unwrap_or_default();
975 let msg = err_body["message"].as_str().unwrap_or("delete failed");
976 anyhow::bail!("failed to delete asset {asset_id}: {msg}");
977 }
978
979 println!("Asset {asset_id} deleted.");
980 Ok(())
981}
982
983fn glob_match(pattern: &str, name: &str) -> bool {
987 let pattern = pattern.to_lowercase();
988 let name = name.to_lowercase();
989
990 if !pattern.contains('*') && !pattern.contains('?') {
992 return pattern == name;
993 }
994
995 glob_match_impl(&pattern, &name)
997}
998
999fn glob_match_impl(pattern: &str, name: &str) -> bool {
1000 let mut pi = pattern.chars();
1001 let mut ni = name.chars();
1002
1003 loop {
1004 match (pi.next(), ni.next()) {
1005 (None | Some('*'), None) => return true,
1006 (Some('*'), Some(_)) => {
1007 let rest_pattern: String = pi.collect();
1008 if rest_pattern.is_empty() {
1009 return true;
1010 }
1011 let remaining: String = ni.collect();
1012 for i in 0..=remaining.len() {
1013 if glob_match_impl(&rest_pattern, &remaining[i..]) {
1014 return true;
1015 }
1016 }
1017 return false;
1018 }
1019 (Some('?'), Some(_)) => {}
1020 (Some(pc), Some(nc)) if pc == nc => {}
1021 _ => return false,
1022 }
1023 }
1024}
1025
1026fn print_release_view(release: &serde_json::Value) {
1030 let tag = release["tag_name"].as_str().unwrap_or("—");
1031 let name = release["name"].as_str().unwrap_or("—");
1032 let body = release["body"].as_str().unwrap_or("");
1033 let html_url = release["html_url"].as_str().unwrap_or("—");
1034 let published = release["published_at"]
1035 .as_str()
1036 .map_or_else(|| "—".to_string(), format_date);
1037 let author = release["author"]["login"].as_str().unwrap_or("—");
1038 let is_draft = release["draft"].as_bool().unwrap_or(false);
1039 let is_prerelease = release["prerelease"].as_bool().unwrap_or(false);
1040 let status = if is_draft {
1041 "Draft"
1042 } else if is_prerelease {
1043 "Pre-release"
1044 } else {
1045 "Latest"
1046 };
1047
1048 println!("Tag: {tag}");
1049 println!("Name: {name}");
1050 println!("Status: {status}");
1051 println!("Published: {published}");
1052 println!("Author: {author}");
1053 println!("URL: {html_url}");
1054
1055 if !body.is_empty() {
1056 println!();
1057 println!("{body}");
1058 }
1059
1060 let assets = release["assets"].as_array();
1062 if let Some(assets) = assets {
1063 if !assets.is_empty() {
1064 println!();
1065 println!("Assets:");
1066 let name_width = 40;
1067 let size_width = 12;
1068 let dl_width = 12;
1069 println!(
1070 " {:<name_width$} {:>size_width$} {:>dl_width$}",
1071 "NAME", "SIZE", "DOWNLOADS"
1072 );
1073 for asset in assets {
1074 let asset_name = asset["name"].as_str().unwrap_or("—");
1075 let size = asset["size"]
1076 .as_u64()
1077 .map_or_else(|| "—".to_string(), format_size);
1078 let downloads = asset["download_count"]
1079 .as_u64()
1080 .map_or_else(|| "—".to_string(), |d| d.to_string());
1081 let name_truncated = crate::cmd::util::truncate(asset_name, name_width);
1082 println!(
1083 " {name_truncated:<name_width$} {size:>size_width$} {downloads:>dl_width$}"
1084 );
1085 }
1086 }
1087 }
1088}
1089
1090#[allow(clippy::cast_precision_loss)]
1092fn format_size(bytes: u64) -> String {
1093 const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
1094 let mut size = bytes as f64;
1095 let mut unit_idx = 0;
1096 while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
1097 size /= 1024.0;
1098 unit_idx += 1;
1099 }
1100 if unit_idx == 0 {
1101 format!("{bytes} B")
1102 } else {
1103 format!("{size:.1} {}", UNITS[unit_idx])
1104 }
1105}
1106
1107fn open_in_browser(url: &str) {
1109 #[cfg(target_os = "linux")]
1110 {
1111 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
1112 }
1113 #[cfg(target_os = "macos")]
1114 {
1115 let _ = std::process::Command::new("open").arg(url).spawn();
1116 }
1117 #[cfg(target_os = "windows")]
1118 {
1119 let _ = std::process::Command::new("cmd")
1120 .args(["/c", "start", url])
1121 .spawn();
1122 }
1123}
1124
1125#[cfg(test)]
1126#[allow(clippy::expect_used)]
1127mod tests {
1128 use super::*;
1129 use serde_json::json;
1130
1131 #[test]
1132 fn print_release_table_basic() {
1133 let releases = vec![json!({
1134 "tag_name": "v1.0.0",
1135 "name": "First Release",
1136 "published_at": "2024-01-15T10:30:00Z",
1137 "draft": false,
1138 "prerelease": false
1139 })];
1140 print_release_table(&releases);
1141 }
1142
1143 #[test]
1144 fn print_release_table_empty() {
1145 let releases: Vec<serde_json::Value> = vec![];
1146 print_release_table(&releases);
1147 }
1148
1149 #[test]
1150 fn print_release_table_multiple() {
1151 let releases = vec![
1152 json!({
1153 "tag_name": "v1.0.0",
1154 "name": "First Release",
1155 "published_at": "2024-01-15T10:30:00Z",
1156 "draft": false,
1157 "prerelease": false
1158 }),
1159 json!({
1160 "tag_name": "v2.0.0-beta",
1161 "name": "Beta Release",
1162 "published_at": "2024-03-01T00:00:00Z",
1163 "draft": false,
1164 "prerelease": true
1165 }),
1166 json!({
1167 "tag_name": "v2.0.0-wip",
1168 "name": "Work in Progress",
1169 "published_at": null,
1170 "draft": true,
1171 "prerelease": false
1172 }),
1173 ];
1174 print_release_table(&releases);
1175 }
1176
1177 #[test]
1178 fn print_release_table_null_fields() {
1179 let releases = vec![json!({
1180 "tag_name": null,
1181 "name": null,
1182 "published_at": null,
1183 "draft": null,
1184 "prerelease": null
1185 })];
1186 print_release_table(&releases);
1187 }
1188
1189 #[test]
1190 fn print_release_view_basic() {
1191 let release = json!({
1192 "tag_name": "v1.0.0",
1193 "name": "First Release",
1194 "body": "Release notes here",
1195 "html_url": "https://github.com/owner/repo/releases/tag/v1.0.0",
1196 "published_at": "2024-01-15T10:30:00Z",
1197 "author": { "login": "octocat" },
1198 "draft": false,
1199 "prerelease": false,
1200 "assets": [
1201 {
1202 "name": "app-linux-amd64.tar.gz",
1203 "size": 5_242_880,
1204 "download_count": 42
1205 },
1206 {
1207 "name": "app-darwin-amd64.tar.gz",
1208 "size": 4_194_304,
1209 "download_count": 18
1210 }
1211 ]
1212 });
1213 print_release_view(&release);
1214 }
1215
1216 #[test]
1217 fn print_release_view_draft() {
1218 let release = json!({
1219 "tag_name": "v2.0.0-beta",
1220 "name": null,
1221 "body": "",
1222 "html_url": "https://github.com/owner/repo/releases/tag/v2.0.0-beta",
1223 "published_at": null,
1224 "author": null,
1225 "draft": true,
1226 "prerelease": false,
1227 "assets": []
1228 });
1229 print_release_view(&release);
1230 }
1231
1232 #[test]
1233 fn print_release_view_prerelease() {
1234 let release = json!({
1235 "tag_name": "v2.0.0-rc1",
1236 "name": "Release Candidate 1",
1237 "body": "Pre-release testing",
1238 "html_url": "https://github.com/owner/repo/releases/tag/v2.0.0-rc1",
1239 "published_at": "2024-06-01T00:00:00Z",
1240 "author": { "login": "devbot" },
1241 "draft": false,
1242 "prerelease": true,
1243 "assets": null
1244 });
1245 print_release_view(&release);
1246 }
1247
1248 #[test]
1249 fn format_size_bytes() {
1250 assert_eq!(format_size(0), "0 B");
1251 assert_eq!(format_size(500), "500 B");
1252 assert_eq!(format_size(1024), "1.0 KB");
1253 assert_eq!(format_size(1_048_576), "1.0 MB");
1254 assert_eq!(format_size(1_073_741_824), "1.0 GB");
1255 assert_eq!(format_size(1_536), "1.5 KB");
1256 }
1257
1258 #[test]
1259 fn open_in_browser_does_not_panic() {
1260 open_in_browser("https://example.com");
1262 }
1263
1264 #[test]
1265 fn detect_mime_type_common() {
1266 assert_eq!(detect_mime_type("app.zip"), "application/zip");
1267 assert_eq!(detect_mime_type("app.tar.gz"), "application/gzip");
1268 assert_eq!(detect_mime_type("app.dmg"), "application/x-apple-diskimage");
1269 assert_eq!(
1270 detect_mime_type("app.deb"),
1271 "application/vnd.debian.binary-package"
1272 );
1273 assert_eq!(detect_mime_type("app.msi"), "application/x-msi");
1274 assert_eq!(detect_mime_type("app.exe"), "application/x-msdownload");
1275 assert_eq!(detect_mime_type("app.txt"), "text/plain");
1276 assert_eq!(detect_mime_type("app.json"), "application/json");
1277 assert_eq!(detect_mime_type("app.png"), "image/png");
1278 assert_eq!(detect_mime_type("app.jpg"), "image/jpeg");
1279 assert_eq!(detect_mime_type("app.pdf"), "application/pdf");
1280 }
1281
1282 #[test]
1283 fn detect_mime_type_unknown() {
1284 assert_eq!(detect_mime_type("app.xyz"), "application/octet-stream");
1285 assert_eq!(detect_mime_type("app"), "application/octet-stream");
1286 }
1287
1288 #[test]
1289 fn glob_match_exact() {
1290 assert!(glob_match("app.zip", "app.zip"));
1291 assert!(!glob_match("app.zip", "app.tar.gz"));
1292 }
1293
1294 #[test]
1295 fn glob_match_star() {
1296 assert!(glob_match("*.zip", "app.zip"));
1297 assert!(glob_match("*.tar.gz", "app.tar.gz"));
1298 assert!(!glob_match("*.zip", "app.tar.gz"));
1299 assert!(glob_match("app-*", "app-linux-amd64"));
1300 }
1301
1302 #[test]
1303 fn glob_match_question() {
1304 assert!(glob_match("app.???", "app.zip"));
1305 assert!(!glob_match("app.???", "app.tar.gz"));
1306 }
1307
1308 #[test]
1309 fn glob_match_case_insensitive() {
1310 assert!(glob_match("*.ZIP", "app.zip"));
1311 assert!(glob_match("App-*", "app-linux"));
1312 }
1313}