subx_cli/config/
validator.rs

1//! High-level configuration validation for configuration sections.
2//!
3//! This module provides validation for complete configuration sections and
4//! the entire configuration structure. It builds upon the low-level validation
5//! functions from the [`crate::config::validation`] module.
6//!
7//! # Architecture
8//!
9//! - [`crate::config::validation`] - Low-level validation functions for individual values
10//! - [`crate::config::validator`] (this module) - High-level configuration section validators
11//! - [`crate::config::field_validator`] - Key-value validation for configuration service
12
13use super::validation::*;
14use crate::Result;
15use crate::config::Config;
16use crate::config::{
17    AIConfig, FormatsConfig, GeneralConfig, ParallelConfig, SyncConfig, VadConfig,
18};
19use crate::error::SubXError;
20
21/// Validate the complete configuration.
22///
23/// This function validates all configuration sections and their
24/// interdependencies.
25///
26/// # Arguments
27/// * `config` - The configuration to validate
28///
29/// # Errors
30/// Returns the first validation error encountered.
31pub fn validate_config(config: &Config) -> Result<()> {
32    validate_ai_config(&config.ai)?;
33    validate_sync_config(&config.sync)?;
34    validate_general_config(&config.general)?;
35    validate_formats_config(&config.formats)?;
36    validate_parallel_config(&config.parallel)?;
37
38    // Cross-section validation
39    validate_config_consistency(config)?;
40
41    Ok(())
42}
43
44/// Validate AI configuration section.
45pub fn validate_ai_config(ai_config: &AIConfig) -> Result<()> {
46    validate_non_empty_string(&ai_config.provider, "AI provider")?;
47
48    // Validate provider-specific settings
49    match ai_config.provider.as_str() {
50        "openai" => {
51            if let Some(api_key) = &ai_config.api_key {
52                if !api_key.is_empty() {
53                    validate_api_key(api_key)?;
54                    if !api_key.starts_with("sk-") {
55                        return Err(SubXError::config("OpenAI API key must start with 'sk-'"));
56                    }
57                }
58            }
59            validate_ai_model(&ai_config.model)?;
60            validate_temperature(ai_config.temperature)?;
61            validate_positive_number(ai_config.max_tokens as f64)?;
62
63            if !ai_config.base_url.is_empty() {
64                validate_url_format(&ai_config.base_url)?;
65            }
66        }
67        "openrouter" => {
68            if let Some(api_key) = &ai_config.api_key {
69                if !api_key.is_empty() {
70                    validate_api_key(api_key)?;
71                    // OpenRouter API keys have no specific prefix requirement
72                }
73            }
74            validate_ai_model(&ai_config.model)?;
75            validate_temperature(ai_config.temperature)?;
76            validate_positive_number(ai_config.max_tokens as f64)?;
77
78            if !ai_config.base_url.is_empty() {
79                validate_url_format(&ai_config.base_url)?;
80            }
81        }
82        "anthropic" => {
83            if let Some(api_key) = &ai_config.api_key {
84                if !api_key.is_empty() {
85                    validate_api_key(api_key)?;
86                }
87            }
88            validate_ai_model(&ai_config.model)?;
89            validate_temperature(ai_config.temperature)?;
90        }
91        "azure-openai" => {
92            if let Some(api_key) = &ai_config.api_key {
93                if !api_key.is_empty() {
94                    validate_api_key(api_key)?;
95                }
96            }
97            validate_ai_model(&ai_config.model)?;
98            validate_temperature(ai_config.temperature)?;
99            validate_positive_number(ai_config.max_tokens as f64)?;
100            if let Some(ver) = &ai_config.api_version {
101                if ver.trim().is_empty() {
102                    return Err(SubXError::config(
103                        "Azure OpenAI api_version must not be empty",
104                    ));
105                }
106            }
107            if !ai_config.base_url.is_empty() {
108                validate_url_format(&ai_config.base_url)?;
109            }
110        }
111        _ => {
112            return Err(SubXError::config(format!(
113                "Unsupported AI provider: {}. Supported providers: openai, openrouter, anthropic, azure-openai",
114                ai_config.provider
115            )));
116        }
117    }
118
119    // Validate retry settings
120    validate_positive_number(ai_config.retry_attempts as f64)?;
121    if ai_config.retry_attempts > 10 {
122        return Err(SubXError::config("Retry count cannot exceed 10 times"));
123    }
124
125    // Validate timeout settings
126    validate_range(ai_config.request_timeout_seconds as f64, 10.0, 600.0)
127        .map_err(|_| SubXError::config("Request timeout must be between 10 and 600 seconds"))?;
128
129    Ok(())
130}
131
132/// Validate sync configuration section.
133pub fn validate_sync_config(sync_config: &SyncConfig) -> Result<()> {
134    // Delegate to SyncConfig's validation with enhancements
135    sync_config.validate()
136}
137
138/// Validate general configuration section.
139pub fn validate_general_config(general_config: &GeneralConfig) -> Result<()> {
140    // Validate concurrent jobs
141    validate_positive_number(general_config.max_concurrent_jobs as f64)?;
142    if general_config.max_concurrent_jobs > 64 {
143        return Err(SubXError::config(
144            "Maximum concurrent jobs should not exceed 64",
145        ));
146    }
147
148    // Validate timeout settings
149    validate_range(general_config.task_timeout_seconds as f64, 30.0, 3600.0)
150        .map_err(|_| SubXError::config("Task timeout must be between 30 and 3600 seconds"))?;
151
152    validate_range(
153        general_config.worker_idle_timeout_seconds as f64,
154        10.0,
155        3600.0,
156    )
157    .map_err(|_| SubXError::config("Worker idle timeout must be between 10 and 3600 seconds"))?;
158
159    Ok(())
160}
161
162/// Validate formats configuration section.
163pub fn validate_formats_config(formats_config: &FormatsConfig) -> Result<()> {
164    // Check default output format
165    validate_non_empty_string(&formats_config.default_output, "Default output format")?;
166    validate_enum(
167        &formats_config.default_output,
168        &["srt", "ass", "vtt", "webvtt"],
169    )?;
170
171    // Check default encoding
172    validate_non_empty_string(&formats_config.default_encoding, "Default encoding")?;
173    validate_enum(
174        &formats_config.default_encoding,
175        &["utf-8", "gbk", "big5", "shift_jis"],
176    )?;
177
178    // Check encoding detection confidence
179    validate_range(formats_config.encoding_detection_confidence, 0.0, 1.0).map_err(|_| {
180        SubXError::config("Encoding detection confidence must be between 0.0 and 1.0")
181    })?;
182
183    Ok(())
184}
185
186/// Validate parallel processing configuration.
187pub fn validate_parallel_config(parallel_config: &ParallelConfig) -> Result<()> {
188    // Check max workers
189    validate_positive_number(parallel_config.max_workers as f64)?;
190    if parallel_config.max_workers > 64 {
191        return Err(SubXError::config("Maximum workers should not exceed 64"));
192    }
193
194    // Check task queue size
195    validate_positive_number(parallel_config.task_queue_size as f64)?;
196    if parallel_config.task_queue_size < 100 {
197        return Err(SubXError::config("Task queue size should be at least 100"));
198    }
199
200    Ok(())
201}
202
203/// Validate configuration consistency across sections.
204fn validate_config_consistency(config: &Config) -> Result<()> {
205    // Example: Ensure AI is properly configured if using AI features
206    if config.ai.provider == "openai" {
207        if let Some(api_key) = &config.ai.api_key {
208            if api_key.is_empty() {
209                return Err(SubXError::config(
210                    "OpenAI provider is selected but API key is empty",
211                ));
212            }
213        }
214        // Note: We don't require API key for default config to allow basic operation
215    }
216
217    // Ensure reasonable resource allocation
218    if config.parallel.max_workers > config.general.max_concurrent_jobs {
219        log::warn!(
220            "Parallel max_workers ({}) exceeds general max_concurrent_jobs ({})",
221            config.parallel.max_workers,
222            config.general.max_concurrent_jobs
223        );
224    }
225
226    Ok(())
227}
228
229impl SyncConfig {
230    /// Validate the sync configuration for correctness.
231    ///
232    /// Checks all sync-related configuration parameters to ensure they
233    /// are within valid ranges and have acceptable values.
234    ///
235    /// # Returns
236    ///
237    /// Returns `Ok(())` if validation passes, or an error describing
238    /// the validation failure.
239    ///
240    /// # Errors
241    ///
242    /// This function returns an error if:
243    /// - `default_method` is not one of the supported methods
244    /// - `max_offset_seconds` is outside the valid range
245    /// - VAD configuration validation fails
246    pub fn validate(&self) -> Result<()> {
247        // Validate default_method
248        validate_enum(&self.default_method, &["vad", "auto", "manual"])?;
249
250        // Validate max_offset_seconds
251        validate_positive_number(self.max_offset_seconds)?;
252        if self.max_offset_seconds > 3600.0 {
253            return Err(SubXError::config(
254                "sync.max_offset_seconds should not exceed 3600 seconds (1 hour). If a larger value is needed, please verify the sync requirements are reasonable.",
255            ));
256        }
257
258        // Provide recommendations for common use cases
259        if self.max_offset_seconds < 5.0 {
260            log::warn!(
261                "sync.max_offset_seconds is set to {:.1}s which may be too small. Consider using 30.0-60.0 seconds.",
262                self.max_offset_seconds
263            );
264        } else if self.max_offset_seconds > 600.0 && self.max_offset_seconds <= 3600.0 {
265            log::warn!(
266                "sync.max_offset_seconds is set to {:.1}s which is quite large. Please confirm this meets your requirements.",
267                self.max_offset_seconds
268            );
269        }
270
271        // Validate sub-configurations
272        self.vad.validate()?;
273
274        Ok(())
275    }
276}
277
278impl VadConfig {
279    /// Validate the local VAD configuration for correctness.
280    ///
281    /// Ensures that all VAD-related parameters are within acceptable
282    /// ranges and have valid values for audio processing.
283    ///
284    /// # Returns
285    ///
286    /// Returns `Ok(())` if validation passes, or an error describing
287    /// the validation failure.
288    ///
289    /// # Errors
290    ///
291    /// This function returns an error if:
292    /// - `sensitivity` is outside the valid range (0.0-1.0)
293    pub fn validate(&self) -> Result<()> {
294        // Validate sensitivity range
295        if !(0.0..=1.0).contains(&self.sensitivity) {
296            return Err(SubXError::config(
297                "VAD sensitivity must be between 0.0 and 1.0",
298            ));
299        }
300        // Validate padding_chunks
301        if self.padding_chunks > 10 {
302            return Err(SubXError::config("VAD padding_chunks must not exceed 10"));
303        }
304        // Validate minimum speech duration
305        if self.min_speech_duration_ms > 5000 {
306            return Err(SubXError::config(
307                "VAD min_speech_duration_ms must not exceed 5000ms",
308            ));
309        }
310        Ok(())
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::config::{AIConfig, Config, SyncConfig, VadConfig};
318
319    #[test]
320    fn test_validate_default_config() {
321        let config = Config::default();
322        assert!(validate_config(&config).is_ok());
323    }
324
325    #[test]
326    fn test_validate_ai_config_valid() {
327        let mut ai_config = AIConfig::default();
328        ai_config.provider = "openai".to_string();
329        ai_config.api_key = Some("sk-test123456789".to_string());
330        ai_config.temperature = 0.8;
331        assert!(validate_ai_config(&ai_config).is_ok());
332
333        // openrouter test
334        let mut ai_config = AIConfig::default();
335        ai_config.provider = "openrouter".to_string();
336        ai_config.api_key = Some("test-openrouter-key".to_string());
337        ai_config.model = "deepseek/deepseek-r1-0528:free".to_string();
338        assert!(validate_ai_config(&ai_config).is_ok());
339
340        // azure-openai test
341        let mut ai_config = AIConfig::default();
342        ai_config.provider = "azure-openai".to_string();
343        ai_config.api_key = Some("azure-key-123".to_string());
344        ai_config.model = "dep123".to_string();
345        ai_config.api_version = Some("2025-04-01-preview".to_string());
346        assert!(validate_ai_config(&ai_config).is_ok());
347    }
348
349    #[test]
350    fn test_validate_ai_config_invalid_provider() {
351        let mut ai_config = AIConfig::default();
352        ai_config.provider = "invalid".to_string();
353        let err = validate_ai_config(&ai_config).unwrap_err();
354        assert!(err.to_string().contains(
355            "Unsupported AI provider: invalid. Supported providers: openai, openrouter, anthropic, azure-openai"
356        ));
357    }
358
359    #[test]
360    fn test_validate_ai_config_invalid_temperature() {
361        let mut ai_config = AIConfig::default();
362        ai_config.provider = "openai".to_string();
363        ai_config.temperature = 3.0; // Too high
364        assert!(validate_ai_config(&ai_config).is_err());
365    }
366
367    #[test]
368    fn test_validate_ai_config_invalid_openai_key() {
369        let mut ai_config = AIConfig::default();
370        ai_config.provider = "openai".to_string();
371        ai_config.api_key = Some("invalid-key".to_string());
372        assert!(validate_ai_config(&ai_config).is_err());
373    }
374
375    #[test]
376    fn test_validate_sync_config_valid() {
377        let sync_config = SyncConfig::default();
378        assert!(validate_sync_config(&sync_config).is_ok());
379    }
380
381    #[test]
382    fn test_validate_vad_config_invalid_sensitivity() {
383        let mut vad_config = VadConfig::default();
384        vad_config.sensitivity = 1.5; // Too high (should be 0.0-1.0)
385        assert!(vad_config.validate().is_err());
386    }
387
388    #[test]
389    fn test_validate_config_consistency() {
390        let mut config = Config::default();
391        config.ai.provider = "openai".to_string();
392        config.ai.api_key = Some("".to_string()); // Empty API key should fail
393        assert!(validate_config(&config).is_err());
394
395        // Valid case with proper API key
396        config.ai.api_key = Some("sk-valid123".to_string());
397        assert!(validate_config(&config).is_ok());
398
399        // Valid case with no API key (default state)
400        config.ai.api_key = None;
401        assert!(validate_config(&config).is_ok());
402    }
403}