use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const EMBEDDED: &[(&str, &str)] = &[
("claude-code", include_str!("../runtimes/claude-code.yaml")),
("codex", include_str!("../runtimes/codex.yaml")),
("gemini", include_str!("../runtimes/gemini.yaml")),
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Runtime {
pub binary: String,
#[serde(default)]
pub supports_mcp: bool,
#[serde(default)]
pub session_resume: Option<String>,
#[serde(default)]
pub default_model: Option<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub rate_limit_patterns: Vec<RateLimitPattern>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitPattern {
pub r#match: String,
#[serde(default)]
pub resets_at_capture: Option<String>,
#[serde(default)]
pub resets_in_capture: Option<String>,
}
pub fn embedded_defaults() -> Result<BTreeMap<String, Runtime>> {
EMBEDDED
.iter()
.map(|(stem, src)| {
let r: Runtime = serde_yaml::from_str(src)
.with_context(|| format!("parse embedded runtime `{stem}`"))?;
Ok(((*stem).to_string(), r))
})
.collect()
}
pub fn load_all(root: &Path) -> Result<BTreeMap<String, Runtime>> {
let mut map = embedded_defaults()?;
let dir = root.join("runtimes");
if !dir.exists() {
return Ok(map);
}
for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
let entry = entry?;
let path: PathBuf = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let content =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let r: Runtime =
serde_yaml::from_str(&content).with_context(|| format!("parse {}", path.display()))?;
map.insert(stem, r);
}
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_defaults_parse() {
let m = embedded_defaults().unwrap();
assert!(m.contains_key("claude-code"));
assert!(m.contains_key("codex"));
assert!(m.contains_key("gemini"));
assert_eq!(m["claude-code"].binary, "claude");
assert!(m["claude-code"].supports_mcp);
}
#[test]
fn load_nonexistent_returns_embedded_defaults() {
let tmp = tempfile::tempdir().unwrap();
let m = load_all(tmp.path()).unwrap();
assert!(m.contains_key("claude-code"));
assert!(m.contains_key("codex"));
assert!(m.contains_key("gemini"));
}
#[test]
fn user_file_overrides_embedded_default() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("runtimes");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("claude-code.yaml"),
"binary: my-claude-fork\nsupports_mcp: false\n",
)
.unwrap();
let m = load_all(tmp.path()).unwrap();
assert_eq!(m["claude-code"].binary, "my-claude-fork");
assert!(!m["claude-code"].supports_mcp);
assert_eq!(m["codex"].binary, "codex");
}
#[test]
fn user_file_can_add_new_runtime() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("runtimes");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("aider.yaml"),
"binary: aider\nsupports_mcp: false\n",
)
.unwrap();
let m = load_all(tmp.path()).unwrap();
assert_eq!(m["aider"].binary, "aider");
assert!(m.contains_key("claude-code"));
}
}