1use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13
14const EMBEDDED: &[(&str, &str)] = &[
17 ("claude-code", include_str!("../runtimes/claude-code.yaml")),
18 ("codex", include_str!("../runtimes/codex.yaml")),
19 ("opencode", include_str!("../runtimes/opencode.yaml")),
20 ("gemini", include_str!("../runtimes/gemini.yaml")),
21];
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Runtime {
25 pub binary: String,
27 #[serde(default)]
28 pub supports_mcp: bool,
29 #[serde(default)]
40 pub session_resume: Option<String>,
41 #[serde(default)]
42 pub default_model: Option<String>,
43 #[serde(default)]
44 pub env: BTreeMap<String, String>,
45
46 #[serde(default)]
49 pub rate_limit_patterns: Vec<RateLimitPattern>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct RateLimitPattern {
57 pub r#match: String,
59 #[serde(default)]
62 pub resets_at_capture: Option<String>,
63 #[serde(default)]
66 pub resets_in_capture: Option<String>,
67}
68
69pub fn embedded_defaults() -> Result<BTreeMap<String, Runtime>> {
72 EMBEDDED
73 .iter()
74 .map(|(stem, src)| {
75 let r: Runtime = serde_yaml::from_str(src)
76 .with_context(|| format!("parse embedded runtime `{stem}`"))?;
77 Ok(((*stem).to_string(), r))
78 })
79 .collect()
80}
81
82pub fn load_all(root: &Path) -> Result<BTreeMap<String, Runtime>> {
89 let mut map = embedded_defaults()?;
90 let dir = root.join("runtimes");
91 if !dir.exists() {
92 return Ok(map);
93 }
94 for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
95 let entry = entry?;
96 let path: PathBuf = entry.path();
97 if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
98 continue;
99 }
100 let stem = path
101 .file_stem()
102 .and_then(|s| s.to_str())
103 .unwrap_or_default()
104 .to_string();
105 let content =
106 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
107 let r: Runtime =
108 serde_yaml::from_str(&content).with_context(|| format!("parse {}", path.display()))?;
109 map.insert(stem, r);
110 }
111 Ok(map)
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn embedded_defaults_parse() {
120 let m = embedded_defaults().unwrap();
121 assert!(m.contains_key("claude-code"));
122 assert!(m.contains_key("codex"));
123 assert!(m.contains_key("opencode"));
124 assert!(m.contains_key("gemini"));
125 assert_eq!(m["claude-code"].binary, "claude");
126 assert!(m["claude-code"].supports_mcp);
127 assert_eq!(m["opencode"].binary, "opencode");
132 assert!(m["opencode"].supports_mcp);
133 assert_eq!(m["opencode"].session_resume.as_deref(), Some("continue"));
134 assert!(m["opencode"].default_model.is_none());
135 }
136
137 #[test]
138 fn load_nonexistent_returns_embedded_defaults() {
139 let tmp = tempfile::tempdir().unwrap();
140 let m = load_all(tmp.path()).unwrap();
141 assert!(m.contains_key("claude-code"));
143 assert!(m.contains_key("codex"));
144 assert!(m.contains_key("opencode"));
145 assert!(m.contains_key("gemini"));
146 }
147
148 #[test]
149 fn user_file_overrides_embedded_default() {
150 let tmp = tempfile::tempdir().unwrap();
151 let dir = tmp.path().join("runtimes");
152 std::fs::create_dir_all(&dir).unwrap();
153 std::fs::write(
154 dir.join("claude-code.yaml"),
155 "binary: my-claude-fork\nsupports_mcp: false\n",
156 )
157 .unwrap();
158 let m = load_all(tmp.path()).unwrap();
159 assert_eq!(m["claude-code"].binary, "my-claude-fork");
160 assert!(!m["claude-code"].supports_mcp);
161 assert_eq!(m["codex"].binary, "codex");
163 }
164
165 #[test]
166 fn user_file_can_add_new_runtime() {
167 let tmp = tempfile::tempdir().unwrap();
168 let dir = tmp.path().join("runtimes");
169 std::fs::create_dir_all(&dir).unwrap();
170 std::fs::write(
171 dir.join("aider.yaml"),
172 "binary: aider\nsupports_mcp: false\n",
173 )
174 .unwrap();
175 let m = load_all(tmp.path()).unwrap();
176 assert_eq!(m["aider"].binary, "aider");
177 assert!(m.contains_key("claude-code"));
179 }
180}