use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use crate::error::{KlaspError, Result};
use crate::trigger_config::{validate_user_triggers, UserTrigger, UserTriggerConfig};
use crate::verdict::VerdictPolicy;
pub const CONFIG_VERSION: u32 = 1;
pub const CLAUDE_PROJECT_DIR_ENV: &str = "CLAUDE_PROJECT_DIR";
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigV1 {
pub version: u32,
pub gate: GateConfig,
#[serde(default)]
pub checks: Vec<CheckConfig>,
#[serde(default, rename = "trigger")]
pub triggers: Vec<UserTriggerConfig>,
#[serde(skip)]
compiled: OnceLock<Vec<UserTrigger>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct GateConfig {
#[serde(default)]
pub agents: Vec<String>,
#[serde(default)]
pub policy: VerdictPolicy,
#[serde(default)]
pub parallel: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CheckConfig {
pub name: String,
#[serde(default)]
pub triggers: Vec<TriggerConfig>,
pub source: CheckSourceConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct TriggerConfig {
pub on: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum CheckSourceConfig {
Shell {
command: String,
},
Plugin {
name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
settings: Option<serde_json::Value>,
},
PreCommit {
#[serde(default, skip_serializing_if = "Option::is_none")]
hook_stage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
config_path: Option<PathBuf>,
},
Fallow {
#[serde(default, skip_serializing_if = "Option::is_none")]
config_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
base: Option<String>,
},
Pytest {
#[serde(default, skip_serializing_if = "Option::is_none")]
extra_args: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
config_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
junit_xml: Option<bool>,
},
Cargo {
subcommand: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
extra_args: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
package: Option<String>,
},
}
pub fn discover_config_for_path(start: &Path, repo_root: &Path) -> Option<PathBuf> {
let root = repo_root.canonicalize().ok()?;
let start_dir = if start.is_file() {
start.parent().map(Path::to_path_buf)?
} else {
start.to_path_buf()
};
let start_canon = start_dir.canonicalize().ok()?;
if !start_canon.starts_with(&root) {
return None;
}
let mut current = start_canon;
loop {
let candidate = current.join("klasp.toml");
if candidate.is_file() {
return Some(candidate);
}
if current == root {
break;
}
match current.parent() {
Some(p) => current = p.to_path_buf(),
None => break,
}
}
None
}
pub fn load_config_for_path(start: &Path, repo_root: &Path) -> Option<Result<(PathBuf, ConfigV1)>> {
let config_path = discover_config_for_path(start, repo_root)?;
Some(ConfigV1::from_file(&config_path).map(|cfg| (config_path, cfg)))
}
fn cwd_inside(root: &Path) -> bool {
let cwd = match std::env::current_dir().and_then(|c| c.canonicalize()) {
Ok(c) => c,
Err(_) => return false,
};
let root = match root.canonicalize() {
Ok(r) => r,
Err(_) => return false,
};
cwd.starts_with(root)
}
impl ConfigV1 {
pub fn load(repo_root: &Path) -> Result<Self> {
let mut searched = Vec::new();
if let Ok(claude_dir) = std::env::var(CLAUDE_PROJECT_DIR_ENV) {
let env_root = PathBuf::from(claude_dir);
let candidate = env_root.join("klasp.toml");
match (candidate.is_file(), cwd_inside(&env_root)) {
(true, true) => return Self::from_file(&candidate),
(true, false) => {}
(false, _) => searched.push(candidate),
}
}
let candidate = repo_root.join("klasp.toml");
if candidate.is_file() {
return Self::from_file(&candidate);
}
searched.push(candidate);
Err(KlaspError::ConfigNotFound { searched })
}
pub fn from_file(path: &Path) -> Result<Self> {
let bytes = std::fs::read_to_string(path).map_err(|source| KlaspError::Io {
path: path.to_path_buf(),
source,
})?;
Self::parse(&bytes)
}
pub fn parse(s: &str) -> Result<Self> {
let config: ConfigV1 = toml::from_str(s)?;
if config.version != CONFIG_VERSION {
return Err(KlaspError::ConfigVersion {
found: config.version,
supported: CONFIG_VERSION,
});
}
let compiled = validate_user_triggers(&config.triggers)?;
let _ = config.compiled.set(compiled);
Ok(config)
}
pub fn compiled_triggers(&self) -> &[UserTrigger] {
self.compiled.get_or_init(|| {
validate_user_triggers(&self.triggers)
.expect("triggers already validated at parse time")
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAL_TOML: &str = r#"
version = 1
[gate]
agents = ["claude_code"]
"#;
fn write_klasp_toml(dir: &std::path::Path) {
std::fs::write(dir.join("klasp.toml"), MINIMAL_TOML).expect("write klasp.toml");
}
#[test]
fn load_cwd_guard_cases() {
struct Guard {
cwd: std::path::PathBuf,
env: Option<String>,
}
impl Drop for Guard {
fn drop(&mut self) {
match &self.env {
Some(v) => std::env::set_var(CLAUDE_PROJECT_DIR_ENV, v),
None => std::env::remove_var(CLAUDE_PROJECT_DIR_ENV),
}
let _ = std::env::set_current_dir(&self.cwd);
}
}
let _guard = Guard {
cwd: std::env::current_dir().expect("current_dir"),
env: std::env::var(CLAUDE_PROJECT_DIR_ENV).ok(),
};
{
let env_root = tempfile::tempdir().expect("tempdir env_root");
let sub = env_root.path().join("sub");
std::fs::create_dir_all(&sub).expect("mkdir sub");
write_klasp_toml(env_root.path());
std::env::set_var(CLAUDE_PROJECT_DIR_ENV, env_root.path());
std::env::set_current_dir(&sub).expect("cd sub");
let cfg = ConfigV1::load(env_root.path()).expect("case 1: should load");
assert_eq!(cfg.version, 1, "case 1: version mismatch");
}
{
let env_root = tempfile::tempdir().expect("tempdir env_root");
let cwd_root = tempfile::tempdir().expect("tempdir cwd_root");
write_klasp_toml(env_root.path());
write_klasp_toml(cwd_root.path());
std::env::set_var(CLAUDE_PROJECT_DIR_ENV, env_root.path());
std::env::set_current_dir(cwd_root.path()).expect("cd cwd_root");
let cfg = ConfigV1::load(cwd_root.path()).expect("case 2: should load");
assert_eq!(cfg.version, 1, "case 2: version mismatch");
}
{
let env_root = tempfile::tempdir().expect("tempdir env_root");
let cwd_root = tempfile::tempdir().expect("tempdir cwd_root");
write_klasp_toml(env_root.path());
std::env::set_var(CLAUDE_PROJECT_DIR_ENV, env_root.path());
std::env::set_current_dir(cwd_root.path()).expect("cd cwd_root");
let err =
ConfigV1::load(cwd_root.path()).expect_err("case 3: should be ConfigNotFound");
assert!(
matches!(err, KlaspError::ConfigNotFound { .. }),
"case 3: expected ConfigNotFound, got {err:?}"
);
}
{
let cwd_root = tempfile::tempdir().expect("tempdir cwd_root");
write_klasp_toml(cwd_root.path());
let bogus = cwd_root.path().join("does-not-exist");
std::env::set_var(CLAUDE_PROJECT_DIR_ENV, &bogus);
std::env::set_current_dir(cwd_root.path()).expect("cd cwd_root");
let cfg = ConfigV1::load(cwd_root.path()).expect("case 4: should load cwd candidate");
assert_eq!(cfg.version, 1, "case 4: version mismatch");
}
{
let cwd_root = tempfile::tempdir().expect("tempdir cwd_root");
write_klasp_toml(cwd_root.path());
std::env::remove_var(CLAUDE_PROJECT_DIR_ENV);
std::env::set_current_dir(cwd_root.path()).expect("cd cwd_root");
let cfg = ConfigV1::load(cwd_root.path()).expect("case 5: should load cwd candidate");
assert_eq!(cfg.version, 1, "case 5: version mismatch");
}
}
#[test]
fn parses_minimal_config() {
let toml = r#"
version = 1
[gate]
agents = ["claude_code"]
"#;
let config = ConfigV1::parse(toml).expect("should parse");
assert_eq!(config.version, 1);
assert_eq!(config.gate.agents, vec!["claude_code"]);
assert_eq!(config.gate.policy, VerdictPolicy::AnyFail);
assert!(config.checks.is_empty());
}
#[test]
fn parses_full_config() {
let toml = r#"
version = 1
[gate]
agents = ["claude_code"]
policy = "any_fail"
[[checks]]
name = "ruff"
triggers = [{ on = ["commit"] }]
timeout_secs = 60
[checks.source]
type = "shell"
command = "ruff check ."
[[checks]]
name = "pytest"
triggers = [{ on = ["push"] }]
[checks.source]
type = "shell"
command = "pytest -q"
"#;
let config = ConfigV1::parse(toml).expect("should parse");
assert_eq!(config.checks.len(), 2);
assert_eq!(config.checks[0].name, "ruff");
assert_eq!(config.checks[0].timeout_secs, Some(60));
assert!(matches!(
&config.checks[0].source,
CheckSourceConfig::Shell { command } if command == "ruff check ."
));
assert_eq!(config.checks[0].triggers[0].on, vec!["commit"]);
assert!(config.checks[1].timeout_secs.is_none());
}
#[test]
fn rejects_wrong_version() {
let toml = r#"
version = 2
[gate]
"#;
let err = ConfigV1::parse(toml).expect_err("should reject");
match err {
KlaspError::ConfigVersion { found, supported } => {
assert_eq!(found, 2);
assert_eq!(supported, CONFIG_VERSION);
}
other => panic!("expected ConfigVersion, got {other:?}"),
}
}
#[test]
fn rejects_missing_version() {
let toml = r#"
[gate]
agents = []
"#;
let err = ConfigV1::parse(toml).expect_err("should reject");
assert!(matches!(err, KlaspError::ConfigParse(_)));
}
#[test]
fn rejects_missing_gate() {
let toml = "version = 1";
let err = ConfigV1::parse(toml).expect_err("should reject");
assert!(matches!(err, KlaspError::ConfigParse(_)));
}
#[test]
fn rejects_unknown_source_type() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "future-recipe"
[checks.source]
type = "future_recipe_not_yet_landed"
command = "noop"
"#;
let err = ConfigV1::parse(toml).expect_err("should reject");
assert!(matches!(err, KlaspError::ConfigParse(_)));
}
#[test]
fn rejects_unknown_field_on_pre_commit_variant() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "typo-test"
[checks.source]
type = "pre_commit"
hook_stages = "pre-push"
"#;
let err = ConfigV1::parse(toml).expect_err("should reject");
assert!(matches!(err, KlaspError::ConfigParse(_)));
}
#[test]
fn parses_pre_commit_recipe_minimal() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "lint"
[checks.source]
type = "pre_commit"
"#;
let config = ConfigV1::parse(toml).expect("should parse");
assert_eq!(config.checks.len(), 1);
match &config.checks[0].source {
CheckSourceConfig::PreCommit {
hook_stage,
config_path,
} => {
assert!(hook_stage.is_none());
assert!(config_path.is_none());
}
other => panic!("expected PreCommit, got {other:?}"),
}
}
#[test]
fn parses_pre_commit_recipe_with_fields() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "lint"
[checks.source]
type = "pre_commit"
hook_stage = "pre-push"
config_path = "tools/pre-commit.yaml"
"#;
let config = ConfigV1::parse(toml).expect("should parse");
match &config.checks[0].source {
CheckSourceConfig::PreCommit {
hook_stage,
config_path,
} => {
assert_eq!(hook_stage.as_deref(), Some("pre-push"));
assert_eq!(
config_path
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
Some("tools/pre-commit.yaml".to_string())
);
}
other => panic!("expected PreCommit, got {other:?}"),
}
}
#[test]
fn parses_fallow_recipe_minimal() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "audit"
[checks.source]
type = "fallow"
"#;
let config = ConfigV1::parse(toml).expect("should parse");
assert_eq!(config.checks.len(), 1);
match &config.checks[0].source {
CheckSourceConfig::Fallow { config_path, base } => {
assert!(config_path.is_none());
assert!(base.is_none());
}
other => panic!("expected Fallow, got {other:?}"),
}
}
#[test]
fn parses_fallow_recipe_with_fields() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "audit"
[checks.source]
type = "fallow"
config_path = "tools/.fallowrc.json"
base = "origin/main"
"#;
let config = ConfigV1::parse(toml).expect("should parse");
match &config.checks[0].source {
CheckSourceConfig::Fallow { config_path, base } => {
assert_eq!(
config_path
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
Some("tools/.fallowrc.json".to_string())
);
assert_eq!(base.as_deref(), Some("origin/main"));
}
other => panic!("expected Fallow, got {other:?}"),
}
}
#[test]
fn rejects_unknown_field_on_fallow_variant() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "audit"
[checks.source]
type = "fallow"
bases = "main"
"#;
let err = ConfigV1::parse(toml).expect_err("should reject");
assert!(matches!(err, KlaspError::ConfigParse(_)));
}
#[test]
fn parses_pytest_recipe_minimal() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "tests"
[checks.source]
type = "pytest"
"#;
let config = ConfigV1::parse(toml).expect("should parse");
assert_eq!(config.checks.len(), 1);
match &config.checks[0].source {
CheckSourceConfig::Pytest {
extra_args,
config_path,
junit_xml,
} => {
assert!(extra_args.is_none());
assert!(config_path.is_none());
assert!(junit_xml.is_none());
}
other => panic!("expected Pytest, got {other:?}"),
}
}
#[test]
fn parses_pytest_recipe_with_fields() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "tests"
[checks.source]
type = "pytest"
extra_args = "-x -q tests/"
config_path = "pytest.ini"
junit_xml = true
"#;
let config = ConfigV1::parse(toml).expect("should parse");
match &config.checks[0].source {
CheckSourceConfig::Pytest {
extra_args,
config_path,
junit_xml,
} => {
assert_eq!(extra_args.as_deref(), Some("-x -q tests/"));
assert_eq!(
config_path
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
Some("pytest.ini".to_string())
);
assert_eq!(*junit_xml, Some(true));
}
other => panic!("expected Pytest, got {other:?}"),
}
}
#[test]
fn rejects_unknown_field_on_pytest_variant() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "tests"
[checks.source]
type = "pytest"
extra_arg = "-x"
"#;
let err = ConfigV1::parse(toml).expect_err("should reject");
assert!(matches!(err, KlaspError::ConfigParse(_)));
}
#[test]
fn parses_cargo_recipe_minimal() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "build"
[checks.source]
type = "cargo"
subcommand = "check"
"#;
let config = ConfigV1::parse(toml).expect("should parse");
assert_eq!(config.checks.len(), 1);
match &config.checks[0].source {
CheckSourceConfig::Cargo {
subcommand,
extra_args,
package,
} => {
assert_eq!(subcommand, "check");
assert!(extra_args.is_none());
assert!(package.is_none());
}
other => panic!("expected Cargo, got {other:?}"),
}
}
#[test]
fn parses_cargo_recipe_with_fields() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "lint"
[checks.source]
type = "cargo"
subcommand = "clippy"
extra_args = "--all-features -- -D warnings"
package = "klasp-core"
"#;
let config = ConfigV1::parse(toml).expect("should parse");
match &config.checks[0].source {
CheckSourceConfig::Cargo {
subcommand,
extra_args,
package,
} => {
assert_eq!(subcommand, "clippy");
assert_eq!(extra_args.as_deref(), Some("--all-features -- -D warnings"));
assert_eq!(package.as_deref(), Some("klasp-core"));
}
other => panic!("expected Cargo, got {other:?}"),
}
}
#[test]
fn rejects_cargo_recipe_missing_subcommand() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "build"
[checks.source]
type = "cargo"
"#;
let err = ConfigV1::parse(toml).expect_err("should reject");
assert!(matches!(err, KlaspError::ConfigParse(_)));
}
#[test]
fn rejects_unknown_field_on_cargo_variant() {
let toml = r#"
version = 1
[gate]
[[checks]]
name = "build"
[checks.source]
type = "cargo"
subcommand = "check"
packages = "klasp-core"
"#;
let err = ConfigV1::parse(toml).expect_err("should reject");
assert!(matches!(err, KlaspError::ConfigParse(_)));
}
#[test]
fn parallel_field_defaults_to_false_when_omitted() {
let toml = r#"
version = 1
[gate]
agents = ["claude_code"]
"#;
let config = ConfigV1::parse(toml).expect("should parse");
assert!(!config.gate.parallel, "parallel should default to false");
}
#[test]
fn parallel_field_parses_true() {
let toml = r#"
version = 1
[gate]
agents = ["claude_code"]
parallel = true
"#;
let config = ConfigV1::parse(toml).expect("should parse");
assert!(config.gate.parallel, "parallel = true should parse");
}
#[test]
fn parallel_field_parses_explicit_false() {
let toml = r#"
version = 1
[gate]
agents = ["claude_code"]
parallel = false
"#;
let config = ConfigV1::parse(toml).expect("should parse");
assert!(!config.gate.parallel, "parallel = false should parse");
}
#[test]
fn rejects_missing_check_name() {
let toml = r#"
version = 1
[gate]
[[checks]]
[checks.source]
type = "shell"
command = "echo"
"#;
let err = ConfigV1::parse(toml).expect_err("should reject");
assert!(matches!(err, KlaspError::ConfigParse(_)));
}
#[test]
fn discover_returns_none_for_path_outside_repo_root() {
let tmp = tempfile::TempDir::new().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
std::fs::write(repo.join("klasp.toml"), MINIMAL_TOML).unwrap();
let outside = tmp.path().join("other");
std::fs::create_dir_all(&outside).unwrap();
assert!(discover_config_for_path(&outside, &repo).is_none());
}
#[test]
fn discover_finds_config_at_repo_root_for_deep_path() {
let tmp = tempfile::TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let deep = repo.join("a").join("b").join("c");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(repo.join("klasp.toml"), MINIMAL_TOML).unwrap();
let found = discover_config_for_path(&deep, &repo).unwrap();
assert_eq!(found, repo.canonicalize().unwrap().join("klasp.toml"));
}
#[test]
fn discover_prefers_nearest_config_over_root() {
let tmp = tempfile::TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let pkg = repo.join("packages").join("web");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(repo.join("klasp.toml"), MINIMAL_TOML).unwrap();
std::fs::write(pkg.join("klasp.toml"), MINIMAL_TOML).unwrap();
let found = discover_config_for_path(&pkg, &repo).unwrap();
assert_eq!(found, pkg.canonicalize().unwrap().join("klasp.toml"));
}
#[test]
fn discover_starts_from_parent_when_given_a_file() {
let tmp = tempfile::TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let pkg = repo.join("packages").join("web");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(repo.join("klasp.toml"), MINIMAL_TOML).unwrap();
std::fs::write(pkg.join("klasp.toml"), MINIMAL_TOML).unwrap();
std::fs::write(pkg.join("index.ts"), "").unwrap();
let found = discover_config_for_path(&pkg.join("index.ts"), &repo).unwrap();
assert_eq!(found, pkg.canonicalize().unwrap().join("klasp.toml"));
}
#[test]
fn discover_returns_none_when_no_config_in_chain() {
let tmp = tempfile::TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let deep = repo.join("a").join("b");
std::fs::create_dir_all(&deep).unwrap();
assert!(discover_config_for_path(&deep, &repo).is_none());
}
}