use std::path::{Path, PathBuf};
use khive_types::namespace::Namespace;
use serde::Deserialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("config file I/O: {0}")]
Io(#[from] std::io::Error),
#[error("config TOML parse error in {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("exactly one engine must be marked `default = true`; found {found}")]
DefaultCount { found: usize },
#[error("duplicate engine name: {name:?}")]
DuplicateName { name: String },
#[error(
"engine {name:?}: model {model:?} is not a recognized lattice_embed::EmbeddingModel name"
)]
UnknownModel { name: String, model: String },
#[error("engine {name:?}: fusion_weight must be > 0, got {value}")]
InvalidFusionWeight { name: String, value: f64 },
#[error("actor.id {id:?} is not a valid namespace: {reason}")]
InvalidActorId { id: String, reason: String },
}
#[derive(Debug, Clone, Deserialize)]
pub struct EngineConfig {
pub name: String,
pub model: String,
#[serde(default)]
pub default: bool,
pub fusion_weight: Option<f64>,
pub dims: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ActorConfig {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct KhiveConfig {
#[serde(default)]
pub engines: Vec<EngineConfig>,
#[serde(default)]
pub actor: ActorConfig,
#[serde(default)]
pub runtime: RuntimeSectionConfig,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RuntimeSectionConfig {
#[serde(default)]
pub brain_profile: Option<String>,
}
impl KhiveConfig {
pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
let resolved = match path {
Some(p) => p.to_path_buf(),
None => PathBuf::from(".khive/config.toml"),
};
if !resolved.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&resolved)?;
let cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
path: resolved,
source,
})?;
cfg.validate()?;
Ok(Some(cfg))
}
pub fn load_with_home_fallback(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
if let Some(p) = path {
return Self::load(Some(p));
}
let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let home_root = std::env::var_os("HOME").map(PathBuf::from);
Self::load_with_roots(&project_root, home_root.as_deref())
}
pub(crate) fn load_with_roots(
project_root: &Path,
home_root: Option<&Path>,
) -> Result<Option<Self>, ConfigError> {
let tier2 = project_root.join("khive.toml");
if tier2.exists() {
return Self::load(Some(&tier2));
}
let tier3 = project_root.join(".khive/config.toml");
if tier3.exists() {
return Self::load(Some(&tier3));
}
if let Some(home) = home_root {
let tier4 = home.join(".khive/config.toml");
if tier4.exists() {
return Self::load(Some(&tier4));
}
}
Ok(None)
}
pub fn validate(&self) -> Result<(), ConfigError> {
if let Some(id) = self.actor.id.as_deref() {
if id.is_empty() {
return Err(ConfigError::InvalidActorId {
id: id.to_string(),
reason: "actor.id must not be empty; remove the key or provide a value"
.to_string(),
});
}
Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
id: id.to_string(),
reason: e.to_string(),
})?;
}
if self.engines.is_empty() {
return Ok(());
}
let mut seen_names = std::collections::HashSet::new();
for engine in &self.engines {
if !seen_names.insert(engine.name.clone()) {
return Err(ConfigError::DuplicateName {
name: engine.name.clone(),
});
}
}
let default_count = self.engines.iter().filter(|e| e.default).count();
if default_count != 1 {
return Err(ConfigError::DefaultCount {
found: default_count,
});
}
for engine in &self.engines {
if let Some(w) = engine.fusion_weight {
if !w.is_finite() || w <= 0.0 {
return Err(ConfigError::InvalidFusionWeight {
name: engine.name.clone(),
value: w,
});
}
}
}
Ok(())
}
pub fn default_engine(&self) -> Option<&EngineConfig> {
self.engines.iter().find(|e| e.default)
}
}
pub fn config_from_env() -> KhiveConfig {
let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
.ok()
.filter(|s| !s.trim().is_empty());
let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
.ok()
.unwrap_or_default();
let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
.into_iter()
.filter(|s| !s.is_empty())
.collect();
if primary_model.is_none() && additional.is_empty() {
return KhiveConfig::default();
}
tracing::info!(
"using env-var embedding config; consider migrating to .khive/config.toml in your project root"
);
let mut engines = Vec::new();
if let Some(model) = primary_model {
engines.push(EngineConfig {
name: "default".to_string(),
model,
default: true,
fusion_weight: None,
dims: None,
});
}
for (i, model) in additional.into_iter().enumerate() {
engines.push(EngineConfig {
name: format!("engine-{}", i + 1),
model,
default: false,
fusion_weight: None,
dims: None,
});
}
if !engines.is_empty() && !engines.iter().any(|e| e.default) {
engines[0].default = true;
}
KhiveConfig {
engines,
actor: ActorConfig::default(),
runtime: RuntimeSectionConfig::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_toml(dir: &tempfile::TempDir, content: &str) -> PathBuf {
let path = dir.path().join("config.toml");
std::fs::write(&path, content).unwrap();
path
}
#[test]
fn test_load_minimal_config() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[engines]]
name = "x"
model = "all-minilm-l6-v2"
default = true
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("load should succeed")
.expect("file should be found");
assert_eq!(cfg.engines.len(), 1);
assert_eq!(cfg.engines[0].name, "x");
assert_eq!(cfg.engines[0].model, "all-minilm-l6-v2");
assert!(cfg.engines[0].default);
}
#[test]
fn test_default_engine_required_when_engines_present() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[engines]]
name = "a"
model = "all-minilm-l6-v2"
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("should fail with no default flagged");
assert!(
matches!(err, ConfigError::DefaultCount { found: 0 }),
"expected DefaultCount {{ found: 0 }}, got {err:?}"
);
}
#[test]
fn test_multiple_default_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[engines]]
name = "a"
model = "all-minilm-l6-v2"
default = true
[[engines]]
name = "b"
model = "paraphrase-multilingual-minilm-l12-v2"
default = true
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("should fail with two defaults");
assert!(
matches!(err, ConfigError::DefaultCount { found: 2 }),
"expected DefaultCount {{ found: 2 }}, got {err:?}"
);
}
#[test]
fn test_fusion_weight_validation() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[engines]]
name = "a"
model = "all-minilm-l6-v2"
default = true
fusion_weight = -0.5
"#,
);
let err =
KhiveConfig::load(Some(&path)).expect_err("should fail with negative fusion_weight");
assert!(
matches!(err, ConfigError::InvalidFusionWeight { .. }),
"expected InvalidFusionWeight, got {err:?}"
);
let path2 = write_toml(
&dir,
r#"
[[engines]]
name = "a"
model = "all-minilm-l6-v2"
default = true
fusion_weight = 0.0
"#,
);
let err2 =
KhiveConfig::load(Some(&path2)).expect_err("should fail with zero fusion_weight");
assert!(
matches!(err2, ConfigError::InvalidFusionWeight { .. }),
"expected InvalidFusionWeight, got {err2:?}"
);
}
#[test]
fn test_env_var_fallback() {
let dir = tempfile::tempdir().unwrap();
let absent = dir.path().join("missing.toml");
let loaded = KhiveConfig::load(Some(&absent)).unwrap();
assert!(loaded.is_none());
let primary = "all-minilm-l6-v2".to_string();
let additional = vec!["paraphrase-multilingual-minilm-l12-v2".to_string()];
let mut engines = vec![EngineConfig {
name: "default".to_string(),
model: primary,
default: true,
fusion_weight: None,
dims: None,
}];
for (i, model) in additional.into_iter().enumerate() {
engines.push(EngineConfig {
name: format!("engine-{}", i + 1),
model,
default: false,
fusion_weight: None,
dims: None,
});
}
let cfg = KhiveConfig {
engines,
actor: ActorConfig::default(),
runtime: RuntimeSectionConfig::default(),
};
cfg.validate().expect("env-derived config should be valid");
assert_eq!(cfg.engines.len(), 2);
assert!(cfg.default_engine().is_some());
assert_eq!(cfg.default_engine().unwrap().name, "default");
}
#[test]
fn test_file_overrides_env() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[engines]]
name = "file-engine"
model = "all-minilm-l6-v2"
default = true
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("load should succeed")
.expect("file should be present");
assert_eq!(cfg.engines[0].name, "file-engine");
}
#[test]
fn test_duplicate_engine_names_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[engines]]
name = "shared"
model = "all-minilm-l6-v2"
default = true
[[engines]]
name = "shared"
model = "paraphrase-multilingual-minilm-l12-v2"
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
assert!(
matches!(err, ConfigError::DuplicateName { .. }),
"expected DuplicateName, got {err:?}"
);
}
#[test]
fn test_empty_config_is_valid() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(&dir, "# no engines\n");
let cfg = KhiveConfig::load(Some(&path))
.expect("load should succeed")
.expect("file should be found");
assert!(cfg.engines.is_empty());
cfg.validate().expect("empty config should be valid");
}
#[test]
fn test_multi_engine_positive_fusion_weight() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[engines]]
name = "primary"
model = "all-minilm-l6-v2"
default = true
fusion_weight = 0.7
[[engines]]
name = "secondary"
model = "paraphrase-multilingual-minilm-l12-v2"
fusion_weight = 0.3
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("load should succeed")
.expect("file should be found");
assert_eq!(cfg.engines.len(), 2);
assert_eq!(cfg.engines[0].fusion_weight, Some(0.7));
assert_eq!(cfg.engines[1].fusion_weight, Some(0.3));
}
#[test]
fn test_actor_id_parsed() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[actor]
id = "lambda:khive"
display_name = "Ocean's khive lambda"
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("load should succeed")
.expect("file should be found");
assert_eq!(cfg.actor.id.as_deref(), Some("lambda:khive"));
assert_eq!(
cfg.actor.display_name.as_deref(),
Some("Ocean's khive lambda")
);
assert!(cfg.engines.is_empty());
}
#[test]
fn test_actor_and_engines_together() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[actor]
id = "lambda:test"
[[engines]]
name = "default"
model = "all-minilm-l6-v2"
default = true
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("load should succeed")
.expect("file should be found");
assert_eq!(cfg.actor.id.as_deref(), Some("lambda:test"));
assert_eq!(cfg.engines.len(), 1);
}
#[test]
fn test_actor_absent_defaults_to_none() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[engines]]
name = "x"
model = "all-minilm-l6-v2"
default = true
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("load should succeed")
.expect("file should be found");
assert!(
cfg.actor.id.is_none(),
"actor.id must be None when [actor] section is absent"
);
}
#[test]
fn test_load_with_home_fallback_no_files() {
let project_dir = tempfile::tempdir().unwrap();
let home_dir = tempfile::tempdir().unwrap();
let result = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()));
assert!(
result.expect("no error expected").is_none(),
"should return None when no config files exist in the given roots"
);
}
#[test]
fn test_load_with_home_fallback_explicit_path() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[actor]
id = "lambda:explicit"
"#,
);
let cfg = KhiveConfig::load_with_home_fallback(Some(&path))
.expect("no error expected")
.expect("file found");
assert_eq!(cfg.actor.id.as_deref(), Some("lambda:explicit"));
}
#[test]
fn test_invalid_actor_id_rejected_at_load() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[actor]
id = "bad namespace"
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("should fail with invalid actor.id");
assert!(
matches!(err, ConfigError::InvalidActorId { .. }),
"expected InvalidActorId, got {err:?}"
);
}
#[test]
fn test_empty_actor_id_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[actor]
id = ""
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id should be rejected");
assert!(
matches!(err, ConfigError::InvalidActorId { .. }),
"expected InvalidActorId for empty string, got {err:?}"
);
}
#[test]
fn test_malformed_actor_id_lambda_colon_only() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[actor]
id = "lambda:"
"#,
);
let err =
KhiveConfig::load(Some(&path)).expect_err("lambda: with no slug should be rejected");
assert!(
matches!(err, ConfigError::InvalidActorId { .. }),
"expected InvalidActorId for 'lambda:', got {err:?}"
);
}
#[test]
fn test_runtime_config_actor_id_applied() {
use crate::runtime::runtime_config_from_khive_config;
use crate::RuntimeConfig;
use khive_types::namespace::Namespace;
let cfg = KhiveConfig {
engines: vec![],
actor: ActorConfig {
id: Some("lambda:test-actor".to_string()),
display_name: None,
},
runtime: RuntimeSectionConfig::default(),
};
cfg.validate().expect("valid config");
let base = RuntimeConfig::default();
let result = runtime_config_from_khive_config(&cfg, base);
assert_eq!(
result.default_namespace,
Namespace::parse("lambda:test-actor").unwrap(),
"actor.id must become default_namespace"
);
}
#[test]
fn test_runtime_config_no_actor_preserves_base() {
use crate::runtime::runtime_config_from_khive_config;
use crate::RuntimeConfig;
use khive_types::namespace::Namespace;
let cfg = KhiveConfig {
engines: vec![],
actor: ActorConfig {
id: None,
display_name: None,
},
runtime: RuntimeSectionConfig::default(),
};
cfg.validate().expect("valid config");
let base_ns = Namespace::parse("lambda:base").unwrap();
let base = RuntimeConfig {
default_namespace: base_ns.clone(),
..RuntimeConfig::default()
};
let result = runtime_config_from_khive_config(&cfg, base);
assert_eq!(
result.default_namespace, base_ns,
"no actor.id must leave base namespace unchanged"
);
}
#[test]
fn test_load_with_home_fallback_project_root_over_hidden() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
std::fs::write(
dir.path().join(".khive/config.toml"),
"[actor]\nid = \"lambda:hidden\"\n",
)
.unwrap();
std::fs::write(
dir.path().join("khive.toml"),
"[actor]\nid = \"lambda:project-root\"\n",
)
.unwrap();
let cfg = KhiveConfig::load_with_roots(dir.path(), None)
.expect("no error expected")
.expect("file should be found");
assert_eq!(
cfg.actor.id.as_deref(),
Some("lambda:project-root"),
"khive.toml (tier 2) must win over .khive/config.toml (tier 3)"
);
}
#[test]
fn test_load_with_home_fallback_hidden_over_absent_root() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
std::fs::write(
dir.path().join(".khive/config.toml"),
"[actor]\nid = \"lambda:hidden-config\"\n",
)
.unwrap();
let cfg = KhiveConfig::load_with_roots(dir.path(), None)
.expect("no error expected")
.expect("file should be found");
assert_eq!(
cfg.actor.id.as_deref(),
Some("lambda:hidden-config"),
".khive/config.toml (tier 3) must be found when khive.toml is absent"
);
}
#[test]
fn test_load_with_roots_home_tier_found() {
let project_dir = tempfile::tempdir().unwrap();
let home_dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
std::fs::write(
home_dir.path().join(".khive/config.toml"),
"[actor]\nid = \"lambda:user-global\"\n",
)
.unwrap();
let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()))
.expect("no error expected")
.expect("file should be found");
assert_eq!(
cfg.actor.id.as_deref(),
Some("lambda:user-global"),
"~/.khive/config.toml (tier 4) must be found when project files absent"
);
}
#[test]
fn test_load_with_roots_project_wins_over_home() {
let project_dir = tempfile::tempdir().unwrap();
let home_dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
std::fs::write(
home_dir.path().join(".khive/config.toml"),
"[actor]\nid = \"lambda:user-global\"\n",
)
.unwrap();
std::fs::create_dir_all(project_dir.path().join(".khive")).unwrap();
std::fs::write(
project_dir.path().join(".khive/config.toml"),
"[actor]\nid = \"lambda:project-wins\"\n",
)
.unwrap();
let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()))
.expect("no error expected")
.expect("file should be found");
assert_eq!(
cfg.actor.id.as_deref(),
Some("lambda:project-wins"),
"project .khive/config.toml (tier 3) must win over ~/.khive/config.toml (tier 4)"
);
}
}