Skip to main content

team_core/
runtimes.rs

1//! Runtime adapter descriptors (`runtimes/*.yaml`).
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Runtime {
11    /// Path / name of the CLI binary (resolved on $PATH by the wrapper).
12    pub binary: String,
13    #[serde(default)]
14    pub supports_mcp: bool,
15    #[serde(default)]
16    pub session_resume: Option<String>,
17    #[serde(default)]
18    pub default_model: Option<String>,
19    #[serde(default)]
20    pub env: BTreeMap<String, String>,
21
22    /// Patterns that, if matched in the runtime's stdout/stderr, indicate a
23    /// rate-limit hit. `teamctl rl-watch` consumes these.
24    #[serde(default)]
25    pub rate_limit_patterns: Vec<RateLimitPattern>,
26}
27
28/// One rate-limit detector. `match` is a regex tested against each line
29/// of runtime output. If matched, the wrapper records a hit. The optional
30/// captures attempt to extract when the limit lifts.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct RateLimitPattern {
33    /// Regex tested against each output line.
34    pub r#match: String,
35    /// Optional regex with one capture group of an absolute reset clock,
36    /// e.g. "resets at (4pm)" or "resets at (16:00)" or an RFC3339 timestamp.
37    #[serde(default)]
38    pub resets_at_capture: Option<String>,
39    /// Optional regex with one capture group of a relative duration,
40    /// e.g. "in (5h 15m)" or "in (1h)" or "(\\d+) seconds".
41    #[serde(default)]
42    pub resets_in_capture: Option<String>,
43}
44
45/// Load every `runtimes/<name>.yaml` under the compose root into a map keyed
46/// by the file stem (so `claude-code.yaml` → key `"claude-code"`).
47pub fn load_all(root: &Path) -> Result<BTreeMap<String, Runtime>> {
48    let dir = root.join("runtimes");
49    let mut map = BTreeMap::new();
50    if !dir.exists() {
51        return Ok(map);
52    }
53    for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
54        let entry = entry?;
55        let path: PathBuf = entry.path();
56        if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
57            continue;
58        }
59        let stem = path
60            .file_stem()
61            .and_then(|s| s.to_str())
62            .unwrap_or_default()
63            .to_string();
64        let content =
65            std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
66        let r: Runtime =
67            serde_yaml::from_str(&content).with_context(|| format!("parse {}", path.display()))?;
68        map.insert(stem, r);
69    }
70    Ok(map)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn load_nonexistent_returns_empty() {
79        let tmp = tempfile::tempdir().unwrap();
80        let m = load_all(tmp.path()).unwrap();
81        assert!(m.is_empty());
82    }
83
84    #[test]
85    fn load_parses_runtimes() {
86        let tmp = tempfile::tempdir().unwrap();
87        let dir = tmp.path().join("runtimes");
88        std::fs::create_dir_all(&dir).unwrap();
89        std::fs::write(
90            dir.join("claude-code.yaml"),
91            "binary: claude\nsupports_mcp: true\ndefault_model: claude-opus-4-7\n",
92        )
93        .unwrap();
94        std::fs::write(
95            dir.join("codex.yaml"),
96            "binary: codex\nsupports_mcp: true\n",
97        )
98        .unwrap();
99        let m = load_all(tmp.path()).unwrap();
100        assert_eq!(m.len(), 2);
101        assert_eq!(m["claude-code"].binary, "claude");
102        assert!(m["claude-code"].supports_mcp);
103        assert_eq!(m["codex"].binary, "codex");
104    }
105}