use camino::Utf8PathBuf;
use serde::Serialize;
use std::path::Path;
use void_core::{cid, refs};
use crate::context::{find_void_dir, resolve_ref, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};
#[derive(Debug)]
pub struct TagArgs {
pub name: Option<String>,
pub list: bool,
pub target: Option<String>,
pub delete: bool,
pub force: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TagOutput {
pub action: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
pub fn run(cwd: &Path, args: TagArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("tag", opts, |ctx| {
let void_dir = find_void_dir(cwd)?;
let void_dir_utf8 = Utf8PathBuf::try_from(void_dir.clone())
.map_err(|e| CliError::internal(format!("invalid void_dir path: {}", e)))?;
if args.list || (args.name.is_none() && !args.delete) {
ctx.progress("Listing tags...");
let tags = refs::list_tags(&void_dir_utf8).map_err(void_err_to_cli)?;
if !ctx.use_json() {
if tags.is_empty() {
ctx.info("No tags found.");
} else {
for tag in &tags {
ctx.info(tag.clone());
}
}
}
return Ok(TagOutput {
action: "list".to_string(),
tags: Some(tags),
name: None,
deleted: None,
target: None,
});
}
if args.delete {
let name = args
.name
.as_ref()
.ok_or_else(|| CliError::invalid_args("tag name required for delete"))?;
ctx.progress(format!("Deleting tag '{}'...", name));
let existing = refs::read_tag(&void_dir_utf8, name).map_err(void_err_to_cli)?;
if existing.is_none() {
return Err(CliError::not_found(format!("tag '{}' not found", name)));
}
refs::delete_tag(&void_dir_utf8, name).map_err(void_err_to_cli)?;
if !ctx.use_json() {
ctx.info(format!("Deleted tag '{}'", name));
}
return Ok(TagOutput {
action: "delete".to_string(),
tags: None,
name: None,
deleted: Some(name.clone()),
target: None,
});
}
let name = args
.name
.as_ref()
.ok_or_else(|| CliError::invalid_args("tag name required"))?;
ctx.progress(format!("Creating tag '{}'...", name));
let existing = refs::read_tag(&void_dir_utf8, name).map_err(void_err_to_cli)?;
if existing.is_some() && !args.force {
return Err(CliError::conflict(format!(
"tag '{}' already exists (use --force to overwrite)",
name
)));
}
let target_ref = args.target.as_deref().unwrap_or("HEAD");
let cid_bytes = resolve_ref(&void_dir, target_ref)?;
let cid_str = cid::from_bytes(cid_bytes.as_bytes())
.map(|c| c.to_string())
.unwrap_or_else(|_| hex::encode(cid_bytes.as_bytes()));
refs::write_tag(&void_dir_utf8, name, &cid_bytes).map_err(void_err_to_cli)?;
if !ctx.use_json() {
let short_cid = if cid_str.len() > 12 {
&cid_str[..12]
} else {
&cid_str
};
ctx.info(format!("Created tag '{}' at {}", name, short_cid));
}
Ok(TagOutput {
action: "create".to_string(),
tags: None,
name: Some(name.clone()),
deleted: None,
target: Some(cid_str),
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::CliOptions;
use std::fs;
use tempfile::tempdir;
use void_core::crypto;
fn default_opts() -> CliOptions {
CliOptions {
human: true,
..Default::default()
}
}
fn setup_test_repo() -> (tempfile::TempDir, std::path::PathBuf, tempfile::TempDir, crate::context::VoidHomeGuard) {
let dir = tempdir().unwrap();
let void_dir = dir.path().join(".void");
fs::create_dir_all(void_dir.join("objects")).unwrap();
fs::create_dir_all(void_dir.join("refs/tags")).unwrap();
fs::create_dir_all(void_dir.join("refs/heads")).unwrap();
let key = crypto::generate_key();
let home = tempdir().unwrap();
let guard = crate::context::setup_test_manifest(&void_dir, &key, home.path());
let repo_secret = hex::encode(crypto::generate_key());
fs::write(
void_dir.join("config.json"),
format!(r#"{{"repoSecret": "{}"}}"#, repo_secret),
)
.unwrap();
(dir, void_dir, home, guard)
}
#[test]
fn test_list_tags_empty() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = TagArgs {
name: None,
list: false,
target: None,
delete: false,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_ok());
}
#[test]
fn test_list_tags_with_tags() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let tags_dir = void_dir.join("refs/tags");
fs::write(
tags_dir.join("v1.0.0"),
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku\n",
)
.unwrap();
fs::write(
tags_dir.join("v2.0.0"),
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku\n",
)
.unwrap();
let args = TagArgs {
name: None,
list: false,
target: None,
delete: false,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_ok());
}
#[test]
fn test_list_tags_with_explicit_flag() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = TagArgs {
name: Some("v1.0.0".to_string()),
list: true,
target: None,
delete: false,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_ok());
}
#[test]
fn test_delete_nonexistent_tag() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = TagArgs {
name: Some("nonexistent".to_string()),
list: false,
target: None,
delete: true,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_delete_requires_name() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = TagArgs {
name: None,
list: false,
target: None,
delete: true,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_create_tag_without_head_fails() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = TagArgs {
name: Some("v1.0.0".to_string()),
list: false,
target: None,
delete: false,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_tag_not_initialized() {
let dir = tempdir().unwrap();
let args = TagArgs {
name: None,
list: false,
target: None,
delete: false,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_tag_output_serialization() {
let output = TagOutput {
action: "list".to_string(),
tags: Some(vec!["v1.0.0".to_string(), "v2.0.0".to_string()]),
name: None,
deleted: None,
target: None,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"action\":\"list\""));
assert!(json.contains("\"tags\":[\"v1.0.0\",\"v2.0.0\"]"));
assert!(!json.contains("\"name\""));
assert!(!json.contains("\"deleted\""));
assert!(!json.contains("\"target\""));
}
#[test]
fn test_tag_output_create_serialization() {
let output = TagOutput {
action: "create".to_string(),
tags: None,
name: Some("v1.0.0".to_string()),
deleted: None,
target: Some("bafytest123".to_string()),
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"action\":\"create\""));
assert!(json.contains("\"name\":\"v1.0.0\""));
assert!(json.contains("\"target\":\"bafytest123\""));
assert!(!json.contains("\"tags\""));
assert!(!json.contains("\"deleted\""));
}
#[test]
fn test_tag_output_delete_serialization() {
let output = TagOutput {
action: "delete".to_string(),
tags: None,
name: None,
deleted: Some("v1.0.0".to_string()),
target: None,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"action\":\"delete\""));
assert!(json.contains("\"deleted\":\"v1.0.0\""));
assert!(!json.contains("\"tags\""));
assert!(!json.contains("\"name\""));
assert!(!json.contains("\"target\""));
}
}