1use 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
18pub const CONFIG_VERSION: u32 = 1;
21
22pub 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 pub version: u32,
33
34 pub gate: GateConfig,
35
36 #[serde(default)]
37 pub checks: Vec<CheckConfig>,
38
39 #[serde(default, rename = "trigger")]
43 pub triggers: Vec<UserTriggerConfig>,
44
45 #[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 #[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#[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 Plugin {
125 name: String,
128 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 args: Vec<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
136 settings: Option<serde_json::Value>,
137 },
138 PreCommit {
139 #[serde(default, skip_serializing_if = "Option::is_none")]
143 hook_stage: Option<String>,
144
145 #[serde(default, skip_serializing_if = "Option::is_none")]
149 config_path: Option<PathBuf>,
150 },
151 Fallow {
152 #[serde(default, skip_serializing_if = "Option::is_none")]
156 config_path: Option<PathBuf>,
157
158 #[serde(default, skip_serializing_if = "Option::is_none")]
166 base: Option<String>,
167 },
168 Pytest {
169 #[serde(default, skip_serializing_if = "Option::is_none")]
173 extra_args: Option<String>,
174
175 #[serde(default, skip_serializing_if = "Option::is_none")]
179 config_path: Option<PathBuf>,
180
181 #[serde(default, skip_serializing_if = "Option::is_none")]
186 junit_xml: Option<bool>,
187 },
188 Cargo {
189 subcommand: String,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
201 extra_args: Option<String>,
202
203 #[serde(default, skip_serializing_if = "Option::is_none")]
206 package: Option<String>,
207 },
208}
209
210pub 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
246pub 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
255fn 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 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 (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 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 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 let compiled = validate_user_triggers(&config.triggers)?;
332 let _ = config.compiled.set(compiled);
333 Ok(config)
334 }
335
336 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 #[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 {
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 {
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 {
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 {
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 {
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 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 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 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 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 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 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 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 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 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 #[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 #[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 assert!(discover_config_for_path(&deep, &repo).is_none());
1006 }
1007}