void-cli 0.0.2

CLI for void — anonymous encrypted source control
//! Restore command implementation.
//!
//! Restores files from a commit to the working tree, or unstages files.

use std::path::Path;
use std::sync::Arc;

use camino::Utf8PathBuf;
use serde::Serialize;
use void_core::store::FsStore;
use void_core::workspace::checkout::{checkout_tree, CheckoutOptions, CheckoutStats};
use void_core::workspace::stage::{reset_paths, ResetOptions};
use void_core::{cid, VoidError};

use crate::context::{build_void_context, find_void_dir, resolve_ref, void_err_to_cli};
use crate::observer::ProgressObserver;
use crate::output::{run_command, CliError, CliOptions};

/// JSON output structure for the restore command.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RestoreOutput {
    /// Number of files restored.
    pub files_restored: usize,
    /// Total bytes written to disk.
    pub bytes_written: u64,
    /// Number of files skipped.
    pub files_skipped: usize,
}

impl From<CheckoutStats> for RestoreOutput {
    fn from(stats: CheckoutStats) -> Self {
        Self {
            files_restored: stats.files_restored,
            bytes_written: stats.bytes_written,
            files_skipped: stats.files_skipped,
        }
    }
}

/// Arguments for the restore command.
pub struct RestoreArgs {
    /// Paths to restore (empty means all).
    pub paths: Vec<String>,
    /// Commit to restore from (default: HEAD).
    pub source: Option<String>,
    /// Unstage files only (don't touch working tree).
    pub staged: bool,
    /// Overwrite local changes.
    pub force: bool,
}

/// Run the restore command.
///
/// Restores files from a commit to the working tree, or unstages files.
///
/// # Arguments
///
/// * `cwd` - Working directory to operate in.
/// * `args` - Restore arguments.
/// * `opts` - CLI options.
pub fn run(cwd: &Path, args: RestoreArgs, opts: &CliOptions) -> Result<(), CliError> {
    run_command("restore", opts, |ctx| {
        if args.staged {
            // --staged mode: reset index entries to match source (default HEAD)
            run_staged_restore(cwd, &args, ctx)
        } else {
            // Regular mode: restore files from commit to working tree
            run_working_tree_restore(cwd, &args, ctx)
        }
    })
}

/// Restore staged files (reset index entries).
fn run_staged_restore(
    cwd: &Path,
    args: &RestoreArgs,
    ctx: &mut crate::output::CommandContext,
) -> Result<RestoreOutput, CliError> {
    let void_ctx = build_void_context(cwd)?;

    // Filter empty/whitespace paths, default to "." if empty
    let patterns: Vec<String> = args
        .paths
        .iter()
        .filter(|p| !p.trim().is_empty())
        .cloned()
        .collect();
    let patterns = if patterns.is_empty() {
        vec![".".to_string()]
    } else {
        patterns
    };

    // Create observer for progress reporting
    let observer: Arc<ProgressObserver> = if ctx.use_json() {
        Arc::new(ProgressObserver::new_hidden())
    } else {
        Arc::new(ProgressObserver::new("Restoring staged files..."))
    };

    let reset_opts = ResetOptions {
        ctx: void_ctx,
        patterns,
        observer: Some(observer.clone()),
    };

    let result = reset_paths(reset_opts).map_err(void_err_to_cli)?;

    // Finish progress bar
    observer.finish();

    // Print human-readable summary if not JSON mode
    if !ctx.use_json() {
        if result.reset.is_empty() {
            ctx.info("Nothing to restore");
        } else {
            for path in &result.reset {
                ctx.info(format!("Unstaged: {}", path));
            }
            ctx.info(format!("\n{} file(s) restored", result.reset.len()));
        }
    }

    Ok(RestoreOutput {
        files_restored: result.reset.len(),
        bytes_written: 0,
        files_skipped: 0,
    })
}

/// Restore files from commit to working tree.
fn run_working_tree_restore(
    cwd: &Path,
    args: &RestoreArgs,
    ctx: &mut crate::output::CommandContext,
) -> Result<RestoreOutput, CliError> {
    let void_dir = find_void_dir(cwd)?;
    let void_ctx = build_void_context(cwd)?;
    let vault = void_ctx.crypto.vault.clone();

    // Resolve source commit (default to HEAD)
    let source_ref = args.source.as_deref().unwrap_or("HEAD");
    let commit_cid_typed = resolve_ref(&void_dir, source_ref)?;
    let commit_cid =
        cid::from_bytes(commit_cid_typed.as_bytes()).map_err(|e| CliError::internal(e.to_string()))?;

    // Get workspace root (parent of .void)
    let workspace = void_dir
        .parent()
        .ok_or_else(|| CliError::internal("void_dir has no parent"))?;

    // Build object store
    let objects_dir = Utf8PathBuf::try_from(void_dir.join("objects"))
        .map_err(|e| CliError::internal(format!("invalid objects path: {}", e)))?;
    let store = FsStore::new(objects_dir).map_err(|e: VoidError| void_err_to_cli(e))?;

    // Filter empty/whitespace paths
    let paths: Vec<String> = args
        .paths
        .iter()
        .filter(|p| !p.trim().is_empty())
        .cloned()
        .collect();

    // Create observer for progress reporting
    let observer: Arc<ProgressObserver> = if ctx.use_json() {
        Arc::new(ProgressObserver::new_hidden())
    } else {
        Arc::new(ProgressObserver::new("Restoring files..."))
    };

    let checkout_opts = CheckoutOptions {
        paths: if paths.is_empty() { None } else { Some(paths) },
        force: args.force,
        observer: Some(observer.clone()),
        workspace_dir: None,
    };

    let stats = checkout_tree(&store, &*vault, &commit_cid, workspace, &checkout_opts)
        .map_err(void_err_to_cli)?;

    // Finish progress bar
    observer.finish();

    // Print human-readable summary if not JSON mode
    if !ctx.use_json() {
        if stats.files_restored == 0 && stats.files_skipped == 0 {
            ctx.info("Nothing to restore");
        } else {
            ctx.info(format!(
                "Restored {} file(s), {} bytes written",
                stats.files_restored, stats.bytes_written
            ));
            if stats.files_skipped > 0 {
                ctx.info(format!("{} file(s) skipped", stats.files_skipped));
            }
        }
    }

    Ok(stats.into())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_restore_output_serialization() {
        let output = RestoreOutput {
            files_restored: 5,
            bytes_written: 12345,
            files_skipped: 2,
        };

        let json = serde_json::to_string(&output).unwrap();
        assert!(json.contains("\"filesRestored\":5"));
        assert!(json.contains("\"bytesWritten\":12345"));
        assert!(json.contains("\"filesSkipped\":2"));
    }

    #[test]
    fn test_restore_output_from_checkout_stats() {
        use void_core::workspace::checkout::CheckoutStats;

        let stats = CheckoutStats {
            files_restored: 10,
            bytes_written: 54321,
            files_skipped: 1,
            shards_read: 3,
        };

        let output: RestoreOutput = stats.into();
        assert_eq!(output.files_restored, 10);
        assert_eq!(output.bytes_written, 54321);
        assert_eq!(output.files_skipped, 1);
    }
}