1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5use thiserror::Error;
6
7use crate::runtime::{RuntimeEnvironmentVariable, RuntimeLanguageVersion, RuntimeSpecError};
8
9#[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 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 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 #[must_use]
93 pub fn name(&self) -> &str {
94 &self.name
95 }
96
97 #[must_use]
103 pub fn folders(&self) -> &[PathBuf] {
104 &self.folders
105 }
106
107 #[must_use]
113 pub fn sandbox(&self) -> &SandboxConfig {
114 &self.sandbox
115 }
116
117 #[must_use]
123 pub fn runtime(&self) -> &RuntimeConfig {
124 &self.runtime
125 }
126}
127
128#[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 #[must_use]
151 pub const fn new(network: bool) -> Self {
152 Self { network }
153 }
154
155 #[must_use]
161 pub const fn network(&self) -> bool {
162 self.network
163 }
164}
165
166#[derive(Debug, Clone, Default, PartialEq, Eq)]
168pub struct RuntimeConfig {
169 image: Option<String>,
170 language_versions: Vec<RuntimeLanguageVersion>,
171}
172
173impl RuntimeConfig {
174 #[must_use]
184 pub fn new(image: Option<String>) -> Self {
185 Self {
186 image,
187 language_versions: Vec::new(),
188 }
189 }
190
191 #[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 #[must_use]
218 pub fn image(&self) -> Option<&str> {
219 self.image.as_deref()
220 }
221
222 #[must_use]
228 pub fn language_versions(&self) -> &[RuntimeLanguageVersion] {
229 &self.language_versions
230 }
231
232 #[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#[derive(Debug, Error)]
248pub enum ManifestError {
249 #[error("failed to read workspace manifest '{path}': {source}")]
251 Read {
252 path: PathBuf,
254 source: std::io::Error,
256 },
257
258 #[error("invalid workspace manifest YAML: {0}")]
260 Yaml(#[from] serde_yaml::Error),
261
262 #[error("workspace manifest name cannot be empty")]
264 EmptyName,
265
266 #[error("workspace manifest must include at least one folder")]
268 NoFolders,
269
270 #[error("workspace manifest runtime image cannot be empty")]
272 EmptyRuntimeImage,
273
274 #[error("invalid workspace runtime: {0}")]
276 RuntimeSpec(#[from] RuntimeSpecError),
277
278 #[error("workspace folder '{path}' does not exist")]
280 FolderMissing {
281 path: PathBuf,
283 },
284
285 #[error("workspace folder '{path}' is not a directory")]
287 FolderNotDirectory {
288 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
381pub 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
405pub 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
424pub 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}