Skip to main content

codex_ws/
manifest.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5use thiserror::Error;
6
7use crate::runtime::{RuntimeEnvironmentVariable, RuntimeLanguageVersion, RuntimeSpecError};
8
9/// Workspace manifest describing folders and sandbox options.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct WorkspaceManifest {
12    name: String,
13    folders: Vec<PathBuf>,
14    sandbox: SandboxConfig,
15    runtime: RuntimeConfig,
16}
17
18impl WorkspaceManifest {
19    /// Create a workspace manifest.
20    ///
21    /// # Arguments
22    ///
23    /// * `name` - Stable workspace name used for session routing.
24    /// * `folders` - One or more project folders included in the workspace.
25    /// * `sandbox` - Runtime options applied when launching the sandbox.
26    ///
27    /// # Returns
28    ///
29    /// A validated workspace manifest.
30    ///
31    /// # Errors
32    ///
33    /// Returns [`ManifestError::EmptyName`] when `name` is blank.
34    /// Returns [`ManifestError::NoFolders`] when no folders are provided.
35    pub fn new(
36        name: String,
37        folders: Vec<PathBuf>,
38        sandbox: SandboxConfig,
39    ) -> Result<Self, ManifestError> {
40        Self::with_runtime(name, folders, sandbox, RuntimeConfig::default())
41    }
42
43    /// Create a workspace manifest with runtime settings.
44    ///
45    /// # Arguments
46    ///
47    /// * `name` - Stable workspace name used for session routing.
48    /// * `folders` - One or more project folders included in the workspace.
49    /// * `sandbox` - Runtime sandbox options applied when launching the sandbox.
50    /// * `runtime` - Container runtime image settings for this workspace.
51    ///
52    /// # Returns
53    ///
54    /// A validated workspace manifest.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`ManifestError::EmptyName`] when `name` is blank.
59    /// Returns [`ManifestError::NoFolders`] when no folders are provided.
60    /// Returns [`ManifestError::EmptyRuntimeImage`] when `runtime.image` is blank.
61    pub fn with_runtime(
62        name: String,
63        folders: Vec<PathBuf>,
64        sandbox: SandboxConfig,
65        runtime: RuntimeConfig,
66    ) -> Result<Self, ManifestError> {
67        if name.trim().is_empty() {
68            return Err(ManifestError::EmptyName);
69        }
70
71        if folders.is_empty() {
72            return Err(ManifestError::NoFolders);
73        }
74
75        if runtime.image().is_some_and(|image| image.trim().is_empty()) {
76            return Err(ManifestError::EmptyRuntimeImage);
77        }
78
79        Ok(Self {
80            name,
81            folders,
82            sandbox,
83            runtime,
84        })
85    }
86
87    /// Return the workspace name.
88    ///
89    /// # Returns
90    ///
91    /// The workspace name as a borrowed string slice.
92    #[must_use]
93    pub fn name(&self) -> &str {
94        &self.name
95    }
96
97    /// Return workspace folders.
98    ///
99    /// # Returns
100    ///
101    /// A slice of folder paths included in this workspace.
102    #[must_use]
103    pub fn folders(&self) -> &[PathBuf] {
104        &self.folders
105    }
106
107    /// Return sandbox runtime options.
108    ///
109    /// # Returns
110    ///
111    /// The sandbox configuration for this workspace.
112    #[must_use]
113    pub fn sandbox(&self) -> &SandboxConfig {
114        &self.sandbox
115    }
116
117    /// Return container runtime options.
118    ///
119    /// # Returns
120    ///
121    /// The runtime configuration for this workspace.
122    #[must_use]
123    pub fn runtime(&self) -> &RuntimeConfig {
124        &self.runtime
125    }
126}
127
128/// Sandbox options loaded from a workspace manifest.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct SandboxConfig {
131    network: bool,
132}
133
134impl Default for SandboxConfig {
135    fn default() -> Self {
136        Self { network: true }
137    }
138}
139
140impl SandboxConfig {
141    /// Create a sandbox configuration.
142    ///
143    /// # Arguments
144    ///
145    /// * `network` - Whether the sandbox should allow network access.
146    ///
147    /// # Returns
148    ///
149    /// A sandbox configuration value.
150    #[must_use]
151    pub const fn new(network: bool) -> Self {
152        Self { network }
153    }
154
155    /// Return whether sandbox network access is enabled.
156    ///
157    /// # Returns
158    ///
159    /// `true` when network access is enabled.
160    #[must_use]
161    pub const fn network(&self) -> bool {
162        self.network
163    }
164}
165
166/// Container runtime options loaded from a workspace manifest.
167#[derive(Debug, Clone, Default, PartialEq, Eq)]
168pub struct RuntimeConfig {
169    image: Option<String>,
170    language_versions: Vec<RuntimeLanguageVersion>,
171}
172
173impl RuntimeConfig {
174    /// Create a runtime configuration.
175    ///
176    /// # Arguments
177    ///
178    /// * `image` - Optional Docker image used for this workspace.
179    ///
180    /// # Returns
181    ///
182    /// A runtime configuration value.
183    #[must_use]
184    pub fn new(image: Option<String>) -> Self {
185        Self {
186            image,
187            language_versions: Vec::new(),
188        }
189    }
190
191    /// Create a runtime configuration with language versions.
192    ///
193    /// # Arguments
194    ///
195    /// * `image` - Optional Docker image used for this workspace.
196    /// * `language_versions` - Codex Universal language runtimes requested by the workspace.
197    ///
198    /// # Returns
199    ///
200    /// A runtime configuration value.
201    #[must_use]
202    pub fn with_language_versions(
203        image: Option<String>,
204        language_versions: Vec<RuntimeLanguageVersion>,
205    ) -> Self {
206        Self {
207            image,
208            language_versions,
209        }
210    }
211
212    /// Return the workspace-specific Docker image.
213    ///
214    /// # Returns
215    ///
216    /// `Some(image)` when the manifest selects a runtime image, otherwise `None`.
217    #[must_use]
218    pub fn image(&self) -> Option<&str> {
219        self.image.as_deref()
220    }
221
222    /// Return selected language runtime versions.
223    ///
224    /// # Returns
225    ///
226    /// Language runtimes requested by this workspace.
227    #[must_use]
228    pub fn language_versions(&self) -> &[RuntimeLanguageVersion] {
229        &self.language_versions
230    }
231
232    /// Return Docker environment variables for Codex Universal.
233    ///
234    /// # Returns
235    ///
236    /// `CODEX_ENV_*` variables generated from configured language runtimes.
237    #[must_use]
238    pub fn environment_variables(&self) -> Vec<RuntimeEnvironmentVariable> {
239        self.language_versions
240            .iter()
241            .map(RuntimeLanguageVersion::environment_variable)
242            .collect()
243    }
244}
245
246/// Errors returned while loading or validating workspace manifests.
247#[derive(Debug, Error)]
248pub enum ManifestError {
249    /// The manifest file could not be read.
250    #[error("failed to read workspace manifest '{path}': {source}")]
251    Read {
252        /// Manifest path that failed to read.
253        path: PathBuf,
254        /// Underlying I/O error.
255        source: std::io::Error,
256    },
257
258    /// The manifest YAML could not be parsed.
259    #[error("invalid workspace manifest YAML: {0}")]
260    Yaml(#[from] serde_yaml::Error),
261
262    /// The workspace name was empty or only whitespace.
263    #[error("workspace manifest name cannot be empty")]
264    EmptyName,
265
266    /// The workspace did not include any folders.
267    #[error("workspace manifest must include at least one folder")]
268    NoFolders,
269
270    /// The workspace runtime image was empty or only whitespace.
271    #[error("workspace manifest runtime image cannot be empty")]
272    EmptyRuntimeImage,
273
274    /// The workspace runtime language selection was invalid.
275    #[error("invalid workspace runtime: {0}")]
276    RuntimeSpec(#[from] RuntimeSpecError),
277
278    /// A workspace folder path does not exist.
279    #[error("workspace folder '{path}' does not exist")]
280    FolderMissing {
281        /// Missing workspace folder path.
282        path: PathBuf,
283    },
284
285    /// A workspace folder path exists but is not a directory.
286    #[error("workspace folder '{path}' is not a directory")]
287    FolderNotDirectory {
288        /// Non-directory workspace folder path.
289        path: PathBuf,
290    },
291}
292
293#[derive(Debug, Deserialize)]
294struct RawWorkspaceManifest {
295    name: String,
296    folders: Vec<PathBuf>,
297    #[serde(default)]
298    sandbox: RawSandboxConfig,
299    #[serde(default)]
300    runtime: Option<RawRuntimeConfig>,
301}
302
303#[derive(Debug, Deserialize)]
304struct RawSandboxConfig {
305    #[serde(default = "default_sandbox_network")]
306    network: bool,
307}
308
309impl Default for RawSandboxConfig {
310    fn default() -> Self {
311        Self {
312            network: default_sandbox_network(),
313        }
314    }
315}
316
317const fn default_sandbox_network() -> bool {
318    true
319}
320
321#[derive(Debug, Deserialize)]
322#[serde(untagged)]
323enum RawRuntimeConfig {
324    Spec(String),
325    Specs(Vec<String>),
326    Map(RawRuntimeMap),
327}
328
329#[derive(Debug, Default, Deserialize)]
330struct RawRuntimeMap {
331    image: Option<String>,
332    #[serde(default)]
333    languages: Vec<String>,
334}
335
336impl TryFrom<RawWorkspaceManifest> for WorkspaceManifest {
337    type Error = ManifestError;
338
339    fn try_from(raw: RawWorkspaceManifest) -> Result<Self, Self::Error> {
340        let runtime = raw.runtime.unwrap_or_default().try_into()?;
341        Self::with_runtime(
342            raw.name,
343            raw.folders,
344            SandboxConfig::new(raw.sandbox.network),
345            runtime,
346        )
347    }
348}
349
350impl Default for RawRuntimeConfig {
351    fn default() -> Self {
352        Self::Map(RawRuntimeMap::default())
353    }
354}
355
356impl TryFrom<RawRuntimeConfig> for RuntimeConfig {
357    type Error = ManifestError;
358
359    fn try_from(raw: RawRuntimeConfig) -> Result<Self, Self::Error> {
360        match raw {
361            RawRuntimeConfig::Spec(spec) => runtime_from_parts(None, vec![spec]),
362            RawRuntimeConfig::Specs(specs) => runtime_from_parts(None, specs),
363            RawRuntimeConfig::Map(map) => runtime_from_parts(map.image, map.languages),
364        }
365    }
366}
367
368fn runtime_from_parts(
369    image: Option<String>,
370    specs: Vec<String>,
371) -> Result<RuntimeConfig, ManifestError> {
372    let image = image.map(|runtime_image| runtime_image.trim().to_owned());
373    let language_versions = crate::runtime::parse_runtime_specs(&specs)?;
374
375    Ok(RuntimeConfig::with_language_versions(
376        image,
377        language_versions,
378    ))
379}
380
381/// Load a workspace manifest from a YAML file.
382///
383/// # Arguments
384///
385/// * `manifest_path` - Path to the YAML workspace manifest.
386///
387/// # Returns
388///
389/// A validated workspace manifest.
390///
391/// # Errors
392///
393/// Returns [`ManifestError::Read`] when the file cannot be read.
394/// Returns [`ManifestError::Yaml`] when YAML parsing fails.
395/// Returns validation errors when required fields are missing or invalid.
396pub fn load_workspace_manifest(manifest_path: &Path) -> Result<WorkspaceManifest, ManifestError> {
397    let manifest_yaml =
398        fs::read_to_string(manifest_path).map_err(|source| ManifestError::Read {
399            path: manifest_path.to_path_buf(),
400            source,
401        })?;
402    parse_workspace_manifest(&manifest_yaml)
403}
404
405/// Parse a workspace manifest from YAML.
406///
407/// # Arguments
408///
409/// * `manifest_yaml` - YAML text containing workspace manifest fields.
410///
411/// # Returns
412///
413/// A validated workspace manifest.
414///
415/// # Errors
416///
417/// Returns [`ManifestError::Yaml`] when YAML parsing fails.
418/// Returns validation errors when required fields are missing or invalid.
419pub fn parse_workspace_manifest(manifest_yaml: &str) -> Result<WorkspaceManifest, ManifestError> {
420    let raw_manifest = serde_yaml::from_str::<RawWorkspaceManifest>(manifest_yaml)?;
421    raw_manifest.try_into()
422}
423
424/// Validate that every workspace folder exists and is a directory.
425///
426/// # Arguments
427///
428/// * `manifest` - Workspace manifest whose folders should be checked.
429///
430/// # Returns
431///
432/// `Ok(())` when all workspace folders exist and are directories.
433///
434/// # Errors
435///
436/// Returns [`ManifestError::FolderMissing`] when a folder path does not exist.
437/// Returns [`ManifestError::FolderNotDirectory`] when a folder path is not a directory.
438pub fn validate_workspace_folders(manifest: &WorkspaceManifest) -> Result<(), ManifestError> {
439    for folder in manifest.folders() {
440        if !folder.exists() {
441            return Err(ManifestError::FolderMissing {
442                path: folder.clone(),
443            });
444        }
445
446        if !folder.is_dir() {
447            return Err(ManifestError::FolderNotDirectory {
448                path: folder.clone(),
449            });
450        }
451    }
452
453    Ok(())
454}
455
456#[cfg(test)]
457mod tests {
458    use std::sync::atomic::{AtomicUsize, Ordering};
459    use std::time::{SystemTime, UNIX_EPOCH};
460
461    use super::*;
462
463    static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
464
465    #[derive(Debug)]
466    struct TestTempDir {
467        path: PathBuf,
468    }
469
470    impl TestTempDir {
471        fn create() -> Self {
472            let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
473            let timestamp = SystemTime::now()
474                .duration_since(UNIX_EPOCH)
475                .expect("system clock should be after Unix epoch")
476                .as_nanos();
477            let path = std::env::temp_dir().join(format!(
478                "codex-ws-test-{}-{timestamp}-{counter}",
479                std::process::id()
480            ));
481            fs::create_dir(&path).expect("temporary test directory should be created");
482            Self { path }
483        }
484
485        fn path(&self) -> &Path {
486            &self.path
487        }
488    }
489
490    impl Drop for TestTempDir {
491        fn drop(&mut self) {
492            let _ = fs::remove_dir_all(&self.path);
493        }
494    }
495
496    #[test]
497    fn parse_workspace_manifest_supports_multiple_folders_and_network() {
498        let manifest = parse_workspace_manifest(
499            r#"
500name: workspace-name
501folders:
502  - /projects/backend
503  - /projects/frontend
504sandbox:
505  network: true
506"#,
507        )
508        .expect("manifest should parse");
509
510        assert_eq!(manifest.name(), "workspace-name");
511        assert_eq!(
512            manifest.folders(),
513            &[
514                PathBuf::from("/projects/backend"),
515                PathBuf::from("/projects/frontend")
516            ]
517        );
518        assert!(manifest.sandbox().network());
519        assert_eq!(manifest.runtime().image(), None);
520    }
521
522    #[test]
523    fn parse_workspace_manifest_supports_single_folder() {
524        let manifest = parse_workspace_manifest(
525            r#"
526name: single-project
527folders:
528  - /projects/backend
529"#,
530        )
531        .expect("manifest should parse");
532
533        assert_eq!(manifest.name(), "single-project");
534        assert_eq!(manifest.folders(), &[PathBuf::from("/projects/backend")]);
535        assert!(manifest.sandbox().network());
536        assert_eq!(manifest.runtime().image(), None);
537    }
538
539    #[test]
540    fn parse_workspace_manifest_supports_runtime_image() {
541        let manifest = parse_workspace_manifest(
542            r#"
543name: rust-project
544folders:
545  - /projects/rust-project
546runtime:
547  image: rust-codex-ws:latest
548"#,
549        )
550        .expect("manifest should parse");
551
552        assert_eq!(manifest.runtime().image(), Some("rust-codex-ws:latest"));
553    }
554
555    #[test]
556    fn parse_workspace_manifest_supports_scalar_runtime_spec() {
557        let manifest = parse_workspace_manifest(
558            r#"
559name: go-project
560folders:
561  - /projects/go-project
562runtime: golang:1.25.1
563"#,
564        )
565        .expect("manifest should parse");
566
567        assert_eq!(
568            manifest.runtime().environment_variables()[0].docker_assignment(),
569            "CODEX_ENV_GO_VERSION=1.25.1"
570        );
571    }
572
573    #[test]
574    fn parse_workspace_manifest_supports_runtime_spec_list() {
575        let manifest = parse_workspace_manifest(
576            r#"
577name: web-project
578folders:
579  - /projects/web-project
580runtime:
581  - node:22
582  - python:3.13
583"#,
584        )
585        .expect("manifest should parse");
586
587        let variables = manifest.runtime().environment_variables();
588        assert_eq!(
589            variables
590                .iter()
591                .map(crate::runtime::RuntimeEnvironmentVariable::docker_assignment)
592                .collect::<Vec<_>>(),
593            vec![
594                "CODEX_ENV_NODE_VERSION=22".to_owned(),
595                "CODEX_ENV_PYTHON_VERSION=3.13".to_owned()
596            ]
597        );
598    }
599
600    #[test]
601    fn parse_workspace_manifest_supports_runtime_map_languages() {
602        let manifest = parse_workspace_manifest(
603            r#"
604name: mixed-project
605folders:
606  - /projects/mixed-project
607runtime:
608  languages:
609    - rust:1.95.0
610    - java:21
611"#,
612        )
613        .expect("manifest should parse");
614
615        let variables = manifest.runtime().environment_variables();
616        assert_eq!(
617            variables
618                .iter()
619                .map(crate::runtime::RuntimeEnvironmentVariable::docker_assignment)
620                .collect::<Vec<_>>(),
621            vec![
622                "CODEX_ENV_RUST_VERSION=1.95.0".to_owned(),
623                "CODEX_ENV_JAVA_VERSION=21".to_owned()
624            ]
625        );
626    }
627
628    #[test]
629    fn parse_workspace_manifest_rejects_empty_name() {
630        let error = parse_workspace_manifest(
631            r#"
632name: " "
633folders:
634  - /projects/backend
635"#,
636        )
637        .expect_err("blank name should fail");
638
639        assert!(matches!(error, ManifestError::EmptyName));
640    }
641
642    #[test]
643    fn parse_workspace_manifest_rejects_empty_folders() {
644        let error = parse_workspace_manifest(
645            r#"
646name: empty-workspace
647folders: []
648"#,
649        )
650        .expect_err("empty folders should fail");
651
652        assert!(matches!(error, ManifestError::NoFolders));
653    }
654
655    #[test]
656    fn parse_workspace_manifest_rejects_empty_runtime_image() {
657        let error = parse_workspace_manifest(
658            r#"
659name: workspace
660folders:
661  - /projects/backend
662runtime:
663  image: " "
664"#,
665        )
666        .expect_err("blank runtime image should fail");
667
668        assert!(matches!(error, ManifestError::EmptyRuntimeImage));
669    }
670
671    #[test]
672    fn parse_workspace_manifest_rejects_unsupported_runtime_versions() {
673        let error = parse_workspace_manifest(
674            r#"
675name: workspace
676folders:
677  - /projects/backend
678runtime: go:1.99.0
679"#,
680        )
681        .expect_err("unsupported runtime version should fail");
682
683        assert!(matches!(
684            error,
685            ManifestError::RuntimeSpec(crate::runtime::RuntimeSpecError::UnsupportedVersion {
686                language: crate::runtime::RuntimeLanguage::Go,
687                version
688            }) if version == "1.99.0"
689        ));
690    }
691
692    #[test]
693    fn validate_workspace_folders_accepts_existing_directories() {
694        let temp_dir = TestTempDir::create();
695        let folder = temp_dir.path().join("project");
696        fs::create_dir(&folder).expect("workspace folder should be created");
697        let manifest = WorkspaceManifest::new(
698            "workspace".to_owned(),
699            vec![folder],
700            SandboxConfig::default(),
701        )
702        .expect("manifest should be valid");
703
704        validate_workspace_folders(&manifest).expect("folder validation should pass");
705    }
706
707    #[test]
708    fn validate_workspace_folders_rejects_missing_paths() {
709        let temp_dir = TestTempDir::create();
710        let missing_folder = temp_dir.path().join("missing");
711        let manifest = WorkspaceManifest::new(
712            "workspace".to_owned(),
713            vec![missing_folder.clone()],
714            SandboxConfig::default(),
715        )
716        .expect("manifest should be valid");
717
718        let error = validate_workspace_folders(&manifest).expect_err("missing folder should fail");
719
720        assert!(matches!(
721            error,
722            ManifestError::FolderMissing { path } if path == missing_folder
723        ));
724    }
725
726    #[test]
727    fn validate_workspace_folders_rejects_files() {
728        let temp_dir = TestTempDir::create();
729        let file_path = temp_dir.path().join("file.txt");
730        fs::write(&file_path, "not a directory").expect("file should be written");
731        let manifest = WorkspaceManifest::new(
732            "workspace".to_owned(),
733            vec![file_path.clone()],
734            SandboxConfig::default(),
735        )
736        .expect("manifest should be valid");
737
738        let error = validate_workspace_folders(&manifest).expect_err("file path should fail");
739
740        assert!(matches!(
741            error,
742            ManifestError::FolderNotDirectory { path } if path == file_path
743        ));
744    }
745}