1use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{KlaspError, Result};
14use crate::verdict::VerdictPolicy;
15
16pub const CONFIG_VERSION: u32 = 1;
19
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct ConfigV1 {
22 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#[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 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 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 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}