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)]
21#[serde(deny_unknown_fields)]
22pub struct ConfigV1 {
23 pub version: u32,
26
27 pub gate: GateConfig,
28
29 #[serde(default)]
30 pub checks: Vec<CheckConfig>,
31}
32
33#[derive(Debug, Clone, Deserialize, Serialize)]
34#[serde(deny_unknown_fields)]
35pub struct GateConfig {
36 #[serde(default)]
37 pub agents: Vec<String>,
38
39 #[serde(default)]
40 pub policy: VerdictPolicy,
41}
42
43#[derive(Debug, Clone, Deserialize, Serialize)]
44#[serde(deny_unknown_fields)]
45pub struct CheckConfig {
46 pub name: String,
47
48 #[serde(default)]
49 pub triggers: Vec<TriggerConfig>,
50
51 pub source: CheckSourceConfig,
52
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub timeout_secs: Option<u64>,
55}
56
57#[derive(Debug, Clone, Deserialize, Serialize)]
58#[serde(deny_unknown_fields)]
59pub struct TriggerConfig {
60 pub on: Vec<String>,
61}
62
63#[derive(Debug, Clone, Deserialize, Serialize)]
81#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
82pub enum CheckSourceConfig {
83 Shell {
84 command: String,
85 },
86 PreCommit {
87 #[serde(default, skip_serializing_if = "Option::is_none")]
91 hook_stage: Option<String>,
92
93 #[serde(default, skip_serializing_if = "Option::is_none")]
97 config_path: Option<PathBuf>,
98 },
99 Fallow {
100 #[serde(default, skip_serializing_if = "Option::is_none")]
104 config_path: Option<PathBuf>,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
114 base: Option<String>,
115 },
116 Pytest {
117 #[serde(default, skip_serializing_if = "Option::is_none")]
121 extra_args: Option<String>,
122
123 #[serde(default, skip_serializing_if = "Option::is_none")]
127 config_path: Option<PathBuf>,
128
129 #[serde(default, skip_serializing_if = "Option::is_none")]
134 junit_xml: Option<bool>,
135 },
136 Cargo {
137 subcommand: String,
144
145 #[serde(default, skip_serializing_if = "Option::is_none")]
149 extra_args: Option<String>,
150
151 #[serde(default, skip_serializing_if = "Option::is_none")]
154 package: Option<String>,
155 },
156}
157
158impl ConfigV1 {
159 pub fn load(repo_root: &Path) -> Result<Self> {
164 let mut searched = Vec::new();
165
166 if let Ok(claude_dir) = std::env::var("CLAUDE_PROJECT_DIR") {
167 let candidate = PathBuf::from(claude_dir).join("klasp.toml");
168 if candidate.is_file() {
169 return Self::from_file(&candidate);
170 }
171 searched.push(candidate);
172 }
173
174 let candidate = repo_root.join("klasp.toml");
175 if candidate.is_file() {
176 return Self::from_file(&candidate);
177 }
178 searched.push(candidate);
179
180 Err(KlaspError::ConfigNotFound { searched })
181 }
182
183 pub fn from_file(path: &Path) -> Result<Self> {
186 let bytes = std::fs::read_to_string(path).map_err(|source| KlaspError::Io {
187 path: path.to_path_buf(),
188 source,
189 })?;
190 Self::parse(&bytes)
191 }
192
193 pub fn parse(s: &str) -> Result<Self> {
196 let config: ConfigV1 = toml::from_str(s)?;
197 if config.version != CONFIG_VERSION {
198 return Err(KlaspError::ConfigVersion {
199 found: config.version,
200 supported: CONFIG_VERSION,
201 });
202 }
203 Ok(config)
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn parses_minimal_config() {
213 let toml = r#"
214 version = 1
215
216 [gate]
217 agents = ["claude_code"]
218 "#;
219 let config = ConfigV1::parse(toml).expect("should parse");
220 assert_eq!(config.version, 1);
221 assert_eq!(config.gate.agents, vec!["claude_code"]);
222 assert_eq!(config.gate.policy, VerdictPolicy::AnyFail);
223 assert!(config.checks.is_empty());
224 }
225
226 #[test]
227 fn parses_full_config() {
228 let toml = r#"
229 version = 1
230
231 [gate]
232 agents = ["claude_code"]
233 policy = "any_fail"
234
235 [[checks]]
236 name = "ruff"
237 triggers = [{ on = ["commit"] }]
238 timeout_secs = 60
239 [checks.source]
240 type = "shell"
241 command = "ruff check ."
242
243 [[checks]]
244 name = "pytest"
245 triggers = [{ on = ["push"] }]
246 [checks.source]
247 type = "shell"
248 command = "pytest -q"
249 "#;
250 let config = ConfigV1::parse(toml).expect("should parse");
251 assert_eq!(config.checks.len(), 2);
252 assert_eq!(config.checks[0].name, "ruff");
253 assert_eq!(config.checks[0].timeout_secs, Some(60));
254 assert!(matches!(
255 &config.checks[0].source,
256 CheckSourceConfig::Shell { command } if command == "ruff check ."
257 ));
258 assert_eq!(config.checks[0].triggers[0].on, vec!["commit"]);
259 assert!(config.checks[1].timeout_secs.is_none());
260 }
261
262 #[test]
263 fn rejects_wrong_version() {
264 let toml = r#"
265 version = 2
266 [gate]
267 "#;
268 let err = ConfigV1::parse(toml).expect_err("should reject");
269 match err {
270 KlaspError::ConfigVersion { found, supported } => {
271 assert_eq!(found, 2);
272 assert_eq!(supported, CONFIG_VERSION);
273 }
274 other => panic!("expected ConfigVersion, got {other:?}"),
275 }
276 }
277
278 #[test]
279 fn rejects_missing_version() {
280 let toml = r#"
281 [gate]
282 agents = []
283 "#;
284 let err = ConfigV1::parse(toml).expect_err("should reject");
285 assert!(matches!(err, KlaspError::ConfigParse(_)));
286 }
287
288 #[test]
289 fn rejects_missing_gate() {
290 let toml = "version = 1";
291 let err = ConfigV1::parse(toml).expect_err("should reject");
292 assert!(matches!(err, KlaspError::ConfigParse(_)));
293 }
294
295 #[test]
296 fn rejects_unknown_source_type() {
297 let toml = r#"
304 version = 1
305 [gate]
306
307 [[checks]]
308 name = "future-recipe"
309 [checks.source]
310 type = "future_recipe_not_yet_landed"
311 command = "noop"
312 "#;
313 let err = ConfigV1::parse(toml).expect_err("should reject");
314 assert!(matches!(err, KlaspError::ConfigParse(_)));
315 }
316
317 #[test]
318 fn rejects_unknown_field_on_pre_commit_variant() {
319 let toml = r#"
326 version = 1
327 [gate]
328
329 [[checks]]
330 name = "typo-test"
331 [checks.source]
332 type = "pre_commit"
333 hook_stages = "pre-push"
334 "#;
335 let err = ConfigV1::parse(toml).expect_err("should reject");
336 assert!(matches!(err, KlaspError::ConfigParse(_)));
337 }
338
339 #[test]
340 fn parses_pre_commit_recipe_minimal() {
341 let toml = r#"
346 version = 1
347 [gate]
348
349 [[checks]]
350 name = "lint"
351 [checks.source]
352 type = "pre_commit"
353 "#;
354 let config = ConfigV1::parse(toml).expect("should parse");
355 assert_eq!(config.checks.len(), 1);
356 match &config.checks[0].source {
357 CheckSourceConfig::PreCommit {
358 hook_stage,
359 config_path,
360 } => {
361 assert!(hook_stage.is_none());
362 assert!(config_path.is_none());
363 }
364 other => panic!("expected PreCommit, got {other:?}"),
365 }
366 }
367
368 #[test]
369 fn parses_pre_commit_recipe_with_fields() {
370 let toml = r#"
371 version = 1
372 [gate]
373
374 [[checks]]
375 name = "lint"
376 [checks.source]
377 type = "pre_commit"
378 hook_stage = "pre-push"
379 config_path = "tools/pre-commit.yaml"
380 "#;
381 let config = ConfigV1::parse(toml).expect("should parse");
382 match &config.checks[0].source {
383 CheckSourceConfig::PreCommit {
384 hook_stage,
385 config_path,
386 } => {
387 assert_eq!(hook_stage.as_deref(), Some("pre-push"));
388 assert_eq!(
389 config_path
390 .as_ref()
391 .map(|p| p.to_string_lossy().into_owned()),
392 Some("tools/pre-commit.yaml".to_string())
393 );
394 }
395 other => panic!("expected PreCommit, got {other:?}"),
396 }
397 }
398
399 #[test]
400 fn parses_fallow_recipe_minimal() {
401 let toml = r#"
406 version = 1
407 [gate]
408
409 [[checks]]
410 name = "audit"
411 [checks.source]
412 type = "fallow"
413 "#;
414 let config = ConfigV1::parse(toml).expect("should parse");
415 assert_eq!(config.checks.len(), 1);
416 match &config.checks[0].source {
417 CheckSourceConfig::Fallow { config_path, base } => {
418 assert!(config_path.is_none());
419 assert!(base.is_none());
420 }
421 other => panic!("expected Fallow, got {other:?}"),
422 }
423 }
424
425 #[test]
426 fn parses_fallow_recipe_with_fields() {
427 let toml = r#"
428 version = 1
429 [gate]
430
431 [[checks]]
432 name = "audit"
433 [checks.source]
434 type = "fallow"
435 config_path = "tools/.fallowrc.json"
436 base = "origin/main"
437 "#;
438 let config = ConfigV1::parse(toml).expect("should parse");
439 match &config.checks[0].source {
440 CheckSourceConfig::Fallow { config_path, base } => {
441 assert_eq!(
442 config_path
443 .as_ref()
444 .map(|p| p.to_string_lossy().into_owned()),
445 Some("tools/.fallowrc.json".to_string())
446 );
447 assert_eq!(base.as_deref(), Some("origin/main"));
448 }
449 other => panic!("expected Fallow, got {other:?}"),
450 }
451 }
452
453 #[test]
454 fn rejects_unknown_field_on_fallow_variant() {
455 let toml = r#"
459 version = 1
460 [gate]
461
462 [[checks]]
463 name = "audit"
464 [checks.source]
465 type = "fallow"
466 bases = "main"
467 "#;
468 let err = ConfigV1::parse(toml).expect_err("should reject");
469 assert!(matches!(err, KlaspError::ConfigParse(_)));
470 }
471
472 #[test]
473 fn parses_pytest_recipe_minimal() {
474 let toml = r#"
478 version = 1
479 [gate]
480
481 [[checks]]
482 name = "tests"
483 [checks.source]
484 type = "pytest"
485 "#;
486 let config = ConfigV1::parse(toml).expect("should parse");
487 assert_eq!(config.checks.len(), 1);
488 match &config.checks[0].source {
489 CheckSourceConfig::Pytest {
490 extra_args,
491 config_path,
492 junit_xml,
493 } => {
494 assert!(extra_args.is_none());
495 assert!(config_path.is_none());
496 assert!(junit_xml.is_none());
497 }
498 other => panic!("expected Pytest, got {other:?}"),
499 }
500 }
501
502 #[test]
503 fn parses_pytest_recipe_with_fields() {
504 let toml = r#"
505 version = 1
506 [gate]
507
508 [[checks]]
509 name = "tests"
510 [checks.source]
511 type = "pytest"
512 extra_args = "-x -q tests/"
513 config_path = "pytest.ini"
514 junit_xml = true
515 "#;
516 let config = ConfigV1::parse(toml).expect("should parse");
517 match &config.checks[0].source {
518 CheckSourceConfig::Pytest {
519 extra_args,
520 config_path,
521 junit_xml,
522 } => {
523 assert_eq!(extra_args.as_deref(), Some("-x -q tests/"));
524 assert_eq!(
525 config_path
526 .as_ref()
527 .map(|p| p.to_string_lossy().into_owned()),
528 Some("pytest.ini".to_string())
529 );
530 assert_eq!(*junit_xml, Some(true));
531 }
532 other => panic!("expected Pytest, got {other:?}"),
533 }
534 }
535
536 #[test]
537 fn rejects_unknown_field_on_pytest_variant() {
538 let toml = r#"
541 version = 1
542 [gate]
543
544 [[checks]]
545 name = "tests"
546 [checks.source]
547 type = "pytest"
548 extra_arg = "-x"
549 "#;
550 let err = ConfigV1::parse(toml).expect_err("should reject");
551 assert!(matches!(err, KlaspError::ConfigParse(_)));
552 }
553
554 #[test]
555 fn parses_cargo_recipe_minimal() {
556 let toml = r#"
559 version = 1
560 [gate]
561
562 [[checks]]
563 name = "build"
564 [checks.source]
565 type = "cargo"
566 subcommand = "check"
567 "#;
568 let config = ConfigV1::parse(toml).expect("should parse");
569 assert_eq!(config.checks.len(), 1);
570 match &config.checks[0].source {
571 CheckSourceConfig::Cargo {
572 subcommand,
573 extra_args,
574 package,
575 } => {
576 assert_eq!(subcommand, "check");
577 assert!(extra_args.is_none());
578 assert!(package.is_none());
579 }
580 other => panic!("expected Cargo, got {other:?}"),
581 }
582 }
583
584 #[test]
585 fn parses_cargo_recipe_with_fields() {
586 let toml = r#"
587 version = 1
588 [gate]
589
590 [[checks]]
591 name = "lint"
592 [checks.source]
593 type = "cargo"
594 subcommand = "clippy"
595 extra_args = "--all-features -- -D warnings"
596 package = "klasp-core"
597 "#;
598 let config = ConfigV1::parse(toml).expect("should parse");
599 match &config.checks[0].source {
600 CheckSourceConfig::Cargo {
601 subcommand,
602 extra_args,
603 package,
604 } => {
605 assert_eq!(subcommand, "clippy");
606 assert_eq!(extra_args.as_deref(), Some("--all-features -- -D warnings"));
607 assert_eq!(package.as_deref(), Some("klasp-core"));
608 }
609 other => panic!("expected Cargo, got {other:?}"),
610 }
611 }
612
613 #[test]
614 fn rejects_cargo_recipe_missing_subcommand() {
615 let toml = r#"
618 version = 1
619 [gate]
620
621 [[checks]]
622 name = "build"
623 [checks.source]
624 type = "cargo"
625 "#;
626 let err = ConfigV1::parse(toml).expect_err("should reject");
627 assert!(matches!(err, KlaspError::ConfigParse(_)));
628 }
629
630 #[test]
631 fn rejects_unknown_field_on_cargo_variant() {
632 let toml = r#"
633 version = 1
634 [gate]
635
636 [[checks]]
637 name = "build"
638 [checks.source]
639 type = "cargo"
640 subcommand = "check"
641 packages = "klasp-core"
642 "#;
643 let err = ConfigV1::parse(toml).expect_err("should reject");
644 assert!(matches!(err, KlaspError::ConfigParse(_)));
645 }
646
647 #[test]
648 fn rejects_missing_check_name() {
649 let toml = r#"
650 version = 1
651 [gate]
652
653 [[checks]]
654 [checks.source]
655 type = "shell"
656 command = "echo"
657 "#;
658 let err = ConfigV1::parse(toml).expect_err("should reject");
659 assert!(matches!(err, KlaspError::ConfigParse(_)));
660 }
661}