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::{
8    CODEX_WS_APT_PACKAGES_ENV, CODEX_WS_SETUP_COMMANDS_ENV, RuntimeEnvironmentVariable,
9    RuntimeSpecError, RuntimeTool, RuntimeToolVersion, validate_apt_packages,
10    validate_runtime_tool_versions, validate_setup_commands, validate_tool_version,
11};
12
13/// Workspace manifest describing folders and sandbox options.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct WorkspaceManifest {
16    name: String,
17    folders: Vec<PathBuf>,
18    sandbox: SandboxConfig,
19    runtime: RuntimeConfig,
20}
21
22impl WorkspaceManifest {
23    /// Create a workspace manifest.
24    ///
25    /// # Arguments
26    ///
27    /// * `name` - Stable workspace name used for session routing.
28    /// * `folders` - One or more project folders included in the workspace.
29    /// * `sandbox` - Runtime options applied when launching the sandbox.
30    ///
31    /// # Returns
32    ///
33    /// A validated workspace manifest.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`ManifestError::EmptyName`] when `name` is blank.
38    /// Returns [`ManifestError::NoFolders`] when no folders are provided.
39    pub fn new(
40        name: String,
41        folders: Vec<PathBuf>,
42        sandbox: SandboxConfig,
43    ) -> Result<Self, ManifestError> {
44        Self::with_runtime(name, folders, sandbox, RuntimeConfig::default())
45    }
46
47    /// Create a workspace manifest with runtime settings.
48    ///
49    /// # Arguments
50    ///
51    /// * `name` - Stable workspace name used for session routing.
52    /// * `folders` - One or more project folders included in the workspace.
53    /// * `sandbox` - Runtime sandbox options applied when launching the sandbox.
54    /// * `runtime` - Container runtime image settings for this workspace.
55    ///
56    /// # Returns
57    ///
58    /// A validated workspace manifest.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`ManifestError::EmptyName`] when `name` is blank.
63    /// Returns [`ManifestError::NoFolders`] when no folders are provided.
64    /// Returns [`ManifestError::EmptyRuntimeImage`] when `runtime.image` is blank.
65    pub fn with_runtime(
66        name: String,
67        folders: Vec<PathBuf>,
68        sandbox: SandboxConfig,
69        runtime: RuntimeConfig,
70    ) -> Result<Self, ManifestError> {
71        if name.trim().is_empty() {
72            return Err(ManifestError::EmptyName);
73        }
74
75        if folders.is_empty() {
76            return Err(ManifestError::NoFolders);
77        }
78
79        if runtime.image().is_some_and(|image| image.trim().is_empty()) {
80            return Err(ManifestError::EmptyRuntimeImage);
81        }
82
83        Ok(Self {
84            name,
85            folders,
86            sandbox,
87            runtime,
88        })
89    }
90
91    /// Return the workspace name.
92    ///
93    /// # Returns
94    ///
95    /// The workspace name as a borrowed string slice.
96    #[must_use]
97    pub fn name(&self) -> &str {
98        &self.name
99    }
100
101    /// Return workspace folders.
102    ///
103    /// # Returns
104    ///
105    /// A slice of folder paths included in this workspace.
106    #[must_use]
107    pub fn folders(&self) -> &[PathBuf] {
108        &self.folders
109    }
110
111    /// Return sandbox runtime options.
112    ///
113    /// # Returns
114    ///
115    /// The sandbox configuration for this workspace.
116    #[must_use]
117    pub fn sandbox(&self) -> &SandboxConfig {
118        &self.sandbox
119    }
120
121    /// Return container runtime options.
122    ///
123    /// # Returns
124    ///
125    /// The runtime configuration for this workspace.
126    #[must_use]
127    pub fn runtime(&self) -> &RuntimeConfig {
128        &self.runtime
129    }
130}
131
132/// Sandbox options loaded from a workspace manifest.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct SandboxConfig {
135    network: bool,
136}
137
138impl Default for SandboxConfig {
139    fn default() -> Self {
140        Self { network: true }
141    }
142}
143
144impl SandboxConfig {
145    /// Create a sandbox configuration.
146    ///
147    /// # Arguments
148    ///
149    /// * `network` - Whether the sandbox should allow network access.
150    ///
151    /// # Returns
152    ///
153    /// A sandbox configuration value.
154    #[must_use]
155    pub const fn new(network: bool) -> Self {
156        Self { network }
157    }
158
159    /// Return whether sandbox network access is enabled.
160    ///
161    /// # Returns
162    ///
163    /// `true` when network access is enabled.
164    #[must_use]
165    pub const fn network(&self) -> bool {
166        self.network
167    }
168}
169
170/// Container runtime options loaded from a workspace manifest.
171#[derive(Debug, Clone, Default, PartialEq, Eq)]
172pub struct RuntimeConfig {
173    image: Option<String>,
174    tool_versions: Vec<RuntimeToolVersion>,
175    apt_packages: Vec<String>,
176    setup_commands: Vec<String>,
177}
178
179impl RuntimeConfig {
180    /// Create a runtime configuration.
181    ///
182    /// # Arguments
183    ///
184    /// * `image` - Optional Docker image used for this workspace.
185    ///
186    /// # Returns
187    ///
188    /// A runtime configuration value.
189    #[must_use]
190    pub fn new(image: Option<String>) -> Self {
191        Self {
192            image,
193            tool_versions: Vec::new(),
194            apt_packages: Vec::new(),
195            setup_commands: Vec::new(),
196        }
197    }
198
199    /// Create a runtime configuration with startup setup.
200    ///
201    /// # Arguments
202    ///
203    /// * `image` - Optional Docker image used for this workspace.
204    /// * `tool_versions` - Declarative language runtime versions installed before Codex starts.
205    /// * `apt_packages` - Apt packages installed before language tools and Codex start.
206    /// * `setup_commands` - Shell commands run by the entrypoint before Codex starts.
207    ///
208    /// # Returns
209    ///
210    /// A runtime configuration value.
211    #[must_use]
212    pub fn with_setup(
213        image: Option<String>,
214        tool_versions: Vec<RuntimeToolVersion>,
215        apt_packages: Vec<String>,
216        setup_commands: Vec<String>,
217    ) -> Self {
218        Self {
219            image,
220            tool_versions,
221            apt_packages,
222            setup_commands,
223        }
224    }
225
226    /// Return the workspace-specific Docker image.
227    ///
228    /// # Returns
229    ///
230    /// `Some(image)` when the manifest selects a runtime image, otherwise `None`.
231    #[must_use]
232    pub fn image(&self) -> Option<&str> {
233        self.image.as_deref()
234    }
235
236    /// Return declarative runtime tool versions.
237    ///
238    /// # Returns
239    ///
240    /// Runtime tool versions requested by this workspace.
241    #[must_use]
242    pub fn tool_versions(&self) -> &[RuntimeToolVersion] {
243        &self.tool_versions
244    }
245
246    /// Return apt packages installed before Codex starts.
247    ///
248    /// # Returns
249    ///
250    /// Apt package names requested by this workspace.
251    #[must_use]
252    pub fn apt_packages(&self) -> &[String] {
253        &self.apt_packages
254    }
255
256    /// Return setup commands run before Codex starts.
257    ///
258    /// # Returns
259    ///
260    /// Shell commands requested by this workspace.
261    #[must_use]
262    pub fn setup_commands(&self) -> &[String] {
263        &self.setup_commands
264    }
265
266    /// Return Docker environment variables for runtime setup.
267    ///
268    /// # Returns
269    ///
270    /// Entrypoint variables generated from configured apt packages and setup commands.
271    #[must_use]
272    pub fn environment_variables(&self) -> Vec<RuntimeEnvironmentVariable> {
273        let mut variables = Vec::with_capacity(self.tool_versions.len() + 2);
274        variables.extend(
275            self.tool_versions
276                .iter()
277                .map(RuntimeToolVersion::environment_variable),
278        );
279
280        if !self.apt_packages.is_empty() {
281            variables.push(RuntimeEnvironmentVariable::new(
282                CODEX_WS_APT_PACKAGES_ENV,
283                self.apt_packages.join(" "),
284            ));
285        }
286
287        if !self.setup_commands.is_empty() {
288            variables.push(RuntimeEnvironmentVariable::new(
289                CODEX_WS_SETUP_COMMANDS_ENV,
290                self.setup_commands.join("\n"),
291            ));
292        }
293
294        variables
295    }
296}
297
298/// Errors returned while loading or validating workspace manifests.
299#[derive(Debug, Error)]
300pub enum ManifestError {
301    /// The manifest file could not be read.
302    #[error("failed to read workspace manifest '{path}': {source}")]
303    Read {
304        /// Manifest path that failed to read.
305        path: PathBuf,
306        /// Underlying I/O error.
307        source: std::io::Error,
308    },
309
310    /// The manifest YAML could not be parsed.
311    #[error("invalid workspace manifest YAML: {0}")]
312    Yaml(#[from] serde_yaml::Error),
313
314    /// The workspace name was empty or only whitespace.
315    #[error("workspace manifest name cannot be empty")]
316    EmptyName,
317
318    /// The workspace did not include any folders.
319    #[error("workspace manifest must include at least one folder")]
320    NoFolders,
321
322    /// The workspace runtime image was empty or only whitespace.
323    #[error("workspace manifest runtime image cannot be empty")]
324    EmptyRuntimeImage,
325
326    /// The workspace runtime language selection was invalid.
327    #[error("invalid workspace runtime: {0}")]
328    RuntimeSpec(#[from] RuntimeSpecError),
329
330    /// A workspace folder path does not exist.
331    #[error("workspace folder '{path}' does not exist")]
332    FolderMissing {
333        /// Missing workspace folder path.
334        path: PathBuf,
335    },
336
337    /// A workspace folder path exists but is not a directory.
338    #[error("workspace folder '{path}' is not a directory")]
339    FolderNotDirectory {
340        /// Non-directory workspace folder path.
341        path: PathBuf,
342    },
343}
344
345#[derive(Debug, Deserialize)]
346struct RawWorkspaceManifest {
347    name: String,
348    folders: Vec<PathBuf>,
349    #[serde(default)]
350    sandbox: RawSandboxConfig,
351    #[serde(default)]
352    runtime: Option<RawRuntimeConfig>,
353}
354
355#[derive(Debug, Deserialize)]
356struct RawSandboxConfig {
357    #[serde(default = "default_sandbox_network")]
358    network: bool,
359}
360
361impl Default for RawSandboxConfig {
362    fn default() -> Self {
363        Self {
364            network: default_sandbox_network(),
365        }
366    }
367}
368
369const fn default_sandbox_network() -> bool {
370    true
371}
372
373#[derive(Debug, Default, Deserialize)]
374struct RawRuntimeConfig {
375    image: Option<String>,
376    python: Option<String>,
377    node: Option<String>,
378    go: Option<String>,
379    rust: Option<String>,
380    java: Option<String>,
381    clang: Option<String>,
382    c: Option<String>,
383    cpp: Option<String>,
384    ruby: Option<String>,
385    php: Option<String>,
386    deno: Option<String>,
387    bun: Option<String>,
388    zig: Option<String>,
389    dotnet: Option<String>,
390    #[serde(default)]
391    apt: Vec<String>,
392    #[serde(default)]
393    setup: Vec<String>,
394}
395
396impl TryFrom<RawWorkspaceManifest> for WorkspaceManifest {
397    type Error = ManifestError;
398
399    fn try_from(raw: RawWorkspaceManifest) -> Result<Self, Self::Error> {
400        let runtime = raw.runtime.unwrap_or_default().try_into()?;
401        Self::with_runtime(
402            raw.name,
403            raw.folders,
404            SandboxConfig::new(raw.sandbox.network),
405            runtime,
406        )
407    }
408}
409
410impl TryFrom<RawRuntimeConfig> for RuntimeConfig {
411    type Error = ManifestError;
412
413    fn try_from(raw: RawRuntimeConfig) -> Result<Self, Self::Error> {
414        runtime_from_raw(raw)
415    }
416}
417
418fn runtime_from_raw(raw: RawRuntimeConfig) -> Result<RuntimeConfig, ManifestError> {
419    let image = raw
420        .image
421        .as_ref()
422        .map(|runtime_image| runtime_image.trim().to_owned());
423    let tool_versions = validate_runtime_tool_versions(runtime_tool_versions(&raw)?)?;
424    let apt_packages = validate_apt_packages(raw.apt)?;
425    let setup_commands = validate_setup_commands(raw.setup)?;
426
427    Ok(RuntimeConfig::with_setup(
428        image,
429        tool_versions,
430        apt_packages,
431        setup_commands,
432    ))
433}
434
435fn runtime_tool_versions(
436    raw: &RawRuntimeConfig,
437) -> Result<Vec<RuntimeToolVersion>, RuntimeSpecError> {
438    let raw_versions = [
439        (RuntimeTool::Python, raw.python.clone()),
440        (RuntimeTool::Node, raw.node.clone()),
441        (RuntimeTool::Go, raw.go.clone()),
442        (RuntimeTool::Rust, raw.rust.clone()),
443        (RuntimeTool::Java, raw.java.clone()),
444        (RuntimeTool::Clang, raw.clang.clone()),
445        (RuntimeTool::C, raw.c.clone()),
446        (RuntimeTool::Cpp, raw.cpp.clone()),
447        (RuntimeTool::Ruby, raw.ruby.clone()),
448        (RuntimeTool::Php, raw.php.clone()),
449        (RuntimeTool::Deno, raw.deno.clone()),
450        (RuntimeTool::Bun, raw.bun.clone()),
451        (RuntimeTool::Zig, raw.zig.clone()),
452        (RuntimeTool::Dotnet, raw.dotnet.clone()),
453    ];
454    let mut versions = Vec::with_capacity(raw_versions.len());
455
456    for (tool, version) in raw_versions {
457        if let Some(version) = validate_tool_version(tool, version)? {
458            versions.push(version);
459        }
460    }
461
462    Ok(versions)
463}
464
465/// Load a workspace manifest from a YAML file.
466///
467/// # Arguments
468///
469/// * `manifest_path` - Path to the YAML workspace manifest.
470///
471/// # Returns
472///
473/// A validated workspace manifest.
474///
475/// # Errors
476///
477/// Returns [`ManifestError::Read`] when the file cannot be read.
478/// Returns [`ManifestError::Yaml`] when YAML parsing fails.
479/// Returns validation errors when required fields are missing or invalid.
480pub fn load_workspace_manifest(manifest_path: &Path) -> Result<WorkspaceManifest, ManifestError> {
481    let manifest_yaml =
482        fs::read_to_string(manifest_path).map_err(|source| ManifestError::Read {
483            path: manifest_path.to_path_buf(),
484            source,
485        })?;
486    parse_workspace_manifest(&manifest_yaml)
487}
488
489/// Parse a workspace manifest from YAML.
490///
491/// # Arguments
492///
493/// * `manifest_yaml` - YAML text containing workspace manifest fields.
494///
495/// # Returns
496///
497/// A validated workspace manifest.
498///
499/// # Errors
500///
501/// Returns [`ManifestError::Yaml`] when YAML parsing fails.
502/// Returns validation errors when required fields are missing or invalid.
503pub fn parse_workspace_manifest(manifest_yaml: &str) -> Result<WorkspaceManifest, ManifestError> {
504    let raw_manifest = serde_yaml::from_str::<RawWorkspaceManifest>(manifest_yaml)?;
505    raw_manifest.try_into()
506}
507
508/// Validate that every workspace folder exists and is a directory.
509///
510/// # Arguments
511///
512/// * `manifest` - Workspace manifest whose folders should be checked.
513///
514/// # Returns
515///
516/// `Ok(())` when all workspace folders exist and are directories.
517///
518/// # Errors
519///
520/// Returns [`ManifestError::FolderMissing`] when a folder path does not exist.
521/// Returns [`ManifestError::FolderNotDirectory`] when a folder path is not a directory.
522pub fn validate_workspace_folders(manifest: &WorkspaceManifest) -> Result<(), ManifestError> {
523    for folder in manifest.folders() {
524        if !folder.exists() {
525            return Err(ManifestError::FolderMissing {
526                path: folder.clone(),
527            });
528        }
529
530        if !folder.is_dir() {
531            return Err(ManifestError::FolderNotDirectory {
532                path: folder.clone(),
533            });
534        }
535    }
536
537    Ok(())
538}
539
540#[cfg(test)]
541mod tests {
542    use std::sync::atomic::{AtomicUsize, Ordering};
543    use std::time::{SystemTime, UNIX_EPOCH};
544
545    use super::*;
546
547    static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
548
549    #[derive(Debug)]
550    struct TestTempDir {
551        path: PathBuf,
552    }
553
554    impl TestTempDir {
555        fn create() -> Self {
556            let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
557            let timestamp = SystemTime::now()
558                .duration_since(UNIX_EPOCH)
559                .expect("system clock should be after Unix epoch")
560                .as_nanos();
561            let path = std::env::temp_dir().join(format!(
562                "codex-ws-test-{}-{timestamp}-{counter}",
563                std::process::id()
564            ));
565            fs::create_dir(&path).expect("temporary test directory should be created");
566            Self { path }
567        }
568
569        fn path(&self) -> &Path {
570            &self.path
571        }
572    }
573
574    impl Drop for TestTempDir {
575        fn drop(&mut self) {
576            let _ = fs::remove_dir_all(&self.path);
577        }
578    }
579
580    #[test]
581    fn parse_workspace_manifest_supports_multiple_folders_and_network() {
582        let manifest = parse_workspace_manifest(
583            r#"
584name: workspace-name
585folders:
586  - /projects/backend
587  - /projects/frontend
588sandbox:
589  network: true
590"#,
591        )
592        .expect("manifest should parse");
593
594        assert_eq!(manifest.name(), "workspace-name");
595        assert_eq!(
596            manifest.folders(),
597            &[
598                PathBuf::from("/projects/backend"),
599                PathBuf::from("/projects/frontend")
600            ]
601        );
602        assert!(manifest.sandbox().network());
603        assert_eq!(manifest.runtime().image(), None);
604    }
605
606    #[test]
607    fn parse_workspace_manifest_supports_single_folder() {
608        let manifest = parse_workspace_manifest(
609            r#"
610name: single-project
611folders:
612  - /projects/backend
613"#,
614        )
615        .expect("manifest should parse");
616
617        assert_eq!(manifest.name(), "single-project");
618        assert_eq!(manifest.folders(), &[PathBuf::from("/projects/backend")]);
619        assert!(manifest.sandbox().network());
620        assert_eq!(manifest.runtime().image(), None);
621    }
622
623    #[test]
624    fn parse_workspace_manifest_supports_runtime_image() {
625        let manifest = parse_workspace_manifest(
626            r#"
627name: rust-project
628folders:
629  - /projects/rust-project
630runtime:
631  image: rust-codex-ws:latest
632"#,
633        )
634        .expect("manifest should parse");
635
636        assert_eq!(manifest.runtime().image(), Some("rust-codex-ws:latest"));
637    }
638
639    #[test]
640    fn parse_workspace_manifest_supports_declarative_runtime_tools() {
641        let manifest = parse_workspace_manifest(
642            r#"
643name: toolchain-project
644folders:
645  - /projects/toolchain-project
646runtime:
647  python: "3.13"
648  node: "22"
649  go: "1.24"
650  rust: "1.86"
651  java: "21"
652  clang: "20"
653  c: "20"
654  cpp: "20"
655  ruby: "3.4"
656  php: "8.4"
657  deno: "2"
658  bun: "1"
659  zig: "0.14"
660  dotnet: "9"
661"#,
662        )
663        .expect("manifest should parse");
664
665        let variables = manifest.runtime().environment_variables();
666        assert_eq!(
667            variables
668                .iter()
669                .map(crate::runtime::RuntimeEnvironmentVariable::docker_assignment)
670                .collect::<Vec<_>>(),
671            vec![
672                "CODEX_WS_PYTHON_VERSION=3.13".to_owned(),
673                "CODEX_WS_NODE_VERSION=22".to_owned(),
674                "CODEX_WS_GO_VERSION=1.24".to_owned(),
675                "CODEX_WS_RUST_VERSION=1.86".to_owned(),
676                "CODEX_WS_JAVA_VERSION=21".to_owned(),
677                "CODEX_WS_CLANG_VERSION=20".to_owned(),
678                "CODEX_WS_C_VERSION=20".to_owned(),
679                "CODEX_WS_CPP_VERSION=20".to_owned(),
680                "CODEX_WS_RUBY_VERSION=3.4".to_owned(),
681                "CODEX_WS_PHP_VERSION=8.4".to_owned(),
682                "CODEX_WS_DENO_VERSION=2".to_owned(),
683                "CODEX_WS_BUN_VERSION=1".to_owned(),
684                "CODEX_WS_ZIG_VERSION=0.14".to_owned(),
685                "CODEX_WS_DOTNET_VERSION=9".to_owned()
686            ]
687        );
688    }
689
690    #[test]
691    fn parse_workspace_manifest_supports_runtime_apt_packages() {
692        let manifest = parse_workspace_manifest(
693            r#"
694name: python-project
695folders:
696  - /projects/python-project
697runtime:
698  apt:
699    - python3
700    - python3-pip
701"#,
702        )
703        .expect("manifest should parse");
704
705        assert_eq!(
706            manifest.runtime().environment_variables()[0].docker_assignment(),
707            "CODEX_WS_APT_PACKAGES=python3 python3-pip"
708        );
709    }
710
711    #[test]
712    fn parse_workspace_manifest_supports_runtime_setup_commands() {
713        let manifest = parse_workspace_manifest(
714            r#"
715name: rust-project
716folders:
717  - /projects/rust-project
718runtime:
719  setup:
720    - curl -fsSL https://sh.rustup.rs | sh -s -- -y
721    - . "$HOME/.cargo/env"
722"#,
723        )
724        .expect("manifest should parse");
725
726        let variables = manifest.runtime().environment_variables();
727        assert_eq!(
728            variables
729                .iter()
730                .map(crate::runtime::RuntimeEnvironmentVariable::docker_assignment)
731                .collect::<Vec<_>>(),
732            vec!["CODEX_WS_SETUP_COMMANDS=curl -fsSL https://sh.rustup.rs | sh -s -- -y\n. \"$HOME/.cargo/env\"".to_owned()]
733        );
734    }
735
736    #[test]
737    fn parse_workspace_manifest_supports_runtime_apt_and_setup() {
738        let manifest = parse_workspace_manifest(
739            r#"
740name: mixed-project
741folders:
742  - /projects/mixed-project
743runtime:
744  apt:
745    - build-essential
746  setup:
747    - echo ready
748"#,
749        )
750        .expect("manifest should parse");
751
752        let variables = manifest.runtime().environment_variables();
753        assert_eq!(
754            variables
755                .iter()
756                .map(crate::runtime::RuntimeEnvironmentVariable::docker_assignment)
757                .collect::<Vec<_>>(),
758            vec![
759                "CODEX_WS_APT_PACKAGES=build-essential".to_owned(),
760                "CODEX_WS_SETUP_COMMANDS=echo ready".to_owned()
761            ]
762        );
763    }
764
765    #[test]
766    fn parse_workspace_manifest_rejects_empty_name() {
767        let error = parse_workspace_manifest(
768            r#"
769name: " "
770folders:
771  - /projects/backend
772"#,
773        )
774        .expect_err("blank name should fail");
775
776        assert!(matches!(error, ManifestError::EmptyName));
777    }
778
779    #[test]
780    fn parse_workspace_manifest_rejects_empty_folders() {
781        let error = parse_workspace_manifest(
782            r#"
783name: empty-workspace
784folders: []
785"#,
786        )
787        .expect_err("empty folders should fail");
788
789        assert!(matches!(error, ManifestError::NoFolders));
790    }
791
792    #[test]
793    fn parse_workspace_manifest_rejects_empty_runtime_image() {
794        let error = parse_workspace_manifest(
795            r#"
796name: workspace
797folders:
798  - /projects/backend
799runtime:
800  image: " "
801"#,
802        )
803        .expect_err("blank runtime image should fail");
804
805        assert!(matches!(error, ManifestError::EmptyRuntimeImage));
806    }
807
808    #[test]
809    fn parse_workspace_manifest_rejects_invalid_apt_package() {
810        let error = parse_workspace_manifest(
811            r#"
812name: workspace
813folders:
814  - /projects/backend
815runtime:
816  apt:
817    - python3;curl
818"#,
819        )
820        .expect_err("invalid apt package should fail");
821
822        assert!(matches!(
823            error,
824            ManifestError::RuntimeSpec(crate::runtime::RuntimeSpecError::InvalidAptPackage {
825                package
826            }) if package == "python3;curl"
827        ));
828    }
829
830    #[test]
831    fn parse_workspace_manifest_rejects_invalid_tool_version() {
832        let error = parse_workspace_manifest(
833            r#"
834name: workspace
835folders:
836  - /projects/backend
837runtime:
838  go: "1.24;curl"
839"#,
840        )
841        .expect_err("invalid tool version should fail");
842
843        assert!(matches!(
844            error,
845            ManifestError::RuntimeSpec(crate::runtime::RuntimeSpecError::InvalidToolVersion {
846                tool: crate::runtime::RuntimeTool::Go,
847                version
848            }) if version == "1.24;curl"
849        ));
850    }
851
852    #[test]
853    fn parse_workspace_manifest_rejects_conflicting_c_and_cpp_versions() {
854        let error = parse_workspace_manifest(
855            r#"
856name: workspace
857folders:
858  - /projects/backend
859runtime:
860  c: "20"
861  cpp: "21"
862"#,
863        )
864        .expect_err("conflicting compiler versions should fail");
865
866        assert!(matches!(
867            error,
868            ManifestError::RuntimeSpec(
869                crate::runtime::RuntimeSpecError::ConflictingCompilerVersions {
870                    first,
871                    second
872                }
873            ) if first == "20" && second == "21"
874        ));
875    }
876
877    #[test]
878    fn validate_workspace_folders_accepts_existing_directories() {
879        let temp_dir = TestTempDir::create();
880        let folder = temp_dir.path().join("project");
881        fs::create_dir(&folder).expect("workspace folder should be created");
882        let manifest = WorkspaceManifest::new(
883            "workspace".to_owned(),
884            vec![folder],
885            SandboxConfig::default(),
886        )
887        .expect("manifest should be valid");
888
889        validate_workspace_folders(&manifest).expect("folder validation should pass");
890    }
891
892    #[test]
893    fn validate_workspace_folders_rejects_missing_paths() {
894        let temp_dir = TestTempDir::create();
895        let missing_folder = temp_dir.path().join("missing");
896        let manifest = WorkspaceManifest::new(
897            "workspace".to_owned(),
898            vec![missing_folder.clone()],
899            SandboxConfig::default(),
900        )
901        .expect("manifest should be valid");
902
903        let error = validate_workspace_folders(&manifest).expect_err("missing folder should fail");
904
905        assert!(matches!(
906            error,
907            ManifestError::FolderMissing { path } if path == missing_folder
908        ));
909    }
910
911    #[test]
912    fn validate_workspace_folders_rejects_files() {
913        let temp_dir = TestTempDir::create();
914        let file_path = temp_dir.path().join("file.txt");
915        fs::write(&file_path, "not a directory").expect("file should be written");
916        let manifest = WorkspaceManifest::new(
917            "workspace".to_owned(),
918            vec![file_path.clone()],
919            SandboxConfig::default(),
920        )
921        .expect("manifest should be valid");
922
923        let error = validate_workspace_folders(&manifest).expect_err("file path should fail");
924
925        assert!(matches!(
926            error,
927            ManifestError::FolderNotDirectory { path } if path == file_path
928        ));
929    }
930}