use std::path::{Path, PathBuf};
use khive_types::namespace::Namespace;
use serde::Deserialize;
use thiserror::Error;
use crate::presentation::OutputFormat;
#[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 },
#[error("duplicate backend name: {name:?}")]
DuplicateBackendName { name: String },
#[error(
"[packs.{pack}].backend = {backend:?} references an unknown backend; \
defined backends: {defined}"
)]
UnknownPackBackend {
pack: String,
backend: String,
defined: String,
},
#[error(
"[[backends]] entry {name:?}: field `{field}` is not yet supported; \
remove it from the config or wait for a future release that implements it"
)]
UnsupportedBackendField { name: String, field: &'static str },
#[error(
"top-level `db = {value:?}` is not a supported config-file key; \
use `--db` / `KHIVE_DB` to select a single-file database, or \
`[[backends]].path` to declare storage backend topology"
)]
UnsupportedTopLevelDb { value: String },
#[error("[[git_write.allowed]] entry {repo:?}: {reason}")]
InvalidGitWriteEntry { repo: 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>,
#[serde(default)]
pub visible_namespaces: Option<Vec<String>>,
#[serde(default)]
pub allowed_outbound_namespaces: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum BackendKind {
#[default]
Sqlite,
Memory,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BackendConfig {
pub name: String,
#[serde(default)]
pub kind: BackendKind,
pub path: Option<std::path::PathBuf>,
pub cache_mb: Option<u32>,
pub journal_mode: Option<String>,
#[serde(default)]
pub read_only: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PackConfig {
pub backend: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "backend", rename_all = "lowercase", deny_unknown_fields)]
pub enum BlobConfig {
Fs {
#[serde(default)]
root: Option<String>,
#[serde(default)]
floor_bytes: Option<u64>,
},
S3 {
bucket: String,
region: String,
#[serde(default)]
endpoint: Option<String>,
#[serde(default)]
prefix: Option<String>,
#[serde(default)]
allow_http: Option<bool>,
},
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct StorageSectionConfig {
#[serde(default)]
pub blob: Option<BlobConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitWriteEntryConfig {
pub repo: String,
pub branches: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct GitWriteSectionConfig {
#[serde(default)]
pub allowed: Vec<GitWriteEntryConfig>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct KhiveConfig {
#[serde(default)]
pub db: Option<String>,
#[serde(default)]
pub engines: Vec<EngineConfig>,
#[serde(default)]
pub actor: ActorConfig,
#[serde(default)]
pub runtime: RuntimeSectionConfig,
#[serde(default)]
pub backends: Vec<BackendConfig>,
#[serde(default)]
pub packs: std::collections::HashMap<String, PackConfig>,
#[serde(default)]
pub git_write: GitWriteSectionConfig,
#[serde(default)]
pub storage: StorageSectionConfig,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RuntimeSectionConfig {
#[serde(default)]
pub brain_profile: Option<String>,
#[serde(default)]
pub default_output_format: Option<OutputFormat>,
}
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>,
db_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(), db_path)
}
pub(crate) fn load_with_roots(
project_root: &Path,
home_root: Option<&Path>,
db_path: Option<&Path>,
) -> Result<Option<Self>, ConfigError> {
let tier2 = project_root.join("khive.toml");
if tier2.exists() {
return Self::load(Some(&tier2));
}
let tier3 = Self::project_config_anchor_dir(db_path, project_root).join("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)
}
fn project_config_anchor_dir(db_path: Option<&Path>, project_root: &Path) -> PathBuf {
let Some(db_path) = db_path else {
return project_root.join(".khive");
};
let absolute = std::fs::canonicalize(db_path).unwrap_or_else(|_| {
if db_path.is_absolute() {
db_path.to_path_buf()
} else {
project_root.join(db_path)
}
});
let db_dir = absolute.parent().map(Path::to_path_buf).unwrap_or(absolute);
if db_dir.file_name().is_some_and(|name| name == ".khive") {
db_dir
} else {
db_dir.join(".khive")
}
}
pub fn validate(&self) -> Result<(), ConfigError> {
if let Some(value) = self.db.as_deref() {
if !value.is_empty() {
return Err(ConfigError::UnsupportedTopLevelDb {
value: value.to_string(),
});
}
}
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 let Some(ref vis) = self.actor.visible_namespaces {
for ns_str in vis {
if ns_str.is_empty() {
return Err(ConfigError::InvalidActorId {
id: ns_str.clone(),
reason: "visible_namespaces entries must not be empty".to_string(),
});
}
Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
id: ns_str.clone(),
reason: format!("invalid visible namespace: {e}"),
})?;
}
}
for ns_str in &self.actor.allowed_outbound_namespaces {
if ns_str.is_empty() {
return Err(ConfigError::InvalidActorId {
id: ns_str.clone(),
reason: "allowed_outbound_namespaces entries must not be empty".to_string(),
});
}
Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
id: ns_str.clone(),
reason: format!("invalid allowed_outbound_namespaces entry: {e}"),
})?;
}
if !self.backends.is_empty() {
let mut seen_backends = std::collections::HashSet::new();
for backend in &self.backends {
if !seen_backends.insert(backend.name.clone()) {
return Err(ConfigError::DuplicateBackendName {
name: backend.name.clone(),
});
}
if backend.cache_mb.is_some() {
return Err(ConfigError::UnsupportedBackendField {
name: backend.name.clone(),
field: "cache_mb",
});
}
if backend.journal_mode.is_some() {
return Err(ConfigError::UnsupportedBackendField {
name: backend.name.clone(),
field: "journal_mode",
});
}
}
let defined: Vec<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
for (pack_name, pack_cfg) in &self.packs {
if !defined.contains(&pack_cfg.backend.as_str()) {
return Err(ConfigError::UnknownPackBackend {
pack: pack_name.clone(),
backend: pack_cfg.backend.clone(),
defined: defined.join(", "),
});
}
}
}
for entry in &self.git_write.allowed {
if entry.repo.trim().is_empty() {
return Err(ConfigError::InvalidGitWriteEntry {
repo: entry.repo.clone(),
reason: "repo must not be empty".to_string(),
});
}
if !Path::new(&entry.repo).is_absolute() {
return Err(ConfigError::InvalidGitWriteEntry {
repo: entry.repo.clone(),
reason: "repo must be an absolute path".to_string(),
});
}
if entry.branches.is_empty() {
return Err(ConfigError::InvalidGitWriteEntry {
repo: entry.repo.clone(),
reason: "branches must not be empty".to_string(),
});
}
if entry.branches.iter().any(|b| b.trim().is_empty()) {
return Err(ConfigError::InvalidGitWriteEntry {
repo: entry.repo.clone(),
reason: "branches entries must not be empty".to_string(),
});
}
if let Some(bad) = entry.branches.iter().find(|b| b.matches('*').count() > 1) {
return Err(ConfigError::InvalidGitWriteEntry {
repo: entry.repo.clone(),
reason: format!(
"branch pattern {bad:?} must contain at most one '*' wildcard (ADR-108)"
),
});
}
}
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,
..KhiveConfig::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,
..KhiveConfig::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 = "example actor"
"#,
);
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("example actor"));
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()), None);
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), None)
.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_does_not_override_namespace() {
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,
..Default::default()
},
..KhiveConfig::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::local(),
"actor.id must NOT become default_namespace (ADR-007 Rev 4 Rule 0); \
writes stay pinned to local"
);
assert!(
result
.visible_namespaces
.contains(&Namespace::parse("lambda:test-actor").unwrap()),
"actor.id must be folded into visible_namespaces (ADR-007 Rev 4 Rule 3b fold-in); \
got: {:?}",
result.visible_namespaces
);
}
#[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,
..Default::default()
},
..KhiveConfig::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, 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, 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()), None)
.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()), None)
.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)"
);
}
#[test]
fn test_load_with_roots_same_db_different_cwd_resolves_identical_config() {
let cwd_a = tempfile::tempdir().unwrap();
let cwd_b = tempfile::tempdir().unwrap();
std::fs::create_dir_all(cwd_a.path().join(".khive")).unwrap();
std::fs::write(
cwd_a.path().join(".khive/config.toml"),
"[actor]\nid = \"lambda:wrong-cwd-a\"\n",
)
.unwrap();
std::fs::create_dir_all(cwd_b.path().join(".khive")).unwrap();
std::fs::write(
cwd_b.path().join(".khive/config.toml"),
"[actor]\nid = \"lambda:wrong-cwd-b\"\n",
)
.unwrap();
let db_root = tempfile::tempdir().unwrap();
let khive_dir = db_root.path().join(".khive");
std::fs::create_dir_all(&khive_dir).unwrap();
let db_path = khive_dir.join("khive.db");
std::fs::write(&db_path, b"").unwrap(); std::fs::write(
khive_dir.join("config.toml"),
"[actor]\nid = \"lambda:db-anchored\"\n",
)
.unwrap();
let cfg_a = KhiveConfig::load_with_roots(cwd_a.path(), None, Some(&db_path))
.expect("no error expected")
.expect("db-anchored config must be found from cwd A");
let cfg_b = KhiveConfig::load_with_roots(cwd_b.path(), None, Some(&db_path))
.expect("no error expected")
.expect("db-anchored config must be found from cwd B");
assert_eq!(
cfg_a.actor.id.as_deref(),
Some("lambda:db-anchored"),
"cwd A must resolve the db-anchored config, not its own decoy"
);
assert_eq!(
cfg_b.actor.id.as_deref(),
Some("lambda:db-anchored"),
"cwd B must resolve the db-anchored config, not its own decoy"
);
assert_eq!(
cfg_a.actor.id, cfg_b.actor.id,
"two processes at different cwds targeting the same db must resolve \
identical config, killing config_id drift between client and daemon"
);
}
#[test]
fn test_load_with_home_fallback_explicit_config_wins_over_db_anchor() {
let explicit_dir = tempfile::tempdir().unwrap();
let explicit_path = write_toml(&explicit_dir, "[actor]\nid = \"lambda:explicit-wins\"\n");
let db_root = tempfile::tempdir().unwrap();
let khive_dir = db_root.path().join(".khive");
std::fs::create_dir_all(&khive_dir).unwrap();
let db_path = khive_dir.join("khive.db");
std::fs::write(&db_path, b"").unwrap();
std::fs::write(
khive_dir.join("config.toml"),
"[actor]\nid = \"lambda:db-anchor-loses\"\n",
)
.unwrap();
let cfg = KhiveConfig::load_with_home_fallback(Some(&explicit_path), Some(&db_path))
.expect("no error expected")
.expect("explicit path must be found");
assert_eq!(
cfg.actor.id.as_deref(),
Some("lambda:explicit-wins"),
"explicit --config/KHIVE_CONFIG must win over the db-dir anchor"
);
}
#[test]
fn test_load_with_roots_home_fallback_reached_when_db_anchor_has_no_config() {
let cwd = 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:home-fallback\"\n",
)
.unwrap();
let db_root = tempfile::tempdir().unwrap();
let khive_dir = db_root.path().join(".khive");
std::fs::create_dir_all(&khive_dir).unwrap();
let db_path = khive_dir.join("khive.db");
std::fs::write(&db_path, b"").unwrap();
let cfg = KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&db_path))
.expect("no error expected")
.expect("home-tier config must be found");
assert_eq!(
cfg.actor.id.as_deref(),
Some("lambda:home-fallback"),
"tier 4 (~/.khive/config.toml) must still be reached when the db-anchored \
tier-3 directory has no config.toml"
);
}
#[test]
fn test_load_with_roots_nonexistent_db_path_does_not_panic_and_falls_through() {
let cwd = 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:home-cold-start\"\n",
)
.unwrap();
let nonexistent_db = cwd.path().join("never-created/.khive/khive.db");
let cfg =
KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&nonexistent_db))
.expect("cold-start db path must not error or panic")
.expect("home-tier config must still be found");
assert_eq!(
cfg.actor.id.as_deref(),
Some("lambda:home-cold-start"),
"a nonexistent db path (cold start) must fall through to tier 4, not panic"
);
}
#[test]
fn test_load_with_roots_relative_nonexistent_db_path_does_not_panic() {
let cwd = tempfile::tempdir().unwrap();
let relative_db = PathBuf::from("never-created/.khive/khive.db");
let result = KhiveConfig::load_with_roots(cwd.path(), None, Some(&relative_db));
assert!(
result.is_ok(),
"relative cold-start db path must not error or panic: {result:?}"
);
assert!(
result.unwrap().is_none(),
"no config exists anywhere in this test; result must be None"
);
}
#[test]
fn test_no_backends_section_is_valid() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[engines]]
name = "default"
model = "all-minilm-l6-v2"
default = true
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("no error")
.expect("file found");
assert!(cfg.backends.is_empty());
assert!(cfg.packs.is_empty());
}
#[test]
fn test_single_sqlite_backend_parses() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[backends]]
name = "knowledge"
kind = "sqlite"
path = "/tmp/knowledge.db"
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("no error")
.expect("file found");
assert_eq!(cfg.backends.len(), 1);
let b = &cfg.backends[0];
assert_eq!(b.name, "knowledge");
assert!(matches!(b.kind, BackendKind::Sqlite));
assert_eq!(
b.path.as_ref().and_then(|p| p.to_str()),
Some("/tmp/knowledge.db")
);
}
#[test]
fn test_memory_backend_parses() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[backends]]
name = "ephemeral"
kind = "memory"
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("no error")
.expect("file found");
assert_eq!(cfg.backends.len(), 1);
assert!(matches!(cfg.backends[0].kind, BackendKind::Memory));
}
#[test]
fn test_pack_backend_assignment_parses() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[backends]]
name = "knowledge"
kind = "memory"
[packs.knowledge]
backend = "knowledge"
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("no error")
.expect("file found");
assert_eq!(cfg.packs.len(), 1);
let pc = cfg.packs.get("knowledge").expect("knowledge pack present");
assert_eq!(pc.backend, "knowledge");
}
#[test]
fn test_duplicate_backend_name_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[backends]]
name = "dup"
kind = "memory"
[[backends]]
name = "dup"
kind = "memory"
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
assert!(
matches!(err, ConfigError::DuplicateBackendName { ref name } if name == "dup"),
"expected DuplicateBackendName {{ name: \"dup\" }}, got {err:?}"
);
}
#[test]
fn test_pack_referencing_undefined_backend_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[backends]]
name = "knowledge"
kind = "memory"
[packs.kg]
backend = "nonexistent"
"#,
);
let err =
KhiveConfig::load(Some(&path)).expect_err("should fail with unknown backend reference");
assert!(
matches!(err, ConfigError::UnknownPackBackend { ref pack, ref backend, .. }
if pack == "kg" && backend == "nonexistent"),
"expected UnknownPackBackend for kg→nonexistent, got {err:?}"
);
}
#[test]
fn test_pack_config_without_backends_section_is_allowed() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[packs.kg]
backend = "main"
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("no error expected")
.expect("file found");
assert_eq!(cfg.backends.len(), 0);
assert_eq!(cfg.packs.len(), 1);
}
#[test]
fn test_backend_cache_mb_rejected_at_validate() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[backends]]
name = "main"
kind = "memory"
cache_mb = 128
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("cache_mb must be rejected");
assert!(
matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "cache_mb" } if name == "main"),
"expected UnsupportedBackendField {{ name: \"main\", field: \"cache_mb\" }}, got {err:?}"
);
}
#[test]
fn test_backend_journal_mode_rejected_at_validate() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[backends]]
name = "main"
kind = "memory"
journal_mode = "wal"
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("journal_mode must be rejected");
assert!(
matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "journal_mode" } if name == "main"),
"expected UnsupportedBackendField {{ name: \"main\", field: \"journal_mode\" }}, got {err:?}"
);
}
#[test]
fn test_top_level_db_rejected_at_validate() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
db = "/tmp/scratch/demo.db"
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("top-level db must be rejected");
assert!(
matches!(err, ConfigError::UnsupportedTopLevelDb { ref value } if value == "/tmp/scratch/demo.db"),
"expected UnsupportedTopLevelDb {{ value: \"/tmp/scratch/demo.db\" }}, got {err:?}"
);
}
#[test]
fn test_no_git_write_section_is_valid_and_empty() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(&dir, "# no git_write section\n");
let cfg = KhiveConfig::load(Some(&path))
.expect("no error")
.expect("file found");
assert!(cfg.git_write.allowed.is_empty());
}
#[test]
fn test_git_write_entry_parses() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[git_write.allowed]]
repo = "/abs/path/repo"
branches = ["feat/*", "fix/*"]
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("no error")
.expect("file found");
assert_eq!(cfg.git_write.allowed.len(), 1);
assert_eq!(cfg.git_write.allowed[0].repo, "/abs/path/repo");
assert_eq!(
cfg.git_write.allowed[0].branches,
vec!["feat/*".to_string(), "fix/*".to_string()]
);
}
#[test]
fn test_git_write_relative_repo_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[git_write.allowed]]
repo = "relative/path"
branches = ["main"]
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("relative repo must be rejected");
assert!(
matches!(err, ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "relative/path"),
"expected InvalidGitWriteEntry, got {err:?}"
);
}
#[test]
fn test_git_write_multi_star_branch_pattern_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[git_write.allowed]]
repo = "/abs/path"
branches = ["**"]
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("** must be rejected");
assert!(
matches!(err, ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "/abs/path"),
"expected InvalidGitWriteEntry, got {err:?}"
);
let dir2 = tempfile::tempdir().unwrap();
let path2 = write_toml(
&dir2,
r#"
[[git_write.allowed]]
repo = "/abs/path"
branches = ["rel-*-*-final"]
"#,
);
let err2 = KhiveConfig::load(Some(&path2)).expect_err("rel-*-*-final must be rejected");
assert!(
matches!(err2, ConfigError::InvalidGitWriteEntry { .. }),
"expected InvalidGitWriteEntry, got {err2:?}"
);
}
#[test]
fn test_git_write_single_star_branch_pattern_accepted() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[git_write.allowed]]
repo = "/abs/path"
branches = ["a*b", "main"]
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("no error")
.expect("file found");
assert_eq!(cfg.git_write.allowed[0].branches, vec!["a*b", "main"]);
}
#[test]
fn test_git_write_empty_branches_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[[git_write.allowed]]
repo = "/abs/path"
branches = []
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("empty branches must be rejected");
assert!(
matches!(err, ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "/abs/path"),
"expected InvalidGitWriteEntry, got {err:?}"
);
}
#[test]
fn test_no_storage_section_defaults_to_fs() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(&dir, "# no storage section\n");
let cfg = KhiveConfig::load(Some(&path))
.expect("no error")
.expect("file found");
assert!(cfg.storage.blob.is_none());
}
#[test]
fn test_storage_blob_fs_selection_parses() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[storage.blob]
backend = "fs"
root = "/var/lib/khive/blobs"
floor_bytes = 100000000000
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("no error")
.expect("file found");
match cfg.storage.blob {
Some(BlobConfig::Fs { root, floor_bytes }) => {
assert_eq!(root.as_deref(), Some("/var/lib/khive/blobs"));
assert_eq!(floor_bytes, Some(100_000_000_000));
}
other => panic!("expected BlobConfig::Fs, got {other:?}"),
}
}
#[test]
fn test_storage_blob_s3_selection_parses() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[storage.blob]
backend = "s3"
bucket = "khive-blobs"
region = "us-east-1"
endpoint = "https://objects.example.invalid"
prefix = "blobs"
"#,
);
let cfg = KhiveConfig::load(Some(&path))
.expect("no error")
.expect("file found");
match cfg.storage.blob {
Some(BlobConfig::S3 {
bucket,
region,
endpoint,
prefix,
allow_http,
}) => {
assert_eq!(bucket, "khive-blobs");
assert_eq!(region, "us-east-1");
assert_eq!(endpoint.as_deref(), Some("https://objects.example.invalid"));
assert_eq!(prefix.as_deref(), Some("blobs"));
assert_eq!(allow_http, None);
}
other => panic!("expected BlobConfig::S3, got {other:?}"),
}
}
#[test]
fn test_storage_blob_unknown_field_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[storage.blob]
backend = "fs"
made_up_field = "x"
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("unknown field must be rejected");
assert!(matches!(err, ConfigError::Parse { .. }), "got {err:?}");
}
#[test]
fn test_storage_blob_other_backend_field_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[storage.blob]
backend = "fs"
bucket = "khive-blobs"
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("s3 field under fs must be rejected");
assert!(matches!(err, ConfigError::Parse { .. }), "got {err:?}");
}
#[test]
fn test_storage_blob_credential_field_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[storage.blob]
backend = "s3"
bucket = "khive-blobs"
region = "us-east-1"
access_key_id = "AKIAEXAMPLE"
"#,
);
let err = KhiveConfig::load(Some(&path))
.expect_err("a credential field in TOML must be rejected");
assert!(matches!(err, ConfigError::Parse { .. }), "got {err:?}");
}
#[test]
fn test_storage_blob_unknown_backend_value_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
&dir,
r#"
[storage.blob]
backend = "gcs"
"#,
);
let err = KhiveConfig::load(Some(&path)).expect_err("unknown backend must be rejected");
assert!(matches!(err, ConfigError::Parse { .. }), "got {err:?}");
}
}