use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
use crate::runtime::{RuntimeEnvironmentVariable, RuntimeLanguageVersion, RuntimeSpecError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceManifest {
name: String,
folders: Vec<PathBuf>,
sandbox: SandboxConfig,
runtime: RuntimeConfig,
}
impl WorkspaceManifest {
pub fn new(
name: String,
folders: Vec<PathBuf>,
sandbox: SandboxConfig,
) -> Result<Self, ManifestError> {
Self::with_runtime(name, folders, sandbox, RuntimeConfig::default())
}
pub fn with_runtime(
name: String,
folders: Vec<PathBuf>,
sandbox: SandboxConfig,
runtime: RuntimeConfig,
) -> Result<Self, ManifestError> {
if name.trim().is_empty() {
return Err(ManifestError::EmptyName);
}
if folders.is_empty() {
return Err(ManifestError::NoFolders);
}
if runtime.image().is_some_and(|image| image.trim().is_empty()) {
return Err(ManifestError::EmptyRuntimeImage);
}
Ok(Self {
name,
folders,
sandbox,
runtime,
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn folders(&self) -> &[PathBuf] {
&self.folders
}
#[must_use]
pub fn sandbox(&self) -> &SandboxConfig {
&self.sandbox
}
#[must_use]
pub fn runtime(&self) -> &RuntimeConfig {
&self.runtime
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SandboxConfig {
network: bool,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self { network: true }
}
}
impl SandboxConfig {
#[must_use]
pub const fn new(network: bool) -> Self {
Self { network }
}
#[must_use]
pub const fn network(&self) -> bool {
self.network
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RuntimeConfig {
image: Option<String>,
language_versions: Vec<RuntimeLanguageVersion>,
}
impl RuntimeConfig {
#[must_use]
pub fn new(image: Option<String>) -> Self {
Self {
image,
language_versions: Vec::new(),
}
}
#[must_use]
pub fn with_language_versions(
image: Option<String>,
language_versions: Vec<RuntimeLanguageVersion>,
) -> Self {
Self {
image,
language_versions,
}
}
#[must_use]
pub fn image(&self) -> Option<&str> {
self.image.as_deref()
}
#[must_use]
pub fn language_versions(&self) -> &[RuntimeLanguageVersion] {
&self.language_versions
}
#[must_use]
pub fn environment_variables(&self) -> Vec<RuntimeEnvironmentVariable> {
self.language_versions
.iter()
.map(RuntimeLanguageVersion::environment_variable)
.collect()
}
}
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("failed to read workspace manifest '{path}': {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("invalid workspace manifest YAML: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("workspace manifest name cannot be empty")]
EmptyName,
#[error("workspace manifest must include at least one folder")]
NoFolders,
#[error("workspace manifest runtime image cannot be empty")]
EmptyRuntimeImage,
#[error("invalid workspace runtime: {0}")]
RuntimeSpec(#[from] RuntimeSpecError),
#[error("workspace folder '{path}' does not exist")]
FolderMissing {
path: PathBuf,
},
#[error("workspace folder '{path}' is not a directory")]
FolderNotDirectory {
path: PathBuf,
},
}
#[derive(Debug, Deserialize)]
struct RawWorkspaceManifest {
name: String,
folders: Vec<PathBuf>,
#[serde(default)]
sandbox: RawSandboxConfig,
#[serde(default)]
runtime: Option<RawRuntimeConfig>,
}
#[derive(Debug, Deserialize)]
struct RawSandboxConfig {
#[serde(default = "default_sandbox_network")]
network: bool,
}
impl Default for RawSandboxConfig {
fn default() -> Self {
Self {
network: default_sandbox_network(),
}
}
}
const fn default_sandbox_network() -> bool {
true
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawRuntimeConfig {
Spec(String),
Specs(Vec<String>),
Map(RawRuntimeMap),
}
#[derive(Debug, Default, Deserialize)]
struct RawRuntimeMap {
image: Option<String>,
#[serde(default)]
languages: Vec<String>,
}
impl TryFrom<RawWorkspaceManifest> for WorkspaceManifest {
type Error = ManifestError;
fn try_from(raw: RawWorkspaceManifest) -> Result<Self, Self::Error> {
let runtime = raw.runtime.unwrap_or_default().try_into()?;
Self::with_runtime(
raw.name,
raw.folders,
SandboxConfig::new(raw.sandbox.network),
runtime,
)
}
}
impl Default for RawRuntimeConfig {
fn default() -> Self {
Self::Map(RawRuntimeMap::default())
}
}
impl TryFrom<RawRuntimeConfig> for RuntimeConfig {
type Error = ManifestError;
fn try_from(raw: RawRuntimeConfig) -> Result<Self, Self::Error> {
match raw {
RawRuntimeConfig::Spec(spec) => runtime_from_parts(None, vec![spec]),
RawRuntimeConfig::Specs(specs) => runtime_from_parts(None, specs),
RawRuntimeConfig::Map(map) => runtime_from_parts(map.image, map.languages),
}
}
}
fn runtime_from_parts(
image: Option<String>,
specs: Vec<String>,
) -> Result<RuntimeConfig, ManifestError> {
let image = image.map(|runtime_image| runtime_image.trim().to_owned());
let language_versions = crate::runtime::parse_runtime_specs(&specs)?;
Ok(RuntimeConfig::with_language_versions(
image,
language_versions,
))
}
pub fn load_workspace_manifest(manifest_path: &Path) -> Result<WorkspaceManifest, ManifestError> {
let manifest_yaml =
fs::read_to_string(manifest_path).map_err(|source| ManifestError::Read {
path: manifest_path.to_path_buf(),
source,
})?;
parse_workspace_manifest(&manifest_yaml)
}
pub fn parse_workspace_manifest(manifest_yaml: &str) -> Result<WorkspaceManifest, ManifestError> {
let raw_manifest = serde_yaml::from_str::<RawWorkspaceManifest>(manifest_yaml)?;
raw_manifest.try_into()
}
pub fn validate_workspace_folders(manifest: &WorkspaceManifest) -> Result<(), ManifestError> {
for folder in manifest.folders() {
if !folder.exists() {
return Err(ManifestError::FolderMissing {
path: folder.clone(),
});
}
if !folder.is_dir() {
return Err(ManifestError::FolderNotDirectory {
path: folder.clone(),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug)]
struct TestTempDir {
path: PathBuf,
}
impl TestTempDir {
fn create() -> Self {
let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after Unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!(
"codex-ws-test-{}-{timestamp}-{counter}",
std::process::id()
));
fs::create_dir(&path).expect("temporary test directory should be created");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestTempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn parse_workspace_manifest_supports_multiple_folders_and_network() {
let manifest = parse_workspace_manifest(
r#"
name: workspace-name
folders:
- /projects/backend
- /projects/frontend
sandbox:
network: true
"#,
)
.expect("manifest should parse");
assert_eq!(manifest.name(), "workspace-name");
assert_eq!(
manifest.folders(),
&[
PathBuf::from("/projects/backend"),
PathBuf::from("/projects/frontend")
]
);
assert!(manifest.sandbox().network());
assert_eq!(manifest.runtime().image(), None);
}
#[test]
fn parse_workspace_manifest_supports_single_folder() {
let manifest = parse_workspace_manifest(
r#"
name: single-project
folders:
- /projects/backend
"#,
)
.expect("manifest should parse");
assert_eq!(manifest.name(), "single-project");
assert_eq!(manifest.folders(), &[PathBuf::from("/projects/backend")]);
assert!(manifest.sandbox().network());
assert_eq!(manifest.runtime().image(), None);
}
#[test]
fn parse_workspace_manifest_supports_runtime_image() {
let manifest = parse_workspace_manifest(
r#"
name: rust-project
folders:
- /projects/rust-project
runtime:
image: rust-codex-ws:latest
"#,
)
.expect("manifest should parse");
assert_eq!(manifest.runtime().image(), Some("rust-codex-ws:latest"));
}
#[test]
fn parse_workspace_manifest_supports_scalar_runtime_spec() {
let manifest = parse_workspace_manifest(
r#"
name: go-project
folders:
- /projects/go-project
runtime: golang:1.25.1
"#,
)
.expect("manifest should parse");
assert_eq!(
manifest.runtime().environment_variables()[0].docker_assignment(),
"CODEX_ENV_GO_VERSION=1.25.1"
);
}
#[test]
fn parse_workspace_manifest_supports_runtime_spec_list() {
let manifest = parse_workspace_manifest(
r#"
name: web-project
folders:
- /projects/web-project
runtime:
- node:22
- python:3.13
"#,
)
.expect("manifest should parse");
let variables = manifest.runtime().environment_variables();
assert_eq!(
variables
.iter()
.map(crate::runtime::RuntimeEnvironmentVariable::docker_assignment)
.collect::<Vec<_>>(),
vec![
"CODEX_ENV_NODE_VERSION=22".to_owned(),
"CODEX_ENV_PYTHON_VERSION=3.13".to_owned()
]
);
}
#[test]
fn parse_workspace_manifest_supports_runtime_map_languages() {
let manifest = parse_workspace_manifest(
r#"
name: mixed-project
folders:
- /projects/mixed-project
runtime:
languages:
- rust:1.95.0
- java:21
"#,
)
.expect("manifest should parse");
let variables = manifest.runtime().environment_variables();
assert_eq!(
variables
.iter()
.map(crate::runtime::RuntimeEnvironmentVariable::docker_assignment)
.collect::<Vec<_>>(),
vec![
"CODEX_ENV_RUST_VERSION=1.95.0".to_owned(),
"CODEX_ENV_JAVA_VERSION=21".to_owned()
]
);
}
#[test]
fn parse_workspace_manifest_rejects_empty_name() {
let error = parse_workspace_manifest(
r#"
name: " "
folders:
- /projects/backend
"#,
)
.expect_err("blank name should fail");
assert!(matches!(error, ManifestError::EmptyName));
}
#[test]
fn parse_workspace_manifest_rejects_empty_folders() {
let error = parse_workspace_manifest(
r#"
name: empty-workspace
folders: []
"#,
)
.expect_err("empty folders should fail");
assert!(matches!(error, ManifestError::NoFolders));
}
#[test]
fn parse_workspace_manifest_rejects_empty_runtime_image() {
let error = parse_workspace_manifest(
r#"
name: workspace
folders:
- /projects/backend
runtime:
image: " "
"#,
)
.expect_err("blank runtime image should fail");
assert!(matches!(error, ManifestError::EmptyRuntimeImage));
}
#[test]
fn parse_workspace_manifest_rejects_unsupported_runtime_versions() {
let error = parse_workspace_manifest(
r#"
name: workspace
folders:
- /projects/backend
runtime: go:1.99.0
"#,
)
.expect_err("unsupported runtime version should fail");
assert!(matches!(
error,
ManifestError::RuntimeSpec(crate::runtime::RuntimeSpecError::UnsupportedVersion {
language: crate::runtime::RuntimeLanguage::Go,
version
}) if version == "1.99.0"
));
}
#[test]
fn validate_workspace_folders_accepts_existing_directories() {
let temp_dir = TestTempDir::create();
let folder = temp_dir.path().join("project");
fs::create_dir(&folder).expect("workspace folder should be created");
let manifest = WorkspaceManifest::new(
"workspace".to_owned(),
vec![folder],
SandboxConfig::default(),
)
.expect("manifest should be valid");
validate_workspace_folders(&manifest).expect("folder validation should pass");
}
#[test]
fn validate_workspace_folders_rejects_missing_paths() {
let temp_dir = TestTempDir::create();
let missing_folder = temp_dir.path().join("missing");
let manifest = WorkspaceManifest::new(
"workspace".to_owned(),
vec![missing_folder.clone()],
SandboxConfig::default(),
)
.expect("manifest should be valid");
let error = validate_workspace_folders(&manifest).expect_err("missing folder should fail");
assert!(matches!(
error,
ManifestError::FolderMissing { path } if path == missing_folder
));
}
#[test]
fn validate_workspace_folders_rejects_files() {
let temp_dir = TestTempDir::create();
let file_path = temp_dir.path().join("file.txt");
fs::write(&file_path, "not a directory").expect("file should be written");
let manifest = WorkspaceManifest::new(
"workspace".to_owned(),
vec![file_path.clone()],
SandboxConfig::default(),
)
.expect("manifest should be valid");
let error = validate_workspace_folders(&manifest).expect_err("file path should fail");
assert!(matches!(
error,
ManifestError::FolderNotDirectory { path } if path == file_path
));
}
}