Skip to main content

codex_ws/
runtime.rs

1use thiserror::Error;
2
3/// Environment variable used by the runtime entrypoint for apt packages.
4pub const CODEX_WS_APT_PACKAGES_ENV: &str = "CODEX_WS_APT_PACKAGES";
5
6/// Environment variable used by the runtime entrypoint for setup commands.
7pub const CODEX_WS_SETUP_COMMANDS_ENV: &str = "CODEX_WS_SETUP_COMMANDS";
8
9/// Environment variable used by the runtime entrypoint for Python version selection.
10pub const CODEX_WS_PYTHON_VERSION_ENV: &str = "CODEX_WS_PYTHON_VERSION";
11
12/// Environment variable used by the runtime entrypoint for Node.js version selection.
13pub const CODEX_WS_NODE_VERSION_ENV: &str = "CODEX_WS_NODE_VERSION";
14
15/// Environment variable used by the runtime entrypoint for Go version selection.
16pub const CODEX_WS_GO_VERSION_ENV: &str = "CODEX_WS_GO_VERSION";
17
18/// Environment variable used by the runtime entrypoint for Rust version selection.
19pub const CODEX_WS_RUST_VERSION_ENV: &str = "CODEX_WS_RUST_VERSION";
20
21/// Environment variable used by the runtime entrypoint for Java version selection.
22pub const CODEX_WS_JAVA_VERSION_ENV: &str = "CODEX_WS_JAVA_VERSION";
23
24/// Environment variable used by the runtime entrypoint for Clang version selection.
25pub const CODEX_WS_CLANG_VERSION_ENV: &str = "CODEX_WS_CLANG_VERSION";
26
27/// Environment variable used by the runtime entrypoint for C compiler version selection.
28pub const CODEX_WS_C_VERSION_ENV: &str = "CODEX_WS_C_VERSION";
29
30/// Environment variable used by the runtime entrypoint for C++ compiler version selection.
31pub const CODEX_WS_CPP_VERSION_ENV: &str = "CODEX_WS_CPP_VERSION";
32
33/// Environment variable used by the runtime entrypoint for Ruby version selection.
34pub const CODEX_WS_RUBY_VERSION_ENV: &str = "CODEX_WS_RUBY_VERSION";
35
36/// Environment variable used by the runtime entrypoint for PHP version selection.
37pub const CODEX_WS_PHP_VERSION_ENV: &str = "CODEX_WS_PHP_VERSION";
38
39/// Environment variable used by the runtime entrypoint for Deno version selection.
40pub const CODEX_WS_DENO_VERSION_ENV: &str = "CODEX_WS_DENO_VERSION";
41
42/// Environment variable used by the runtime entrypoint for Bun version selection.
43pub const CODEX_WS_BUN_VERSION_ENV: &str = "CODEX_WS_BUN_VERSION";
44
45/// Environment variable used by the runtime entrypoint for Zig version selection.
46pub const CODEX_WS_ZIG_VERSION_ENV: &str = "CODEX_WS_ZIG_VERSION";
47
48/// Environment variable used by the runtime entrypoint for .NET version selection.
49pub const CODEX_WS_DOTNET_VERSION_ENV: &str = "CODEX_WS_DOTNET_VERSION";
50
51/// Docker environment variable generated from a workspace runtime config.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct RuntimeEnvironmentVariable {
54    name: &'static str,
55    value: String,
56}
57
58impl RuntimeEnvironmentVariable {
59    /// Create a runtime environment variable.
60    ///
61    /// # Arguments
62    ///
63    /// * `name` - Environment variable name recognized by the runtime entrypoint.
64    /// * `value` - Environment variable value passed to Docker.
65    ///
66    /// # Returns
67    ///
68    /// A runtime environment variable.
69    #[must_use]
70    pub fn new(name: &'static str, value: String) -> Self {
71        Self { name, value }
72    }
73
74    /// Return the environment variable name.
75    ///
76    /// # Returns
77    ///
78    /// The runtime entrypoint environment variable name.
79    #[must_use]
80    pub const fn name(&self) -> &'static str {
81        self.name
82    }
83
84    /// Return the environment variable value.
85    ///
86    /// # Returns
87    ///
88    /// The value passed to Docker.
89    #[must_use]
90    pub fn value(&self) -> &str {
91        &self.value
92    }
93
94    /// Return the `NAME=value` form accepted by `docker run -e`.
95    ///
96    /// # Returns
97    ///
98    /// A Docker environment assignment.
99    #[must_use]
100    pub fn docker_assignment(&self) -> String {
101        format!("{}={}", self.name, self.value)
102    }
103}
104
105/// Validate apt package names from a workspace manifest.
106///
107/// # Arguments
108///
109/// * `packages` - Package names requested by `runtime.apt`.
110///
111/// # Returns
112///
113/// Trimmed package names in input order.
114///
115/// # Errors
116///
117/// Returns [`RuntimeSpecError::EmptyAptPackage`] for a blank package or
118/// [`RuntimeSpecError::InvalidAptPackage`] when a package contains shell metacharacters.
119pub fn validate_apt_packages(packages: Vec<String>) -> Result<Vec<String>, RuntimeSpecError> {
120    let mut validated_packages = Vec::with_capacity(packages.len());
121    for package in packages {
122        let package = package.trim().to_owned();
123        if package.is_empty() {
124            return Err(RuntimeSpecError::EmptyAptPackage);
125        }
126        if !is_valid_apt_package(&package) {
127            return Err(RuntimeSpecError::InvalidAptPackage { package });
128        }
129        validated_packages.push(package);
130    }
131
132    Ok(validated_packages)
133}
134
135/// Validate setup commands from a workspace manifest.
136///
137/// # Arguments
138///
139/// * `commands` - Shell commands requested by `runtime.setup`.
140///
141/// # Returns
142///
143/// Trimmed commands in input order.
144///
145/// # Errors
146///
147/// Returns [`RuntimeSpecError::EmptySetupCommand`] when a setup command is blank.
148pub fn validate_setup_commands(commands: Vec<String>) -> Result<Vec<String>, RuntimeSpecError> {
149    let mut validated_commands = Vec::with_capacity(commands.len());
150    for command in commands {
151        let command = command.trim().to_owned();
152        if command.is_empty() {
153            return Err(RuntimeSpecError::EmptySetupCommand);
154        }
155        validated_commands.push(command);
156    }
157
158    Ok(validated_commands)
159}
160
161/// Validate a runtime tool version from a workspace manifest.
162///
163/// # Arguments
164///
165/// * `tool` - Tool name used in diagnostics.
166/// * `version` - Optional version requested by the workspace manifest.
167///
168/// # Returns
169///
170/// A trimmed version when one was configured.
171///
172/// # Errors
173///
174/// Returns [`RuntimeSpecError::EmptyToolVersion`] for a blank version or
175/// [`RuntimeSpecError::InvalidToolVersion`] when the version contains shell metacharacters.
176pub fn validate_tool_version(
177    tool: RuntimeTool,
178    version: Option<String>,
179) -> Result<Option<RuntimeToolVersion>, RuntimeSpecError> {
180    let Some(version) = version else {
181        return Ok(None);
182    };
183    let version = version.trim().to_owned();
184    if version.is_empty() {
185        return Err(RuntimeSpecError::EmptyToolVersion { tool });
186    }
187    if !is_valid_tool_version(&version) {
188        return Err(RuntimeSpecError::InvalidToolVersion { tool, version });
189    }
190
191    Ok(Some(RuntimeToolVersion::new(tool, version)))
192}
193
194/// Validate a set of requested runtime tool versions.
195///
196/// # Arguments
197///
198/// * `versions` - Runtime tool versions collected from a workspace manifest.
199///
200/// # Returns
201///
202/// Runtime tool versions in input order.
203///
204/// # Errors
205///
206/// Returns [`RuntimeSpecError::ConflictingCompilerVersions`] when `c`, `cpp`, and `clang`
207/// request different LLVM Clang versions.
208pub fn validate_runtime_tool_versions(
209    versions: Vec<RuntimeToolVersion>,
210) -> Result<Vec<RuntimeToolVersion>, RuntimeSpecError> {
211    let mut clang_version: Option<&str> = None;
212
213    for version in &versions {
214        if !version.tool().uses_clang() {
215            continue;
216        }
217        if let Some(existing_version) = clang_version
218            && existing_version != version.version()
219        {
220            return Err(RuntimeSpecError::ConflictingCompilerVersions {
221                first: existing_version.to_owned(),
222                second: version.version().to_owned(),
223            });
224        }
225        clang_version = Some(version.version());
226    }
227
228    Ok(versions)
229}
230
231fn is_valid_apt_package(package: &str) -> bool {
232    package.bytes().all(|byte| {
233        byte.is_ascii_alphanumeric()
234            || matches!(byte, b'+' | b'-' | b'.' | b'_' | b':' | b'=' | b'~')
235    })
236}
237
238fn is_valid_tool_version(version: &str) -> bool {
239    version.bytes().all(|byte| {
240        byte.is_ascii_alphanumeric()
241            || matches!(byte, b'+' | b'-' | b'.' | b'_' | b':' | b'/' | b'@')
242    })
243}
244
245/// Runtime tools that can be installed declaratively.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum RuntimeTool {
248    /// Python installed with uv.
249    Python,
250    /// Node.js installed with mise.
251    Node,
252    /// Go installed with mise.
253    Go,
254    /// Rust installed with mise.
255    Rust,
256    /// Java installed with mise.
257    Java,
258    /// Clang installed with LLVM apt packages.
259    Clang,
260    /// C compiler installed with LLVM apt packages.
261    C,
262    /// C++ compiler installed with LLVM apt packages.
263    Cpp,
264    /// Ruby installed with mise.
265    Ruby,
266    /// PHP installed with mise.
267    Php,
268    /// Deno installed with mise.
269    Deno,
270    /// Bun installed with mise.
271    Bun,
272    /// Zig installed with mise.
273    Zig,
274    /// .NET SDK installed with mise.
275    Dotnet,
276}
277
278impl RuntimeTool {
279    /// Return the manifest field name for this tool.
280    ///
281    /// # Returns
282    ///
283    /// Lowercase tool name used in workspace manifests.
284    #[must_use]
285    pub const fn name(self) -> &'static str {
286        match self {
287            Self::Python => "python",
288            Self::Node => "node",
289            Self::Go => "go",
290            Self::Rust => "rust",
291            Self::Java => "java",
292            Self::Clang => "clang",
293            Self::C => "c",
294            Self::Cpp => "cpp",
295            Self::Ruby => "ruby",
296            Self::Php => "php",
297            Self::Deno => "deno",
298            Self::Bun => "bun",
299            Self::Zig => "zig",
300            Self::Dotnet => "dotnet",
301        }
302    }
303
304    /// Return the environment variable consumed by the runtime entrypoint.
305    ///
306    /// # Returns
307    ///
308    /// Environment variable name for this declarative runtime tool.
309    #[must_use]
310    pub const fn environment_variable(self) -> &'static str {
311        match self {
312            Self::Python => CODEX_WS_PYTHON_VERSION_ENV,
313            Self::Node => CODEX_WS_NODE_VERSION_ENV,
314            Self::Go => CODEX_WS_GO_VERSION_ENV,
315            Self::Rust => CODEX_WS_RUST_VERSION_ENV,
316            Self::Java => CODEX_WS_JAVA_VERSION_ENV,
317            Self::Clang => CODEX_WS_CLANG_VERSION_ENV,
318            Self::C => CODEX_WS_C_VERSION_ENV,
319            Self::Cpp => CODEX_WS_CPP_VERSION_ENV,
320            Self::Ruby => CODEX_WS_RUBY_VERSION_ENV,
321            Self::Php => CODEX_WS_PHP_VERSION_ENV,
322            Self::Deno => CODEX_WS_DENO_VERSION_ENV,
323            Self::Bun => CODEX_WS_BUN_VERSION_ENV,
324            Self::Zig => CODEX_WS_ZIG_VERSION_ENV,
325            Self::Dotnet => CODEX_WS_DOTNET_VERSION_ENV,
326        }
327    }
328
329    const fn uses_clang(self) -> bool {
330        matches!(self, Self::Clang | Self::C | Self::Cpp)
331    }
332}
333
334/// One declarative runtime tool version selected for a workspace.
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct RuntimeToolVersion {
337    tool: RuntimeTool,
338    version: String,
339}
340
341impl RuntimeToolVersion {
342    /// Create a runtime tool version.
343    ///
344    /// # Arguments
345    ///
346    /// * `tool` - Runtime tool requested by the workspace.
347    /// * `version` - Version string passed to the runtime installer.
348    ///
349    /// # Returns
350    ///
351    /// A runtime tool version.
352    #[must_use]
353    pub fn new(tool: RuntimeTool, version: String) -> Self {
354        Self { tool, version }
355    }
356
357    /// Return the requested runtime tool.
358    ///
359    /// # Returns
360    ///
361    /// Runtime tool enum value.
362    #[must_use]
363    pub const fn tool(&self) -> RuntimeTool {
364        self.tool
365    }
366
367    /// Return the requested tool version.
368    ///
369    /// # Returns
370    ///
371    /// Version string passed to the runtime installer.
372    #[must_use]
373    pub fn version(&self) -> &str {
374        &self.version
375    }
376
377    /// Convert this runtime tool into a Docker environment variable.
378    ///
379    /// # Returns
380    ///
381    /// Runtime entrypoint environment variable.
382    #[must_use]
383    pub fn environment_variable(&self) -> RuntimeEnvironmentVariable {
384        RuntimeEnvironmentVariable::new(self.tool.environment_variable(), self.version.clone())
385    }
386}
387
388/// Errors returned while parsing workspace runtime setup.
389#[derive(Debug, Error, PartialEq, Eq)]
390pub enum RuntimeSpecError {
391    /// An apt package entry was empty or only whitespace.
392    #[error("runtime apt package cannot be empty")]
393    EmptyAptPackage,
394
395    /// An apt package entry contained characters that are unsafe for shell word splitting.
396    #[error("invalid runtime apt package '{package}'")]
397    InvalidAptPackage {
398        /// Invalid package entry.
399        package: String,
400    },
401
402    /// A setup command was empty or only whitespace.
403    #[error("runtime setup command cannot be empty")]
404    EmptySetupCommand,
405
406    /// A declarative runtime tool version was empty or only whitespace.
407    #[error("runtime {tool} version cannot be empty", tool = .tool.name())]
408    EmptyToolVersion {
409        /// Tool with an empty version.
410        tool: RuntimeTool,
411    },
412
413    /// A declarative runtime tool version contained unsafe characters.
414    #[error("invalid runtime {tool} version '{version}'", tool = .tool.name())]
415    InvalidToolVersion {
416        /// Tool with an invalid version.
417        tool: RuntimeTool,
418        /// Invalid version text.
419        version: String,
420    },
421
422    /// Multiple C/C++ compiler aliases requested different Clang versions.
423    #[error("conflicting C/C++ runtime versions '{first}' and '{second}'")]
424    ConflictingCompilerVersions {
425        /// First configured compiler version.
426        first: String,
427        /// Conflicting compiler version.
428        second: String,
429    },
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn validate_apt_packages_accepts_common_package_syntax() {
438        let packages = validate_apt_packages(vec![
439            " python3 ".to_owned(),
440            "libssl-dev:amd64".to_owned(),
441            "nodejs=22.0.0-1nodesource1".to_owned(),
442        ])
443        .expect("apt packages should validate");
444
445        assert_eq!(
446            packages,
447            vec![
448                "python3".to_owned(),
449                "libssl-dev:amd64".to_owned(),
450                "nodejs=22.0.0-1nodesource1".to_owned()
451            ]
452        );
453    }
454
455    #[test]
456    fn validate_apt_packages_rejects_shell_metacharacters() {
457        let error = validate_apt_packages(vec!["python3;curl".to_owned()])
458            .expect_err("shell metacharacters should fail");
459
460        assert!(matches!(
461            error,
462            RuntimeSpecError::InvalidAptPackage { package } if package == "python3;curl"
463        ));
464    }
465
466    #[test]
467    fn validate_setup_commands_rejects_empty_commands() {
468        let error =
469            validate_setup_commands(vec![" ".to_owned()]).expect_err("blank command should fail");
470
471        assert_eq!(error, RuntimeSpecError::EmptySetupCommand);
472    }
473
474    #[test]
475    fn validate_tool_version_trims_versions() {
476        let version = validate_tool_version(RuntimeTool::Python, Some(" 3.13 ".to_owned()))
477            .expect("version should validate");
478
479        assert_eq!(
480            version,
481            Some(RuntimeToolVersion::new(
482                RuntimeTool::Python,
483                "3.13".to_owned()
484            ))
485        );
486    }
487
488    #[test]
489    fn validate_tool_version_rejects_shell_metacharacters() {
490        let error = validate_tool_version(RuntimeTool::Go, Some("1.24;curl".to_owned()))
491            .expect_err("shell metacharacters should fail");
492
493        assert!(matches!(
494            error,
495            RuntimeSpecError::InvalidToolVersion {
496                tool: RuntimeTool::Go,
497                version
498            } if version == "1.24;curl"
499        ));
500    }
501
502    #[test]
503    fn validate_runtime_tool_versions_accepts_matching_compiler_aliases() {
504        let versions = validate_runtime_tool_versions(vec![
505            RuntimeToolVersion::new(RuntimeTool::C, "20".to_owned()),
506            RuntimeToolVersion::new(RuntimeTool::Cpp, "20".to_owned()),
507        ])
508        .expect("matching compiler aliases should validate");
509
510        assert_eq!(versions.len(), 2);
511    }
512
513    #[test]
514    fn validate_runtime_tool_versions_rejects_conflicting_compiler_aliases() {
515        let error = validate_runtime_tool_versions(vec![
516            RuntimeToolVersion::new(RuntimeTool::C, "20".to_owned()),
517            RuntimeToolVersion::new(RuntimeTool::Cpp, "21".to_owned()),
518        ])
519        .expect_err("conflicting compiler aliases should fail");
520
521        assert!(matches!(
522            error,
523            RuntimeSpecError::ConflictingCompilerVersions { first, second }
524                if first == "20" && second == "21"
525        ));
526    }
527}