void-cli 0.0.2

CLI for void — anonymous encrypted source control
//! Initialize a new void repository.
//!
//! Creates the `.void` directory structure in **collaboration mode** by default.
//! Requires an existing identity (`void identity init`). The identity's public keys
//! are used to create a contributor manifest with the user as owner and the repo key
//! ECIES-wrapped for the owner.

use rand::RngCore;
use serde::Serialize;
use std::collections::HashMap;
#[cfg(test)]
use std::fs;
use std::path::Path;
use void_core::collab::manifest::RepoKey;

use crate::context::load_public_identity;
use crate::output::{run_command, CliError, CliOptions, ErrorCode};
use crate::repo_init::{self, NewRepoOpts};

/// JSON output for the init command.
#[derive(Debug, Serialize)]
pub struct InitOutput {
    /// Path to the created .void directory.
    pub path: String,
    /// Whether the repository was created (always true on success).
    pub created: bool,
    /// UUID v4 identifying this repository.
    pub repo_id: String,
    /// Human-friendly name (derived from directory name).
    pub repo_name: String,
}

/// Initialize a new void repository in the given directory.
///
/// Requires an existing identity (created via `void identity init`).
///
/// Creates:
/// - `.void/` directory
/// - `.void/objects/` directory for object storage
/// - `.void/refs/heads/` directory for branch refs
/// - `.void/staged/` directory for staged blobs
/// - `.void/HEAD` file pointing to refs/heads/trunk
/// - `.void/contributors.json` manifest with owner as first contributor (ECIES-wrapped repo key)
/// - `.void/config.json` with default configuration including repoSecret and user.name
///
/// # Errors
///
/// Returns an error if:
/// - `.void` directory already exists (conflict)
/// - No identity exists (must run `void identity init` first)
/// - Failed to create directory or write files
pub fn run(cwd: &Path, opts: &CliOptions) -> Result<(), CliError> {
    run_command("init", opts, |ctx| {
        let void_dir = cwd.join(".void");

        // Check if already initialized
        if void_dir.exists() {
            let mut details = HashMap::new();
            details.insert(
                "path".to_string(),
                serde_json::Value::String(void_dir.display().to_string()),
            );
            return Err(CliError::with_details(
                ErrorCode::Conflict,
                "repository already initialized",
                details,
            ));
        }

        ctx.progress("Checking identity...");

        // Pre-flight: verify identity exists before creating any directories.
        // setup_owner_manifest will load identity again, but checking early
        // avoids leaving a partial .void directory on failure.
        load_public_identity().map_err(|_| {
            CliError::new(
                ErrorCode::InvalidArgs,
                "no identity found — run 'void identity init' first",
            )
        })?;

        ctx.progress("Creating .void directory...");

        // Generate repo identity
        let repo_id = uuid::Uuid::new_v4().to_string();
        // Canonicalize to resolve "." to an absolute path before extracting dir name
        let abs_cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
        let repo_name = abs_cwd
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("unnamed")
            .to_string();

        // Create .void directory structure (canonical: includes refs/tags)
        repo_init::create_void_dir_structure(&void_dir)?;
        ctx.verbose("Created .void directory structure");

        // Generate random 32-byte key (only exists in memory; persisted
        // solely as an ECIES-wrapped blob inside the manifest below).
        let mut key = [0u8; 32];
        rand::thread_rng().fill_bytes(&mut key);
        let repo_key = RepoKey::from_bytes(key);
        ctx.verbose("Generated encryption key");

        // Create manifest + config via shared helper
        let result = repo_init::setup_owner_manifest(&void_dir, &repo_key, NewRepoOpts {
            repo_name: Some(repo_name.clone()),
            repo_id: Some(repo_id.clone()),
            repo_secret: Some(repo_init::generate_repo_secret()),
        })?;
        ctx.verbose("Created contributor manifest and config");

        // Register in local repo registry (best-effort)
        match crate::registry::register_repo(&repo_id, &repo_name, cwd, "self", None) {
            Ok(_record) => {}
            Err(e) => {
                ctx.warn(format!("Failed to register repo in registry: {}", e));
            }
        }

        let void_dir_str = void_dir.display().to_string();

        // Human-readable output
        if !ctx.use_json() {
            ctx.info(format!(
                "Initialized void repository in {} (collaboration mode, owner: {})",
                void_dir_str, result.username
            ));
        }

        Ok(InitOutput {
            path: void_dir_str,
            created: true,
            repo_id,
            repo_name,
        })
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::output::CliOptions;
    use tempfile::tempdir;
    use void_core::collab::manifest::{load_manifest, SigningPubKey, RecipientPubKey};
    use void_core::collab::Identity;

    fn default_opts() -> CliOptions {
        CliOptions {
            human: true,
            ..Default::default()
        }
    }

    /// Set up a fake identity on disk so `load_public_identity()` succeeds.
    ///
    /// Creates `<home>/.void/identity/{signing.pub, recipient.pub, profile.json}`
    /// and returns a `VoidHomeGuard` that overrides `get_void_home()` for this
    /// thread. The caller must hold the guard for the duration of the test.
    fn setup_test_identity(
        home_dir: &Path,
        username: &str,
    ) -> (SigningPubKey, RecipientPubKey, crate::context::VoidHomeGuard) {
        let identity = Identity::generate();
        let signing_pub = identity.signing_pubkey();
        let recipient_pub = identity.recipient_pubkey();

        let identity_dir = home_dir.join(".void").join("identity");
        fs::create_dir_all(&identity_dir).unwrap();

        fs::write(
            identity_dir.join("signing.pub"),
            signing_pub.to_hex(),
        )
        .unwrap();
        fs::write(
            identity_dir.join("recipient.pub"),
            recipient_pub.to_hex(),
        )
        .unwrap();
        fs::write(
            identity_dir.join("profile.json"),
            format!(r#"{{"username":"{}"}}"#, username),
        )
        .unwrap();

        let guard = crate::context::VoidHomeGuard::new(home_dir);

        (signing_pub, recipient_pub, guard)
    }

    #[test]
    fn test_init_creates_void_dir() {
        let home = tempdir().unwrap();
        let _guard = setup_test_identity(home.path(), "alice");

        let dir = tempdir().unwrap();
        let result = run(dir.path(), &default_opts());
        assert!(result.is_ok());

        let void_dir = dir.path().join(".void");
        assert!(void_dir.exists());
        assert!(
            !void_dir.join("key.legacy").exists(),
            "should NOT have key.legacy (plaintext key storage eliminated)"
        );
        assert!(!void_dir.join("key").exists(), "should NOT have plaintext key");
        assert!(void_dir.join("contributors.json").exists());
        assert!(void_dir.join("config.json").exists());
        assert!(void_dir.join("objects").is_dir());
        assert!(void_dir.join("refs/heads").is_dir());
        assert!(void_dir.join("staged").is_dir());
        assert!(void_dir.join("HEAD").exists());
    }

    #[test]
    fn test_init_head_file_content() {
        let home = tempdir().unwrap();
        let _guard = setup_test_identity(home.path(), "alice");

        let dir = tempdir().unwrap();
        run(dir.path(), &default_opts()).unwrap();

        let head_content = fs::read_to_string(dir.path().join(".void/HEAD")).unwrap();
        assert_eq!(head_content, "ref: refs/heads/trunk\n");
    }

    #[test]
    fn test_init_key_wrapped_in_manifest() {
        let home = tempdir().unwrap();
        let (signing_pub, _recipient_pub, _guard) = setup_test_identity(home.path(), "alice");

        let dir = tempdir().unwrap();
        run(dir.path(), &default_opts()).unwrap();

        let void_dir = dir.path().join(".void");

        // No plaintext key should exist
        assert!(!void_dir.join("key.legacy").exists());

        // Manifest should have a wrapped key for the owner
        let manifest = load_manifest(&void_dir).unwrap().expect("manifest should exist");
        assert!(
            manifest.read_keys.wrapped.contains_key(&signing_pub),
            "manifest should contain a wrapped key for the owner"
        );
    }

    #[test]
    fn test_init_repo_secret_in_config() {
        let home = tempdir().unwrap();
        let _guard = setup_test_identity(home.path(), "alice");

        let dir = tempdir().unwrap();
        run(dir.path(), &default_opts()).unwrap();

        let config = void_core::config::load(&dir.path().join(".void")).unwrap();
        assert!(config.repo_secret.is_some());
        let repo_secret = config.repo_secret.unwrap();
        // Should be 64 hex chars (32 bytes)
        assert_eq!(repo_secret.len(), 64);
        // Should be valid hex
        assert!(hex::decode(&repo_secret).is_ok());
    }

    #[test]
    fn test_init_already_initialized() {
        let home = tempdir().unwrap();
        let _guard = setup_test_identity(home.path(), "alice");

        let dir = tempdir().unwrap();

        // First init should succeed
        run(dir.path(), &default_opts()).unwrap();

        // Second init should fail with conflict error
        let result = run(dir.path(), &default_opts());
        assert!(result.is_err());
    }

    #[test]
    fn test_init_creates_manifest() {
        let home = tempdir().unwrap();
        let _guard = setup_test_identity(home.path(), "alice");

        let dir = tempdir().unwrap();
        run(dir.path(), &default_opts()).unwrap();

        let void_dir = dir.path().join(".void");
        let manifest = load_manifest(&void_dir).unwrap().expect("manifest should exist");

        // Manifest should be self-consistent: owner is the first contributor's signing key
        let owner = &manifest.owner;
        assert_eq!(manifest.contributors.len(), 1);
        assert!(manifest.is_owner(&manifest.contributors[0].identity.signing));
        assert_eq!(&manifest.contributors[0].added_by, owner);

        // Should have a wrapped key for the owner
        assert!(manifest.read_keys.wrapped.contains_key(owner));

        // Should have repo metadata
        assert!(manifest.repo_id.is_some());
        assert!(manifest.repo_name.is_some());
    }

    #[test]
    fn test_init_no_identity_fails() {
        let home = tempdir().unwrap();
        // Point void home to empty tempdir (no identity files)
        let _guard = crate::context::VoidHomeGuard::new(home.path());

        let dir = tempdir().unwrap();
        let result = run(dir.path(), &default_opts());
        let err = result.unwrap_err();

        // Should give a clear error mentioning how to fix it
        assert!(
            err.message.contains("void identity init"),
            "error should mention 'void identity init', got: {}",
            err.message
        );

        // .void directory should NOT have been created
        assert!(!dir.path().join(".void").exists());
    }

    #[test]
    fn test_init_config_has_username() {
        let home = tempdir().unwrap();
        let _guard = setup_test_identity(home.path(), "bob");

        let dir = tempdir().unwrap();
        run(dir.path(), &default_opts()).unwrap();

        let void_dir = dir.path().join(".void");
        let config = void_core::config::load(&void_dir).unwrap();
        // Config should have a username set (matches whatever identity was loaded)
        assert!(config.user.name.is_some());

        // Username in config should match the contributor name in the manifest
        let manifest = load_manifest(&void_dir).unwrap().expect("manifest should exist");
        assert_eq!(config.user.name, manifest.contributors[0].name);
    }

    #[test]
    fn test_init_repo_name_not_unnamed() {
        let home = tempdir().unwrap();
        let _guard = setup_test_identity(home.path(), "alice");

        // Create a named subdirectory (simulates a real project dir)
        let dir = tempdir().unwrap();
        let project_dir = dir.path().join("my-project");
        fs::create_dir(&project_dir).unwrap();

        run(&project_dir, &default_opts()).unwrap();

        let config = void_core::config::load(&project_dir.join(".void")).unwrap();
        assert_eq!(
            config.repo_name,
            Some("my-project".to_string()),
            "repo_name should be derived from directory name"
        );
    }

    #[test]
    fn test_init_repo_name_with_relative_path() {
        let home = tempdir().unwrap();
        let _guard = setup_test_identity(home.path(), "alice");

        // Create a named subdirectory and use a relative path to it
        let dir = tempdir().unwrap();
        let project_dir = dir.path().join("my-repo");
        fs::create_dir(&project_dir).unwrap();

        // Use a relative path like the CLI does when cwd = "."
        // We cd into the project dir and pass "." as the path
        let old_cwd = std::env::current_dir().unwrap();
        std::env::set_current_dir(&project_dir).unwrap();

        let result = run(Path::new("."), &default_opts());

        // Restore cwd before any assertions (so cleanup works)
        std::env::set_current_dir(&old_cwd).unwrap();

        result.unwrap();

        let config = void_core::config::load(&project_dir.join(".void")).unwrap();
        assert_ne!(
            config.repo_name,
            Some("unnamed".to_string()),
            "repo_name should NOT be 'unnamed' when using relative path"
        );
        assert_eq!(
            config.repo_name,
            Some("my-repo".to_string()),
            "repo_name should be 'my-repo' even when cwd is '.'"
        );
    }
}