use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize;
use void_core::collab::invite::{Invite, INVITE_TYPE_V1};
use void_core::collab::manifest::{ecies_wrap_key, load_manifest, load_repo_key};
use crate::context::{find_void_dir, load_identity_cached, resolve_ref, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteOutput {
pub invite_json: String,
pub target: String,
}
pub struct InviteArgs {
pub target: String,
}
pub fn run(cwd: &Path, args: InviteArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("contributors invite", opts, |ctx| {
let void_dir = find_void_dir(cwd)?;
let identity = load_identity_cached()?;
let caller_signing = identity.signing_pubkey();
ctx.progress("Loading manifest...");
let manifest = load_manifest(&void_dir)
.map_err(void_err_to_cli)?
.ok_or_else(|| {
CliError::not_found("No manifest found. Initialize with 'void contributors add'.")
})?;
if !manifest.is_owner_or_identity(&caller_signing) {
return Err(CliError::invalid_args(
"Only the repository owner can create invites",
));
}
let contributor = find_contributor_by_target(&manifest, &args.target)?;
let recipient_signing = contributor.identity.signing.clone();
let recipient_pubkey = contributor.identity.recipient.clone();
ctx.progress("Building invite...");
let repo_key = load_repo_key(&void_dir, Some(&identity)).map_err(void_err_to_cli)?;
let wrapped = ecies_wrap_key(&repo_key, &recipient_pubkey)
.map_err(|e| CliError::internal(format!("ECIES key wrapping failed: {e}")))?;
let head_bytes = resolve_ref(&void_dir, "HEAD")?;
let head_cid_obj =
void_core::cid::from_bytes(head_bytes.as_bytes()).map_err(void_err_to_cli)?;
let head_cid = head_cid_obj.to_string();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut invite = Invite {
invite_type: INVITE_TYPE_V1.to_string(),
repo_name: manifest.repo_name.clone(),
repo_id: manifest.repo_id.clone(),
head_cid,
wrapped_key: wrapped,
for_recipient: recipient_signing.clone(),
from_owner: caller_signing,
created_at: timestamp,
signature: vec![],
};
invite.sign(identity.signing_key());
let invite_json = serde_json::to_string_pretty(&invite)
.map_err(|e| CliError::internal(format!("JSON serialization failed: {e}")))?;
if !ctx.use_json() {
let hex = recipient_signing.to_hex();
let recipient_label = contributor
.name
.as_deref()
.unwrap_or(&hex[..16]);
ctx.info(format!("Invite created for {}", recipient_label));
ctx.info("");
ctx.info("Share this invite JSON with the contributor:");
ctx.info(&invite_json);
}
Ok(InviteOutput {
invite_json,
target: args.target,
})
})
}
fn find_contributor_by_target<'a>(
manifest: &'a void_core::collab::Manifest,
target: &str,
) -> Result<&'a void_core::collab::Contributor, CliError> {
if let Some(contrib) = manifest
.contributors
.iter()
.find(|c| c.name.as_deref() == Some(target))
{
return Ok(contrib);
}
let target_lower = target.to_lowercase();
let matches: Vec<_> = manifest
.contributors
.iter()
.filter(|c| c.identity.signing.to_hex().starts_with(&target_lower))
.collect();
match matches.len() {
0 => Err(CliError::not_found(format!(
"No contributor matching '{}'. Use 'void contributors list' to see contributors.",
target
))),
1 => Ok(matches[0]),
_ => Err(CliError::invalid_args(format!(
"Ambiguous target '{}': matches {} contributors. Use a longer prefix.",
target,
matches.len()
))),
}
}