vtcode_core/config/
validation.rs1use 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#[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 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
68pub 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
96fn 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
103pub 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
114pub fn validate_config(config: &VTCodeConfig, workspace: &Path) -> Result<ValidationResult> {
116 let mut result = ValidationResult::new();
117
118 validate_agent_model(
120 &config.agent.provider,
121 &config.agent.default_model,
122 &mut result,
123 );
124
125 validate_context_window(config, &mut result);
127
128 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 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 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 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); }
324}