1use std::path::{Path, PathBuf};
8
9use khive_types::namespace::Namespace;
10use serde::Deserialize;
11use thiserror::Error;
12
13use crate::presentation::OutputFormat;
14
15#[derive(Debug, Error)]
19pub enum ConfigError {
20 #[error("config file I/O: {0}")]
21 Io(#[from] std::io::Error),
22
23 #[error("config TOML parse error in {path}: {source}")]
24 Parse {
25 path: PathBuf,
26 #[source]
27 source: toml::de::Error,
28 },
29
30 #[error("exactly one engine must be marked `default = true`; found {found}")]
31 DefaultCount { found: usize },
32
33 #[error("duplicate engine name: {name:?}")]
34 DuplicateName { name: String },
35
36 #[error(
37 "engine {name:?}: model {model:?} is not a recognized lattice_embed::EmbeddingModel name"
38 )]
39 UnknownModel { name: String, model: String },
40
41 #[error("engine {name:?}: fusion_weight must be > 0, got {value}")]
42 InvalidFusionWeight { name: String, value: f64 },
43
44 #[error("actor.id {id:?} is not a valid namespace: {reason}")]
45 InvalidActorId { id: String, reason: String },
46
47 #[error("duplicate backend name: {name:?}")]
48 DuplicateBackendName { name: String },
49
50 #[error(
51 "[packs.{pack}].backend = {backend:?} references an unknown backend; \
52 defined backends: {defined}"
53 )]
54 UnknownPackBackend {
55 pack: String,
56 backend: String,
57 defined: String,
58 },
59
60 #[error(
61 "[[backends]] entry {name:?}: field `{field}` is not yet supported; \
62 remove it from the config or wait for a future release that implements it"
63 )]
64 UnsupportedBackendField { name: String, field: &'static str },
65
66 #[error(
67 "top-level `db = {value:?}` is not a supported config-file key; \
68 use `--db` / `KHIVE_DB` to select a single-file database, or \
69 `[[backends]].path` to declare storage backend topology"
70 )]
71 UnsupportedTopLevelDb { value: String },
72
73 #[error("[[git_write.allowed]] entry {repo:?}: {reason}")]
74 InvalidGitWriteEntry { repo: String, reason: String },
75}
76
77#[derive(Debug, Clone, Deserialize)]
81pub struct EngineConfig {
82 pub name: String,
84
85 pub model: String,
90
91 #[serde(default)]
94 pub default: bool,
95
96 pub fusion_weight: Option<f64>,
106
107 pub dims: Option<u32>,
113}
114
115#[derive(Debug, Clone, Deserialize, Default)]
136pub struct ActorConfig {
137 #[serde(default)]
143 pub id: Option<String>,
144
145 #[serde(default)]
148 pub display_name: Option<String>,
149
150 #[serde(default)]
156 pub visible_namespaces: Option<Vec<String>>,
157
158 #[serde(default)]
170 pub allowed_outbound_namespaces: Vec<String>,
171}
172
173#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
177#[serde(rename_all = "lowercase")]
178pub enum BackendKind {
179 #[default]
181 Sqlite,
182 Memory,
184}
185
186#[derive(Debug, Clone, Deserialize)]
203pub struct BackendConfig {
204 pub name: String,
206 #[serde(default)]
208 pub kind: BackendKind,
209 pub path: Option<std::path::PathBuf>,
212 pub cache_mb: Option<u32>,
214 pub journal_mode: Option<String>,
216 #[serde(default)]
218 pub read_only: bool,
219}
220
221#[derive(Debug, Clone, Deserialize)]
231pub struct PackConfig {
232 pub backend: String,
234}
235
236#[derive(Debug, Clone, Deserialize)]
263#[serde(tag = "backend", rename_all = "lowercase", deny_unknown_fields)]
264pub enum BlobConfig {
265 Fs {
269 #[serde(default)]
270 root: Option<String>,
271 #[serde(default)]
272 floor_bytes: Option<u64>,
273 },
274 S3 {
279 bucket: String,
280 region: String,
281 #[serde(default)]
282 endpoint: Option<String>,
283 #[serde(default)]
284 prefix: Option<String>,
285 #[serde(default)]
286 allow_http: Option<bool>,
287 },
288}
289
290#[derive(Debug, Clone, Deserialize, Default)]
293pub struct StorageSectionConfig {
294 #[serde(default)]
299 pub blob: Option<BlobConfig>,
300}
301
302#[derive(Debug, Clone, Deserialize)]
314pub struct GitWriteEntryConfig {
315 pub repo: String,
317 pub branches: Vec<String>,
320}
321
322#[derive(Debug, Clone, Deserialize, Default)]
334pub struct GitWriteSectionConfig {
335 #[serde(default)]
336 pub allowed: Vec<GitWriteEntryConfig>,
337}
338
339#[derive(Debug, Clone, Deserialize, Default)]
350pub struct KhiveConfig {
351 #[serde(default)]
357 pub db: Option<String>,
358
359 #[serde(default)]
361 pub engines: Vec<EngineConfig>,
362
363 #[serde(default)]
371 pub actor: ActorConfig,
372
373 #[serde(default)]
375 pub runtime: RuntimeSectionConfig,
376
377 #[serde(default)]
382 pub backends: Vec<BackendConfig>,
383
384 #[serde(default)]
390 pub packs: std::collections::HashMap<String, PackConfig>,
391
392 #[serde(default)]
396 pub git_write: GitWriteSectionConfig,
397
398 #[serde(default)]
401 pub storage: StorageSectionConfig,
402}
403
404#[derive(Debug, Clone, Deserialize, Default)]
410pub struct RuntimeSectionConfig {
411 #[serde(default)]
418 pub brain_profile: Option<String>,
419
420 #[serde(default)]
427 pub default_output_format: Option<OutputFormat>,
428}
429
430impl KhiveConfig {
431 pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
447 let resolved = match path {
448 Some(p) => p.to_path_buf(),
449 None => PathBuf::from(".khive/config.toml"),
450 };
451
452 if !resolved.exists() {
453 return Ok(None);
454 }
455
456 let raw = std::fs::read_to_string(&resolved)?;
457 let cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
458 path: resolved,
459 source,
460 })?;
461 cfg.validate()?;
462 Ok(Some(cfg))
463 }
464
465 pub fn load_with_home_fallback(
485 path: Option<&Path>,
486 db_path: Option<&Path>,
487 ) -> Result<Option<Self>, ConfigError> {
488 if let Some(p) = path {
490 return Self::load(Some(p));
491 }
492
493 let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
495 let home_root = std::env::var_os("HOME").map(PathBuf::from);
496 Self::load_with_roots(&project_root, home_root.as_deref(), db_path)
497 }
498
499 pub(crate) fn load_with_roots(
508 project_root: &Path,
509 home_root: Option<&Path>,
510 db_path: Option<&Path>,
511 ) -> Result<Option<Self>, ConfigError> {
512 let tier2 = project_root.join("khive.toml");
514 if tier2.exists() {
515 return Self::load(Some(&tier2));
516 }
517
518 let tier3 = Self::project_config_anchor_dir(db_path, project_root).join("config.toml");
521 if tier3.exists() {
522 return Self::load(Some(&tier3));
523 }
524
525 if let Some(home) = home_root {
527 let tier4 = home.join(".khive/config.toml");
528 if tier4.exists() {
529 return Self::load(Some(&tier4));
530 }
531 }
532
533 Ok(None)
534 }
535
536 fn project_config_anchor_dir(db_path: Option<&Path>, project_root: &Path) -> PathBuf {
562 let Some(db_path) = db_path else {
563 return project_root.join(".khive");
564 };
565
566 let absolute = std::fs::canonicalize(db_path).unwrap_or_else(|_| {
567 if db_path.is_absolute() {
568 db_path.to_path_buf()
569 } else {
570 project_root.join(db_path)
571 }
572 });
573
574 let db_dir = absolute.parent().map(Path::to_path_buf).unwrap_or(absolute);
575
576 if db_dir.file_name().is_some_and(|name| name == ".khive") {
577 db_dir
578 } else {
579 db_dir.join(".khive")
580 }
581 }
582
583 pub fn validate(&self) -> Result<(), ConfigError> {
593 if let Some(value) = self.db.as_deref() {
598 if !value.is_empty() {
599 return Err(ConfigError::UnsupportedTopLevelDb {
600 value: value.to_string(),
601 });
602 }
603 }
604
605 if let Some(id) = self.actor.id.as_deref() {
608 if id.is_empty() {
609 return Err(ConfigError::InvalidActorId {
610 id: id.to_string(),
611 reason: "actor.id must not be empty; remove the key or provide a value"
612 .to_string(),
613 });
614 }
615 Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
616 id: id.to_string(),
617 reason: e.to_string(),
618 })?;
619 }
620
621 if let Some(ref vis) = self.actor.visible_namespaces {
622 for ns_str in vis {
623 if ns_str.is_empty() {
624 return Err(ConfigError::InvalidActorId {
625 id: ns_str.clone(),
626 reason: "visible_namespaces entries must not be empty".to_string(),
627 });
628 }
629 Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
630 id: ns_str.clone(),
631 reason: format!("invalid visible namespace: {e}"),
632 })?;
633 }
634 }
635
636 for ns_str in &self.actor.allowed_outbound_namespaces {
638 if ns_str.is_empty() {
639 return Err(ConfigError::InvalidActorId {
640 id: ns_str.clone(),
641 reason: "allowed_outbound_namespaces entries must not be empty".to_string(),
642 });
643 }
644 Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
645 id: ns_str.clone(),
646 reason: format!("invalid allowed_outbound_namespaces entry: {e}"),
647 })?;
648 }
649
650 if !self.backends.is_empty() {
652 let mut seen_backends = std::collections::HashSet::new();
653 for backend in &self.backends {
654 if !seen_backends.insert(backend.name.clone()) {
655 return Err(ConfigError::DuplicateBackendName {
656 name: backend.name.clone(),
657 });
658 }
659
660 if backend.cache_mb.is_some() {
663 return Err(ConfigError::UnsupportedBackendField {
664 name: backend.name.clone(),
665 field: "cache_mb",
666 });
667 }
668 if backend.journal_mode.is_some() {
669 return Err(ConfigError::UnsupportedBackendField {
670 name: backend.name.clone(),
671 field: "journal_mode",
672 });
673 }
674 }
675
676 let defined: Vec<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
678 for (pack_name, pack_cfg) in &self.packs {
679 if !defined.contains(&pack_cfg.backend.as_str()) {
680 return Err(ConfigError::UnknownPackBackend {
681 pack: pack_name.clone(),
682 backend: pack_cfg.backend.clone(),
683 defined: defined.join(", "),
684 });
685 }
686 }
687 }
688
689 for entry in &self.git_write.allowed {
696 if entry.repo.trim().is_empty() {
697 return Err(ConfigError::InvalidGitWriteEntry {
698 repo: entry.repo.clone(),
699 reason: "repo must not be empty".to_string(),
700 });
701 }
702 if !Path::new(&entry.repo).is_absolute() {
703 return Err(ConfigError::InvalidGitWriteEntry {
704 repo: entry.repo.clone(),
705 reason: "repo must be an absolute path".to_string(),
706 });
707 }
708 if entry.branches.is_empty() {
709 return Err(ConfigError::InvalidGitWriteEntry {
710 repo: entry.repo.clone(),
711 reason: "branches must not be empty".to_string(),
712 });
713 }
714 if entry.branches.iter().any(|b| b.trim().is_empty()) {
715 return Err(ConfigError::InvalidGitWriteEntry {
716 repo: entry.repo.clone(),
717 reason: "branches entries must not be empty".to_string(),
718 });
719 }
720 if let Some(bad) = entry.branches.iter().find(|b| b.matches('*').count() > 1) {
725 return Err(ConfigError::InvalidGitWriteEntry {
726 repo: entry.repo.clone(),
727 reason: format!(
728 "branch pattern {bad:?} must contain at most one '*' wildcard (ADR-108)"
729 ),
730 });
731 }
732 }
733
734 if self.engines.is_empty() {
735 return Ok(());
736 }
737
738 let mut seen_names = std::collections::HashSet::new();
739 for engine in &self.engines {
740 if !seen_names.insert(engine.name.clone()) {
741 return Err(ConfigError::DuplicateName {
742 name: engine.name.clone(),
743 });
744 }
745 }
746
747 let default_count = self.engines.iter().filter(|e| e.default).count();
748 if default_count != 1 {
749 return Err(ConfigError::DefaultCount {
750 found: default_count,
751 });
752 }
753
754 for engine in &self.engines {
757 if let Some(w) = engine.fusion_weight {
758 if !w.is_finite() || w <= 0.0 {
759 return Err(ConfigError::InvalidFusionWeight {
760 name: engine.name.clone(),
761 value: w,
762 });
763 }
764 }
765 }
766
767 Ok(())
768 }
769
770 pub fn default_engine(&self) -> Option<&EngineConfig> {
772 self.engines.iter().find(|e| e.default)
773 }
774}
775
776pub fn config_from_env() -> KhiveConfig {
786 let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
787 .ok()
788 .filter(|s| !s.trim().is_empty());
789 let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
790 .ok()
791 .unwrap_or_default();
792 let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
793 .into_iter()
794 .filter(|s| !s.is_empty())
795 .collect();
796
797 if primary_model.is_none() && additional.is_empty() {
798 return KhiveConfig::default();
799 }
800
801 tracing::info!(
802 "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
803 );
804
805 let mut engines = Vec::new();
806
807 if let Some(model) = primary_model {
808 engines.push(EngineConfig {
809 name: "default".to_string(),
810 model,
811 default: true,
812 fusion_weight: None,
813 dims: None,
814 });
815 }
816
817 for (i, model) in additional.into_iter().enumerate() {
818 engines.push(EngineConfig {
819 name: format!("engine-{}", i + 1),
820 model,
821 default: false,
822 fusion_weight: None,
823 dims: None,
824 });
825 }
826
827 if !engines.is_empty() && !engines.iter().any(|e| e.default) {
830 engines[0].default = true;
831 }
832
833 KhiveConfig {
834 engines,
835 ..KhiveConfig::default()
836 }
837}
838
839#[cfg(test)]
844mod tests {
845 use super::*;
846
847 fn write_toml(dir: &tempfile::TempDir, content: &str) -> PathBuf {
848 let path = dir.path().join("config.toml");
849 std::fs::write(&path, content).unwrap();
850 path
851 }
852
853 #[test]
854 fn test_load_minimal_config() {
855 let dir = tempfile::tempdir().unwrap();
856 let path = write_toml(
857 &dir,
858 r#"
859[[engines]]
860name = "x"
861model = "all-minilm-l6-v2"
862default = true
863"#,
864 );
865 let cfg = KhiveConfig::load(Some(&path))
866 .expect("load should succeed")
867 .expect("file should be found");
868 assert_eq!(cfg.engines.len(), 1);
869 assert_eq!(cfg.engines[0].name, "x");
870 assert_eq!(cfg.engines[0].model, "all-minilm-l6-v2");
871 assert!(cfg.engines[0].default);
872 }
873
874 #[test]
875 fn test_default_engine_required_when_engines_present() {
876 let dir = tempfile::tempdir().unwrap();
877 let path = write_toml(
878 &dir,
879 r#"
880[[engines]]
881name = "a"
882model = "all-minilm-l6-v2"
883"#,
884 );
885 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with no default flagged");
886 assert!(
887 matches!(err, ConfigError::DefaultCount { found: 0 }),
888 "expected DefaultCount {{ found: 0 }}, got {err:?}"
889 );
890 }
891
892 #[test]
893 fn test_multiple_default_rejected() {
894 let dir = tempfile::tempdir().unwrap();
895 let path = write_toml(
896 &dir,
897 r#"
898[[engines]]
899name = "a"
900model = "all-minilm-l6-v2"
901default = true
902
903[[engines]]
904name = "b"
905model = "paraphrase-multilingual-minilm-l12-v2"
906default = true
907"#,
908 );
909 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with two defaults");
910 assert!(
911 matches!(err, ConfigError::DefaultCount { found: 2 }),
912 "expected DefaultCount {{ found: 2 }}, got {err:?}"
913 );
914 }
915
916 #[test]
917 fn test_fusion_weight_validation() {
918 let dir = tempfile::tempdir().unwrap();
919 let path = write_toml(
920 &dir,
921 r#"
922[[engines]]
923name = "a"
924model = "all-minilm-l6-v2"
925default = true
926fusion_weight = -0.5
927"#,
928 );
929 let err =
930 KhiveConfig::load(Some(&path)).expect_err("should fail with negative fusion_weight");
931 assert!(
932 matches!(err, ConfigError::InvalidFusionWeight { .. }),
933 "expected InvalidFusionWeight, got {err:?}"
934 );
935
936 let path2 = write_toml(
937 &dir,
938 r#"
939[[engines]]
940name = "a"
941model = "all-minilm-l6-v2"
942default = true
943fusion_weight = 0.0
944"#,
945 );
946 let err2 =
947 KhiveConfig::load(Some(&path2)).expect_err("should fail with zero fusion_weight");
948 assert!(
949 matches!(err2, ConfigError::InvalidFusionWeight { .. }),
950 "expected InvalidFusionWeight, got {err2:?}"
951 );
952 }
953
954 #[test]
955 fn test_env_var_fallback() {
956 let dir = tempfile::tempdir().unwrap();
957 let absent = dir.path().join("missing.toml");
958
959 let loaded = KhiveConfig::load(Some(&absent)).unwrap();
960 assert!(loaded.is_none());
961
962 let primary = "all-minilm-l6-v2".to_string();
965 let additional = vec!["paraphrase-multilingual-minilm-l12-v2".to_string()];
966
967 let mut engines = vec![EngineConfig {
968 name: "default".to_string(),
969 model: primary,
970 default: true,
971 fusion_weight: None,
972 dims: None,
973 }];
974 for (i, model) in additional.into_iter().enumerate() {
975 engines.push(EngineConfig {
976 name: format!("engine-{}", i + 1),
977 model,
978 default: false,
979 fusion_weight: None,
980 dims: None,
981 });
982 }
983 let cfg = KhiveConfig {
984 engines,
985 ..KhiveConfig::default()
986 };
987 cfg.validate().expect("env-derived config should be valid");
988 assert_eq!(cfg.engines.len(), 2);
989 assert!(cfg.default_engine().is_some());
990 assert_eq!(cfg.default_engine().unwrap().name, "default");
991 }
992
993 #[test]
994 fn test_file_overrides_env() {
995 let dir = tempfile::tempdir().unwrap();
996 let path = write_toml(
997 &dir,
998 r#"
999[[engines]]
1000name = "file-engine"
1001model = "all-minilm-l6-v2"
1002default = true
1003"#,
1004 );
1005
1006 let cfg = KhiveConfig::load(Some(&path))
1009 .expect("load should succeed")
1010 .expect("file should be present");
1011 assert_eq!(cfg.engines[0].name, "file-engine");
1012 }
1013
1014 #[test]
1015 fn test_duplicate_engine_names_rejected() {
1016 let dir = tempfile::tempdir().unwrap();
1017 let path = write_toml(
1018 &dir,
1019 r#"
1020[[engines]]
1021name = "shared"
1022model = "all-minilm-l6-v2"
1023default = true
1024
1025[[engines]]
1026name = "shared"
1027model = "paraphrase-multilingual-minilm-l12-v2"
1028"#,
1029 );
1030 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
1031 assert!(
1032 matches!(err, ConfigError::DuplicateName { .. }),
1033 "expected DuplicateName, got {err:?}"
1034 );
1035 }
1036
1037 #[test]
1038 fn test_empty_config_is_valid() {
1039 let dir = tempfile::tempdir().unwrap();
1040 let path = write_toml(&dir, "# no engines\n");
1041 let cfg = KhiveConfig::load(Some(&path))
1042 .expect("load should succeed")
1043 .expect("file should be found");
1044 assert!(cfg.engines.is_empty());
1045 cfg.validate().expect("empty config should be valid");
1046 }
1047
1048 #[test]
1049 fn test_multi_engine_positive_fusion_weight() {
1050 let dir = tempfile::tempdir().unwrap();
1051 let path = write_toml(
1052 &dir,
1053 r#"
1054[[engines]]
1055name = "primary"
1056model = "all-minilm-l6-v2"
1057default = true
1058fusion_weight = 0.7
1059
1060[[engines]]
1061name = "secondary"
1062model = "paraphrase-multilingual-minilm-l12-v2"
1063fusion_weight = 0.3
1064"#,
1065 );
1066 let cfg = KhiveConfig::load(Some(&path))
1067 .expect("load should succeed")
1068 .expect("file should be found");
1069 assert_eq!(cfg.engines.len(), 2);
1070 assert_eq!(cfg.engines[0].fusion_weight, Some(0.7));
1071 assert_eq!(cfg.engines[1].fusion_weight, Some(0.3));
1072 }
1073
1074 #[test]
1075 fn test_actor_id_parsed() {
1076 let dir = tempfile::tempdir().unwrap();
1077 let path = write_toml(
1078 &dir,
1079 r#"
1080[actor]
1081id = "lambda:khive"
1082display_name = "example actor"
1083"#,
1084 );
1085 let cfg = KhiveConfig::load(Some(&path))
1086 .expect("load should succeed")
1087 .expect("file should be found");
1088 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:khive"));
1089 assert_eq!(cfg.actor.display_name.as_deref(), Some("example actor"));
1090 assert!(cfg.engines.is_empty());
1091 }
1092
1093 #[test]
1094 fn test_actor_and_engines_together() {
1095 let dir = tempfile::tempdir().unwrap();
1096 let path = write_toml(
1097 &dir,
1098 r#"
1099[actor]
1100id = "lambda:test"
1101
1102[[engines]]
1103name = "default"
1104model = "all-minilm-l6-v2"
1105default = true
1106"#,
1107 );
1108 let cfg = KhiveConfig::load(Some(&path))
1109 .expect("load should succeed")
1110 .expect("file should be found");
1111 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:test"));
1112 assert_eq!(cfg.engines.len(), 1);
1113 }
1114
1115 #[test]
1116 fn test_actor_absent_defaults_to_none() {
1117 let dir = tempfile::tempdir().unwrap();
1118 let path = write_toml(
1119 &dir,
1120 r#"
1121[[engines]]
1122name = "x"
1123model = "all-minilm-l6-v2"
1124default = true
1125"#,
1126 );
1127 let cfg = KhiveConfig::load(Some(&path))
1128 .expect("load should succeed")
1129 .expect("file should be found");
1130 assert!(
1131 cfg.actor.id.is_none(),
1132 "actor.id must be None when [actor] section is absent"
1133 );
1134 }
1135
1136 #[test]
1137 fn test_load_with_home_fallback_no_files() {
1138 let project_dir = tempfile::tempdir().unwrap();
1139 let home_dir = tempfile::tempdir().unwrap();
1140 let result = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None);
1141 assert!(
1142 result.expect("no error expected").is_none(),
1143 "should return None when no config files exist in the given roots"
1144 );
1145 }
1146
1147 #[test]
1148 fn test_load_with_home_fallback_explicit_path() {
1149 let dir = tempfile::tempdir().unwrap();
1150 let path = write_toml(
1151 &dir,
1152 r#"
1153[actor]
1154id = "lambda:explicit"
1155"#,
1156 );
1157 let cfg = KhiveConfig::load_with_home_fallback(Some(&path), None)
1158 .expect("no error expected")
1159 .expect("file found");
1160 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:explicit"));
1161 }
1162
1163 #[test]
1164 fn test_invalid_actor_id_rejected_at_load() {
1165 let dir = tempfile::tempdir().unwrap();
1166 let path = write_toml(
1167 &dir,
1168 r#"
1169[actor]
1170id = "bad namespace"
1171"#,
1172 );
1173 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with invalid actor.id");
1174 assert!(
1175 matches!(err, ConfigError::InvalidActorId { .. }),
1176 "expected InvalidActorId, got {err:?}"
1177 );
1178 }
1179
1180 #[test]
1181 fn test_empty_actor_id_rejected() {
1182 let dir = tempfile::tempdir().unwrap();
1183 let path = write_toml(
1184 &dir,
1185 r#"
1186[actor]
1187id = ""
1188"#,
1189 );
1190 let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id should be rejected");
1191 assert!(
1192 matches!(err, ConfigError::InvalidActorId { .. }),
1193 "expected InvalidActorId for empty string, got {err:?}"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_malformed_actor_id_lambda_colon_only() {
1199 let dir = tempfile::tempdir().unwrap();
1200 let path = write_toml(
1201 &dir,
1202 r#"
1203[actor]
1204id = "lambda:"
1205"#,
1206 );
1207 let err =
1208 KhiveConfig::load(Some(&path)).expect_err("lambda: with no slug should be rejected");
1209 assert!(
1210 matches!(err, ConfigError::InvalidActorId { .. }),
1211 "expected InvalidActorId for 'lambda:', got {err:?}"
1212 );
1213 }
1214
1215 #[test]
1218 fn test_runtime_config_actor_id_does_not_override_namespace() {
1219 use crate::runtime::runtime_config_from_khive_config;
1220 use crate::RuntimeConfig;
1221 use khive_types::namespace::Namespace;
1222
1223 let cfg = KhiveConfig {
1224 engines: vec![],
1225 actor: ActorConfig {
1226 id: Some("lambda:test-actor".to_string()),
1227 display_name: None,
1228 ..Default::default()
1229 },
1230 ..KhiveConfig::default()
1231 };
1232 cfg.validate().expect("valid config");
1233
1234 let base = RuntimeConfig::default();
1235 let result = runtime_config_from_khive_config(&cfg, base);
1236 assert_eq!(
1237 result.default_namespace,
1238 Namespace::local(),
1239 "actor.id must NOT become default_namespace (ADR-007 Rev 4 Rule 0); \
1240 writes stay pinned to local"
1241 );
1242 assert!(
1245 result
1246 .visible_namespaces
1247 .contains(&Namespace::parse("lambda:test-actor").unwrap()),
1248 "actor.id must be folded into visible_namespaces (ADR-007 Rev 4 Rule 3b fold-in); \
1249 got: {:?}",
1250 result.visible_namespaces
1251 );
1252 }
1253
1254 #[test]
1255 fn test_runtime_config_no_actor_preserves_base() {
1256 use crate::runtime::runtime_config_from_khive_config;
1257 use crate::RuntimeConfig;
1258 use khive_types::namespace::Namespace;
1259
1260 let cfg = KhiveConfig {
1261 engines: vec![],
1262 actor: ActorConfig {
1263 id: None,
1264 display_name: None,
1265 ..Default::default()
1266 },
1267 ..KhiveConfig::default()
1268 };
1269 cfg.validate().expect("valid config");
1270
1271 let base_ns = Namespace::parse("lambda:base").unwrap();
1272 let base = RuntimeConfig {
1273 default_namespace: base_ns.clone(),
1274 ..RuntimeConfig::default()
1275 };
1276 let result = runtime_config_from_khive_config(&cfg, base);
1277 assert_eq!(
1278 result.default_namespace, base_ns,
1279 "no actor.id must leave base namespace unchanged"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_load_with_home_fallback_project_root_over_hidden() {
1285 let dir = tempfile::tempdir().unwrap();
1286
1287 std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
1289 std::fs::write(
1290 dir.path().join(".khive/config.toml"),
1291 "[actor]\nid = \"lambda:hidden\"\n",
1292 )
1293 .unwrap();
1294
1295 std::fs::write(
1297 dir.path().join("khive.toml"),
1298 "[actor]\nid = \"lambda:project-root\"\n",
1299 )
1300 .unwrap();
1301
1302 let cfg = KhiveConfig::load_with_roots(dir.path(), None, None)
1303 .expect("no error expected")
1304 .expect("file should be found");
1305 assert_eq!(
1306 cfg.actor.id.as_deref(),
1307 Some("lambda:project-root"),
1308 "khive.toml (tier 2) must win over .khive/config.toml (tier 3)"
1309 );
1310 }
1311
1312 #[test]
1313 fn test_load_with_home_fallback_hidden_over_absent_root() {
1314 let dir = tempfile::tempdir().unwrap();
1315
1316 std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
1317 std::fs::write(
1318 dir.path().join(".khive/config.toml"),
1319 "[actor]\nid = \"lambda:hidden-config\"\n",
1320 )
1321 .unwrap();
1322 let cfg = KhiveConfig::load_with_roots(dir.path(), None, None)
1325 .expect("no error expected")
1326 .expect("file should be found");
1327 assert_eq!(
1328 cfg.actor.id.as_deref(),
1329 Some("lambda:hidden-config"),
1330 ".khive/config.toml (tier 3) must be found when khive.toml is absent"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_load_with_roots_home_tier_found() {
1336 let project_dir = tempfile::tempdir().unwrap();
1337 let home_dir = tempfile::tempdir().unwrap();
1338
1339 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1340 std::fs::write(
1341 home_dir.path().join(".khive/config.toml"),
1342 "[actor]\nid = \"lambda:user-global\"\n",
1343 )
1344 .unwrap();
1345 let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
1348 .expect("no error expected")
1349 .expect("file should be found");
1350 assert_eq!(
1351 cfg.actor.id.as_deref(),
1352 Some("lambda:user-global"),
1353 "~/.khive/config.toml (tier 4) must be found when project files absent"
1354 );
1355 }
1356
1357 #[test]
1358 fn test_load_with_roots_project_wins_over_home() {
1359 let project_dir = tempfile::tempdir().unwrap();
1360 let home_dir = tempfile::tempdir().unwrap();
1361
1362 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1364 std::fs::write(
1365 home_dir.path().join(".khive/config.toml"),
1366 "[actor]\nid = \"lambda:user-global\"\n",
1367 )
1368 .unwrap();
1369
1370 std::fs::create_dir_all(project_dir.path().join(".khive")).unwrap();
1372 std::fs::write(
1373 project_dir.path().join(".khive/config.toml"),
1374 "[actor]\nid = \"lambda:project-wins\"\n",
1375 )
1376 .unwrap();
1377
1378 let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
1379 .expect("no error expected")
1380 .expect("file should be found");
1381 assert_eq!(
1382 cfg.actor.id.as_deref(),
1383 Some("lambda:project-wins"),
1384 "project .khive/config.toml (tier 3) must win over ~/.khive/config.toml (tier 4)"
1385 );
1386 }
1387
1388 #[test]
1396 fn test_load_with_roots_same_db_different_cwd_resolves_identical_config() {
1397 let cwd_a = tempfile::tempdir().unwrap();
1398 let cwd_b = tempfile::tempdir().unwrap();
1399
1400 std::fs::create_dir_all(cwd_a.path().join(".khive")).unwrap();
1403 std::fs::write(
1404 cwd_a.path().join(".khive/config.toml"),
1405 "[actor]\nid = \"lambda:wrong-cwd-a\"\n",
1406 )
1407 .unwrap();
1408 std::fs::create_dir_all(cwd_b.path().join(".khive")).unwrap();
1409 std::fs::write(
1410 cwd_b.path().join(".khive/config.toml"),
1411 "[actor]\nid = \"lambda:wrong-cwd-b\"\n",
1412 )
1413 .unwrap();
1414
1415 let db_root = tempfile::tempdir().unwrap();
1418 let khive_dir = db_root.path().join(".khive");
1419 std::fs::create_dir_all(&khive_dir).unwrap();
1420 let db_path = khive_dir.join("khive.db");
1421 std::fs::write(&db_path, b"").unwrap(); std::fs::write(
1423 khive_dir.join("config.toml"),
1424 "[actor]\nid = \"lambda:db-anchored\"\n",
1425 )
1426 .unwrap();
1427
1428 let cfg_a = KhiveConfig::load_with_roots(cwd_a.path(), None, Some(&db_path))
1429 .expect("no error expected")
1430 .expect("db-anchored config must be found from cwd A");
1431 let cfg_b = KhiveConfig::load_with_roots(cwd_b.path(), None, Some(&db_path))
1432 .expect("no error expected")
1433 .expect("db-anchored config must be found from cwd B");
1434
1435 assert_eq!(
1436 cfg_a.actor.id.as_deref(),
1437 Some("lambda:db-anchored"),
1438 "cwd A must resolve the db-anchored config, not its own decoy"
1439 );
1440 assert_eq!(
1441 cfg_b.actor.id.as_deref(),
1442 Some("lambda:db-anchored"),
1443 "cwd B must resolve the db-anchored config, not its own decoy"
1444 );
1445 assert_eq!(
1446 cfg_a.actor.id, cfg_b.actor.id,
1447 "two processes at different cwds targeting the same db must resolve \
1448 identical config, killing config_id drift between client and daemon"
1449 );
1450 }
1451
1452 #[test]
1456 fn test_load_with_home_fallback_explicit_config_wins_over_db_anchor() {
1457 let explicit_dir = tempfile::tempdir().unwrap();
1458 let explicit_path = write_toml(&explicit_dir, "[actor]\nid = \"lambda:explicit-wins\"\n");
1459
1460 let db_root = tempfile::tempdir().unwrap();
1461 let khive_dir = db_root.path().join(".khive");
1462 std::fs::create_dir_all(&khive_dir).unwrap();
1463 let db_path = khive_dir.join("khive.db");
1464 std::fs::write(&db_path, b"").unwrap();
1465 std::fs::write(
1466 khive_dir.join("config.toml"),
1467 "[actor]\nid = \"lambda:db-anchor-loses\"\n",
1468 )
1469 .unwrap();
1470
1471 let cfg = KhiveConfig::load_with_home_fallback(Some(&explicit_path), Some(&db_path))
1472 .expect("no error expected")
1473 .expect("explicit path must be found");
1474 assert_eq!(
1475 cfg.actor.id.as_deref(),
1476 Some("lambda:explicit-wins"),
1477 "explicit --config/KHIVE_CONFIG must win over the db-dir anchor"
1478 );
1479 }
1480
1481 #[test]
1484 fn test_load_with_roots_home_fallback_reached_when_db_anchor_has_no_config() {
1485 let cwd = tempfile::tempdir().unwrap();
1486 let home_dir = tempfile::tempdir().unwrap();
1487 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1488 std::fs::write(
1489 home_dir.path().join(".khive/config.toml"),
1490 "[actor]\nid = \"lambda:home-fallback\"\n",
1491 )
1492 .unwrap();
1493
1494 let db_root = tempfile::tempdir().unwrap();
1496 let khive_dir = db_root.path().join(".khive");
1497 std::fs::create_dir_all(&khive_dir).unwrap();
1498 let db_path = khive_dir.join("khive.db");
1499 std::fs::write(&db_path, b"").unwrap();
1500
1501 let cfg = KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&db_path))
1502 .expect("no error expected")
1503 .expect("home-tier config must be found");
1504 assert_eq!(
1505 cfg.actor.id.as_deref(),
1506 Some("lambda:home-fallback"),
1507 "tier 4 (~/.khive/config.toml) must still be reached when the db-anchored \
1508 tier-3 directory has no config.toml"
1509 );
1510 }
1511
1512 #[test]
1515 fn test_load_with_roots_nonexistent_db_path_does_not_panic_and_falls_through() {
1516 let cwd = tempfile::tempdir().unwrap();
1517 let home_dir = tempfile::tempdir().unwrap();
1518 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1519 std::fs::write(
1520 home_dir.path().join(".khive/config.toml"),
1521 "[actor]\nid = \"lambda:home-cold-start\"\n",
1522 )
1523 .unwrap();
1524
1525 let nonexistent_db = cwd.path().join("never-created/.khive/khive.db");
1527
1528 let cfg =
1529 KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&nonexistent_db))
1530 .expect("cold-start db path must not error or panic")
1531 .expect("home-tier config must still be found");
1532 assert_eq!(
1533 cfg.actor.id.as_deref(),
1534 Some("lambda:home-cold-start"),
1535 "a nonexistent db path (cold start) must fall through to tier 4, not panic"
1536 );
1537 }
1538
1539 #[test]
1544 fn test_load_with_roots_relative_nonexistent_db_path_does_not_panic() {
1545 let cwd = tempfile::tempdir().unwrap();
1546 let relative_db = PathBuf::from("never-created/.khive/khive.db");
1547
1548 let result = KhiveConfig::load_with_roots(cwd.path(), None, Some(&relative_db));
1549 assert!(
1550 result.is_ok(),
1551 "relative cold-start db path must not error or panic: {result:?}"
1552 );
1553 assert!(
1554 result.unwrap().is_none(),
1555 "no config exists anywhere in this test; result must be None"
1556 );
1557 }
1558
1559 #[test]
1562 fn test_no_backends_section_is_valid() {
1563 let dir = tempfile::tempdir().unwrap();
1564 let path = write_toml(
1565 &dir,
1566 r#"
1567[[engines]]
1568name = "default"
1569model = "all-minilm-l6-v2"
1570default = true
1571"#,
1572 );
1573 let cfg = KhiveConfig::load(Some(&path))
1574 .expect("no error")
1575 .expect("file found");
1576 assert!(cfg.backends.is_empty());
1577 assert!(cfg.packs.is_empty());
1578 }
1579
1580 #[test]
1581 fn test_single_sqlite_backend_parses() {
1582 let dir = tempfile::tempdir().unwrap();
1583 let path = write_toml(
1584 &dir,
1585 r#"
1586[[backends]]
1587name = "knowledge"
1588kind = "sqlite"
1589path = "/tmp/knowledge.db"
1590"#,
1591 );
1592 let cfg = KhiveConfig::load(Some(&path))
1593 .expect("no error")
1594 .expect("file found");
1595 assert_eq!(cfg.backends.len(), 1);
1596 let b = &cfg.backends[0];
1597 assert_eq!(b.name, "knowledge");
1598 assert!(matches!(b.kind, BackendKind::Sqlite));
1599 assert_eq!(
1600 b.path.as_ref().and_then(|p| p.to_str()),
1601 Some("/tmp/knowledge.db")
1602 );
1603 }
1604
1605 #[test]
1606 fn test_memory_backend_parses() {
1607 let dir = tempfile::tempdir().unwrap();
1608 let path = write_toml(
1609 &dir,
1610 r#"
1611[[backends]]
1612name = "ephemeral"
1613kind = "memory"
1614"#,
1615 );
1616 let cfg = KhiveConfig::load(Some(&path))
1617 .expect("no error")
1618 .expect("file found");
1619 assert_eq!(cfg.backends.len(), 1);
1620 assert!(matches!(cfg.backends[0].kind, BackendKind::Memory));
1621 }
1622
1623 #[test]
1624 fn test_pack_backend_assignment_parses() {
1625 let dir = tempfile::tempdir().unwrap();
1626 let path = write_toml(
1627 &dir,
1628 r#"
1629[[backends]]
1630name = "knowledge"
1631kind = "memory"
1632
1633[packs.knowledge]
1634backend = "knowledge"
1635"#,
1636 );
1637 let cfg = KhiveConfig::load(Some(&path))
1638 .expect("no error")
1639 .expect("file found");
1640 assert_eq!(cfg.packs.len(), 1);
1641 let pc = cfg.packs.get("knowledge").expect("knowledge pack present");
1642 assert_eq!(pc.backend, "knowledge");
1643 }
1644
1645 #[test]
1646 fn test_duplicate_backend_name_rejected() {
1647 let dir = tempfile::tempdir().unwrap();
1648 let path = write_toml(
1649 &dir,
1650 r#"
1651[[backends]]
1652name = "dup"
1653kind = "memory"
1654
1655[[backends]]
1656name = "dup"
1657kind = "memory"
1658"#,
1659 );
1660 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
1661 assert!(
1662 matches!(err, ConfigError::DuplicateBackendName { ref name } if name == "dup"),
1663 "expected DuplicateBackendName {{ name: \"dup\" }}, got {err:?}"
1664 );
1665 }
1666
1667 #[test]
1668 fn test_pack_referencing_undefined_backend_rejected() {
1669 let dir = tempfile::tempdir().unwrap();
1670 let path = write_toml(
1671 &dir,
1672 r#"
1673[[backends]]
1674name = "knowledge"
1675kind = "memory"
1676
1677[packs.kg]
1678backend = "nonexistent"
1679"#,
1680 );
1681 let err =
1682 KhiveConfig::load(Some(&path)).expect_err("should fail with unknown backend reference");
1683 assert!(
1684 matches!(err, ConfigError::UnknownPackBackend { ref pack, ref backend, .. }
1685 if pack == "kg" && backend == "nonexistent"),
1686 "expected UnknownPackBackend for kg→nonexistent, got {err:?}"
1687 );
1688 }
1689
1690 #[test]
1691 fn test_pack_config_without_backends_section_is_allowed() {
1692 let dir = tempfile::tempdir().unwrap();
1693 let path = write_toml(
1696 &dir,
1697 r#"
1698[packs.kg]
1699backend = "main"
1700"#,
1701 );
1702 let cfg = KhiveConfig::load(Some(&path))
1703 .expect("no error expected")
1704 .expect("file found");
1705 assert_eq!(cfg.backends.len(), 0);
1706 assert_eq!(cfg.packs.len(), 1);
1707 }
1708
1709 #[test]
1710 fn test_backend_cache_mb_rejected_at_validate() {
1711 let dir = tempfile::tempdir().unwrap();
1712 let path = write_toml(
1713 &dir,
1714 r#"
1715[[backends]]
1716name = "main"
1717kind = "memory"
1718cache_mb = 128
1719"#,
1720 );
1721 let err = KhiveConfig::load(Some(&path)).expect_err("cache_mb must be rejected");
1722 assert!(
1723 matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "cache_mb" } if name == "main"),
1724 "expected UnsupportedBackendField {{ name: \"main\", field: \"cache_mb\" }}, got {err:?}"
1725 );
1726 }
1727
1728 #[test]
1729 fn test_backend_journal_mode_rejected_at_validate() {
1730 let dir = tempfile::tempdir().unwrap();
1731 let path = write_toml(
1732 &dir,
1733 r#"
1734[[backends]]
1735name = "main"
1736kind = "memory"
1737journal_mode = "wal"
1738"#,
1739 );
1740 let err = KhiveConfig::load(Some(&path)).expect_err("journal_mode must be rejected");
1741 assert!(
1742 matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "journal_mode" } if name == "main"),
1743 "expected UnsupportedBackendField {{ name: \"main\", field: \"journal_mode\" }}, got {err:?}"
1744 );
1745 }
1746
1747 #[test]
1750 fn test_top_level_db_rejected_at_validate() {
1751 let dir = tempfile::tempdir().unwrap();
1752 let path = write_toml(
1753 &dir,
1754 r#"
1755db = "/tmp/scratch/demo.db"
1756"#,
1757 );
1758 let err = KhiveConfig::load(Some(&path)).expect_err("top-level db must be rejected");
1759 assert!(
1760 matches!(err, ConfigError::UnsupportedTopLevelDb { ref value } if value == "/tmp/scratch/demo.db"),
1761 "expected UnsupportedTopLevelDb {{ value: \"/tmp/scratch/demo.db\" }}, got {err:?}"
1762 );
1763 }
1764
1765 #[test]
1769 fn test_no_git_write_section_is_valid_and_empty() {
1770 let dir = tempfile::tempdir().unwrap();
1771 let path = write_toml(&dir, "# no git_write section\n");
1772 let cfg = KhiveConfig::load(Some(&path))
1773 .expect("no error")
1774 .expect("file found");
1775 assert!(cfg.git_write.allowed.is_empty());
1776 }
1777
1778 #[test]
1780 fn test_git_write_entry_parses() {
1781 let dir = tempfile::tempdir().unwrap();
1782 let path = write_toml(
1783 &dir,
1784 r#"
1785[[git_write.allowed]]
1786repo = "/abs/path/repo"
1787branches = ["feat/*", "fix/*"]
1788"#,
1789 );
1790 let cfg = KhiveConfig::load(Some(&path))
1791 .expect("no error")
1792 .expect("file found");
1793 assert_eq!(cfg.git_write.allowed.len(), 1);
1794 assert_eq!(cfg.git_write.allowed[0].repo, "/abs/path/repo");
1795 assert_eq!(
1796 cfg.git_write.allowed[0].branches,
1797 vec!["feat/*".to_string(), "fix/*".to_string()]
1798 );
1799 }
1800
1801 #[test]
1803 fn test_git_write_relative_repo_rejected() {
1804 let dir = tempfile::tempdir().unwrap();
1805 let path = write_toml(
1806 &dir,
1807 r#"
1808[[git_write.allowed]]
1809repo = "relative/path"
1810branches = ["main"]
1811"#,
1812 );
1813 let err = KhiveConfig::load(Some(&path)).expect_err("relative repo must be rejected");
1814 assert!(
1815 matches!(err, ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "relative/path"),
1816 "expected InvalidGitWriteEntry, got {err:?}"
1817 );
1818 }
1819
1820 #[test]
1824 fn test_git_write_multi_star_branch_pattern_rejected() {
1825 let dir = tempfile::tempdir().unwrap();
1826 let path = write_toml(
1827 &dir,
1828 r#"
1829[[git_write.allowed]]
1830repo = "/abs/path"
1831branches = ["**"]
1832"#,
1833 );
1834 let err = KhiveConfig::load(Some(&path)).expect_err("** must be rejected");
1835 assert!(
1836 matches!(err, ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "/abs/path"),
1837 "expected InvalidGitWriteEntry, got {err:?}"
1838 );
1839
1840 let dir2 = tempfile::tempdir().unwrap();
1841 let path2 = write_toml(
1842 &dir2,
1843 r#"
1844[[git_write.allowed]]
1845repo = "/abs/path"
1846branches = ["rel-*-*-final"]
1847"#,
1848 );
1849 let err2 = KhiveConfig::load(Some(&path2)).expect_err("rel-*-*-final must be rejected");
1850 assert!(
1851 matches!(err2, ConfigError::InvalidGitWriteEntry { .. }),
1852 "expected InvalidGitWriteEntry, got {err2:?}"
1853 );
1854 }
1855
1856 #[test]
1858 fn test_git_write_single_star_branch_pattern_accepted() {
1859 let dir = tempfile::tempdir().unwrap();
1860 let path = write_toml(
1861 &dir,
1862 r#"
1863[[git_write.allowed]]
1864repo = "/abs/path"
1865branches = ["a*b", "main"]
1866"#,
1867 );
1868 let cfg = KhiveConfig::load(Some(&path))
1869 .expect("no error")
1870 .expect("file found");
1871 assert_eq!(cfg.git_write.allowed[0].branches, vec!["a*b", "main"]);
1872 }
1873
1874 #[test]
1877 fn test_git_write_empty_branches_rejected() {
1878 let dir = tempfile::tempdir().unwrap();
1879 let path = write_toml(
1880 &dir,
1881 r#"
1882[[git_write.allowed]]
1883repo = "/abs/path"
1884branches = []
1885"#,
1886 );
1887 let err = KhiveConfig::load(Some(&path)).expect_err("empty branches must be rejected");
1888 assert!(
1889 matches!(err, ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "/abs/path"),
1890 "expected InvalidGitWriteEntry, got {err:?}"
1891 );
1892 }
1893
1894 #[test]
1899 fn test_no_storage_section_defaults_to_fs() {
1900 let dir = tempfile::tempdir().unwrap();
1901 let path = write_toml(&dir, "# no storage section\n");
1902 let cfg = KhiveConfig::load(Some(&path))
1903 .expect("no error")
1904 .expect("file found");
1905 assert!(cfg.storage.blob.is_none());
1906 }
1907
1908 #[test]
1909 fn test_storage_blob_fs_selection_parses() {
1910 let dir = tempfile::tempdir().unwrap();
1911 let path = write_toml(
1912 &dir,
1913 r#"
1914[storage.blob]
1915backend = "fs"
1916root = "/var/lib/khive/blobs"
1917floor_bytes = 100000000000
1918"#,
1919 );
1920 let cfg = KhiveConfig::load(Some(&path))
1921 .expect("no error")
1922 .expect("file found");
1923 match cfg.storage.blob {
1924 Some(BlobConfig::Fs { root, floor_bytes }) => {
1925 assert_eq!(root.as_deref(), Some("/var/lib/khive/blobs"));
1926 assert_eq!(floor_bytes, Some(100_000_000_000));
1927 }
1928 other => panic!("expected BlobConfig::Fs, got {other:?}"),
1929 }
1930 }
1931
1932 #[test]
1933 fn test_storage_blob_s3_selection_parses() {
1934 let dir = tempfile::tempdir().unwrap();
1935 let path = write_toml(
1936 &dir,
1937 r#"
1938[storage.blob]
1939backend = "s3"
1940bucket = "khive-blobs"
1941region = "us-east-1"
1942endpoint = "https://objects.example.invalid"
1943prefix = "blobs"
1944"#,
1945 );
1946 let cfg = KhiveConfig::load(Some(&path))
1947 .expect("no error")
1948 .expect("file found");
1949 match cfg.storage.blob {
1950 Some(BlobConfig::S3 {
1951 bucket,
1952 region,
1953 endpoint,
1954 prefix,
1955 allow_http,
1956 }) => {
1957 assert_eq!(bucket, "khive-blobs");
1958 assert_eq!(region, "us-east-1");
1959 assert_eq!(endpoint.as_deref(), Some("https://objects.example.invalid"));
1960 assert_eq!(prefix.as_deref(), Some("blobs"));
1961 assert_eq!(allow_http, None);
1962 }
1963 other => panic!("expected BlobConfig::S3, got {other:?}"),
1964 }
1965 }
1966
1967 #[test]
1971 fn test_storage_blob_unknown_field_rejected() {
1972 let dir = tempfile::tempdir().unwrap();
1973 let path = write_toml(
1974 &dir,
1975 r#"
1976[storage.blob]
1977backend = "fs"
1978made_up_field = "x"
1979"#,
1980 );
1981 let err = KhiveConfig::load(Some(&path)).expect_err("unknown field must be rejected");
1982 assert!(matches!(err, ConfigError::Parse { .. }), "got {err:?}");
1983 }
1984
1985 #[test]
1989 fn test_storage_blob_other_backend_field_rejected() {
1990 let dir = tempfile::tempdir().unwrap();
1991 let path = write_toml(
1992 &dir,
1993 r#"
1994[storage.blob]
1995backend = "fs"
1996bucket = "khive-blobs"
1997"#,
1998 );
1999 let err = KhiveConfig::load(Some(&path)).expect_err("s3 field under fs must be rejected");
2000 assert!(matches!(err, ConfigError::Parse { .. }), "got {err:?}");
2001 }
2002
2003 #[test]
2007 fn test_storage_blob_credential_field_rejected() {
2008 let dir = tempfile::tempdir().unwrap();
2009 let path = write_toml(
2010 &dir,
2011 r#"
2012[storage.blob]
2013backend = "s3"
2014bucket = "khive-blobs"
2015region = "us-east-1"
2016access_key_id = "AKIAEXAMPLE"
2017"#,
2018 );
2019 let err = KhiveConfig::load(Some(&path))
2020 .expect_err("a credential field in TOML must be rejected");
2021 assert!(matches!(err, ConfigError::Parse { .. }), "got {err:?}");
2022 }
2023
2024 #[test]
2027 fn test_storage_blob_unknown_backend_value_rejected() {
2028 let dir = tempfile::tempdir().unwrap();
2029 let path = write_toml(
2030 &dir,
2031 r#"
2032[storage.blob]
2033backend = "gcs"
2034"#,
2035 );
2036 let err = KhiveConfig::load(Some(&path)).expect_err("unknown backend must be rejected");
2037 assert!(matches!(err, ConfigError::Parse { .. }), "got {err:?}");
2038 }
2039}