use std::path::Path;
use toml::Value;
use crate::config::{ENGRAM_CONFIG_SUBPATH, home_directory, resolve_project_dir};
use crate::error::CoreError;
pub(crate) fn load_global_config_tree() -> Result<Option<Value>, CoreError> {
let Some(home) = home_directory() else {
return Ok(None);
};
let global_config_path = Path::new(&home).join(ENGRAM_CONFIG_SUBPATH);
parse_config_tree_if_present(&global_config_path)
}
pub(crate) fn load_project_config_tree() -> Result<Option<Value>, CoreError> {
let Ok(current_directory) = std::env::current_dir() else {
return Ok(None);
};
let Ok(project_directory) = resolve_project_dir(¤t_directory, None) else {
return Ok(None);
};
let project_config_path = project_directory.join(ENGRAM_CONFIG_SUBPATH);
parse_config_tree_if_present(&project_config_path)
}
pub(crate) fn parse_config_tree_if_present(path: &Path) -> Result<Option<Value>, CoreError> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(CoreError::ConfigParseError(format!(
"failed to read {}: {error}",
path.display()
)));
}
};
let tree = contents
.parse::<Value>()
.map_err(|error| CoreError::ConfigParseError(error.to_string()))?;
Ok(Some(tree))
}
pub(crate) fn deep_merge(base: &mut Value, overlay: Value) {
match (base, overlay) {
(Value::Table(base_table), Value::Table(overlay_table)) => {
for (key, overlay_value) in overlay_table {
match base_table.get_mut(&key) {
Some(base_value) => deep_merge(base_value, overlay_value),
None => {
base_table.insert(key, overlay_value);
}
}
}
}
(base_slot, overlay_value) => *base_slot = overlay_value,
}
}
pub(crate) fn secret_at(tree: &Value, section: &str) -> Option<Value> {
tree.get(section)
.and_then(|section_value| section_value.get("api_key"))
.cloned()
}
pub(crate) fn restore_secret(tree: &mut Value, section: &str, global_secret: Option<Value>) {
let Some(Value::Table(section_table)) = tree.get_mut(section) else {
return;
};
match global_secret {
Some(secret) => {
section_table.insert("api_key".to_string(), secret);
}
None => {
section_table.remove("api_key");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deep_merge_overlay_wins_on_type_mismatch() {
let mut base = toml::toml! {
[embedding]
provider = "voyage"
model = "voyage-4"
dimension = 1024
tags = ["a", "b"]
}
.into();
let overlay: Value = toml::toml! {
embedding = 7
}
.into();
deep_merge(&mut base, overlay);
assert_eq!(base.get("embedding"), Some(&Value::Integer(7)));
}
#[test]
fn deep_merge_replaces_scalar_with_table() {
let mut base = toml::toml! {
[embedding]
provider = "voyage"
}
.into();
let overlay: Value = toml::toml! {
[embedding.provider]
nested = true
}
.into();
deep_merge(&mut base, overlay);
let provider = base
.get("embedding")
.and_then(|section| section.get("provider"))
.expect("provider slot present");
assert_eq!(provider.get("nested"), Some(&Value::Boolean(true)));
}
#[test]
fn deep_merge_replaces_array_wholesale() {
let mut base = toml::toml! {
[embedding]
tags = ["global-a", "global-b", "global-c"]
}
.into();
let overlay: Value = toml::toml! {
[embedding]
tags = ["project-only"]
}
.into();
deep_merge(&mut base, overlay);
let tags = base
.get("embedding")
.and_then(|section| section.get("tags"))
.and_then(Value::as_array)
.expect("tags array present");
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].as_str(), Some("project-only"));
}
#[test]
fn deep_merge_inserts_key_present_only_in_overlay() {
let mut base = toml::toml! {
[embedding]
provider = "voyage"
}
.into();
let overlay: Value = toml::toml! {
[embedding]
hyde_threshold = 9
[hnsw]
ef_search = 80
}
.into();
deep_merge(&mut base, overlay);
assert_eq!(
base.get("embedding")
.and_then(|section| section.get("hyde_threshold")),
Some(&Value::Integer(9))
);
assert_eq!(
base.get("hnsw")
.and_then(|section| section.get("ef_search")),
Some(&Value::Integer(80))
);
}
#[test]
fn restore_secret_ignores_absent_section() {
let mut tree: Value = toml::toml! {
[embedding]
provider = "voyage"
}
.into();
let before = tree.clone();
restore_secret(&mut tree, "llm", Some(Value::String("global-key".into())));
assert_eq!(tree, before);
}
#[test]
fn restore_secret_inserts_global_key() {
let mut tree: Value = toml::toml! {
[embedding]
provider = "voyage"
api_key = "project-local-key"
}
.into();
restore_secret(
&mut tree,
"embedding",
Some(Value::String("global-key".into())),
);
assert_eq!(
tree.get("embedding")
.and_then(|section| section.get("api_key")),
Some(&Value::String("global-key".into()))
);
}
#[test]
fn restore_secret_removes_key_when_global_absent() {
let mut tree: Value = toml::toml! {
[embedding]
provider = "voyage"
api_key = "project-local-key"
}
.into();
restore_secret(&mut tree, "embedding", None);
assert_eq!(
tree.get("embedding")
.and_then(|section| section.get("api_key")),
None
);
}
}