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,
46 pub mode: WorkspaceMode,
47}
48
49impl WorkspaceConfig {
50 pub fn directory(base: impl Into<PathBuf>) -> Self {
51 Self {
52 base: base.into(),
53 mode: WorkspaceMode::Directory,
54 }
55 }
56
57 pub fn git_worktree(base: impl Into<PathBuf>) -> Self {
58 Self {
59 base: base.into(),
60 mode: WorkspaceMode::GitWorktree,
61 }
62 }
63}
64
65fn sanitize(name: &str) -> String {
71 let s: String = name
72 .chars()
73 .map(|c| {
74 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
75 c
76 } else {
77 '-'
78 }
79 })
80 .collect();
81 if s.is_empty() {
82 "agent".to_string()
83 } else {
84 s
85 }
86}
87
88#[derive(Debug)]
91pub struct AgentWorkspace {
92 path: PathBuf,
93 mode: WorkspaceMode,
94 repo_root: Option<PathBuf>,
96}
97
98impl AgentWorkspace {
99 pub fn provision(config: &WorkspaceConfig, agent_name: &str) -> Result<Self, String> {
101 let path = config.base.join(sanitize(agent_name));
102 match config.mode {
103 WorkspaceMode::Directory => {
104 std::fs::create_dir_all(&path)
105 .map_err(|e| format!("create workspace dir {}: {e}", path.display()))?;
106 Ok(Self {
107 path,
108 mode: WorkspaceMode::Directory,
109 repo_root: None,
110 })
111 }
112 WorkspaceMode::GitWorktree => {
113 use std::ffi::OsStr;
114 let repo_root = git_repo_root(&config.base).ok_or_else(|| {
115 format!(
116 "git_worktree workspace requires {} to be inside a git repo",
117 config.base.display()
118 )
119 })?;
120 let _ = run_git(
125 &repo_root,
126 &[
127 OsStr::new("worktree"),
128 OsStr::new("remove"),
129 OsStr::new("--force"),
130 path.as_os_str(),
131 ],
132 );
133 let _ = run_git(&repo_root, &[OsStr::new("worktree"), OsStr::new("prune")]);
134 if path.exists() {
135 let _ = std::fs::remove_dir_all(&path);
136 }
137 run_git(
138 &repo_root,
139 &[
140 OsStr::new("worktree"),
141 OsStr::new("add"),
142 OsStr::new("--detach"),
143 path.as_os_str(),
144 OsStr::new("HEAD"),
145 ],
146 )?;
147 Ok(Self {
148 path,
149 mode: WorkspaceMode::GitWorktree,
150 repo_root: Some(repo_root),
151 })
152 }
153 }
154 }
155
156 pub fn path(&self) -> &Path {
158 &self.path
159 }
160
161 pub fn inject(&self, mut spec: AgentSpec) -> AgentSpec {
163 spec.metadata.insert(
164 WORKSPACE_METADATA_KEY.to_string(),
165 Value::String(self.path.to_string_lossy().into_owned()),
166 );
167 spec
168 }
169}
170
171impl Drop for AgentWorkspace {
172 fn drop(&mut self) {
173 match self.mode {
174 WorkspaceMode::Directory => {
175 let _ = std::fs::remove_dir_all(&self.path);
176 }
177 WorkspaceMode::GitWorktree => {
178 if let Some(root) = &self.repo_root {
179 use std::ffi::OsStr;
180 let _ = run_git(
182 root,
183 &[
184 OsStr::new("worktree"),
185 OsStr::new("remove"),
186 OsStr::new("--force"),
187 self.path.as_os_str(),
188 ],
189 );
190 let _ = std::fs::remove_dir_all(&self.path);
191 }
192 }
193 }
194 }
195}
196
197fn git_repo_root(dir: &Path) -> Option<PathBuf> {
199 let out = std::process::Command::new("git")
200 .arg("-C")
201 .arg(dir)
202 .args(["rev-parse", "--show-toplevel"])
203 .output()
204 .ok()?;
205 if !out.status.success() {
206 return None;
207 }
208 let root = String::from_utf8(out.stdout).ok()?.trim().to_string();
209 if root.is_empty() {
210 None
211 } else {
212 Some(PathBuf::from(root))
213 }
214}
215
216fn run_git(repo_root: &Path, args: &[&std::ffi::OsStr]) -> Result<(), String> {
217 let out = std::process::Command::new("git")
218 .arg("-C")
219 .arg(repo_root)
220 .args(args)
221 .output()
222 .map_err(|e| format!("git {:?}: {e}", args))?;
223 if out.status.success() {
224 Ok(())
225 } else {
226 Err(format!(
227 "git {:?} failed: {}",
228 args,
229 String::from_utf8_lossy(&out.stderr).trim()
230 ))
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 fn unique_base(tag: &str) -> PathBuf {
239 use std::sync::atomic::{AtomicU64, Ordering};
241 static N: AtomicU64 = AtomicU64::new(0);
242 let n = N.fetch_add(1, Ordering::Relaxed);
243 std::env::temp_dir().join(format!("car-ws-{tag}-{}-{n}", std::process::id()))
244 }
245
246 #[test]
247 fn directory_workspace_is_created_injected_and_cleaned() {
248 let base = unique_base("dir");
249 let cfg = WorkspaceConfig::directory(&base);
250 let path;
251 {
252 let ws = AgentWorkspace::provision(&cfg, "alice/../x").unwrap();
253 path = ws.path().to_path_buf();
254 assert!(path.exists() && path.is_dir());
255 assert_eq!(path.parent().unwrap(), base);
257 assert!(!path.to_string_lossy().contains(".."));
258
259 let spec = ws.inject(AgentSpec::new("alice", "sys"));
260 assert_eq!(
261 spec.metadata.get(WORKSPACE_METADATA_KEY).unwrap(),
262 &Value::String(path.to_string_lossy().into_owned())
263 );
264 }
265 assert!(!path.exists(), "workspace should be removed on drop");
267 let _ = std::fs::remove_dir_all(&base);
268 }
269
270 #[test]
271 fn distinct_agents_get_distinct_dirs() {
272 let base = unique_base("distinct");
273 let cfg = WorkspaceConfig::directory(&base);
274 let a = AgentWorkspace::provision(&cfg, "a").unwrap();
275 let b = AgentWorkspace::provision(&cfg, "b").unwrap();
276 assert_ne!(a.path(), b.path());
277 drop(a);
278 drop(b);
279 let _ = std::fs::remove_dir_all(&base);
280 }
281}