Skip to main content

agentic_config/
validation.rs

1//! Advisory validation for `AgenticConfig`.
2//!
3//! Validation is advisory - it produces warnings but doesn't prevent
4//! the config from being used. This allows tools to work with imperfect
5//! configs while still surfacing potential issues.
6
7use crate::types::AgenticConfig;
8use std::collections::BTreeSet;
9
10/// An advisory warning about a configuration issue.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct AdvisoryWarning {
13    /// Machine-readable warning code.
14    pub code: &'static str,
15
16    /// Human-readable warning message.
17    pub message: String,
18
19    /// Config path to the problematic field.
20    pub path: &'static str,
21}
22
23impl AdvisoryWarning {
24    /// Create a new advisory warning.
25    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
40/// Detect deprecated config keys in raw TOML before deserialization.
41///
42/// This inspects the merged TOML Value to detect keys that are no longer
43/// used and emit advisory warnings. The config will still load successfully,
44/// but users will be notified that they should update their configuration.
45pub 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
70// TODO(2): This list must be kept in sync with AgenticConfig fields in types.rs.
71// Consider generating dynamically via schemars introspection, or adding a compile-time
72// test that extracts field names from AgenticConfig's JsonSchema and verifies they
73// match this list. Currently requires manual updates when adding new config sections.
74// See research/pr127-group7-type-safety-external-type-dependencies.md for analysis.
75
76/// Known top-level keys for unknown key detection.
77/// Unknown keys at root level produce advisory warnings.
78const 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
93/// Detect unknown top-level keys in raw TOML before deserialization.
94///
95/// Unknown keys at the root are ignored by serde, so we emit an advisory warning
96/// to help users catch typos like `[servics]` instead of `[services]`.
97pub 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
116/// Validate a configuration and return advisory warnings.
117///
118/// This does NOT fail on issues - it only collects warnings that
119/// callers can choose to display or log.
120pub fn validate(cfg: &AgenticConfig) -> Vec<AdvisoryWarning> {
121    let mut warnings = vec![];
122
123    // Validate service URLs
124    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    // Validate log level
151    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    // Validate subagents model values are not empty
165    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    // Validate reasoning model values are not empty
188    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    // Validate OpenRouter format for reasoning models (should contain '/')
204    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    // Validate reasoning_effort enum
237    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    // Validate orchestrator.compaction_threshold is in (0,1]
278    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    // Validate web_retrieval: default_search_results <= max_search_results
310    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    // Validate web_retrieval.summarizer.model is not empty
319    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    // Validate cli_tools.max_depth is reasonable
328    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(); // Missing provider prefix
686
687        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(); // Not GPT-5
699
700        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()); // Invalid value
712
713        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; // Invalid
738
739        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}