void-cli 0.0.4

CLI for void — anonymous encrypted source control
//! Contributors command — manage repository contributors.
//!
//! List, add, remove, and rename contributors in a multi-user void repository.
//! Requires an identity (`void identity init`) and a manifest-enabled repo.

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,
};

// ============================================================================
// Output types
// ============================================================================

#[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,
}

// ============================================================================
// Subcommand enum
// ============================================================================

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,
}

// ============================================================================
// Unified output
// ============================================================================

#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum ContributorsOutput {
    List(ListOutput),
    Add(AddOutput),
    Remove(RemoveOutput),
    Rename(RenameOutput),
}

// ============================================================================
// Implementation
// ============================================================================

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(),
                }))
            }
        }
    })
}