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