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)]
120pub struct KhiveConfig {
121 #[serde(default)]
123 pub engines: Vec<EngineConfig>,
124
125 #[serde(default)]
133 pub actor: ActorConfig,
134}
135
136impl KhiveConfig {
137 pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
153 let resolved = match path {
154 Some(p) => p.to_path_buf(),
155 None => PathBuf::from(".khive/config.toml"),
156 };
157
158 if !resolved.exists() {
159 return Ok(None);
160 }
161
162 let raw = std::fs::read_to_string(&resolved)?;
163 let cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
164 path: resolved,
165 source,
166 })?;
167 cfg.validate()?;
168 Ok(Some(cfg))
169 }
170
171 pub fn load_with_home_fallback(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
182 if let Some(p) = path {
184 return Self::load(Some(p));
185 }
186
187 let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
189 let home_root = std::env::var_os("HOME").map(PathBuf::from);
190 Self::load_with_roots(&project_root, home_root.as_deref())
191 }
192
193 pub(crate) fn load_with_roots(
200 project_root: &Path,
201 home_root: Option<&Path>,
202 ) -> Result<Option<Self>, ConfigError> {
203 let tier2 = project_root.join("khive.toml");
205 if tier2.exists() {
206 return Self::load(Some(&tier2));
207 }
208
209 let tier3 = project_root.join(".khive/config.toml");
211 if tier3.exists() {
212 return Self::load(Some(&tier3));
213 }
214
215 if let Some(home) = home_root {
217 let tier4 = home.join(".khive/config.toml");
218 if tier4.exists() {
219 return Self::load(Some(&tier4));
220 }
221 }
222
223 Ok(None)
224 }
225
226 pub fn validate(&self) -> Result<(), ConfigError> {
236 if let Some(id) = self.actor.id.as_deref() {
239 if id.is_empty() {
240 return Err(ConfigError::InvalidActorId {
241 id: id.to_string(),
242 reason: "actor.id must not be empty; remove the key or provide a value"
243 .to_string(),
244 });
245 }
246 Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
247 id: id.to_string(),
248 reason: e.to_string(),
249 })?;
250 }
251
252 if self.engines.is_empty() {
253 return Ok(());
254 }
255
256 let mut seen_names = std::collections::HashSet::new();
258 for engine in &self.engines {
259 if !seen_names.insert(engine.name.clone()) {
260 return Err(ConfigError::DuplicateName {
261 name: engine.name.clone(),
262 });
263 }
264 }
265
266 let default_count = self.engines.iter().filter(|e| e.default).count();
268 if default_count != 1 {
269 return Err(ConfigError::DefaultCount {
270 found: default_count,
271 });
272 }
273
274 for engine in &self.engines {
278 if let Some(w) = engine.fusion_weight {
279 if !w.is_finite() || w <= 0.0 {
280 return Err(ConfigError::InvalidFusionWeight {
281 name: engine.name.clone(),
282 value: w,
283 });
284 }
285 }
286 }
287
288 Ok(())
289 }
290
291 pub fn default_engine(&self) -> Option<&EngineConfig> {
293 self.engines.iter().find(|e| e.default)
294 }
295}
296
297pub fn config_from_env() -> KhiveConfig {
307 let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
308 .ok()
309 .filter(|s| !s.trim().is_empty());
310 let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
311 .ok()
312 .unwrap_or_default();
313 let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
314 .into_iter()
315 .filter(|s| !s.is_empty())
316 .collect();
317
318 if primary_model.is_none() && additional.is_empty() {
319 return KhiveConfig::default();
320 }
321
322 tracing::info!(
323 "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
324 );
325
326 let mut engines = Vec::new();
327
328 if let Some(model) = primary_model {
329 engines.push(EngineConfig {
330 name: "default".to_string(),
331 model,
332 default: true,
333 fusion_weight: None,
334 dims: None,
335 });
336 }
337
338 for (i, model) in additional.into_iter().enumerate() {
339 engines.push(EngineConfig {
340 name: format!("engine-{}", i + 1),
341 model,
342 default: false,
343 fusion_weight: None,
344 dims: None,
345 });
346 }
347
348 if !engines.is_empty() && !engines.iter().any(|e| e.default) {
351 engines[0].default = true;
352 }
353
354 KhiveConfig {
355 engines,
356 actor: ActorConfig::default(),
357 }
358}
359
360#[cfg(test)]
367mod tests {
368 use super::*;
369
370 fn write_toml(dir: &tempfile::TempDir, content: &str) -> PathBuf {
372 let path = dir.path().join("config.toml");
373 std::fs::write(&path, content).unwrap();
374 path
375 }
376
377 #[test]
379 fn test_load_minimal_config() {
380 let dir = tempfile::tempdir().unwrap();
381 let path = write_toml(
382 &dir,
383 r#"
384[[engines]]
385name = "x"
386model = "all-minilm-l6-v2"
387default = true
388"#,
389 );
390 let cfg = KhiveConfig::load(Some(&path))
391 .expect("load should succeed")
392 .expect("file should be found");
393 assert_eq!(cfg.engines.len(), 1);
394 assert_eq!(cfg.engines[0].name, "x");
395 assert_eq!(cfg.engines[0].model, "all-minilm-l6-v2");
396 assert!(cfg.engines[0].default);
397 }
398
399 #[test]
401 fn test_default_engine_required_when_engines_present() {
402 let dir = tempfile::tempdir().unwrap();
403 let path = write_toml(
404 &dir,
405 r#"
406[[engines]]
407name = "a"
408model = "all-minilm-l6-v2"
409"#,
410 );
411 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with no default flagged");
412 assert!(
413 matches!(err, ConfigError::DefaultCount { found: 0 }),
414 "expected DefaultCount {{ found: 0 }}, got {err:?}"
415 );
416 }
417
418 #[test]
420 fn test_multiple_default_rejected() {
421 let dir = tempfile::tempdir().unwrap();
422 let path = write_toml(
423 &dir,
424 r#"
425[[engines]]
426name = "a"
427model = "all-minilm-l6-v2"
428default = true
429
430[[engines]]
431name = "b"
432model = "paraphrase-multilingual-minilm-l12-v2"
433default = true
434"#,
435 );
436 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with two defaults");
437 assert!(
438 matches!(err, ConfigError::DefaultCount { found: 2 }),
439 "expected DefaultCount {{ found: 2 }}, got {err:?}"
440 );
441 }
442
443 #[test]
445 fn test_fusion_weight_validation() {
446 let dir = tempfile::tempdir().unwrap();
447 let path = write_toml(
448 &dir,
449 r#"
450[[engines]]
451name = "a"
452model = "all-minilm-l6-v2"
453default = true
454fusion_weight = -0.5
455"#,
456 );
457 let err =
458 KhiveConfig::load(Some(&path)).expect_err("should fail with negative fusion_weight");
459 assert!(
460 matches!(err, ConfigError::InvalidFusionWeight { .. }),
461 "expected InvalidFusionWeight, got {err:?}"
462 );
463
464 let path2 = write_toml(
465 &dir,
466 r#"
467[[engines]]
468name = "a"
469model = "all-minilm-l6-v2"
470default = true
471fusion_weight = 0.0
472"#,
473 );
474 let err2 =
475 KhiveConfig::load(Some(&path2)).expect_err("should fail with zero fusion_weight");
476 assert!(
477 matches!(err2, ConfigError::InvalidFusionWeight { .. }),
478 "expected InvalidFusionWeight, got {err2:?}"
479 );
480 }
481
482 #[test]
484 fn test_env_var_fallback() {
485 let dir = tempfile::tempdir().unwrap();
486 let absent = dir.path().join("missing.toml");
487
488 let loaded = KhiveConfig::load(Some(&absent)).unwrap();
490 assert!(loaded.is_none());
491
492 let primary = "all-minilm-l6-v2".to_string();
496 let additional = vec!["paraphrase-multilingual-minilm-l12-v2".to_string()];
497
498 let mut engines = vec![EngineConfig {
499 name: "default".to_string(),
500 model: primary,
501 default: true,
502 fusion_weight: None,
503 dims: None,
504 }];
505 for (i, model) in additional.into_iter().enumerate() {
506 engines.push(EngineConfig {
507 name: format!("engine-{}", i + 1),
508 model,
509 default: false,
510 fusion_weight: None,
511 dims: None,
512 });
513 }
514 let cfg = KhiveConfig {
515 engines,
516 actor: ActorConfig::default(),
517 };
518 cfg.validate().expect("env-derived config should be valid");
519 assert_eq!(cfg.engines.len(), 2);
520 assert!(cfg.default_engine().is_some());
521 assert_eq!(cfg.default_engine().unwrap().name, "default");
522 }
523
524 #[test]
526 fn test_file_overrides_env() {
527 let dir = tempfile::tempdir().unwrap();
528 let path = write_toml(
529 &dir,
530 r#"
531[[engines]]
532name = "file-engine"
533model = "all-minilm-l6-v2"
534default = true
535"#,
536 );
537
538 let cfg = KhiveConfig::load(Some(&path))
543 .expect("load should succeed")
544 .expect("file should be present");
545 assert_eq!(cfg.engines[0].name, "file-engine");
546 }
547
548 #[test]
550 fn test_duplicate_engine_names_rejected() {
551 let dir = tempfile::tempdir().unwrap();
552 let path = write_toml(
553 &dir,
554 r#"
555[[engines]]
556name = "shared"
557model = "all-minilm-l6-v2"
558default = true
559
560[[engines]]
561name = "shared"
562model = "paraphrase-multilingual-minilm-l12-v2"
563"#,
564 );
565 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
566 assert!(
567 matches!(err, ConfigError::DuplicateName { .. }),
568 "expected DuplicateName, got {err:?}"
569 );
570 }
571
572 #[test]
574 fn test_empty_config_is_valid() {
575 let dir = tempfile::tempdir().unwrap();
576 let path = write_toml(&dir, "# no engines\n");
577 let cfg = KhiveConfig::load(Some(&path))
578 .expect("load should succeed")
579 .expect("file should be found");
580 assert!(cfg.engines.is_empty());
581 cfg.validate().expect("empty config should be valid");
582 }
583
584 #[test]
586 fn test_multi_engine_positive_fusion_weight() {
587 let dir = tempfile::tempdir().unwrap();
588 let path = write_toml(
589 &dir,
590 r#"
591[[engines]]
592name = "primary"
593model = "all-minilm-l6-v2"
594default = true
595fusion_weight = 0.7
596
597[[engines]]
598name = "secondary"
599model = "paraphrase-multilingual-minilm-l12-v2"
600fusion_weight = 0.3
601"#,
602 );
603 let cfg = KhiveConfig::load(Some(&path))
604 .expect("load should succeed")
605 .expect("file should be found");
606 assert_eq!(cfg.engines.len(), 2);
607 assert_eq!(cfg.engines[0].fusion_weight, Some(0.7));
608 assert_eq!(cfg.engines[1].fusion_weight, Some(0.3));
609 }
610
611 #[test]
613 fn test_actor_id_parsed() {
614 let dir = tempfile::tempdir().unwrap();
615 let path = write_toml(
616 &dir,
617 r#"
618[actor]
619id = "lambda:khive"
620display_name = "Ocean's khive lambda"
621"#,
622 );
623 let cfg = KhiveConfig::load(Some(&path))
624 .expect("load should succeed")
625 .expect("file should be found");
626 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:khive"));
627 assert_eq!(
628 cfg.actor.display_name.as_deref(),
629 Some("Ocean's khive lambda")
630 );
631 assert!(cfg.engines.is_empty());
632 }
633
634 #[test]
636 fn test_actor_and_engines_together() {
637 let dir = tempfile::tempdir().unwrap();
638 let path = write_toml(
639 &dir,
640 r#"
641[actor]
642id = "lambda:test"
643
644[[engines]]
645name = "default"
646model = "all-minilm-l6-v2"
647default = true
648"#,
649 );
650 let cfg = KhiveConfig::load(Some(&path))
651 .expect("load should succeed")
652 .expect("file should be found");
653 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:test"));
654 assert_eq!(cfg.engines.len(), 1);
655 }
656
657 #[test]
659 fn test_actor_absent_defaults_to_none() {
660 let dir = tempfile::tempdir().unwrap();
661 let path = write_toml(
662 &dir,
663 r#"
664[[engines]]
665name = "x"
666model = "all-minilm-l6-v2"
667default = true
668"#,
669 );
670 let cfg = KhiveConfig::load(Some(&path))
671 .expect("load should succeed")
672 .expect("file should be found");
673 assert!(
674 cfg.actor.id.is_none(),
675 "actor.id must be None when [actor] section is absent"
676 );
677 }
678
679 #[test]
681 fn test_load_with_home_fallback_no_files() {
682 let project_dir = tempfile::tempdir().unwrap();
683 let home_dir = tempfile::tempdir().unwrap();
684 let result = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()));
685 assert!(
686 result.expect("no error expected").is_none(),
687 "should return None when no config files exist in the given roots"
688 );
689 }
690
691 #[test]
693 fn test_load_with_home_fallback_explicit_path() {
694 let dir = tempfile::tempdir().unwrap();
695 let path = write_toml(
696 &dir,
697 r#"
698[actor]
699id = "lambda:explicit"
700"#,
701 );
702 let cfg = KhiveConfig::load_with_home_fallback(Some(&path))
703 .expect("no error expected")
704 .expect("file found");
705 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:explicit"));
706 }
707
708 #[test]
710 fn test_invalid_actor_id_rejected_at_load() {
711 let dir = tempfile::tempdir().unwrap();
712 let path = write_toml(
713 &dir,
714 r#"
715[actor]
716id = "bad namespace"
717"#,
718 );
719 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with invalid actor.id");
720 assert!(
721 matches!(err, ConfigError::InvalidActorId { .. }),
722 "expected InvalidActorId, got {err:?}"
723 );
724 }
725
726 #[test]
728 fn test_empty_actor_id_rejected() {
729 let dir = tempfile::tempdir().unwrap();
730 let path = write_toml(
731 &dir,
732 r#"
733[actor]
734id = ""
735"#,
736 );
737 let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id should be rejected");
738 assert!(
739 matches!(err, ConfigError::InvalidActorId { .. }),
740 "expected InvalidActorId for empty string, got {err:?}"
741 );
742 }
743
744 #[test]
746 fn test_malformed_actor_id_lambda_colon_only() {
747 let dir = tempfile::tempdir().unwrap();
748 let path = write_toml(
749 &dir,
750 r#"
751[actor]
752id = "lambda:"
753"#,
754 );
755 let err =
756 KhiveConfig::load(Some(&path)).expect_err("lambda: with no slug should be rejected");
757 assert!(
758 matches!(err, ConfigError::InvalidActorId { .. }),
759 "expected InvalidActorId for 'lambda:', got {err:?}"
760 );
761 }
762
763 #[test]
765 fn test_runtime_config_actor_id_applied() {
766 use crate::runtime::runtime_config_from_khive_config;
767 use crate::RuntimeConfig;
768 use khive_types::namespace::Namespace;
769
770 let cfg = KhiveConfig {
771 engines: vec![],
772 actor: ActorConfig {
773 id: Some("lambda:test-actor".to_string()),
774 display_name: None,
775 },
776 };
777 cfg.validate().expect("valid config");
778
779 let base = RuntimeConfig::default();
780 let result = runtime_config_from_khive_config(&cfg, base);
781 assert_eq!(
782 result.default_namespace,
783 Namespace::parse("lambda:test-actor").unwrap(),
784 "actor.id must become default_namespace"
785 );
786 }
787
788 #[test]
790 fn test_runtime_config_no_actor_preserves_base() {
791 use crate::runtime::runtime_config_from_khive_config;
792 use crate::RuntimeConfig;
793 use khive_types::namespace::Namespace;
794
795 let cfg = KhiveConfig {
796 engines: vec![],
797 actor: ActorConfig {
798 id: None,
799 display_name: None,
800 },
801 };
802 cfg.validate().expect("valid config");
803
804 let base_ns = Namespace::parse("lambda:base").unwrap();
805 let base = RuntimeConfig {
806 default_namespace: base_ns.clone(),
807 ..RuntimeConfig::default()
808 };
809 let result = runtime_config_from_khive_config(&cfg, base);
810 assert_eq!(
811 result.default_namespace, base_ns,
812 "no actor.id must leave base namespace unchanged"
813 );
814 }
815
816 #[test]
818 fn test_load_with_home_fallback_project_root_over_hidden() {
819 let dir = tempfile::tempdir().unwrap();
820
821 std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
823 std::fs::write(
824 dir.path().join(".khive/config.toml"),
825 "[actor]\nid = \"lambda:hidden\"\n",
826 )
827 .unwrap();
828
829 std::fs::write(
831 dir.path().join("khive.toml"),
832 "[actor]\nid = \"lambda:project-root\"\n",
833 )
834 .unwrap();
835
836 let cfg = KhiveConfig::load_with_roots(dir.path(), None)
837 .expect("no error expected")
838 .expect("file should be found");
839 assert_eq!(
840 cfg.actor.id.as_deref(),
841 Some("lambda:project-root"),
842 "khive.toml (tier 2) must win over .khive/config.toml (tier 3)"
843 );
844 }
845
846 #[test]
848 fn test_load_with_home_fallback_hidden_over_absent_root() {
849 let dir = tempfile::tempdir().unwrap();
850
851 std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
852 std::fs::write(
853 dir.path().join(".khive/config.toml"),
854 "[actor]\nid = \"lambda:hidden-config\"\n",
855 )
856 .unwrap();
857 let cfg = KhiveConfig::load_with_roots(dir.path(), None)
860 .expect("no error expected")
861 .expect("file should be found");
862 assert_eq!(
863 cfg.actor.id.as_deref(),
864 Some("lambda:hidden-config"),
865 ".khive/config.toml (tier 3) must be found when khive.toml is absent"
866 );
867 }
868
869 #[test]
871 fn test_load_with_roots_home_tier_found() {
872 let project_dir = tempfile::tempdir().unwrap();
873 let home_dir = tempfile::tempdir().unwrap();
874
875 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
876 std::fs::write(
877 home_dir.path().join(".khive/config.toml"),
878 "[actor]\nid = \"lambda:user-global\"\n",
879 )
880 .unwrap();
881 let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()))
884 .expect("no error expected")
885 .expect("file should be found");
886 assert_eq!(
887 cfg.actor.id.as_deref(),
888 Some("lambda:user-global"),
889 "~/.khive/config.toml (tier 4) must be found when project files absent"
890 );
891 }
892
893 #[test]
895 fn test_load_with_roots_project_wins_over_home() {
896 let project_dir = tempfile::tempdir().unwrap();
897 let home_dir = tempfile::tempdir().unwrap();
898
899 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
901 std::fs::write(
902 home_dir.path().join(".khive/config.toml"),
903 "[actor]\nid = \"lambda:user-global\"\n",
904 )
905 .unwrap();
906
907 std::fs::create_dir_all(project_dir.path().join(".khive")).unwrap();
909 std::fs::write(
910 project_dir.path().join(".khive/config.toml"),
911 "[actor]\nid = \"lambda:project-wins\"\n",
912 )
913 .unwrap();
914
915 let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()))
916 .expect("no error expected")
917 .expect("file should be found");
918 assert_eq!(
919 cfg.actor.id.as_deref(),
920 Some("lambda:project-wins"),
921 "project .khive/config.toml (tier 3) must win over ~/.khive/config.toml (tier 4)"
922 );
923 }
924}