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, validate_apt_packages, validate_setup_commands,
10};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct WorkspaceManifest {
15 name: String,
16 folders: Vec<PathBuf>,
17 sandbox: SandboxConfig,
18 runtime: RuntimeConfig,
19}
20
21impl WorkspaceManifest {
22 pub fn new(
39 name: String,
40 folders: Vec<PathBuf>,
41 sandbox: SandboxConfig,
42 ) -> Result<Self, ManifestError> {
43 Self::with_runtime(name, folders, sandbox, RuntimeConfig::default())
44 }
45
46 pub fn with_runtime(
65 name: String,
66 folders: Vec<PathBuf>,
67 sandbox: SandboxConfig,
68 runtime: RuntimeConfig,
69 ) -> Result<Self, ManifestError> {
70 if name.trim().is_empty() {
71 return Err(ManifestError::EmptyName);
72 }
73
74 if folders.is_empty() {
75 return Err(ManifestError::NoFolders);
76 }
77
78 if runtime.image().is_some_and(|image| image.trim().is_empty()) {
79 return Err(ManifestError::EmptyRuntimeImage);
80 }
81
82 Ok(Self {
83 name,
84 folders,
85 sandbox,
86 runtime,
87 })
88 }
89
90 #[must_use]
96 pub fn name(&self) -> &str {
97 &self.name
98 }
99
100 #[must_use]
106 pub fn folders(&self) -> &[PathBuf] {
107 &self.folders
108 }
109
110 #[must_use]
116 pub fn sandbox(&self) -> &SandboxConfig {
117 &self.sandbox
118 }
119
120 #[must_use]
126 pub fn runtime(&self) -> &RuntimeConfig {
127 &self.runtime
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct SandboxConfig {
134 network: bool,
135}
136
137impl Default for SandboxConfig {
138 fn default() -> Self {
139 Self { network: true }
140 }
141}
142
143impl SandboxConfig {
144 #[must_use]
154 pub const fn new(network: bool) -> Self {
155 Self { network }
156 }
157
158 #[must_use]
164 pub const fn network(&self) -> bool {
165 self.network
166 }
167}
168
169#[derive(Debug, Clone, Default, PartialEq, Eq)]
171pub struct RuntimeConfig {
172 image: Option<String>,
173 apt_packages: Vec<String>,
174 setup_commands: Vec<String>,
175}
176
177impl RuntimeConfig {
178 #[must_use]
188 pub fn new(image: Option<String>) -> Self {
189 Self {
190 image,
191 apt_packages: Vec::new(),
192 setup_commands: Vec::new(),
193 }
194 }
195
196 #[must_use]
208 pub fn with_setup(
209 image: Option<String>,
210 apt_packages: Vec<String>,
211 setup_commands: Vec<String>,
212 ) -> Self {
213 Self {
214 image,
215 apt_packages,
216 setup_commands,
217 }
218 }
219
220 #[must_use]
226 pub fn image(&self) -> Option<&str> {
227 self.image.as_deref()
228 }
229
230 #[must_use]
236 pub fn apt_packages(&self) -> &[String] {
237 &self.apt_packages
238 }
239
240 #[must_use]
246 pub fn setup_commands(&self) -> &[String] {
247 &self.setup_commands
248 }
249
250 #[must_use]
256 pub fn environment_variables(&self) -> Vec<RuntimeEnvironmentVariable> {
257 let mut variables = Vec::with_capacity(2);
258
259 if !self.apt_packages.is_empty() {
260 variables.push(RuntimeEnvironmentVariable::new(
261 CODEX_WS_APT_PACKAGES_ENV,
262 self.apt_packages.join(" "),
263 ));
264 }
265
266 if !self.setup_commands.is_empty() {
267 variables.push(RuntimeEnvironmentVariable::new(
268 CODEX_WS_SETUP_COMMANDS_ENV,
269 self.setup_commands.join("\n"),
270 ));
271 }
272
273 variables
274 }
275}
276
277#[derive(Debug, Error)]
279pub enum ManifestError {
280 #[error("failed to read workspace manifest '{path}': {source}")]
282 Read {
283 path: PathBuf,
285 source: std::io::Error,
287 },
288
289 #[error("invalid workspace manifest YAML: {0}")]
291 Yaml(#[from] serde_yaml::Error),
292
293 #[error("workspace manifest name cannot be empty")]
295 EmptyName,
296
297 #[error("workspace manifest must include at least one folder")]
299 NoFolders,
300
301 #[error("workspace manifest runtime image cannot be empty")]
303 EmptyRuntimeImage,
304
305 #[error("invalid workspace runtime: {0}")]
307 RuntimeSpec(#[from] RuntimeSpecError),
308
309 #[error("workspace folder '{path}' does not exist")]
311 FolderMissing {
312 path: PathBuf,
314 },
315
316 #[error("workspace folder '{path}' is not a directory")]
318 FolderNotDirectory {
319 path: PathBuf,
321 },
322}
323
324#[derive(Debug, Deserialize)]
325struct RawWorkspaceManifest {
326 name: String,
327 folders: Vec<PathBuf>,
328 #[serde(default)]
329 sandbox: RawSandboxConfig,
330 #[serde(default)]
331 runtime: Option<RawRuntimeConfig>,
332}
333
334#[derive(Debug, Deserialize)]
335struct RawSandboxConfig {
336 #[serde(default = "default_sandbox_network")]
337 network: bool,
338}
339
340impl Default for RawSandboxConfig {
341 fn default() -> Self {
342 Self {
343 network: default_sandbox_network(),
344 }
345 }
346}
347
348const fn default_sandbox_network() -> bool {
349 true
350}
351
352#[derive(Debug, Default, Deserialize)]
353struct RawRuntimeConfig {
354 image: Option<String>,
355 #[serde(default)]
356 apt: Vec<String>,
357 #[serde(default)]
358 setup: Vec<String>,
359}
360
361impl TryFrom<RawWorkspaceManifest> for WorkspaceManifest {
362 type Error = ManifestError;
363
364 fn try_from(raw: RawWorkspaceManifest) -> Result<Self, Self::Error> {
365 let runtime = raw.runtime.unwrap_or_default().try_into()?;
366 Self::with_runtime(
367 raw.name,
368 raw.folders,
369 SandboxConfig::new(raw.sandbox.network),
370 runtime,
371 )
372 }
373}
374
375impl TryFrom<RawRuntimeConfig> for RuntimeConfig {
376 type Error = ManifestError;
377
378 fn try_from(raw: RawRuntimeConfig) -> Result<Self, Self::Error> {
379 runtime_from_parts(raw.image, raw.apt, raw.setup)
380 }
381}
382
383fn runtime_from_parts(
384 image: Option<String>,
385 apt_packages: Vec<String>,
386 setup_commands: Vec<String>,
387) -> Result<RuntimeConfig, ManifestError> {
388 let image = image.map(|runtime_image| runtime_image.trim().to_owned());
389 let apt_packages = validate_apt_packages(apt_packages)?;
390 let setup_commands = validate_setup_commands(setup_commands)?;
391
392 Ok(RuntimeConfig::with_setup(
393 image,
394 apt_packages,
395 setup_commands,
396 ))
397}
398
399pub fn load_workspace_manifest(manifest_path: &Path) -> Result<WorkspaceManifest, ManifestError> {
415 let manifest_yaml =
416 fs::read_to_string(manifest_path).map_err(|source| ManifestError::Read {
417 path: manifest_path.to_path_buf(),
418 source,
419 })?;
420 parse_workspace_manifest(&manifest_yaml)
421}
422
423pub fn parse_workspace_manifest(manifest_yaml: &str) -> Result<WorkspaceManifest, ManifestError> {
438 let raw_manifest = serde_yaml::from_str::<RawWorkspaceManifest>(manifest_yaml)?;
439 raw_manifest.try_into()
440}
441
442pub fn validate_workspace_folders(manifest: &WorkspaceManifest) -> Result<(), ManifestError> {
457 for folder in manifest.folders() {
458 if !folder.exists() {
459 return Err(ManifestError::FolderMissing {
460 path: folder.clone(),
461 });
462 }
463
464 if !folder.is_dir() {
465 return Err(ManifestError::FolderNotDirectory {
466 path: folder.clone(),
467 });
468 }
469 }
470
471 Ok(())
472}
473
474#[cfg(test)]
475mod tests {
476 use std::sync::atomic::{AtomicUsize, Ordering};
477 use std::time::{SystemTime, UNIX_EPOCH};
478
479 use super::*;
480
481 static TEMP_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
482
483 #[derive(Debug)]
484 struct TestTempDir {
485 path: PathBuf,
486 }
487
488 impl TestTempDir {
489 fn create() -> Self {
490 let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
491 let timestamp = SystemTime::now()
492 .duration_since(UNIX_EPOCH)
493 .expect("system clock should be after Unix epoch")
494 .as_nanos();
495 let path = std::env::temp_dir().join(format!(
496 "codex-ws-test-{}-{timestamp}-{counter}",
497 std::process::id()
498 ));
499 fs::create_dir(&path).expect("temporary test directory should be created");
500 Self { path }
501 }
502
503 fn path(&self) -> &Path {
504 &self.path
505 }
506 }
507
508 impl Drop for TestTempDir {
509 fn drop(&mut self) {
510 let _ = fs::remove_dir_all(&self.path);
511 }
512 }
513
514 #[test]
515 fn parse_workspace_manifest_supports_multiple_folders_and_network() {
516 let manifest = parse_workspace_manifest(
517 r#"
518name: workspace-name
519folders:
520 - /projects/backend
521 - /projects/frontend
522sandbox:
523 network: true
524"#,
525 )
526 .expect("manifest should parse");
527
528 assert_eq!(manifest.name(), "workspace-name");
529 assert_eq!(
530 manifest.folders(),
531 &[
532 PathBuf::from("/projects/backend"),
533 PathBuf::from("/projects/frontend")
534 ]
535 );
536 assert!(manifest.sandbox().network());
537 assert_eq!(manifest.runtime().image(), None);
538 }
539
540 #[test]
541 fn parse_workspace_manifest_supports_single_folder() {
542 let manifest = parse_workspace_manifest(
543 r#"
544name: single-project
545folders:
546 - /projects/backend
547"#,
548 )
549 .expect("manifest should parse");
550
551 assert_eq!(manifest.name(), "single-project");
552 assert_eq!(manifest.folders(), &[PathBuf::from("/projects/backend")]);
553 assert!(manifest.sandbox().network());
554 assert_eq!(manifest.runtime().image(), None);
555 }
556
557 #[test]
558 fn parse_workspace_manifest_supports_runtime_image() {
559 let manifest = parse_workspace_manifest(
560 r#"
561name: rust-project
562folders:
563 - /projects/rust-project
564runtime:
565 image: rust-codex-ws:latest
566"#,
567 )
568 .expect("manifest should parse");
569
570 assert_eq!(manifest.runtime().image(), Some("rust-codex-ws:latest"));
571 }
572
573 #[test]
574 fn parse_workspace_manifest_supports_runtime_apt_packages() {
575 let manifest = parse_workspace_manifest(
576 r#"
577name: python-project
578folders:
579 - /projects/python-project
580runtime:
581 apt:
582 - python3
583 - python3-pip
584"#,
585 )
586 .expect("manifest should parse");
587
588 assert_eq!(
589 manifest.runtime().environment_variables()[0].docker_assignment(),
590 "CODEX_WS_APT_PACKAGES=python3 python3-pip"
591 );
592 }
593
594 #[test]
595 fn parse_workspace_manifest_supports_runtime_setup_commands() {
596 let manifest = parse_workspace_manifest(
597 r#"
598name: rust-project
599folders:
600 - /projects/rust-project
601runtime:
602 setup:
603 - curl -fsSL https://sh.rustup.rs | sh -s -- -y
604 - . "$HOME/.cargo/env"
605"#,
606 )
607 .expect("manifest should parse");
608
609 let variables = manifest.runtime().environment_variables();
610 assert_eq!(
611 variables
612 .iter()
613 .map(crate::runtime::RuntimeEnvironmentVariable::docker_assignment)
614 .collect::<Vec<_>>(),
615 vec!["CODEX_WS_SETUP_COMMANDS=curl -fsSL https://sh.rustup.rs | sh -s -- -y\n. \"$HOME/.cargo/env\"".to_owned()]
616 );
617 }
618
619 #[test]
620 fn parse_workspace_manifest_supports_runtime_apt_and_setup() {
621 let manifest = parse_workspace_manifest(
622 r#"
623name: mixed-project
624folders:
625 - /projects/mixed-project
626runtime:
627 apt:
628 - build-essential
629 setup:
630 - echo ready
631"#,
632 )
633 .expect("manifest should parse");
634
635 let variables = manifest.runtime().environment_variables();
636 assert_eq!(
637 variables
638 .iter()
639 .map(crate::runtime::RuntimeEnvironmentVariable::docker_assignment)
640 .collect::<Vec<_>>(),
641 vec![
642 "CODEX_WS_APT_PACKAGES=build-essential".to_owned(),
643 "CODEX_WS_SETUP_COMMANDS=echo ready".to_owned()
644 ]
645 );
646 }
647
648 #[test]
649 fn parse_workspace_manifest_rejects_empty_name() {
650 let error = parse_workspace_manifest(
651 r#"
652name: " "
653folders:
654 - /projects/backend
655"#,
656 )
657 .expect_err("blank name should fail");
658
659 assert!(matches!(error, ManifestError::EmptyName));
660 }
661
662 #[test]
663 fn parse_workspace_manifest_rejects_empty_folders() {
664 let error = parse_workspace_manifest(
665 r#"
666name: empty-workspace
667folders: []
668"#,
669 )
670 .expect_err("empty folders should fail");
671
672 assert!(matches!(error, ManifestError::NoFolders));
673 }
674
675 #[test]
676 fn parse_workspace_manifest_rejects_empty_runtime_image() {
677 let error = parse_workspace_manifest(
678 r#"
679name: workspace
680folders:
681 - /projects/backend
682runtime:
683 image: " "
684"#,
685 )
686 .expect_err("blank runtime image should fail");
687
688 assert!(matches!(error, ManifestError::EmptyRuntimeImage));
689 }
690
691 #[test]
692 fn parse_workspace_manifest_rejects_invalid_apt_package() {
693 let error = parse_workspace_manifest(
694 r#"
695name: workspace
696folders:
697 - /projects/backend
698runtime:
699 apt:
700 - python3;curl
701"#,
702 )
703 .expect_err("invalid apt package should fail");
704
705 assert!(matches!(
706 error,
707 ManifestError::RuntimeSpec(crate::runtime::RuntimeSpecError::InvalidAptPackage {
708 package
709 }) if package == "python3;curl"
710 ));
711 }
712
713 #[test]
714 fn validate_workspace_folders_accepts_existing_directories() {
715 let temp_dir = TestTempDir::create();
716 let folder = temp_dir.path().join("project");
717 fs::create_dir(&folder).expect("workspace folder should be created");
718 let manifest = WorkspaceManifest::new(
719 "workspace".to_owned(),
720 vec![folder],
721 SandboxConfig::default(),
722 )
723 .expect("manifest should be valid");
724
725 validate_workspace_folders(&manifest).expect("folder validation should pass");
726 }
727
728 #[test]
729 fn validate_workspace_folders_rejects_missing_paths() {
730 let temp_dir = TestTempDir::create();
731 let missing_folder = temp_dir.path().join("missing");
732 let manifest = WorkspaceManifest::new(
733 "workspace".to_owned(),
734 vec![missing_folder.clone()],
735 SandboxConfig::default(),
736 )
737 .expect("manifest should be valid");
738
739 let error = validate_workspace_folders(&manifest).expect_err("missing folder should fail");
740
741 assert!(matches!(
742 error,
743 ManifestError::FolderMissing { path } if path == missing_folder
744 ));
745 }
746
747 #[test]
748 fn validate_workspace_folders_rejects_files() {
749 let temp_dir = TestTempDir::create();
750 let file_path = temp_dir.path().join("file.txt");
751 fs::write(&file_path, "not a directory").expect("file should be written");
752 let manifest = WorkspaceManifest::new(
753 "workspace".to_owned(),
754 vec![file_path.clone()],
755 SandboxConfig::default(),
756 )
757 .expect("manifest should be valid");
758
759 let error = validate_workspace_folders(&manifest).expect_err("file path should fail");
760
761 assert!(matches!(
762 error,
763 ManifestError::FolderNotDirectory { path } if path == file_path
764 ));
765 }
766}