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