battlecommand_forge/
workspace.rs1use anyhow::{Context, Result};
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11const WORKSPACES_DIR: &str = ".battlecommand/workspaces";
12
13pub struct Workspace {
15 pub path: PathBuf,
16 pub mission_id: String,
17}
18
19impl Workspace {
20 pub fn create(mission_id: &str) -> Result<Self> {
22 let path = PathBuf::from(WORKSPACES_DIR).join(mission_id);
23 fs::create_dir_all(&path)
24 .with_context(|| format!("Failed to create workspace at {}", path.display()))?;
25
26 let output = Command::new("git")
28 .args(["init", "--quiet"])
29 .current_dir(&path)
30 .output();
31
32 match output {
33 Ok(o) if o.status.success() => {}
34 Ok(o) => {
35 let stderr = String::from_utf8_lossy(&o.stderr);
36 eprintln!(" git init warning: {}", stderr.trim());
37 }
38 Err(e) => {
39 eprintln!(" git not available: {}", e);
40 }
42 }
43
44 Ok(Self {
45 path,
46 mission_id: mission_id.to_string(),
47 })
48 }
49
50 pub fn open(mission_id: &str) -> Result<Self> {
52 let path = PathBuf::from(WORKSPACES_DIR).join(mission_id);
53 if !path.exists() {
54 anyhow::bail!("Workspace {} does not exist", mission_id);
55 }
56 Ok(Self {
57 path,
58 mission_id: mission_id.to_string(),
59 })
60 }
61
62 pub fn commit(&self, message: &str) -> Result<String> {
64 run_git(&self.path, &["add", "-A"])?;
66
67 let status = run_git(&self.path, &["status", "--porcelain"])?;
69 if status.trim().is_empty() {
70 return Ok("no changes".to_string());
71 }
72
73 run_git(&self.path, &["commit", "-m", message, "--allow-empty"])?;
75
76 let hash = run_git(&self.path, &["rev-parse", "--short", "HEAD"])?;
78 Ok(hash.trim().to_string())
79 }
80
81 pub fn rollback(&self, commit: &str) -> Result<()> {
83 run_git(&self.path, &["checkout", commit, "--", "."])?;
84 Ok(())
85 }
86
87 pub fn log(&self, count: usize) -> Result<String> {
89 let n = format!("-{}", count);
90 run_git(&self.path, &["log", "--oneline", &n])
91 }
92
93 pub fn export_to(&self, output_dir: &Path) -> Result<()> {
95 fs::create_dir_all(output_dir)?;
96 copy_dir_contents(&self.path, output_dir)?;
97 Ok(())
98 }
99}
100
101pub fn mission_id_from_prompt(prompt: &str) -> String {
103 let slug: String = prompt
104 .to_lowercase()
105 .chars()
106 .map(|c| if c.is_alphanumeric() { c } else { '_' })
107 .collect::<String>()
108 .chars()
109 .take(30)
110 .collect();
111
112 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
113 format!("{}_{}", slug.trim_matches('_'), timestamp)
114}
115
116pub fn list_workspaces() -> Result<Vec<String>> {
118 let dir = Path::new(WORKSPACES_DIR);
119 if !dir.exists() {
120 return Ok(vec![]);
121 }
122
123 let mut workspaces = Vec::new();
124 for entry in fs::read_dir(dir)? {
125 let entry = entry?;
126 if entry.path().is_dir() {
127 workspaces.push(entry.file_name().to_string_lossy().to_string());
128 }
129 }
130
131 workspaces.sort();
132 Ok(workspaces)
133}
134
135fn run_git(cwd: &Path, args: &[&str]) -> Result<String> {
136 let output = Command::new("git")
137 .args(args)
138 .current_dir(cwd)
139 .output()
140 .with_context(|| format!("Failed to run git {:?}", args))?;
141
142 if !output.status.success() {
143 let stderr = String::from_utf8_lossy(&output.stderr);
144 anyhow::bail!("git {:?} failed: {}", args, stderr.trim());
145 }
146
147 Ok(String::from_utf8_lossy(&output.stdout).to_string())
148}
149
150pub fn clone_repo(url: &str, target: &Path) -> Result<()> {
152 println!("[REPO] Cloning {} ...", url);
153 let output = Command::new("git")
154 .args(["clone", "--depth", "1", url])
155 .arg(target)
156 .output()
157 .context("Failed to run git clone")?;
158 if !output.status.success() {
159 let stderr = String::from_utf8_lossy(&output.stderr);
160 anyhow::bail!("git clone failed: {}", stderr.trim());
161 }
162 println!("[REPO] Cloned to {}", target.display());
163 Ok(())
164}
165
166pub(crate) fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> {
167 for entry in fs::read_dir(src)? {
168 let entry = entry?;
169 let src_path = entry.path();
170 let dst_path = dst.join(entry.file_name());
171
172 if src_path.file_name().map(|n| n == ".git").unwrap_or(false) {
174 continue;
175 }
176
177 if src_path.is_dir() {
178 fs::create_dir_all(&dst_path)?;
179 copy_dir_contents(&src_path, &dst_path)?;
180 } else {
181 fs::copy(&src_path, &dst_path)?;
182 }
183 }
184 Ok(())
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn test_mission_id_from_prompt() {
193 let id = mission_id_from_prompt("Build a FastAPI auth endpoint");
194 assert!(id.starts_with("build_a_fastapi_auth_endpoint_"));
195 assert!(id.len() > 30); }
197
198 #[test]
199 fn test_list_workspaces_empty() {
200 let result = list_workspaces();
202 assert!(result.is_ok());
203 }
204}