Skip to main content

klasp_core/
config.rs

1//! `klasp.toml` config — `version = 1` schema.
2//!
3//! Design: [docs/design.md §3.5]. The `version` field is enforced at parse
4//! time so v2 configs reject loudly with an upgrade message rather than
5//! silently dropping unknown sections. `CheckSourceConfig` is
6//! `#[serde(tag = "type")]`-tagged so unknown source types also fail at
7//! parse time.
8
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{KlaspError, Result};
14use crate::verdict::VerdictPolicy;
15
16/// Config schema version. Bumps only when the TOML syntax breaks; new
17/// optional fields do not bump it.
18pub const CONFIG_VERSION: u32 = 1;
19
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct ConfigV1 {
22    /// Schema version. Must equal [`CONFIG_VERSION`]; mismatches fail with
23    /// [`KlaspError::ConfigVersion`].
24    pub version: u32,
25
26    pub gate: GateConfig,
27
28    #[serde(default)]
29    pub checks: Vec<CheckConfig>,
30}
31
32#[derive(Debug, Clone, Deserialize, Serialize)]
33pub struct GateConfig {
34    #[serde(default)]
35    pub agents: Vec<String>,
36
37    #[serde(default)]
38    pub policy: VerdictPolicy,
39}
40
41#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct CheckConfig {
43    pub name: String,
44
45    #[serde(default)]
46    pub triggers: Vec<TriggerConfig>,
47
48    pub source: CheckSourceConfig,
49
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub timeout_secs: Option<u64>,
52}
53
54#[derive(Debug, Clone, Deserialize, Serialize)]
55pub struct TriggerConfig {
56    pub on: Vec<String>,
57}
58
59/// Tagged enum: TOML `type = "shell"` selects the `Shell` variant.
60/// Unknown `type` values fail at parse time — that's the v0.1 contract
61/// for additive forwards-incompatibility.
62#[derive(Debug, Clone, Deserialize, Serialize)]
63#[serde(tag = "type", rename_all = "snake_case")]
64pub enum CheckSourceConfig {
65    Shell { command: String },
66}
67
68impl ConfigV1 {
69    /// Resolve and load `klasp.toml`. The lookup order matches design §14:
70    /// `$CLAUDE_PROJECT_DIR` first (set by Claude Code), then the supplied
71    /// `repo_root`. The first existing file wins; any parse error
72    /// short-circuits.
73    pub fn load(repo_root: &Path) -> Result<Self> {
74        let mut searched = Vec::new();
75
76        if let Ok(claude_dir) = std::env::var("CLAUDE_PROJECT_DIR") {
77            let candidate = PathBuf::from(claude_dir).join("klasp.toml");
78            if candidate.is_file() {
79                return Self::from_file(&candidate);
80            }
81            searched.push(candidate);
82        }
83
84        let candidate = repo_root.join("klasp.toml");
85        if candidate.is_file() {
86            return Self::from_file(&candidate);
87        }
88        searched.push(candidate);
89
90        Err(KlaspError::ConfigNotFound { searched })
91    }
92
93    /// Read and parse a specific TOML file. Public so tests and callers
94    /// that already know the path can skip the lookup logic.
95    pub fn from_file(path: &Path) -> Result<Self> {
96        let bytes = std::fs::read_to_string(path).map_err(|source| KlaspError::Io {
97            path: path.to_path_buf(),
98            source,
99        })?;
100        Self::parse(&bytes)
101    }
102
103    /// Parse from raw TOML. Validates the `version` field as part of the
104    /// parse step so caller code never sees a malformed `ConfigV1`.
105    pub fn parse(s: &str) -> Result<Self> {
106        let config: ConfigV1 = toml::from_str(s)?;
107        if config.version != CONFIG_VERSION {
108            return Err(KlaspError::ConfigVersion {
109                found: config.version,
110                supported: CONFIG_VERSION,
111            });
112        }
113        Ok(config)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn parses_minimal_config() {
123        let toml = r#"
124            version = 1
125
126            [gate]
127            agents = ["claude_code"]
128        "#;
129        let config = ConfigV1::parse(toml).expect("should parse");
130        assert_eq!(config.version, 1);
131        assert_eq!(config.gate.agents, vec!["claude_code"]);
132        assert_eq!(config.gate.policy, VerdictPolicy::AnyFail);
133        assert!(config.checks.is_empty());
134    }
135
136    #[test]
137    fn parses_full_config() {
138        let toml = r#"
139            version = 1
140
141            [gate]
142            agents = ["claude_code"]
143            policy = "any_fail"
144
145            [[checks]]
146            name = "ruff"
147            triggers = [{ on = ["commit"] }]
148            timeout_secs = 60
149            [checks.source]
150            type = "shell"
151            command = "ruff check ."
152
153            [[checks]]
154            name = "pytest"
155            triggers = [{ on = ["push"] }]
156            [checks.source]
157            type = "shell"
158            command = "pytest -q"
159        "#;
160        let config = ConfigV1::parse(toml).expect("should parse");
161        assert_eq!(config.checks.len(), 2);
162        assert_eq!(config.checks[0].name, "ruff");
163        assert_eq!(config.checks[0].timeout_secs, Some(60));
164        assert!(matches!(
165            &config.checks[0].source,
166            CheckSourceConfig::Shell { command } if command == "ruff check ."
167        ));
168        assert_eq!(config.checks[0].triggers[0].on, vec!["commit"]);
169        assert!(config.checks[1].timeout_secs.is_none());
170    }
171
172    #[test]
173    fn rejects_wrong_version() {
174        let toml = r#"
175            version = 2
176            [gate]
177        "#;
178        let err = ConfigV1::parse(toml).expect_err("should reject");
179        match err {
180            KlaspError::ConfigVersion { found, supported } => {
181                assert_eq!(found, 2);
182                assert_eq!(supported, CONFIG_VERSION);
183            }
184            other => panic!("expected ConfigVersion, got {other:?}"),
185        }
186    }
187
188    #[test]
189    fn rejects_missing_version() {
190        let toml = r#"
191            [gate]
192            agents = []
193        "#;
194        let err = ConfigV1::parse(toml).expect_err("should reject");
195        assert!(matches!(err, KlaspError::ConfigParse(_)));
196    }
197
198    #[test]
199    fn rejects_missing_gate() {
200        let toml = "version = 1";
201        let err = ConfigV1::parse(toml).expect_err("should reject");
202        assert!(matches!(err, KlaspError::ConfigParse(_)));
203    }
204
205    #[test]
206    fn rejects_unknown_source_type() {
207        let toml = r#"
208            version = 1
209            [gate]
210
211            [[checks]]
212            name = "future-recipe"
213            [checks.source]
214            type = "pre_commit"
215            command = "pre-commit run"
216        "#;
217        let err = ConfigV1::parse(toml).expect_err("should reject");
218        assert!(matches!(err, KlaspError::ConfigParse(_)));
219    }
220
221    #[test]
222    fn rejects_missing_check_name() {
223        let toml = r#"
224            version = 1
225            [gate]
226
227            [[checks]]
228            [checks.source]
229            type = "shell"
230            command = "echo"
231        "#;
232        let err = ConfigV1::parse(toml).expect_err("should reject");
233        assert!(matches!(err, KlaspError::ConfigParse(_)));
234    }
235}