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
67#[derive(Debug, Clone, Deserialize)]
71pub struct EngineConfig {
72 pub name: String,
74
75 pub model: String,
80
81 #[serde(default)]
84 pub default: bool,
85
86 pub fusion_weight: Option<f64>,
96
97 pub dims: Option<u32>,
103}
104
105#[derive(Debug, Clone, Deserialize, Default)]
126pub struct ActorConfig {
127 #[serde(default)]
133 pub id: Option<String>,
134
135 #[serde(default)]
138 pub display_name: Option<String>,
139
140 #[serde(default)]
146 pub visible_namespaces: Option<Vec<String>>,
147
148 #[serde(default)]
160 pub allowed_outbound_namespaces: Vec<String>,
161}
162
163#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
167#[serde(rename_all = "lowercase")]
168pub enum BackendKind {
169 #[default]
171 Sqlite,
172 Memory,
174}
175
176#[derive(Debug, Clone, Deserialize)]
193pub struct BackendConfig {
194 pub name: String,
196 #[serde(default)]
198 pub kind: BackendKind,
199 pub path: Option<std::path::PathBuf>,
202 pub cache_mb: Option<u32>,
204 pub journal_mode: Option<String>,
206 #[serde(default)]
208 pub read_only: bool,
209}
210
211#[derive(Debug, Clone, Deserialize)]
221pub struct PackConfig {
222 pub backend: String,
224}
225
226#[derive(Debug, Clone, Deserialize, Default)]
237pub struct KhiveConfig {
238 #[serde(default)]
240 pub engines: Vec<EngineConfig>,
241
242 #[serde(default)]
250 pub actor: ActorConfig,
251
252 #[serde(default)]
254 pub runtime: RuntimeSectionConfig,
255
256 #[serde(default)]
261 pub backends: Vec<BackendConfig>,
262
263 #[serde(default)]
269 pub packs: std::collections::HashMap<String, PackConfig>,
270}
271
272#[derive(Debug, Clone, Deserialize, Default)]
278pub struct RuntimeSectionConfig {
279 #[serde(default)]
286 pub brain_profile: Option<String>,
287
288 #[serde(default)]
295 pub default_output_format: Option<OutputFormat>,
296}
297
298impl KhiveConfig {
299 pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
315 let resolved = match path {
316 Some(p) => p.to_path_buf(),
317 None => PathBuf::from(".khive/config.toml"),
318 };
319
320 if !resolved.exists() {
321 return Ok(None);
322 }
323
324 let raw = std::fs::read_to_string(&resolved)?;
325 let cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
326 path: resolved,
327 source,
328 })?;
329 cfg.validate()?;
330 Ok(Some(cfg))
331 }
332
333 pub fn load_with_home_fallback(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
344 if let Some(p) = path {
346 return Self::load(Some(p));
347 }
348
349 let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
351 let home_root = std::env::var_os("HOME").map(PathBuf::from);
352 Self::load_with_roots(&project_root, home_root.as_deref())
353 }
354
355 pub(crate) fn load_with_roots(
362 project_root: &Path,
363 home_root: Option<&Path>,
364 ) -> Result<Option<Self>, ConfigError> {
365 let tier2 = project_root.join("khive.toml");
367 if tier2.exists() {
368 return Self::load(Some(&tier2));
369 }
370
371 let tier3 = project_root.join(".khive/config.toml");
373 if tier3.exists() {
374 return Self::load(Some(&tier3));
375 }
376
377 if let Some(home) = home_root {
379 let tier4 = home.join(".khive/config.toml");
380 if tier4.exists() {
381 return Self::load(Some(&tier4));
382 }
383 }
384
385 Ok(None)
386 }
387
388 pub fn validate(&self) -> Result<(), ConfigError> {
398 if let Some(id) = self.actor.id.as_deref() {
401 if id.is_empty() {
402 return Err(ConfigError::InvalidActorId {
403 id: id.to_string(),
404 reason: "actor.id must not be empty; remove the key or provide a value"
405 .to_string(),
406 });
407 }
408 Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
409 id: id.to_string(),
410 reason: e.to_string(),
411 })?;
412 }
413
414 if let Some(ref vis) = self.actor.visible_namespaces {
416 for ns_str in vis {
417 if ns_str.is_empty() {
418 return Err(ConfigError::InvalidActorId {
419 id: ns_str.clone(),
420 reason: "visible_namespaces entries must not be empty".to_string(),
421 });
422 }
423 Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
424 id: ns_str.clone(),
425 reason: format!("invalid visible namespace: {e}"),
426 })?;
427 }
428 }
429
430 for ns_str in &self.actor.allowed_outbound_namespaces {
432 if ns_str.is_empty() {
433 return Err(ConfigError::InvalidActorId {
434 id: ns_str.clone(),
435 reason: "allowed_outbound_namespaces entries must not be empty".to_string(),
436 });
437 }
438 Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
439 id: ns_str.clone(),
440 reason: format!("invalid allowed_outbound_namespaces entry: {e}"),
441 })?;
442 }
443
444 if !self.backends.is_empty() {
446 let mut seen_backends = std::collections::HashSet::new();
447 for backend in &self.backends {
448 if !seen_backends.insert(backend.name.clone()) {
449 return Err(ConfigError::DuplicateBackendName {
450 name: backend.name.clone(),
451 });
452 }
453
454 if backend.cache_mb.is_some() {
458 return Err(ConfigError::UnsupportedBackendField {
459 name: backend.name.clone(),
460 field: "cache_mb",
461 });
462 }
463 if backend.journal_mode.is_some() {
464 return Err(ConfigError::UnsupportedBackendField {
465 name: backend.name.clone(),
466 field: "journal_mode",
467 });
468 }
469 }
470
471 let defined: Vec<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
473 for (pack_name, pack_cfg) in &self.packs {
474 if !defined.contains(&pack_cfg.backend.as_str()) {
475 return Err(ConfigError::UnknownPackBackend {
476 pack: pack_name.clone(),
477 backend: pack_cfg.backend.clone(),
478 defined: defined.join(", "),
479 });
480 }
481 }
482 }
483
484 if self.engines.is_empty() {
485 return Ok(());
486 }
487
488 let mut seen_names = std::collections::HashSet::new();
490 for engine in &self.engines {
491 if !seen_names.insert(engine.name.clone()) {
492 return Err(ConfigError::DuplicateName {
493 name: engine.name.clone(),
494 });
495 }
496 }
497
498 let default_count = self.engines.iter().filter(|e| e.default).count();
500 if default_count != 1 {
501 return Err(ConfigError::DefaultCount {
502 found: default_count,
503 });
504 }
505
506 for engine in &self.engines {
510 if let Some(w) = engine.fusion_weight {
511 if !w.is_finite() || w <= 0.0 {
512 return Err(ConfigError::InvalidFusionWeight {
513 name: engine.name.clone(),
514 value: w,
515 });
516 }
517 }
518 }
519
520 Ok(())
521 }
522
523 pub fn default_engine(&self) -> Option<&EngineConfig> {
525 self.engines.iter().find(|e| e.default)
526 }
527}
528
529pub fn config_from_env() -> KhiveConfig {
539 let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
540 .ok()
541 .filter(|s| !s.trim().is_empty());
542 let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
543 .ok()
544 .unwrap_or_default();
545 let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
546 .into_iter()
547 .filter(|s| !s.is_empty())
548 .collect();
549
550 if primary_model.is_none() && additional.is_empty() {
551 return KhiveConfig::default();
552 }
553
554 tracing::info!(
555 "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
556 );
557
558 let mut engines = Vec::new();
559
560 if let Some(model) = primary_model {
561 engines.push(EngineConfig {
562 name: "default".to_string(),
563 model,
564 default: true,
565 fusion_weight: None,
566 dims: None,
567 });
568 }
569
570 for (i, model) in additional.into_iter().enumerate() {
571 engines.push(EngineConfig {
572 name: format!("engine-{}", i + 1),
573 model,
574 default: false,
575 fusion_weight: None,
576 dims: None,
577 });
578 }
579
580 if !engines.is_empty() && !engines.iter().any(|e| e.default) {
583 engines[0].default = true;
584 }
585
586 KhiveConfig {
587 engines,
588 ..KhiveConfig::default()
589 }
590}
591
592#[cfg(test)]
599mod tests {
600 use super::*;
601
602 fn write_toml(dir: &tempfile::TempDir, content: &str) -> PathBuf {
604 let path = dir.path().join("config.toml");
605 std::fs::write(&path, content).unwrap();
606 path
607 }
608
609 #[test]
611 fn test_load_minimal_config() {
612 let dir = tempfile::tempdir().unwrap();
613 let path = write_toml(
614 &dir,
615 r#"
616[[engines]]
617name = "x"
618model = "all-minilm-l6-v2"
619default = true
620"#,
621 );
622 let cfg = KhiveConfig::load(Some(&path))
623 .expect("load should succeed")
624 .expect("file should be found");
625 assert_eq!(cfg.engines.len(), 1);
626 assert_eq!(cfg.engines[0].name, "x");
627 assert_eq!(cfg.engines[0].model, "all-minilm-l6-v2");
628 assert!(cfg.engines[0].default);
629 }
630
631 #[test]
633 fn test_default_engine_required_when_engines_present() {
634 let dir = tempfile::tempdir().unwrap();
635 let path = write_toml(
636 &dir,
637 r#"
638[[engines]]
639name = "a"
640model = "all-minilm-l6-v2"
641"#,
642 );
643 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with no default flagged");
644 assert!(
645 matches!(err, ConfigError::DefaultCount { found: 0 }),
646 "expected DefaultCount {{ found: 0 }}, got {err:?}"
647 );
648 }
649
650 #[test]
652 fn test_multiple_default_rejected() {
653 let dir = tempfile::tempdir().unwrap();
654 let path = write_toml(
655 &dir,
656 r#"
657[[engines]]
658name = "a"
659model = "all-minilm-l6-v2"
660default = true
661
662[[engines]]
663name = "b"
664model = "paraphrase-multilingual-minilm-l12-v2"
665default = true
666"#,
667 );
668 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with two defaults");
669 assert!(
670 matches!(err, ConfigError::DefaultCount { found: 2 }),
671 "expected DefaultCount {{ found: 2 }}, got {err:?}"
672 );
673 }
674
675 #[test]
677 fn test_fusion_weight_validation() {
678 let dir = tempfile::tempdir().unwrap();
679 let path = write_toml(
680 &dir,
681 r#"
682[[engines]]
683name = "a"
684model = "all-minilm-l6-v2"
685default = true
686fusion_weight = -0.5
687"#,
688 );
689 let err =
690 KhiveConfig::load(Some(&path)).expect_err("should fail with negative fusion_weight");
691 assert!(
692 matches!(err, ConfigError::InvalidFusionWeight { .. }),
693 "expected InvalidFusionWeight, got {err:?}"
694 );
695
696 let path2 = write_toml(
697 &dir,
698 r#"
699[[engines]]
700name = "a"
701model = "all-minilm-l6-v2"
702default = true
703fusion_weight = 0.0
704"#,
705 );
706 let err2 =
707 KhiveConfig::load(Some(&path2)).expect_err("should fail with zero fusion_weight");
708 assert!(
709 matches!(err2, ConfigError::InvalidFusionWeight { .. }),
710 "expected InvalidFusionWeight, got {err2:?}"
711 );
712 }
713
714 #[test]
716 fn test_env_var_fallback() {
717 let dir = tempfile::tempdir().unwrap();
718 let absent = dir.path().join("missing.toml");
719
720 let loaded = KhiveConfig::load(Some(&absent)).unwrap();
722 assert!(loaded.is_none());
723
724 let primary = "all-minilm-l6-v2".to_string();
728 let additional = vec!["paraphrase-multilingual-minilm-l12-v2".to_string()];
729
730 let mut engines = vec![EngineConfig {
731 name: "default".to_string(),
732 model: primary,
733 default: true,
734 fusion_weight: None,
735 dims: None,
736 }];
737 for (i, model) in additional.into_iter().enumerate() {
738 engines.push(EngineConfig {
739 name: format!("engine-{}", i + 1),
740 model,
741 default: false,
742 fusion_weight: None,
743 dims: None,
744 });
745 }
746 let cfg = KhiveConfig {
747 engines,
748 ..KhiveConfig::default()
749 };
750 cfg.validate().expect("env-derived config should be valid");
751 assert_eq!(cfg.engines.len(), 2);
752 assert!(cfg.default_engine().is_some());
753 assert_eq!(cfg.default_engine().unwrap().name, "default");
754 }
755
756 #[test]
758 fn test_file_overrides_env() {
759 let dir = tempfile::tempdir().unwrap();
760 let path = write_toml(
761 &dir,
762 r#"
763[[engines]]
764name = "file-engine"
765model = "all-minilm-l6-v2"
766default = true
767"#,
768 );
769
770 let cfg = KhiveConfig::load(Some(&path))
775 .expect("load should succeed")
776 .expect("file should be present");
777 assert_eq!(cfg.engines[0].name, "file-engine");
778 }
779
780 #[test]
782 fn test_duplicate_engine_names_rejected() {
783 let dir = tempfile::tempdir().unwrap();
784 let path = write_toml(
785 &dir,
786 r#"
787[[engines]]
788name = "shared"
789model = "all-minilm-l6-v2"
790default = true
791
792[[engines]]
793name = "shared"
794model = "paraphrase-multilingual-minilm-l12-v2"
795"#,
796 );
797 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
798 assert!(
799 matches!(err, ConfigError::DuplicateName { .. }),
800 "expected DuplicateName, got {err:?}"
801 );
802 }
803
804 #[test]
806 fn test_empty_config_is_valid() {
807 let dir = tempfile::tempdir().unwrap();
808 let path = write_toml(&dir, "# no engines\n");
809 let cfg = KhiveConfig::load(Some(&path))
810 .expect("load should succeed")
811 .expect("file should be found");
812 assert!(cfg.engines.is_empty());
813 cfg.validate().expect("empty config should be valid");
814 }
815
816 #[test]
818 fn test_multi_engine_positive_fusion_weight() {
819 let dir = tempfile::tempdir().unwrap();
820 let path = write_toml(
821 &dir,
822 r#"
823[[engines]]
824name = "primary"
825model = "all-minilm-l6-v2"
826default = true
827fusion_weight = 0.7
828
829[[engines]]
830name = "secondary"
831model = "paraphrase-multilingual-minilm-l12-v2"
832fusion_weight = 0.3
833"#,
834 );
835 let cfg = KhiveConfig::load(Some(&path))
836 .expect("load should succeed")
837 .expect("file should be found");
838 assert_eq!(cfg.engines.len(), 2);
839 assert_eq!(cfg.engines[0].fusion_weight, Some(0.7));
840 assert_eq!(cfg.engines[1].fusion_weight, Some(0.3));
841 }
842
843 #[test]
845 fn test_actor_id_parsed() {
846 let dir = tempfile::tempdir().unwrap();
847 let path = write_toml(
848 &dir,
849 r#"
850[actor]
851id = "lambda:khive"
852display_name = "Ocean's khive lambda"
853"#,
854 );
855 let cfg = KhiveConfig::load(Some(&path))
856 .expect("load should succeed")
857 .expect("file should be found");
858 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:khive"));
859 assert_eq!(
860 cfg.actor.display_name.as_deref(),
861 Some("Ocean's khive lambda")
862 );
863 assert!(cfg.engines.is_empty());
864 }
865
866 #[test]
868 fn test_actor_and_engines_together() {
869 let dir = tempfile::tempdir().unwrap();
870 let path = write_toml(
871 &dir,
872 r#"
873[actor]
874id = "lambda:test"
875
876[[engines]]
877name = "default"
878model = "all-minilm-l6-v2"
879default = true
880"#,
881 );
882 let cfg = KhiveConfig::load(Some(&path))
883 .expect("load should succeed")
884 .expect("file should be found");
885 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:test"));
886 assert_eq!(cfg.engines.len(), 1);
887 }
888
889 #[test]
891 fn test_actor_absent_defaults_to_none() {
892 let dir = tempfile::tempdir().unwrap();
893 let path = write_toml(
894 &dir,
895 r#"
896[[engines]]
897name = "x"
898model = "all-minilm-l6-v2"
899default = true
900"#,
901 );
902 let cfg = KhiveConfig::load(Some(&path))
903 .expect("load should succeed")
904 .expect("file should be found");
905 assert!(
906 cfg.actor.id.is_none(),
907 "actor.id must be None when [actor] section is absent"
908 );
909 }
910
911 #[test]
913 fn test_load_with_home_fallback_no_files() {
914 let project_dir = tempfile::tempdir().unwrap();
915 let home_dir = tempfile::tempdir().unwrap();
916 let result = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()));
917 assert!(
918 result.expect("no error expected").is_none(),
919 "should return None when no config files exist in the given roots"
920 );
921 }
922
923 #[test]
925 fn test_load_with_home_fallback_explicit_path() {
926 let dir = tempfile::tempdir().unwrap();
927 let path = write_toml(
928 &dir,
929 r#"
930[actor]
931id = "lambda:explicit"
932"#,
933 );
934 let cfg = KhiveConfig::load_with_home_fallback(Some(&path))
935 .expect("no error expected")
936 .expect("file found");
937 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:explicit"));
938 }
939
940 #[test]
942 fn test_invalid_actor_id_rejected_at_load() {
943 let dir = tempfile::tempdir().unwrap();
944 let path = write_toml(
945 &dir,
946 r#"
947[actor]
948id = "bad namespace"
949"#,
950 );
951 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with invalid actor.id");
952 assert!(
953 matches!(err, ConfigError::InvalidActorId { .. }),
954 "expected InvalidActorId, got {err:?}"
955 );
956 }
957
958 #[test]
960 fn test_empty_actor_id_rejected() {
961 let dir = tempfile::tempdir().unwrap();
962 let path = write_toml(
963 &dir,
964 r#"
965[actor]
966id = ""
967"#,
968 );
969 let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id should be rejected");
970 assert!(
971 matches!(err, ConfigError::InvalidActorId { .. }),
972 "expected InvalidActorId for empty string, got {err:?}"
973 );
974 }
975
976 #[test]
978 fn test_malformed_actor_id_lambda_colon_only() {
979 let dir = tempfile::tempdir().unwrap();
980 let path = write_toml(
981 &dir,
982 r#"
983[actor]
984id = "lambda:"
985"#,
986 );
987 let err =
988 KhiveConfig::load(Some(&path)).expect_err("lambda: with no slug should be rejected");
989 assert!(
990 matches!(err, ConfigError::InvalidActorId { .. }),
991 "expected InvalidActorId for 'lambda:', got {err:?}"
992 );
993 }
994
995 #[test]
1000 fn test_runtime_config_actor_id_does_not_override_namespace() {
1001 use crate::runtime::runtime_config_from_khive_config;
1002 use crate::RuntimeConfig;
1003 use khive_types::namespace::Namespace;
1004
1005 let cfg = KhiveConfig {
1006 engines: vec![],
1007 actor: ActorConfig {
1008 id: Some("lambda:test-actor".to_string()),
1009 display_name: None,
1010 ..Default::default()
1011 },
1012 ..KhiveConfig::default()
1013 };
1014 cfg.validate().expect("valid config");
1015
1016 let base = RuntimeConfig::default();
1017 let result = runtime_config_from_khive_config(&cfg, base);
1018 assert_eq!(
1019 result.default_namespace,
1020 Namespace::local(),
1021 "actor.id must NOT become default_namespace (ADR-007 Rev 4 Rule 0); \
1022 writes stay pinned to local"
1023 );
1024 assert!(
1028 result
1029 .visible_namespaces
1030 .contains(&Namespace::parse("lambda:test-actor").unwrap()),
1031 "actor.id must be folded into visible_namespaces (ADR-007 Rev 4 Rule 3b fold-in); \
1032 got: {:?}",
1033 result.visible_namespaces
1034 );
1035 }
1036
1037 #[test]
1039 fn test_runtime_config_no_actor_preserves_base() {
1040 use crate::runtime::runtime_config_from_khive_config;
1041 use crate::RuntimeConfig;
1042 use khive_types::namespace::Namespace;
1043
1044 let cfg = KhiveConfig {
1045 engines: vec![],
1046 actor: ActorConfig {
1047 id: None,
1048 display_name: None,
1049 ..Default::default()
1050 },
1051 ..KhiveConfig::default()
1052 };
1053 cfg.validate().expect("valid config");
1054
1055 let base_ns = Namespace::parse("lambda:base").unwrap();
1056 let base = RuntimeConfig {
1057 default_namespace: base_ns.clone(),
1058 ..RuntimeConfig::default()
1059 };
1060 let result = runtime_config_from_khive_config(&cfg, base);
1061 assert_eq!(
1062 result.default_namespace, base_ns,
1063 "no actor.id must leave base namespace unchanged"
1064 );
1065 }
1066
1067 #[test]
1069 fn test_load_with_home_fallback_project_root_over_hidden() {
1070 let dir = tempfile::tempdir().unwrap();
1071
1072 std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
1074 std::fs::write(
1075 dir.path().join(".khive/config.toml"),
1076 "[actor]\nid = \"lambda:hidden\"\n",
1077 )
1078 .unwrap();
1079
1080 std::fs::write(
1082 dir.path().join("khive.toml"),
1083 "[actor]\nid = \"lambda:project-root\"\n",
1084 )
1085 .unwrap();
1086
1087 let cfg = KhiveConfig::load_with_roots(dir.path(), None)
1088 .expect("no error expected")
1089 .expect("file should be found");
1090 assert_eq!(
1091 cfg.actor.id.as_deref(),
1092 Some("lambda:project-root"),
1093 "khive.toml (tier 2) must win over .khive/config.toml (tier 3)"
1094 );
1095 }
1096
1097 #[test]
1099 fn test_load_with_home_fallback_hidden_over_absent_root() {
1100 let dir = tempfile::tempdir().unwrap();
1101
1102 std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
1103 std::fs::write(
1104 dir.path().join(".khive/config.toml"),
1105 "[actor]\nid = \"lambda:hidden-config\"\n",
1106 )
1107 .unwrap();
1108 let cfg = KhiveConfig::load_with_roots(dir.path(), None)
1111 .expect("no error expected")
1112 .expect("file should be found");
1113 assert_eq!(
1114 cfg.actor.id.as_deref(),
1115 Some("lambda:hidden-config"),
1116 ".khive/config.toml (tier 3) must be found when khive.toml is absent"
1117 );
1118 }
1119
1120 #[test]
1122 fn test_load_with_roots_home_tier_found() {
1123 let project_dir = tempfile::tempdir().unwrap();
1124 let home_dir = tempfile::tempdir().unwrap();
1125
1126 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1127 std::fs::write(
1128 home_dir.path().join(".khive/config.toml"),
1129 "[actor]\nid = \"lambda:user-global\"\n",
1130 )
1131 .unwrap();
1132 let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()))
1135 .expect("no error expected")
1136 .expect("file should be found");
1137 assert_eq!(
1138 cfg.actor.id.as_deref(),
1139 Some("lambda:user-global"),
1140 "~/.khive/config.toml (tier 4) must be found when project files absent"
1141 );
1142 }
1143
1144 #[test]
1146 fn test_load_with_roots_project_wins_over_home() {
1147 let project_dir = tempfile::tempdir().unwrap();
1148 let home_dir = tempfile::tempdir().unwrap();
1149
1150 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1152 std::fs::write(
1153 home_dir.path().join(".khive/config.toml"),
1154 "[actor]\nid = \"lambda:user-global\"\n",
1155 )
1156 .unwrap();
1157
1158 std::fs::create_dir_all(project_dir.path().join(".khive")).unwrap();
1160 std::fs::write(
1161 project_dir.path().join(".khive/config.toml"),
1162 "[actor]\nid = \"lambda:project-wins\"\n",
1163 )
1164 .unwrap();
1165
1166 let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()))
1167 .expect("no error expected")
1168 .expect("file should be found");
1169 assert_eq!(
1170 cfg.actor.id.as_deref(),
1171 Some("lambda:project-wins"),
1172 "project .khive/config.toml (tier 3) must win over ~/.khive/config.toml (tier 4)"
1173 );
1174 }
1175
1176 #[test]
1180 fn test_no_backends_section_is_valid() {
1181 let dir = tempfile::tempdir().unwrap();
1182 let path = write_toml(
1183 &dir,
1184 r#"
1185[[engines]]
1186name = "default"
1187model = "all-minilm-l6-v2"
1188default = true
1189"#,
1190 );
1191 let cfg = KhiveConfig::load(Some(&path))
1192 .expect("no error")
1193 .expect("file found");
1194 assert!(cfg.backends.is_empty());
1195 assert!(cfg.packs.is_empty());
1196 }
1197
1198 #[test]
1200 fn test_single_sqlite_backend_parses() {
1201 let dir = tempfile::tempdir().unwrap();
1202 let path = write_toml(
1203 &dir,
1204 r#"
1205[[backends]]
1206name = "knowledge"
1207kind = "sqlite"
1208path = "/tmp/knowledge.db"
1209"#,
1210 );
1211 let cfg = KhiveConfig::load(Some(&path))
1212 .expect("no error")
1213 .expect("file found");
1214 assert_eq!(cfg.backends.len(), 1);
1215 let b = &cfg.backends[0];
1216 assert_eq!(b.name, "knowledge");
1217 assert!(matches!(b.kind, BackendKind::Sqlite));
1218 assert_eq!(
1219 b.path.as_ref().and_then(|p| p.to_str()),
1220 Some("/tmp/knowledge.db")
1221 );
1222 }
1223
1224 #[test]
1226 fn test_memory_backend_parses() {
1227 let dir = tempfile::tempdir().unwrap();
1228 let path = write_toml(
1229 &dir,
1230 r#"
1231[[backends]]
1232name = "ephemeral"
1233kind = "memory"
1234"#,
1235 );
1236 let cfg = KhiveConfig::load(Some(&path))
1237 .expect("no error")
1238 .expect("file found");
1239 assert_eq!(cfg.backends.len(), 1);
1240 assert!(matches!(cfg.backends[0].kind, BackendKind::Memory));
1241 }
1242
1243 #[test]
1245 fn test_pack_backend_assignment_parses() {
1246 let dir = tempfile::tempdir().unwrap();
1247 let path = write_toml(
1248 &dir,
1249 r#"
1250[[backends]]
1251name = "knowledge"
1252kind = "memory"
1253
1254[packs.knowledge]
1255backend = "knowledge"
1256"#,
1257 );
1258 let cfg = KhiveConfig::load(Some(&path))
1259 .expect("no error")
1260 .expect("file found");
1261 assert_eq!(cfg.packs.len(), 1);
1262 let pc = cfg.packs.get("knowledge").expect("knowledge pack present");
1263 assert_eq!(pc.backend, "knowledge");
1264 }
1265
1266 #[test]
1268 fn test_duplicate_backend_name_rejected() {
1269 let dir = tempfile::tempdir().unwrap();
1270 let path = write_toml(
1271 &dir,
1272 r#"
1273[[backends]]
1274name = "dup"
1275kind = "memory"
1276
1277[[backends]]
1278name = "dup"
1279kind = "memory"
1280"#,
1281 );
1282 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
1283 assert!(
1284 matches!(err, ConfigError::DuplicateBackendName { ref name } if name == "dup"),
1285 "expected DuplicateBackendName {{ name: \"dup\" }}, got {err:?}"
1286 );
1287 }
1288
1289 #[test]
1291 fn test_pack_referencing_undefined_backend_rejected() {
1292 let dir = tempfile::tempdir().unwrap();
1293 let path = write_toml(
1294 &dir,
1295 r#"
1296[[backends]]
1297name = "knowledge"
1298kind = "memory"
1299
1300[packs.kg]
1301backend = "nonexistent"
1302"#,
1303 );
1304 let err =
1305 KhiveConfig::load(Some(&path)).expect_err("should fail with unknown backend reference");
1306 assert!(
1307 matches!(err, ConfigError::UnknownPackBackend { ref pack, ref backend, .. }
1308 if pack == "kg" && backend == "nonexistent"),
1309 "expected UnknownPackBackend for kg→nonexistent, got {err:?}"
1310 );
1311 }
1312
1313 #[test]
1315 fn test_pack_config_without_backends_section_is_allowed() {
1316 let dir = tempfile::tempdir().unwrap();
1317 let path = write_toml(
1320 &dir,
1321 r#"
1322[packs.kg]
1323backend = "main"
1324"#,
1325 );
1326 let cfg = KhiveConfig::load(Some(&path))
1328 .expect("no error expected")
1329 .expect("file found");
1330 assert_eq!(cfg.backends.len(), 0);
1331 assert_eq!(cfg.packs.len(), 1);
1332 }
1333
1334 #[test]
1336 fn test_backend_cache_mb_rejected_at_validate() {
1337 let dir = tempfile::tempdir().unwrap();
1338 let path = write_toml(
1339 &dir,
1340 r#"
1341[[backends]]
1342name = "main"
1343kind = "memory"
1344cache_mb = 128
1345"#,
1346 );
1347 let err = KhiveConfig::load(Some(&path)).expect_err("cache_mb must be rejected");
1348 assert!(
1349 matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "cache_mb" } if name == "main"),
1350 "expected UnsupportedBackendField {{ name: \"main\", field: \"cache_mb\" }}, got {err:?}"
1351 );
1352 }
1353
1354 #[test]
1356 fn test_backend_journal_mode_rejected_at_validate() {
1357 let dir = tempfile::tempdir().unwrap();
1358 let path = write_toml(
1359 &dir,
1360 r#"
1361[[backends]]
1362name = "main"
1363kind = "memory"
1364journal_mode = "wal"
1365"#,
1366 );
1367 let err = KhiveConfig::load(Some(&path)).expect_err("journal_mode must be rejected");
1368 assert!(
1369 matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "journal_mode" } if name == "main"),
1370 "expected UnsupportedBackendField {{ name: \"main\", field: \"journal_mode\" }}, got {err:?}"
1371 );
1372 }
1373}