void-cli 0.0.2

CLI for void — anonymous encrypted source control
//! Workspace command — manage linked worktrees.
//!
//! Allows creating, listing, removing, and pruning workspaces.

use std::path::{Path, PathBuf};

use serde::Serialize;
use void_core::workspace;

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

// ============================================================================
// Output types
// ============================================================================

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceEntry {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub branch: Option<String>,
    pub path: String,
    pub is_main: bool,
    pub is_stale: bool,
}

#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceListOutput {
    pub workspaces: Vec<WorkspaceEntry>,
}

#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceCreateOutput {
    pub name: String,
    pub path: String,
    pub branch: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceRemoveOutput {
    pub name: String,
    pub removed: bool,
}

#[derive(Debug, Clone, Serialize)]
pub struct WorkspacePruneOutput {
    pub pruned: Vec<String>,
}

// ============================================================================
// Subcommand enum
// ============================================================================

pub enum WorkspaceSubcommand {
    List,
    Create {
        name: String,
        branch: Option<String>,
        path: Option<PathBuf>,
    },
    Remove {
        name: String,
    },
    Prune,
}

pub struct WorkspaceArgs {
    pub subcommand: WorkspaceSubcommand,
}

// ============================================================================
// Unified output enum
// ============================================================================

#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum WorkspaceOutput {
    List(WorkspaceListOutput),
    Create(WorkspaceCreateOutput),
    Remove(WorkspaceRemoveOutput),
    Prune(WorkspacePruneOutput),
}

// ============================================================================
// Implementation
// ============================================================================

fn info_to_entry(info: &workspace::WorkspaceInfo) -> WorkspaceEntry {
    WorkspaceEntry {
        name: info.name.clone(),
        branch: info.branch.clone(),
        path: info.path.to_string_lossy().to_string(),
        is_main: info.is_main,
        is_stale: info.is_stale,
    }
}

pub fn run(cwd: &Path, args: WorkspaceArgs, opts: &CliOptions) -> Result<(), CliError> {
    run_command("workspace", opts, |ctx| {
        let void_dir = find_void_dir(cwd)?;
        let root = void_dir
            .parent()
            .ok_or_else(|| CliError::internal("void dir has no parent"))?;

        match args.subcommand {
            WorkspaceSubcommand::List => {
                let infos = workspace::list_workspaces(&void_dir, root)
                    .map_err(|e| CliError::internal(format!("failed to list workspaces: {e}")))?;

                let entries: Vec<WorkspaceEntry> = infos.iter().map(info_to_entry).collect();

                if !ctx.use_json() {
                    for entry in &entries {
                        let stale = if entry.is_stale { " (stale)" } else { "" };
                        let branch = entry.branch.as_deref().unwrap_or("(detached)");
                        ctx.info(format!(
                            "{}\t{}\t{}{}",
                            entry.name, branch, entry.path, stale
                        ));
                    }
                }

                Ok(WorkspaceOutput::List(WorkspaceListOutput {
                    workspaces: entries,
                }))
            }

            WorkspaceSubcommand::Create { name, branch, path } => {
                let branch_name = branch.unwrap_or_else(|| name.clone());
                let work_tree_path =
                    path.unwrap_or_else(|| root.parent().unwrap_or(root).join(&name));

                workspace::create_workspace(&void_dir, &name, &branch_name, None, &work_tree_path)
                    .map_err(|e| CliError::internal(format!("failed to create workspace: {e}")))?;

                if !ctx.use_json() {
                    ctx.info(format!(
                        "Created workspace '{}' on branch '{}' at {}",
                        name,
                        branch_name,
                        work_tree_path.display()
                    ));
                }

                Ok(WorkspaceOutput::Create(WorkspaceCreateOutput {
                    name,
                    path: work_tree_path.to_string_lossy().to_string(),
                    branch: branch_name,
                }))
            }

            WorkspaceSubcommand::Remove { name } => {
                workspace::remove_workspace(&void_dir, &name)
                    .map_err(|e| CliError::internal(format!("failed to remove workspace: {e}")))?;

                if !ctx.use_json() {
                    ctx.info(format!("Removed workspace '{}'", name));
                }

                Ok(WorkspaceOutput::Remove(WorkspaceRemoveOutput {
                    name,
                    removed: true,
                }))
            }

            WorkspaceSubcommand::Prune => {
                let pruned = workspace::prune_workspaces(&void_dir)
                    .map_err(|e| CliError::internal(format!("failed to prune workspaces: {e}")))?;

                if !ctx.use_json() {
                    if pruned.is_empty() {
                        ctx.info("No stale workspaces to prune.");
                    } else {
                        for name in &pruned {
                            ctx.info(format!("Pruned stale workspace '{}'", name));
                        }
                    }
                }

                Ok(WorkspaceOutput::Prune(WorkspacePruneOutput { pruned }))
            }
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

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

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

        // Set up manifest-based key
        let key = hex::decode("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef").unwrap();
        let key: [u8; 32] = key.try_into().unwrap();
        let home = tempdir().unwrap();
        let guard = crate::context::setup_test_manifest(&void_dir, &key, home.path());

        fs::write(void_dir.join("config.json"), "{}").unwrap();
        // Create HEAD so list_workspaces can read it
        fs::write(void_dir.join("HEAD"), "ref: refs/heads/trunk").unwrap();
        (dir, void_dir, home, guard)
    }

    #[test]
    fn test_workspace_list() {
        let (dir, _void_dir, _home, _guard) = setup_test_repo();
        let args = WorkspaceArgs {
            subcommand: WorkspaceSubcommand::List,
        };
        let result = run(dir.path(), args, &default_opts());
        assert!(result.is_ok());
    }

    #[test]
    fn test_workspace_entry_serialization() {
        let entry = WorkspaceEntry {
            name: "main".to_string(),
            branch: Some("trunk".to_string()),
            path: "/repo".to_string(),
            is_main: true,
            is_stale: false,
        };

        let json = serde_json::to_string(&entry).unwrap();
        assert!(json.contains("\"name\":\"main\""));
        assert!(json.contains("\"isMain\":true"));
        assert!(json.contains("\"isStale\":false"));
    }

    #[test]
    fn test_workspace_prune_empty() {
        let (dir, _void_dir, _home, _guard) = setup_test_repo();
        let args = WorkspaceArgs {
            subcommand: WorkspaceSubcommand::Prune,
        };
        let result = run(dir.path(), args, &default_opts());
        assert!(result.is_ok());
    }
}