use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
const WORKSPACES_DIR: &str = ".battlecommand/workspaces";
pub struct Workspace {
pub path: PathBuf,
pub mission_id: String,
}
impl Workspace {
pub fn create(mission_id: &str) -> Result<Self> {
let path = PathBuf::from(WORKSPACES_DIR).join(mission_id);
fs::create_dir_all(&path)
.with_context(|| format!("Failed to create workspace at {}", path.display()))?;
let output = Command::new("git")
.args(["init", "--quiet"])
.current_dir(&path)
.output();
match output {
Ok(o) if o.status.success() => {}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
eprintln!(" git init warning: {}", stderr.trim());
}
Err(e) => {
eprintln!(" git not available: {}", e);
}
}
Ok(Self {
path,
mission_id: mission_id.to_string(),
})
}
pub fn open(mission_id: &str) -> Result<Self> {
let path = PathBuf::from(WORKSPACES_DIR).join(mission_id);
if !path.exists() {
anyhow::bail!("Workspace {} does not exist", mission_id);
}
Ok(Self {
path,
mission_id: mission_id.to_string(),
})
}
pub fn commit(&self, message: &str) -> Result<String> {
run_git(&self.path, &["add", "-A"])?;
let status = run_git(&self.path, &["status", "--porcelain"])?;
if status.trim().is_empty() {
return Ok("no changes".to_string());
}
run_git(&self.path, &["commit", "-m", message, "--allow-empty"])?;
let hash = run_git(&self.path, &["rev-parse", "--short", "HEAD"])?;
Ok(hash.trim().to_string())
}
pub fn rollback(&self, commit: &str) -> Result<()> {
run_git(&self.path, &["checkout", commit, "--", "."])?;
Ok(())
}
pub fn log(&self, count: usize) -> Result<String> {
let n = format!("-{}", count);
run_git(&self.path, &["log", "--oneline", &n])
}
pub fn export_to(&self, output_dir: &Path) -> Result<()> {
fs::create_dir_all(output_dir)?;
copy_dir_contents(&self.path, output_dir)?;
Ok(())
}
}
pub fn mission_id_from_prompt(prompt: &str) -> String {
let slug: String = prompt
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.chars()
.take(30)
.collect();
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
format!("{}_{}", slug.trim_matches('_'), timestamp)
}
pub fn list_workspaces() -> Result<Vec<String>> {
let dir = Path::new(WORKSPACES_DIR);
if !dir.exists() {
return Ok(vec![]);
}
let mut workspaces = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
if entry.path().is_dir() {
workspaces.push(entry.file_name().to_string_lossy().to_string());
}
}
workspaces.sort();
Ok(workspaces)
}
fn run_git(cwd: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.with_context(|| format!("Failed to run git {:?}", args))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git {:?} failed: {}", args, stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn clone_repo(url: &str, target: &Path) -> Result<()> {
println!("[REPO] Cloning {} ...", url);
let output = Command::new("git")
.args(["clone", "--depth", "1", url])
.arg(target)
.output()
.context("Failed to run git clone")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git clone failed: {}", stderr.trim());
}
println!("[REPO] Cloned to {}", target.display());
Ok(())
}
pub(crate) fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> {
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.file_name().map(|n| n == ".git").unwrap_or(false) {
continue;
}
if src_path.is_dir() {
fs::create_dir_all(&dst_path)?;
copy_dir_contents(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mission_id_from_prompt() {
let id = mission_id_from_prompt("Build a FastAPI auth endpoint");
assert!(id.starts_with("build_a_fastapi_auth_endpoint_"));
assert!(id.len() > 30); }
#[test]
fn test_list_workspaces_empty() {
let result = list_workspaces();
assert!(result.is_ok());
}
}