Skip to main content

engram_core/
config_loader.rs

1//! Config file loading and layered merge.
2//!
3//! `Config::load()` resolves three layers — global `~/.engram/engram.toml`,
4//! project-local `<project>/.engram/engram.toml`, and `ENGRAM_*` env
5//! overrides — with priority `env > project-local > global`. This module
6//! owns the file-tree side: it reads each TOML file into a generic
7//! `toml::Value` tree, deep-merges the project-local tree over the global
8//! one, and enforces the secret invariant that `api_key` values always come
9//! from the global layer. `config.rs` keeps the `Config` shape and the
10//! `Config::load()` orchestration that drives these functions.
11
12use std::path::Path;
13
14use toml::Value;
15
16use crate::config::{ENGRAM_CONFIG_SUBPATH, home_directory, resolve_project_dir};
17use crate::error::CoreError;
18
19/// Reads the global `~/.engram/engram.toml` into a generic TOML tree.
20///
21/// Returns `Ok(None)` when `HOME` is unset or the file is absent — both mean
22/// "no global layer", not an error. A present-but-malformed file is a
23/// `ConfigParseError`.
24pub(crate) fn load_global_config_tree() -> Result<Option<Value>, CoreError> {
25    let Some(home) = home_directory() else {
26        return Ok(None);
27    };
28    let global_config_path = Path::new(&home).join(ENGRAM_CONFIG_SUBPATH);
29    parse_config_tree_if_present(&global_config_path)
30}
31
32/// Reads the project-local `<project>/.engram/engram.toml` into a generic
33/// TOML tree, discovering the project root by walking up from the current
34/// directory like `.git`.
35///
36/// Returns `Ok(None)` when the current directory is unavailable, no project
37/// `.engram/` marker exists, or the project has no `engram.toml`. A
38/// present-but-malformed project-local file is a `ConfigParseError`.
39pub(crate) fn load_project_config_tree() -> Result<Option<Value>, CoreError> {
40    let Ok(current_directory) = std::env::current_dir() else {
41        return Ok(None);
42    };
43    let Ok(project_directory) = resolve_project_dir(&current_directory, None) else {
44        return Ok(None);
45    };
46    let project_config_path = project_directory.join(ENGRAM_CONFIG_SUBPATH);
47    parse_config_tree_if_present(&project_config_path)
48}
49
50/// Parses the TOML file at `path` into a generic tree.
51///
52/// A missing file yields `Ok(None)`. Any other read failure, or a parse
53/// failure, yields `ConfigParseError` so the caller never silently proceeds
54/// on a config it could not read.
55pub(crate) fn parse_config_tree_if_present(path: &Path) -> Result<Option<Value>, CoreError> {
56    let contents = match std::fs::read_to_string(path) {
57        Ok(contents) => contents,
58        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
59        Err(error) => {
60            return Err(CoreError::ConfigParseError(format!(
61                "failed to read {}: {error}",
62                path.display()
63            )));
64        }
65    };
66    let tree = contents
67        .parse::<Value>()
68        .map_err(|error| CoreError::ConfigParseError(error.to_string()))?;
69    Ok(Some(tree))
70}
71
72/// Deep-merges `overlay` into `base`, with `overlay` winning field-by-field.
73///
74/// Two tables merge recursively per key; any other pairing (scalar, array, or
75/// mismatched types) replaces the `base` slot wholesale.
76pub(crate) fn deep_merge(base: &mut Value, overlay: Value) {
77    match (base, overlay) {
78        (Value::Table(base_table), Value::Table(overlay_table)) => {
79            for (key, overlay_value) in overlay_table {
80                match base_table.get_mut(&key) {
81                    Some(base_value) => deep_merge(base_value, overlay_value),
82                    None => {
83                        base_table.insert(key, overlay_value);
84                    }
85                }
86            }
87        }
88        (base_slot, overlay_value) => *base_slot = overlay_value,
89    }
90}
91
92/// Clones `<section>.api_key` out of `tree`, if present.
93pub(crate) fn secret_at(tree: &Value, section: &str) -> Option<Value> {
94    tree.get(section)
95        .and_then(|section_value| section_value.get("api_key"))
96        .cloned()
97}
98
99/// Restores the `api_key` of `<section>` in `tree` to `global_secret`.
100///
101/// When `global_secret` is `Some`, the key is inserted (overwriting any
102/// project-local value). When `None`, any project-local key is removed, so a
103/// project-local config can never introduce a secret the global layer lacks.
104pub(crate) fn restore_secret(tree: &mut Value, section: &str, global_secret: Option<Value>) {
105    let Some(Value::Table(section_table)) = tree.get_mut(section) else {
106        return;
107    };
108    match global_secret {
109        Some(secret) => {
110            section_table.insert("api_key".to_string(), secret);
111        }
112        None => {
113            section_table.remove("api_key");
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn deep_merge_overlay_wins_on_type_mismatch() {
124        let mut base = toml::toml! {
125            [embedding]
126            provider = "voyage"
127            model = "voyage-4"
128            dimension = 1024
129            tags = ["a", "b"]
130        }
131        .into();
132        let overlay: Value = toml::toml! {
133            // Table-typed slot replaced by a scalar.
134            embedding = 7
135        }
136        .into();
137
138        deep_merge(&mut base, overlay);
139
140        // The whole `[embedding]` table is replaced wholesale by the scalar.
141        assert_eq!(base.get("embedding"), Some(&Value::Integer(7)));
142    }
143
144    #[test]
145    fn deep_merge_replaces_scalar_with_table() {
146        let mut base = toml::toml! {
147            [embedding]
148            provider = "voyage"
149        }
150        .into();
151        let overlay: Value = toml::toml! {
152            [embedding.provider]
153            nested = true
154        }
155        .into();
156
157        deep_merge(&mut base, overlay);
158
159        // The scalar `provider = "voyage"` is replaced wholesale by a table.
160        let provider = base
161            .get("embedding")
162            .and_then(|section| section.get("provider"))
163            .expect("provider slot present");
164        assert_eq!(provider.get("nested"), Some(&Value::Boolean(true)));
165    }
166
167    #[test]
168    fn deep_merge_replaces_array_wholesale() {
169        let mut base = toml::toml! {
170            [embedding]
171            tags = ["global-a", "global-b", "global-c"]
172        }
173        .into();
174        let overlay: Value = toml::toml! {
175            [embedding]
176            tags = ["project-only"]
177        }
178        .into();
179
180        deep_merge(&mut base, overlay);
181
182        let tags = base
183            .get("embedding")
184            .and_then(|section| section.get("tags"))
185            .and_then(Value::as_array)
186            .expect("tags array present");
187        // Arrays are not concatenated — the overlay array replaces the base array.
188        assert_eq!(tags.len(), 1);
189        assert_eq!(tags[0].as_str(), Some("project-only"));
190    }
191
192    #[test]
193    fn deep_merge_inserts_key_present_only_in_overlay() {
194        let mut base = toml::toml! {
195            [embedding]
196            provider = "voyage"
197        }
198        .into();
199        let overlay: Value = toml::toml! {
200            [embedding]
201            hyde_threshold = 9
202
203            [hnsw]
204            ef_search = 80
205        }
206        .into();
207
208        deep_merge(&mut base, overlay);
209
210        // Key absent in base, present in overlay — inserted into the existing table.
211        assert_eq!(
212            base.get("embedding")
213                .and_then(|section| section.get("hyde_threshold")),
214            Some(&Value::Integer(9))
215        );
216        // Section absent in base entirely — inserted at the top level.
217        assert_eq!(
218            base.get("hnsw")
219                .and_then(|section| section.get("ef_search")),
220            Some(&Value::Integer(80))
221        );
222    }
223
224    #[test]
225    fn restore_secret_ignores_absent_section() {
226        let mut tree: Value = toml::toml! {
227            [embedding]
228            provider = "voyage"
229        }
230        .into();
231        let before = tree.clone();
232
233        // `llm` section is absent — the early return must leave the tree untouched.
234        restore_secret(&mut tree, "llm", Some(Value::String("global-key".into())));
235
236        assert_eq!(tree, before);
237    }
238
239    #[test]
240    fn restore_secret_inserts_global_key() {
241        let mut tree: Value = toml::toml! {
242            [embedding]
243            provider = "voyage"
244            api_key = "project-local-key"
245        }
246        .into();
247
248        restore_secret(
249            &mut tree,
250            "embedding",
251            Some(Value::String("global-key".into())),
252        );
253
254        assert_eq!(
255            tree.get("embedding")
256                .and_then(|section| section.get("api_key")),
257            Some(&Value::String("global-key".into()))
258        );
259    }
260
261    #[test]
262    fn restore_secret_removes_key_when_global_absent() {
263        let mut tree: Value = toml::toml! {
264            [embedding]
265            provider = "voyage"
266            api_key = "project-local-key"
267        }
268        .into();
269
270        restore_secret(&mut tree, "embedding", None);
271
272        assert_eq!(
273            tree.get("embedding")
274                .and_then(|section| section.get("api_key")),
275            None
276        );
277    }
278}