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        // Focus config validation
201        if self.agent.focus.compression_interval == 0 {
202            return Err(ConfigError::Validation(
203                "agent.focus.compression_interval must be >= 1".into(),
204            ));
205        }
206        if self.agent.focus.min_messages_per_focus == 0 {
207            return Err(ConfigError::Validation(
208                "agent.focus.min_messages_per_focus must be >= 1".into(),
209            ));
210        }
211
212        // SideQuest config validation
213        if self.memory.sidequest.interval_turns == 0 {
214            return Err(ConfigError::Validation(
215                "memory.sidequest.interval_turns must be >= 1".into(),
216            ));
217        }
218        if !self.memory.sidequest.max_eviction_ratio.is_finite()
219            || self.memory.sidequest.max_eviction_ratio <= 0.0
220            || self.memory.sidequest.max_eviction_ratio > 1.0
221        {
222            return Err(ConfigError::Validation(format!(
223                "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
224                self.memory.sidequest.max_eviction_ratio
225            )));
226        }
227
228        let sct = self.llm.semantic_cache_threshold;
229        if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
230            return Err(ConfigError::Validation(format!(
231                "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
232                 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
233            )));
234        }
235
236        Ok(())
237    }
238
239    fn normalize_legacy_runtime_defaults(&mut self) {
240        use crate::defaults::{
241            default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
242            is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
243            is_legacy_default_sqlite_path,
244        };
245
246        if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
247            self.memory.sqlite_path = default_sqlite_path();
248        }
249
250        for skill_path in &mut self.skills.paths {
251            if is_legacy_default_skills_path(skill_path) {
252                *skill_path = default_skills_dir();
253            }
254        }
255
256        if is_legacy_default_debug_dir(&self.debug.output_dir) {
257            self.debug.output_dir = default_debug_dir();
258        }
259
260        if is_legacy_default_log_file(&self.logging.file) {
261            self.logging.file = default_log_file_path();
262        }
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn config_with_sct(threshold: f32) -> Config {
271        let mut cfg = Config::default();
272        cfg.llm.semantic_cache_threshold = threshold;
273        cfg
274    }
275
276    #[test]
277    fn semantic_cache_threshold_valid_zero() {
278        assert!(config_with_sct(0.0).validate().is_ok());
279    }
280
281    #[test]
282    fn semantic_cache_threshold_valid_mid() {
283        assert!(config_with_sct(0.5).validate().is_ok());
284    }
285
286    #[test]
287    fn semantic_cache_threshold_valid_one() {
288        assert!(config_with_sct(1.0).validate().is_ok());
289    }
290
291    #[test]
292    fn semantic_cache_threshold_invalid_negative() {
293        let err = config_with_sct(-0.1).validate().unwrap_err();
294        assert!(
295            err.to_string().contains("semantic_cache_threshold"),
296            "unexpected error: {err}"
297        );
298    }
299
300    #[test]
301    fn semantic_cache_threshold_invalid_above_one() {
302        let err = config_with_sct(1.1).validate().unwrap_err();
303        assert!(
304            err.to_string().contains("semantic_cache_threshold"),
305            "unexpected error: {err}"
306        );
307    }
308
309    #[test]
310    fn semantic_cache_threshold_invalid_nan() {
311        let err = config_with_sct(f32::NAN).validate().unwrap_err();
312        assert!(
313            err.to_string().contains("semantic_cache_threshold"),
314            "unexpected error: {err}"
315        );
316    }
317
318    #[test]
319    fn semantic_cache_threshold_invalid_infinity() {
320        let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
321        assert!(
322            err.to_string().contains("semantic_cache_threshold"),
323            "unexpected error: {err}"
324        );
325    }
326
327    #[test]
328    fn semantic_cache_threshold_invalid_neg_infinity() {
329        let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
330        assert!(
331            err.to_string().contains("semantic_cache_threshold"),
332            "unexpected error: {err}"
333        );
334    }
335
336    fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
337        let mut cfg = Config::default();
338        cfg.memory.compression.probe.enabled = enabled;
339        cfg.memory.compression.probe.threshold = threshold;
340        cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
341        cfg
342    }
343
344    #[test]
345    fn probe_disabled_skips_validation() {
346        // Invalid thresholds when probe is disabled must not cause errors.
347        let cfg = probe_config(false, 0.0, 1.0);
348        assert!(cfg.validate().is_ok());
349    }
350
351    #[test]
352    fn probe_valid_thresholds() {
353        let cfg = probe_config(true, 0.6, 0.35);
354        assert!(cfg.validate().is_ok());
355    }
356
357    #[test]
358    fn probe_threshold_zero_invalid() {
359        let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
360        assert!(
361            err.to_string().contains("probe.threshold"),
362            "unexpected error: {err}"
363        );
364    }
365
366    #[test]
367    fn probe_hard_fail_threshold_above_one_invalid() {
368        let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
369        assert!(
370            err.to_string().contains("probe.hard_fail_threshold"),
371            "unexpected error: {err}"
372        );
373    }
374
375    #[test]
376    fn probe_hard_fail_gte_threshold_invalid() {
377        let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
378        assert!(
379            err.to_string().contains("probe.hard_fail_threshold"),
380            "unexpected error: {err}"
381        );
382    }
383}