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 runtime setup for the lightweight Ubuntu image.
219# runtime:
220# apt:
221# - python3
222# - python3-pip
223# setup:
224# - python3 -m pip install --user maturin
225"#
226 )
227}
228
229fn open_editor(editor: &str, path: &Path) -> Result<()> {
230 let status = Command::new(editor)
231 .arg(path)
232 .status()
233 .with_context(|| format!("failed to launch editor '{editor}'"))?;
234 if status.success() {
235 return Ok(());
236 }
237
238 Err(anyhow!(
239 "editor '{editor}' exited unsuccessfully while editing '{}'",
240 path.display()
241 ))
242}
243
244fn selected_editor() -> String {
245 std::env::var("VISUAL")
246 .ok()
247 .filter(|editor| !editor.trim().is_empty())
248 .or_else(|| {
249 std::env::var("EDITOR")
250 .ok()
251 .filter(|editor| !editor.trim().is_empty())
252 })
253 .unwrap_or_else(|| "vim".to_owned())
254}
255
256fn validate_workspace_name(workspace_name: &str) -> Result<()> {
257 if workspace_name.trim().is_empty() {
258 return Err(anyhow!("workspace name cannot be empty"));
259 }
260 if Path::new(workspace_name)
261 .components()
262 .any(|component| matches!(component, Component::ParentDir | Component::RootDir))
263 || workspace_name.contains('/')
264 || workspace_name.contains('\\')
265 {
266 return Err(anyhow!(
267 "workspace name '{workspace_name}' cannot contain path separators"
268 ));
269 }
270 Ok(())
271}
272
273fn is_path_like(path: &Path) -> bool {
274 if path.is_absolute() {
275 return true;
276 }
277 let Some(path_text) = path.to_str() else {
278 return true;
279 };
280 path_text == "~"
281 || path_text.starts_with("~/")
282 || path_text.starts_with("./")
283 || path_text.starts_with("../")
284 || path_text.contains('/')
285 || path_text.contains('\\')
286 || path.extension().is_some()
287}
288
289#[must_use]
299pub fn expand_home_path(path: PathBuf) -> PathBuf {
300 let Some(path_text) = path.to_str() else {
301 return path;
302 };
303
304 if path_text == "~" {
305 return home_dir().unwrap_or(path);
306 }
307
308 if let Some(rest) = path_text.strip_prefix("~/")
309 && let Some(home) = home_dir()
310 {
311 return home.join(rest);
312 }
313
314 path
315}
316
317fn home_dir() -> Option<PathBuf> {
318 directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
319}
320
321#[cfg(test)]
322mod tests {
323 use std::sync::atomic::{AtomicUsize, Ordering};
324 use std::time::{SystemTime, UNIX_EPOCH};
325
326 use super::*;
327
328 static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
329
330 #[test]
331 fn workspace_manifest_path_uses_config_workspace_directory() {
332 let path = workspace_manifest_path(Path::new("/host/.codex-ws"), "backend")
333 .expect("path should build");
334
335 assert_eq!(
336 path,
337 PathBuf::from("/host/.codex-ws/config/workspace/backend.yaml")
338 );
339 }
340
341 #[test]
342 fn resolve_workspace_path_maps_names_to_saved_manifest_paths() {
343 let path = resolve_workspace_path(PathBuf::from("backend"), Path::new("/host/.codex-ws"))
344 .expect("path should resolve");
345
346 assert_eq!(
347 path,
348 PathBuf::from("/host/.codex-ws/config/workspace/backend.yaml")
349 );
350 }
351
352 #[test]
353 fn resolve_workspace_path_keeps_path_like_values() {
354 let path = resolve_workspace_path(
355 PathBuf::from("/tmp/workspace.yaml"),
356 Path::new("/host/.codex-ws"),
357 )
358 .expect("path should resolve");
359
360 assert_eq!(path, PathBuf::from("/tmp/workspace.yaml"));
361 }
362
363 #[test]
364 fn list_workspaces_returns_sorted_yaml_files() {
365 let temp_dir = TestTempDir::create();
366 let config_dir = workspace_config_dir(temp_dir.path());
367 fs::create_dir_all(&config_dir).expect("config dir should be created");
368 fs::write(config_dir.join("zeta.yaml"), "").expect("workspace should be written");
369 fs::write(config_dir.join("alpha.yaml"), "").expect("workspace should be written");
370 fs::write(config_dir.join("ignored.txt"), "").expect("ignored file should be written");
371
372 let entries = list_workspaces(temp_dir.path()).expect("workspaces should list");
373
374 assert_eq!(
375 entries
376 .iter()
377 .map(|entry| entry.name().to_owned())
378 .collect::<Vec<_>>(),
379 vec!["alpha".to_owned(), "zeta".to_owned()]
380 );
381 }
382
383 #[test]
384 fn add_workspace_writes_template_without_overwriting_existing_file() {
385 let temp_dir = TestTempDir::create();
386 let editor = "true".to_owned();
387 let path = add_workspace_with_editor(temp_dir.path(), "backend", editor.clone())
388 .expect("workspace should be added");
389
390 let first_content = fs::read_to_string(&path).expect("workspace should be readable");
391 assert!(first_content.contains("name: backend"));
392
393 fs::write(&path, "name: custom\n").expect("workspace should be overwritten for test");
394 add_workspace_with_editor(temp_dir.path(), "backend", editor)
395 .expect("existing workspace should open");
396
397 assert_eq!(
398 fs::read_to_string(&path).expect("workspace should be readable"),
399 "name: custom\n"
400 );
401 }
402
403 #[derive(Debug)]
404 struct TestTempDir {
405 path: PathBuf,
406 }
407
408 impl TestTempDir {
409 fn create() -> Self {
410 let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
411 let timestamp = SystemTime::now()
412 .duration_since(UNIX_EPOCH)
413 .expect("system clock should be after Unix epoch")
414 .as_nanos();
415 let path = std::env::temp_dir().join(format!(
416 "codex-ws-workspace-test-{}-{timestamp}-{counter}",
417 std::process::id()
418 ));
419 fs::create_dir(&path).expect("temporary test directory should be created");
420 Self { path }
421 }
422
423 fn path(&self) -> &Path {
424 &self.path
425 }
426 }
427
428 impl Drop for TestTempDir {
429 fn drop(&mut self) {
430 let _ = fs::remove_dir_all(&self.path);
431 }
432 }
433}