1use std::ffi::OsStr;
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4use std::process::Command;
5
6use anyhow::{Context, Result, anyhow};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct WorkspaceEntry {
11 name: String,
12 path: PathBuf,
13}
14
15impl WorkspaceEntry {
16 #[must_use]
27 pub fn new(name: String, path: PathBuf) -> Self {
28 Self { name, path }
29 }
30
31 #[must_use]
37 pub fn name(&self) -> &str {
38 &self.name
39 }
40
41 #[must_use]
47 pub fn path(&self) -> &Path {
48 &self.path
49 }
50}
51
52#[must_use]
62pub fn workspace_config_dir(sessions_root: &Path) -> PathBuf {
63 sessions_root.join("config").join("workspace")
64}
65
66pub fn workspace_manifest_path(sessions_root: &Path, workspace_name: &str) -> Result<PathBuf> {
81 validate_workspace_name(workspace_name)?;
82 Ok(workspace_config_dir(sessions_root).join(format!("{workspace_name}.yaml")))
83}
84
85pub fn resolve_workspace_path(workspace: PathBuf, sessions_root: &Path) -> Result<PathBuf> {
103 if is_path_like(&workspace) {
104 return Ok(expand_home_path(workspace));
105 }
106
107 let Some(workspace_name) = workspace.to_str() else {
108 return Ok(workspace);
109 };
110 workspace_manifest_path(sessions_root, workspace_name)
111}
112
113pub fn list_workspaces(sessions_root: &Path) -> Result<Vec<WorkspaceEntry>> {
127 let config_dir = workspace_config_dir(sessions_root);
128 if !config_dir.exists() {
129 return Ok(Vec::new());
130 }
131
132 let mut entries = Vec::new();
133 for entry in fs::read_dir(&config_dir).with_context(|| {
134 format!(
135 "failed to read workspace config directory '{}'",
136 config_dir.display()
137 )
138 })? {
139 let entry = entry.with_context(|| {
140 format!(
141 "failed to read entry in workspace config directory '{}'",
142 config_dir.display()
143 )
144 })?;
145 let path = entry.path();
146 if path.extension() != Some(OsStr::new("yaml")) {
147 continue;
148 }
149 let Some(name) = path.file_stem().and_then(OsStr::to_str) else {
150 continue;
151 };
152 entries.push(WorkspaceEntry::new(name.to_owned(), path));
153 }
154
155 entries.sort_by(|left, right| left.name().cmp(right.name()));
156 Ok(entries)
157}
158
159pub fn add_workspace(sessions_root: &Path, workspace_name: &str) -> Result<PathBuf> {
174 add_workspace_with_editor(sessions_root, workspace_name, selected_editor())
175}
176
177fn add_workspace_with_editor(
178 sessions_root: &Path,
179 workspace_name: &str,
180 editor: String,
181) -> Result<PathBuf> {
182 let manifest_path = workspace_manifest_path(sessions_root, workspace_name)?;
183 if let Some(parent) = manifest_path.parent() {
184 fs::create_dir_all(parent).with_context(|| {
185 format!(
186 "failed to create workspace config directory '{}'",
187 parent.display()
188 )
189 })?;
190 }
191
192 if !manifest_path.exists() {
193 fs::write(&manifest_path, workspace_template(workspace_name)).with_context(|| {
194 format!(
195 "failed to write workspace manifest template '{}'",
196 manifest_path.display()
197 )
198 })?;
199 }
200
201 open_editor(&editor, &manifest_path)?;
202 Ok(manifest_path)
203}
204
205fn workspace_template(workspace_name: &str) -> String {
206 format!(
207 r#"# Workspace manifest for codex-ws.
208# Replace the folder examples with absolute host paths.
209name: {workspace_name}
210folders:
211 - /absolute/path/to/project
212
213# The container has network access by default so Codex can reach the model provider.
214# Advanced offline-only configuration:
215# sandbox:
216# network: false
217
218# Optional declarative runtime setup for the lightweight Ubuntu image.
219# runtime:
220# python: "3.13"
221# node: "22"
222# go: "1.24"
223# rust: "1.86"
224# java: "21"
225# clang: "20"
226# c: "20"
227# cpp: "20"
228# ruby: "3.4"
229# php: "8.4"
230# deno: "2"
231# bun: "1"
232# zig: "0.14"
233# dotnet: "9"
234# apt:
235# - build-essential
236# setup:
237# - python -m pip install --user maturin
238"#
239 )
240}
241
242fn open_editor(editor: &str, path: &Path) -> Result<()> {
243 let status = Command::new(editor)
244 .arg(path)
245 .status()
246 .with_context(|| format!("failed to launch editor '{editor}'"))?;
247 if status.success() {
248 return Ok(());
249 }
250
251 Err(anyhow!(
252 "editor '{editor}' exited unsuccessfully while editing '{}'",
253 path.display()
254 ))
255}
256
257fn selected_editor() -> String {
258 std::env::var("VISUAL")
259 .ok()
260 .filter(|editor| !editor.trim().is_empty())
261 .or_else(|| {
262 std::env::var("EDITOR")
263 .ok()
264 .filter(|editor| !editor.trim().is_empty())
265 })
266 .unwrap_or_else(|| "vim".to_owned())
267}
268
269fn validate_workspace_name(workspace_name: &str) -> Result<()> {
270 if workspace_name.trim().is_empty() {
271 return Err(anyhow!("workspace name cannot be empty"));
272 }
273 if Path::new(workspace_name)
274 .components()
275 .any(|component| matches!(component, Component::ParentDir | Component::RootDir))
276 || workspace_name.contains('/')
277 || workspace_name.contains('\\')
278 {
279 return Err(anyhow!(
280 "workspace name '{workspace_name}' cannot contain path separators"
281 ));
282 }
283 Ok(())
284}
285
286fn is_path_like(path: &Path) -> bool {
287 if path.is_absolute() {
288 return true;
289 }
290 let Some(path_text) = path.to_str() else {
291 return true;
292 };
293 path_text == "~"
294 || path_text.starts_with("~/")
295 || path_text.starts_with("./")
296 || path_text.starts_with("../")
297 || path_text.contains('/')
298 || path_text.contains('\\')
299 || path.extension().is_some()
300}
301
302#[must_use]
312pub fn expand_home_path(path: PathBuf) -> PathBuf {
313 let Some(path_text) = path.to_str() else {
314 return path;
315 };
316
317 if path_text == "~" {
318 return home_dir().unwrap_or(path);
319 }
320
321 if let Some(rest) = path_text.strip_prefix("~/")
322 && let Some(home) = home_dir()
323 {
324 return home.join(rest);
325 }
326
327 path
328}
329
330fn home_dir() -> Option<PathBuf> {
331 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
332}
333
334#[cfg(test)]
335mod tests {
336 use std::sync::atomic::{AtomicUsize, Ordering};
337 use std::time::{SystemTime, UNIX_EPOCH};
338
339 use super::*;
340
341 static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
342
343 #[test]
344 fn workspace_manifest_path_uses_config_workspace_directory() {
345 let path = workspace_manifest_path(Path::new("/host/.codex-ws"), "backend")
346 .expect("path should build");
347
348 assert_eq!(
349 path,
350 PathBuf::from("/host/.codex-ws/config/workspace/backend.yaml")
351 );
352 }
353
354 #[test]
355 fn resolve_workspace_path_maps_names_to_saved_manifest_paths() {
356 let path = resolve_workspace_path(PathBuf::from("backend"), Path::new("/host/.codex-ws"))
357 .expect("path should resolve");
358
359 assert_eq!(
360 path,
361 PathBuf::from("/host/.codex-ws/config/workspace/backend.yaml")
362 );
363 }
364
365 #[test]
366 fn resolve_workspace_path_keeps_path_like_values() {
367 let path = resolve_workspace_path(
368 PathBuf::from("/tmp/workspace.yaml"),
369 Path::new("/host/.codex-ws"),
370 )
371 .expect("path should resolve");
372
373 assert_eq!(path, PathBuf::from("/tmp/workspace.yaml"));
374 }
375
376 #[test]
377 fn list_workspaces_returns_sorted_yaml_files() {
378 let temp_dir = TestTempDir::create();
379 let config_dir = workspace_config_dir(temp_dir.path());
380 fs::create_dir_all(&config_dir).expect("config dir should be created");
381 fs::write(config_dir.join("zeta.yaml"), "").expect("workspace should be written");
382 fs::write(config_dir.join("alpha.yaml"), "").expect("workspace should be written");
383 fs::write(config_dir.join("ignored.txt"), "").expect("ignored file should be written");
384
385 let entries = list_workspaces(temp_dir.path()).expect("workspaces should list");
386
387 assert_eq!(
388 entries
389 .iter()
390 .map(|entry| entry.name().to_owned())
391 .collect::<Vec<_>>(),
392 vec!["alpha".to_owned(), "zeta".to_owned()]
393 );
394 }
395
396 #[test]
397 fn add_workspace_writes_template_without_overwriting_existing_file() {
398 let temp_dir = TestTempDir::create();
399 let editor = "true".to_owned();
400 let path = add_workspace_with_editor(temp_dir.path(), "backend", editor.clone())
401 .expect("workspace should be added");
402
403 let first_content = fs::read_to_string(&path).expect("workspace should be readable");
404 assert!(first_content.contains("name: backend"));
405
406 fs::write(&path, "name: custom\n").expect("workspace should be overwritten for test");
407 add_workspace_with_editor(temp_dir.path(), "backend", editor)
408 .expect("existing workspace should open");
409
410 assert_eq!(
411 fs::read_to_string(&path).expect("workspace should be readable"),
412 "name: custom\n"
413 );
414 }
415
416 #[derive(Debug)]
417 struct TestTempDir {
418 path: PathBuf,
419 }
420
421 impl TestTempDir {
422 fn create() -> Self {
423 let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
424 let timestamp = SystemTime::now()
425 .duration_since(UNIX_EPOCH)
426 .expect("system clock should be after Unix epoch")
427 .as_nanos();
428 let path = std::env::temp_dir().join(format!(
429 "codex-ws-workspace-test-{}-{timestamp}-{counter}",
430 std::process::id()
431 ));
432 fs::create_dir(&path).expect("temporary test directory should be created");
433 Self { path }
434 }
435
436 fn path(&self) -> &Path {
437 &self.path
438 }
439 }
440
441 impl Drop for TestTempDir {
442 fn drop(&mut self) {
443 let _ = fs::remove_dir_all(&self.path);
444 }
445 }
446}