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 ("gemini", include_str!("../runtimes/gemini.yaml")),
20];
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Runtime {
24 pub binary: String,
26 #[serde(default)]
27 pub supports_mcp: bool,
28 #[serde(default)]
36 pub session_resume: Option<String>,
37 #[serde(default)]
38 pub default_model: Option<String>,
39 #[serde(default)]
40 pub env: BTreeMap<String, String>,
41
42 #[serde(default)]
45 pub rate_limit_patterns: Vec<RateLimitPattern>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct RateLimitPattern {
53 pub r#match: String,
55 #[serde(default)]
58 pub resets_at_capture: Option<String>,
59 #[serde(default)]
62 pub resets_in_capture: Option<String>,
63}
64
65pub fn embedded_defaults() -> Result<BTreeMap<String, Runtime>> {
68 EMBEDDED
69 .iter()
70 .map(|(stem, src)| {
71 let r: Runtime = serde_yaml::from_str(src)
72 .with_context(|| format!("parse embedded runtime `{stem}`"))?;
73 Ok(((*stem).to_string(), r))
74 })
75 .collect()
76}
77
78pub fn load_all(root: &Path) -> Result<BTreeMap<String, Runtime>> {
85 let mut map = embedded_defaults()?;
86 let dir = root.join("runtimes");
87 if !dir.exists() {
88 return Ok(map);
89 }
90 for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
91 let entry = entry?;
92 let path: PathBuf = entry.path();
93 if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
94 continue;
95 }
96 let stem = path
97 .file_stem()
98 .and_then(|s| s.to_str())
99 .unwrap_or_default()
100 .to_string();
101 let content =
102 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
103 let r: Runtime =
104 serde_yaml::from_str(&content).with_context(|| format!("parse {}", path.display()))?;
105 map.insert(stem, r);
106 }
107 Ok(map)
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn embedded_defaults_parse() {
116 let m = embedded_defaults().unwrap();
117 assert!(m.contains_key("claude-code"));
118 assert!(m.contains_key("codex"));
119 assert!(m.contains_key("gemini"));
120 assert_eq!(m["claude-code"].binary, "claude");
121 assert!(m["claude-code"].supports_mcp);
122 }
123
124 #[test]
125 fn load_nonexistent_returns_embedded_defaults() {
126 let tmp = tempfile::tempdir().unwrap();
127 let m = load_all(tmp.path()).unwrap();
128 assert!(m.contains_key("claude-code"));
130 assert!(m.contains_key("codex"));
131 assert!(m.contains_key("gemini"));
132 }
133
134 #[test]
135 fn user_file_overrides_embedded_default() {
136 let tmp = tempfile::tempdir().unwrap();
137 let dir = tmp.path().join("runtimes");
138 std::fs::create_dir_all(&dir).unwrap();
139 std::fs::write(
140 dir.join("claude-code.yaml"),
141 "binary: my-claude-fork\nsupports_mcp: false\n",
142 )
143 .unwrap();
144 let m = load_all(tmp.path()).unwrap();
145 assert_eq!(m["claude-code"].binary, "my-claude-fork");
146 assert!(!m["claude-code"].supports_mcp);
147 assert_eq!(m["codex"].binary, "codex");
149 }
150
151 #[test]
152 fn user_file_can_add_new_runtime() {
153 let tmp = tempfile::tempdir().unwrap();
154 let dir = tmp.path().join("runtimes");
155 std::fs::create_dir_all(&dir).unwrap();
156 std::fs::write(
157 dir.join("aider.yaml"),
158 "binary: aider\nsupports_mcp: false\n",
159 )
160 .unwrap();
161 let m = load_all(tmp.path()).unwrap();
162 assert_eq!(m["aider"].binary, "aider");
163 assert!(m.contains_key("claude-code"));
165 }
166}