1use crate::error::{Error, Result};
9use crate::features::{Features, TaskKind};
10use serde::{Deserialize, Serialize};
11use std::time::Duration;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Mode {
17 Enforce,
19 Observe,
22}
23
24#[derive(Debug, Clone, Default, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct Config {
28 #[serde(rename = "route", default)]
30 pub routes: Vec<Route>,
31 #[serde(default)]
33 pub budget: Budget,
34 #[serde(default)]
36 pub escalation: Escalation,
37 #[serde(rename = "gate", default)]
40 pub gate_defs: Vec<GateDef>,
41}
42
43#[derive(Debug, Clone, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct GateDef {
50 pub id: String,
52 #[serde(default)]
55 pub cmd: Vec<String>,
56 #[serde(default = "default_gate_timeout_ms")]
59 pub timeout_ms: u64,
60 #[serde(default)]
62 pub judge: Option<JudgeDef>,
63}
64
65#[derive(Debug, Clone, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct JudgeDef {
71 pub model: String,
73 #[serde(default = "default_judge_threshold")]
75 pub threshold: f64,
76 #[serde(default)]
78 pub rubric: Option<String>,
79}
80
81fn default_gate_timeout_ms() -> u64 {
84 30_000
85}
86
87fn default_judge_threshold() -> f64 {
89 0.7
90}
91
92#[derive(Debug, Clone, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct Route {
96 #[serde(rename = "match", default)]
98 pub match_: Match,
99 pub mode: Mode,
101 #[serde(default)]
103 pub ladder: Vec<String>,
104 #[serde(default)]
106 pub gates: Vec<String>,
107 #[serde(default)]
110 pub deferred_gates: Vec<String>,
111}
112
113#[derive(Debug, Clone, Default, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct Match {
118 #[serde(default)]
120 pub agent: Option<String>,
121 #[serde(default)]
123 pub subagent: Option<StringOrVec>,
124 #[serde(default)]
126 pub task_kind: Option<TaskKind>,
127 #[serde(default)]
129 pub language: Option<StringOrVec>,
130}
131
132impl Match {
133 #[must_use]
135 pub fn matches(&self, f: &Features) -> bool {
136 if let Some(agent) = &self.agent
137 && f.agent.as_deref() != Some(agent.as_str())
138 {
139 return false;
140 }
141 if let Some(subs) = &self.subagent {
142 match &f.subagent {
143 Some(s) if subs.contains(s) => {}
144 _ => return false,
145 }
146 }
147 if let Some(tk) = self.task_kind
148 && f.task_kind != tk
149 {
150 return false;
151 }
152 if let Some(langs) = &self.language {
153 match &f.language {
154 Some(l) if langs.contains(l) => {}
155 _ => return false,
156 }
157 }
158 true
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(untagged)]
165pub enum StringOrVec {
166 One(String),
168 Many(Vec<String>),
170}
171
172impl StringOrVec {
173 #[must_use]
175 pub fn contains(&self, needle: &str) -> bool {
176 match self {
177 StringOrVec::One(s) => s == needle,
178 StringOrVec::Many(v) => v.iter().any(|s| s == needle),
179 }
180 }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum OnExhausted {
187 #[default]
189 ServeBestAttempt,
190 Error,
192}
193
194#[derive(Debug, Clone, Default, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct Budget {
198 #[serde(default)]
200 pub per_request_usd: Option<f64>,
201 #[serde(default)]
203 pub per_session_usd: Option<f64>,
204 #[serde(default)]
206 pub per_day_usd: Option<f64>,
207 #[serde(default)]
209 pub on_exhausted: OnExhausted,
210}
211
212#[derive(Debug, Clone, Deserialize)]
214#[serde(deny_unknown_fields)]
215pub struct Escalation {
216 #[serde(default = "default_max_rungs")]
218 pub max_rungs_per_request: u32,
219 #[serde(default)]
221 pub session_promotion: Option<SessionPromotion>,
222 #[serde(default)]
225 pub speculation: u32,
226 #[serde(default)]
230 pub serve_threshold: Option<f64>,
231}
232
233const fn default_max_rungs() -> u32 {
234 3
235}
236
237impl Default for Escalation {
238 fn default() -> Self {
239 Self {
240 max_rungs_per_request: default_max_rungs(),
241 session_promotion: None,
242 speculation: 0,
243 serve_threshold: None,
244 }
245 }
246}
247
248#[derive(Debug, Clone, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct SessionPromotion {
252 pub after_failures: u32,
254 pub window: String,
256}
257
258impl SessionPromotion {
259 pub fn window_duration(&self) -> Result<Duration> {
264 parse_window(&self.window)
265 }
266}
267
268fn parse_window(s: &str) -> Result<Duration> {
270 let s = s.trim();
271 let split = s
272 .find(|c: char| c.is_ascii_alphabetic())
273 .ok_or_else(|| Error::BadDuration(s.to_owned()))?;
274 let (num, unit) = s.split_at(split);
275 let n: u64 = num
276 .trim()
277 .parse()
278 .map_err(|_| Error::BadDuration(s.to_owned()))?;
279 let secs = match unit {
280 "s" => n,
281 "m" => n.saturating_mul(60),
282 "h" => n.saturating_mul(3600),
283 "d" => n.saturating_mul(86_400),
284 _ => return Err(Error::BadDuration(s.to_owned())),
285 };
286 Ok(Duration::from_secs(secs))
287}
288
289#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct ModelRef {
292 pub provider: String,
294 pub model: String,
296}
297
298impl ModelRef {
299 pub fn parse(s: &str) -> Result<Self> {
304 match s.split_once('/') {
305 Some((p, m)) if !p.is_empty() && !m.is_empty() && !m.contains('/') => Ok(Self {
306 provider: p.to_owned(),
307 model: m.to_owned(),
308 }),
309 _ => Err(Error::BadModelRef(s.to_owned())),
310 }
311 }
312}
313
314impl Config {
315 pub fn parse(toml_str: &str) -> Result<Self> {
322 let config: Self = toml::from_str(toml_str)?;
323 let mut seen = std::collections::HashSet::new();
324 for def in &config.gate_defs {
325 if def.id.trim().is_empty() {
326 return Err(Error::InvalidConfig("gate id must not be empty".to_owned()));
327 }
328 if def.cmd.is_empty() == def.judge.is_none() {
330 return Err(Error::InvalidConfig(format!(
331 "gate {:?} must set exactly one of `cmd` or `judge`",
332 def.id
333 )));
334 }
335 if let Some(judge) = &def.judge
336 && !(0.0..=1.0).contains(&judge.threshold)
337 {
338 return Err(Error::InvalidConfig(format!(
339 "gate {:?} judge threshold {} is outside [0, 1]",
340 def.id, judge.threshold
341 )));
342 }
343 if !seen.insert(def.id.as_str()) {
344 return Err(Error::InvalidConfig(format!(
345 "duplicate gate id {:?}",
346 def.id
347 )));
348 }
349 }
350 Ok(config)
351 }
352
353 #[must_use]
355 pub fn route_for(&self, f: &Features) -> Option<&Route> {
356 self.routes.iter().find(|r| r.match_.matches(f))
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::features::Features;
364
365 const SPEC_CONFIG: &str = r#"
366[[route]]
367match = { agent = "claude-code", subagent = ["test-runner", "explore"] }
368mode = "enforce"
369ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
370gates = ["schema", "judge-diff"]
371
372[[route]]
373match = { task_kind = "code_edit" }
374mode = "enforce"
375ladder = ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"]
376gates = ["patch-applies", "lint-diff", "judge-diff"]
377deferred_gates = ["compiles", "tests"]
378
379[[route]]
380match = {}
381mode = "observe"
382ladder = ["anthropic/claude-opus-4-8"]
383
384[budget]
385per_request_usd = 0.50
386per_session_usd = 10.00
387per_day_usd = 250.00
388on_exhausted = "serve_best_attempt"
389
390[escalation]
391max_rungs_per_request = 3
392session_promotion = { after_failures = 3, window = "30m" }
393"#;
394
395 #[test]
396 fn parses_the_spec_example() {
397 let c = Config::parse(SPEC_CONFIG).unwrap();
398 assert_eq!(c.routes.len(), 3);
399 assert_eq!(c.routes[0].mode, Mode::Enforce);
400 assert_eq!(
401 c.routes[0].ladder,
402 ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
403 );
404 assert_eq!(c.routes[1].deferred_gates, ["compiles", "tests"]);
405 assert_eq!(c.budget.per_request_usd, Some(0.50));
406 assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
407 assert_eq!(c.escalation.max_rungs_per_request, 3);
408 let sp = c.escalation.session_promotion.as_ref().unwrap();
409 assert_eq!(sp.after_failures, 3);
410 assert_eq!(sp.window_duration().unwrap(), Duration::from_secs(1800));
411 }
412
413 #[test]
414 fn first_matching_route_wins() {
415 let c = Config::parse(SPEC_CONFIG).unwrap();
416
417 let mut f = Features::new(TaskKind::Explore);
419 f.agent = Some("claude-code".into());
420 f.subagent = Some("test-runner".into());
421 let r = c.route_for(&f).unwrap();
422 assert_eq!(r.ladder[0], "anthropic/claude-haiku-4-5");
423
424 let f2 = Features::new(TaskKind::CodeEdit);
426 let r2 = c.route_for(&f2).unwrap();
427 assert_eq!(r2.gates, ["patch-applies", "lint-diff", "judge-diff"]);
428
429 let f3 = Features::new(TaskKind::Chat);
431 let r3 = c.route_for(&f3).unwrap();
432 assert_eq!(r3.mode, Mode::Observe);
433 }
434
435 #[test]
436 fn shipped_example_config_parses() {
437 let toml = include_str!("../../../firstpass.example.toml");
440 let c = Config::parse(toml).expect("firstpass.example.toml must parse");
441 assert_eq!(c.routes.len(), 3);
442 assert_eq!(c.routes[0].mode, Mode::Enforce);
443 }
444
445 #[test]
446 fn parses_gate_definitions() {
447 let toml = r#"
448[[route]]
449match = {}
450mode = "enforce"
451ladder = ["anthropic/claude-haiku-4-5"]
452gates = ["my-tests"]
453
454[[gate]]
455id = "my-tests"
456cmd = ["pytest", "-q"]
457
458[[gate]]
459id = "judge"
460cmd = ["bash", "-c", "./judge.sh"]
461timeout_ms = 60000
462"#;
463 let c = Config::parse(toml).unwrap();
464 assert_eq!(c.gate_defs.len(), 2);
465 assert_eq!(c.gate_defs[0].id, "my-tests");
466 assert_eq!(c.gate_defs[0].cmd, ["pytest", "-q"]);
467 assert_eq!(c.gate_defs[0].timeout_ms, 30_000, "default timeout applies");
468 assert_eq!(c.gate_defs[1].timeout_ms, 60_000);
469 }
470
471 #[test]
472 fn rejects_invalid_gate_definitions() {
473 let empty_cmd = "[[gate]]\nid = \"g\"\ncmd = []\n";
474 assert!(matches!(
475 Config::parse(empty_cmd),
476 Err(Error::InvalidConfig(_))
477 ));
478
479 let dup = "[[gate]]\nid = \"g\"\ncmd = [\"a\"]\n[[gate]]\nid = \"g\"\ncmd = [\"b\"]\n";
480 assert!(matches!(Config::parse(dup), Err(Error::InvalidConfig(_))));
481 }
482
483 #[test]
484 fn empty_match_is_wildcard() {
485 let m = Match::default();
486 assert!(m.matches(&Features::new(TaskKind::Other)));
487 }
488
489 #[test]
490 fn subagent_list_membership() {
491 let c = Config::parse(SPEC_CONFIG).unwrap();
492 let route0 = &c.routes[0];
493 let mut f = Features::new(TaskKind::Other);
494 f.agent = Some("claude-code".into());
495 f.subagent = Some("docs-writer".into()); assert!(!route0.match_.matches(&f));
497 f.subagent = Some("explore".into());
498 assert!(route0.match_.matches(&f));
499 }
500
501 #[test]
502 fn model_ref_parsing() {
503 let m = ModelRef::parse("anthropic/claude-haiku-4-5").unwrap();
504 assert_eq!(m.provider, "anthropic");
505 assert_eq!(m.model, "claude-haiku-4-5");
506 assert!(ModelRef::parse("no-slash").is_err());
507 assert!(ModelRef::parse("/model").is_err());
508 assert!(ModelRef::parse("a/b/c").is_err());
509 }
510
511 #[test]
512 fn window_parsing_units_and_errors() {
513 assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90));
514 assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1800));
515 assert_eq!(parse_window("2h").unwrap(), Duration::from_secs(7200));
516 assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400));
517 assert!(parse_window("30x").is_err());
518 assert!(parse_window("abc").is_err());
519 }
520
521 #[test]
522 fn empty_config_defaults() {
523 let c = Config::parse("").unwrap();
524 assert!(c.routes.is_empty());
525 assert_eq!(c.escalation.max_rungs_per_request, 3);
526 assert_eq!(c.budget.on_exhausted, OnExhausted::ServeBestAttempt);
527 }
528}