Skip to main content

opencode_provider_manager/config_core/
validate.rs

1//! Config validation against the OpenCode JSON schema and custom rules.
2
3use super::error::{ConfigError, Result};
4use super::schema::OpenCodeConfig;
5
6/// Validate an OpenCode config structure.
7///
8/// Checks for:
9/// - Valid model ID format (`provider/model`)
10/// - Valid provider IDs against known providers
11/// - Valid API key format patterns
12/// - Schema consistency
13pub fn validate_config(config: &OpenCodeConfig) -> Result<()> {
14    let mut errors = Vec::new();
15
16    // Validate model ID format
17    if let Some(ref model) = config.model {
18        validate_model_id(model, &mut errors);
19    }
20
21    if let Some(ref small_model) = config.small_model {
22        validate_model_id(small_model, &mut errors);
23    }
24
25    // Validate provider configs
26    if let Some(ref providers) = config.provider {
27        for (provider_id, provider_config) in providers {
28            validate_provider(provider_id, provider_config, &mut errors);
29        }
30    }
31
32    // Validate disabled_providers and enabled_providers don't conflict
33    if let (Some(disabled), Some(enabled)) = (&config.disabled_providers, &config.enabled_providers)
34    {
35        for provider_id in disabled {
36            if enabled.contains(provider_id) {
37                errors.push(format!(
38                    "Provider '{}' is in both disabled_providers and enabled_providers (disabled takes priority)",
39                    provider_id
40                ));
41            }
42        }
43    }
44
45    if errors.is_empty() {
46        Ok(())
47    } else {
48        Err(ConfigError::Validation(errors.join("; ")))
49    }
50}
51
52/// Validate a model ID in `provider/model` format.
53fn validate_model_id(model_id: &str, errors: &mut Vec<String>) {
54    if model_id.contains('/') {
55        let parts: Vec<&str> = model_id.splitn(2, '/').collect();
56        if parts[0].is_empty() {
57            errors.push(format!(
58                "Invalid model ID '{}': provider part is empty",
59                model_id
60            ));
61        }
62        if parts[1].is_empty() {
63            errors.push(format!(
64                "Invalid model ID '{}': model part is empty",
65                model_id
66            ));
67        }
68    } else {
69        errors.push(format!(
70            "Invalid model ID '{}': must be in 'provider/model' format",
71            model_id
72        ));
73    }
74}
75
76/// Validate a provider configuration.
77fn validate_provider(
78    provider_id: &str,
79    provider: &super::schema::ProviderConfig,
80    errors: &mut Vec<String>,
81) {
82    // Provider ID should not contain spaces or special chars
83    if provider_id.contains(' ') || provider_id.contains('\t') {
84        errors.push(format!("Provider ID '{}' contains whitespace", provider_id));
85    }
86
87    // If npm is specified, it should be a valid npm package name pattern
88    if let Some(ref npm) = provider.npm {
89        if npm.is_empty() {
90            errors.push(format!(
91                "Provider '{}' has empty npm package name",
92                provider_id
93            ));
94        }
95    }
96
97    // Validate models
98    if let Some(ref models) = provider.models {
99        for (model_id, model_config) in models {
100            if model_id.is_empty() {
101                errors.push(format!(
102                    "Provider '{}' has a model with empty ID",
103                    provider_id
104                ));
105            }
106            if let Some(ref limit) = model_config.limit {
107                if limit.context == Some(0) {
108                    errors.push(format!(
109                        "Provider '{}' model '{}' has context limit of 0",
110                        provider_id, model_id
111                    ));
112                }
113            }
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::super::schema::*;
121    use super::*;
122    use std::collections::HashMap;
123
124    #[test]
125    fn test_validate_valid_config() {
126        let config = OpenCodeConfig {
127            model: Some("anthropic/claude-sonnet-4-5".to_string()),
128            provider: Some({
129                let mut providers = HashMap::new();
130                providers.insert(
131                    "anthropic".to_string(),
132                    ProviderConfig {
133                        options: Some(HashMap::new()),
134                        ..Default::default()
135                    },
136                );
137                providers
138            }),
139            ..Default::default()
140        };
141
142        assert!(validate_config(&config).is_ok());
143    }
144
145    #[test]
146    fn test_validate_invalid_model_id() {
147        let config = OpenCodeConfig {
148            model: Some("invalid-model-id".to_string()),
149            ..Default::default()
150        };
151
152        let result = validate_config(&config);
153        assert!(result.is_err());
154        assert!(result.unwrap_err().to_string().contains("provider/model"));
155    }
156
157    #[test]
158    fn test_validate_empty_provider_id() {
159        // Model ID with empty provider part
160        let config = OpenCodeConfig {
161            model: Some("/claude-sonnet-4-5".to_string()),
162            ..Default::default()
163        };
164
165        let result = validate_config(&config);
166        assert!(result.is_err());
167    }
168
169    #[test]
170    fn test_validate_provider_whitespace_id() {
171        let config = OpenCodeConfig {
172            provider: Some({
173                let mut providers = HashMap::new();
174                providers.insert("has space".to_string(), ProviderConfig::default());
175                providers
176            }),
177            ..Default::default()
178        };
179
180        let result = validate_config(&config);
181        assert!(result.is_err());
182    }
183
184    #[test]
185    fn test_validate_disabled_enabled_conflict() {
186        let config = OpenCodeConfig {
187            disabled_providers: Some(vec!["anthropic".to_string()]),
188            enabled_providers: Some(vec!["anthropic".to_string()]),
189            ..Default::default()
190        };
191
192        let result = validate_config(&config);
193        assert!(result.is_err());
194        assert!(
195            result
196                .unwrap_err()
197                .to_string()
198                .contains("disabled_providers and enabled_providers")
199        );
200    }
201
202    #[test]
203    fn test_validate_empty_config() {
204        let config = OpenCodeConfig::default();
205        assert!(validate_config(&config).is_ok());
206    }
207}