1use std::path::{Path, PathBuf};
8
9use khive_types::namespace::Namespace;
10use serde::Deserialize;
11use thiserror::Error;
12
13#[derive(Debug, Error)]
17pub enum ConfigError {
18 #[error("config file I/O: {0}")]
19 Io(#[from] std::io::Error),
20
21 #[error("config TOML parse error in {path}: {source}")]
22 Parse {
23 path: PathBuf,
24 #[source]
25 source: toml::de::Error,
26 },
27
28 #[error("exactly one engine must be marked `default = true`; found {found}")]
29 DefaultCount { found: usize },
30
31 #[error("duplicate engine name: {name:?}")]
32 DuplicateName { name: String },
33
34 #[error(
35 "engine {name:?}: model {model:?} is not a recognized lattice_embed::EmbeddingModel name"
36 )]
37 UnknownModel { name: String, model: String },
38
39 #[error("engine {name:?}: fusion_weight must be > 0, got {value}")]
40 InvalidFusionWeight { name: String, value: f64 },
41
42 #[error("actor.id {id:?} is not a valid namespace: {reason}")]
43 InvalidActorId { id: String, reason: String },
44}
45
46#[derive(Debug, Clone, Deserialize)]
50pub struct EngineConfig {
51 pub name: String,
53
54 pub model: String,
59
60 #[serde(default)]
63 pub default: bool,
64
65 pub fusion_weight: Option<f64>,
75
76 pub dims: Option<u32>,
82}
83
84#[derive(Debug, Clone, Deserialize, Default)]
97pub struct ActorConfig {
98 #[serde(default)]
104 pub id: Option<String>,
105
106 #[serde(default)]
109 pub display_name: Option<String>,
110}
111
112#[derive(Debug, Clone, Deserialize, Default)]
121pub struct KhiveConfig {
122 #[serde(default)]
124 pub engines: Vec<EngineConfig>,
125
126 #[serde(default)]
134 pub actor: ActorConfig,
135
136 #[serde(default)]
138 pub runtime: RuntimeSectionConfig,
139}
140
141#[derive(Debug, Clone, Deserialize, Default)]
147pub struct RuntimeSectionConfig {
148 #[serde(default)]
155 pub brain_profile: Option<String>,
156}
157
158impl KhiveConfig {
159 pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
175 let resolved = match path {
176 Some(p) => p.to_path_buf(),
177 None => PathBuf::from(".khive/config.toml"),
178 };
179
180 if !resolved.exists() {
181 return Ok(None);
182 }
183
184 let raw = std::fs::read_to_string(&resolved)?;
185 let cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
186 path: resolved,
187 source,
188 })?;
189 cfg.validate()?;
190 Ok(Some(cfg))
191 }
192
193 pub fn load_with_home_fallback(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
204 if let Some(p) = path {
206 return Self::load(Some(p));
207 }
208
209 let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
211 let home_root = std::env::var_os("HOME").map(PathBuf::from);
212 Self::load_with_roots(&project_root, home_root.as_deref())
213 }
214
215 pub(crate) fn load_with_roots(
222 project_root: &Path,
223 home_root: Option<&Path>,
224 ) -> Result<Option<Self>, ConfigError> {
225 let tier2 = project_root.join("khive.toml");
227 if tier2.exists() {
228 return Self::load(Some(&tier2));
229 }
230
231 let tier3 = project_root.join(".khive/config.toml");
233 if tier3.exists() {
234 return Self::load(Some(&tier3));
235 }
236
237 if let Some(home) = home_root {
239 let tier4 = home.join(".khive/config.toml");
240 if tier4.exists() {
241 return Self::load(Some(&tier4));
242 }
243 }
244
245 Ok(None)
246 }
247
248 pub fn validate(&self) -> Result<(), ConfigError> {
258 if let Some(id) = self.actor.id.as_deref() {
261 if id.is_empty() {
262 return Err(ConfigError::InvalidActorId {
263 id: id.to_string(),
264 reason: "actor.id must not be empty; remove the key or provide a value"
265 .to_string(),
266 });
267 }
268 Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
269 id: id.to_string(),
270 reason: e.to_string(),
271 })?;
272 }
273
274 if self.engines.is_empty() {
275 return Ok(());
276 }
277
278 let mut seen_names = std::collections::HashSet::new();
280 for engine in &self.engines {
281 if !seen_names.insert(engine.name.clone()) {
282 return Err(ConfigError::DuplicateName {
283 name: engine.name.clone(),
284 });
285 }
286 }
287
288 let default_count = self.engines.iter().filter(|e| e.default).count();
290 if default_count != 1 {
291 return Err(ConfigError::DefaultCount {
292 found: default_count,
293 });
294 }
295
296 for engine in &self.engines {
300 if let Some(w) = engine.fusion_weight {
301 if !w.is_finite() || w <= 0.0 {
302 return Err(ConfigError::InvalidFusionWeight {
303 name: engine.name.clone(),
304 value: w,
305 });
306 }
307 }
308 }
309
310 Ok(())
311 }
312
313 pub fn default_engine(&self) -> Option<&EngineConfig> {
315 self.engines.iter().find(|e| e.default)
316 }
317}
318
319pub fn config_from_env() -> KhiveConfig {
329 let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
330 .ok()
331 .filter(|s| !s.trim().is_empty());
332 let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
333 .ok()
334 .unwrap_or_default();
335 let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
336 .into_iter()
337 .filter(|s| !s.is_empty())
338 .collect();
339
340 if primary_model.is_none() && additional.is_empty() {
341 return KhiveConfig::default();
342 }
343
344 tracing::info!(
345 "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
346 );
347
348 let mut engines = Vec::new();
349
350 if let Some(model) = primary_model {
351 engines.push(EngineConfig {
352 name: "default".to_string(),
353 model,
354 default: true,
355 fusion_weight: None,
356 dims: None,
357 });
358 }
359
360 for (i, model) in additional.into_iter().enumerate() {
361 engines.push(EngineConfig {
362 name: format!("engine-{}", i + 1),
363 model,
364 default: false,
365 fusion_weight: None,
366 dims: None,
367 });
368 }
369
370 if !engines.is_empty() && !engines.iter().any(|e| e.default) {
373 engines[0].default = true;
374 }
375
376 KhiveConfig {
377 engines,
378 actor: ActorConfig::default(),
379 runtime: RuntimeSectionConfig::default(),
380 }
381}
382
383#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn write_toml(dir: &tempfile::TempDir, content: &str) -> PathBuf {
395 let path = dir.path().join("config.toml");
396 std::fs::write(&path, content).unwrap();
397 path
398 }
399
400 #[test]
402 fn test_load_minimal_config() {
403 let dir = tempfile::tempdir().unwrap();
404 let path = write_toml(
405 &dir,
406 r#"
407[[engines]]
408name = "x"
409model = "all-minilm-l6-v2"
410default = true
411"#,
412 );
413 let cfg = KhiveConfig::load(Some(&path))
414 .expect("load should succeed")
415 .expect("file should be found");
416 assert_eq!(cfg.engines.len(), 1);
417 assert_eq!(cfg.engines[0].name, "x");
418 assert_eq!(cfg.engines[0].model, "all-minilm-l6-v2");
419 assert!(cfg.engines[0].default);
420 }
421
422 #[test]
424 fn test_default_engine_required_when_engines_present() {
425 let dir = tempfile::tempdir().unwrap();
426 let path = write_toml(
427 &dir,
428 r#"
429[[engines]]
430name = "a"
431model = "all-minilm-l6-v2"
432"#,
433 );
434 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with no default flagged");
435 assert!(
436 matches!(err, ConfigError::DefaultCount { found: 0 }),
437 "expected DefaultCount {{ found: 0 }}, got {err:?}"
438 );
439 }
440
441 #[test]
443 fn test_multiple_default_rejected() {
444 let dir = tempfile::tempdir().unwrap();
445 let path = write_toml(
446 &dir,
447 r#"
448[[engines]]
449name = "a"
450model = "all-minilm-l6-v2"
451default = true
452
453[[engines]]
454name = "b"
455model = "paraphrase-multilingual-minilm-l12-v2"
456default = true
457"#,
458 );
459 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with two defaults");
460 assert!(
461 matches!(err, ConfigError::DefaultCount { found: 2 }),
462 "expected DefaultCount {{ found: 2 }}, got {err:?}"
463 );
464 }
465
466 #[test]
468 fn test_fusion_weight_validation() {
469 let dir = tempfile::tempdir().unwrap();
470 let path = write_toml(
471 &dir,
472 r#"
473[[engines]]
474name = "a"
475model = "all-minilm-l6-v2"
476default = true
477fusion_weight = -0.5
478"#,
479 );
480 let err =
481 KhiveConfig::load(Some(&path)).expect_err("should fail with negative fusion_weight");
482 assert!(
483 matches!(err, ConfigError::InvalidFusionWeight { .. }),
484 "expected InvalidFusionWeight, got {err:?}"
485 );
486
487 let path2 = write_toml(
488 &dir,
489 r#"
490[[engines]]
491name = "a"
492model = "all-minilm-l6-v2"
493default = true
494fusion_weight = 0.0
495"#,
496 );
497 let err2 =
498 KhiveConfig::load(Some(&path2)).expect_err("should fail with zero fusion_weight");
499 assert!(
500 matches!(err2, ConfigError::InvalidFusionWeight { .. }),
501 "expected InvalidFusionWeight, got {err2:?}"
502 );
503 }
504
505 #[test]
507 fn test_env_var_fallback() {
508 let dir = tempfile::tempdir().unwrap();
509 let absent = dir.path().join("missing.toml");
510
511 let loaded = KhiveConfig::load(Some(&absent)).unwrap();
513 assert!(loaded.is_none());
514
515 let primary = "all-minilm-l6-v2".to_string();
519 let additional = vec!["paraphrase-multilingual-minilm-l12-v2".to_string()];
520
521 let mut engines = vec![EngineConfig {
522 name: "default".to_string(),
523 model: primary,
524 default: true,
525 fusion_weight: None,
526 dims: None,
527 }];
528 for (i, model) in additional.into_iter().enumerate() {
529 engines.push(EngineConfig {
530 name: format!("engine-{}", i + 1),
531 model,
532 default: false,
533 fusion_weight: None,
534 dims: None,
535 });
536 }
537 let cfg = KhiveConfig {
538 engines,
539 actor: ActorConfig::default(),
540 runtime: RuntimeSectionConfig::default(),
541 };
542 cfg.validate().expect("env-derived config should be valid");
543 assert_eq!(cfg.engines.len(), 2);
544 assert!(cfg.default_engine().is_some());
545 assert_eq!(cfg.default_engine().unwrap().name, "default");
546 }
547
548 #[test]
550 fn test_file_overrides_env() {
551 let dir = tempfile::tempdir().unwrap();
552 let path = write_toml(
553 &dir,
554 r#"
555[[engines]]
556name = "file-engine"
557model = "all-minilm-l6-v2"
558default = true
559"#,
560 );
561
562 let cfg = KhiveConfig::load(Some(&path))
567 .expect("load should succeed")
568 .expect("file should be present");
569 assert_eq!(cfg.engines[0].name, "file-engine");
570 }
571
572 #[test]
574 fn test_duplicate_engine_names_rejected() {
575 let dir = tempfile::tempdir().unwrap();
576 let path = write_toml(
577 &dir,
578 r#"
579[[engines]]
580name = "shared"
581model = "all-minilm-l6-v2"
582default = true
583
584[[engines]]
585name = "shared"
586model = "paraphrase-multilingual-minilm-l12-v2"
587"#,
588 );
589 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
590 assert!(
591 matches!(err, ConfigError::DuplicateName { .. }),
592 "expected DuplicateName, got {err:?}"
593 );
594 }
595
596 #[test]
598 fn test_empty_config_is_valid() {
599 let dir = tempfile::tempdir().unwrap();
600 let path = write_toml(&dir, "# no engines\n");
601 let cfg = KhiveConfig::load(Some(&path))
602 .expect("load should succeed")
603 .expect("file should be found");
604 assert!(cfg.engines.is_empty());
605 cfg.validate().expect("empty config should be valid");
606 }
607
608 #[test]
610 fn test_multi_engine_positive_fusion_weight() {
611 let dir = tempfile::tempdir().unwrap();
612 let path = write_toml(
613 &dir,
614 r#"
615[[engines]]
616name = "primary"
617model = "all-minilm-l6-v2"
618default = true
619fusion_weight = 0.7
620
621[[engines]]
622name = "secondary"
623model = "paraphrase-multilingual-minilm-l12-v2"
624fusion_weight = 0.3
625"#,
626 );
627 let cfg = KhiveConfig::load(Some(&path))
628 .expect("load should succeed")
629 .expect("file should be found");
630 assert_eq!(cfg.engines.len(), 2);
631 assert_eq!(cfg.engines[0].fusion_weight, Some(0.7));
632 assert_eq!(cfg.engines[1].fusion_weight, Some(0.3));
633 }
634
635 #[test]
637 fn test_actor_id_parsed() {
638 let dir = tempfile::tempdir().unwrap();
639 let path = write_toml(
640 &dir,
641 r#"
642[actor]
643id = "lambda:khive"
644display_name = "Ocean's khive lambda"
645"#,
646 );
647 let cfg = KhiveConfig::load(Some(&path))
648 .expect("load should succeed")
649 .expect("file should be found");
650 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:khive"));
651 assert_eq!(
652 cfg.actor.display_name.as_deref(),
653 Some("Ocean's khive lambda")
654 );
655 assert!(cfg.engines.is_empty());
656 }
657
658 #[test]
660 fn test_actor_and_engines_together() {
661 let dir = tempfile::tempdir().unwrap();
662 let path = write_toml(
663 &dir,
664 r#"
665[actor]
666id = "lambda:test"
667
668[[engines]]
669name = "default"
670model = "all-minilm-l6-v2"
671default = true
672"#,
673 );
674 let cfg = KhiveConfig::load(Some(&path))
675 .expect("load should succeed")
676 .expect("file should be found");
677 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:test"));
678 assert_eq!(cfg.engines.len(), 1);
679 }
680
681 #[test]
683 fn test_actor_absent_defaults_to_none() {
684 let dir = tempfile::tempdir().unwrap();
685 let path = write_toml(
686 &dir,
687 r#"
688[[engines]]
689name = "x"
690model = "all-minilm-l6-v2"
691default = true
692"#,
693 );
694 let cfg = KhiveConfig::load(Some(&path))
695 .expect("load should succeed")
696 .expect("file should be found");
697 assert!(
698 cfg.actor.id.is_none(),
699 "actor.id must be None when [actor] section is absent"
700 );
701 }
702
703 #[test]
705 fn test_load_with_home_fallback_no_files() {
706 let project_dir = tempfile::tempdir().unwrap();
707 let home_dir = tempfile::tempdir().unwrap();
708 let result = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()));
709 assert!(
710 result.expect("no error expected").is_none(),
711 "should return None when no config files exist in the given roots"
712 );
713 }
714
715 #[test]
717 fn test_load_with_home_fallback_explicit_path() {
718 let dir = tempfile::tempdir().unwrap();
719 let path = write_toml(
720 &dir,
721 r#"
722[actor]
723id = "lambda:explicit"
724"#,
725 );
726 let cfg = KhiveConfig::load_with_home_fallback(Some(&path))
727 .expect("no error expected")
728 .expect("file found");
729 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:explicit"));
730 }
731
732 #[test]
734 fn test_invalid_actor_id_rejected_at_load() {
735 let dir = tempfile::tempdir().unwrap();
736 let path = write_toml(
737 &dir,
738 r#"
739[actor]
740id = "bad namespace"
741"#,
742 );
743 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with invalid actor.id");
744 assert!(
745 matches!(err, ConfigError::InvalidActorId { .. }),
746 "expected InvalidActorId, got {err:?}"
747 );
748 }
749
750 #[test]
752 fn test_empty_actor_id_rejected() {
753 let dir = tempfile::tempdir().unwrap();
754 let path = write_toml(
755 &dir,
756 r#"
757[actor]
758id = ""
759"#,
760 );
761 let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id should be rejected");
762 assert!(
763 matches!(err, ConfigError::InvalidActorId { .. }),
764 "expected InvalidActorId for empty string, got {err:?}"
765 );
766 }
767
768 #[test]
770 fn test_malformed_actor_id_lambda_colon_only() {
771 let dir = tempfile::tempdir().unwrap();
772 let path = write_toml(
773 &dir,
774 r#"
775[actor]
776id = "lambda:"
777"#,
778 );
779 let err =
780 KhiveConfig::load(Some(&path)).expect_err("lambda: with no slug should be rejected");
781 assert!(
782 matches!(err, ConfigError::InvalidActorId { .. }),
783 "expected InvalidActorId for 'lambda:', got {err:?}"
784 );
785 }
786
787 #[test]
789 fn test_runtime_config_actor_id_applied() {
790 use crate::runtime::runtime_config_from_khive_config;
791 use crate::RuntimeConfig;
792 use khive_types::namespace::Namespace;
793
794 let cfg = KhiveConfig {
795 engines: vec![],
796 actor: ActorConfig {
797 id: Some("lambda:test-actor".to_string()),
798 display_name: None,
799 },
800 runtime: RuntimeSectionConfig::default(),
801 };
802 cfg.validate().expect("valid config");
803
804 let base = RuntimeConfig::default();
805 let result = runtime_config_from_khive_config(&cfg, base);
806 assert_eq!(
807 result.default_namespace,
808 Namespace::parse("lambda:test-actor").unwrap(),
809 "actor.id must become default_namespace"
810 );
811 }
812
813 #[test]
815 fn test_runtime_config_no_actor_preserves_base() {
816 use crate::runtime::runtime_config_from_khive_config;
817 use crate::RuntimeConfig;
818 use khive_types::namespace::Namespace;
819
820 let cfg = KhiveConfig {
821 engines: vec![],
822 actor: ActorConfig {
823 id: None,
824 display_name: None,
825 },
826 runtime: RuntimeSectionConfig::default(),
827 };
828 cfg.validate().expect("valid config");
829
830 let base_ns = Namespace::parse("lambda:base").unwrap();
831 let base = RuntimeConfig {
832 default_namespace: base_ns.clone(),
833 ..RuntimeConfig::default()
834 };
835 let result = runtime_config_from_khive_config(&cfg, base);
836 assert_eq!(
837 result.default_namespace, base_ns,
838 "no actor.id must leave base namespace unchanged"
839 );
840 }
841
842 #[test]
844 fn test_load_with_home_fallback_project_root_over_hidden() {
845 let dir = tempfile::tempdir().unwrap();
846
847 std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
849 std::fs::write(
850 dir.path().join(".khive/config.toml"),
851 "[actor]\nid = \"lambda:hidden\"\n",
852 )
853 .unwrap();
854
855 std::fs::write(
857 dir.path().join("khive.toml"),
858 "[actor]\nid = \"lambda:project-root\"\n",
859 )
860 .unwrap();
861
862 let cfg = KhiveConfig::load_with_roots(dir.path(), None)
863 .expect("no error expected")
864 .expect("file should be found");
865 assert_eq!(
866 cfg.actor.id.as_deref(),
867 Some("lambda:project-root"),
868 "khive.toml (tier 2) must win over .khive/config.toml (tier 3)"
869 );
870 }
871
872 #[test]
874 fn test_load_with_home_fallback_hidden_over_absent_root() {
875 let dir = tempfile::tempdir().unwrap();
876
877 std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
878 std::fs::write(
879 dir.path().join(".khive/config.toml"),
880 "[actor]\nid = \"lambda:hidden-config\"\n",
881 )
882 .unwrap();
883 let cfg = KhiveConfig::load_with_roots(dir.path(), None)
886 .expect("no error expected")
887 .expect("file should be found");
888 assert_eq!(
889 cfg.actor.id.as_deref(),
890 Some("lambda:hidden-config"),
891 ".khive/config.toml (tier 3) must be found when khive.toml is absent"
892 );
893 }
894
895 #[test]
897 fn test_load_with_roots_home_tier_found() {
898 let project_dir = tempfile::tempdir().unwrap();
899 let home_dir = tempfile::tempdir().unwrap();
900
901 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
902 std::fs::write(
903 home_dir.path().join(".khive/config.toml"),
904 "[actor]\nid = \"lambda:user-global\"\n",
905 )
906 .unwrap();
907 let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()))
910 .expect("no error expected")
911 .expect("file should be found");
912 assert_eq!(
913 cfg.actor.id.as_deref(),
914 Some("lambda:user-global"),
915 "~/.khive/config.toml (tier 4) must be found when project files absent"
916 );
917 }
918
919 #[test]
921 fn test_load_with_roots_project_wins_over_home() {
922 let project_dir = tempfile::tempdir().unwrap();
923 let home_dir = tempfile::tempdir().unwrap();
924
925 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
927 std::fs::write(
928 home_dir.path().join(".khive/config.toml"),
929 "[actor]\nid = \"lambda:user-global\"\n",
930 )
931 .unwrap();
932
933 std::fs::create_dir_all(project_dir.path().join(".khive")).unwrap();
935 std::fs::write(
936 project_dir.path().join(".khive/config.toml"),
937 "[actor]\nid = \"lambda:project-wins\"\n",
938 )
939 .unwrap();
940
941 let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()))
942 .expect("no error expected")
943 .expect("file should be found");
944 assert_eq!(
945 cfg.actor.id.as_deref(),
946 Some("lambda:project-wins"),
947 "project .khive/config.toml (tier 3) must win over ~/.khive/config.toml (tier 4)"
948 );
949 }
950}