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