use std::path::Path;
use crate::context::{find_void_dir, load_identity_cached, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};
use serde::Serialize;
use void_core::collab::manifest::{
add_contributor, list_contributors, remove_contributor, rename_contributor, ContributorId,
};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ContributorEntry {
pub name: Option<String>,
pub signing_pubkey: String,
pub recipient_pubkey: String,
pub is_owner: bool,
}
#[derive(Debug, Serialize)]
pub struct ListOutput {
pub contributors: Vec<ContributorEntry>,
pub count: usize,
}
#[derive(Debug, Serialize)]
pub struct AddOutput {
pub added: bool,
pub name: Option<String>,
pub identity: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoveOutput {
pub removed: bool,
pub name: Option<String>,
pub signing_pubkey: String,
pub key_rotation_recommended: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RenameOutput {
pub renamed: bool,
pub old_name: Option<String>,
pub new_name: String,
pub signing_pubkey: String,
}
pub enum ContributorsSubcommand {
List,
Add {
name: Option<String>,
identity: String,
},
Remove {
target: String,
},
Rename {
target: String,
new_name: String,
},
}
pub struct ContributorsArgs {
pub subcommand: ContributorsSubcommand,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum ContributorsOutput {
List(ListOutput),
Add(AddOutput),
Remove(RemoveOutput),
Rename(RenameOutput),
}
pub fn run(cwd: &Path, args: ContributorsArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("contributors", opts, |ctx| {
let void_dir = find_void_dir(cwd)?;
let identity = load_identity_cached()?;
match args.subcommand {
ContributorsSubcommand::List => {
ctx.progress("Loading contributors...");
let contribs = list_contributors(&void_dir, &identity).map_err(void_err_to_cli)?;
let entries: Vec<ContributorEntry> = contribs
.iter()
.map(|c| ContributorEntry {
name: c.name.clone(),
signing_pubkey: c.signing_pubkey.to_hex(),
recipient_pubkey: c.recipient_pubkey.to_hex(),
is_owner: c.is_owner,
})
.collect();
let count = entries.len();
if !ctx.use_json() {
if entries.is_empty() {
ctx.info("No contributors. Use 'void contributors add' to add one.");
} else {
for entry in &entries {
let name_str = entry.name.as_deref().unwrap_or("<unnamed>");
let owner_tag = if entry.is_owner { " (owner)" } else { "" };
ctx.info(format!(
"{}{} {}",
name_str,
owner_tag,
&entry.signing_pubkey[..16]
));
}
ctx.info(format!("{} contributor(s)", count));
}
}
Ok(ContributorsOutput::List(ListOutput {
contributors: entries,
count,
}))
}
ContributorsSubcommand::Add {
name,
identity: identity_str,
} => {
ctx.progress("Adding contributor...");
let contributor_id = ContributorId::from_uri(&identity_str).map_err(|e| {
CliError::invalid_args(format!("invalid identity string: {}", e))
})?;
add_contributor(&void_dir, &identity, &contributor_id, name.clone())
.map_err(void_err_to_cli)?;
if !ctx.use_json() {
let label = name.as_deref().unwrap_or("contributor");
ctx.info(format!("Added {}", label));
}
Ok(ContributorsOutput::Add(AddOutput {
added: true,
name,
identity: identity_str,
}))
}
ContributorsSubcommand::Remove { target } => {
ctx.progress("Removing contributor...");
let result =
remove_contributor(&void_dir, &identity, &target).map_err(void_err_to_cli)?;
if !ctx.use_json() {
let label = result.removed_name.as_deref().unwrap_or(&target);
ctx.info(format!("Removed contributor '{}'", label));
if result.key_rotation_recommended {
ctx.warn(
"Key rotation recommended: removed contributor had access to repo key",
);
}
}
Ok(ContributorsOutput::Remove(RemoveOutput {
removed: true,
name: result.removed_name,
signing_pubkey: result.removed_signing_pubkey.to_hex(),
key_rotation_recommended: result.key_rotation_recommended,
}))
}
ContributorsSubcommand::Rename { target, new_name } => {
ctx.progress("Renaming contributor...");
let result = rename_contributor(&void_dir, &identity, &target, new_name.clone())
.map_err(void_err_to_cli)?;
if !ctx.use_json() {
let old_label = result.old_name.as_deref().unwrap_or(&target);
ctx.info(format!("Renamed '{}' → '{}'", old_label, result.new_name));
}
Ok(ContributorsOutput::Rename(RenameOutput {
renamed: true,
old_name: result.old_name,
new_name: result.new_name,
signing_pubkey: result.signing_pubkey.to_hex(),
}))
}
}
})
}