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#[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 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 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 #[must_use]
97 pub fn name(&self) -> &str {
98 &self.name
99 }
100
101 #[must_use]
107 pub fn folders(&self) -> &[PathBuf] {
108 &self.folders
109 }
110
111 #[must_use]
117 pub fn sandbox(&self) -> &SandboxConfig {
118 &self.sandbox
119 }
120
121 #[must_use]
127 pub fn runtime(&self) -> &RuntimeConfig {
128 &self.runtime
129 }
130}
131
132#[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 #[must_use]
155 pub const fn new(network: bool) -> Self {
156 Self { network }
157 }
158
159 #[must_use]
165 pub const fn network(&self) -> bool {
166 self.network
167 }
168}
169
170#[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 #[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 #[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 #[must_use]
232 pub fn image(&self) -> Option<&str> {
233 self.image.as_deref()
234 }
235
236 #[must_use]
242 pub fn tool_versions(&self) -> &[RuntimeToolVersion] {
243 &self.tool_versions
244 }
245
246 #[must_use]
252 pub fn apt_packages(&self) -> &[String] {
253 &self.apt_packages
254 }
255
256 #[must_use]
262 pub fn setup_commands(&self) -> &[String] {
263 &self.setup_commands
264 }
265
266 #[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#[derive(Debug, Error)]
300pub enum ManifestError {
301 #[error("failed to read workspace manifest '{path}': {source}")]
303 Read {
304 path: PathBuf,
306 source: std::io::Error,
308 },
309
310 #[error("invalid workspace manifest YAML: {0}")]
312 Yaml(#[from] serde_yaml::Error),
313
314 #[error("workspace manifest name cannot be empty")]
316 EmptyName,
317
318 #[error("workspace manifest must include at least one folder")]
320 NoFolders,
321
322 #[error("workspace manifest runtime image cannot be empty")]
324 EmptyRuntimeImage,
325
326 #[error("invalid workspace runtime: {0}")]
328 RuntimeSpec(#[from] RuntimeSpecError),
329
330 #[error("workspace folder '{path}' does not exist")]
332 FolderMissing {
333 path: PathBuf,
335 },
336
337 #[error("workspace folder '{path}' is not a directory")]
339 FolderNotDirectory {
340 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
465pub 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
489pub 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
508pub 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}