Skip to main content

codex_ws/
workspace.rs

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/// Saved workspace manifest entry.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct WorkspaceEntry {
11    name: String,
12    path: PathBuf,
13}
14
15impl WorkspaceEntry {
16    /// Create a saved workspace manifest entry.
17    ///
18    /// # Arguments
19    ///
20    /// * `name` - Workspace name derived from the manifest file stem.
21    /// * `path` - Manifest file path.
22    ///
23    /// # Returns
24    ///
25    /// A workspace entry.
26    #[must_use]
27    pub fn new(name: String, path: PathBuf) -> Self {
28        Self { name, path }
29    }
30
31    /// Return the workspace name.
32    ///
33    /// # Returns
34    ///
35    /// Workspace name shown by `workspace ls`.
36    #[must_use]
37    pub fn name(&self) -> &str {
38        &self.name
39    }
40
41    /// Return the manifest path.
42    ///
43    /// # Returns
44    ///
45    /// Path to the saved workspace manifest.
46    #[must_use]
47    pub fn path(&self) -> &Path {
48        &self.path
49    }
50}
51
52/// Return the saved workspace manifest directory.
53///
54/// # Arguments
55///
56/// * `sessions_root` - codex-ws state root.
57///
58/// # Returns
59///
60/// Directory containing saved workspace manifests.
61#[must_use]
62pub fn workspace_config_dir(sessions_root: &Path) -> PathBuf {
63    sessions_root.join("config").join("workspace")
64}
65
66/// Return the manifest path for a saved workspace name.
67///
68/// # Arguments
69///
70/// * `sessions_root` - codex-ws state root.
71/// * `workspace_name` - Saved workspace name.
72///
73/// # Returns
74///
75/// Path to the saved workspace manifest.
76///
77/// # Errors
78///
79/// Returns an error when the workspace name is empty or contains path separators.
80pub 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
85/// Resolve a `run --workspace` value to a manifest path.
86///
87/// Path-like values are expanded and returned as paths. Bare workspace names resolve under
88/// `~/.codex-ws/config/workspace` relative to the configured sessions root.
89///
90/// # Arguments
91///
92/// * `workspace` - User-provided workspace name or path.
93/// * `sessions_root` - codex-ws state root.
94///
95/// # Returns
96///
97/// Manifest path to load.
98///
99/// # Errors
100///
101/// Returns an error when a bare workspace name is invalid.
102pub 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
113/// List saved workspace manifests.
114///
115/// # Arguments
116///
117/// * `sessions_root` - codex-ws state root.
118///
119/// # Returns
120///
121/// Sorted workspace entries for `.yaml` files under the workspace config directory.
122///
123/// # Errors
124///
125/// Returns an error when the workspace config directory cannot be read.
126pub 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
159/// Create a saved workspace manifest if needed and open it in an editor.
160///
161/// # Arguments
162///
163/// * `sessions_root` - codex-ws state root.
164/// * `workspace_name` - Workspace name used for the manifest file.
165///
166/// # Returns
167///
168/// Path to the saved workspace manifest.
169///
170/// # Errors
171///
172/// Returns an error when the file cannot be created or the editor exits unsuccessfully.
173pub 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/// Expand a leading `~` in a path.
303///
304/// # Arguments
305///
306/// * `path` - Path that may start with `~` or `~/`.
307///
308/// # Returns
309///
310/// The path with a leading home-directory marker expanded when possible.
311#[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}