engram_core/
config_loader.rs1use std::path::Path;
13
14use toml::Value;
15
16use crate::config::{ENGRAM_CONFIG_SUBPATH, home_directory, resolve_project_dir};
17use crate::error::CoreError;
18
19pub(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
32pub(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(¤t_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
50pub(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
72pub(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
92pub(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
99pub(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 embedding = 7
135 }
136 .into();
137
138 deep_merge(&mut base, overlay);
139
140 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 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 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 assert_eq!(
212 base.get("embedding")
213 .and_then(|section| section.get("hyde_threshold")),
214 Some(&Value::Integer(9))
215 );
216 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 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}