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