1use crate::types::AgentSpec;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use std::path::{Path, PathBuf};
23
24pub const WORKSPACE_METADATA_KEY: &str = "workspace";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum WorkspaceMode {
32 Directory,
34 GitWorktree,
38}
39
40#[derive(Debug, Clone)]
42pub struct WorkspaceConfig {
43 pub base: PathBuf,
47 pub mode: WorkspaceMode,
48 pub repo: Option<PathBuf>,
54}
55
56impl WorkspaceConfig {
57 pub fn directory(base: impl Into<PathBuf>) -> Self {
58 Self {
59 base: base.into(),
60 mode: WorkspaceMode::Directory,
61 repo: None,
62 }
63 }
64
65 pub fn git_worktree(base: impl Into<PathBuf>) -> Self {
66 Self {
67 base: base.into(),
68 mode: WorkspaceMode::GitWorktree,
69 repo: None,
70 }
71 }
72
73 pub fn git_worktree_at(repo: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
76 Self {
77 base: base.into(),
78 mode: WorkspaceMode::GitWorktree,
79 repo: Some(repo.into()),
80 }
81 }
82}
83
84fn sanitize(name: &str) -> String {
90 let s: String = name
91 .chars()
92 .map(|c| {
93 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
94 c
95 } else {
96 '-'
97 }
98 })
99 .collect();
100 if s.is_empty() {
101 "agent".to_string()
102 } else {
103 s
104 }
105}
106
107#[derive(Debug)]
110pub struct AgentWorkspace {
111 path: PathBuf,
112 mode: WorkspaceMode,
113 repo_root: Option<PathBuf>,
115}
116
117impl AgentWorkspace {
118 pub fn provision(config: &WorkspaceConfig, agent_name: &str) -> Result<Self, String> {
120 let path = config.base.join(sanitize(agent_name));
121 match config.mode {
122 WorkspaceMode::Directory => {
123 std::fs::create_dir_all(&path)
124 .map_err(|e| format!("create workspace dir {}: {e}", path.display()))?;
125 Ok(Self {
126 path,
127 mode: WorkspaceMode::Directory,
128 repo_root: None,
129 })
130 }
131 WorkspaceMode::GitWorktree => {
132 use std::ffi::OsStr;
133 let repo_hint = config.repo.as_ref().unwrap_or(&config.base);
134 let repo_root = git_repo_root(repo_hint).ok_or_else(|| {
135 format!(
136 "git_worktree workspace requires {} to be inside a git repo",
137 repo_hint.display()
138 )
139 })?;
140 std::fs::create_dir_all(&config.base)
141 .map_err(|e| format!("create workspace base {}: {e}", config.base.display()))?;
142 let _ = run_git(
147 &repo_root,
148 &[
149 OsStr::new("worktree"),
150 OsStr::new("remove"),
151 OsStr::new("--force"),
152 path.as_os_str(),
153 ],
154 );
155 let _ = run_git(&repo_root, &[OsStr::new("worktree"), OsStr::new("prune")]);
156 if path.exists() {
157 let _ = std::fs::remove_dir_all(&path);
158 }
159 run_git(
160 &repo_root,
161 &[
162 OsStr::new("worktree"),
163 OsStr::new("add"),
164 OsStr::new("--detach"),
165 path.as_os_str(),
166 OsStr::new("HEAD"),
167 ],
168 )?;
169 Ok(Self {
170 path,
171 mode: WorkspaceMode::GitWorktree,
172 repo_root: Some(repo_root),
173 })
174 }
175 }
176 }
177
178 pub fn path(&self) -> &Path {
180 &self.path
181 }
182
183 pub fn inject(&self, mut spec: AgentSpec) -> AgentSpec {
185 spec.metadata.insert(
186 WORKSPACE_METADATA_KEY.to_string(),
187 Value::String(self.path.to_string_lossy().into_owned()),
188 );
189 spec
190 }
191}
192
193impl Drop for AgentWorkspace {
194 fn drop(&mut self) {
195 match self.mode {
196 WorkspaceMode::Directory => {
197 let _ = std::fs::remove_dir_all(&self.path);
198 }
199 WorkspaceMode::GitWorktree => {
200 if let Some(root) = &self.repo_root {
201 use std::ffi::OsStr;
202 let _ = run_git(
204 root,
205 &[
206 OsStr::new("worktree"),
207 OsStr::new("remove"),
208 OsStr::new("--force"),
209 self.path.as_os_str(),
210 ],
211 );
212 let _ = std::fs::remove_dir_all(&self.path);
213 }
214 }
215 }
216 }
217}
218
219fn git_repo_root(dir: &Path) -> Option<PathBuf> {
221 let out = std::process::Command::new("git")
222 .arg("-C")
223 .arg(dir)
224 .args(["rev-parse", "--show-toplevel"])
225 .output()
226 .ok()?;
227 if !out.status.success() {
228 return None;
229 }
230 let root = String::from_utf8(out.stdout).ok()?.trim().to_string();
231 if root.is_empty() {
232 None
233 } else {
234 Some(PathBuf::from(root))
235 }
236}
237
238fn run_git(repo_root: &Path, args: &[&std::ffi::OsStr]) -> Result<(), String> {
239 let out = std::process::Command::new("git")
240 .arg("-C")
241 .arg(repo_root)
242 .args(args)
243 .output()
244 .map_err(|e| format!("git {:?}: {e}", args))?;
245 if out.status.success() {
246 Ok(())
247 } else {
248 Err(format!(
249 "git {:?} failed: {}",
250 args,
251 String::from_utf8_lossy(&out.stderr).trim()
252 ))
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 fn unique_base(tag: &str) -> PathBuf {
261 use std::sync::atomic::{AtomicU64, Ordering};
263 static N: AtomicU64 = AtomicU64::new(0);
264 let n = N.fetch_add(1, Ordering::Relaxed);
265 std::env::temp_dir().join(format!("car-ws-{tag}-{}-{n}", std::process::id()))
266 }
267
268 #[test]
269 fn directory_workspace_is_created_injected_and_cleaned() {
270 let base = unique_base("dir");
271 let cfg = WorkspaceConfig::directory(&base);
272 let path;
273 {
274 let ws = AgentWorkspace::provision(&cfg, "alice/../x").unwrap();
275 path = ws.path().to_path_buf();
276 assert!(path.exists() && path.is_dir());
277 assert_eq!(path.parent().unwrap(), base);
279 assert!(!path.to_string_lossy().contains(".."));
280
281 let spec = ws.inject(AgentSpec::new("alice", "sys"));
282 assert_eq!(
283 spec.metadata.get(WORKSPACE_METADATA_KEY).unwrap(),
284 &Value::String(path.to_string_lossy().into_owned())
285 );
286 }
287 assert!(!path.exists(), "workspace should be removed on drop");
289 let _ = std::fs::remove_dir_all(&base);
290 }
291
292 #[test]
293 fn git_worktree_at_provisions_outside_the_repo() {
294 if std::process::Command::new("git")
297 .arg("--version")
298 .output()
299 .is_err()
300 {
301 return;
302 }
303 let repo = unique_base("repo");
304 std::fs::create_dir_all(&repo).unwrap();
305 for args in [
306 vec!["init", "-q"],
307 vec![
308 "-c",
309 "user.name=t",
310 "-c",
311 "user.email=t@t",
312 "commit",
313 "-q",
314 "--allow-empty",
315 "-m",
316 "init",
317 ],
318 ] {
319 let out = std::process::Command::new("git")
320 .arg("-C")
321 .arg(&repo)
322 .args(&args)
323 .output()
324 .unwrap();
325 assert!(
326 out.status.success(),
327 "git {args:?}: {}",
328 String::from_utf8_lossy(&out.stderr)
329 );
330 }
331
332 let base = unique_base("wt-base");
333 let cfg = WorkspaceConfig::git_worktree_at(&repo, &base);
334 let path;
335 {
336 let ws = AgentWorkspace::provision(&cfg, "session-1").unwrap();
337 path = ws.path().to_path_buf();
338 assert!(
339 path.starts_with(&base),
340 "worktree must live under base, not the repo"
341 );
342 assert!(path.join(".git").exists(), "worktree checkout expected");
343 let out = std::process::Command::new("git")
345 .arg("-C")
346 .arg(&repo)
347 .args(["status", "--porcelain"])
348 .output()
349 .unwrap();
350 assert!(out.stdout.is_empty(), "repo status must stay clean");
351 }
352 assert!(!path.exists(), "worktree removed on drop");
353 let _ = std::fs::remove_dir_all(&base);
354 let _ = std::fs::remove_dir_all(&repo);
355 }
356
357 #[test]
358 fn distinct_agents_get_distinct_dirs() {
359 let base = unique_base("distinct");
360 let cfg = WorkspaceConfig::directory(&base);
361 let a = AgentWorkspace::provision(&cfg, "a").unwrap();
362 let b = AgentWorkspace::provision(&cfg, "b").unwrap();
363 assert_ne!(a.path(), b.path());
364 drop(a);
365 drop(b);
366 let _ = std::fs::remove_dir_all(&base);
367 }
368}