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
74#[derive(Debug, Clone, Deserialize)]
78pub struct EngineConfig {
79 pub name: String,
81
82 pub model: String,
87
88 #[serde(default)]
91 pub default: bool,
92
93 pub fusion_weight: Option<f64>,
103
104 pub dims: Option<u32>,
110}
111
112#[derive(Debug, Clone, Deserialize, Default)]
133pub struct ActorConfig {
134 #[serde(default)]
140 pub id: Option<String>,
141
142 #[serde(default)]
145 pub display_name: Option<String>,
146
147 #[serde(default)]
153 pub visible_namespaces: Option<Vec<String>>,
154
155 #[serde(default)]
167 pub allowed_outbound_namespaces: Vec<String>,
168}
169
170#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
174#[serde(rename_all = "lowercase")]
175pub enum BackendKind {
176 #[default]
178 Sqlite,
179 Memory,
181}
182
183#[derive(Debug, Clone, Deserialize)]
200pub struct BackendConfig {
201 pub name: String,
203 #[serde(default)]
205 pub kind: BackendKind,
206 pub path: Option<std::path::PathBuf>,
209 pub cache_mb: Option<u32>,
211 pub journal_mode: Option<String>,
213 #[serde(default)]
215 pub read_only: bool,
216}
217
218#[derive(Debug, Clone, Deserialize)]
228pub struct PackConfig {
229 pub backend: String,
231}
232
233#[derive(Debug, Clone, Deserialize, Default)]
244pub struct KhiveConfig {
245 #[serde(default)]
251 pub db: Option<String>,
252
253 #[serde(default)]
255 pub engines: Vec<EngineConfig>,
256
257 #[serde(default)]
265 pub actor: ActorConfig,
266
267 #[serde(default)]
269 pub runtime: RuntimeSectionConfig,
270
271 #[serde(default)]
276 pub backends: Vec<BackendConfig>,
277
278 #[serde(default)]
284 pub packs: std::collections::HashMap<String, PackConfig>,
285}
286
287#[derive(Debug, Clone, Deserialize, Default)]
293pub struct RuntimeSectionConfig {
294 #[serde(default)]
301 pub brain_profile: Option<String>,
302
303 #[serde(default)]
310 pub default_output_format: Option<OutputFormat>,
311}
312
313impl KhiveConfig {
314 pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
330 let resolved = match path {
331 Some(p) => p.to_path_buf(),
332 None => PathBuf::from(".khive/config.toml"),
333 };
334
335 if !resolved.exists() {
336 return Ok(None);
337 }
338
339 let raw = std::fs::read_to_string(&resolved)?;
340 let cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
341 path: resolved,
342 source,
343 })?;
344 cfg.validate()?;
345 Ok(Some(cfg))
346 }
347
348 pub fn load_with_home_fallback(
368 path: Option<&Path>,
369 db_path: Option<&Path>,
370 ) -> Result<Option<Self>, ConfigError> {
371 if let Some(p) = path {
373 return Self::load(Some(p));
374 }
375
376 let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
378 let home_root = std::env::var_os("HOME").map(PathBuf::from);
379 Self::load_with_roots(&project_root, home_root.as_deref(), db_path)
380 }
381
382 pub(crate) fn load_with_roots(
391 project_root: &Path,
392 home_root: Option<&Path>,
393 db_path: Option<&Path>,
394 ) -> Result<Option<Self>, ConfigError> {
395 let tier2 = project_root.join("khive.toml");
397 if tier2.exists() {
398 return Self::load(Some(&tier2));
399 }
400
401 let tier3 = Self::project_config_anchor_dir(db_path, project_root).join("config.toml");
404 if tier3.exists() {
405 return Self::load(Some(&tier3));
406 }
407
408 if let Some(home) = home_root {
410 let tier4 = home.join(".khive/config.toml");
411 if tier4.exists() {
412 return Self::load(Some(&tier4));
413 }
414 }
415
416 Ok(None)
417 }
418
419 fn project_config_anchor_dir(db_path: Option<&Path>, project_root: &Path) -> PathBuf {
445 let Some(db_path) = db_path else {
446 return project_root.join(".khive");
447 };
448
449 let absolute = std::fs::canonicalize(db_path).unwrap_or_else(|_| {
450 if db_path.is_absolute() {
451 db_path.to_path_buf()
452 } else {
453 project_root.join(db_path)
454 }
455 });
456
457 let db_dir = absolute.parent().map(Path::to_path_buf).unwrap_or(absolute);
458
459 if db_dir.file_name().is_some_and(|name| name == ".khive") {
460 db_dir
461 } else {
462 db_dir.join(".khive")
463 }
464 }
465
466 pub fn validate(&self) -> Result<(), ConfigError> {
476 if let Some(value) = self.db.as_deref() {
481 if !value.is_empty() {
482 return Err(ConfigError::UnsupportedTopLevelDb {
483 value: value.to_string(),
484 });
485 }
486 }
487
488 if let Some(id) = self.actor.id.as_deref() {
491 if id.is_empty() {
492 return Err(ConfigError::InvalidActorId {
493 id: id.to_string(),
494 reason: "actor.id must not be empty; remove the key or provide a value"
495 .to_string(),
496 });
497 }
498 Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
499 id: id.to_string(),
500 reason: e.to_string(),
501 })?;
502 }
503
504 if let Some(ref vis) = self.actor.visible_namespaces {
505 for ns_str in vis {
506 if ns_str.is_empty() {
507 return Err(ConfigError::InvalidActorId {
508 id: ns_str.clone(),
509 reason: "visible_namespaces entries must not be empty".to_string(),
510 });
511 }
512 Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
513 id: ns_str.clone(),
514 reason: format!("invalid visible namespace: {e}"),
515 })?;
516 }
517 }
518
519 for ns_str in &self.actor.allowed_outbound_namespaces {
521 if ns_str.is_empty() {
522 return Err(ConfigError::InvalidActorId {
523 id: ns_str.clone(),
524 reason: "allowed_outbound_namespaces entries must not be empty".to_string(),
525 });
526 }
527 Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
528 id: ns_str.clone(),
529 reason: format!("invalid allowed_outbound_namespaces entry: {e}"),
530 })?;
531 }
532
533 if !self.backends.is_empty() {
535 let mut seen_backends = std::collections::HashSet::new();
536 for backend in &self.backends {
537 if !seen_backends.insert(backend.name.clone()) {
538 return Err(ConfigError::DuplicateBackendName {
539 name: backend.name.clone(),
540 });
541 }
542
543 if backend.cache_mb.is_some() {
546 return Err(ConfigError::UnsupportedBackendField {
547 name: backend.name.clone(),
548 field: "cache_mb",
549 });
550 }
551 if backend.journal_mode.is_some() {
552 return Err(ConfigError::UnsupportedBackendField {
553 name: backend.name.clone(),
554 field: "journal_mode",
555 });
556 }
557 }
558
559 let defined: Vec<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
561 for (pack_name, pack_cfg) in &self.packs {
562 if !defined.contains(&pack_cfg.backend.as_str()) {
563 return Err(ConfigError::UnknownPackBackend {
564 pack: pack_name.clone(),
565 backend: pack_cfg.backend.clone(),
566 defined: defined.join(", "),
567 });
568 }
569 }
570 }
571
572 if self.engines.is_empty() {
573 return Ok(());
574 }
575
576 let mut seen_names = std::collections::HashSet::new();
577 for engine in &self.engines {
578 if !seen_names.insert(engine.name.clone()) {
579 return Err(ConfigError::DuplicateName {
580 name: engine.name.clone(),
581 });
582 }
583 }
584
585 let default_count = self.engines.iter().filter(|e| e.default).count();
586 if default_count != 1 {
587 return Err(ConfigError::DefaultCount {
588 found: default_count,
589 });
590 }
591
592 for engine in &self.engines {
595 if let Some(w) = engine.fusion_weight {
596 if !w.is_finite() || w <= 0.0 {
597 return Err(ConfigError::InvalidFusionWeight {
598 name: engine.name.clone(),
599 value: w,
600 });
601 }
602 }
603 }
604
605 Ok(())
606 }
607
608 pub fn default_engine(&self) -> Option<&EngineConfig> {
610 self.engines.iter().find(|e| e.default)
611 }
612}
613
614pub fn config_from_env() -> KhiveConfig {
624 let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
625 .ok()
626 .filter(|s| !s.trim().is_empty());
627 let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
628 .ok()
629 .unwrap_or_default();
630 let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
631 .into_iter()
632 .filter(|s| !s.is_empty())
633 .collect();
634
635 if primary_model.is_none() && additional.is_empty() {
636 return KhiveConfig::default();
637 }
638
639 tracing::info!(
640 "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
641 );
642
643 let mut engines = Vec::new();
644
645 if let Some(model) = primary_model {
646 engines.push(EngineConfig {
647 name: "default".to_string(),
648 model,
649 default: true,
650 fusion_weight: None,
651 dims: None,
652 });
653 }
654
655 for (i, model) in additional.into_iter().enumerate() {
656 engines.push(EngineConfig {
657 name: format!("engine-{}", i + 1),
658 model,
659 default: false,
660 fusion_weight: None,
661 dims: None,
662 });
663 }
664
665 if !engines.is_empty() && !engines.iter().any(|e| e.default) {
668 engines[0].default = true;
669 }
670
671 KhiveConfig {
672 engines,
673 ..KhiveConfig::default()
674 }
675}
676
677#[cfg(test)]
682mod tests {
683 use super::*;
684
685 fn write_toml(dir: &tempfile::TempDir, content: &str) -> PathBuf {
686 let path = dir.path().join("config.toml");
687 std::fs::write(&path, content).unwrap();
688 path
689 }
690
691 #[test]
692 fn test_load_minimal_config() {
693 let dir = tempfile::tempdir().unwrap();
694 let path = write_toml(
695 &dir,
696 r#"
697[[engines]]
698name = "x"
699model = "all-minilm-l6-v2"
700default = true
701"#,
702 );
703 let cfg = KhiveConfig::load(Some(&path))
704 .expect("load should succeed")
705 .expect("file should be found");
706 assert_eq!(cfg.engines.len(), 1);
707 assert_eq!(cfg.engines[0].name, "x");
708 assert_eq!(cfg.engines[0].model, "all-minilm-l6-v2");
709 assert!(cfg.engines[0].default);
710 }
711
712 #[test]
713 fn test_default_engine_required_when_engines_present() {
714 let dir = tempfile::tempdir().unwrap();
715 let path = write_toml(
716 &dir,
717 r#"
718[[engines]]
719name = "a"
720model = "all-minilm-l6-v2"
721"#,
722 );
723 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with no default flagged");
724 assert!(
725 matches!(err, ConfigError::DefaultCount { found: 0 }),
726 "expected DefaultCount {{ found: 0 }}, got {err:?}"
727 );
728 }
729
730 #[test]
731 fn test_multiple_default_rejected() {
732 let dir = tempfile::tempdir().unwrap();
733 let path = write_toml(
734 &dir,
735 r#"
736[[engines]]
737name = "a"
738model = "all-minilm-l6-v2"
739default = true
740
741[[engines]]
742name = "b"
743model = "paraphrase-multilingual-minilm-l12-v2"
744default = true
745"#,
746 );
747 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with two defaults");
748 assert!(
749 matches!(err, ConfigError::DefaultCount { found: 2 }),
750 "expected DefaultCount {{ found: 2 }}, got {err:?}"
751 );
752 }
753
754 #[test]
755 fn test_fusion_weight_validation() {
756 let dir = tempfile::tempdir().unwrap();
757 let path = write_toml(
758 &dir,
759 r#"
760[[engines]]
761name = "a"
762model = "all-minilm-l6-v2"
763default = true
764fusion_weight = -0.5
765"#,
766 );
767 let err =
768 KhiveConfig::load(Some(&path)).expect_err("should fail with negative fusion_weight");
769 assert!(
770 matches!(err, ConfigError::InvalidFusionWeight { .. }),
771 "expected InvalidFusionWeight, got {err:?}"
772 );
773
774 let path2 = write_toml(
775 &dir,
776 r#"
777[[engines]]
778name = "a"
779model = "all-minilm-l6-v2"
780default = true
781fusion_weight = 0.0
782"#,
783 );
784 let err2 =
785 KhiveConfig::load(Some(&path2)).expect_err("should fail with zero fusion_weight");
786 assert!(
787 matches!(err2, ConfigError::InvalidFusionWeight { .. }),
788 "expected InvalidFusionWeight, got {err2:?}"
789 );
790 }
791
792 #[test]
793 fn test_env_var_fallback() {
794 let dir = tempfile::tempdir().unwrap();
795 let absent = dir.path().join("missing.toml");
796
797 let loaded = KhiveConfig::load(Some(&absent)).unwrap();
798 assert!(loaded.is_none());
799
800 let primary = "all-minilm-l6-v2".to_string();
803 let additional = vec!["paraphrase-multilingual-minilm-l12-v2".to_string()];
804
805 let mut engines = vec![EngineConfig {
806 name: "default".to_string(),
807 model: primary,
808 default: true,
809 fusion_weight: None,
810 dims: None,
811 }];
812 for (i, model) in additional.into_iter().enumerate() {
813 engines.push(EngineConfig {
814 name: format!("engine-{}", i + 1),
815 model,
816 default: false,
817 fusion_weight: None,
818 dims: None,
819 });
820 }
821 let cfg = KhiveConfig {
822 engines,
823 ..KhiveConfig::default()
824 };
825 cfg.validate().expect("env-derived config should be valid");
826 assert_eq!(cfg.engines.len(), 2);
827 assert!(cfg.default_engine().is_some());
828 assert_eq!(cfg.default_engine().unwrap().name, "default");
829 }
830
831 #[test]
832 fn test_file_overrides_env() {
833 let dir = tempfile::tempdir().unwrap();
834 let path = write_toml(
835 &dir,
836 r#"
837[[engines]]
838name = "file-engine"
839model = "all-minilm-l6-v2"
840default = true
841"#,
842 );
843
844 let cfg = KhiveConfig::load(Some(&path))
847 .expect("load should succeed")
848 .expect("file should be present");
849 assert_eq!(cfg.engines[0].name, "file-engine");
850 }
851
852 #[test]
853 fn test_duplicate_engine_names_rejected() {
854 let dir = tempfile::tempdir().unwrap();
855 let path = write_toml(
856 &dir,
857 r#"
858[[engines]]
859name = "shared"
860model = "all-minilm-l6-v2"
861default = true
862
863[[engines]]
864name = "shared"
865model = "paraphrase-multilingual-minilm-l12-v2"
866"#,
867 );
868 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
869 assert!(
870 matches!(err, ConfigError::DuplicateName { .. }),
871 "expected DuplicateName, got {err:?}"
872 );
873 }
874
875 #[test]
876 fn test_empty_config_is_valid() {
877 let dir = tempfile::tempdir().unwrap();
878 let path = write_toml(&dir, "# no engines\n");
879 let cfg = KhiveConfig::load(Some(&path))
880 .expect("load should succeed")
881 .expect("file should be found");
882 assert!(cfg.engines.is_empty());
883 cfg.validate().expect("empty config should be valid");
884 }
885
886 #[test]
887 fn test_multi_engine_positive_fusion_weight() {
888 let dir = tempfile::tempdir().unwrap();
889 let path = write_toml(
890 &dir,
891 r#"
892[[engines]]
893name = "primary"
894model = "all-minilm-l6-v2"
895default = true
896fusion_weight = 0.7
897
898[[engines]]
899name = "secondary"
900model = "paraphrase-multilingual-minilm-l12-v2"
901fusion_weight = 0.3
902"#,
903 );
904 let cfg = KhiveConfig::load(Some(&path))
905 .expect("load should succeed")
906 .expect("file should be found");
907 assert_eq!(cfg.engines.len(), 2);
908 assert_eq!(cfg.engines[0].fusion_weight, Some(0.7));
909 assert_eq!(cfg.engines[1].fusion_weight, Some(0.3));
910 }
911
912 #[test]
913 fn test_actor_id_parsed() {
914 let dir = tempfile::tempdir().unwrap();
915 let path = write_toml(
916 &dir,
917 r#"
918[actor]
919id = "lambda:khive"
920display_name = "example actor"
921"#,
922 );
923 let cfg = KhiveConfig::load(Some(&path))
924 .expect("load should succeed")
925 .expect("file should be found");
926 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:khive"));
927 assert_eq!(cfg.actor.display_name.as_deref(), Some("example actor"));
928 assert!(cfg.engines.is_empty());
929 }
930
931 #[test]
932 fn test_actor_and_engines_together() {
933 let dir = tempfile::tempdir().unwrap();
934 let path = write_toml(
935 &dir,
936 r#"
937[actor]
938id = "lambda:test"
939
940[[engines]]
941name = "default"
942model = "all-minilm-l6-v2"
943default = true
944"#,
945 );
946 let cfg = KhiveConfig::load(Some(&path))
947 .expect("load should succeed")
948 .expect("file should be found");
949 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:test"));
950 assert_eq!(cfg.engines.len(), 1);
951 }
952
953 #[test]
954 fn test_actor_absent_defaults_to_none() {
955 let dir = tempfile::tempdir().unwrap();
956 let path = write_toml(
957 &dir,
958 r#"
959[[engines]]
960name = "x"
961model = "all-minilm-l6-v2"
962default = true
963"#,
964 );
965 let cfg = KhiveConfig::load(Some(&path))
966 .expect("load should succeed")
967 .expect("file should be found");
968 assert!(
969 cfg.actor.id.is_none(),
970 "actor.id must be None when [actor] section is absent"
971 );
972 }
973
974 #[test]
975 fn test_load_with_home_fallback_no_files() {
976 let project_dir = tempfile::tempdir().unwrap();
977 let home_dir = tempfile::tempdir().unwrap();
978 let result = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None);
979 assert!(
980 result.expect("no error expected").is_none(),
981 "should return None when no config files exist in the given roots"
982 );
983 }
984
985 #[test]
986 fn test_load_with_home_fallback_explicit_path() {
987 let dir = tempfile::tempdir().unwrap();
988 let path = write_toml(
989 &dir,
990 r#"
991[actor]
992id = "lambda:explicit"
993"#,
994 );
995 let cfg = KhiveConfig::load_with_home_fallback(Some(&path), None)
996 .expect("no error expected")
997 .expect("file found");
998 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:explicit"));
999 }
1000
1001 #[test]
1002 fn test_invalid_actor_id_rejected_at_load() {
1003 let dir = tempfile::tempdir().unwrap();
1004 let path = write_toml(
1005 &dir,
1006 r#"
1007[actor]
1008id = "bad namespace"
1009"#,
1010 );
1011 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with invalid actor.id");
1012 assert!(
1013 matches!(err, ConfigError::InvalidActorId { .. }),
1014 "expected InvalidActorId, got {err:?}"
1015 );
1016 }
1017
1018 #[test]
1019 fn test_empty_actor_id_rejected() {
1020 let dir = tempfile::tempdir().unwrap();
1021 let path = write_toml(
1022 &dir,
1023 r#"
1024[actor]
1025id = ""
1026"#,
1027 );
1028 let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id should be rejected");
1029 assert!(
1030 matches!(err, ConfigError::InvalidActorId { .. }),
1031 "expected InvalidActorId for empty string, got {err:?}"
1032 );
1033 }
1034
1035 #[test]
1036 fn test_malformed_actor_id_lambda_colon_only() {
1037 let dir = tempfile::tempdir().unwrap();
1038 let path = write_toml(
1039 &dir,
1040 r#"
1041[actor]
1042id = "lambda:"
1043"#,
1044 );
1045 let err =
1046 KhiveConfig::load(Some(&path)).expect_err("lambda: with no slug should be rejected");
1047 assert!(
1048 matches!(err, ConfigError::InvalidActorId { .. }),
1049 "expected InvalidActorId for 'lambda:', got {err:?}"
1050 );
1051 }
1052
1053 #[test]
1056 fn test_runtime_config_actor_id_does_not_override_namespace() {
1057 use crate::runtime::runtime_config_from_khive_config;
1058 use crate::RuntimeConfig;
1059 use khive_types::namespace::Namespace;
1060
1061 let cfg = KhiveConfig {
1062 engines: vec![],
1063 actor: ActorConfig {
1064 id: Some("lambda:test-actor".to_string()),
1065 display_name: None,
1066 ..Default::default()
1067 },
1068 ..KhiveConfig::default()
1069 };
1070 cfg.validate().expect("valid config");
1071
1072 let base = RuntimeConfig::default();
1073 let result = runtime_config_from_khive_config(&cfg, base);
1074 assert_eq!(
1075 result.default_namespace,
1076 Namespace::local(),
1077 "actor.id must NOT become default_namespace (ADR-007 Rev 4 Rule 0); \
1078 writes stay pinned to local"
1079 );
1080 assert!(
1083 result
1084 .visible_namespaces
1085 .contains(&Namespace::parse("lambda:test-actor").unwrap()),
1086 "actor.id must be folded into visible_namespaces (ADR-007 Rev 4 Rule 3b fold-in); \
1087 got: {:?}",
1088 result.visible_namespaces
1089 );
1090 }
1091
1092 #[test]
1093 fn test_runtime_config_no_actor_preserves_base() {
1094 use crate::runtime::runtime_config_from_khive_config;
1095 use crate::RuntimeConfig;
1096 use khive_types::namespace::Namespace;
1097
1098 let cfg = KhiveConfig {
1099 engines: vec![],
1100 actor: ActorConfig {
1101 id: None,
1102 display_name: None,
1103 ..Default::default()
1104 },
1105 ..KhiveConfig::default()
1106 };
1107 cfg.validate().expect("valid config");
1108
1109 let base_ns = Namespace::parse("lambda:base").unwrap();
1110 let base = RuntimeConfig {
1111 default_namespace: base_ns.clone(),
1112 ..RuntimeConfig::default()
1113 };
1114 let result = runtime_config_from_khive_config(&cfg, base);
1115 assert_eq!(
1116 result.default_namespace, base_ns,
1117 "no actor.id must leave base namespace unchanged"
1118 );
1119 }
1120
1121 #[test]
1122 fn test_load_with_home_fallback_project_root_over_hidden() {
1123 let dir = tempfile::tempdir().unwrap();
1124
1125 std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
1127 std::fs::write(
1128 dir.path().join(".khive/config.toml"),
1129 "[actor]\nid = \"lambda:hidden\"\n",
1130 )
1131 .unwrap();
1132
1133 std::fs::write(
1135 dir.path().join("khive.toml"),
1136 "[actor]\nid = \"lambda:project-root\"\n",
1137 )
1138 .unwrap();
1139
1140 let cfg = KhiveConfig::load_with_roots(dir.path(), None, None)
1141 .expect("no error expected")
1142 .expect("file should be found");
1143 assert_eq!(
1144 cfg.actor.id.as_deref(),
1145 Some("lambda:project-root"),
1146 "khive.toml (tier 2) must win over .khive/config.toml (tier 3)"
1147 );
1148 }
1149
1150 #[test]
1151 fn test_load_with_home_fallback_hidden_over_absent_root() {
1152 let dir = tempfile::tempdir().unwrap();
1153
1154 std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
1155 std::fs::write(
1156 dir.path().join(".khive/config.toml"),
1157 "[actor]\nid = \"lambda:hidden-config\"\n",
1158 )
1159 .unwrap();
1160 let cfg = KhiveConfig::load_with_roots(dir.path(), None, None)
1163 .expect("no error expected")
1164 .expect("file should be found");
1165 assert_eq!(
1166 cfg.actor.id.as_deref(),
1167 Some("lambda:hidden-config"),
1168 ".khive/config.toml (tier 3) must be found when khive.toml is absent"
1169 );
1170 }
1171
1172 #[test]
1173 fn test_load_with_roots_home_tier_found() {
1174 let project_dir = tempfile::tempdir().unwrap();
1175 let home_dir = tempfile::tempdir().unwrap();
1176
1177 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1178 std::fs::write(
1179 home_dir.path().join(".khive/config.toml"),
1180 "[actor]\nid = \"lambda:user-global\"\n",
1181 )
1182 .unwrap();
1183 let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
1186 .expect("no error expected")
1187 .expect("file should be found");
1188 assert_eq!(
1189 cfg.actor.id.as_deref(),
1190 Some("lambda:user-global"),
1191 "~/.khive/config.toml (tier 4) must be found when project files absent"
1192 );
1193 }
1194
1195 #[test]
1196 fn test_load_with_roots_project_wins_over_home() {
1197 let project_dir = tempfile::tempdir().unwrap();
1198 let home_dir = tempfile::tempdir().unwrap();
1199
1200 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1202 std::fs::write(
1203 home_dir.path().join(".khive/config.toml"),
1204 "[actor]\nid = \"lambda:user-global\"\n",
1205 )
1206 .unwrap();
1207
1208 std::fs::create_dir_all(project_dir.path().join(".khive")).unwrap();
1210 std::fs::write(
1211 project_dir.path().join(".khive/config.toml"),
1212 "[actor]\nid = \"lambda:project-wins\"\n",
1213 )
1214 .unwrap();
1215
1216 let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
1217 .expect("no error expected")
1218 .expect("file should be found");
1219 assert_eq!(
1220 cfg.actor.id.as_deref(),
1221 Some("lambda:project-wins"),
1222 "project .khive/config.toml (tier 3) must win over ~/.khive/config.toml (tier 4)"
1223 );
1224 }
1225
1226 #[test]
1234 fn test_load_with_roots_same_db_different_cwd_resolves_identical_config() {
1235 let cwd_a = tempfile::tempdir().unwrap();
1236 let cwd_b = tempfile::tempdir().unwrap();
1237
1238 std::fs::create_dir_all(cwd_a.path().join(".khive")).unwrap();
1241 std::fs::write(
1242 cwd_a.path().join(".khive/config.toml"),
1243 "[actor]\nid = \"lambda:wrong-cwd-a\"\n",
1244 )
1245 .unwrap();
1246 std::fs::create_dir_all(cwd_b.path().join(".khive")).unwrap();
1247 std::fs::write(
1248 cwd_b.path().join(".khive/config.toml"),
1249 "[actor]\nid = \"lambda:wrong-cwd-b\"\n",
1250 )
1251 .unwrap();
1252
1253 let db_root = tempfile::tempdir().unwrap();
1256 let khive_dir = db_root.path().join(".khive");
1257 std::fs::create_dir_all(&khive_dir).unwrap();
1258 let db_path = khive_dir.join("khive.db");
1259 std::fs::write(&db_path, b"").unwrap(); std::fs::write(
1261 khive_dir.join("config.toml"),
1262 "[actor]\nid = \"lambda:db-anchored\"\n",
1263 )
1264 .unwrap();
1265
1266 let cfg_a = KhiveConfig::load_with_roots(cwd_a.path(), None, Some(&db_path))
1267 .expect("no error expected")
1268 .expect("db-anchored config must be found from cwd A");
1269 let cfg_b = KhiveConfig::load_with_roots(cwd_b.path(), None, Some(&db_path))
1270 .expect("no error expected")
1271 .expect("db-anchored config must be found from cwd B");
1272
1273 assert_eq!(
1274 cfg_a.actor.id.as_deref(),
1275 Some("lambda:db-anchored"),
1276 "cwd A must resolve the db-anchored config, not its own decoy"
1277 );
1278 assert_eq!(
1279 cfg_b.actor.id.as_deref(),
1280 Some("lambda:db-anchored"),
1281 "cwd B must resolve the db-anchored config, not its own decoy"
1282 );
1283 assert_eq!(
1284 cfg_a.actor.id, cfg_b.actor.id,
1285 "two processes at different cwds targeting the same db must resolve \
1286 identical config, killing config_id drift between client and daemon"
1287 );
1288 }
1289
1290 #[test]
1294 fn test_load_with_home_fallback_explicit_config_wins_over_db_anchor() {
1295 let explicit_dir = tempfile::tempdir().unwrap();
1296 let explicit_path = write_toml(&explicit_dir, "[actor]\nid = \"lambda:explicit-wins\"\n");
1297
1298 let db_root = tempfile::tempdir().unwrap();
1299 let khive_dir = db_root.path().join(".khive");
1300 std::fs::create_dir_all(&khive_dir).unwrap();
1301 let db_path = khive_dir.join("khive.db");
1302 std::fs::write(&db_path, b"").unwrap();
1303 std::fs::write(
1304 khive_dir.join("config.toml"),
1305 "[actor]\nid = \"lambda:db-anchor-loses\"\n",
1306 )
1307 .unwrap();
1308
1309 let cfg = KhiveConfig::load_with_home_fallback(Some(&explicit_path), Some(&db_path))
1310 .expect("no error expected")
1311 .expect("explicit path must be found");
1312 assert_eq!(
1313 cfg.actor.id.as_deref(),
1314 Some("lambda:explicit-wins"),
1315 "explicit --config/KHIVE_CONFIG must win over the db-dir anchor"
1316 );
1317 }
1318
1319 #[test]
1322 fn test_load_with_roots_home_fallback_reached_when_db_anchor_has_no_config() {
1323 let cwd = tempfile::tempdir().unwrap();
1324 let home_dir = tempfile::tempdir().unwrap();
1325 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1326 std::fs::write(
1327 home_dir.path().join(".khive/config.toml"),
1328 "[actor]\nid = \"lambda:home-fallback\"\n",
1329 )
1330 .unwrap();
1331
1332 let db_root = tempfile::tempdir().unwrap();
1334 let khive_dir = db_root.path().join(".khive");
1335 std::fs::create_dir_all(&khive_dir).unwrap();
1336 let db_path = khive_dir.join("khive.db");
1337 std::fs::write(&db_path, b"").unwrap();
1338
1339 let cfg = KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&db_path))
1340 .expect("no error expected")
1341 .expect("home-tier config must be found");
1342 assert_eq!(
1343 cfg.actor.id.as_deref(),
1344 Some("lambda:home-fallback"),
1345 "tier 4 (~/.khive/config.toml) must still be reached when the db-anchored \
1346 tier-3 directory has no config.toml"
1347 );
1348 }
1349
1350 #[test]
1353 fn test_load_with_roots_nonexistent_db_path_does_not_panic_and_falls_through() {
1354 let cwd = tempfile::tempdir().unwrap();
1355 let home_dir = tempfile::tempdir().unwrap();
1356 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1357 std::fs::write(
1358 home_dir.path().join(".khive/config.toml"),
1359 "[actor]\nid = \"lambda:home-cold-start\"\n",
1360 )
1361 .unwrap();
1362
1363 let nonexistent_db = cwd.path().join("never-created/.khive/khive.db");
1365
1366 let cfg =
1367 KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&nonexistent_db))
1368 .expect("cold-start db path must not error or panic")
1369 .expect("home-tier config must still be found");
1370 assert_eq!(
1371 cfg.actor.id.as_deref(),
1372 Some("lambda:home-cold-start"),
1373 "a nonexistent db path (cold start) must fall through to tier 4, not panic"
1374 );
1375 }
1376
1377 #[test]
1382 fn test_load_with_roots_relative_nonexistent_db_path_does_not_panic() {
1383 let cwd = tempfile::tempdir().unwrap();
1384 let relative_db = PathBuf::from("never-created/.khive/khive.db");
1385
1386 let result = KhiveConfig::load_with_roots(cwd.path(), None, Some(&relative_db));
1387 assert!(
1388 result.is_ok(),
1389 "relative cold-start db path must not error or panic: {result:?}"
1390 );
1391 assert!(
1392 result.unwrap().is_none(),
1393 "no config exists anywhere in this test; result must be None"
1394 );
1395 }
1396
1397 #[test]
1400 fn test_no_backends_section_is_valid() {
1401 let dir = tempfile::tempdir().unwrap();
1402 let path = write_toml(
1403 &dir,
1404 r#"
1405[[engines]]
1406name = "default"
1407model = "all-minilm-l6-v2"
1408default = true
1409"#,
1410 );
1411 let cfg = KhiveConfig::load(Some(&path))
1412 .expect("no error")
1413 .expect("file found");
1414 assert!(cfg.backends.is_empty());
1415 assert!(cfg.packs.is_empty());
1416 }
1417
1418 #[test]
1419 fn test_single_sqlite_backend_parses() {
1420 let dir = tempfile::tempdir().unwrap();
1421 let path = write_toml(
1422 &dir,
1423 r#"
1424[[backends]]
1425name = "knowledge"
1426kind = "sqlite"
1427path = "/tmp/knowledge.db"
1428"#,
1429 );
1430 let cfg = KhiveConfig::load(Some(&path))
1431 .expect("no error")
1432 .expect("file found");
1433 assert_eq!(cfg.backends.len(), 1);
1434 let b = &cfg.backends[0];
1435 assert_eq!(b.name, "knowledge");
1436 assert!(matches!(b.kind, BackendKind::Sqlite));
1437 assert_eq!(
1438 b.path.as_ref().and_then(|p| p.to_str()),
1439 Some("/tmp/knowledge.db")
1440 );
1441 }
1442
1443 #[test]
1444 fn test_memory_backend_parses() {
1445 let dir = tempfile::tempdir().unwrap();
1446 let path = write_toml(
1447 &dir,
1448 r#"
1449[[backends]]
1450name = "ephemeral"
1451kind = "memory"
1452"#,
1453 );
1454 let cfg = KhiveConfig::load(Some(&path))
1455 .expect("no error")
1456 .expect("file found");
1457 assert_eq!(cfg.backends.len(), 1);
1458 assert!(matches!(cfg.backends[0].kind, BackendKind::Memory));
1459 }
1460
1461 #[test]
1462 fn test_pack_backend_assignment_parses() {
1463 let dir = tempfile::tempdir().unwrap();
1464 let path = write_toml(
1465 &dir,
1466 r#"
1467[[backends]]
1468name = "knowledge"
1469kind = "memory"
1470
1471[packs.knowledge]
1472backend = "knowledge"
1473"#,
1474 );
1475 let cfg = KhiveConfig::load(Some(&path))
1476 .expect("no error")
1477 .expect("file found");
1478 assert_eq!(cfg.packs.len(), 1);
1479 let pc = cfg.packs.get("knowledge").expect("knowledge pack present");
1480 assert_eq!(pc.backend, "knowledge");
1481 }
1482
1483 #[test]
1484 fn test_duplicate_backend_name_rejected() {
1485 let dir = tempfile::tempdir().unwrap();
1486 let path = write_toml(
1487 &dir,
1488 r#"
1489[[backends]]
1490name = "dup"
1491kind = "memory"
1492
1493[[backends]]
1494name = "dup"
1495kind = "memory"
1496"#,
1497 );
1498 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
1499 assert!(
1500 matches!(err, ConfigError::DuplicateBackendName { ref name } if name == "dup"),
1501 "expected DuplicateBackendName {{ name: \"dup\" }}, got {err:?}"
1502 );
1503 }
1504
1505 #[test]
1506 fn test_pack_referencing_undefined_backend_rejected() {
1507 let dir = tempfile::tempdir().unwrap();
1508 let path = write_toml(
1509 &dir,
1510 r#"
1511[[backends]]
1512name = "knowledge"
1513kind = "memory"
1514
1515[packs.kg]
1516backend = "nonexistent"
1517"#,
1518 );
1519 let err =
1520 KhiveConfig::load(Some(&path)).expect_err("should fail with unknown backend reference");
1521 assert!(
1522 matches!(err, ConfigError::UnknownPackBackend { ref pack, ref backend, .. }
1523 if pack == "kg" && backend == "nonexistent"),
1524 "expected UnknownPackBackend for kg→nonexistent, got {err:?}"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_pack_config_without_backends_section_is_allowed() {
1530 let dir = tempfile::tempdir().unwrap();
1531 let path = write_toml(
1534 &dir,
1535 r#"
1536[packs.kg]
1537backend = "main"
1538"#,
1539 );
1540 let cfg = KhiveConfig::load(Some(&path))
1541 .expect("no error expected")
1542 .expect("file found");
1543 assert_eq!(cfg.backends.len(), 0);
1544 assert_eq!(cfg.packs.len(), 1);
1545 }
1546
1547 #[test]
1548 fn test_backend_cache_mb_rejected_at_validate() {
1549 let dir = tempfile::tempdir().unwrap();
1550 let path = write_toml(
1551 &dir,
1552 r#"
1553[[backends]]
1554name = "main"
1555kind = "memory"
1556cache_mb = 128
1557"#,
1558 );
1559 let err = KhiveConfig::load(Some(&path)).expect_err("cache_mb must be rejected");
1560 assert!(
1561 matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "cache_mb" } if name == "main"),
1562 "expected UnsupportedBackendField {{ name: \"main\", field: \"cache_mb\" }}, got {err:?}"
1563 );
1564 }
1565
1566 #[test]
1567 fn test_backend_journal_mode_rejected_at_validate() {
1568 let dir = tempfile::tempdir().unwrap();
1569 let path = write_toml(
1570 &dir,
1571 r#"
1572[[backends]]
1573name = "main"
1574kind = "memory"
1575journal_mode = "wal"
1576"#,
1577 );
1578 let err = KhiveConfig::load(Some(&path)).expect_err("journal_mode must be rejected");
1579 assert!(
1580 matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "journal_mode" } if name == "main"),
1581 "expected UnsupportedBackendField {{ name: \"main\", field: \"journal_mode\" }}, got {err:?}"
1582 );
1583 }
1584
1585 #[test]
1588 fn test_top_level_db_rejected_at_validate() {
1589 let dir = tempfile::tempdir().unwrap();
1590 let path = write_toml(
1591 &dir,
1592 r#"
1593db = "/tmp/scratch/demo.db"
1594"#,
1595 );
1596 let err = KhiveConfig::load(Some(&path)).expect_err("top-level db must be rejected");
1597 assert!(
1598 matches!(err, ConfigError::UnsupportedTopLevelDb { ref value } if value == "/tmp/scratch/demo.db"),
1599 "expected UnsupportedTopLevelDb {{ value: \"/tmp/scratch/demo.db\" }}, got {err:?}"
1600 );
1601 }
1602}