void-cli 0.0.3

CLI for void — anonymous encrypted source control
//! Create a new commit from staged changes.
//!
//! Commits the current staged files to create a new snapshot in the void repository.

use serde::Serialize;
use std::path::Path;
use void_core::cid;
use void_core::cid::ToVoidCid;
use void_core::metadata::ShardMap;
use void_core::pipeline::{commit_workspace, CommitOptions, SealOptions};
use void_core::refs;
use void_core::shard::PaddingStrategy;

use crate::context::{open_repo, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};

/// Command-line arguments for commit.
#[derive(Debug)]
pub struct CommitArgs {
    pub message: String,
    pub sign: bool,
    pub no_sign: bool,
    pub target_shard_size: Option<u64>,
    pub max_shard_size: Option<u64>,
    pub mmap_threshold: Option<u64>,
    pub padding: Option<String>,
    pub allow_data_loss: bool,
}

/// JSON output for the commit command (matches TypeScript CLI).
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitOutput {
    /// CID of the created commit
    pub commit: String,
    /// Parent commit CID (null for initial commit)
    pub parent: Option<String>,
    /// Commit message
    pub message: String,
    /// Whether the commit was signed
    pub signed: bool,
    /// Total files in the commit
    pub files: u64,
    /// Raw bytes read
    pub bytes: u64,
    /// Number of files that changed
    pub files_changed: u64,
    /// Number of shards created
    pub shards: u64,
    /// Number of shards that changed
    pub shards_changed: u64,
    /// Detailed statistics
    pub stats: CommitStats,
}

/// Detailed commit statistics.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitStats {
    pub bytes_read: u64,
    pub bytes_compressed: u64,
    pub bytes_encrypted: u64,
    pub walk_ms: u64,
    pub compress_ms: u64,
    pub total_ms: u64,
}

/// Parse padding strategy from string.
fn parse_padding_strategy(s: &str) -> Result<PaddingStrategy, CliError> {
    match s.to_lowercase().as_str() {
        "none" => Ok(PaddingStrategy::None),
        "power2" => Ok(PaddingStrategy::PowerOfTwo),
        "buckets" => Ok(PaddingStrategy::Buckets),
        other => other
            .parse::<usize>()
            .map(PaddingStrategy::Fixed)
            .map_err(|_| {
                CliError::invalid_args(format!(
                    "Invalid padding strategy '{}'. Use: none, power2, buckets, or a number",
                    other
                ))
            }),
    }
}

/// Validate signing flags and decide whether to clear the signing key.
///
/// Signing behavior:
/// - If --no-sign: clear the signing key from ctx
/// - If --sign and no key loaded: error
/// - If key loaded: auto-sign (default)
/// - If no key loaded: skip signing silently
fn validate_signing_flags(
    sign: bool,
    no_sign: bool,
    has_key: bool,
) -> Result<bool, CliError> {
    // Validate mutual exclusion
    if sign && no_sign {
        return Err(CliError::invalid_args(
            "--sign and --no-sign are mutually exclusive",
        ));
    }

    // Explicit opt-out
    if no_sign {
        return Ok(false); // clear signing key
    }

    // Explicit --sign but no key exists
    if sign && !has_key {
        return Err(CliError::not_found(
            "No identity found. Run 'void identity init' first to create signing keys.",
        ));
    }

    Ok(has_key) // keep signing key if present
}

/// Create a new commit from staged changes.
///
/// # Arguments
///
/// * `cwd` - Current working directory
/// * `args` - Commit arguments
/// * `opts` - CLI options
///
/// # Errors
///
/// Returns an error if:
/// - Not in a void repository
/// - No changes staged for commit
/// - Failed to create commit
/// - Signing requested but no identity exists
pub fn run(cwd: &Path, args: CommitArgs, opts: &CliOptions) -> Result<(), CliError> {
    run_command("commit", opts, |ctx| {
        ctx.progress("Preparing commit...");

        // Open repo via SDK (loads key, config, signing key, repo secret)
        let repo = open_repo(cwd)?;
        let mut void_ctx = repo.context().clone();

        // Handle --sign / --no-sign flags
        let keep_signing = validate_signing_flags(
            args.sign,
            args.no_sign,
            void_ctx.crypto.signing_key.is_some(),
        )?;
        if !keep_signing {
            void_ctx.crypto.signing_key = None;
        }
        let is_signed = void_ctx.crypto.signing_key.is_some();

        // Apply CLI overrides to seal config
        if let Some(target) = args.target_shard_size {
            void_ctx.seal.target_shard_size = target;
            // Also update max if not explicitly provided
            if args.max_shard_size.is_none() {
                void_ctx.seal.max_shard_size = target.saturating_mul(3) / 2;
            }
        }
        if let Some(max) = args.max_shard_size {
            void_ctx.seal.max_shard_size = max;
        }
        if let Some(threshold) = args.mmap_threshold {
            void_ctx.seal.mmap_threshold = threshold;
        }
        if let Some(ref s) = args.padding {
            void_ctx.seal.padding = parse_padding_strategy(s)?;
        }

        // Read HEAD to get parent CID
        let parent_cid = refs::resolve_head(repo.void_dir()).map_err(void_err_to_cli)?;

        // Store parent CID string for output
        let parent_cid_str = parent_cid.as_ref().map(|commit_cid| {
            cid::from_bytes(commit_cid.as_bytes())
                .map(|c| c.to_string())
                .unwrap_or_else(|_| hex::encode(commit_cid.as_bytes()))
        });

        ctx.verbose(format!(
            "Parent CID: {}",
            parent_cid_str.as_deref().unwrap_or("none (initial commit)")
        ));

        // Build seal options
        let seal_opts = SealOptions {
            ctx: void_ctx,
            shard_map: ShardMap::new(64), // DEFAULT_RANGE_COUNT
            content_key: None,
            parent_content_key: None,
        };

        // Build commit options
        let commit_opts = CommitOptions {
            seal: seal_opts,
            message: args.message.clone(),
            parent_cid,
            allow_data_loss: args.allow_data_loss,
            foreign_parent: false,
        };

        ctx.progress("Creating commit...");

        // Execute commit
        let result = commit_workspace(commit_opts).map_err(void_err_to_cli)?;

        let commit_cid_str = result.commit_cid.to_cid_string();

        // Human-readable output
        if !ctx.use_json() {
            // Show short CID (first 12 chars) + message
            let short_cid = &commit_cid_str[..12.min(commit_cid_str.len())];
            ctx.info(format!("Created commit {}...", short_cid));
            ctx.info(format!("  Files: {}", result.stats.files_sealed));
            ctx.info(format!("  Size: {} bytes", result.stats.bytes_read));
            if result.stats.files_changed > 0 {
                ctx.info(format!("  Changed: {} files", result.stats.files_changed));
            }
            if is_signed {
                ctx.info("  Signed: yes".to_string());
            }
        }

        Ok(CommitOutput {
            commit: commit_cid_str,
            parent: parent_cid_str,
            message: args.message,
            signed: is_signed,
            files: result.stats.files_sealed as u64,
            bytes: result.stats.bytes_read as u64,
            files_changed: result.stats.files_changed as u64,
            shards: result.stats.shards_created as u64,
            shards_changed: result.stats.shards_changed as u64,
            stats: CommitStats {
                bytes_read: result.stats.bytes_read as u64,
                bytes_compressed: result.stats.bytes_compressed as u64,
                bytes_encrypted: result.stats.bytes_encrypted as u64,
                walk_ms: result.stats.walk_ms as u64,
                compress_ms: result.stats.compress_ms as u64,
                total_ms: result.stats.total_ms as u64,
            },
        })
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::output::CliOptions;
    use std::fs;
    use tempfile::tempdir;
    use void_core::crypto;

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

    fn default_args(message: &str) -> CommitArgs {
        CommitArgs {
            message: message.to_string(),
            sign: false,
            no_sign: false,
            target_shard_size: None,
            max_shard_size: None,
            mmap_threshold: None,
            padding: None,
            allow_data_loss: false,
        }
    }

    fn setup_test_repo() -> (tempfile::TempDir, std::path::PathBuf, tempfile::TempDir, crate::context::VoidHomeGuard) {
        let dir = tempdir().unwrap();
        let void_dir = dir.path().join(".void");
        fs::create_dir_all(void_dir.join("objects")).unwrap();

        // Create key and manifest-based access
        let key = crypto::generate_key();
        let home = tempdir().unwrap();
        let guard = crate::context::setup_test_manifest(&void_dir, &key, home.path());

        // Create config file with repoSecret
        let repo_secret = hex::encode(crypto::generate_key());
        fs::write(
            void_dir.join("config.json"),
            format!(r#"{{"repoSecret": "{}"}}"#, repo_secret),
        )
        .unwrap();

        // Create a test file to commit
        fs::write(dir.path().join("test.txt"), "hello world").unwrap();

        (dir, void_dir, home, guard)
    }

    #[test]
    fn test_commit_creates_commit() {
        let (dir, void_dir, _home, _guard) = setup_test_repo();

        let result = run(dir.path(), default_args("test commit"), &default_opts());
        assert!(result.is_ok());

        // HEAD should exist after commit
        let head_path = void_dir.join("HEAD");
        assert!(head_path.exists() || void_dir.join("refs/heads/trunk").exists());
    }

    #[test]
    fn test_commit_not_initialized() {
        let dir = tempdir().unwrap();
        // Don't create .void directory

        let result = run(dir.path(), default_args("test commit"), &default_opts());
        assert!(result.is_err());
    }

    #[test]
    fn test_commit_default_message() {
        let (dir, _void_dir, _home, _guard) = setup_test_repo();

        // Default message from CLI is "commit"
        let args = CommitArgs {
            message: "commit".to_string(),
            ..default_args("")
        };

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

    #[test]
    fn test_commit_sign_with_identity_succeeds() {
        let (dir, _void_dir, _home, _guard) = setup_test_repo();

        // With manifest-based identity, --sign should succeed
        let args = CommitArgs {
            sign: true,
            ..default_args("test commit")
        };

        let result = run(dir.path(), args, &default_opts());

        assert!(result.is_ok());
    }

    #[test]
    fn test_commit_no_sign_errors_in_collab_mode() {
        let (dir, _void_dir, _home, _guard) = setup_test_repo();

        // In collaboration mode (manifest present), --no-sign is rejected
        // because commits must be signed
        let args = CommitArgs {
            no_sign: true,
            ..default_args("test commit")
        };

        let result = run(dir.path(), args, &default_opts());

        assert!(result.is_err());
    }

    #[test]
    fn test_parse_padding_strategy() {
        assert!(matches!(
            parse_padding_strategy("none"),
            Ok(PaddingStrategy::None)
        ));
        assert!(matches!(
            parse_padding_strategy("NONE"),
            Ok(PaddingStrategy::None)
        ));
        assert!(matches!(
            parse_padding_strategy("power2"),
            Ok(PaddingStrategy::PowerOfTwo)
        ));
        assert!(matches!(
            parse_padding_strategy("buckets"),
            Ok(PaddingStrategy::Buckets)
        ));
        assert!(matches!(
            parse_padding_strategy("4096"),
            Ok(PaddingStrategy::Fixed(4096))
        ));
        assert!(parse_padding_strategy("invalid").is_err());
    }
}