use thiserror::Error;
pub const CODEX_WS_APT_PACKAGES_ENV: &str = "CODEX_WS_APT_PACKAGES";
pub const CODEX_WS_SETUP_COMMANDS_ENV: &str = "CODEX_WS_SETUP_COMMANDS";
pub const CODEX_WS_PYTHON_VERSION_ENV: &str = "CODEX_WS_PYTHON_VERSION";
pub const CODEX_WS_NODE_VERSION_ENV: &str = "CODEX_WS_NODE_VERSION";
pub const CODEX_WS_GO_VERSION_ENV: &str = "CODEX_WS_GO_VERSION";
pub const CODEX_WS_RUST_VERSION_ENV: &str = "CODEX_WS_RUST_VERSION";
pub const CODEX_WS_JAVA_VERSION_ENV: &str = "CODEX_WS_JAVA_VERSION";
pub const CODEX_WS_CLANG_VERSION_ENV: &str = "CODEX_WS_CLANG_VERSION";
pub const CODEX_WS_C_VERSION_ENV: &str = "CODEX_WS_C_VERSION";
pub const CODEX_WS_CPP_VERSION_ENV: &str = "CODEX_WS_CPP_VERSION";
pub const CODEX_WS_RUBY_VERSION_ENV: &str = "CODEX_WS_RUBY_VERSION";
pub const CODEX_WS_PHP_VERSION_ENV: &str = "CODEX_WS_PHP_VERSION";
pub const CODEX_WS_DENO_VERSION_ENV: &str = "CODEX_WS_DENO_VERSION";
pub const CODEX_WS_BUN_VERSION_ENV: &str = "CODEX_WS_BUN_VERSION";
pub const CODEX_WS_ZIG_VERSION_ENV: &str = "CODEX_WS_ZIG_VERSION";
pub const CODEX_WS_DOTNET_VERSION_ENV: &str = "CODEX_WS_DOTNET_VERSION";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeEnvironmentVariable {
name: &'static str,
value: String,
}
impl RuntimeEnvironmentVariable {
#[must_use]
pub fn new(name: &'static str, value: String) -> Self {
Self { name, value }
}
#[must_use]
pub const fn name(&self) -> &'static str {
self.name
}
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
#[must_use]
pub fn docker_assignment(&self) -> String {
format!("{}={}", self.name, self.value)
}
}
pub fn validate_apt_packages(packages: Vec<String>) -> Result<Vec<String>, RuntimeSpecError> {
let mut validated_packages = Vec::with_capacity(packages.len());
for package in packages {
let package = package.trim().to_owned();
if package.is_empty() {
return Err(RuntimeSpecError::EmptyAptPackage);
}
if !is_valid_apt_package(&package) {
return Err(RuntimeSpecError::InvalidAptPackage { package });
}
validated_packages.push(package);
}
Ok(validated_packages)
}
pub fn validate_setup_commands(commands: Vec<String>) -> Result<Vec<String>, RuntimeSpecError> {
let mut validated_commands = Vec::with_capacity(commands.len());
for command in commands {
let command = command.trim().to_owned();
if command.is_empty() {
return Err(RuntimeSpecError::EmptySetupCommand);
}
validated_commands.push(command);
}
Ok(validated_commands)
}
pub fn validate_tool_version(
tool: RuntimeTool,
version: Option<String>,
) -> Result<Option<RuntimeToolVersion>, RuntimeSpecError> {
let Some(version) = version else {
return Ok(None);
};
let version = version.trim().to_owned();
if version.is_empty() {
return Err(RuntimeSpecError::EmptyToolVersion { tool });
}
if !is_valid_tool_version(&version) {
return Err(RuntimeSpecError::InvalidToolVersion { tool, version });
}
Ok(Some(RuntimeToolVersion::new(tool, version)))
}
pub fn validate_runtime_tool_versions(
versions: Vec<RuntimeToolVersion>,
) -> Result<Vec<RuntimeToolVersion>, RuntimeSpecError> {
let mut clang_version: Option<&str> = None;
for version in &versions {
if !version.tool().uses_clang() {
continue;
}
if let Some(existing_version) = clang_version
&& existing_version != version.version()
{
return Err(RuntimeSpecError::ConflictingCompilerVersions {
first: existing_version.to_owned(),
second: version.version().to_owned(),
});
}
clang_version = Some(version.version());
}
Ok(versions)
}
fn is_valid_apt_package(package: &str) -> bool {
package.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(byte, b'+' | b'-' | b'.' | b'_' | b':' | b'=' | b'~')
})
}
fn is_valid_tool_version(version: &str) -> bool {
version.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(byte, b'+' | b'-' | b'.' | b'_' | b':' | b'/' | b'@')
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeTool {
Python,
Node,
Go,
Rust,
Java,
Clang,
C,
Cpp,
Ruby,
Php,
Deno,
Bun,
Zig,
Dotnet,
}
impl RuntimeTool {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Python => "python",
Self::Node => "node",
Self::Go => "go",
Self::Rust => "rust",
Self::Java => "java",
Self::Clang => "clang",
Self::C => "c",
Self::Cpp => "cpp",
Self::Ruby => "ruby",
Self::Php => "php",
Self::Deno => "deno",
Self::Bun => "bun",
Self::Zig => "zig",
Self::Dotnet => "dotnet",
}
}
#[must_use]
pub const fn environment_variable(self) -> &'static str {
match self {
Self::Python => CODEX_WS_PYTHON_VERSION_ENV,
Self::Node => CODEX_WS_NODE_VERSION_ENV,
Self::Go => CODEX_WS_GO_VERSION_ENV,
Self::Rust => CODEX_WS_RUST_VERSION_ENV,
Self::Java => CODEX_WS_JAVA_VERSION_ENV,
Self::Clang => CODEX_WS_CLANG_VERSION_ENV,
Self::C => CODEX_WS_C_VERSION_ENV,
Self::Cpp => CODEX_WS_CPP_VERSION_ENV,
Self::Ruby => CODEX_WS_RUBY_VERSION_ENV,
Self::Php => CODEX_WS_PHP_VERSION_ENV,
Self::Deno => CODEX_WS_DENO_VERSION_ENV,
Self::Bun => CODEX_WS_BUN_VERSION_ENV,
Self::Zig => CODEX_WS_ZIG_VERSION_ENV,
Self::Dotnet => CODEX_WS_DOTNET_VERSION_ENV,
}
}
const fn uses_clang(self) -> bool {
matches!(self, Self::Clang | Self::C | Self::Cpp)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeToolVersion {
tool: RuntimeTool,
version: String,
}
impl RuntimeToolVersion {
#[must_use]
pub fn new(tool: RuntimeTool, version: String) -> Self {
Self { tool, version }
}
#[must_use]
pub const fn tool(&self) -> RuntimeTool {
self.tool
}
#[must_use]
pub fn version(&self) -> &str {
&self.version
}
#[must_use]
pub fn environment_variable(&self) -> RuntimeEnvironmentVariable {
RuntimeEnvironmentVariable::new(self.tool.environment_variable(), self.version.clone())
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RuntimeSpecError {
#[error("runtime apt package cannot be empty")]
EmptyAptPackage,
#[error("invalid runtime apt package '{package}'")]
InvalidAptPackage {
package: String,
},
#[error("runtime setup command cannot be empty")]
EmptySetupCommand,
#[error("runtime {tool} version cannot be empty", tool = .tool.name())]
EmptyToolVersion {
tool: RuntimeTool,
},
#[error("invalid runtime {tool} version '{version}'", tool = .tool.name())]
InvalidToolVersion {
tool: RuntimeTool,
version: String,
},
#[error("conflicting C/C++ runtime versions '{first}' and '{second}'")]
ConflictingCompilerVersions {
first: String,
second: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_apt_packages_accepts_common_package_syntax() {
let packages = validate_apt_packages(vec![
" python3 ".to_owned(),
"libssl-dev:amd64".to_owned(),
"nodejs=22.0.0-1nodesource1".to_owned(),
])
.expect("apt packages should validate");
assert_eq!(
packages,
vec![
"python3".to_owned(),
"libssl-dev:amd64".to_owned(),
"nodejs=22.0.0-1nodesource1".to_owned()
]
);
}
#[test]
fn validate_apt_packages_rejects_shell_metacharacters() {
let error = validate_apt_packages(vec!["python3;curl".to_owned()])
.expect_err("shell metacharacters should fail");
assert!(matches!(
error,
RuntimeSpecError::InvalidAptPackage { package } if package == "python3;curl"
));
}
#[test]
fn validate_setup_commands_rejects_empty_commands() {
let error =
validate_setup_commands(vec![" ".to_owned()]).expect_err("blank command should fail");
assert_eq!(error, RuntimeSpecError::EmptySetupCommand);
}
#[test]
fn validate_tool_version_trims_versions() {
let version = validate_tool_version(RuntimeTool::Python, Some(" 3.13 ".to_owned()))
.expect("version should validate");
assert_eq!(
version,
Some(RuntimeToolVersion::new(
RuntimeTool::Python,
"3.13".to_owned()
))
);
}
#[test]
fn validate_tool_version_rejects_shell_metacharacters() {
let error = validate_tool_version(RuntimeTool::Go, Some("1.24;curl".to_owned()))
.expect_err("shell metacharacters should fail");
assert!(matches!(
error,
RuntimeSpecError::InvalidToolVersion {
tool: RuntimeTool::Go,
version
} if version == "1.24;curl"
));
}
#[test]
fn validate_runtime_tool_versions_accepts_matching_compiler_aliases() {
let versions = validate_runtime_tool_versions(vec![
RuntimeToolVersion::new(RuntimeTool::C, "20".to_owned()),
RuntimeToolVersion::new(RuntimeTool::Cpp, "20".to_owned()),
])
.expect("matching compiler aliases should validate");
assert_eq!(versions.len(), 2);
}
#[test]
fn validate_runtime_tool_versions_rejects_conflicting_compiler_aliases() {
let error = validate_runtime_tool_versions(vec![
RuntimeToolVersion::new(RuntimeTool::C, "20".to_owned()),
RuntimeToolVersion::new(RuntimeTool::Cpp, "21".to_owned()),
])
.expect_err("conflicting compiler aliases should fail");
assert!(matches!(
error,
RuntimeSpecError::ConflictingCompilerVersions { first, second }
if first == "20" && second == "21"
));
}
}