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/// Env var Claude Code sets to the project root it was launched from.
21/// Centralised so [`ConfigV1::load`] and the runtime's repo-root resolver
22/// can't drift on the spelling.
23pub const CLAUDE_PROJECT_DIR_ENV: &str = "CLAUDE_PROJECT_DIR";
24
25#[derive(Debug, Clone, Deserialize, Serialize)]
26#[serde(deny_unknown_fields)]
27pub struct ConfigV1 {
28    /// Schema version. Must equal [`CONFIG_VERSION`]; mismatches fail with
29    /// [`KlaspError::ConfigVersion`].
30    pub version: u32,
31
32    pub gate: GateConfig,
33
34    #[serde(default)]
35    pub checks: Vec<CheckConfig>,
36}
37
38#[derive(Debug, Clone, Deserialize, Serialize)]
39#[serde(deny_unknown_fields)]
40pub struct GateConfig {
41    #[serde(default)]
42    pub agents: Vec<String>,
43
44    #[serde(default)]
45    pub policy: VerdictPolicy,
46
47    /// Run checks in parallel via rayon's work-stealing scheduler. v0.2.5+
48    /// behaviour. Default `false` for back-compat. Per [docs/design.md §6.1],
49    /// checks MUST be stateless when this is enabled — anything writing to a
50    /// shared temp file or process-global state will race.
51    #[serde(default)]
52    pub parallel: bool,
53}
54
55#[derive(Debug, Clone, Deserialize, Serialize)]
56#[serde(deny_unknown_fields)]
57pub struct CheckConfig {
58    pub name: String,
59
60    #[serde(default)]
61    pub triggers: Vec<TriggerConfig>,
62
63    pub source: CheckSourceConfig,
64
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub timeout_secs: Option<u64>,
67}
68
69#[derive(Debug, Clone, Deserialize, Serialize)]
70#[serde(deny_unknown_fields)]
71pub struct TriggerConfig {
72    pub on: Vec<String>,
73}
74
75/// Tagged enum: TOML `type = "shell"` selects the `Shell` variant,
76/// `type = "pre_commit"` selects the v0.2 W4 `PreCommit` named recipe,
77/// `type = "fallow"` selects the v0.2 W5 `Fallow` named recipe,
78/// `type = "pytest"` selects the v0.2 W6 `Pytest` named recipe,
79/// `type = "cargo"` selects the v0.2 W6 `Cargo` named recipe.
80/// Unknown `type` values fail at parse time — that's the v0.1 contract
81/// for additive forwards-incompatibility, preserved as new recipes land.
82///
83/// **Adding new variants is the v0.2 named-recipe extension point** —
84/// each new recipe is a sibling variant here plus a paired `CheckSource`
85/// impl in the binary crate. Field shape is per-recipe: `Shell` carries
86/// a free-form `command`, `PreCommit` carries optional `hook_stage` /
87/// `config_path` fields, `Fallow` carries optional `config_path` /
88/// `base` fields, `Pytest` carries optional `extra_args`, `config_path`,
89/// and `junit_xml` toggle, `Cargo` requires a `subcommand` plus optional
90/// `extra_args` / `package`. `verdict_path` is deferred — see
91/// [docs/design.md §14] for the explicit scope note.
92#[derive(Debug, Clone, Deserialize, Serialize)]
93#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
94pub enum CheckSourceConfig {
95    Shell {
96        command: String,
97    },
98    PreCommit {
99        /// Maps to `pre-commit run --hook-stage <stage>`. `None` defaults
100        /// to `"pre-commit"` at run time, matching pre-commit's own
101        /// default when invoked from a `.git/hooks/pre-commit` shim.
102        #[serde(default, skip_serializing_if = "Option::is_none")]
103        hook_stage: Option<String>,
104
105        /// Maps to `pre-commit run -c <config_path>`. `None` lets
106        /// pre-commit fall back to its own default discovery
107        /// (`.pre-commit-config.yaml` at the repo root).
108        #[serde(default, skip_serializing_if = "Option::is_none")]
109        config_path: Option<PathBuf>,
110    },
111    Fallow {
112        /// Maps to `fallow audit -c <config_path>`. `None` lets fallow
113        /// fall back to its own discovery (`.fallowrc.json`,
114        /// `.fallowrc.jsonc`, or `fallow.toml` at the repo root).
115        #[serde(default, skip_serializing_if = "Option::is_none")]
116        config_path: Option<PathBuf>,
117
118        /// Maps to `fallow audit --base <ref>`. `None` falls back to
119        /// `${KLASP_BASE_REF}` at run time, which the gate runtime
120        /// resolves to the merge-base of `HEAD` against the upstream
121        /// tracking branch. Set this only when the diff-base for the
122        /// audit should diverge from the gate's resolved base ref —
123        /// e.g. auditing against a fixed mainline for a long-lived
124        /// release branch.
125        #[serde(default, skip_serializing_if = "Option::is_none")]
126        base: Option<String>,
127    },
128    Pytest {
129        /// Free-form extra args appended after pytest's own flags.
130        /// e.g. `"-x -q tests/integration"`. `None` runs pytest with
131        /// its own defaults.
132        #[serde(default, skip_serializing_if = "Option::is_none")]
133        extra_args: Option<String>,
134
135        /// Maps to `pytest -c <config_path>`. `None` lets pytest fall
136        /// back to its own discovery (`pytest.ini`, `pyproject.toml`,
137        /// `tox.ini`, …).
138        #[serde(default, skip_serializing_if = "Option::is_none")]
139        config_path: Option<PathBuf>,
140
141        /// When `true`, the recipe asks pytest to write a JUnit XML
142        /// report and parses it for per-failure findings. When `false`
143        /// (default), the recipe falls back to a generic count-based
144        /// finding from pytest's exit code alone.
145        #[serde(default, skip_serializing_if = "Option::is_none")]
146        junit_xml: Option<bool>,
147    },
148    Cargo {
149        /// Required: which `cargo <subcommand>` to dispatch. Accepted
150        /// values are `"check"`, `"clippy"`, `"test"`, `"build"`. Any
151        /// other value fails at run time with an unparseable detail
152        /// (the schema doesn't enum-restrict this so a future cargo
153        /// subcommand can be tried by an adventurous user without a
154        /// klasp release).
155        subcommand: String,
156
157        /// Free-form extra args appended after cargo's own flags
158        /// (e.g. `"--all-features"`). `None` runs cargo with its
159        /// own defaults.
160        #[serde(default, skip_serializing_if = "Option::is_none")]
161        extra_args: Option<String>,
162
163        /// Maps to `cargo <sub> -p <package>`. `None` runs across
164        /// the workspace via `--workspace`.
165        #[serde(default, skip_serializing_if = "Option::is_none")]
166        package: Option<String>,
167    },
168}
169
170/// Walk up from `start` to `repo_root` looking for `klasp.toml`.
171///
172/// Lookup order:
173/// 1. Canonicalize both `start` and `repo_root` (resolves macOS symlinks).
174/// 2. If `start` is a file, begin from its parent directory.
175/// 3. Walk upward, checking for `klasp.toml` at each level, stopping at
176///    `repo_root` inclusive.
177/// 4. Return `None` if no config found or `start` is outside `repo_root`.
178pub fn discover_config_for_path(start: &Path, repo_root: &Path) -> Option<PathBuf> {
179    let root = repo_root.canonicalize().ok()?;
180    let start_dir = if start.is_file() {
181        start.parent().map(Path::to_path_buf)?
182    } else {
183        start.to_path_buf()
184    };
185    let start_canon = start_dir.canonicalize().ok()?;
186    if !start_canon.starts_with(&root) {
187        return None;
188    }
189    let mut current = start_canon;
190    loop {
191        let candidate = current.join("klasp.toml");
192        if candidate.is_file() {
193            return Some(candidate);
194        }
195        if current == root {
196            break;
197        }
198        match current.parent() {
199            Some(p) => current = p.to_path_buf(),
200            None => break,
201        }
202    }
203    None
204}
205
206/// Convenience wrapper: discover nearest `klasp.toml` then load it.
207///
208/// Returns `None` if no config file is found in the walk-up chain.
209/// Returns `Some(Err(_))` if a config file exists but fails to parse.
210pub fn load_config_for_path(start: &Path, repo_root: &Path) -> Option<Result<(PathBuf, ConfigV1)>> {
211    let config_path = discover_config_for_path(start, repo_root)?;
212    Some(ConfigV1::from_file(&config_path).map(|cfg| (config_path, cfg)))
213}
214
215/// True when the process cwd resolves under `root`. Both paths are
216/// canonicalised so symlinked layouts (`/var` → `/private/var` on macOS,
217/// worktrees, etc.) compare correctly. Any failure to canonicalise either
218/// side is treated as "not inside" — the caller falls back to its
219/// alternative resolution path.
220fn cwd_inside(root: &Path) -> bool {
221    let cwd = match std::env::current_dir().and_then(|c| c.canonicalize()) {
222        Ok(c) => c,
223        Err(_) => return false,
224    };
225    let root = match root.canonicalize() {
226        Ok(r) => r,
227        Err(_) => return false,
228    };
229    cwd.starts_with(root)
230}
231
232impl ConfigV1 {
233    /// Resolve and load `klasp.toml`. Lookup order per design §14:
234    /// `$CLAUDE_PROJECT_DIR` first (set by Claude Code), then the supplied
235    /// `repo_root`. The first existing file wins; any parse error
236    /// short-circuits.
237    ///
238    /// The `$CLAUDE_PROJECT_DIR` candidate is only honoured when the process
239    /// cwd is inside that directory — otherwise a session bound to repo A
240    /// would run A's gate against an unrelated sibling repo B. On mismatch
241    /// the env candidate is skipped and resolution falls through to
242    /// `repo_root`; if neither exists, [`KlaspError::ConfigNotFound`] is
243    /// returned and the gate fails open.
244    pub fn load(repo_root: &Path) -> Result<Self> {
245        let mut searched = Vec::new();
246
247        if let Ok(claude_dir) = std::env::var(CLAUDE_PROJECT_DIR_ENV) {
248            let env_root = PathBuf::from(claude_dir);
249            let candidate = env_root.join("klasp.toml");
250            match (candidate.is_file(), cwd_inside(&env_root)) {
251                (true, true) => return Self::from_file(&candidate),
252                // env candidate exists but cwd is elsewhere — skip silently;
253                // the file isn't missing, it's just not ours to load.
254                (true, false) => {}
255                (false, _) => searched.push(candidate),
256            }
257        }
258
259        let candidate = repo_root.join("klasp.toml");
260        if candidate.is_file() {
261            return Self::from_file(&candidate);
262        }
263        searched.push(candidate);
264
265        Err(KlaspError::ConfigNotFound { searched })
266    }
267
268    /// Read and parse a specific TOML file. Public so tests and callers
269    /// that already know the path can skip the lookup logic.
270    pub fn from_file(path: &Path) -> Result<Self> {
271        let bytes = std::fs::read_to_string(path).map_err(|source| KlaspError::Io {
272            path: path.to_path_buf(),
273            source,
274        })?;
275        Self::parse(&bytes)
276    }
277
278    /// Parse from raw TOML. Validates the `version` field as part of the
279    /// parse step so caller code never sees a malformed `ConfigV1`.
280    pub fn parse(s: &str) -> Result<Self> {
281        let config: ConfigV1 = toml::from_str(s)?;
282        if config.version != CONFIG_VERSION {
283            return Err(KlaspError::ConfigVersion {
284                found: config.version,
285                supported: CONFIG_VERSION,
286            });
287        }
288        Ok(config)
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    const MINIMAL_TOML: &str = r#"
297        version = 1
298        [gate]
299        agents = ["claude_code"]
300    "#;
301
302    fn write_klasp_toml(dir: &std::path::Path) {
303        std::fs::write(dir.join("klasp.toml"), MINIMAL_TOML).expect("write klasp.toml");
304    }
305
306    /// All `load()` cases live in one `#[test]` because both `CLAUDE_PROJECT_DIR`
307    /// and cwd are process-global; running them in parallel under cargo's
308    /// default test harness would clobber each other.
309    #[test]
310    fn load_cwd_guard_cases() {
311        struct Guard {
312            cwd: std::path::PathBuf,
313            env: Option<String>,
314        }
315        impl Drop for Guard {
316            fn drop(&mut self) {
317                match &self.env {
318                    Some(v) => std::env::set_var(CLAUDE_PROJECT_DIR_ENV, v),
319                    None => std::env::remove_var(CLAUDE_PROJECT_DIR_ENV),
320                }
321                let _ = std::env::set_current_dir(&self.cwd);
322            }
323        }
324        let _guard = Guard {
325            cwd: std::env::current_dir().expect("current_dir"),
326            env: std::env::var(CLAUDE_PROJECT_DIR_ENV).ok(),
327        };
328
329        // Case 1: cwd inside env_root with env candidate present → uses env candidate.
330        {
331            let env_root = tempfile::tempdir().expect("tempdir env_root");
332            let sub = env_root.path().join("sub");
333            std::fs::create_dir_all(&sub).expect("mkdir sub");
334            write_klasp_toml(env_root.path());
335
336            std::env::set_var(CLAUDE_PROJECT_DIR_ENV, env_root.path());
337            std::env::set_current_dir(&sub).expect("cd sub");
338
339            let cfg = ConfigV1::load(env_root.path()).expect("case 1: should load");
340            assert_eq!(cfg.version, 1, "case 1: version mismatch");
341        }
342
343        // Case 2: cwd outside env_root, cwd_root candidate present → uses cwd_root.
344        {
345            let env_root = tempfile::tempdir().expect("tempdir env_root");
346            let cwd_root = tempfile::tempdir().expect("tempdir cwd_root");
347            write_klasp_toml(env_root.path());
348            write_klasp_toml(cwd_root.path());
349
350            std::env::set_var(CLAUDE_PROJECT_DIR_ENV, env_root.path());
351            std::env::set_current_dir(cwd_root.path()).expect("cd cwd_root");
352
353            let cfg = ConfigV1::load(cwd_root.path()).expect("case 2: should load");
354            assert_eq!(cfg.version, 1, "case 2: version mismatch");
355        }
356
357        // Case 3: cwd outside env_root, no cwd_root candidate → ConfigNotFound.
358        {
359            let env_root = tempfile::tempdir().expect("tempdir env_root");
360            let cwd_root = tempfile::tempdir().expect("tempdir cwd_root");
361            write_klasp_toml(env_root.path());
362
363            std::env::set_var(CLAUDE_PROJECT_DIR_ENV, env_root.path());
364            std::env::set_current_dir(cwd_root.path()).expect("cd cwd_root");
365
366            let err =
367                ConfigV1::load(cwd_root.path()).expect_err("case 3: should be ConfigNotFound");
368            assert!(
369                matches!(err, KlaspError::ConfigNotFound { .. }),
370                "case 3: expected ConfigNotFound, got {err:?}"
371            );
372        }
373
374        // Case 4: env var points at a non-existent path → falls through to cwd_root.
375        {
376            let cwd_root = tempfile::tempdir().expect("tempdir cwd_root");
377            write_klasp_toml(cwd_root.path());
378            let bogus = cwd_root.path().join("does-not-exist");
379
380            std::env::set_var(CLAUDE_PROJECT_DIR_ENV, &bogus);
381            std::env::set_current_dir(cwd_root.path()).expect("cd cwd_root");
382
383            let cfg = ConfigV1::load(cwd_root.path()).expect("case 4: should load cwd candidate");
384            assert_eq!(cfg.version, 1, "case 4: version mismatch");
385        }
386
387        // Case 5: env var unset → uses cwd_root candidate (regression check on the
388        // pre-#65 happy path; distinct branch from case 2).
389        {
390            let cwd_root = tempfile::tempdir().expect("tempdir cwd_root");
391            write_klasp_toml(cwd_root.path());
392
393            std::env::remove_var(CLAUDE_PROJECT_DIR_ENV);
394            std::env::set_current_dir(cwd_root.path()).expect("cd cwd_root");
395
396            let cfg = ConfigV1::load(cwd_root.path()).expect("case 5: should load cwd candidate");
397            assert_eq!(cfg.version, 1, "case 5: version mismatch");
398        }
399    }
400
401    #[test]
402    fn parses_minimal_config() {
403        let toml = r#"
404            version = 1
405
406            [gate]
407            agents = ["claude_code"]
408        "#;
409        let config = ConfigV1::parse(toml).expect("should parse");
410        assert_eq!(config.version, 1);
411        assert_eq!(config.gate.agents, vec!["claude_code"]);
412        assert_eq!(config.gate.policy, VerdictPolicy::AnyFail);
413        assert!(config.checks.is_empty());
414    }
415
416    #[test]
417    fn parses_full_config() {
418        let toml = r#"
419            version = 1
420
421            [gate]
422            agents = ["claude_code"]
423            policy = "any_fail"
424
425            [[checks]]
426            name = "ruff"
427            triggers = [{ on = ["commit"] }]
428            timeout_secs = 60
429            [checks.source]
430            type = "shell"
431            command = "ruff check ."
432
433            [[checks]]
434            name = "pytest"
435            triggers = [{ on = ["push"] }]
436            [checks.source]
437            type = "shell"
438            command = "pytest -q"
439        "#;
440        let config = ConfigV1::parse(toml).expect("should parse");
441        assert_eq!(config.checks.len(), 2);
442        assert_eq!(config.checks[0].name, "ruff");
443        assert_eq!(config.checks[0].timeout_secs, Some(60));
444        assert!(matches!(
445            &config.checks[0].source,
446            CheckSourceConfig::Shell { command } if command == "ruff check ."
447        ));
448        assert_eq!(config.checks[0].triggers[0].on, vec!["commit"]);
449        assert!(config.checks[1].timeout_secs.is_none());
450    }
451
452    #[test]
453    fn rejects_wrong_version() {
454        let toml = r#"
455            version = 2
456            [gate]
457        "#;
458        let err = ConfigV1::parse(toml).expect_err("should reject");
459        match err {
460            KlaspError::ConfigVersion { found, supported } => {
461                assert_eq!(found, 2);
462                assert_eq!(supported, CONFIG_VERSION);
463            }
464            other => panic!("expected ConfigVersion, got {other:?}"),
465        }
466    }
467
468    #[test]
469    fn rejects_missing_version() {
470        let toml = r#"
471            [gate]
472            agents = []
473        "#;
474        let err = ConfigV1::parse(toml).expect_err("should reject");
475        assert!(matches!(err, KlaspError::ConfigParse(_)));
476    }
477
478    #[test]
479    fn rejects_missing_gate() {
480        let toml = "version = 1";
481        let err = ConfigV1::parse(toml).expect_err("should reject");
482        assert!(matches!(err, KlaspError::ConfigParse(_)));
483    }
484
485    #[test]
486    fn rejects_unknown_source_type() {
487        // `pre_commit` was an unknown recipe in v0.1, `fallow` was unknown
488        // in v0.2 W4, `pytest` / `cargo` were unknown in W5; all are
489        // first-class variants now. Pivot to a recipe that hasn't landed
490        // yet (placeholder for whichever recipe lands next post-W6) so
491        // the additive-forwards-incompat contract keeps its regression
492        // coverage.
493        let toml = r#"
494            version = 1
495            [gate]
496
497            [[checks]]
498            name = "future-recipe"
499            [checks.source]
500            type = "future_recipe_not_yet_landed"
501            command = "noop"
502        "#;
503        let err = ConfigV1::parse(toml).expect_err("should reject");
504        assert!(matches!(err, KlaspError::ConfigParse(_)));
505    }
506
507    #[test]
508    fn rejects_unknown_field_on_pre_commit_variant() {
509        // A typo like `hook_stages` (plural) on the `pre_commit` variant
510        // would silently parse as the default `None` without
511        // `#[serde(deny_unknown_fields)]` on the tagged enum — defaulting
512        // to `--hook-stage pre-commit` regardless of the user's intent.
513        // Locks in the variant-level footgun closure so a future serde
514        // refactor (e.g. `untagged`) doesn't silently regress it.
515        let toml = r#"
516            version = 1
517            [gate]
518
519            [[checks]]
520            name = "typo-test"
521            [checks.source]
522            type = "pre_commit"
523            hook_stages = "pre-push"
524        "#;
525        let err = ConfigV1::parse(toml).expect_err("should reject");
526        assert!(matches!(err, KlaspError::ConfigParse(_)));
527    }
528
529    #[test]
530    fn parses_pre_commit_recipe_minimal() {
531        // Bare `type = "pre_commit"` with no extra fields: both optional
532        // fields default to `None` and the recipe applies its own
533        // run-time defaults (`hook_stage = "pre-commit"`,
534        // `config_path = ".pre-commit-config.yaml"`).
535        let toml = r#"
536            version = 1
537            [gate]
538
539            [[checks]]
540            name = "lint"
541            [checks.source]
542            type = "pre_commit"
543        "#;
544        let config = ConfigV1::parse(toml).expect("should parse");
545        assert_eq!(config.checks.len(), 1);
546        match &config.checks[0].source {
547            CheckSourceConfig::PreCommit {
548                hook_stage,
549                config_path,
550            } => {
551                assert!(hook_stage.is_none());
552                assert!(config_path.is_none());
553            }
554            other => panic!("expected PreCommit, got {other:?}"),
555        }
556    }
557
558    #[test]
559    fn parses_pre_commit_recipe_with_fields() {
560        let toml = r#"
561            version = 1
562            [gate]
563
564            [[checks]]
565            name = "lint"
566            [checks.source]
567            type = "pre_commit"
568            hook_stage = "pre-push"
569            config_path = "tools/pre-commit.yaml"
570        "#;
571        let config = ConfigV1::parse(toml).expect("should parse");
572        match &config.checks[0].source {
573            CheckSourceConfig::PreCommit {
574                hook_stage,
575                config_path,
576            } => {
577                assert_eq!(hook_stage.as_deref(), Some("pre-push"));
578                assert_eq!(
579                    config_path
580                        .as_ref()
581                        .map(|p| p.to_string_lossy().into_owned()),
582                    Some("tools/pre-commit.yaml".to_string())
583                );
584            }
585            other => panic!("expected PreCommit, got {other:?}"),
586        }
587    }
588
589    #[test]
590    fn parses_fallow_recipe_minimal() {
591        // Bare `type = "fallow"` with no extra fields: both optional
592        // fields default to `None` and the recipe applies its own
593        // run-time defaults (`base = "${KLASP_BASE_REF}"`,
594        // `config_path = <fallow's own discovery>`).
595        let toml = r#"
596            version = 1
597            [gate]
598
599            [[checks]]
600            name = "audit"
601            [checks.source]
602            type = "fallow"
603        "#;
604        let config = ConfigV1::parse(toml).expect("should parse");
605        assert_eq!(config.checks.len(), 1);
606        match &config.checks[0].source {
607            CheckSourceConfig::Fallow { config_path, base } => {
608                assert!(config_path.is_none());
609                assert!(base.is_none());
610            }
611            other => panic!("expected Fallow, got {other:?}"),
612        }
613    }
614
615    #[test]
616    fn parses_fallow_recipe_with_fields() {
617        let toml = r#"
618            version = 1
619            [gate]
620
621            [[checks]]
622            name = "audit"
623            [checks.source]
624            type = "fallow"
625            config_path = "tools/.fallowrc.json"
626            base = "origin/main"
627        "#;
628        let config = ConfigV1::parse(toml).expect("should parse");
629        match &config.checks[0].source {
630            CheckSourceConfig::Fallow { config_path, base } => {
631                assert_eq!(
632                    config_path
633                        .as_ref()
634                        .map(|p| p.to_string_lossy().into_owned()),
635                    Some("tools/.fallowrc.json".to_string())
636                );
637                assert_eq!(base.as_deref(), Some("origin/main"));
638            }
639            other => panic!("expected Fallow, got {other:?}"),
640        }
641    }
642
643    #[test]
644    fn rejects_unknown_field_on_fallow_variant() {
645        // Same footgun closure as the pre_commit variant: a typo like
646        // `bases` (plural) on the `fallow` variant must fail at parse
647        // time rather than silently default to `None`.
648        let toml = r#"
649            version = 1
650            [gate]
651
652            [[checks]]
653            name = "audit"
654            [checks.source]
655            type = "fallow"
656            bases = "main"
657        "#;
658        let err = ConfigV1::parse(toml).expect_err("should reject");
659        assert!(matches!(err, KlaspError::ConfigParse(_)));
660    }
661
662    #[test]
663    fn parses_pytest_recipe_minimal() {
664        // Bare `type = "pytest"` with no extra fields: every optional
665        // field defaults to `None` and the recipe applies its own
666        // run-time defaults.
667        let toml = r#"
668            version = 1
669            [gate]
670
671            [[checks]]
672            name = "tests"
673            [checks.source]
674            type = "pytest"
675        "#;
676        let config = ConfigV1::parse(toml).expect("should parse");
677        assert_eq!(config.checks.len(), 1);
678        match &config.checks[0].source {
679            CheckSourceConfig::Pytest {
680                extra_args,
681                config_path,
682                junit_xml,
683            } => {
684                assert!(extra_args.is_none());
685                assert!(config_path.is_none());
686                assert!(junit_xml.is_none());
687            }
688            other => panic!("expected Pytest, got {other:?}"),
689        }
690    }
691
692    #[test]
693    fn parses_pytest_recipe_with_fields() {
694        let toml = r#"
695            version = 1
696            [gate]
697
698            [[checks]]
699            name = "tests"
700            [checks.source]
701            type = "pytest"
702            extra_args = "-x -q tests/"
703            config_path = "pytest.ini"
704            junit_xml = true
705        "#;
706        let config = ConfigV1::parse(toml).expect("should parse");
707        match &config.checks[0].source {
708            CheckSourceConfig::Pytest {
709                extra_args,
710                config_path,
711                junit_xml,
712            } => {
713                assert_eq!(extra_args.as_deref(), Some("-x -q tests/"));
714                assert_eq!(
715                    config_path
716                        .as_ref()
717                        .map(|p| p.to_string_lossy().into_owned()),
718                    Some("pytest.ini".to_string())
719                );
720                assert_eq!(*junit_xml, Some(true));
721            }
722            other => panic!("expected Pytest, got {other:?}"),
723        }
724    }
725
726    #[test]
727    fn rejects_unknown_field_on_pytest_variant() {
728        // Same footgun closure as the pre_commit / fallow variants: a
729        // typo on the `pytest` variant must fail at parse time.
730        let toml = r#"
731            version = 1
732            [gate]
733
734            [[checks]]
735            name = "tests"
736            [checks.source]
737            type = "pytest"
738            extra_arg = "-x"
739        "#;
740        let err = ConfigV1::parse(toml).expect_err("should reject");
741        assert!(matches!(err, KlaspError::ConfigParse(_)));
742    }
743
744    #[test]
745    fn parses_cargo_recipe_minimal() {
746        // `type = "cargo"` requires `subcommand`; the other fields
747        // default to `None` and the recipe runs across the workspace.
748        let toml = r#"
749            version = 1
750            [gate]
751
752            [[checks]]
753            name = "build"
754            [checks.source]
755            type = "cargo"
756            subcommand = "check"
757        "#;
758        let config = ConfigV1::parse(toml).expect("should parse");
759        assert_eq!(config.checks.len(), 1);
760        match &config.checks[0].source {
761            CheckSourceConfig::Cargo {
762                subcommand,
763                extra_args,
764                package,
765            } => {
766                assert_eq!(subcommand, "check");
767                assert!(extra_args.is_none());
768                assert!(package.is_none());
769            }
770            other => panic!("expected Cargo, got {other:?}"),
771        }
772    }
773
774    #[test]
775    fn parses_cargo_recipe_with_fields() {
776        let toml = r#"
777            version = 1
778            [gate]
779
780            [[checks]]
781            name = "lint"
782            [checks.source]
783            type = "cargo"
784            subcommand = "clippy"
785            extra_args = "--all-features -- -D warnings"
786            package = "klasp-core"
787        "#;
788        let config = ConfigV1::parse(toml).expect("should parse");
789        match &config.checks[0].source {
790            CheckSourceConfig::Cargo {
791                subcommand,
792                extra_args,
793                package,
794            } => {
795                assert_eq!(subcommand, "clippy");
796                assert_eq!(extra_args.as_deref(), Some("--all-features -- -D warnings"));
797                assert_eq!(package.as_deref(), Some("klasp-core"));
798            }
799            other => panic!("expected Cargo, got {other:?}"),
800        }
801    }
802
803    #[test]
804    fn rejects_cargo_recipe_missing_subcommand() {
805        // `subcommand` is required (no `#[serde(default)]`), so a
806        // bare `type = "cargo"` must fail at parse time.
807        let toml = r#"
808            version = 1
809            [gate]
810
811            [[checks]]
812            name = "build"
813            [checks.source]
814            type = "cargo"
815        "#;
816        let err = ConfigV1::parse(toml).expect_err("should reject");
817        assert!(matches!(err, KlaspError::ConfigParse(_)));
818    }
819
820    #[test]
821    fn rejects_unknown_field_on_cargo_variant() {
822        let toml = r#"
823            version = 1
824            [gate]
825
826            [[checks]]
827            name = "build"
828            [checks.source]
829            type = "cargo"
830            subcommand = "check"
831            packages = "klasp-core"
832        "#;
833        let err = ConfigV1::parse(toml).expect_err("should reject");
834        assert!(matches!(err, KlaspError::ConfigParse(_)));
835    }
836
837    // ── GateConfig.parallel field tests ─────────────────────────────────────
838
839    #[test]
840    fn parallel_field_defaults_to_false_when_omitted() {
841        let toml = r#"
842            version = 1
843            [gate]
844            agents = ["claude_code"]
845        "#;
846        let config = ConfigV1::parse(toml).expect("should parse");
847        assert!(!config.gate.parallel, "parallel should default to false");
848    }
849
850    #[test]
851    fn parallel_field_parses_true() {
852        let toml = r#"
853            version = 1
854            [gate]
855            agents = ["claude_code"]
856            parallel = true
857        "#;
858        let config = ConfigV1::parse(toml).expect("should parse");
859        assert!(config.gate.parallel, "parallel = true should parse");
860    }
861
862    #[test]
863    fn parallel_field_parses_explicit_false() {
864        let toml = r#"
865            version = 1
866            [gate]
867            agents = ["claude_code"]
868            parallel = false
869        "#;
870        let config = ConfigV1::parse(toml).expect("should parse");
871        assert!(!config.gate.parallel, "parallel = false should parse");
872    }
873
874    #[test]
875    fn rejects_missing_check_name() {
876        let toml = r#"
877            version = 1
878            [gate]
879
880            [[checks]]
881            [checks.source]
882            type = "shell"
883            command = "echo"
884        "#;
885        let err = ConfigV1::parse(toml).expect_err("should reject");
886        assert!(matches!(err, KlaspError::ConfigParse(_)));
887    }
888
889    // ── discover_config_for_path unit tests ─────────────────────────────────
890
891    #[test]
892    fn discover_returns_none_for_path_outside_repo_root() {
893        let tmp = tempfile::TempDir::new().unwrap();
894        let repo = tmp.path().join("repo");
895        std::fs::create_dir_all(&repo).unwrap();
896        std::fs::write(repo.join("klasp.toml"), MINIMAL_TOML).unwrap();
897
898        let outside = tmp.path().join("other");
899        std::fs::create_dir_all(&outside).unwrap();
900        assert!(discover_config_for_path(&outside, &repo).is_none());
901    }
902
903    #[test]
904    fn discover_finds_config_at_repo_root_for_deep_path() {
905        let tmp = tempfile::TempDir::new().unwrap();
906        let repo = tmp.path().join("repo");
907        let deep = repo.join("a").join("b").join("c");
908        std::fs::create_dir_all(&deep).unwrap();
909        std::fs::write(repo.join("klasp.toml"), MINIMAL_TOML).unwrap();
910
911        let found = discover_config_for_path(&deep, &repo).unwrap();
912        assert_eq!(found, repo.canonicalize().unwrap().join("klasp.toml"));
913    }
914
915    #[test]
916    fn discover_prefers_nearest_config_over_root() {
917        let tmp = tempfile::TempDir::new().unwrap();
918        let repo = tmp.path().join("repo");
919        let pkg = repo.join("packages").join("web");
920        std::fs::create_dir_all(&pkg).unwrap();
921        std::fs::write(repo.join("klasp.toml"), MINIMAL_TOML).unwrap();
922        std::fs::write(pkg.join("klasp.toml"), MINIMAL_TOML).unwrap();
923
924        let found = discover_config_for_path(&pkg, &repo).unwrap();
925        assert_eq!(found, pkg.canonicalize().unwrap().join("klasp.toml"));
926    }
927
928    #[test]
929    fn discover_starts_from_parent_when_given_a_file() {
930        let tmp = tempfile::TempDir::new().unwrap();
931        let repo = tmp.path().join("repo");
932        let pkg = repo.join("packages").join("web");
933        std::fs::create_dir_all(&pkg).unwrap();
934        std::fs::write(repo.join("klasp.toml"), MINIMAL_TOML).unwrap();
935        std::fs::write(pkg.join("klasp.toml"), MINIMAL_TOML).unwrap();
936        std::fs::write(pkg.join("index.ts"), "").unwrap();
937
938        let found = discover_config_for_path(&pkg.join("index.ts"), &repo).unwrap();
939        assert_eq!(found, pkg.canonicalize().unwrap().join("klasp.toml"));
940    }
941
942    #[test]
943    fn discover_returns_none_when_no_config_in_chain() {
944        let tmp = tempfile::TempDir::new().unwrap();
945        let repo = tmp.path().join("repo");
946        let deep = repo.join("a").join("b");
947        std::fs::create_dir_all(&deep).unwrap();
948        // No klasp.toml anywhere.
949
950        assert!(discover_config_for_path(&deep, &repo).is_none());
951    }
952}