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;
8
9/// An advisory warning about a configuration issue.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct AdvisoryWarning {
12    /// Machine-readable warning code.
13    pub code: &'static str,
14
15    /// Human-readable warning message.
16    pub message: String,
17
18    /// Config path to the problematic field.
19    pub path: &'static str,
20}
21
22impl AdvisoryWarning {
23    /// Create a new advisory warning.
24    pub fn new(code: &'static str, path: &'static str, message: impl Into<String>) -> Self {
25        Self {
26            code,
27            path,
28            message: message.into(),
29        }
30    }
31}
32
33impl std::fmt::Display for AdvisoryWarning {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        write!(f, "[{}] {}: {}", self.code, self.path, self.message)
36    }
37}
38
39/// Detect deprecated config keys in raw TOML before deserialization.
40///
41/// This inspects the merged TOML Value to detect keys that are no longer
42/// used and emit advisory warnings. The config will still load successfully,
43/// but users will be notified that they should update their configuration.
44pub fn detect_deprecated_keys_toml(v: &toml::Value) -> Vec<AdvisoryWarning> {
45    let mut warnings = Vec::new();
46
47    // Warn if old "thoughts" section exists (removed in this version)
48    if let Some(tbl) = v.as_table() {
49        if tbl.contains_key("thoughts") {
50            warnings.push(AdvisoryWarning::new(
51                "config.deprecated.thoughts",
52                "thoughts",
53                "The 'thoughts' section has been removed. thoughts-core now has its own config.",
54            ));
55        }
56        if tbl.contains_key("models") {
57            warnings.push(AdvisoryWarning::new(
58                "config.deprecated.models",
59                "models",
60                "The 'models' section has been replaced by 'subagents' and 'reasoning'.",
61            ));
62        }
63    }
64
65    warnings
66}
67
68// TODO(2): This list must be kept in sync with AgenticConfig fields in types.rs.
69// Consider generating dynamically via schemars introspection, or adding a compile-time
70// test that extracts field names from AgenticConfig's JsonSchema and verifies they
71// match this list. Currently requires manual updates when adding new config sections.
72// See research/pr127-group7-type-safety-external-type-dependencies.md for analysis.
73
74/// Known top-level keys for unknown key detection.
75/// Unknown keys at root level produce advisory warnings.
76const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
77    "$schema",
78    "subagents",
79    "reasoning",
80    "services",
81    "orchestrator",
82    "web_retrieval",
83    "cli_tools",
84    "logging",
85];
86
87/// Detect unknown top-level keys in raw TOML before deserialization.
88///
89/// Unknown keys at the root are ignored by serde, so we emit an advisory warning
90/// to help users catch typos like `[servics]` instead of `[services]`.
91pub fn detect_unknown_top_level_keys_toml(v: &toml::Value) -> Vec<AdvisoryWarning> {
92    let mut warnings = Vec::new();
93    let Some(tbl) = v.as_table() else {
94        return warnings;
95    };
96
97    for key in tbl.keys() {
98        if !KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str()) {
99            warnings.push(AdvisoryWarning::new(
100                "config.unknown_top_level_key",
101                "$",
102                format!("Unknown top-level key '{key}' will be ignored"),
103            ));
104        }
105    }
106
107    warnings
108}
109
110/// Validate a configuration and return advisory warnings.
111///
112/// This does NOT fail on issues - it only collects warnings that
113/// callers can choose to display or log.
114pub fn validate(cfg: &AgenticConfig) -> Vec<AdvisoryWarning> {
115    let mut warnings = vec![];
116
117    // Validate service URLs
118    validate_url(
119        &cfg.services.anthropic.base_url,
120        "services.anthropic.base_url",
121        "services.anthropic.base_url.invalid",
122        &mut warnings,
123    );
124
125    validate_url(
126        &cfg.services.exa.base_url,
127        "services.exa.base_url",
128        "services.exa.base_url.invalid",
129        &mut warnings,
130    );
131
132    // Validate log level
133    let valid_levels = ["trace", "debug", "info", "warn", "error"];
134    if !valid_levels.contains(&cfg.logging.level.to_lowercase().as_str()) {
135        warnings.push(AdvisoryWarning {
136            code: "logging.level.invalid",
137            path: "logging.level",
138            message: format!(
139                "Unknown log level '{}'. Expected one of: {}",
140                cfg.logging.level,
141                valid_levels.join(", ")
142            ),
143        });
144    }
145
146    // Validate subagents model values are not empty
147    if cfg.subagents.locator_model.trim().is_empty() {
148        warnings.push(AdvisoryWarning::new(
149            "subagents.locator_model.empty",
150            "subagents.locator_model",
151            "value is empty",
152        ));
153    }
154    if cfg.subagents.analyzer_model.trim().is_empty() {
155        warnings.push(AdvisoryWarning::new(
156            "subagents.analyzer_model.empty",
157            "subagents.analyzer_model",
158            "value is empty",
159        ));
160    }
161
162    // Validate reasoning model values are not empty
163    if cfg.reasoning.optimizer_model.trim().is_empty() {
164        warnings.push(AdvisoryWarning::new(
165            "reasoning.optimizer_model.empty",
166            "reasoning.optimizer_model",
167            "value is empty",
168        ));
169    }
170    if cfg.reasoning.executor_model.trim().is_empty() {
171        warnings.push(AdvisoryWarning::new(
172            "reasoning.executor_model.empty",
173            "reasoning.executor_model",
174            "value is empty",
175        ));
176    }
177
178    // Validate OpenRouter format for reasoning models (should contain '/')
179    if !cfg.reasoning.optimizer_model.trim().is_empty()
180        && !cfg.reasoning.optimizer_model.contains('/')
181    {
182        warnings.push(AdvisoryWarning::new(
183            "reasoning.optimizer_model.format",
184            "reasoning.optimizer_model",
185            "expected OpenRouter format like `anthropic/claude-sonnet-4.6`",
186        ));
187    }
188
189    if !cfg.reasoning.executor_model.trim().is_empty()
190        && !cfg.reasoning.executor_model.contains('/')
191    {
192        warnings.push(AdvisoryWarning::new(
193            "reasoning.executor_model.format",
194            "reasoning.executor_model",
195            "expected OpenRouter format like `openai/gpt-5.2`",
196        ));
197    } else if !cfg.reasoning.executor_model.trim().is_empty()
198        && !cfg
199            .reasoning
200            .executor_model
201            .to_lowercase()
202            .contains("gpt-5")
203    {
204        warnings.push(AdvisoryWarning::new(
205            "reasoning.executor_model.suspicious",
206            "reasoning.executor_model",
207            "executor_model does not look like a GPT-5 model; reasoning_effort may not work",
208        ));
209    }
210
211    // Validate reasoning_effort enum
212    if let Some(eff) = cfg.reasoning.reasoning_effort.as_deref() {
213        let eff_lc = eff.trim().to_lowercase();
214        if !matches!(eff_lc.as_str(), "low" | "medium" | "high" | "xhigh") {
215            warnings.push(AdvisoryWarning::new(
216                "reasoning.reasoning_effort.invalid",
217                "reasoning.reasoning_effort",
218                "expected one of: low, medium, high, xhigh",
219            ));
220        }
221    }
222
223    // Validate orchestrator.compaction_threshold is in (0,1]
224    if !(0.0..=1.0).contains(&cfg.orchestrator.compaction_threshold) {
225        warnings.push(AdvisoryWarning::new(
226            "orchestrator.compaction_threshold.out_of_range",
227            "orchestrator.compaction_threshold",
228            "expected a value between 0.0 and 1.0",
229        ));
230    }
231
232    // Validate web_retrieval: default_search_results <= max_search_results
233    if cfg.web_retrieval.default_search_results > cfg.web_retrieval.max_search_results {
234        warnings.push(AdvisoryWarning::new(
235            "web_retrieval.default_exceeds_max",
236            "web_retrieval.default_search_results",
237            "default_search_results exceeds max_search_results",
238        ));
239    }
240
241    // Validate web_retrieval.summarizer.model is not empty
242    if cfg.web_retrieval.summarizer.model.trim().is_empty() {
243        warnings.push(AdvisoryWarning::new(
244            "web_retrieval.summarizer.model.empty",
245            "web_retrieval.summarizer.model",
246            "value is empty",
247        ));
248    }
249
250    // Validate cli_tools.max_depth is reasonable
251    if cfg.cli_tools.max_depth == 0 {
252        warnings.push(AdvisoryWarning::new(
253            "cli_tools.max_depth.zero",
254            "cli_tools.max_depth",
255            "max_depth is 0, directory listing may be limited",
256        ));
257    }
258
259    warnings
260}
261
262fn validate_url(
263    url: &str,
264    path: &'static str,
265    code: &'static str,
266    warnings: &mut Vec<AdvisoryWarning>,
267) {
268    if !url.starts_with("http://") && !url.starts_with("https://") {
269        warnings.push(AdvisoryWarning {
270            code,
271            path,
272            message: format!("Expected an http(s) URL, got: '{url}'"),
273        });
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_default_config_has_no_warnings() {
283        let config = AgenticConfig::default();
284        let warnings = validate(&config);
285        assert!(
286            warnings.is_empty(),
287            "Default config should have no warnings: {warnings:?}"
288        );
289    }
290
291    #[test]
292    fn test_invalid_anthropic_url_warns() {
293        let mut config = AgenticConfig::default();
294        config.services.anthropic.base_url = "not-a-url".into();
295
296        let warnings = validate(&config);
297        assert_eq!(warnings.len(), 1);
298        assert_eq!(warnings[0].code, "services.anthropic.base_url.invalid");
299    }
300
301    #[test]
302    fn test_invalid_log_level_warns() {
303        let mut config = AgenticConfig::default();
304        config.logging.level = "verbose".into();
305
306        let warnings = validate(&config);
307        assert!(warnings.iter().any(|w| w.code == "logging.level.invalid"));
308    }
309
310    #[test]
311    fn test_warning_display() {
312        let warning = AdvisoryWarning {
313            code: "test.code",
314            path: "test.path",
315            message: "Test message".into(),
316        };
317        let display = format!("{warning}");
318        assert_eq!(display, "[test.code] test.path: Test message");
319    }
320
321    #[test]
322    fn test_empty_subagent_model_warns() {
323        let mut config = AgenticConfig::default();
324        config.subagents.locator_model = String::new();
325
326        let warnings = validate(&config);
327        assert!(
328            warnings
329                .iter()
330                .any(|w| w.code == "subagents.locator_model.empty")
331        );
332    }
333
334    #[test]
335    fn test_reasoning_optimizer_model_format_warns() {
336        let mut config = AgenticConfig::default();
337        config.reasoning.optimizer_model = "claude-sonnet-4.6".into(); // Missing provider prefix
338
339        let warnings = validate(&config);
340        assert!(
341            warnings
342                .iter()
343                .any(|w| w.code == "reasoning.optimizer_model.format")
344        );
345    }
346
347    #[test]
348    fn test_reasoning_executor_model_suspicious_warns() {
349        let mut config = AgenticConfig::default();
350        config.reasoning.executor_model = "anthropic/claude-sonnet-4.6".into(); // Not GPT-5
351
352        let warnings = validate(&config);
353        assert!(
354            warnings
355                .iter()
356                .any(|w| w.code == "reasoning.executor_model.suspicious")
357        );
358    }
359
360    #[test]
361    fn test_reasoning_effort_invalid_warns() {
362        let mut config = AgenticConfig::default();
363        config.reasoning.reasoning_effort = Some("extreme".into()); // Invalid value
364
365        let warnings = validate(&config);
366        assert!(
367            warnings
368                .iter()
369                .any(|w| w.code == "reasoning.reasoning_effort.invalid")
370        );
371    }
372
373    #[test]
374    fn test_reasoning_effort_valid_no_warning() {
375        let mut config = AgenticConfig::default();
376        config.reasoning.reasoning_effort = Some("high".into());
377
378        let warnings = validate(&config);
379        assert!(
380            !warnings
381                .iter()
382                .any(|w| w.code == "reasoning.reasoning_effort.invalid")
383        );
384    }
385
386    #[test]
387    fn test_orchestrator_compaction_threshold_out_of_range() {
388        let mut config = AgenticConfig::default();
389        config.orchestrator.compaction_threshold = 1.5; // Invalid
390
391        let warnings = validate(&config);
392        assert!(
393            warnings
394                .iter()
395                .any(|w| w.code == "orchestrator.compaction_threshold.out_of_range")
396        );
397    }
398
399    #[test]
400    fn test_web_retrieval_default_exceeds_max() {
401        let mut config = AgenticConfig::default();
402        config.web_retrieval.default_search_results = 100;
403        config.web_retrieval.max_search_results = 20;
404
405        let warnings = validate(&config);
406        assert!(
407            warnings
408                .iter()
409                .any(|w| w.code == "web_retrieval.default_exceeds_max")
410        );
411    }
412
413    #[test]
414    fn test_detect_deprecated_thoughts_toml() {
415        let toml_val: toml::Value = toml::from_str(
416            r"
417[thoughts]
418mount_dirs = {}
419",
420        )
421        .unwrap();
422
423        let warnings = detect_deprecated_keys_toml(&toml_val);
424        assert!(
425            warnings
426                .iter()
427                .any(|w| w.code == "config.deprecated.thoughts")
428        );
429    }
430}