Skip to main content

speccoder_runtime/verify/
config.rs

1//! `speccoder.toml`: the checks a project declares.
2
3use std::time::Duration;
4
5use serde::Deserialize;
6
7/// The configuration file, at the project root.
8pub const FILE: &str = "speccoder.toml";
9
10const DEFAULT_TIMEOUT_SECS: u64 = 600;
11
12/// When a check runs.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum Tier {
16    /// Every task.
17    Fast,
18    /// Once after a run ticks the feature's last task, and with `speccoder verify`.
19    Full,
20}
21
22impl Tier {
23    /// Lowercase name.
24    #[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/// One declared check.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Check {
48    /// Table name, e.g. `test` for `[checks.test]`.
49    pub name: String,
50    /// Program and arguments; run without a shell.
51    pub argv: Vec<String>,
52    /// Working directory relative to the root (`.` by default).
53    pub cwd: String,
54    /// Only applicable when this root-relative path exists.
55    pub when_exists: Option<String>,
56    /// `fast` (default) or `full`.
57    pub tier: Tier,
58    /// Deadline for the whole process group.
59    pub timeout: Duration,
60    /// Root-relative JUnit report the check writes.
61    pub junit: Option<String>,
62    /// A command listing the tests the check would run (one test id or
63    /// file path per line), for deciding which test files it collects.
64    pub list: Option<Vec<String>>,
65}
66
67/// The top-level sections of `speccoder.toml`.
68pub 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
95/// Parses the configuration text; checks keep their file order.
96pub fn parse(text: &str) -> Result<Vec<Check>, String> {
97    let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
98    // A misspelled section would otherwise be silently ignored by every
99    // command; the other sections are read (and their keys checked) by the
100    // modules that own them.
101    // A person's UI settings are not the project's policy.
102    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}