Skip to main content

zeph_config/
loader.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::Path;
5
6use crate::error::ConfigError;
7use crate::root::Config;
8
9impl Config {
10    /// Load configuration from a TOML file with env var overrides.
11    ///
12    /// Falls back to sensible defaults when the file does not exist.
13    ///
14    /// # Errors
15    ///
16    /// Returns an error if the file exists but cannot be read or parsed.
17    pub fn load(path: &Path) -> Result<Self, ConfigError> {
18        let mut config = if path.exists() {
19            let content = std::fs::read_to_string(path)?;
20            toml::from_str::<Self>(&content)?
21        } else {
22            Self::default()
23        };
24
25        config.apply_env_overrides();
26        config.normalize_legacy_runtime_defaults();
27        Ok(config)
28    }
29
30    /// Validate configuration values are within sane bounds.
31    ///
32    /// # Errors
33    ///
34    /// Returns an error if any value is out of range.
35    #[allow(clippy::too_many_lines)]
36    pub fn validate(&self) -> Result<(), ConfigError> {
37        if self.memory.history_limit > 10_000 {
38            return Err(ConfigError::Validation(format!(
39                "history_limit must be <= 10000, got {}",
40                self.memory.history_limit
41            )));
42        }
43        if self.memory.context_budget_tokens > 1_000_000 {
44            return Err(ConfigError::Validation(format!(
45                "context_budget_tokens must be <= 1000000, got {}",
46                self.memory.context_budget_tokens
47            )));
48        }
49        if self.agent.max_tool_iterations > 100 {
50            return Err(ConfigError::Validation(format!(
51                "max_tool_iterations must be <= 100, got {}",
52                self.agent.max_tool_iterations
53            )));
54        }
55        if self.a2a.rate_limit == 0 {
56            return Err(ConfigError::Validation("a2a.rate_limit must be > 0".into()));
57        }
58        if self.gateway.rate_limit == 0 {
59            return Err(ConfigError::Validation(
60                "gateway.rate_limit must be > 0".into(),
61            ));
62        }
63        if self.gateway.max_body_size > 10_485_760 {
64            return Err(ConfigError::Validation(format!(
65                "gateway.max_body_size must be <= 10485760 (10 MiB), got {}",
66                self.gateway.max_body_size
67            )));
68        }
69        if self.memory.token_safety_margin <= 0.0 {
70            return Err(ConfigError::Validation(format!(
71                "token_safety_margin must be > 0.0, got {}",
72                self.memory.token_safety_margin
73            )));
74        }
75        if self.memory.tool_call_cutoff == 0 {
76            return Err(ConfigError::Validation(
77                "tool_call_cutoff must be >= 1".into(),
78            ));
79        }
80        if let crate::memory::CompressionStrategy::Proactive {
81            threshold_tokens,
82            max_summary_tokens,
83        } = &self.memory.compression.strategy
84        {
85            if *threshold_tokens < 1_000 {
86                return Err(ConfigError::Validation(format!(
87                    "compression.threshold_tokens must be >= 1000, got {threshold_tokens}"
88                )));
89            }
90            if *max_summary_tokens < 128 {
91                return Err(ConfigError::Validation(format!(
92                    "compression.max_summary_tokens must be >= 128, got {max_summary_tokens}"
93                )));
94            }
95        }
96        if !self.memory.soft_compaction_threshold.is_finite()
97            || self.memory.soft_compaction_threshold <= 0.0
98            || self.memory.soft_compaction_threshold >= 1.0
99        {
100            return Err(ConfigError::Validation(format!(
101                "soft_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
102                self.memory.soft_compaction_threshold
103            )));
104        }
105        if !self.memory.hard_compaction_threshold.is_finite()
106            || self.memory.hard_compaction_threshold <= 0.0
107            || self.memory.hard_compaction_threshold >= 1.0
108        {
109            return Err(ConfigError::Validation(format!(
110                "hard_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
111                self.memory.hard_compaction_threshold
112            )));
113        }
114        if self.memory.soft_compaction_threshold >= self.memory.hard_compaction_threshold {
115            return Err(ConfigError::Validation(format!(
116                "soft_compaction_threshold ({}) must be less than hard_compaction_threshold ({})",
117                self.memory.soft_compaction_threshold, self.memory.hard_compaction_threshold,
118            )));
119        }
120        if self.memory.graph.temporal_decay_rate < 0.0
121            || self.memory.graph.temporal_decay_rate > 10.0
122        {
123            return Err(ConfigError::Validation(format!(
124                "memory.graph.temporal_decay_rate must be in [0.0, 10.0], got {}",
125                self.memory.graph.temporal_decay_rate
126            )));
127        }
128        if self.memory.compression.probe.enabled {
129            let probe = &self.memory.compression.probe;
130            if !probe.threshold.is_finite() || probe.threshold <= 0.0 || probe.threshold > 1.0 {
131                return Err(ConfigError::Validation(format!(
132                    "memory.compression.probe.threshold must be in (0.0, 1.0], got {}",
133                    probe.threshold
134                )));
135            }
136            if !probe.hard_fail_threshold.is_finite()
137                || probe.hard_fail_threshold < 0.0
138                || probe.hard_fail_threshold >= 1.0
139            {
140                return Err(ConfigError::Validation(format!(
141                    "memory.compression.probe.hard_fail_threshold must be in [0.0, 1.0), got {}",
142                    probe.hard_fail_threshold
143                )));
144            }
145            if probe.hard_fail_threshold >= probe.threshold {
146                return Err(ConfigError::Validation(format!(
147                    "memory.compression.probe.hard_fail_threshold ({}) must be less than \
148                     memory.compression.probe.threshold ({})",
149                    probe.hard_fail_threshold, probe.threshold
150                )));
151            }
152            if probe.max_questions < 1 {
153                return Err(ConfigError::Validation(
154                    "memory.compression.probe.max_questions must be >= 1".into(),
155                ));
156            }
157            if probe.timeout_secs < 1 {
158                return Err(ConfigError::Validation(
159                    "memory.compression.probe.timeout_secs must be >= 1".into(),
160                ));
161            }
162        }
163        // MCP server validation
164        {
165            use std::collections::HashSet;
166            let mut seen_oauth_vault_keys: HashSet<String> = HashSet::new();
167            for s in &self.mcp.servers {
168                // headers and oauth are mutually exclusive
169                if !s.headers.is_empty() && s.oauth.as_ref().is_some_and(|o| o.enabled) {
170                    return Err(ConfigError::Validation(format!(
171                        "MCP server '{}': cannot use both 'headers' and 'oauth' simultaneously",
172                        s.id
173                    )));
174                }
175                // vault key collision detection
176                if s.oauth.as_ref().is_some_and(|o| o.enabled) {
177                    let key = format!("ZEPH_MCP_OAUTH_{}", s.id.to_uppercase().replace('-', "_"));
178                    if !seen_oauth_vault_keys.insert(key.clone()) {
179                        return Err(ConfigError::Validation(format!(
180                            "MCP server '{}' has vault key collision ('{key}'): another server \
181                             with the same normalized ID already uses this key",
182                            s.id
183                        )));
184                    }
185                }
186            }
187        }
188
189        self.experiments
190            .validate()
191            .map_err(ConfigError::Validation)?;
192
193        if self.orchestration.plan_cache.enabled {
194            self.orchestration
195                .plan_cache
196                .validate()
197                .map_err(ConfigError::Validation)?;
198        }
199
200        let ct = self.orchestration.completeness_threshold;
201        if !ct.is_finite() || !(0.0..=1.0).contains(&ct) {
202            return Err(ConfigError::Validation(format!(
203                "orchestration.completeness_threshold must be in [0.0, 1.0], got {ct}"
204            )));
205        }
206
207        // Focus config validation
208        if self.agent.focus.compression_interval == 0 {
209            return Err(ConfigError::Validation(
210                "agent.focus.compression_interval must be >= 1".into(),
211            ));
212        }
213        if self.agent.focus.min_messages_per_focus == 0 {
214            return Err(ConfigError::Validation(
215                "agent.focus.min_messages_per_focus must be >= 1".into(),
216            ));
217        }
218
219        // SideQuest config validation
220        if self.memory.sidequest.interval_turns == 0 {
221            return Err(ConfigError::Validation(
222                "memory.sidequest.interval_turns must be >= 1".into(),
223            ));
224        }
225        if !self.memory.sidequest.max_eviction_ratio.is_finite()
226            || self.memory.sidequest.max_eviction_ratio <= 0.0
227            || self.memory.sidequest.max_eviction_ratio > 1.0
228        {
229            return Err(ConfigError::Validation(format!(
230                "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
231                self.memory.sidequest.max_eviction_ratio
232            )));
233        }
234
235        let sct = self.llm.semantic_cache_threshold;
236        if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
237            return Err(ConfigError::Validation(format!(
238                "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
239                 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
240            )));
241        }
242
243        Ok(())
244    }
245
246    fn normalize_legacy_runtime_defaults(&mut self) {
247        use crate::defaults::{
248            default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
249            is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
250            is_legacy_default_sqlite_path,
251        };
252
253        if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
254            self.memory.sqlite_path = default_sqlite_path();
255        }
256
257        for skill_path in &mut self.skills.paths {
258            if is_legacy_default_skills_path(skill_path) {
259                *skill_path = default_skills_dir();
260            }
261        }
262
263        if is_legacy_default_debug_dir(&self.debug.output_dir) {
264            self.debug.output_dir = default_debug_dir();
265        }
266
267        if is_legacy_default_log_file(&self.logging.file) {
268            self.logging.file = default_log_file_path();
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    fn config_with_sct(threshold: f32) -> Config {
278        let mut cfg = Config::default();
279        cfg.llm.semantic_cache_threshold = threshold;
280        cfg
281    }
282
283    #[test]
284    fn semantic_cache_threshold_valid_zero() {
285        assert!(config_with_sct(0.0).validate().is_ok());
286    }
287
288    #[test]
289    fn semantic_cache_threshold_valid_mid() {
290        assert!(config_with_sct(0.5).validate().is_ok());
291    }
292
293    #[test]
294    fn semantic_cache_threshold_valid_one() {
295        assert!(config_with_sct(1.0).validate().is_ok());
296    }
297
298    #[test]
299    fn semantic_cache_threshold_invalid_negative() {
300        let err = config_with_sct(-0.1).validate().unwrap_err();
301        assert!(
302            err.to_string().contains("semantic_cache_threshold"),
303            "unexpected error: {err}"
304        );
305    }
306
307    #[test]
308    fn semantic_cache_threshold_invalid_above_one() {
309        let err = config_with_sct(1.1).validate().unwrap_err();
310        assert!(
311            err.to_string().contains("semantic_cache_threshold"),
312            "unexpected error: {err}"
313        );
314    }
315
316    #[test]
317    fn semantic_cache_threshold_invalid_nan() {
318        let err = config_with_sct(f32::NAN).validate().unwrap_err();
319        assert!(
320            err.to_string().contains("semantic_cache_threshold"),
321            "unexpected error: {err}"
322        );
323    }
324
325    #[test]
326    fn semantic_cache_threshold_invalid_infinity() {
327        let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
328        assert!(
329            err.to_string().contains("semantic_cache_threshold"),
330            "unexpected error: {err}"
331        );
332    }
333
334    #[test]
335    fn semantic_cache_threshold_invalid_neg_infinity() {
336        let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
337        assert!(
338            err.to_string().contains("semantic_cache_threshold"),
339            "unexpected error: {err}"
340        );
341    }
342
343    fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
344        let mut cfg = Config::default();
345        cfg.memory.compression.probe.enabled = enabled;
346        cfg.memory.compression.probe.threshold = threshold;
347        cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
348        cfg
349    }
350
351    #[test]
352    fn probe_disabled_skips_validation() {
353        // Invalid thresholds when probe is disabled must not cause errors.
354        let cfg = probe_config(false, 0.0, 1.0);
355        assert!(cfg.validate().is_ok());
356    }
357
358    #[test]
359    fn probe_valid_thresholds() {
360        let cfg = probe_config(true, 0.6, 0.35);
361        assert!(cfg.validate().is_ok());
362    }
363
364    #[test]
365    fn probe_threshold_zero_invalid() {
366        let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
367        assert!(
368            err.to_string().contains("probe.threshold"),
369            "unexpected error: {err}"
370        );
371    }
372
373    #[test]
374    fn probe_hard_fail_threshold_above_one_invalid() {
375        let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
376        assert!(
377            err.to_string().contains("probe.hard_fail_threshold"),
378            "unexpected error: {err}"
379        );
380    }
381
382    #[test]
383    fn probe_hard_fail_gte_threshold_invalid() {
384        let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
385        assert!(
386            err.to_string().contains("probe.hard_fail_threshold"),
387            "unexpected error: {err}"
388        );
389    }
390
391    fn config_with_completeness_threshold(ct: f32) -> Config {
392        let mut cfg = Config::default();
393        cfg.orchestration.completeness_threshold = ct;
394        cfg
395    }
396
397    #[test]
398    fn completeness_threshold_valid_zero() {
399        assert!(config_with_completeness_threshold(0.0).validate().is_ok());
400    }
401
402    #[test]
403    fn completeness_threshold_valid_default() {
404        assert!(config_with_completeness_threshold(0.7).validate().is_ok());
405    }
406
407    #[test]
408    fn completeness_threshold_valid_one() {
409        assert!(config_with_completeness_threshold(1.0).validate().is_ok());
410    }
411
412    #[test]
413    fn completeness_threshold_invalid_negative() {
414        let err = config_with_completeness_threshold(-0.1)
415            .validate()
416            .unwrap_err();
417        assert!(
418            err.to_string().contains("completeness_threshold"),
419            "unexpected error: {err}"
420        );
421    }
422
423    #[test]
424    fn completeness_threshold_invalid_above_one() {
425        let err = config_with_completeness_threshold(1.1)
426            .validate()
427            .unwrap_err();
428        assert!(
429            err.to_string().contains("completeness_threshold"),
430            "unexpected error: {err}"
431        );
432    }
433
434    #[test]
435    fn completeness_threshold_invalid_nan() {
436        let err = config_with_completeness_threshold(f32::NAN)
437            .validate()
438            .unwrap_err();
439        assert!(
440            err.to_string().contains("completeness_threshold"),
441            "unexpected error: {err}"
442        );
443    }
444
445    #[test]
446    fn completeness_threshold_invalid_infinity() {
447        let err = config_with_completeness_threshold(f32::INFINITY)
448            .validate()
449            .unwrap_err();
450        assert!(
451            err.to_string().contains("completeness_threshold"),
452            "unexpected error: {err}"
453        );
454    }
455}