speccoder_runtime/verify/
config.rs1use std::time::Duration;
4
5use serde::Deserialize;
6
7pub const FILE: &str = "speccoder.toml";
9
10const DEFAULT_TIMEOUT_SECS: u64 = 600;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum Tier {
16 Fast,
18 Full,
20}
21
22impl Tier {
23 #[must_use]
25 pub const fn as_str(self) -> &'static str {
26 match self {
27 Self::Fast => "fast",
28 Self::Full => "full",
29 }
30 }
31}
32
33#[derive(Debug, Deserialize)]
34#[serde(deny_unknown_fields)]
35struct RawCheck {
36 argv: Vec<String>,
37 cwd: Option<String>,
38 when_exists: Option<String>,
39 tier: Option<Tier>,
40 timeout_secs: Option<u64>,
41 junit: Option<String>,
42 list: Option<Vec<String>>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Check {
48 pub name: String,
50 pub argv: Vec<String>,
52 pub cwd: String,
54 pub when_exists: Option<String>,
56 pub tier: Tier,
58 pub timeout: Duration,
60 pub junit: Option<String>,
62 pub list: Option<Vec<String>>,
65}
66
67pub const SECTIONS: &[&str] = &[
69 "checks",
70 "model",
71 "budget",
72 "commands",
73 "git",
74 "acceptance",
75 "tests",
76 "monitor",
77 "converge",
78 "ask",
79 "ladder",
80 "review",
81 "format",
82 "sandbox",
83 "mutation",
84 "executor",
85 "mcp",
86 "spec",
87 "approvals",
88 "attest",
89 "context",
90 "bug",
91 "worktree",
92 "hooks",
93];
94
95pub fn parse(text: &str) -> Result<Vec<Check>, String> {
97 let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
98 if table.contains_key("ui") {
103 return Err(format!(
104 "[ui] is a person's setting, not project policy; move it to {}",
105 crate::user_config::path().map_or_else(
106 || "~/.config/speccoder/config.toml".to_owned(),
107 |p| p.display().to_string()
108 )
109 ));
110 }
111 if let Some(unknown) = table.keys().find(|k| !SECTIONS.contains(&k.as_str())) {
112 return Err(format!(
113 "unknown section `[{unknown}]` (known: {})",
114 SECTIONS.join(", ")
115 ));
116 }
117 let Some(checks) = table.get("checks") else {
118 return Ok(Vec::new());
119 };
120 let checks = checks
121 .as_table()
122 .ok_or_else(|| "`checks` must be a table of [checks.<name>] tables".to_owned())?;
123 checks
124 .iter()
125 .map(|(name, value)| {
126 let raw: RawCheck = value
127 .clone()
128 .try_into()
129 .map_err(|e| format!("[checks.{name}]: {e}"))?;
130 if raw.argv.is_empty() || raw.argv[0].is_empty() {
131 return Err(format!("[checks.{name}]: `argv` must name a program"));
132 }
133 Ok(Check {
134 name: name.clone(),
135 argv: raw.argv,
136 cwd: raw.cwd.unwrap_or_else(|| ".".to_owned()),
137 when_exists: raw.when_exists,
138 tier: raw.tier.unwrap_or(Tier::Fast),
139 timeout: Duration::from_secs(raw.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)),
140 junit: raw.junit,
141 list: raw
142 .list
143 .filter(|l| l.first().is_some_and(|p| !p.is_empty())),
144 })
145 })
146 .collect()
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn a_misspelled_section_is_an_error_not_ignored() {
155 let e = parse("[checks.t]\nargv = [\"true\"]\n[budgett]\nmax_turns = 3\n").unwrap_err();
156 assert!(e.contains("unknown section `[budgett]`"), "{e}");
157 assert!(parse("[checks.t]\nargv = [\"true\"]\n[sandbox]\nmode = \"off\"\n").is_ok());
158 }
159
160 #[test]
161 fn parses_checks_in_file_order_with_defaults() {
162 let checks = parse(
163 r#"
164[model]
165provider = "anthropic"
166
167[checks.test]
168argv = ["cargo", "nextest", "run"]
169junit = "target/nextest/speccoder/junit.xml"
170when_exists = "Cargo.toml"
171
172[checks.clippy]
173argv = ["cargo", "clippy"]
174tier = "full"
175timeout_secs = 30
176"#,
177 )
178 .unwrap();
179 let names: Vec<_> = checks.iter().map(|c| c.name.as_str()).collect();
180 assert_eq!(names, ["test", "clippy"]);
181 assert_eq!(checks[0].tier, Tier::Fast);
182 assert_eq!(checks[0].cwd, ".");
183 assert_eq!(checks[0].timeout, Duration::from_secs(600));
184 assert_eq!(checks[1].tier, Tier::Full);
185 assert_eq!(checks[1].timeout, Duration::from_secs(30));
186 assert_eq!(checks[0].list, None);
187 let listed = parse("[checks.t]\nargv = [\"x\"]\nlist = [\"x\", \"--list\"]\n").unwrap();
188 assert_eq!(
189 listed[0].list.as_deref(),
190 Some(&["x".to_owned(), "--list".to_owned()][..])
191 );
192 }
193
194 #[test]
195 fn rejects_unknown_keys_and_empty_argv() {
196 let e = parse("[checks.t]\nargv = [\"x\"]\nshell = true\n").unwrap_err();
197 assert!(e.contains("[checks.t]") && e.contains("shell"), "{e}");
198 assert!(
199 parse("[checks.t]\nargv = []\n")
200 .unwrap_err()
201 .contains("argv")
202 );
203 }
204}