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 pub fn dump_defaults() -> Result<String, crate::error::ConfigError> {
53 let defaults = Self::default();
54 toml::to_string_pretty(&defaults).map_err(|e| {
55 crate::error::ConfigError::Validation(format!("failed to serialize defaults: {e}"))
56 })
57 }
58
59 #[must_use = "validation result must be checked"]
65 pub fn validate(&self) -> Result<(), ConfigError> {
66 self.validate_scalar_bounds()?;
67 self.validate_memory_compression()?;
68 self.validate_memory_probe_and_graph()?;
69 self.validate_mcp_servers()?;
70 self.experiments
71 .validate()
72 .map_err(ConfigError::Validation)?;
73 if self.orchestration.plan_cache.enabled {
74 self.orchestration
75 .plan_cache
76 .validate()
77 .map_err(ConfigError::Validation)?;
78 }
79 self.validate_orchestration()?;
80 self.validate_focus_and_sidequest()?;
81 self.validate_llm_and_skills()?;
82 self.validate_provider_names()?;
83 self.validate_mcp_misc()?;
84 self.validate_scheduler()?;
85 self.acp
86 .validate_auth_clients()
87 .map_err(ConfigError::Validation)?;
88 crate::providers::validate_pool(&self.llm.providers)?;
93 self.llm.validate_stt()?;
94 self.security
95 .trajectory
96 .validate()
97 .map_err(ConfigError::Validation)?;
98 self.gateway.validate().map_err(ConfigError::Validation)?;
99 self.tools
100 .utility
101 .validate()
102 .map_err(ConfigError::Validation)?;
103 if let Some(fidelity) = &self.memory.fidelity {
104 fidelity.validate().map_err(ConfigError::Validation)?;
105 }
106 self.memory
107 .compression
108 .acon
109 .validate()
110 .map_err(ConfigError::Validation)?;
111 if self.memory.shadow_memory.enabled {
112 self.memory
113 .shadow_memory
114 .validate()
115 .map_err(ConfigError::Validation)?;
116 }
117 self.warn_insecure_qdrant_endpoint();
118 Ok(())
119 }
120
121 fn warn_insecure_qdrant_endpoint(&self) {
131 let Ok(url) = url::Url::parse(&self.memory.qdrant_url) else {
132 return;
133 };
134 let Some(host) = url.host_str() else {
135 return;
136 };
137 if zeph_common::net::is_loopback_host(host) {
138 return;
139 }
140
141 let has_tls = url.scheme().eq_ignore_ascii_case("https");
142 let has_api_key = self
143 .memory
144 .qdrant_api_key
145 .as_ref()
146 .is_some_and(|k| !k.expose().trim().is_empty());
147
148 if !has_tls || !has_api_key {
149 tracing::warn!(
150 qdrant_url = %self.memory.qdrant_url,
151 tls = has_tls,
152 api_key_configured = has_api_key,
153 "memory.qdrant_url points at a non-loopback host without TLS and/or an API key \
154 configured — memory content would travel in plaintext with no server \
155 authentication; set qdrant_url to an https:// endpoint and configure \
156 memory.qdrant_api_key (vault key ZEPH_QDRANT_API_KEY) for remote/managed Qdrant"
157 );
158 }
159 }
160
161 fn validate_scalar_bounds(&self) -> Result<(), ConfigError> {
163 if self.memory.history_limit > 10_000 {
164 return Err(ConfigError::Validation(format!(
165 "history_limit must be <= 10000, got {}",
166 self.memory.history_limit
167 )));
168 }
169 if self.memory.context_budget_tokens > 1_000_000 {
170 return Err(ConfigError::Validation(format!(
171 "context_budget_tokens must be <= 1000000, got {}",
172 self.memory.context_budget_tokens
173 )));
174 }
175 if self.agent.max_tool_iterations > 100 {
176 return Err(ConfigError::Validation(format!(
177 "max_tool_iterations must be <= 100, got {}",
178 self.agent.max_tool_iterations
179 )));
180 }
181 if self.a2a.rate_limit == 0 {
182 return Err(ConfigError::Validation("a2a.rate_limit must be > 0".into()));
183 }
184 self.validate_a2a_client_trust()?;
185 if self.gateway.rate_limit == 0 {
186 return Err(ConfigError::Validation(
187 "gateway.rate_limit must be > 0".into(),
188 ));
189 }
190 if self.gateway.max_body_size > 10_485_760 {
191 return Err(ConfigError::Validation(format!(
192 "gateway.max_body_size must be <= 10485760 (10 MiB), got {}",
193 self.gateway.max_body_size
194 )));
195 }
196 if self.memory.token_safety_margin <= 0.0 {
197 return Err(ConfigError::Validation(format!(
198 "token_safety_margin must be > 0.0, got {}",
199 self.memory.token_safety_margin
200 )));
201 }
202 if self.memory.tool_call_cutoff == 0 {
203 return Err(ConfigError::Validation(
204 "tool_call_cutoff must be >= 1".into(),
205 ));
206 }
207 if self.worktree.max_worktrees == Some(0) {
208 return Err(ConfigError::Validation(
209 "worktree.max_worktrees must be > 0 or unset (unlimited); 0 would block all \
210 worktree creation"
211 .into(),
212 ));
213 }
214 if self.worktree.disk_quota_mb == Some(0) {
215 return Err(ConfigError::Validation(
216 "worktree.disk_quota_mb must be > 0 or unset (no accounting); 0 would leave \
217 every non-empty worktree permanently over quota"
218 .into(),
219 ));
220 }
221 if self.worktree.disk_quota_mb.is_some()
222 && self.worktree.auto_reconcile_secs == 0
223 && !self.worktree.reconcile_on_startup
224 {
225 return Err(ConfigError::Validation(
226 "worktree.disk_quota_mb is set but neither reconcile_on_startup nor \
227 auto_reconcile_secs is enabled — the quota will only be checked when you run \
228 `zeph worktree list` manually, never automatically"
229 .into(),
230 ));
231 }
232 if (1..60).contains(&self.worktree.auto_reconcile_secs) {
233 return Err(ConfigError::Validation(format!(
234 "worktree.auto_reconcile_secs must be 0 (disabled) or >= 60, got {}; a short \
235 interval runs a full filesystem walk in a tight loop",
236 self.worktree.auto_reconcile_secs
237 )));
238 }
239 Ok(())
240 }
241
242 #[cfg_attr(
252 feature = "card-signing",
253 allow(clippy::unused_self, clippy::unnecessary_wraps)
254 )]
255 fn validate_a2a_client_trust(&self) -> Result<(), ConfigError> {
256 #[cfg(not(feature = "card-signing"))]
257 if self.a2a_client.card_trust_policy == crate::channels::CardTrustPolicy::Require {
258 return Err(ConfigError::Validation(
259 "a2a_client.card_trust_policy = require requires the card-signing feature \
260 to be enabled at build time (see the `a2a` feature in the root Cargo.toml)"
261 .into(),
262 ));
263 }
264 Ok(())
265 }
266
267 fn validate_memory_compression(&self) -> Result<(), ConfigError> {
269 if let crate::memory::CompressionStrategy::Proactive {
270 threshold_tokens,
271 max_summary_tokens,
272 } = &self.memory.compression.strategy
273 {
274 if *threshold_tokens < 1_000 {
275 return Err(ConfigError::Validation(format!(
276 "compression.threshold_tokens must be >= 1000, got {threshold_tokens}"
277 )));
278 }
279 if *max_summary_tokens < 128 {
280 return Err(ConfigError::Validation(format!(
281 "compression.max_summary_tokens must be >= 128, got {max_summary_tokens}"
282 )));
283 }
284 }
285 if !self.memory.soft_compaction_threshold.is_finite()
286 || self.memory.soft_compaction_threshold <= 0.0
287 || self.memory.soft_compaction_threshold >= 1.0
288 {
289 return Err(ConfigError::Validation(format!(
290 "soft_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
291 self.memory.soft_compaction_threshold
292 )));
293 }
294 if !self.memory.hard_compaction_threshold.is_finite()
295 || self.memory.hard_compaction_threshold <= 0.0
296 || self.memory.hard_compaction_threshold >= 1.0
297 {
298 return Err(ConfigError::Validation(format!(
299 "hard_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
300 self.memory.hard_compaction_threshold
301 )));
302 }
303 if self.memory.soft_compaction_threshold >= self.memory.hard_compaction_threshold {
304 return Err(ConfigError::Validation(format!(
305 "soft_compaction_threshold ({}) must be less than hard_compaction_threshold ({})",
306 self.memory.soft_compaction_threshold, self.memory.hard_compaction_threshold,
307 )));
308 }
309 Ok(())
310 }
311
312 fn validate_memory_probe_and_graph(&self) -> Result<(), ConfigError> {
314 if self.memory.graph.temporal_decay_rate < 0.0
315 || self.memory.graph.temporal_decay_rate > 10.0
316 {
317 return Err(ConfigError::Validation(format!(
318 "memory.graph.temporal_decay_rate must be in [0.0, 10.0], got {}",
319 self.memory.graph.temporal_decay_rate
320 )));
321 }
322 if self.memory.compression.probe.enabled {
323 let probe = &self.memory.compression.probe;
324 if !probe.threshold.is_finite() || probe.threshold <= 0.0 || probe.threshold > 1.0 {
325 return Err(ConfigError::Validation(format!(
326 "memory.compression.probe.threshold must be in (0.0, 1.0], got {}",
327 probe.threshold
328 )));
329 }
330 if !probe.hard_fail_threshold.is_finite()
331 || probe.hard_fail_threshold < 0.0
332 || probe.hard_fail_threshold >= 1.0
333 {
334 return Err(ConfigError::Validation(format!(
335 "memory.compression.probe.hard_fail_threshold must be in [0.0, 1.0), got {}",
336 probe.hard_fail_threshold
337 )));
338 }
339 if probe.hard_fail_threshold >= probe.threshold {
340 return Err(ConfigError::Validation(format!(
341 "memory.compression.probe.hard_fail_threshold ({}) must be less than \
342 memory.compression.probe.threshold ({})",
343 probe.hard_fail_threshold, probe.threshold
344 )));
345 }
346 if probe.max_questions < 1 {
347 return Err(ConfigError::Validation(
348 "memory.compression.probe.max_questions must be >= 1".into(),
349 ));
350 }
351 if probe.timeout_secs < 1 {
352 return Err(ConfigError::Validation(
353 "memory.compression.probe.timeout_secs must be >= 1".into(),
354 ));
355 }
356 }
357 Ok(())
358 }
359
360 fn validate_mcp_servers(&self) -> Result<(), ConfigError> {
362 use std::collections::HashSet;
363 let mut seen_oauth_vault_keys: HashSet<String> = HashSet::new();
364 for s in &self.mcp.servers {
365 if !s.headers.is_empty() && s.oauth.as_ref().is_some_and(|o| o.enabled) {
367 return Err(ConfigError::Validation(format!(
368 "MCP server '{}': cannot use both 'headers' and 'oauth' simultaneously",
369 s.id
370 )));
371 }
372 if s.oauth.as_ref().is_some_and(|o| o.enabled) {
374 let key = format!("ZEPH_MCP_OAUTH_{}", s.id.to_uppercase().replace('-', "_"));
375 if !seen_oauth_vault_keys.insert(key.clone()) {
376 return Err(ConfigError::Validation(format!(
377 "MCP server '{}' has vault key collision ('{key}'): another server \
378 with the same normalized ID already uses this key",
379 s.id
380 )));
381 }
382 }
383 }
384 Ok(())
385 }
386
387 fn validate_orchestration(&self) -> Result<(), ConfigError> {
389 if self.orchestration.max_parallel == 0 {
390 return Err(ConfigError::Validation(
391 "orchestration.max_parallel must be > 0".into(),
392 ));
393 }
394 if self.orchestration.max_tasks == 0 {
395 return Err(ConfigError::Validation(
396 "orchestration.max_tasks must be > 0".into(),
397 ));
398 }
399 let ct = self.orchestration.completeness_threshold;
400 if !ct.is_finite() || !(0.0..=1.0).contains(&ct) {
401 return Err(ConfigError::Validation(format!(
402 "orchestration.completeness_threshold must be in [0.0, 1.0], got {ct}"
403 )));
404 }
405 let ensemble = &self.orchestration.ensemble;
409 if ensemble.enabled && ensemble.verify {
410 let n = ensemble.members.len();
411 if n.is_multiple_of(2) || n < 3 {
412 return Err(ConfigError::Validation(format!(
413 "orchestration.ensemble.members must be odd and >= 3, got {n}"
414 )));
415 }
416 let unique: std::collections::HashSet<&str> =
417 ensemble.members.iter().map(String::as_str).collect();
418 if unique.len() != ensemble.members.len() {
419 return Err(ConfigError::Validation(
420 "orchestration.ensemble.members contains a duplicate provider name".into(),
421 ));
422 }
423 let alpha = ensemble.ema_alpha;
428 if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) {
429 return Err(ConfigError::Validation(format!(
430 "orchestration.ensemble.ema_alpha must be in [0.0, 1.0], got {alpha}"
431 )));
432 }
433 let decay = ensemble.ema_decay;
434 if !decay.is_finite() || !(0.0..=1.0).contains(&decay) {
435 return Err(ConfigError::Validation(format!(
436 "orchestration.ensemble.ema_decay must be in [0.0, 1.0], got {decay}"
437 )));
438 }
439 }
440 if self.orchestration.cascade_chain_threshold == 1 {
442 return Err(ConfigError::Validation(
443 "orchestration.cascade_chain_threshold=1 aborts on every failure; \
444 use 0 to disable linear-chain cascade abort instead"
445 .into(),
446 ));
447 }
448 let cfrat = self.orchestration.cascade_failure_rate_abort_threshold;
449 if !cfrat.is_finite() || !(0.0..=1.0).contains(&cfrat) {
450 return Err(ConfigError::Validation(format!(
451 "orchestration.cascade_failure_rate_abort_threshold must be in [0.0, 1.0], got {cfrat}"
452 )));
453 }
454 if self.orchestration.lineage_ttl_secs == 0 {
455 return Err(ConfigError::Validation(
456 "orchestration.lineage_ttl_secs must be > 0; \
457 set cascade_chain_threshold=0 to disable lineage tracking instead"
458 .into(),
459 ));
460 }
461 if self.orchestration.aggregator_timeout_secs == 0 {
462 return Err(ConfigError::Validation(
463 "orchestration.aggregator_timeout_secs must be > 0".into(),
464 ));
465 }
466 if self.orchestration.planner_timeout_secs == 0 {
467 return Err(ConfigError::Validation(
468 "orchestration.planner_timeout_secs must be > 0".into(),
469 ));
470 }
471 if self.orchestration.verifier_timeout_secs == 0 {
472 return Err(ConfigError::Validation(
473 "orchestration.verifier_timeout_secs must be > 0".into(),
474 ));
475 }
476 if self.orchestration.default_idle_timeout_secs == Some(0) {
477 return Err(ConfigError::Validation(
478 "orchestration.default_idle_timeout_secs must be > 0 or unset; 0 would mean \
479 an instant idle timeout"
480 .into(),
481 ));
482 }
483 self.validate_command_handoff()?;
484 Ok(())
485 }
486
487 fn validate_command_handoff(&self) -> Result<(), ConfigError> {
490 if self.orchestration.command.max_handoffs == 0 {
491 return Err(ConfigError::Validation(
492 "orchestration.command.max_handoffs must be > 0; set \
493 orchestration.command.enabled = false to disable Command handoff instead"
494 .into(),
495 ));
496 }
497 if self.orchestration.command.max_handoffs > 10_000 {
503 return Err(ConfigError::Validation(format!(
504 "orchestration.command.max_handoffs must be <= 10000, got {}",
505 self.orchestration.command.max_handoffs
506 )));
507 }
508 if !self.orchestration.command.enabled {
520 return Ok(());
521 }
522 if !self.memory.store.enabled {
523 return Err(ConfigError::Validation(
524 "orchestration.command.enabled = true requires memory.store.enabled = \
525 true — Command handoff has nowhere to persist its `update` payload \
526 without the cross-thread store"
527 .into(),
528 ));
529 }
530 if !self.security.content_isolation.enabled {
531 return Err(ConfigError::Validation(
532 "orchestration.command.enabled = true requires \
533 security.content_isolation.enabled = true — the FR-B-003 sanitizer \
534 scan that gates a Command handoff before it drives routing or a \
535 store write becomes a silent no-op otherwise"
536 .into(),
537 ));
538 }
539 if !self.security.content_isolation.flag_injection_patterns {
540 return Err(ConfigError::Validation(
541 "orchestration.command.enabled = true requires \
542 security.content_isolation.flag_injection_patterns = true — the \
543 FR-B-003 sanitizer scan never flags anything otherwise, silently \
544 bypassing the reject gate"
545 .into(),
546 ));
547 }
548 Ok(())
549 }
550
551 fn validate_focus_and_sidequest(&self) -> Result<(), ConfigError> {
553 if self.agent.focus.compression_interval == 0 {
554 return Err(ConfigError::Validation(
555 "agent.focus.compression_interval must be >= 1".into(),
556 ));
557 }
558 if self.agent.focus.min_messages_per_focus == 0 {
559 return Err(ConfigError::Validation(
560 "agent.focus.min_messages_per_focus must be >= 1".into(),
561 ));
562 }
563 if self.agent.focus.auto_consolidate_min_window == 0 {
564 return Err(ConfigError::Validation(
565 "agent.focus.auto_consolidate_min_window must be >= 1 \
566 (set focus.enabled = false to disable auto-consolidation)"
567 .into(),
568 ));
569 }
570 if self.memory.sidequest.interval_turns == 0 {
571 return Err(ConfigError::Validation(
572 "memory.sidequest.interval_turns must be >= 1".into(),
573 ));
574 }
575 if !self.memory.sidequest.max_eviction_ratio.is_finite()
576 || self.memory.sidequest.max_eviction_ratio <= 0.0
577 || self.memory.sidequest.max_eviction_ratio > 1.0
578 {
579 return Err(ConfigError::Validation(format!(
580 "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
581 self.memory.sidequest.max_eviction_ratio
582 )));
583 }
584 Ok(())
585 }
586
587 fn validate_llm_and_skills(&self) -> Result<(), ConfigError> {
589 let sct = self.llm.semantic_cache_threshold;
590 if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
591 return Err(ConfigError::Validation(format!(
592 "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
593 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
594 )));
595 }
596 if self.memory.memcot.enabled && !self.memory.memcot.distill_provider.is_empty() {
598 self.llm.warn_non_fast_tier_provider(
599 &self.memory.memcot.distill_provider,
600 "memory.memcot.distill_provider",
601 &self.memory.memcot.fast_tier_models,
602 );
603 }
604 self.skills
605 .learning
606 .validate()
607 .map_err(ConfigError::Validation)?;
608 if self.skills.evaluation.enabled {
610 let weight_sum = self.skills.evaluation.weight_correctness
611 + self.skills.evaluation.weight_reusability
612 + self.skills.evaluation.weight_specificity;
613 if (weight_sum - 1.0_f32).abs() > 1e-3 {
614 return Err(ConfigError::Validation(format!(
615 "skills.evaluation weights must sum to 1.0 (got {weight_sum:.4})"
616 )));
617 }
618 }
619 Ok(())
620 }
621
622 fn validate_mcp_misc(&self) -> Result<(), ConfigError> {
624 if self.mcp.output_schema_hint_bytes < 64 {
625 return Err(ConfigError::Validation(format!(
626 "mcp.output_schema_hint_bytes must be >= 64, got {}; \
627 use forward_output_schema = false to disable forwarding",
628 self.mcp.output_schema_hint_bytes
629 )));
630 }
631 Ok(())
632 }
633
634 fn validate_scheduler(&self) -> Result<(), ConfigError> {
636 for task in &self.scheduler.tasks {
637 match (&task.cron, &task.run_at) {
638 (Some(_), Some(_)) => {
639 return Err(ConfigError::Validation(format!(
640 "scheduler task {:?}: only one of `cron` or `run_at` may be set, not both",
641 task.name
642 )));
643 }
644 (None, None) => {
645 return Err(ConfigError::Validation(format!(
646 "scheduler task {:?}: either `cron` or `run_at` must be set",
647 task.name
648 )));
649 }
650 _ => {}
651 }
652 }
653 Ok(())
654 }
655
656 fn validate_provider_names(&self) -> Result<(), ConfigError> {
657 let known = self.known_provider_names();
658 self.validate_named_provider_refs(&known)?;
659 self.validate_optional_provider_refs(&known)?;
660 Ok(())
661 }
662
663 fn known_provider_names(&self) -> std::collections::HashSet<String> {
665 self.llm
666 .providers
667 .iter()
668 .map(super::providers::ProviderEntry::effective_name)
669 .collect()
670 }
671
672 fn validate_named_provider_refs(
677 &self,
678 known: &std::collections::HashSet<String>,
679 ) -> Result<(), ConfigError> {
680 self.validate_core_provider_refs(known)?;
681 self.validate_tool_and_quality_provider_refs(known)
682 }
683
684 fn validate_core_provider_refs(
685 &self,
686 known: &std::collections::HashSet<String>,
687 ) -> Result<(), ConfigError> {
688 let fields: &[(&str, &crate::providers::ProviderName)] = &[
689 (
690 "memory.tiers.scene_provider",
691 &self.memory.tiers.scene_provider,
692 ),
693 (
694 "memory.compression.compress_provider",
695 &self.memory.compression.compress_provider,
696 ),
697 (
698 "memory.consolidation.consolidation_provider",
699 &self.memory.consolidation.consolidation_provider,
700 ),
701 (
702 "memory.admission.admission_provider",
703 &self.memory.admission.admission_provider,
704 ),
705 (
706 "memory.admission.goal_utility_provider",
707 &self.memory.admission.goal_utility_provider,
708 ),
709 (
710 "memory.store_routing.routing_classifier_provider",
711 &self.memory.store_routing.routing_classifier_provider,
712 ),
713 (
714 "skills.learning.feedback_provider",
715 &self.skills.learning.feedback_provider,
716 ),
717 (
718 "skills.learning.arise_trace_provider",
719 &self.skills.learning.arise_trace_provider,
720 ),
721 (
722 "skills.learning.stem_provider",
723 &self.skills.learning.stem_provider,
724 ),
725 (
726 "skills.learning.erl_extract_provider",
727 &self.skills.learning.erl_extract_provider,
728 ),
729 (
730 "mcp.pruning.pruning_provider",
731 &self.mcp.pruning.pruning_provider,
732 ),
733 (
734 "mcp.tool_discovery.embedding_provider",
735 &self.mcp.tool_discovery.embedding_provider,
736 ),
737 (
738 "security.response_verification.verifier_provider",
739 &self.security.response_verification.verifier_provider,
740 ),
741 (
742 "orchestration.planner_provider",
743 &self.orchestration.planner_provider,
744 ),
745 (
746 "orchestration.verify_provider",
747 &self.orchestration.verify_provider,
748 ),
749 (
750 "orchestration.tool_provider",
751 &self.orchestration.tool_provider,
752 ),
753 (
754 "skills.evaluation.provider",
755 &self.skills.evaluation.provider,
756 ),
757 (
758 "skills.proactive_exploration.provider",
759 &self.skills.proactive_exploration.provider,
760 ),
761 (
762 "memory.compression_spectrum.promotion_provider",
763 &self.memory.compression_spectrum.promotion_provider,
764 ),
765 ];
766 Self::check_provider_refs(fields, known)
767 }
768
769 fn validate_tool_and_quality_provider_refs(
770 &self,
771 known: &std::collections::HashSet<String>,
772 ) -> Result<(), ConfigError> {
773 let fields: &[(&str, &crate::providers::ProviderName)] = &[
774 (
775 "security.shadow_sentinel.probe_provider",
776 &self.security.shadow_sentinel.probe_provider,
777 ),
778 (
779 "tools.retry.parameter_reformat_provider",
780 &self.tools.retry.parameter_reformat_provider,
781 ),
782 (
783 "tools.policy.policy_provider",
784 &self.tools.policy.policy_provider,
785 ),
786 (
787 "tools.adversarial_policy.policy_provider",
788 &self.tools.adversarial_policy.policy_provider,
789 ),
790 (
791 "tools.speculative.pattern.rerank_provider",
792 &self.tools.speculative.pattern.rerank_provider,
793 ),
794 (
795 "tools.compression.evolution_provider",
796 &self.tools.compression.evolution_provider,
797 ),
798 ("quality.proposer_provider", &self.quality.proposer_provider),
799 ("quality.checker_provider", &self.quality.checker_provider),
800 ];
801 Self::check_provider_refs(fields, known)
802 }
803
804 fn check_provider_refs(
805 fields: &[(&str, &crate::providers::ProviderName)],
806 known: &std::collections::HashSet<String>,
807 ) -> Result<(), ConfigError> {
808 for (field, name) in fields {
809 if !name.is_empty() && !known.contains(name.as_str()) {
810 return Err(ConfigError::Validation(format!(
811 "{field} = {:?} does not match any [[llm.providers]] entry",
812 name.as_str()
813 )));
814 }
815 }
816 Ok(())
817 }
818
819 fn validate_optional_provider_refs(
821 &self,
822 known: &std::collections::HashSet<String>,
823 ) -> Result<(), ConfigError> {
824 if let Some(triage) = self
825 .llm
826 .complexity_routing
827 .as_ref()
828 .and_then(|cr| cr.triage_provider.as_ref())
829 .filter(|t| !t.is_empty() && !known.contains(t.as_str()))
830 {
831 return Err(ConfigError::Validation(format!(
832 "llm.complexity_routing.triage_provider = {:?} does not match any \
833 [[llm.providers]] entry",
834 triage.as_str()
835 )));
836 }
837
838 if let Some(embed) = self
839 .llm
840 .router
841 .as_ref()
842 .and_then(|r| r.bandit.as_ref())
843 .map(|b| &b.embedding_provider)
844 .filter(|p| !p.is_empty() && !known.contains(p.as_str()))
845 {
846 return Err(ConfigError::Validation(format!(
847 "llm.router.bandit.embedding_provider = {:?} does not match any \
848 [[llm.providers]] entry",
849 embed.as_str()
850 )));
851 }
852
853 Ok(())
854 }
855
856 fn normalize_legacy_runtime_defaults(&mut self) {
857 use crate::defaults::{
858 default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
859 is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
860 is_legacy_default_sqlite_path,
861 };
862
863 if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
864 self.memory.sqlite_path = default_sqlite_path();
865 }
866
867 for skill_path in &mut self.skills.paths {
868 if is_legacy_default_skills_path(skill_path) {
869 *skill_path = default_skills_dir();
870 }
871 }
872
873 if is_legacy_default_debug_dir(&self.debug.output_dir) {
874 self.debug.output_dir = default_debug_dir();
875 }
876
877 if is_legacy_default_log_file(&self.logging.file) {
878 self.logging.file = default_log_file_path();
879 }
880 }
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886
887 fn config_with_sct(threshold: f32) -> Config {
888 let mut cfg = Config::default();
889 cfg.llm.semantic_cache_threshold = threshold;
890 cfg
891 }
892
893 #[test]
894 fn semantic_cache_threshold_valid_zero() {
895 assert!(config_with_sct(0.0).validate().is_ok());
896 }
897
898 #[test]
899 fn semantic_cache_threshold_valid_mid() {
900 assert!(config_with_sct(0.5).validate().is_ok());
901 }
902
903 #[test]
904 fn semantic_cache_threshold_valid_one() {
905 assert!(config_with_sct(1.0).validate().is_ok());
906 }
907
908 #[test]
909 fn semantic_cache_threshold_invalid_negative() {
910 let err = config_with_sct(-0.1).validate().unwrap_err();
911 assert!(
912 err.to_string().contains("semantic_cache_threshold"),
913 "unexpected error: {err}"
914 );
915 }
916
917 #[test]
918 fn semantic_cache_threshold_invalid_above_one() {
919 let err = config_with_sct(1.1).validate().unwrap_err();
920 assert!(
921 err.to_string().contains("semantic_cache_threshold"),
922 "unexpected error: {err}"
923 );
924 }
925
926 #[test]
927 fn semantic_cache_threshold_invalid_nan() {
928 let err = config_with_sct(f32::NAN).validate().unwrap_err();
929 assert!(
930 err.to_string().contains("semantic_cache_threshold"),
931 "unexpected error: {err}"
932 );
933 }
934
935 #[cfg(not(feature = "card-signing"))]
936 #[test]
937 fn card_trust_policy_require_without_feature_fails_validation() {
938 let mut cfg = Config::default();
939 cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Require;
940 let err = cfg.validate().unwrap_err();
941 assert!(
942 err.to_string().contains("card_trust_policy"),
943 "unexpected error: {err}"
944 );
945 }
946
947 #[test]
948 fn card_trust_policy_ignore_and_prefer_always_pass_validation() {
949 let mut cfg = Config::default();
950 cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Ignore;
951 assert!(cfg.validate().is_ok());
952 cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Prefer;
953 assert!(cfg.validate().is_ok());
954 }
955
956 #[cfg(feature = "card-signing")]
957 #[test]
958 fn card_trust_policy_require_with_feature_passes_validation() {
959 let mut cfg = Config::default();
960 cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Require;
961 assert!(cfg.validate().is_ok());
962 }
963
964 #[test]
965 fn semantic_cache_threshold_invalid_infinity() {
966 let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
967 assert!(
968 err.to_string().contains("semantic_cache_threshold"),
969 "unexpected error: {err}"
970 );
971 }
972
973 #[test]
974 fn semantic_cache_threshold_invalid_neg_infinity() {
975 let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
976 assert!(
977 err.to_string().contains("semantic_cache_threshold"),
978 "unexpected error: {err}"
979 );
980 }
981
982 fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
983 let mut cfg = Config::default();
984 cfg.memory.compression.probe.enabled = enabled;
985 cfg.memory.compression.probe.threshold = threshold;
986 cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
987 cfg
988 }
989
990 #[test]
991 fn probe_disabled_skips_validation() {
992 let cfg = probe_config(false, 0.0, 1.0);
994 assert!(cfg.validate().is_ok());
995 }
996
997 #[test]
998 fn probe_valid_thresholds() {
999 let cfg = probe_config(true, 0.6, 0.35);
1000 assert!(cfg.validate().is_ok());
1001 }
1002
1003 #[test]
1004 fn probe_threshold_zero_invalid() {
1005 let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
1006 assert!(
1007 err.to_string().contains("probe.threshold"),
1008 "unexpected error: {err}"
1009 );
1010 }
1011
1012 #[test]
1013 fn probe_hard_fail_threshold_above_one_invalid() {
1014 let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
1015 assert!(
1016 err.to_string().contains("probe.hard_fail_threshold"),
1017 "unexpected error: {err}"
1018 );
1019 }
1020
1021 #[test]
1022 fn probe_hard_fail_gte_threshold_invalid() {
1023 let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
1024 assert!(
1025 err.to_string().contains("probe.hard_fail_threshold"),
1026 "unexpected error: {err}"
1027 );
1028 }
1029
1030 fn config_with_completeness_threshold(ct: f32) -> Config {
1031 let mut cfg = Config::default();
1032 cfg.orchestration.completeness_threshold = ct;
1033 cfg
1034 }
1035
1036 #[test]
1037 fn completeness_threshold_valid_zero() {
1038 assert!(config_with_completeness_threshold(0.0).validate().is_ok());
1039 }
1040
1041 #[test]
1042 fn completeness_threshold_valid_default() {
1043 assert!(config_with_completeness_threshold(0.7).validate().is_ok());
1044 }
1045
1046 #[test]
1047 fn completeness_threshold_valid_one() {
1048 assert!(config_with_completeness_threshold(1.0).validate().is_ok());
1049 }
1050
1051 #[test]
1052 fn completeness_threshold_invalid_negative() {
1053 let err = config_with_completeness_threshold(-0.1)
1054 .validate()
1055 .unwrap_err();
1056 assert!(
1057 err.to_string().contains("completeness_threshold"),
1058 "unexpected error: {err}"
1059 );
1060 }
1061
1062 #[test]
1063 fn completeness_threshold_invalid_above_one() {
1064 let err = config_with_completeness_threshold(1.1)
1065 .validate()
1066 .unwrap_err();
1067 assert!(
1068 err.to_string().contains("completeness_threshold"),
1069 "unexpected error: {err}"
1070 );
1071 }
1072
1073 #[test]
1074 fn completeness_threshold_invalid_nan() {
1075 let err = config_with_completeness_threshold(f32::NAN)
1076 .validate()
1077 .unwrap_err();
1078 assert!(
1079 err.to_string().contains("completeness_threshold"),
1080 "unexpected error: {err}"
1081 );
1082 }
1083
1084 #[test]
1085 fn completeness_threshold_invalid_infinity() {
1086 let err = config_with_completeness_threshold(f32::INFINITY)
1087 .validate()
1088 .unwrap_err();
1089 assert!(
1090 err.to_string().contains("completeness_threshold"),
1091 "unexpected error: {err}"
1092 );
1093 }
1094
1095 fn config_with_provider(name: &str) -> Config {
1096 let mut cfg = Config::default();
1097 cfg.llm.providers.push(crate::providers::ProviderEntry {
1098 provider_type: crate::providers::ProviderKind::Ollama,
1099 name: Some(name.into()),
1100 ..Default::default()
1101 });
1102 cfg
1103 }
1104
1105 #[test]
1106 fn validate_provider_names_all_empty_ok() {
1107 let cfg = Config::default();
1108 assert!(cfg.validate_provider_names().is_ok());
1109 }
1110
1111 #[test]
1112 fn validate_provider_names_matching_provider_ok() {
1113 let mut cfg = config_with_provider("fast");
1114 cfg.memory.admission.admission_provider = crate::providers::ProviderName::new("fast");
1115 assert!(cfg.validate_provider_names().is_ok());
1116 }
1117
1118 #[test]
1119 fn validate_provider_names_unknown_provider_err() {
1120 let mut cfg = config_with_provider("fast");
1121 cfg.memory.admission.admission_provider =
1122 crate::providers::ProviderName::new("nonexistent");
1123 let err = cfg.validate_provider_names().unwrap_err();
1124 let msg = err.to_string();
1125 assert!(
1126 msg.contains("admission_provider") && msg.contains("nonexistent"),
1127 "unexpected error: {msg}"
1128 );
1129 }
1130
1131 #[test]
1132 fn validate_provider_names_triage_provider_none_ok() {
1133 let mut cfg = config_with_provider("fast");
1134 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
1135 triage_provider: None,
1136 ..Default::default()
1137 });
1138 assert!(cfg.validate_provider_names().is_ok());
1139 }
1140
1141 #[test]
1142 fn validate_provider_names_triage_provider_matching_ok() {
1143 let mut cfg = config_with_provider("fast");
1144 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
1145 triage_provider: Some(crate::providers::ProviderName::new("fast")),
1146 ..Default::default()
1147 });
1148 assert!(cfg.validate_provider_names().is_ok());
1149 }
1150
1151 #[test]
1152 fn validate_provider_names_triage_provider_unknown_err() {
1153 let mut cfg = config_with_provider("fast");
1154 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
1155 triage_provider: Some(crate::providers::ProviderName::new("ghost")),
1156 ..Default::default()
1157 });
1158 let err = cfg.validate_provider_names().unwrap_err();
1159 let msg = err.to_string();
1160 assert!(
1161 msg.contains("triage_provider") && msg.contains("ghost"),
1162 "unexpected error: {msg}"
1163 );
1164 }
1165
1166 #[test]
1169 fn toml_float_fields_deserialise_correctly() {
1170 let toml = r"
1171[llm.router.reputation]
1172enabled = true
1173decay_factor = 0.95
1174weight = 0.3
1175
1176[llm.router.bandit]
1177enabled = false
1178cost_weight = 0.3
1179alpha = 1.0
1180decay_factor = 0.99
1181
1182[skills]
1183disambiguation_threshold = 0.25
1184cosine_weight = 0.7
1185";
1186 let wrapped = format!(
1188 "{}\n{}",
1189 toml,
1190 r"[memory.semantic]
1191mmr_lambda = 0.7
1192"
1193 );
1194 let router: crate::providers::RouterConfig = toml::from_str(
1196 r"[reputation]
1197enabled = true
1198decay_factor = 0.95
1199weight = 0.3
1200",
1201 )
1202 .expect("RouterConfig with float fields must deserialise");
1203 assert!((router.reputation.unwrap().decay_factor - 0.95).abs() < f64::EPSILON);
1204
1205 let bandit: crate::providers::BanditConfig =
1206 toml::from_str("cost_weight = 0.3\nalpha = 1.0\n")
1207 .expect("BanditConfig with float fields must deserialise");
1208 assert!((bandit.cost_weight - 0.3_f32).abs() < f32::EPSILON);
1209
1210 let semantic: crate::memory::SemanticConfig = toml::from_str("mmr_lambda = 0.7\n")
1211 .expect("SemanticConfig with float fields must deserialise");
1212 assert!((semantic.mmr_lambda - 0.7_f32).abs() < f32::EPSILON);
1213
1214 let skills: crate::features::SkillsConfig =
1215 toml::from_str("disambiguation_threshold = 0.25\n")
1216 .expect("SkillsConfig with float fields must deserialise");
1217 assert!((skills.disambiguation_threshold - 0.25_f32).abs() < f32::EPSILON);
1218
1219 let _ = wrapped; }
1221
1222 #[test]
1223 fn validate_max_parallel_zero_rejected() {
1224 let mut cfg = Config::default();
1225 cfg.orchestration.max_parallel = 0;
1226 let err = cfg.validate().unwrap_err().to_string();
1227 assert!(
1228 err.contains("max_parallel"),
1229 "expected max_parallel in error, got: {err}"
1230 );
1231 }
1232
1233 #[test]
1234 fn validate_max_parallel_one_accepted() {
1235 let mut cfg = Config::default();
1236 cfg.orchestration.max_parallel = 1;
1237 assert!(cfg.validate().is_ok());
1238 }
1239
1240 #[test]
1241 fn validate_max_tasks_zero_rejected() {
1242 let mut cfg = Config::default();
1243 cfg.orchestration.max_tasks = 0;
1244 let err = cfg.validate().unwrap_err().to_string();
1245 assert!(
1246 err.contains("max_tasks"),
1247 "expected max_tasks in error, got: {err}"
1248 );
1249 }
1250
1251 #[test]
1252 fn validate_max_tasks_one_accepted() {
1253 let mut cfg = Config::default();
1254 cfg.orchestration.max_tasks = 1;
1255 assert!(cfg.validate().is_ok());
1256 }
1257
1258 #[test]
1259 fn validate_aggregator_timeout_zero_rejected() {
1260 let mut cfg = Config::default();
1261 cfg.orchestration.aggregator_timeout_secs = 0;
1262 let err = cfg.validate().unwrap_err().to_string();
1263 assert!(
1264 err.contains("aggregator_timeout_secs"),
1265 "expected aggregator_timeout_secs in error, got: {err}"
1266 );
1267 }
1268
1269 #[test]
1270 fn validate_planner_timeout_zero_rejected() {
1271 let mut cfg = Config::default();
1272 cfg.orchestration.planner_timeout_secs = 0;
1273 let err = cfg.validate().unwrap_err().to_string();
1274 assert!(
1275 err.contains("planner_timeout_secs"),
1276 "expected planner_timeout_secs in error, got: {err}"
1277 );
1278 }
1279
1280 #[test]
1281 fn validate_verifier_timeout_zero_rejected() {
1282 let mut cfg = Config::default();
1283 cfg.orchestration.verifier_timeout_secs = 0;
1284 let err = cfg.validate().unwrap_err().to_string();
1285 assert!(
1286 err.contains("verifier_timeout_secs"),
1287 "expected verifier_timeout_secs in error, got: {err}"
1288 );
1289 }
1290
1291 #[test]
1292 fn validate_default_idle_timeout_zero_rejected() {
1293 let mut cfg = Config::default();
1294 cfg.orchestration.default_idle_timeout_secs = Some(0);
1295 let err = cfg.validate().unwrap_err().to_string();
1296 assert!(
1297 err.contains("default_idle_timeout_secs"),
1298 "expected default_idle_timeout_secs in error, got: {err}"
1299 );
1300 }
1301
1302 #[test]
1303 fn validate_default_idle_timeout_none_accepted() {
1304 let mut cfg = Config::default();
1305 cfg.orchestration.default_idle_timeout_secs = None;
1306 assert!(cfg.validate().is_ok());
1307 }
1308
1309 #[test]
1310 fn validate_default_idle_timeout_positive_accepted() {
1311 let mut cfg = Config::default();
1312 cfg.orchestration.default_idle_timeout_secs = Some(60);
1313 assert!(cfg.validate().is_ok());
1314 }
1315
1316 #[test]
1317 fn validate_command_max_handoffs_zero_rejected() {
1318 let mut cfg = Config::default();
1319 cfg.orchestration.command.max_handoffs = 0;
1320 let err = cfg.validate().unwrap_err().to_string();
1321 assert!(
1322 err.contains("max_handoffs"),
1323 "expected max_handoffs in error, got: {err}"
1324 );
1325 }
1326
1327 #[test]
1328 fn validate_command_max_handoffs_default_accepted() {
1329 let cfg = Config::default();
1330 assert_eq!(cfg.orchestration.command.max_handoffs, 16);
1331 assert!(!cfg.orchestration.command.enabled);
1332 assert!(cfg.validate().is_ok());
1333 }
1334
1335 #[test]
1338 fn validate_command_max_handoffs_over_10000_rejected() {
1339 let mut cfg = Config::default();
1340 cfg.orchestration.command.max_handoffs = 10_001;
1341 let err = cfg.validate().unwrap_err().to_string();
1342 assert!(
1343 err.contains("max_handoffs") && err.contains("<= 10000"),
1344 "expected max_handoffs upper-bound error, got: {err}"
1345 );
1346 }
1347
1348 #[test]
1349 fn validate_command_max_handoffs_exactly_10000_accepted() {
1350 let mut cfg = Config::default();
1351 cfg.orchestration.command.max_handoffs = 10_000;
1352 assert!(cfg.validate().is_ok());
1353 }
1354
1355 fn config_with_command_enabled() -> Config {
1358 let mut cfg = Config::default();
1359 cfg.orchestration.command.enabled = true;
1360 cfg.memory.store.enabled = true;
1361 cfg.security.content_isolation.enabled = true;
1362 cfg.security.content_isolation.flag_injection_patterns = true;
1363 cfg
1364 }
1365
1366 #[test]
1367 fn validate_command_enabled_with_all_prerequisites_accepted() {
1368 assert!(config_with_command_enabled().validate().is_ok());
1369 }
1370
1371 #[test]
1372 fn validate_command_enabled_without_store_enabled_rejected() {
1373 let mut cfg = config_with_command_enabled();
1374 cfg.memory.store.enabled = false;
1375 let err = cfg.validate().unwrap_err().to_string();
1376 assert!(
1377 err.contains("memory.store.enabled"),
1378 "expected store-prerequisite error, got: {err}"
1379 );
1380 }
1381
1382 #[test]
1383 fn validate_command_enabled_without_content_isolation_enabled_rejected() {
1384 let mut cfg = config_with_command_enabled();
1385 cfg.security.content_isolation.enabled = false;
1386 let err = cfg.validate().unwrap_err().to_string();
1387 assert!(
1388 err.contains("content_isolation.enabled"),
1389 "expected content_isolation-prerequisite error, got: {err}"
1390 );
1391 }
1392
1393 #[test]
1394 fn validate_command_enabled_without_flag_injection_patterns_rejected() {
1395 let mut cfg = config_with_command_enabled();
1396 cfg.security.content_isolation.flag_injection_patterns = false;
1397 let err = cfg.validate().unwrap_err().to_string();
1398 assert!(
1399 err.contains("flag_injection_patterns"),
1400 "expected flag_injection_patterns-prerequisite error, got: {err}"
1401 );
1402 }
1403
1404 #[test]
1405 fn validate_command_disabled_ignores_store_and_content_isolation_state() {
1406 let mut cfg = Config::default();
1409 cfg.memory.store.enabled = false;
1410 cfg.security.content_isolation.enabled = false;
1411 cfg.security.content_isolation.flag_injection_patterns = false;
1412 assert!(cfg.validate().is_ok());
1413 }
1414
1415 #[test]
1416 fn focus_auto_consolidate_min_window_zero_rejected() {
1417 let mut cfg = Config::default();
1418 cfg.agent.focus.auto_consolidate_min_window = 0;
1419 let err = cfg.validate().unwrap_err().to_string();
1420 assert!(
1421 err.contains("auto_consolidate_min_window"),
1422 "expected auto_consolidate_min_window in error, got: {err}"
1423 );
1424 }
1425
1426 #[test]
1427 fn focus_auto_consolidate_min_window_one_accepted() {
1428 let mut cfg = Config::default();
1429 cfg.agent.focus.auto_consolidate_min_window = 1;
1430 assert!(cfg.validate().is_ok());
1431 }
1432
1433 fn task_with(cron: Option<&str>, run_at: Option<&str>) -> crate::features::ScheduledTaskConfig {
1434 crate::features::ScheduledTaskConfig {
1435 name: "test-task".into(),
1436 cron: cron.map(Into::into),
1437 run_at: run_at.map(Into::into),
1438 kind: crate::features::ScheduledTaskKind::HealthCheck,
1439 config: serde_json::Value::Null,
1440 }
1441 }
1442
1443 #[test]
1444 fn scheduler_task_valid_cron() {
1445 let mut cfg = Config::default();
1446 cfg.scheduler.tasks.push(task_with(Some("0 9 * * *"), None));
1447 assert!(cfg.validate().is_ok());
1448 }
1449
1450 #[test]
1451 fn scheduler_task_valid_run_at() {
1452 let mut cfg = Config::default();
1453 cfg.scheduler
1454 .tasks
1455 .push(task_with(None, Some("2025-01-01T09:00:00Z")));
1456 assert!(cfg.validate().is_ok());
1457 }
1458
1459 #[test]
1460 fn scheduler_task_neither_cron_nor_run_at_rejected() {
1461 let mut cfg = Config::default();
1462 cfg.scheduler.tasks.push(task_with(None, None));
1463 let err = cfg.validate().unwrap_err().to_string();
1464 assert!(
1465 err.contains("either `cron` or `run_at` must be set"),
1466 "unexpected error: {err}"
1467 );
1468 }
1469
1470 #[test]
1471 fn scheduler_task_both_cron_and_run_at_rejected() {
1472 let mut cfg = Config::default();
1473 cfg.scheduler
1474 .tasks
1475 .push(task_with(Some("0 9 * * *"), Some("2025-01-01T09:00:00Z")));
1476 let err = cfg.validate().unwrap_err().to_string();
1477 assert!(
1478 err.contains("only one of `cron` or `run_at` may be set"),
1479 "unexpected error: {err}"
1480 );
1481 }
1482
1483 #[test]
1486 fn validate_rejects_empty_provider_pool() {
1487 let mut cfg = Config::default();
1493 cfg.llm.providers.clear();
1494 let err = cfg.validate().unwrap_err().to_string();
1495 assert!(
1496 err.contains("at least one LLM provider"),
1497 "expected empty-pool error, got: {err}"
1498 );
1499 }
1500
1501 #[test]
1502 fn validate_rejects_duplicate_provider_names() {
1503 let mut cfg = Config::default();
1504 cfg.llm.providers.push(crate::providers::ProviderEntry {
1505 provider_type: crate::providers::ProviderKind::Ollama,
1506 ..Default::default()
1507 });
1508 let err = cfg.validate().unwrap_err().to_string();
1509 assert!(
1510 err.contains("duplicate provider name"),
1511 "expected duplicate-name error, got: {err}"
1512 );
1513 }
1514
1515 #[test]
1516 fn validate_rejects_multiple_default_providers() {
1517 let mut cfg = Config::default();
1518 cfg.llm.providers[0].default = true;
1519 cfg.llm.providers.push(crate::providers::ProviderEntry {
1520 provider_type: crate::providers::ProviderKind::Ollama,
1521 name: Some("second".into()),
1522 default: true,
1523 ..Default::default()
1524 });
1525 let err = cfg.validate().unwrap_err().to_string();
1526 assert!(
1527 err.contains("default = true"),
1528 "expected multiple-default error, got: {err}"
1529 );
1530 }
1531
1532 #[test]
1533 fn validate_rejects_stt_provider_pointing_at_nonexistent_provider() {
1534 let mut cfg = Config::default();
1535 cfg.llm.stt = Some(crate::providers::SttConfig {
1536 provider: crate::providers::ProviderName::new("ghost"),
1537 language: crate::providers::default_stt_language(),
1538 });
1539 let err = cfg.validate().unwrap_err().to_string();
1540 assert!(
1541 err.contains("[llm.stt].provider") && err.contains("ghost"),
1542 "expected stt-provider-mismatch error, got: {err}"
1543 );
1544 }
1545
1546 #[test]
1547 fn validate_accepts_stt_provider_matching_existing_provider() {
1548 let mut cfg = Config::default();
1549 cfg.llm.stt = Some(crate::providers::SttConfig {
1550 provider: crate::providers::ProviderName::new("ollama"),
1551 language: crate::providers::default_stt_language(),
1552 });
1553 assert!(cfg.validate().is_ok());
1554 }
1555
1556 #[test]
1557 fn validate_rejects_trajectory_sentinel_inverted_thresholds() {
1558 let mut cfg = Config::default();
1559 cfg.security.trajectory.elevated_at = 0.9;
1560 cfg.security.trajectory.high_at = 0.5;
1561 let err = cfg.validate().unwrap_err().to_string();
1562 assert!(
1563 err.contains("elevated_at") && err.contains("high_at"),
1564 "expected trajectory threshold-ordering error, got: {err}"
1565 );
1566 }
1567
1568 #[test]
1569 fn validate_rejects_gateway_invalid_webhook_timeout() {
1570 let mut cfg = Config::default();
1576 cfg.gateway.webhook_send_timeout_secs = 0;
1577 let err = cfg.validate().unwrap_err().to_string();
1578 assert!(
1579 err.contains("webhook_send_timeout_secs"),
1580 "expected gateway webhook_send_timeout_secs error, got: {err}"
1581 );
1582 }
1583
1584 #[test]
1585 fn validate_rejects_negative_utility_scoring_weight() {
1586 let mut cfg = Config::default();
1587 cfg.tools.utility.gain_weight = -1.0;
1588 let err = cfg.validate().unwrap_err().to_string();
1589 assert!(
1590 err.contains("gain_weight"),
1591 "expected utility-scoring weight error, got: {err}"
1592 );
1593 }
1594
1595 #[test]
1596 fn validate_rejects_fidelity_threshold_ordering() {
1597 let mut cfg = Config::default();
1598 cfg.memory.fidelity = Some(crate::fidelity::FidelityConfig {
1599 full_threshold: 0.2,
1600 compressed_threshold: 0.5,
1601 ..Default::default()
1602 });
1603 let err = cfg.validate().unwrap_err().to_string();
1604 assert!(
1605 err.contains("full_threshold") && err.contains("compressed_threshold"),
1606 "expected fidelity threshold-ordering error, got: {err}"
1607 );
1608 }
1609
1610 #[test]
1611 fn validate_accepts_absent_fidelity_config() {
1612 let cfg = Config::default();
1613 assert!(cfg.memory.fidelity.is_none());
1614 assert!(cfg.validate().is_ok());
1615 }
1616
1617 #[test]
1618 fn validate_rejects_acon_inverted_thresholds() {
1619 let mut cfg = Config::default();
1620 cfg.memory.compression.acon.passthrough_threshold = 5000;
1621 cfg.memory.compression.acon.summarize_threshold = 1000;
1622 let err = cfg.validate().unwrap_err().to_string();
1623 assert!(
1624 err.contains("passthrough_threshold") && err.contains("summarize_threshold"),
1625 "expected acon threshold-ordering error, got: {err}"
1626 );
1627 }
1628
1629 #[test]
1630 fn validate_rejects_shadow_memory_inverted_thresholds() {
1631 let mut cfg = Config::default();
1632 cfg.memory.shadow_memory.enabled = true;
1633 cfg.memory.shadow_memory.escalation_threshold = 0.75;
1634 cfg.memory.shadow_memory.risk_threshold = 0.50;
1635 let err = cfg.validate().unwrap_err().to_string();
1636 assert!(
1637 err.contains("escalation_threshold") && err.contains("risk_threshold"),
1638 "expected shadow_memory threshold-ordering error, got: {err}"
1639 );
1640 }
1641
1642 #[test]
1643 fn validate_rejects_shadow_memory_equal_thresholds() {
1644 let mut cfg = Config::default();
1645 cfg.memory.shadow_memory.enabled = true;
1646 cfg.memory.shadow_memory.escalation_threshold = 0.6;
1647 cfg.memory.shadow_memory.risk_threshold = 0.6;
1648 assert!(
1649 cfg.validate().is_err(),
1650 "equal thresholds must be rejected — the escalation band would be empty"
1651 );
1652 }
1653
1654 #[test]
1655 fn validate_ignores_shadow_memory_thresholds_when_disabled() {
1656 let mut cfg = Config::default();
1657 cfg.memory.shadow_memory.enabled = false;
1658 cfg.memory.shadow_memory.escalation_threshold = 0.9;
1659 cfg.memory.shadow_memory.risk_threshold = 0.1;
1660 assert!(
1661 cfg.validate().is_ok(),
1662 "inverted thresholds on a disabled shadow_memory config must not fail validation"
1663 );
1664 }
1665
1666 #[test]
1667 fn validate_rejects_worktree_max_worktrees_zero() {
1668 let mut cfg = Config::default();
1669 cfg.worktree.max_worktrees = Some(0);
1670 let err = cfg.validate().unwrap_err().to_string();
1671 assert!(
1672 err.contains("max_worktrees"),
1673 "expected max_worktrees in error, got: {err}"
1674 );
1675 }
1676
1677 #[test]
1678 fn validate_accepts_worktree_max_worktrees_positive_or_unset() {
1679 let mut cfg = Config::default();
1680 cfg.worktree.max_worktrees = Some(1);
1681 assert!(cfg.validate().is_ok());
1682 cfg.worktree.max_worktrees = None;
1683 assert!(cfg.validate().is_ok());
1684 }
1685
1686 #[test]
1687 fn validate_rejects_worktree_disk_quota_mb_zero() {
1688 let mut cfg = Config::default();
1689 cfg.worktree.disk_quota_mb = Some(0);
1690 let err = cfg.validate().unwrap_err().to_string();
1691 assert!(
1692 err.contains("disk_quota_mb"),
1693 "expected disk_quota_mb in error, got: {err}"
1694 );
1695 }
1696
1697 #[test]
1698 fn validate_accepts_worktree_disk_quota_mb_positive_or_unset() {
1699 let mut cfg = Config::default();
1700 cfg.worktree.disk_quota_mb = Some(1);
1701 assert!(cfg.validate().is_ok());
1702 cfg.worktree.disk_quota_mb = None;
1703 assert!(cfg.validate().is_ok());
1704 }
1705
1706 #[test]
1710 fn validate_rejects_worktree_disk_quota_mb_with_no_evaluation_path_enabled() {
1711 let mut cfg = Config::default();
1712 cfg.worktree.disk_quota_mb = Some(100);
1713 cfg.worktree.auto_reconcile_secs = 0;
1714 cfg.worktree.reconcile_on_startup = false;
1715 let err = cfg.validate().unwrap_err().to_string();
1716 assert!(
1717 err.contains("disk_quota_mb") && err.contains("never automatically"),
1718 "expected inert-path error, got: {err}"
1719 );
1720 }
1721
1722 #[test]
1723 fn validate_accepts_worktree_disk_quota_mb_when_startup_sweep_enabled() {
1724 let mut cfg = Config::default();
1725 cfg.worktree.disk_quota_mb = Some(100);
1726 cfg.worktree.auto_reconcile_secs = 0;
1727 cfg.worktree.reconcile_on_startup = true;
1728 assert!(cfg.validate().is_ok());
1729 }
1730
1731 #[test]
1732 fn validate_accepts_worktree_disk_quota_mb_when_periodic_sweep_enabled() {
1733 let mut cfg = Config::default();
1734 cfg.worktree.disk_quota_mb = Some(100);
1735 cfg.worktree.auto_reconcile_secs = 3600;
1736 cfg.worktree.reconcile_on_startup = false;
1737 assert!(cfg.validate().is_ok());
1738 }
1739
1740 #[test]
1743 fn validate_rejects_worktree_auto_reconcile_secs_short_interval() {
1744 let mut cfg = Config::default();
1745 cfg.worktree.auto_reconcile_secs = 1;
1746 let err = cfg.validate().unwrap_err().to_string();
1747 assert!(
1748 err.contains("auto_reconcile_secs"),
1749 "expected auto_reconcile_secs in error, got: {err}"
1750 );
1751 }
1752
1753 #[test]
1754 fn validate_accepts_worktree_auto_reconcile_secs_zero_or_at_least_60() {
1755 let mut cfg = Config::default();
1756 cfg.worktree.auto_reconcile_secs = 0;
1757 assert!(cfg.validate().is_ok());
1758 cfg.worktree.auto_reconcile_secs = 60;
1759 assert!(cfg.validate().is_ok());
1760 cfg.worktree.auto_reconcile_secs = 3600;
1761 assert!(cfg.validate().is_ok());
1762 }
1763
1764 #[test]
1769 fn dump_defaults_output_is_self_consistent_and_validates() {
1770 assert!(Config::default().validate().is_ok());
1771
1772 let dumped = Config::dump_defaults().expect("dump defaults");
1773 assert!(
1774 dumped.contains("[[llm.providers]]"),
1775 "dumped defaults must include an active provider entry, got:\n{dumped}"
1776 );
1777 let reparsed: Config = toml::from_str(&dumped).expect("reparse dumped defaults");
1778 assert!(reparsed.validate().is_ok());
1779 }
1780
1781 fn config_with_ensemble(enabled: bool, verify: bool, members: Vec<&str>) -> Config {
1784 let mut cfg = Config::default();
1785 cfg.orchestration.ensemble.enabled = enabled;
1786 cfg.orchestration.ensemble.verify = verify;
1787 cfg.orchestration.ensemble.members = members.into_iter().map(String::from).collect();
1788 cfg
1789 }
1790
1791 #[test]
1792 fn ensemble_default_config_validates_trivially() {
1793 assert!(Config::default().validate().is_ok());
1794 }
1795
1796 #[test]
1797 fn ensemble_disabled_skips_member_list_validation() {
1798 let cfg = config_with_ensemble(false, false, vec!["a", "b"]);
1800 assert!(cfg.validate().is_ok());
1801 }
1802
1803 #[test]
1804 fn ensemble_enabled_but_not_verify_skips_member_list_validation() {
1805 let cfg = config_with_ensemble(true, false, vec!["a", "b"]);
1807 assert!(cfg.validate().is_ok());
1808 }
1809
1810 #[test]
1811 fn ensemble_active_even_length_members_rejected() {
1812 let cfg = config_with_ensemble(true, true, vec!["a", "b"]);
1813 let err = cfg.validate().unwrap_err();
1814 assert!(
1815 err.to_string().contains("must be odd and >= 3"),
1816 "unexpected error: {err}"
1817 );
1818 }
1819
1820 #[test]
1821 fn ensemble_active_short_members_rejected() {
1822 let cfg = config_with_ensemble(true, true, vec!["a"]);
1823 let err = cfg.validate().unwrap_err();
1824 assert!(
1825 err.to_string().contains("must be odd and >= 3"),
1826 "unexpected error: {err}"
1827 );
1828 }
1829
1830 #[test]
1831 fn ensemble_active_duplicate_members_rejected() {
1832 let cfg = config_with_ensemble(true, true, vec!["a", "b", "a"]);
1833 let err = cfg.validate().unwrap_err();
1834 assert!(
1835 err.to_string().contains("duplicate provider name"),
1836 "unexpected error: {err}"
1837 );
1838 }
1839
1840 #[test]
1841 fn ensemble_active_valid_odd_unique_members_accepted() {
1842 let cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1843 assert!(cfg.validate().is_ok());
1844 }
1845
1846 #[test]
1847 fn ensemble_active_valid_five_members_accepted() {
1848 let cfg = config_with_ensemble(true, true, vec!["a", "b", "c", "d", "e"]);
1849 assert!(cfg.validate().is_ok());
1850 }
1851
1852 #[test]
1855 fn ensemble_active_ema_alpha_above_one_rejected() {
1856 let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1857 cfg.orchestration.ensemble.ema_alpha = 1.5;
1858 let err = cfg.validate().unwrap_err();
1859 assert!(
1860 err.to_string().contains("ema_alpha"),
1861 "unexpected error: {err}"
1862 );
1863 }
1864
1865 #[test]
1866 fn ensemble_active_ema_alpha_negative_rejected() {
1867 let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1868 cfg.orchestration.ensemble.ema_alpha = -0.1;
1869 let err = cfg.validate().unwrap_err();
1870 assert!(
1871 err.to_string().contains("ema_alpha"),
1872 "unexpected error: {err}"
1873 );
1874 }
1875
1876 #[test]
1877 fn ensemble_active_ema_alpha_nan_rejected() {
1878 let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1879 cfg.orchestration.ensemble.ema_alpha = f64::NAN;
1880 let err = cfg.validate().unwrap_err();
1881 assert!(
1882 err.to_string().contains("ema_alpha"),
1883 "unexpected error: {err}"
1884 );
1885 }
1886
1887 #[test]
1888 fn ensemble_active_ema_decay_above_one_rejected() {
1889 let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1890 cfg.orchestration.ensemble.ema_decay = 1.1;
1891 let err = cfg.validate().unwrap_err();
1892 assert!(
1893 err.to_string().contains("ema_decay"),
1894 "unexpected error: {err}"
1895 );
1896 }
1897
1898 #[test]
1899 fn ensemble_active_ema_decay_negative_rejected() {
1900 let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1901 cfg.orchestration.ensemble.ema_decay = -0.1;
1902 let err = cfg.validate().unwrap_err();
1903 assert!(
1904 err.to_string().contains("ema_decay"),
1905 "unexpected error: {err}"
1906 );
1907 }
1908
1909 #[test]
1910 fn ensemble_active_ema_boundaries_zero_and_one_accepted() {
1911 let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1912 cfg.orchestration.ensemble.ema_alpha = 0.0;
1913 cfg.orchestration.ensemble.ema_decay = 1.0;
1914 assert!(cfg.validate().is_ok());
1915 }
1916
1917 #[test]
1918 fn ensemble_disabled_skips_ema_range_validation() {
1919 let mut cfg = config_with_ensemble(false, false, vec![]);
1921 cfg.orchestration.ensemble.ema_alpha = 5.0;
1922 assert!(cfg.validate().is_ok());
1923 }
1924
1925 #[test]
1928 #[tracing_test::traced_test]
1929 fn qdrant_loopback_url_never_warns() {
1930 let mut cfg = Config::default();
1931 cfg.memory.qdrant_url = "http://localhost:6334".into();
1932 assert!(cfg.validate().is_ok());
1933 assert!(!logs_contain("memory.qdrant_url"));
1934 }
1935
1936 #[test]
1937 #[tracing_test::traced_test]
1938 fn qdrant_non_loopback_plaintext_no_key_warns() {
1939 let mut cfg = Config::default();
1940 cfg.memory.qdrant_url = "http://qdrant.example.com:6334".into();
1941 assert!(cfg.validate().is_ok(), "must warn, not hard-fail");
1942 assert!(logs_contain("memory.qdrant_url"));
1943 }
1944
1945 #[test]
1946 #[tracing_test::traced_test]
1947 fn qdrant_non_loopback_https_with_key_does_not_warn() {
1948 let mut cfg = Config::default();
1949 cfg.memory.qdrant_url = "https://qdrant.example.com:6334".into();
1950 cfg.memory.qdrant_api_key = Some(zeph_common::secret::Secret::new("test-key"));
1951 assert!(cfg.validate().is_ok());
1952 assert!(!logs_contain("memory.qdrant_url"));
1953 }
1954
1955 #[test]
1956 #[tracing_test::traced_test]
1957 fn qdrant_non_loopback_https_without_key_still_warns() {
1958 let mut cfg = Config::default();
1959 cfg.memory.qdrant_url = "https://qdrant.example.com:6334".into();
1960 assert!(cfg.validate().is_ok());
1961 assert!(logs_contain("memory.qdrant_url"));
1962 }
1963
1964 #[test]
1965 #[tracing_test::traced_test]
1966 fn qdrant_non_loopback_plaintext_with_key_still_warns() {
1967 let mut cfg = Config::default();
1970 cfg.memory.qdrant_url = "http://qdrant.example.com:6334".into();
1971 cfg.memory.qdrant_api_key = Some(zeph_common::secret::Secret::new("test-key"));
1972 assert!(cfg.validate().is_ok());
1973 assert!(logs_contain("memory.qdrant_url"));
1974 }
1975}