1use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{KlaspError, Result};
14use crate::verdict::VerdictPolicy;
15
16pub const CONFIG_VERSION: u32 = 1;
19
20pub 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 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 #[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
103 hook_stage: Option<String>,
104
105 #[serde(default, skip_serializing_if = "Option::is_none")]
109 config_path: Option<PathBuf>,
110 },
111 Fallow {
112 #[serde(default, skip_serializing_if = "Option::is_none")]
116 config_path: Option<PathBuf>,
117
118 #[serde(default, skip_serializing_if = "Option::is_none")]
126 base: Option<String>,
127 },
128 Pytest {
129 #[serde(default, skip_serializing_if = "Option::is_none")]
133 extra_args: Option<String>,
134
135 #[serde(default, skip_serializing_if = "Option::is_none")]
139 config_path: Option<PathBuf>,
140
141 #[serde(default, skip_serializing_if = "Option::is_none")]
146 junit_xml: Option<bool>,
147 },
148 Cargo {
149 subcommand: String,
156
157 #[serde(default, skip_serializing_if = "Option::is_none")]
161 extra_args: Option<String>,
162
163 #[serde(default, skip_serializing_if = "Option::is_none")]
166 package: Option<String>,
167 },
168}
169
170pub 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
206pub 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
215fn 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 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 (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 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 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 #[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 {
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 {
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 {
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 {
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 {
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 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 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 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 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 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 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 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 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 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 #[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 #[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 assert!(discover_config_for_path(&deep, &repo).is_none());
951 }
952}