1use std::path::Path;
5
6use crate::error::ConfigError;
7use crate::root::Config;
8
9impl Config {
10 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 #[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 {
165 use std::collections::HashSet;
166 let mut seen_oauth_vault_keys: HashSet<String> = HashSet::new();
167 for s in &self.mcp.servers {
168 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 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 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 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 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}