use crate::client::Client;
use crate::config;
use crate::output;
use clap::{Args, Subcommand, ValueEnum};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum OutputFormat {
Table,
Json,
}
#[derive(Args)]
pub struct ReleaseArgs {
#[command(subcommand)]
pub command: ReleaseCommand,
}
#[derive(Subcommand)]
pub enum ReleaseCommand {
List {
repo: Option<String>,
#[arg(long, value_enum, default_value_t = OutputFormat::Table)]
format: OutputFormat,
},
View {
tag: String,
#[arg(long)]
repo: Option<String>,
},
Create {
#[arg(long)]
repo: Option<String>,
#[arg(long)]
tag: String,
#[arg(long)]
title: Option<String>,
#[arg(long, default_value = "")]
body: String,
#[arg(long)]
prerelease: bool,
#[arg(long)]
draft: bool,
},
Delete {
tag: String,
#[arg(long)]
repo: Option<String>,
},
Update {
tag: String,
#[arg(long)]
title: Option<String>,
#[arg(long)]
body: Option<String>,
#[arg(long)]
draft: Option<bool>,
#[arg(long)]
prerelease: Option<bool>,
#[arg(long)]
repo: Option<String>,
},
Publish {
tag: String,
#[arg(long)]
repo: Option<String>,
},
Upload {
tag: String,
file: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
repo: Option<String>,
},
Download {
tag: String,
filename: String,
#[arg(long)]
output: Option<String>,
#[arg(long)]
repo: Option<String>,
},
Assets {
tag: String,
#[arg(long)]
repo: Option<String>,
},
#[command(name = "delete-asset")]
DeleteAsset {
tag: String,
asset_id: String,
#[arg(long)]
repo: Option<String>,
},
Changelog {
base: String,
head: String,
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Debug, Deserialize)]
struct Release {
tag_name: Option<String>,
title: Option<String>,
body: Option<String>,
prerelease: Option<bool>,
draft: Option<bool>,
inserted_at: Option<String>,
author: Option<ReleaseAuthor>,
#[serde(default)]
short_id: Option<String>,
#[serde(default)]
assets: Option<Vec<Asset>>,
}
#[derive(Debug, Deserialize)]
struct ReleaseAuthor {
username: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Asset {
#[serde(default)]
id: Option<String>,
#[serde(default)]
short_id: Option<String>,
#[serde(default)]
filename: Option<String>,
#[serde(default)]
size_bytes: Option<u64>,
#[serde(default)]
download_count: Option<u64>,
#[serde(default)]
content_type: Option<String>,
#[serde(default)]
inserted_at: Option<String>,
}
pub async fn run(args: ReleaseArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
ReleaseCommand::List { repo, format } => list(repo.as_deref(), format).await,
ReleaseCommand::View { tag, repo } => view(repo.as_deref(), &tag).await,
ReleaseCommand::Create {
repo,
tag,
title,
body,
prerelease,
draft,
} => {
create(
repo.as_deref(),
&tag,
title.as_deref(),
&body,
prerelease,
draft,
)
.await
}
ReleaseCommand::Delete { tag, repo } => delete(repo.as_deref(), &tag).await,
ReleaseCommand::Update {
tag,
title,
body,
draft,
prerelease,
repo,
} => {
update(
repo.as_deref(),
&tag,
title.as_deref(),
body.as_deref(),
draft,
prerelease,
)
.await
}
ReleaseCommand::Publish { tag, repo } => publish(repo.as_deref(), &tag).await,
ReleaseCommand::Upload {
tag,
file,
name,
repo,
} => upload(repo.as_deref(), &tag, &file, name.as_deref()).await,
ReleaseCommand::Download {
tag,
filename,
output,
repo,
} => download(repo.as_deref(), &tag, &filename, output.as_deref()).await,
ReleaseCommand::Assets { tag, repo } => assets(repo.as_deref(), &tag).await,
ReleaseCommand::DeleteAsset {
tag,
asset_id,
repo,
} => delete_asset(repo.as_deref(), &tag, &asset_id).await,
ReleaseCommand::Changelog { base, head, repo } => {
changelog(repo.as_deref(), &base, &head).await
}
}
}
async fn list(repo: Option<&str>, format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/releases")).await?;
let releases_val = resp
.get("releases")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp);
if matches!(format, OutputFormat::Json) {
println!("{}", serde_json::to_string(&releases_val)?);
return Ok(());
}
let releases: Vec<Release> = serde_json::from_value(releases_val).unwrap_or_default();
output::header(&format!("Releases ({org}/{name})"));
let rows: Vec<Vec<String>> = releases
.iter()
.map(|r| {
let flags = [
r.prerelease.unwrap_or(false).then_some("pre"),
r.draft.unwrap_or(false).then_some("draft"),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(", ");
vec![
r.tag_name.clone().unwrap_or_default(),
r.title.clone().unwrap_or_default(),
flags,
r.inserted_at
.as_deref()
.map(output::format_time)
.unwrap_or_default(),
]
})
.collect();
output::print_table(&["TAG", "TITLE", "FLAGS", "CREATED"], &rows);
Ok(())
}
async fn view(repo: Option<&str>, tag: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/releases/{tag}")).await?;
let rel: Release = if let Some(obj) = resp.get("release") {
serde_json::from_value(obj.clone())?
} else if let Some(obj) = resp.get("data") {
serde_json::from_value(obj.clone())?
} else {
serde_json::from_value(resp)?
};
output::header(&format!(
"Release {}",
rel.tag_name.as_deref().unwrap_or(tag)
));
if let Some(ref title) = rel.title {
output::detail("Title", title);
}
if let Some(ref short_id) = rel.short_id {
output::detail("ID", short_id);
}
if rel.prerelease.unwrap_or(false) {
output::detail("Prerelease", "yes");
}
if rel.draft.unwrap_or(false) {
output::detail("Draft", "yes");
}
if let Some(ref author) = rel.author {
if let Some(ref name) = author.username {
output::detail("Author", name);
}
}
if let Some(ref ts) = rel.inserted_at {
output::detail("Created", &output::format_time(ts));
}
if let Some(ref body) = rel.body {
if !body.is_empty() {
println!("\n{body}");
}
}
if let Some(ref asset_list) = rel.assets {
if !asset_list.is_empty() {
println!();
output::header("Assets");
let rows: Vec<Vec<String>> = asset_list
.iter()
.map(|a| {
vec![
a.filename.clone().unwrap_or_default(),
format_size(a.size_bytes.unwrap_or(0)),
a.download_count.unwrap_or(0).to_string(),
a.short_id.clone().unwrap_or_default(),
]
})
.collect();
output::print_table(&["FILENAME", "SIZE", "DOWNLOADS", "ID"], &rows);
}
}
Ok(())
}
async fn create(
repo: Option<&str>,
tag: &str,
title: Option<&str>,
body: &str,
prerelease: bool,
draft: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/releases"),
&serde_json::json!({
"tag_name": tag,
"title": title.unwrap_or(tag),
"body": body,
"prerelease": prerelease,
"draft": draft,
}),
)
.await?;
output::success(&format!("Created release {tag}"));
let config = crate::config::Config::load()?;
if let Some(ref url) = config.server_url {
println!("\n {url}/{org}/{name}/releases/{tag}");
}
Ok(())
}
async fn delete(repo: Option<&str>, tag: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!("/{org}/{name}/releases/{tag}"))
.await?;
output::success(&format!("Deleted release {tag}"));
Ok(())
}
async fn update(
repo: Option<&str>,
tag: &str,
title: Option<&str>,
body: Option<&str>,
draft: Option<bool>,
prerelease: Option<bool>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::Map::new();
if let Some(t) = title {
payload.insert("title".to_string(), serde_json::json!(t));
}
if let Some(b) = body {
payload.insert("body".to_string(), serde_json::json!(b));
}
if let Some(d) = draft {
payload.insert("draft".to_string(), serde_json::json!(d));
}
if let Some(p) = prerelease {
payload.insert("prerelease".to_string(), serde_json::json!(p));
}
if payload.is_empty() {
output::warn("No fields to update. Use --title, --body, --draft, or --prerelease.");
return Ok(());
}
let _resp: serde_json::Value = client
.put(
&format!("/{org}/{name}/releases/{tag}"),
&serde_json::Value::Object(payload),
)
.await?;
output::success(&format!("Updated release {tag}"));
Ok(())
}
async fn publish(repo: Option<&str>, tag: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.post_empty(&format!("/{org}/{name}/releases/{tag}/publish"))
.await?;
output::success(&format!("Published release {tag}"));
Ok(())
}
async fn upload(
repo: Option<&str>,
tag: &str,
file: &str,
name_override: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let file_path = PathBuf::from(file);
if !file_path.exists() {
return Err(format!("File not found: {file}").into());
}
let upload_path = if let Some(override_name) = name_override {
let temp_dir = std::env::temp_dir().join("anvil-upload");
tokio::fs::create_dir_all(&temp_dir).await?;
let temp_path = temp_dir.join(override_name);
tokio::fs::copy(&file_path, &temp_path).await?;
temp_path
} else {
file_path.clone()
};
let resp = client
.upload_file(
&format!("/{org}/{name}/releases/{tag}/assets"),
&upload_path,
"file",
)
.await?;
if name_override.is_some() {
let _ = tokio::fs::remove_file(&upload_path).await;
}
let asset_name = resp
.get("asset")
.or_else(|| resp.get("data"))
.and_then(|a| a.get("filename"))
.and_then(|f| f.as_str())
.unwrap_or("(unknown)");
let asset_size = resp
.get("asset")
.or_else(|| resp.get("data"))
.and_then(|a| a.get("size_bytes"))
.and_then(|s| s.as_u64())
.unwrap_or(0);
output::success(&format!(
"Uploaded {} ({})",
asset_name,
format_size(asset_size)
));
Ok(())
}
async fn download(
repo: Option<&str>,
tag: &str,
filename: &str,
output_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/releases/{tag}/assets"))
.await?;
let assets_val = resp
.get("assets")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
let assets: Vec<Asset> = serde_json::from_value(assets_val).unwrap_or_default();
let asset = assets
.iter()
.find(|a| a.filename.as_deref() == Some(filename))
.ok_or_else(|| format!("Asset '{filename}' not found in release {tag}"))?;
let asset_id = asset
.short_id
.as_deref()
.or(asset.id.as_deref())
.ok_or("Asset has no ID")?;
let dest = if let Some(p) = output_path {
PathBuf::from(p)
} else {
PathBuf::from(filename)
};
client
.download_file(
&format!("/{org}/{name}/releases/{tag}/assets/{asset_id}/download"),
&dest,
)
.await?;
output::success(&format!("Downloaded {} to {}", filename, dest.display()));
Ok(())
}
async fn assets(repo: Option<&str>, tag: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/releases/{tag}/assets"))
.await?;
let assets_val = resp
.get("assets")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
let asset_list: Vec<Asset> = serde_json::from_value(assets_val).unwrap_or_default();
output::header(&format!("Assets for {tag} ({org}/{name})"));
let rows: Vec<Vec<String>> = asset_list
.iter()
.map(|a| {
vec![
a.filename.clone().unwrap_or_default(),
format_size(a.size_bytes.unwrap_or(0)),
a.download_count.unwrap_or(0).to_string(),
a.short_id.clone().unwrap_or_default(),
]
})
.collect();
output::print_table(&["FILENAME", "SIZE", "DOWNLOADS", "ID"], &rows);
Ok(())
}
async fn delete_asset(
repo: Option<&str>,
tag: &str,
asset_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!("/{org}/{name}/releases/{tag}/assets/{asset_id}"))
.await?;
output::success(&format!("Deleted asset {asset_id} from release {tag}"));
Ok(())
}
async fn changelog(
repo: Option<&str>,
base: &str,
head: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get_with_query(
&format!("/{org}/{name}/releases/changelog"),
&[("base", base), ("head", head)],
)
.await?;
let changelog_text = resp
.get("changelog")
.and_then(|c| c.as_str())
.or_else(|| resp.get("body").and_then(|b| b.as_str()))
.or_else(|| resp.get("data").and_then(|d| d.as_str()));
if let Some(text) = changelog_text {
output::header(&format!("Changelog: {base}..{head}"));
println!("\n{text}");
} else {
if let Some(commits) = resp.get("commits").and_then(|c| c.as_array()) {
output::header(&format!(
"Changelog: {base}..{head} ({} commits)",
commits.len()
));
for commit in commits {
let sha = commit
.get("sha")
.or_else(|| commit.get("id"))
.and_then(|s| s.as_str())
.unwrap_or("???????");
let short_sha = if sha.len() > 7 { &sha[..7] } else { sha };
let message = commit.get("message").and_then(|m| m.as_str()).unwrap_or("");
let first_line = message.lines().next().unwrap_or("");
println!(" {short_sha} {first_line}");
}
} else {
println!("{}", serde_json::to_string_pretty(&resp)?);
}
}
Ok(())
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}