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 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/// Expand a leading `~` in a path.
287///
288/// # Arguments
289///
290/// * `path` - Path that may start with `~` or `~/`.
291///
292/// # Returns
293///
294/// The path with a leading home-directory marker expanded when possible.
295#[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}