use std::time::Duration;
use serde::Deserialize;
pub const FILE: &str = "speccoder.toml";
const DEFAULT_TIMEOUT_SECS: u64 = 600;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Tier {
Fast,
Full,
}
impl Tier {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Fast => "fast",
Self::Full => "full",
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCheck {
argv: Vec<String>,
cwd: Option<String>,
when_exists: Option<String>,
tier: Option<Tier>,
timeout_secs: Option<u64>,
junit: Option<String>,
list: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Check {
pub name: String,
pub argv: Vec<String>,
pub cwd: String,
pub when_exists: Option<String>,
pub tier: Tier,
pub timeout: Duration,
pub junit: Option<String>,
pub list: Option<Vec<String>>,
}
pub const SECTIONS: &[&str] = &[
"checks",
"model",
"budget",
"commands",
"git",
"acceptance",
"tests",
"monitor",
"converge",
"ask",
"ladder",
"review",
"format",
"sandbox",
"mutation",
"executor",
"mcp",
"spec",
"approvals",
"attest",
"context",
"bug",
"worktree",
];
pub fn parse(text: &str) -> Result<Vec<Check>, String> {
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
if table.contains_key("ui") {
return Err(format!(
"[ui] is a person's setting, not project policy; move it to {}",
crate::user_config::path().map_or_else(
|| "~/.config/speccoder/config.toml".to_owned(),
|p| p.display().to_string()
)
));
}
if let Some(unknown) = table.keys().find(|k| !SECTIONS.contains(&k.as_str())) {
return Err(format!(
"unknown section `[{unknown}]` (known: {})",
SECTIONS.join(", ")
));
}
let Some(checks) = table.get("checks") else {
return Ok(Vec::new());
};
let checks = checks
.as_table()
.ok_or_else(|| "`checks` must be a table of [checks.<name>] tables".to_owned())?;
checks
.iter()
.map(|(name, value)| {
let raw: RawCheck = value
.clone()
.try_into()
.map_err(|e| format!("[checks.{name}]: {e}"))?;
if raw.argv.is_empty() || raw.argv[0].is_empty() {
return Err(format!("[checks.{name}]: `argv` must name a program"));
}
Ok(Check {
name: name.clone(),
argv: raw.argv,
cwd: raw.cwd.unwrap_or_else(|| ".".to_owned()),
when_exists: raw.when_exists,
tier: raw.tier.unwrap_or(Tier::Fast),
timeout: Duration::from_secs(raw.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)),
junit: raw.junit,
list: raw
.list
.filter(|l| l.first().is_some_and(|p| !p.is_empty())),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_misspelled_section_is_an_error_not_ignored() {
let e = parse("[checks.t]\nargv = [\"true\"]\n[budgett]\nmax_turns = 3\n").unwrap_err();
assert!(e.contains("unknown section `[budgett]`"), "{e}");
assert!(parse("[checks.t]\nargv = [\"true\"]\n[sandbox]\nmode = \"off\"\n").is_ok());
}
#[test]
fn parses_checks_in_file_order_with_defaults() {
let checks = parse(
r#"
[model]
provider = "anthropic"
[checks.test]
argv = ["cargo", "nextest", "run"]
junit = "target/nextest/speccoder/junit.xml"
when_exists = "Cargo.toml"
[checks.clippy]
argv = ["cargo", "clippy"]
tier = "full"
timeout_secs = 30
"#,
)
.unwrap();
let names: Vec<_> = checks.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["test", "clippy"]);
assert_eq!(checks[0].tier, Tier::Fast);
assert_eq!(checks[0].cwd, ".");
assert_eq!(checks[0].timeout, Duration::from_secs(600));
assert_eq!(checks[1].tier, Tier::Full);
assert_eq!(checks[1].timeout, Duration::from_secs(30));
assert_eq!(checks[0].list, None);
let listed = parse("[checks.t]\nargv = [\"x\"]\nlist = [\"x\", \"--list\"]\n").unwrap();
assert_eq!(
listed[0].list.as_deref(),
Some(&["x".to_owned(), "--list".to_owned()][..])
);
}
#[test]
fn rejects_unknown_keys_and_empty_argv() {
let e = parse("[checks.t]\nargv = [\"x\"]\nshell = true\n").unwrap_err();
assert!(e.contains("[checks.t]") && e.contains("shell"), "{e}");
assert!(
parse("[checks.t]\nargv = []\n")
.unwrap_err()
.contains("argv")
);
}
}