1use crate::types::AgenticConfig;
8use std::collections::BTreeSet;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct AdvisoryWarning {
13 pub code: &'static str,
15
16 pub message: String,
18
19 pub path: &'static str,
21}
22
23impl AdvisoryWarning {
24 pub fn new(code: &'static str, path: &'static str, message: impl Into<String>) -> Self {
26 Self {
27 code,
28 path,
29 message: message.into(),
30 }
31 }
32}
33
34impl std::fmt::Display for AdvisoryWarning {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 write!(f, "[{}] {}: {}", self.code, self.path, self.message)
37 }
38}
39
40pub fn detect_deprecated_keys_toml(v: &toml::Value) -> Vec<AdvisoryWarning> {
46 let mut warnings = Vec::new();
47
48 if let Some(tbl) = v.as_table() {
49 if let Some(thoughts) = tbl.get("thoughts").and_then(toml::Value::as_table)
50 && thoughts.contains_key("mount_dirs")
51 {
52 warnings.push(AdvisoryWarning::new(
53 "config.deprecated.thoughts.mount_dirs",
54 "thoughts.mount_dirs",
55 "The legacy thoughts.mount_dirs key is no longer supported. The agentic [thoughts] section now only models add_reference_timeout_secs.",
56 ));
57 }
58 if tbl.contains_key("models") {
59 warnings.push(AdvisoryWarning::new(
60 "config.deprecated.models",
61 "models",
62 "The 'models' section has been replaced by 'subagents' and 'reasoning'.",
63 ));
64 }
65 }
66
67 warnings
68}
69
70const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
79 "$schema",
80 "subagents",
81 "reasoning",
82 "services",
83 "orchestrator",
84 "web_retrieval",
85 "cli_tools",
86 "review",
87 "thoughts",
88 "logging",
89];
90
91const GPT5_2_COMPLETION_TOKENS_DOC_MAX: u32 = 128_000;
92
93pub fn detect_unknown_top_level_keys_toml(v: &toml::Value) -> Vec<AdvisoryWarning> {
98 let mut warnings = Vec::new();
99 let Some(tbl) = v.as_table() else {
100 return warnings;
101 };
102
103 for key in tbl.keys() {
104 if !KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str()) {
105 warnings.push(AdvisoryWarning::new(
106 "config.unknown_top_level_key",
107 "$",
108 format!("Unknown top-level key '{key}' will be ignored"),
109 ));
110 }
111 }
112
113 warnings
114}
115
116pub fn validate(cfg: &AgenticConfig) -> Vec<AdvisoryWarning> {
121 let mut warnings = vec![];
122
123 validate_url(
125 &cfg.services.anthropic.base_url,
126 "services.anthropic.base_url",
127 "services.anthropic.base_url.invalid",
128 &mut warnings,
129 );
130
131 validate_url(
132 &cfg.services.exa.base_url,
133 "services.exa.base_url",
134 "services.exa.base_url.invalid",
135 &mut warnings,
136 );
137 validate_url(
138 &cfg.services.linear.base_url,
139 "services.linear.base_url",
140 "services.linear.base_url.invalid",
141 &mut warnings,
142 );
143 validate_url(
144 &cfg.services.github.base_url,
145 "services.github.base_url",
146 "services.github.base_url.invalid",
147 &mut warnings,
148 );
149
150 let valid_levels = ["trace", "debug", "info", "warn", "error"];
152 if !valid_levels.contains(&cfg.logging.level.to_lowercase().as_str()) {
153 warnings.push(AdvisoryWarning {
154 code: "logging.level.invalid",
155 path: "logging.level",
156 message: format!(
157 "Unknown log level '{}'. Expected one of: {}",
158 cfg.logging.level,
159 valid_levels.join(", ")
160 ),
161 });
162 }
163
164 if cfg.subagents.locator_model.trim().is_empty() {
166 warnings.push(AdvisoryWarning::new(
167 "subagents.locator_model.empty",
168 "subagents.locator_model",
169 "value is empty",
170 ));
171 }
172 if cfg.subagents.analyzer_model.trim().is_empty() {
173 warnings.push(AdvisoryWarning::new(
174 "subagents.analyzer_model.empty",
175 "subagents.analyzer_model",
176 "value is empty",
177 ));
178 }
179 validate_low_nonzero_timeout(
180 cfg.subagents.runtime_timeout_secs,
181 30,
182 "subagents.runtime_timeout_secs",
183 "subagents.runtime_timeout_secs.suspicious",
184 &mut warnings,
185 );
186
187 if cfg.reasoning.optimizer_model.trim().is_empty() {
189 warnings.push(AdvisoryWarning::new(
190 "reasoning.optimizer_model.empty",
191 "reasoning.optimizer_model",
192 "value is empty",
193 ));
194 }
195 if cfg.reasoning.executor_model.trim().is_empty() {
196 warnings.push(AdvisoryWarning::new(
197 "reasoning.executor_model.empty",
198 "reasoning.executor_model",
199 "value is empty",
200 ));
201 }
202
203 if !cfg.reasoning.optimizer_model.trim().is_empty()
205 && !cfg.reasoning.optimizer_model.contains('/')
206 {
207 warnings.push(AdvisoryWarning::new(
208 "reasoning.optimizer_model.format",
209 "reasoning.optimizer_model",
210 "expected OpenRouter format like `anthropic/claude-sonnet-4.6`",
211 ));
212 }
213
214 if !cfg.reasoning.executor_model.trim().is_empty()
215 && !cfg.reasoning.executor_model.contains('/')
216 {
217 warnings.push(AdvisoryWarning::new(
218 "reasoning.executor_model.format",
219 "reasoning.executor_model",
220 "expected OpenRouter format like `openai/gpt-5.2`",
221 ));
222 } else if !cfg.reasoning.executor_model.trim().is_empty()
223 && !cfg
224 .reasoning
225 .executor_model
226 .to_lowercase()
227 .contains("gpt-5")
228 {
229 warnings.push(AdvisoryWarning::new(
230 "reasoning.executor_model.suspicious",
231 "reasoning.executor_model",
232 "executor_model does not look like a GPT-5 model; reasoning_effort may not work",
233 ));
234 }
235
236 if let Some(eff) = cfg.reasoning.reasoning_effort.as_deref() {
238 let eff_lc = eff.trim().to_lowercase();
239 if !matches!(eff_lc.as_str(), "low" | "medium" | "high" | "xhigh") {
240 warnings.push(AdvisoryWarning::new(
241 "reasoning.reasoning_effort.invalid",
242 "reasoning.reasoning_effort",
243 "expected one of: low, medium, high, xhigh",
244 ));
245 }
246 }
247
248 if cfg
249 .reasoning
250 .executor_model
251 .to_lowercase()
252 .contains("gpt-5.2")
253 && let Some(n) = cfg.reasoning.max_completion_tokens
254 && n > GPT5_2_COMPLETION_TOKENS_DOC_MAX
255 {
256 warnings.push(AdvisoryWarning::new(
257 "reasoning.max_completion_tokens.exceeds_doc",
258 "reasoning.max_completion_tokens",
259 format!(
260 "max_completion_tokens={n} exceeds documented GPT-5.2 ceiling {GPT5_2_COMPLETION_TOKENS_DOC_MAX}; request may be rejected or truncate unexpectedly (warn-only; not clamped)."
261 ),
262 ));
263 }
264
265 if let Some(n) = cfg.reasoning.max_input_tokens
266 && n > 250_000
267 {
268 warnings.push(AdvisoryWarning::new(
269 "reasoning.max_input_tokens.suspicious",
270 "reasoning.max_input_tokens",
271 format!(
272 "max_input_tokens={n} exceeds the tool's default prompt cap (250000); ensure executor model supports this context size (warn-only)."
273 ),
274 ));
275 }
276
277 if !(0.0..=1.0).contains(&cfg.orchestrator.compaction_threshold) {
279 warnings.push(AdvisoryWarning::new(
280 "orchestrator.compaction_threshold.out_of_range",
281 "orchestrator.compaction_threshold",
282 "expected a value between 0.0 and 1.0",
283 ));
284 }
285
286 validate_command_entries(
287 &cfg.orchestrator.commands.allow,
288 "orchestrator.commands.allow",
289 &mut warnings,
290 );
291 validate_command_entries(
292 &cfg.orchestrator.commands.deny,
293 "orchestrator.commands.deny",
294 &mut warnings,
295 );
296 validate_command_overlap(cfg, &mut warnings);
297 validate_agent_entries(
298 &cfg.orchestrator.agents.allow,
299 "orchestrator.agents.allow",
300 &mut warnings,
301 );
302 validate_agent_entries(
303 &cfg.orchestrator.agents.deny,
304 "orchestrator.agents.deny",
305 &mut warnings,
306 );
307 validate_agent_overlap(cfg, &mut warnings);
308
309 if cfg.web_retrieval.default_search_results > cfg.web_retrieval.max_search_results {
311 warnings.push(AdvisoryWarning::new(
312 "web_retrieval.default_exceeds_max",
313 "web_retrieval.default_search_results",
314 "default_search_results exceeds max_search_results",
315 ));
316 }
317
318 if cfg.web_retrieval.summarizer.model.trim().is_empty() {
320 warnings.push(AdvisoryWarning::new(
321 "web_retrieval.summarizer.model.empty",
322 "web_retrieval.summarizer.model",
323 "value is empty",
324 ));
325 }
326
327 if cfg.cli_tools.max_depth == 0 {
329 warnings.push(AdvisoryWarning::new(
330 "cli_tools.max_depth.zero",
331 "cli_tools.max_depth",
332 "max_depth is 0, directory listing may be limited",
333 ));
334 }
335
336 validate_low_nonzero_timeout(
337 cfg.cli_tools.just_execute_timeout_secs,
338 5,
339 "cli_tools.just_execute_timeout_secs",
340 "cli_tools.just_execute_timeout_secs.suspicious",
341 &mut warnings,
342 );
343 validate_low_nonzero_timeout(
344 cfg.cli_tools.just_search_timeout_secs,
345 2,
346 "cli_tools.just_search_timeout_secs",
347 "cli_tools.just_search_timeout_secs.suspicious",
348 &mut warnings,
349 );
350 validate_low_nonzero_timeout(
351 cfg.services.linear.connect_timeout_secs,
352 1,
353 "services.linear.connect_timeout_secs",
354 "services.linear.connect_timeout_secs.suspicious",
355 &mut warnings,
356 );
357 validate_low_nonzero_timeout(
358 cfg.services.linear.request_timeout_secs,
359 5,
360 "services.linear.request_timeout_secs",
361 "services.linear.request_timeout_secs.suspicious",
362 &mut warnings,
363 );
364 validate_low_nonzero_timeout(
365 cfg.services.github.total_timeout_secs,
366 5,
367 "services.github.total_timeout_secs",
368 "services.github.total_timeout_secs.suspicious",
369 &mut warnings,
370 );
371 validate_low_nonzero_timeout(
372 cfg.review.run_timeout_secs,
373 30,
374 "review.run_timeout_secs",
375 "review.run_timeout_secs.suspicious",
376 &mut warnings,
377 );
378 validate_low_nonzero_timeout(
379 cfg.thoughts.add_reference_timeout_secs,
380 5,
381 "thoughts.add_reference_timeout_secs",
382 "thoughts.add_reference_timeout_secs.suspicious",
383 &mut warnings,
384 );
385
386 warnings
387}
388
389fn validate_low_nonzero_timeout(
390 value: u64,
391 minimum_recommended: u64,
392 path: &'static str,
393 code: &'static str,
394 warnings: &mut Vec<AdvisoryWarning>,
395) {
396 if value != 0 && value < minimum_recommended {
397 warnings.push(AdvisoryWarning::new(
398 code,
399 path,
400 format!(
401 "value {value}s is very low; {minimum_recommended}s or higher is usually safer, or use 0 to disable"
402 ),
403 ));
404 }
405}
406
407fn validate_command_entries(
408 entries: &[String],
409 path: &'static str,
410 warnings: &mut Vec<AdvisoryWarning>,
411) {
412 let mut seen = BTreeSet::new();
413 let mut duplicates = BTreeSet::new();
414
415 for entry in entries {
416 let trimmed = entry.trim();
417
418 if trimmed.is_empty() {
419 warnings.push(AdvisoryWarning::new(
420 if path.ends_with("allow") {
421 "orchestrator.commands.allow.empty_entry"
422 } else {
423 "orchestrator.commands.deny.empty_entry"
424 },
425 path,
426 format!("entry {entry:?} becomes empty after trimming"),
427 ));
428 continue;
429 }
430
431 if trimmed != entry {
432 warnings.push(AdvisoryWarning::new(
433 if path.ends_with("allow") {
434 "orchestrator.commands.allow.trimmed_entry"
435 } else {
436 "orchestrator.commands.deny.trimmed_entry"
437 },
438 path,
439 format!(
440 "entry {entry:?} has surrounding whitespace; effective value is {trimmed:?}"
441 ),
442 ));
443 }
444
445 if !seen.insert(trimmed.to_string()) {
446 duplicates.insert(trimmed.to_string());
447 }
448 }
449
450 if !duplicates.is_empty() {
451 let duplicates = duplicates.into_iter().collect::<Vec<_>>().join(", ");
452 warnings.push(AdvisoryWarning::new(
453 if path.ends_with("allow") {
454 "orchestrator.commands.allow.duplicate"
455 } else {
456 "orchestrator.commands.deny.duplicate"
457 },
458 path,
459 format!("duplicate command entries after trimming: {duplicates}"),
460 ));
461 }
462}
463
464fn validate_command_overlap(cfg: &AgenticConfig, warnings: &mut Vec<AdvisoryWarning>) {
465 let allow = cfg
466 .orchestrator
467 .commands
468 .allow
469 .iter()
470 .map(|entry| entry.trim())
471 .filter(|entry| !entry.is_empty())
472 .map(str::to_string)
473 .collect::<BTreeSet<_>>();
474 let deny = cfg
475 .orchestrator
476 .commands
477 .deny
478 .iter()
479 .map(|entry| entry.trim())
480 .filter(|entry| !entry.is_empty())
481 .map(str::to_string)
482 .collect::<BTreeSet<_>>();
483
484 let overlap = allow.intersection(&deny).cloned().collect::<Vec<_>>();
485 if overlap.is_empty() {
486 return;
487 }
488
489 warnings.push(AdvisoryWarning::new(
490 "orchestrator.commands.overlap",
491 "orchestrator.commands",
492 format!(
493 "commands appear in both allow and deny: {}. deny wins at runtime",
494 overlap.join(", ")
495 ),
496 ));
497}
498
499fn validate_agent_entries(
500 entries: &[String],
501 path: &'static str,
502 warnings: &mut Vec<AdvisoryWarning>,
503) {
504 let mut seen = BTreeSet::new();
505 let mut duplicates = BTreeSet::new();
506
507 for entry in entries {
508 let trimmed = entry.trim();
509
510 if trimmed.is_empty() {
511 warnings.push(AdvisoryWarning::new(
512 if path.ends_with("allow") {
513 "orchestrator.agents.allow.empty_entry"
514 } else {
515 "orchestrator.agents.deny.empty_entry"
516 },
517 path,
518 format!("entry {entry:?} becomes empty after trimming"),
519 ));
520 continue;
521 }
522
523 if trimmed != entry {
524 warnings.push(AdvisoryWarning::new(
525 if path.ends_with("allow") {
526 "orchestrator.agents.allow.trimmed_entry"
527 } else {
528 "orchestrator.agents.deny.trimmed_entry"
529 },
530 path,
531 format!(
532 "entry {entry:?} has surrounding whitespace; effective value is {trimmed:?}"
533 ),
534 ));
535 }
536
537 if !seen.insert(trimmed.to_string()) {
538 duplicates.insert(trimmed.to_string());
539 }
540 }
541
542 if !duplicates.is_empty() {
543 let duplicates = duplicates.into_iter().collect::<Vec<_>>().join(", ");
544 warnings.push(AdvisoryWarning::new(
545 if path.ends_with("allow") {
546 "orchestrator.agents.allow.duplicate"
547 } else {
548 "orchestrator.agents.deny.duplicate"
549 },
550 path,
551 format!("duplicate agent entries after trimming: {duplicates}"),
552 ));
553 }
554}
555
556fn validate_agent_overlap(cfg: &AgenticConfig, warnings: &mut Vec<AdvisoryWarning>) {
557 let allow = cfg
558 .orchestrator
559 .agents
560 .allow
561 .iter()
562 .map(|entry| entry.trim())
563 .filter(|entry| !entry.is_empty())
564 .map(str::to_string)
565 .collect::<BTreeSet<_>>();
566 let deny = cfg
567 .orchestrator
568 .agents
569 .deny
570 .iter()
571 .map(|entry| entry.trim())
572 .filter(|entry| !entry.is_empty())
573 .map(str::to_string)
574 .collect::<BTreeSet<_>>();
575
576 let overlap = allow.intersection(&deny).cloned().collect::<Vec<_>>();
577 if overlap.is_empty() {
578 return;
579 }
580
581 warnings.push(AdvisoryWarning::new(
582 "orchestrator.agents.overlap",
583 "orchestrator.agents",
584 format!(
585 "agents appear in both allow and deny: {}. deny wins at runtime",
586 overlap.join(", ")
587 ),
588 ));
589}
590
591fn validate_url(
592 url: &str,
593 path: &'static str,
594 code: &'static str,
595 warnings: &mut Vec<AdvisoryWarning>,
596) {
597 if !url.starts_with("http://") && !url.starts_with("https://") {
598 warnings.push(AdvisoryWarning {
599 code,
600 path,
601 message: format!("Expected an http(s) URL, got: '{url}'"),
602 });
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 #[test]
611 fn test_default_config_has_no_warnings() {
612 let config = AgenticConfig::default();
613 let warnings = validate(&config);
614 assert!(
615 warnings.is_empty(),
616 "Default config should have no warnings: {warnings:?}"
617 );
618 }
619
620 #[test]
621 fn test_invalid_anthropic_url_warns() {
622 let mut config = AgenticConfig::default();
623 config.services.anthropic.base_url = "not-a-url".into();
624
625 let warnings = validate(&config);
626 assert_eq!(warnings.len(), 1);
627 assert_eq!(warnings[0].code, "services.anthropic.base_url.invalid");
628 }
629
630 #[test]
631 fn test_invalid_linear_and_github_urls_warn() {
632 let mut config = AgenticConfig::default();
633 config.services.linear.base_url = "linear".into();
634 config.services.github.base_url = "github".into();
635
636 let warnings = validate(&config);
637 assert!(
638 warnings
639 .iter()
640 .any(|w| w.code == "services.linear.base_url.invalid")
641 );
642 assert!(
643 warnings
644 .iter()
645 .any(|w| w.code == "services.github.base_url.invalid")
646 );
647 }
648
649 #[test]
650 fn test_invalid_log_level_warns() {
651 let mut config = AgenticConfig::default();
652 config.logging.level = "verbose".into();
653
654 let warnings = validate(&config);
655 assert!(warnings.iter().any(|w| w.code == "logging.level.invalid"));
656 }
657
658 #[test]
659 fn test_warning_display() {
660 let warning = AdvisoryWarning {
661 code: "test.code",
662 path: "test.path",
663 message: "Test message".into(),
664 };
665 let display = format!("{warning}");
666 assert_eq!(display, "[test.code] test.path: Test message");
667 }
668
669 #[test]
670 fn test_empty_subagent_model_warns() {
671 let mut config = AgenticConfig::default();
672 config.subagents.locator_model = String::new();
673
674 let warnings = validate(&config);
675 assert!(
676 warnings
677 .iter()
678 .any(|w| w.code == "subagents.locator_model.empty")
679 );
680 }
681
682 #[test]
683 fn test_reasoning_optimizer_model_format_warns() {
684 let mut config = AgenticConfig::default();
685 config.reasoning.optimizer_model = "claude-sonnet-4.6".into(); let warnings = validate(&config);
688 assert!(
689 warnings
690 .iter()
691 .any(|w| w.code == "reasoning.optimizer_model.format")
692 );
693 }
694
695 #[test]
696 fn test_reasoning_executor_model_suspicious_warns() {
697 let mut config = AgenticConfig::default();
698 config.reasoning.executor_model = "anthropic/claude-sonnet-4.6".into(); let warnings = validate(&config);
701 assert!(
702 warnings
703 .iter()
704 .any(|w| w.code == "reasoning.executor_model.suspicious")
705 );
706 }
707
708 #[test]
709 fn test_reasoning_effort_invalid_warns() {
710 let mut config = AgenticConfig::default();
711 config.reasoning.reasoning_effort = Some("extreme".into()); let warnings = validate(&config);
714 assert!(
715 warnings
716 .iter()
717 .any(|w| w.code == "reasoning.reasoning_effort.invalid")
718 );
719 }
720
721 #[test]
722 fn test_reasoning_effort_valid_no_warning() {
723 let mut config = AgenticConfig::default();
724 config.reasoning.reasoning_effort = Some("high".into());
725
726 let warnings = validate(&config);
727 assert!(
728 !warnings
729 .iter()
730 .any(|w| w.code == "reasoning.reasoning_effort.invalid")
731 );
732 }
733
734 #[test]
735 fn test_orchestrator_compaction_threshold_out_of_range() {
736 let mut config = AgenticConfig::default();
737 config.orchestrator.compaction_threshold = 1.5; let warnings = validate(&config);
740 assert!(
741 warnings
742 .iter()
743 .any(|w| w.code == "orchestrator.compaction_threshold.out_of_range")
744 );
745 }
746
747 #[test]
748 fn test_orchestrator_allow_empty_entry_warns() {
749 let mut config = AgenticConfig::default();
750 config.orchestrator.commands.allow = vec![" ".into()];
751
752 let warnings = validate(&config);
753 let warning = warnings
754 .iter()
755 .find(|w| w.code == "orchestrator.commands.allow.empty_entry")
756 .expect("empty allow warning expected");
757
758 assert_eq!(warning.path, "orchestrator.commands.allow");
759 assert!(warning.message.contains("becomes empty after trimming"));
760 }
761
762 #[test]
763 fn test_orchestrator_deny_trimmed_entry_warns() {
764 let mut config = AgenticConfig::default();
765 config.orchestrator.commands.deny = vec![" plan ".into()];
766
767 let warnings = validate(&config);
768 let warning = warnings
769 .iter()
770 .find(|w| w.code == "orchestrator.commands.deny.trimmed_entry")
771 .expect("trimmed deny warning expected");
772
773 assert_eq!(warning.path, "orchestrator.commands.deny");
774 assert!(warning.message.contains("effective value is \"plan\""));
775 }
776
777 #[test]
778 fn test_orchestrator_allow_duplicate_warns() {
779 let mut config = AgenticConfig::default();
780 config.orchestrator.commands.allow = vec!["plan".into(), " plan ".into()];
781
782 let warnings = validate(&config);
783 let warning = warnings
784 .iter()
785 .find(|w| w.code == "orchestrator.commands.allow.duplicate")
786 .expect("duplicate allow warning expected");
787
788 assert_eq!(warning.path, "orchestrator.commands.allow");
789 assert!(warning.message.contains("plan"));
790 }
791
792 #[test]
793 fn test_orchestrator_command_overlap_warns_with_deny_wins_message() {
794 let mut config = AgenticConfig::default();
795 config.orchestrator.commands.allow = vec!["plan".into()];
796 config.orchestrator.commands.deny = vec![" plan ".into()];
797
798 let warnings = validate(&config);
799 let warning = warnings
800 .iter()
801 .find(|w| w.code == "orchestrator.commands.overlap")
802 .expect("overlap warning expected");
803
804 assert_eq!(warning.path, "orchestrator.commands");
805 assert!(warning.message.contains("plan"));
806 assert!(warning.message.contains("deny wins at runtime"));
807 }
808
809 #[test]
810 fn test_orchestrator_agents_allow_empty_entry_warns() {
811 let mut config = AgenticConfig::default();
812 config.orchestrator.agents.allow = vec![" ".into()];
813
814 let warnings = validate(&config);
815 let warning = warnings
816 .iter()
817 .find(|w| w.code == "orchestrator.agents.allow.empty_entry")
818 .expect("empty allow warning expected");
819
820 assert_eq!(warning.path, "orchestrator.agents.allow");
821 assert!(warning.message.contains("becomes empty after trimming"));
822 }
823
824 #[test]
825 fn test_orchestrator_agents_deny_trimmed_entry_warns() {
826 let mut config = AgenticConfig::default();
827 config.orchestrator.agents.deny = vec![" Bash ".into()];
828
829 let warnings = validate(&config);
830 let warning = warnings
831 .iter()
832 .find(|w| w.code == "orchestrator.agents.deny.trimmed_entry")
833 .expect("trimmed deny warning expected");
834
835 assert_eq!(warning.path, "orchestrator.agents.deny");
836 assert!(warning.message.contains("effective value is \"Bash\""));
837 }
838
839 #[test]
840 fn test_orchestrator_agents_allow_duplicate_warns() {
841 let mut config = AgenticConfig::default();
842 config.orchestrator.agents.allow = vec!["Bash".into(), " Bash ".into()];
843
844 let warnings = validate(&config);
845 let warning = warnings
846 .iter()
847 .find(|w| w.code == "orchestrator.agents.allow.duplicate")
848 .expect("duplicate allow warning expected");
849
850 assert_eq!(warning.path, "orchestrator.agents.allow");
851 assert!(warning.message.contains("Bash"));
852 }
853
854 #[test]
855 fn test_orchestrator_agent_overlap_warns_with_deny_wins_message() {
856 let mut config = AgenticConfig::default();
857 config.orchestrator.agents.allow = vec!["Bash".into()];
858 config.orchestrator.agents.deny = vec![" Bash ".into()];
859
860 let warnings = validate(&config);
861 let warning = warnings
862 .iter()
863 .find(|w| w.code == "orchestrator.agents.overlap")
864 .expect("overlap warning expected");
865
866 assert_eq!(warning.path, "orchestrator.agents");
867 assert!(warning.message.contains("Bash"));
868 assert!(warning.message.contains("deny wins at runtime"));
869 }
870
871 #[test]
872 fn test_web_retrieval_default_exceeds_max() {
873 let mut config = AgenticConfig::default();
874 config.web_retrieval.default_search_results = 100;
875 config.web_retrieval.max_search_results = 20;
876
877 let warnings = validate(&config);
878 assert!(
879 warnings
880 .iter()
881 .any(|w| w.code == "web_retrieval.default_exceeds_max")
882 );
883 }
884
885 #[test]
886 fn test_detect_deprecated_thoughts_toml() {
887 let toml_val: toml::Value = toml::from_str(
888 r"
889[thoughts]
890mount_dirs = {}
891",
892 )
893 .unwrap();
894
895 let warnings = detect_deprecated_keys_toml(&toml_val);
896 assert!(
897 warnings
898 .iter()
899 .any(|w| w.code == "config.deprecated.thoughts.mount_dirs")
900 );
901 }
902
903 #[test]
904 fn test_supported_thoughts_section_is_not_deprecated() {
905 let toml_val: toml::Value = toml::from_str(
906 r"
907[thoughts]
908add_reference_timeout_secs = 600
909",
910 )
911 .unwrap();
912
913 let warnings = detect_deprecated_keys_toml(&toml_val);
914 assert!(warnings.is_empty());
915 }
916
917 #[test]
918 fn test_detect_deprecated_reasoning_token_limit_toml_is_silent() {
919 let toml_val: toml::Value = toml::from_str(
920 r"
921[reasoning]
922token_limit = 12345
923",
924 )
925 .unwrap();
926
927 let warnings = detect_deprecated_keys_toml(&toml_val);
928 assert!(warnings.is_empty());
929 }
930
931 #[test]
932 fn test_reasoning_max_completion_tokens_above_doc_max_warns() {
933 let mut config = AgenticConfig::default();
934 config.reasoning.max_completion_tokens = Some(128_001);
935
936 let warnings = validate(&config);
937 assert!(
938 warnings
939 .iter()
940 .any(|w| w.code == "reasoning.max_completion_tokens.exceeds_doc")
941 );
942 }
943
944 #[test]
945 fn test_reasoning_max_input_tokens_above_default_cap_warns() {
946 let mut config = AgenticConfig::default();
947 config.reasoning.max_input_tokens = Some(250_001);
948
949 let warnings = validate(&config);
950 assert!(
951 warnings
952 .iter()
953 .any(|w| w.code == "reasoning.max_input_tokens.suspicious")
954 );
955 }
956
957 #[test]
958 fn test_low_nonzero_timeout_values_warn() {
959 let mut config = AgenticConfig::default();
960 config.subagents.runtime_timeout_secs = 1;
961 config.cli_tools.just_execute_timeout_secs = 1;
962 config.cli_tools.just_search_timeout_secs = 1;
963 config.services.linear.request_timeout_secs = 1;
964 config.services.github.total_timeout_secs = 1;
965 config.review.run_timeout_secs = 1;
966 config.thoughts.add_reference_timeout_secs = 1;
967
968 let warnings = validate(&config);
969 for code in [
970 "subagents.runtime_timeout_secs.suspicious",
971 "cli_tools.just_execute_timeout_secs.suspicious",
972 "cli_tools.just_search_timeout_secs.suspicious",
973 "services.linear.request_timeout_secs.suspicious",
974 "services.github.total_timeout_secs.suspicious",
975 "review.run_timeout_secs.suspicious",
976 "thoughts.add_reference_timeout_secs.suspicious",
977 ] {
978 assert!(warnings.iter().any(|w| w.code == code), "missing {code}");
979 }
980 }
981}