1use std::path::{Path, PathBuf};
2use std::process::{Command, ExitStatus};
3
4#[cfg(not(test))]
5use crate::config::ConfigInitializer;
6use crate::tools::FenceCommand;
7use crate::workspace::Workspace;
8
9#[derive(Debug, thiserror::Error)]
10pub enum SandboxError {
11 #[error("Fence executable 'fence' not found in PATH")]
12 FenceNotFound,
13 #[error("Invalid argument ordering: {0}")]
14 InvalidArgOrder(String),
15 #[error("Sandbox command failed with exit status: {0}")]
16 ExecutionFailed(ExitStatus),
17 #[error("I/O error during sandbox execution: {0}")]
18 Io(#[from] std::io::Error),
19}
20
21#[derive(Debug)]
22pub struct SandboxBuilder<'a> {
23 workspace_path: &'a Path,
24 repo_root: &'a Path,
25 command: &'a [String],
26 settings_path: Option<&'a Path>,
27 use_direnv: bool,
28 #[cfg(test)]
29 shared_config_dir: Option<PathBuf>,
30}
31
32impl<'a> SandboxBuilder<'a> {
33 #[must_use]
35 pub fn new(workspace_path: &'a Path, repo_root: &'a Path, command: &'a [String]) -> Self {
36 Self {
37 workspace_path,
38 repo_root,
39 command,
40 settings_path: None,
41 use_direnv: false,
42 #[cfg(test)]
43 shared_config_dir: None,
44 }
45 }
46
47 #[must_use]
49 pub fn for_workspace(workspace: &'a Workspace, command: &'a [String]) -> Self {
50 Self::new(workspace.path(), workspace.repo_root(), command)
51 }
52
53 #[must_use]
55 pub fn with_settings_path(mut self, settings_path: &'a Path) -> Self {
56 self.settings_path = Some(settings_path);
57 self
58 }
59
60 #[must_use]
62 pub fn with_direnv(mut self, use_direnv: bool) -> Self {
63 self.use_direnv = use_direnv;
64 self
65 }
66
67 #[must_use]
69 pub fn with_use_direnv(self, use_direnv: bool) -> Self {
70 self.with_direnv(use_direnv)
71 }
72
73 #[cfg(test)]
75 #[must_use]
76 pub fn with_shared_config_dir(mut self, dir: PathBuf) -> Self {
77 self.shared_config_dir = Some(dir);
78 self
79 }
80
81 fn append_settings_args(&self, cmd: &mut Command) {
82 if let Some(settings) = self.resolve_settings_path() {
83 cmd.args(["--settings", &settings.to_string_lossy()]);
84 }
85 }
86
87 pub(crate) fn append_direnv_args(&self, cmd: &mut Command) -> Result<(), SandboxError> {
88 if !self.use_direnv {
89 return Ok(());
90 }
91 if cmd.get_args().last() != Some(std::ffi::OsStr::new("--")) {
92 return Err(SandboxError::InvalidArgOrder(
93 "direnv arguments must be appended immediately after '--'".to_string(),
94 ));
95 }
96 cmd.args(["direnv", "exec", "."]);
97 Ok(())
98 }
99
100 fn resolve_settings_path(&self) -> Option<PathBuf> {
101 self.settings_path
102 .filter(|p| p.exists())
103 .map(Path::to_path_buf)
104 .or_else(|| {
105 [
106 self.workspace_path.join("fence.jsonc"),
107 self.workspace_path.join("fence.json"),
108 self.repo_root.join("fence.jsonc"),
109 self.repo_root.join("fence.json"),
110 ]
111 .into_iter()
112 .find(|p| p.exists())
113 })
114 .or_else(|| {
115 self.shared_template_candidates()
116 .into_iter()
117 .find(|p| p.exists())
118 })
119 }
120
121 fn shared_template_candidates(&self) -> Vec<PathBuf> {
122 #[cfg(test)]
123 {
124 self.shared_config_dir
125 .as_ref()
126 .map(|dir| vec![dir.join("fence.jsonc")])
127 .unwrap_or_default()
128 }
129
130 #[cfg(not(test))]
131 {
132 let mut candidates = Vec::new();
133
134 if let Some(user_config) = ConfigInitializer::user_config_path() {
136 candidates.push(user_config);
137 }
138
139 if let Ok(data_dirs) = std::env::var("XDG_DATA_DIRS") {
141 for data_dir in data_dirs.split(':').filter(|s| !s.is_empty()) {
142 candidates.push(Path::new(data_dir).join("aiw").join("fence.jsonc"));
143 }
144 }
145
146 candidates.push(PathBuf::from("/usr/local/share/aiw/fence.jsonc"));
147 candidates.push(PathBuf::from("/usr/share/aiw/fence.jsonc"));
148
149 candidates
150 }
151 }
152
153 fn construct_command(&self) -> Result<Command, SandboxError> {
154 let mut cmd = FenceCommand::create(self.workspace_path);
155
156 self.append_settings_args(&mut cmd);
157 cmd.arg("--");
158 self.append_direnv_args(&mut cmd)?;
159 cmd.args(self.command);
160
161 Ok(cmd)
162 }
163
164 pub fn build_command(&self) -> Result<Command, SandboxError> {
166 if which::which("fence").is_err() {
167 return Err(SandboxError::FenceNotFound);
168 }
169
170 self.construct_command()
171 }
172
173 pub fn run(&self) -> Result<(), SandboxError> {
176 let mut cmd = self.build_command()?;
177 cmd.stdin(std::process::Stdio::inherit())
178 .stdout(std::process::Stdio::inherit())
179 .stderr(std::process::Stdio::inherit());
180 let status = cmd.status()?;
181 if !status.success() {
182 return Err(SandboxError::ExecutionFailed(status));
183 }
184 Ok(())
185 }
186
187 #[cfg(test)]
188 fn build_args(&self) -> Result<Vec<String>, SandboxError> {
189 let cmd = self.build_command()?;
190 Ok(cmd
191 .get_args()
192 .map(|a| a.to_string_lossy().into_owned())
193 .collect())
194 }
195}
196
197impl std::fmt::Display for SandboxBuilder<'_> {
198 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 let cmd = self.construct_command().map_err(|_err| std::fmt::Error)?;
200 write!(f, "{}", cmd.get_program().to_string_lossy())?;
201 for arg in cmd.get_args() {
202 write!(f, " {}", arg.to_string_lossy())?;
203 }
204 Ok(())
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use googletest::prelude::*;
212
213 fn contains_subslice<'a, 'b>(expected: &'a [&'a str]) -> impl Matcher<&'b [String]> + 'a {
214 predicate(move |actual: &[String]| {
215 if expected.is_empty() {
216 return true;
217 }
218 actual
219 .windows(expected.len())
220 .any(|window| window.iter().zip(expected.iter()).all(|(a, b)| a == b))
221 })
222 .with_description(
223 format!("contains contiguous subslice {expected:?}"),
224 format!("does not contain contiguous subslice {expected:?}"),
225 )
226 }
227
228 type TestResult = Result<(), Box<dyn std::error::Error>>;
229
230 #[googletest::test]
231 fn build_args_with_existing_fence_json_in_repo_root_passes_settings_flag() -> TestResult {
232 let temp_dir = tempfile::tempdir()?;
233 let repo_root = temp_dir.path().join("repo");
234 let workspace_path = repo_root.join(".workspaces").join("my-ws");
235 std::fs::create_dir_all(&workspace_path)?;
236
237 let fence_json = repo_root.join("fence.json");
238 std::fs::write(&fence_json, r#"{"extends": "code"}"#)?;
239
240 let cmd = vec![
241 "agy".to_string(),
242 "--dangerously-skip-permissions".to_string(),
243 ];
244 let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd);
245 let args = builder.build_args()?;
246
247 let fence_str = fence_json.display().to_string();
248 expect_that!(&args[..], contains_subslice(&["--settings", &fence_str]));
249 expect_that!(
250 &args[..],
251 contains_subslice(&["--", "agy", "--dangerously-skip-permissions"])
252 );
253 Ok(())
254 }
255
256 #[googletest::test]
257 fn build_args_with_fence_jsonc_in_workspace_takes_precedence_over_repo_root() -> TestResult {
258 let temp_dir = tempfile::tempdir()?;
259 let repo_root = temp_dir.path().join("repo");
260 let workspace_path = repo_root.join(".workspaces").join("ws");
261 std::fs::create_dir_all(&workspace_path)?;
262
263 let root_fence = repo_root.join("fence.json");
264 std::fs::write(&root_fence, r#"{"extends": "code"}"#)?;
265 let ws_fence = workspace_path.join("fence.jsonc");
266 std::fs::write(&ws_fence, r#"{"extends": "code"}"#)?;
267
268 let cmd = vec!["agy".to_string()];
269 let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd);
270 let args = builder.build_args()?;
271
272 let ws_fence_str = ws_fence.display().to_string();
273 expect_that!(&args[..], contains_subslice(&["--settings", &ws_fence_str]));
274 Ok(())
275 }
276
277 #[googletest::test]
278 fn build_args_without_any_fence_config_delegates_to_fence_discovery() -> TestResult {
279 let temp_dir = tempfile::tempdir()?;
280 let repo_root = temp_dir.path().join("repo");
281 let workspace_path = repo_root.join(".workspaces").join("ws");
282 std::fs::create_dir_all(&workspace_path)?;
283
284 let cmd = vec![
285 "agy".to_string(),
286 "--dangerously-skip-permissions".to_string(),
287 ];
288 let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd);
289 let args = builder.build_args()?;
290
291 expect_that!(
292 &args[..],
293 elements_are![eq("--"), eq("agy"), eq("--dangerously-skip-permissions")]
294 );
295 Ok(())
296 }
297
298 #[googletest::test]
299 fn build_args_discovers_shared_template_when_no_local_fence_config_exists() -> TestResult {
300 let temp_dir = tempfile::tempdir()?;
301 let repo_root = temp_dir.path().join("repo");
302 let workspace_path = repo_root.join(".workspaces").join("ws");
303 let shared_dir = temp_dir.path().join("shared");
304 std::fs::create_dir_all(&workspace_path)?;
305 std::fs::create_dir_all(&shared_dir)?;
306
307 let shared_template = shared_dir.join("fence.jsonc");
308 std::fs::write(&shared_template, r#"{"extends": "code"}"#)?;
309
310 let cmd = vec!["agy".to_string()];
311 let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd)
312 .with_shared_config_dir(shared_dir.clone());
313 let args = builder.build_args()?;
314
315 let tpl_str = shared_template.display().to_string();
316 expect_that!(&*args, contains_subslice(&["--settings", &tpl_str]));
317 Ok(())
318 }
319
320 #[googletest::test]
321 fn build_args_local_fence_config_takes_precedence_over_shared_template() -> TestResult {
322 let temp_dir = tempfile::tempdir()?;
323 let repo_root = temp_dir.path().join("repo");
324 let workspace_path = repo_root.join(".workspaces").join("ws");
325 let shared_dir = temp_dir.path().join("shared");
326 std::fs::create_dir_all(&workspace_path)?;
327 std::fs::create_dir_all(&shared_dir)?;
328
329 let shared_template = shared_dir.join("fence.jsonc");
330 std::fs::write(&shared_template, r#"{"extends": "code"}"#)?;
331
332 let local_fence = workspace_path.join("fence.jsonc");
333 std::fs::write(&local_fence, r#"{"extends": "code"}"#)?;
334
335 let cmd = vec!["agy".to_string()];
336 let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd)
337 .with_shared_config_dir(shared_dir);
338 let args = builder.build_args()?;
339
340 let local_str = local_fence.display().to_string();
341 expect_that!(&*args, contains_subslice(&["--settings", &local_str]));
342 Ok(())
343 }
344
345 #[googletest::test]
346 fn build_args_with_explicit_settings_path_uses_specified_path() -> TestResult {
347 let temp_dir = tempfile::tempdir()?;
348 let repo_root = temp_dir.path().join("repo");
349 let workspace_path = repo_root.join(".workspaces").join("ws");
350 std::fs::create_dir_all(&workspace_path)?;
351
352 let custom_fence = temp_dir.path().join("custom-fence.json");
353 std::fs::write(&custom_fence, r#"{"extends": "code"}"#)?;
354
355 let cmd = vec!["agy".to_string()];
356 let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd)
357 .with_settings_path(&custom_fence);
358 let args = builder.build_args()?;
359
360 let custom_str = custom_fence.display().to_string();
361 expect_that!(&args[..], contains_subslice(&["--settings", &custom_str]));
362 Ok(())
363 }
364
365 #[googletest::test]
366 fn build_args_with_use_direnv_true_prepends_direnv_exec() -> TestResult {
367 let temp_dir = tempfile::tempdir()?;
368 let repo_root = temp_dir.path().join("repo");
369 let workspace_path = repo_root.join(".workspaces").join("ws");
370 std::fs::create_dir_all(&workspace_path)?;
371
372 let cmd = vec![
373 "agy".to_string(),
374 "--dangerously-skip-permissions".to_string(),
375 ];
376 let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd).with_direnv(true);
377 let args = builder.build_args()?;
378
379 expect_that!(
380 &args[..],
381 contains_subslice(&[
382 "--",
383 "direnv",
384 "exec",
385 ".",
386 "agy",
387 "--dangerously-skip-permissions"
388 ])
389 );
390 Ok(())
391 }
392
393 #[googletest::test]
394 fn build_args_with_custom_command_executes_specified_command() -> TestResult {
395 let temp_dir = tempfile::tempdir()?;
396 let repo_root = temp_dir.path().join("repo");
397 let workspace_path = repo_root.join(".workspaces").join("ws");
398 std::fs::create_dir_all(&workspace_path)?;
399
400 let custom_cmd = vec!["sh".to_string(), "-c".to_string(), "echo ok".to_string()];
401 let builder =
402 SandboxBuilder::new(&workspace_path, &repo_root, &custom_cmd).with_direnv(true);
403 let args = builder.build_args()?;
404
405 expect_that!(
406 &args[..],
407 contains_subslice(&["--", "direnv", "exec", ".", "sh", "-c", "echo ok"])
408 );
409 Ok(())
410 }
411
412 #[googletest::test]
413 fn build_command_sets_fence_program_and_workspace_cwd() -> TestResult {
414 let temp_dir = tempfile::tempdir()?;
415 let repo_root = temp_dir.path().join("repo");
416 let workspace_path = repo_root.join(".workspaces").join("ws");
417 std::fs::create_dir_all(&workspace_path)?;
418
419 let cmd_payload = vec!["agy".to_string()];
420 let builder = SandboxBuilder::new(&workspace_path, &repo_root, &cmd_payload);
421 let cmd = builder.build_command()?;
422
423 expect_that!(cmd.get_program(), eq("fence"));
424 expect_that!(cmd.get_current_dir(), some(eq(&workspace_path)));
425 Ok(())
426 }
427
428 #[googletest::test]
429 fn append_direnv_args_called_out_of_order_returns_error() -> TestResult {
430 let temp_dir = tempfile::tempdir()?;
431 let repo_root = temp_dir.path();
432 let ws_path = repo_root.join("ws");
433 let cmd_payload = vec!["echo".to_string()];
434
435 let builder = SandboxBuilder::new(&ws_path, repo_root, &cmd_payload).with_direnv(true);
436
437 let mut cmd1 = Command::new("fence");
439 let res1 = builder.append_direnv_args(&mut cmd1);
440 expect_that!(
441 res1,
442 matches_pattern!(Err(matches_pattern!(SandboxError::InvalidArgOrder(
443 anything()
444 ))))
445 );
446
447 let mut cmd2 = Command::new("fence");
449 cmd2.arg("--");
450 cmd2.arg("echo");
451 let res2 = builder.append_direnv_args(&mut cmd2);
452 expect_that!(
453 res2,
454 matches_pattern!(Err(matches_pattern!(SandboxError::InvalidArgOrder(
455 anything()
456 ))))
457 );
458
459 let mut cmd3 = Command::new("fence");
461 cmd3.arg("--");
462 let res3 = builder.append_direnv_args(&mut cmd3);
463 expect_that!(res3, ok(anything()));
464 Ok(())
465 }
466
467 #[googletest::test]
468 fn display_trait_produces_readable_shell_string() -> TestResult {
469 let temp_dir = tempfile::tempdir()?;
470 let repo_root = temp_dir.path().join("repo");
471 let workspace_path = repo_root.join(".workspaces").join("ws");
472 std::fs::create_dir_all(&workspace_path)?;
473
474 let custom_cmd = vec!["sh".to_string(), "-c".to_string(), "echo hi".to_string()];
475 let builder =
476 SandboxBuilder::new(&workspace_path, &repo_root, &custom_cmd).with_direnv(true);
477
478 let formatted = format!("{builder}");
479 expect_that!(&formatted, eq("fence -- direnv exec . sh -c echo hi"));
480 expect_that!(&builder.to_string(), eq(&formatted));
481 Ok(())
482 }
483
484 #[googletest::test]
485 fn for_workspace_configures_workspace_cwd_and_preserves_command() -> TestResult {
486 let temp_dir = tempfile::tempdir()?;
487 let repo_root = temp_dir.path();
488 let output = std::process::Command::new("jj")
489 .args(["--no-pager", "git", "init"])
490 .arg(repo_root)
491 .output()?;
492 expect_that!(output.status.success(), is_true());
493
494 let ws = Workspace::new(repo_root, "builder-ws")?;
495 let cmd_payload = vec![
496 "agy".to_string(),
497 "--dangerously-skip-permissions".to_string(),
498 "--extra-flag".to_string(),
499 ];
500 let builder = SandboxBuilder::for_workspace(&ws, &cmd_payload).with_direnv(true);
501
502 let cmd = builder.build_command()?;
503 expect_that!(cmd.get_current_dir(), some(eq(ws.path())));
504 let formatted = format!("{builder}");
505 expect_that!(
506 &formatted,
507 contains_substring("direnv exec . agy --dangerously-skip-permissions --extra-flag")
508 );
509 Ok(())
510 }
511}