void-cli 0.0.4

CLI for void — anonymous encrypted source control
//! Seal workspace into encrypted shards without creating a commit.
//!
//! This is a low-level command that creates a sealed snapshot of the workspace
//! and returns the metadata CID. Unlike `commit`, it does not create a commit
//! object or update HEAD.

use serde::Serialize;
use std::path::Path;
use void_core::cid::ToVoidCid;
use void_core::metadata::ShardMap;
use void_core::pipeline::{seal_workspace, SealOptions};
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 seal.
#[derive(Debug)]
pub struct SealArgs {
    pub target_shard_size: Option<u64>,
    pub max_shard_size: Option<u64>,
    pub padding: Option<String>,
}

/// JSON output for the seal command.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SealOutput {
    /// CID of the metadata bundle
    pub metadata: String,
    /// Total files sealed
    pub files: u64,
    /// Number of shards created
    pub shards: u64,
    /// Detailed statistics
    pub stats: SealStats,
}

/// Detailed seal statistics.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SealStats {
    pub bytes_read: u64,
    pub bytes_compressed: 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
                ))
            }),
    }
}

/// Seal workspace into encrypted shards without creating a commit.
///
/// # Arguments
///
/// * `cwd` - Current working directory
/// * `args` - Seal arguments
/// * `opts` - CLI options
///
/// # Errors
///
/// Returns an error if:
/// - Not in a void repository
/// - Failed to seal workspace
pub fn run(cwd: &Path, args: SealArgs, opts: &CliOptions) -> Result<(), CliError> {
    run_command("seal", opts, |ctx| {
        ctx.progress("Sealing workspace...");

        // Open repo via SDK and get a mutable copy of VoidContext for overrides
        let repo = open_repo(cwd)?;
        let mut void_ctx = repo.context().clone();

        // Apply CLI overrides to seal config
        if let Some(target) = args.target_shard_size {
            void_ctx.seal.target_shard_size = target;
            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(ref s) = args.padding {
            void_ctx.seal.padding = parse_padding_strategy(s)?;
        }

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

        ctx.progress("Creating sealed snapshot...");

        // Execute seal
        let result = seal_workspace(seal_opts).map_err(void_err_to_cli)?;

        // Human-readable output
        if !ctx.use_json() {
            let metadata_cid_str = result.metadata_cid.to_cid_string();
            ctx.info(format!("Sealed workspace: {}", metadata_cid_str));
            ctx.info(format!("  Files: {}", result.stats.files_sealed));
            ctx.info(format!("  Shards: {}", result.stats.shards_created));
            ctx.info(format!("  Size: {} bytes", result.stats.bytes_read));
        }

        Ok(SealOutput {
            metadata: result.metadata_cid.to_cid_string(),
            files: result.stats.files_sealed as u64,
            shards: result.stats.shards_created as u64,
            stats: SealStats {
                bytes_read: result.stats.bytes_read as u64,
                bytes_compressed: result.stats.bytes_compressed 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() -> SealArgs {
        SealArgs {
            target_shard_size: None,
            max_shard_size: None,
            padding: None,
        }
    }

    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
        let key = crypto::generate_key();
        let home = tempdir().unwrap();
        let guard = crate::context::setup_test_manifest(&void_dir, &key, home.path());

        // Create config file
        fs::write(void_dir.join("config.json"), "{}").unwrap();

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

        (dir, void_dir, home, guard)
    }

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

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

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

        let result = run(dir.path(), default_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());
    }
}