void-cli 0.0.2

CLI for void — anonymous encrypted source control
//! Shared repo-initialization helpers used by `init`, `fork`, and `clone`.
//!
//! Two entry points:
//!
//! - [`create_void_dir_structure`] — canonical `.void/` scaffold (objects, refs, staged, HEAD).
//! - [`setup_owner_manifest`] — identity load → manifest → ECIES key wrap → config write.

use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use rand::RngCore;
use void_core::collab::manifest::{
    ecies_wrap_key, save_manifest, Contributor, ContributorId, Manifest, RepoKey,
};
use void_core::config::{Config, CoreConfig, UserConfig};

use crate::context::load_public_identity;
use crate::output::{CliError, ErrorCode};

/// Options that vary between `init` and `fork`.
pub(crate) struct NewRepoOpts {
    /// Human-friendly repo name (derived from directory name).
    pub repo_name: Option<String>,
    /// UUID identifying this repository.
    pub repo_id: Option<String>,
    /// Random 32-byte hex secret. `Some` for init, `None` for fork.
    pub repo_secret: Option<String>,
}

/// The result of [`setup_owner_manifest`], giving callers access to values
/// they need for registry registration and CLI output.
pub(crate) struct ManifestResult {
    pub manifest: Manifest,
    pub username: String,
}

/// Create the canonical `.void` directory structure.
///
/// Creates: `.void/`, `objects/`, `refs/heads/`, `refs/tags/`, `staged/`, `HEAD`.
/// This is the single source of truth — `init`, `fork`, and `clone` all call this.
pub(crate) fn create_void_dir_structure(void_dir: &Path) -> Result<(), CliError> {
    fs::create_dir_all(void_dir)
        .map_err(|e| CliError::internal(format!("failed to create .void directory: {e}")))?;
    fs::create_dir_all(void_dir.join("objects"))
        .map_err(|e| CliError::internal(format!("failed to create objects directory: {e}")))?;
    fs::create_dir_all(void_dir.join("refs/heads"))
        .map_err(|e| CliError::internal(format!("failed to create refs/heads: {e}")))?;
    fs::create_dir_all(void_dir.join("refs/tags"))
        .map_err(|e| CliError::internal(format!("failed to create refs/tags: {e}")))?;
    fs::create_dir_all(void_dir.join("staged"))
        .map_err(|e| CliError::internal(format!("failed to create staged: {e}")))?;
    fs::write(void_dir.join("HEAD"), "ref: refs/heads/trunk\n")
        .map_err(|e| CliError::internal(format!("failed to write HEAD: {e}")))?;
    Ok(())
}

/// Load identity, create owner manifest with ECIES-wrapped key, and write config.
///
/// Shared between `init` (with `repo_secret`) and `fork` (without).
/// Clone uses different logic (merges existing manifest) and does NOT call this.
pub(crate) fn setup_owner_manifest(
    void_dir: &Path,
    repo_key: &RepoKey,
    opts: NewRepoOpts,
) -> Result<ManifestResult, CliError> {
    // Load public identity (no PIN needed — only public keys for ECIES wrapping)
    let (username, signing_pubkey, recipient_pubkey, _nostr_pubkey) =
        load_public_identity().map_err(|_| {
            CliError::new(
                ErrorCode::InvalidArgs,
                "no identity found — run 'void identity init' first",
            )
        })?;
    let username = username.unwrap_or_else(|| "anonymous".to_string());

    // Create contributor manifest with owner as first contributor
    let mut manifest = Manifest::new(signing_pubkey.clone(), None);
    manifest.repo_id = opts.repo_id;
    manifest.repo_name = opts.repo_name;

    // ECIES-wrap repo key for the owner's recipient pubkey
    let wrapped = ecies_wrap_key(repo_key, &recipient_pubkey)
        .map_err(|e| CliError::internal(format!("failed to wrap key: {e}")))?;
    manifest
        .read_keys
        .wrapped
        .insert(signing_pubkey.clone(), wrapped);

    // Add owner as first contributor (self-signed, no signature verification needed)
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    manifest.contributors.push(Contributor {
        identity: ContributorId::new(signing_pubkey.clone(), recipient_pubkey),
        name: Some(username.clone()),
        nostr_pubkey: None,
        added_at: timestamp,
        added_by: signing_pubkey,
        signature: vec![],
    });

    save_manifest(void_dir, &manifest)
        .map_err(|e| CliError::internal(format!("failed to write manifest: {e}")))?;

    // Write config
    let config = Config {
        version: Some(1),
        created: Some(chrono::Utc::now().to_rfc3339()),
        repo_secret: opts.repo_secret,
        repo_id: manifest.repo_id.clone(),
        repo_name: manifest.repo_name.clone(),
        ipfs: None,
        tor: None,
        user: UserConfig {
            name: Some(username.clone()),
            email: None,
        },
        core: CoreConfig::default(),
        remote: Default::default(),
    };

    void_core::config::save(void_dir, &config)
        .map_err(|e| CliError::internal(format!("failed to write config file: {e}")))?;

    Ok(ManifestResult { manifest, username })
}

/// Generate a random 32-byte repo secret as a 64-char hex string.
pub(crate) fn generate_repo_secret() -> String {
    let mut bytes = [0u8; 32];
    rand::thread_rng().fill_bytes(&mut bytes);
    hex::encode(bytes)
}