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)]
21#[serde(deny_unknown_fields)]
22pub struct ConfigV1 {
23    /// Schema version. Must equal [`CONFIG_VERSION`]; mismatches fail with
24    /// [`KlaspError::ConfigVersion`].
25    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/// Tagged enum: TOML `type = "shell"` selects the `Shell` variant,
64/// `type = "pre_commit"` selects the v0.2 W4 `PreCommit` named recipe,
65/// `type = "fallow"` selects the v0.2 W5 `Fallow` named recipe,
66/// `type = "pytest"` selects the v0.2 W6 `Pytest` named recipe,
67/// `type = "cargo"` selects the v0.2 W6 `Cargo` named recipe.
68/// Unknown `type` values fail at parse time — that's the v0.1 contract
69/// for additive forwards-incompatibility, preserved as new recipes land.
70///
71/// **Adding new variants is the v0.2 named-recipe extension point** —
72/// each new recipe is a sibling variant here plus a paired `CheckSource`
73/// impl in the binary crate. Field shape is per-recipe: `Shell` carries
74/// a free-form `command`, `PreCommit` carries optional `hook_stage` /
75/// `config_path` fields, `Fallow` carries optional `config_path` /
76/// `base` fields, `Pytest` carries optional `extra_args`, `config_path`,
77/// and `junit_xml` toggle, `Cargo` requires a `subcommand` plus optional
78/// `extra_args` / `package`. `verdict_path` is deferred — see
79/// [docs/design.md §14] for the explicit scope note.
80#[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        /// Maps to `pre-commit run --hook-stage <stage>`. `None` defaults
88        /// to `"pre-commit"` at run time, matching pre-commit's own
89        /// default when invoked from a `.git/hooks/pre-commit` shim.
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        hook_stage: Option<String>,
92
93        /// Maps to `pre-commit run -c <config_path>`. `None` lets
94        /// pre-commit fall back to its own default discovery
95        /// (`.pre-commit-config.yaml` at the repo root).
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        config_path: Option<PathBuf>,
98    },
99    Fallow {
100        /// Maps to `fallow audit -c <config_path>`. `None` lets fallow
101        /// fall back to its own discovery (`.fallowrc.json`,
102        /// `.fallowrc.jsonc`, or `fallow.toml` at the repo root).
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        config_path: Option<PathBuf>,
105
106        /// Maps to `fallow audit --base <ref>`. `None` falls back to
107        /// `${KLASP_BASE_REF}` at run time, which the gate runtime
108        /// resolves to the merge-base of `HEAD` against the upstream
109        /// tracking branch. Set this only when the diff-base for the
110        /// audit should diverge from the gate's resolved base ref —
111        /// e.g. auditing against a fixed mainline for a long-lived
112        /// release branch.
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        base: Option<String>,
115    },
116    Pytest {
117        /// Free-form extra args appended after pytest's own flags.
118        /// e.g. `"-x -q tests/integration"`. `None` runs pytest with
119        /// its own defaults.
120        #[serde(default, skip_serializing_if = "Option::is_none")]
121        extra_args: Option<String>,
122
123        /// Maps to `pytest -c <config_path>`. `None` lets pytest fall
124        /// back to its own discovery (`pytest.ini`, `pyproject.toml`,
125        /// `tox.ini`, …).
126        #[serde(default, skip_serializing_if = "Option::is_none")]
127        config_path: Option<PathBuf>,
128
129        /// When `true`, the recipe asks pytest to write a JUnit XML
130        /// report and parses it for per-failure findings. When `false`
131        /// (default), the recipe falls back to a generic count-based
132        /// finding from pytest's exit code alone.
133        #[serde(default, skip_serializing_if = "Option::is_none")]
134        junit_xml: Option<bool>,
135    },
136    Cargo {
137        /// Required: which `cargo <subcommand>` to dispatch. Accepted
138        /// values are `"check"`, `"clippy"`, `"test"`, `"build"`. Any
139        /// other value fails at run time with an unparseable detail
140        /// (the schema doesn't enum-restrict this so a future cargo
141        /// subcommand can be tried by an adventurous user without a
142        /// klasp release).
143        subcommand: String,
144
145        /// Free-form extra args appended after cargo's own flags
146        /// (e.g. `"--all-features"`). `None` runs cargo with its
147        /// own defaults.
148        #[serde(default, skip_serializing_if = "Option::is_none")]
149        extra_args: Option<String>,
150
151        /// Maps to `cargo <sub> -p <package>`. `None` runs across
152        /// the workspace via `--workspace`.
153        #[serde(default, skip_serializing_if = "Option::is_none")]
154        package: Option<String>,
155    },
156}
157
158impl ConfigV1 {
159    /// Resolve and load `klasp.toml`. The lookup order matches design §14:
160    /// `$CLAUDE_PROJECT_DIR` first (set by Claude Code), then the supplied
161    /// `repo_root`. The first existing file wins; any parse error
162    /// short-circuits.
163    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    /// Read and parse a specific TOML file. Public so tests and callers
184    /// that already know the path can skip the lookup logic.
185    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    /// Parse from raw TOML. Validates the `version` field as part of the
194    /// parse step so caller code never sees a malformed `ConfigV1`.
195    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        // `pre_commit` was an unknown recipe in v0.1, `fallow` was unknown
298        // in v0.2 W4, `pytest` / `cargo` were unknown in W5; all are
299        // first-class variants now. Pivot to a recipe that hasn't landed
300        // yet (placeholder for whichever recipe lands next post-W6) so
301        // the additive-forwards-incompat contract keeps its regression
302        // coverage.
303        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        // A typo like `hook_stages` (plural) on the `pre_commit` variant
320        // would silently parse as the default `None` without
321        // `#[serde(deny_unknown_fields)]` on the tagged enum — defaulting
322        // to `--hook-stage pre-commit` regardless of the user's intent.
323        // Locks in the variant-level footgun closure so a future serde
324        // refactor (e.g. `untagged`) doesn't silently regress it.
325        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        // Bare `type = "pre_commit"` with no extra fields: both optional
342        // fields default to `None` and the recipe applies its own
343        // run-time defaults (`hook_stage = "pre-commit"`,
344        // `config_path = ".pre-commit-config.yaml"`).
345        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        // Bare `type = "fallow"` with no extra fields: both optional
402        // fields default to `None` and the recipe applies its own
403        // run-time defaults (`base = "${KLASP_BASE_REF}"`,
404        // `config_path = <fallow's own discovery>`).
405        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        // Same footgun closure as the pre_commit variant: a typo like
456        // `bases` (plural) on the `fallow` variant must fail at parse
457        // time rather than silently default to `None`.
458        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        // Bare `type = "pytest"` with no extra fields: every optional
475        // field defaults to `None` and the recipe applies its own
476        // run-time defaults.
477        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        // Same footgun closure as the pre_commit / fallow variants: a
539        // typo on the `pytest` variant must fail at parse time.
540        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        // `type = "cargo"` requires `subcommand`; the other fields
557        // default to `None` and the recipe runs across the workspace.
558        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        // `subcommand` is required (no `#[serde(default)]`), so a
616        // bare `type = "cargo"` must fail at parse time.
617        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}