Skip to main content

vtcode_core/config/
validation.rs

1/// Configuration validation module
2///
3/// Provides comprehensive validation of VTCodeConfig at startup to catch
4/// common configuration errors early and provide helpful error messages.
5use anyhow::{Result, bail};
6use std::path::Path;
7
8use crate::config::FullAutoConfig;
9use crate::config::loader::VTCodeConfig;
10use crate::config::models::{
11    catalog_provider_keys, model_catalog_entry, supported_models_for_provider,
12};
13
14/// Result of a configuration validation check
15#[derive(Debug, Clone)]
16pub struct ValidationResult {
17    pub is_valid: bool,
18    pub errors: Vec<String>,
19    pub warnings: Vec<String>,
20}
21
22impl ValidationResult {
23    pub fn new() -> Self {
24        Self {
25            is_valid: true,
26            errors: Vec::new(),
27            warnings: Vec::new(),
28        }
29    }
30
31    pub fn add_error(&mut self, error: String) {
32        self.is_valid = false;
33        self.errors.push(error);
34    }
35
36    pub fn add_warning(&mut self, warning: String) {
37        self.warnings.push(warning);
38    }
39
40    pub fn to_result(self) -> Result<()> {
41        if !self.is_valid {
42            let error_msg = self
43                .errors
44                .iter()
45                .enumerate()
46                .map(|(i, e)| format!("  {}. {}", i + 1, e))
47                .collect::<Vec<_>>()
48                .join("\n");
49
50            bail!("Configuration validation failed:\n{}", error_msg);
51        }
52
53        // Print warnings if any
54        for warning in &self.warnings {
55            tracing::warn!(warning = %warning, "configuration warning");
56        }
57
58        Ok(())
59    }
60}
61
62impl Default for ValidationResult {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68/// Validate that the configured model exists in the generated model catalog.
69pub fn validate_model_exists(provider: &str, model: &str) -> Result<()> {
70    if provider.eq_ignore_ascii_case("copilot") {
71        if model.trim().is_empty() {
72            bail!("Model must not be empty for provider 'copilot'");
73        }
74        return Ok(());
75    }
76
77    if let Some(models) = supported_models_for_provider(provider) {
78        if !models.contains(&model) {
79            bail!(
80                "Model '{}' not found for provider '{}'. Available models: {}",
81                model,
82                provider,
83                models.join(", ")
84            );
85        }
86        Ok(())
87    } else {
88        bail!(
89            "Provider '{}' not recognized. Available providers: {}",
90            provider,
91            catalog_provider_keys().join(", ")
92        );
93    }
94}
95
96/// Get context window size for a model from the catalog.
97fn catalog_model_context_window(provider: &str, model: &str) -> Result<Option<usize>> {
98    Ok(model_catalog_entry(provider, model)
99        .map(|entry| entry.context_window)
100        .filter(|context_window| *context_window > 0))
101}
102
103/// Resolve the effective context window size for a model.
104pub fn effective_model_context_window(provider: &str, model: &str) -> Result<Option<usize>> {
105    if provider.eq_ignore_ascii_case("anthropic") {
106        return Ok(Some(
107            crate::llm::providers::anthropic::capabilities::effective_context_size(model),
108        ));
109    }
110
111    catalog_model_context_window(provider, model)
112}
113
114/// Validate full VTCodeConfig at startup
115pub fn validate_config(config: &VTCodeConfig, workspace: &Path) -> Result<ValidationResult> {
116    let mut result = ValidationResult::new();
117
118    // Validate agent model exists
119    validate_agent_model(
120        &config.agent.provider,
121        &config.agent.default_model,
122        &mut result,
123    );
124
125    // Validate context window if specified
126    validate_context_window(config, &mut result);
127
128    // Validate checkpointing directory if enabled
129    if config.agent.checkpointing.enabled
130        && let Some(storage_dir) = &config.agent.checkpointing.storage_dir
131    {
132        validate_checkpointing_dir(storage_dir, workspace, &mut result);
133    }
134
135    // Validate automation configuration
136    if config.automation.full_auto.enabled {
137        validate_full_auto_config(&config.automation.full_auto, workspace, &mut result);
138    }
139
140    Ok(result)
141}
142
143fn validate_agent_model(provider: &str, model: &str, result: &mut ValidationResult) {
144    if provider.eq_ignore_ascii_case("codex") {
145        return;
146    }
147
148    match validate_model_exists(provider, model) {
149        Ok(_) => {
150            // Also check context window
151            if let Ok(Some(context_size)) = effective_model_context_window(provider, model) {
152                let display_size = if context_size >= 1_000_000 {
153                    format!("{}M", context_size / 1_000_000)
154                } else if context_size >= 1_000 {
155                    format!("{}K", context_size / 1_000)
156                } else {
157                    context_size.to_string()
158                };
159                tracing::debug!("Agent model '{}' context window: {}", model, display_size);
160            }
161        }
162        Err(e) => {
163            result.add_error(format!("Agent model configuration invalid: {}", e));
164        }
165    }
166}
167
168fn validate_context_window(config: &VTCodeConfig, result: &mut ValidationResult) {
169    if config.agent.provider.eq_ignore_ascii_case("codex") {
170        return;
171    }
172
173    let context_window = config.context.max_context_tokens;
174    if context_window > 0
175        && let Ok(Some(model_context)) =
176            effective_model_context_window(&config.agent.provider, &config.agent.default_model)
177        && context_window > model_context
178    {
179        result.add_warning(format!(
180            "Configured context window {} exceeds model capacity {}. \
181             The model will use its maximum context size.",
182            context_window, model_context
183        ));
184    }
185}
186
187fn validate_checkpointing_dir(storage_dir: &str, workspace: &Path, result: &mut ValidationResult) {
188    let path = if Path::new(storage_dir).is_absolute() {
189        std::path::PathBuf::from(storage_dir)
190    } else {
191        workspace.join(storage_dir)
192    };
193
194    // Check if parent directory exists
195    if let Some(parent) = path.parent()
196        && !parent.exists()
197    {
198        result.add_warning(format!(
199            "Checkpointing storage directory parent '{}' does not exist. \
200             It will be created when checkpointing is first used.",
201            parent.display()
202        ));
203    }
204}
205
206fn validate_full_auto_config(
207    full_auto_cfg: &FullAutoConfig,
208    workspace: &Path,
209    result: &mut ValidationResult,
210) {
211    if full_auto_cfg.require_profile_ack {
212        if let Some(profile_path) = &full_auto_cfg.profile_path {
213            let resolved = if Path::new(profile_path).is_absolute() {
214                std::path::PathBuf::from(profile_path)
215            } else {
216                workspace.join(profile_path)
217            };
218
219            if !resolved.exists() {
220                result.add_error(format!(
221                    "Full-auto profile '{}' required but not found. \
222                     Create the acknowledgement file before using --full-auto.",
223                    resolved.display()
224                ));
225            }
226        } else {
227            result.add_error(
228                "Full-auto profile_path is required when require_profile_ack = true".to_owned(),
229            );
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn generated_catalog_contains_providers() {
240        let providers = catalog_provider_keys();
241        assert!(!providers.is_empty(), "Should expose generated providers");
242        assert!(
243            providers.contains(&"gemini") || providers.contains(&"openai"),
244            "Should have at least one major provider"
245        );
246    }
247
248    #[test]
249    fn validates_known_model() {
250        let result = validate_model_exists("google", "gemini-3-flash-preview");
251        assert!(
252            result.is_ok(),
253            "Should validate gemini-3-flash-preview for google provider"
254        );
255    }
256
257    #[test]
258    fn rejects_unknown_model() {
259        let result = validate_model_exists("google", "model-does-not-exist");
260        assert!(result.is_err(), "Should reject unknown model");
261    }
262
263    #[test]
264    fn accepts_live_copilot_model_id() {
265        let result = validate_model_exists("copilot", "gpt-5.3-codex");
266        assert!(result.is_ok(), "Should accept live Copilot model ids");
267    }
268
269    #[test]
270    fn validate_config_skips_codex_model_catalog_checks() {
271        let mut config = VTCodeConfig::default();
272        config.agent.provider = "codex".to_string();
273        config.agent.default_model = "upstream-managed-model".to_string();
274
275        let result =
276            validate_config(&config, Path::new(".")).expect("config validation should run");
277
278        assert!(result.errors.is_empty());
279    }
280
281    #[test]
282    fn rejects_unknown_provider() {
283        let result = validate_model_exists("provider-does-not-exist", "some-model");
284        assert!(result.is_err(), "Should reject unknown provider");
285    }
286
287    #[test]
288    fn gets_context_window() {
289        let result = effective_model_context_window("google", "gemini-3-flash-preview");
290        assert!(result.is_ok(), "Should get context window");
291
292        let context = result.unwrap();
293        assert!(
294            context.is_some() && context.unwrap() > 0,
295            "Should have positive context window"
296        );
297    }
298
299    #[test]
300    fn anthropic_46_uses_effective_context_window() {
301        let result = effective_model_context_window("anthropic", "claude-sonnet-4-6");
302        assert_eq!(result.unwrap(), Some(1_000_000));
303    }
304
305    #[test]
306    fn validation_result_collects_errors() {
307        let mut result = ValidationResult::new();
308        assert!(result.is_valid);
309
310        result.add_error("Error 1".to_owned());
311        assert!(!result.is_valid);
312
313        result.add_error("Error 2".to_owned());
314        assert_eq!(result.errors.len(), 2);
315    }
316
317    #[test]
318    fn validation_result_collects_warnings() {
319        let mut result = ValidationResult::new();
320        result.add_warning("Warning 1".to_owned());
321        assert_eq!(result.warnings.len(), 1);
322        assert!(result.is_valid); // Warnings don't invalidate
323    }
324}