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};
#[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>,
}
pub enum WorkspaceSubcommand {
List,
Create {
name: String,
branch: Option<String>,
path: Option<PathBuf>,
},
Remove {
name: String,
},
Prune,
}
pub struct WorkspaceArgs {
pub subcommand: WorkspaceSubcommand,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum WorkspaceOutput {
List(WorkspaceListOutput),
Create(WorkspaceCreateOutput),
Remove(WorkspaceRemoveOutput),
Prune(WorkspacePruneOutput),
}
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();
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();
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());
}
}