void-cli 0.0.4

CLI for void — anonymous encrypted source control
//! Show shard information for a commit.
//!
//! Loads a commit, its metadata bundle, and displays the shard map.

use std::path::Path;

use serde::Serialize;
use void_core::crypto::{CommitReader, EncryptedCommit, EncryptedMetadata};
use void_core::store::{FsStore, ObjectStoreExt};
use void_core::{cid, metadata};
use void_core::support::ToVoidCid;

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

/// Command-line arguments for debug-shards.
#[derive(Debug)]
pub struct DebugShardsArgs {
    /// Commit ref (defaults to HEAD).
    pub commit_ref: Option<String>,
}

/// JSON output for the debug-shards command.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DebugShardsOutput {
    /// The commit CID.
    pub commit_cid: String,
    /// The metadata bundle CID.
    pub metadata_cid: String,
    /// Number of ranges in the shard map.
    pub range_count: usize,
    /// Number of ranges with content.
    pub populated_ranges: usize,
    /// Total compressed size of all shards.
    pub total_compressed_size: u64,
    /// Shard ranges with content.
    pub shards: Vec<ShardRangeOutput>,
}

/// A shard range with content.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShardRangeOutput {
    /// Shard ID.
    pub shard_id: u64,
    /// Range start (hex).
    pub range_start: String,
    /// Range end (hex).
    pub range_end: String,
    /// Shard CID.
    pub cid: String,
    /// Compressed size.
    pub compressed_size: u64,
}

/// Run the debug-shards command.
///
/// # Arguments
///
/// * `cwd` - Current working directory
/// * `args` - Debug-shards arguments
/// * `opts` - CLI options
pub fn run(cwd: &Path, args: DebugShardsArgs, opts: &CliOptions) -> Result<(), CliError> {
    run_command("debug-shards", opts, |ctx| {
        let repo = open_repo(cwd)?;
        let vault = repo.vault();
        let objects_dir = repo.void_dir().join("objects");
        let store = FsStore::new(objects_dir).map_err(void_err_to_cli)?;

        // Resolve commit ref
        let commit_ref = args.commit_ref.as_deref().unwrap_or("HEAD");
        ctx.progress(format!("Loading commit {}...", commit_ref));

        let commit_cid_typed = resolve_ref(repo.void_dir().as_std_path(), commit_ref)?;
        let commit_cid = cid::from_bytes(commit_cid_typed.as_bytes())
            .map_err(|e| CliError::internal(format!("invalid CID: {}", e)))?;
        let commit_cid_str = commit_cid.to_string();

        // Load and decrypt commit using CommitReader (handles VD01 format)
        let commit_encrypted: EncryptedCommit = store.get_blob(&commit_cid).map_err(void_err_to_cli)?;
        let (commit_decrypted, reader) = CommitReader::open_with_vault(vault, &commit_encrypted)
            .map_err(|e| CliError::encryption_error(format!("commit decryption failed: {}", e)))?;
        let commit = commit_decrypted.parse().map_err(void_err_to_cli)?;

        // Load and decrypt metadata bundle using CommitReader
        ctx.progress("Loading metadata bundle...");

        let metadata_cid = commit.metadata_bundle.to_void_cid()
            .map_err(|e| CliError::internal(format!("invalid metadata CID: {}", e)))?;
        let metadata_cid_str = metadata_cid.to_string();

        let metadata_encrypted: EncryptedMetadata = store.get_blob(&metadata_cid).map_err(void_err_to_cli)?;
        let bundle: metadata::MetadataBundle = reader
            .decrypt_metadata::<metadata::MetadataBundle>(&metadata_encrypted)
            .map_err(|e| {
                CliError::encryption_error(format!("metadata decryption failed: {}", e))
            })?;

        // Extract shard information
        let range_count = bundle.shard_map.ranges.len();
        let mut shards = Vec::new();
        let mut total_compressed_size = 0u64;

        for range in bundle.shard_map.ranges.iter() {
            if let Some(shard_cid_typed) = &range.cid {
                let shard_cid_str = cid::from_bytes(shard_cid_typed.as_bytes())
                    .map(|c| c.to_string())
                    .unwrap_or_else(|_| "<invalid>".to_string());

                total_compressed_size += range.compressed_size;

                shards.push(ShardRangeOutput {
                    shard_id: range.shard_id,
                    range_start: format!("{:016x}", range.start),
                    range_end: format!("{:016x}", range.end),
                    cid: shard_cid_str,
                    compressed_size: range.compressed_size,
                });
            }
        }

        let populated_ranges = shards.len();

        // Human-readable output
        if !ctx.use_json() {
            ctx.info(format!("Commit: {}", commit_cid_str));
            ctx.info(format!("Metadata: {}", metadata_cid_str));
            ctx.info(format!(
                "Shard map: {} ranges, {} populated",
                range_count, populated_ranges
            ));
            ctx.info(format!(
                "Total compressed size: {} bytes",
                total_compressed_size
            ));

            if !shards.is_empty() {
                ctx.info(format!("\nShards:"));
                for shard in &shards {
                    let short_cid = if shard.cid.len() > 16 {
                        &shard.cid[..16]
                    } else {
                        &shard.cid
                    };
                    ctx.info(format!(
                        "  ID {} [{}-{}]: {}... ({} bytes)",
                        shard.shard_id,
                        &shard.range_start[..8],
                        &shard.range_end[..8],
                        short_cid,
                        shard.compressed_size
                    ));
                }
            }
        }

        Ok(DebugShardsOutput {
            commit_cid: commit_cid_str,
            metadata_cid: metadata_cid_str,
            range_count,
            populated_ranges,
            total_compressed_size,
            shards,
        })
    })
}