subx_cli/config/
field_validator.rs

1//! Key-value validation for configuration service.
2//!
3//! This module handles the validation logic that was previously embedded
4//! in `ProductionConfigService::validate_and_set_value`. It provides
5//! field-specific validation for configuration keys and values.
6//!
7//! # Architecture
8//!
9//! - [`crate::config::validation`] - Low-level validation functions for individual values
10//! - [`crate::config::validator`] - High-level configuration section validators  
11//! - [`crate::config::field_validator`] (this module) - Key-value validation for configuration service
12
13use super::validation::*;
14use crate::{Result, error::SubXError};
15
16/// Validate and parse a configuration field based on its key.
17///
18/// This function handles the validation logic that was previously
19/// embedded in ProductionConfigService::validate_and_set_value.
20///
21/// # Arguments
22/// * `key` - The configuration key (e.g., "ai.temperature")
23/// * `value` - The string value to validate and parse
24///
25/// # Returns
26/// Returns Ok(()) if validation passes, or an error describing the validation failure.
27pub fn validate_field(key: &str, value: &str) -> Result<()> {
28    match key {
29        // AI configuration fields
30        "ai.provider" => {
31            validate_non_empty_string(value, "AI provider")?;
32            validate_enum(value, &["openai", "anthropic", "local"])?;
33        }
34        "ai.model" => validate_ai_model(value)?,
35        "ai.api_key" => {
36            if !value.is_empty() {
37                validate_api_key(value)?;
38            }
39        }
40        "ai.base_url" => validate_url_format(value)?,
41        "ai.temperature" => {
42            let temp: f32 = value
43                .parse()
44                .map_err(|_| SubXError::config("Temperature must be a number"))?;
45            validate_temperature(temp)?;
46        }
47        "ai.max_tokens" => {
48            let tokens: u32 = value
49                .parse()
50                .map_err(|_| SubXError::config("Max tokens must be a positive integer"))?;
51            validate_positive_number(tokens as f64)?;
52        }
53        "ai.max_sample_length" => {
54            let length: usize = value
55                .parse()
56                .map_err(|_| SubXError::config("Max sample length must be a positive integer"))?;
57            validate_range(length, 100, 10000)?;
58        }
59        "ai.retry_attempts" => {
60            let attempts: u32 = value
61                .parse()
62                .map_err(|_| SubXError::config("Retry attempts must be a positive integer"))?;
63            validate_range(attempts, 1, 10)?;
64        }
65        "ai.retry_delay_ms" => {
66            let delay: u64 = value
67                .parse()
68                .map_err(|_| SubXError::config("Retry delay must be a positive integer"))?;
69            validate_range(delay, 100, 30000)?;
70        }
71        "ai.request_timeout_seconds" => {
72            let timeout: u64 = value
73                .parse()
74                .map_err(|_| SubXError::config("Request timeout must be a positive integer"))?;
75            validate_range(timeout, 10, 600)?;
76        }
77
78        // Sync configuration fields
79        "sync.default_method" => {
80            validate_enum(value, &["auto", "vad", "manual"])?;
81        }
82        "sync.max_offset_seconds" => {
83            let offset: f32 = value
84                .parse()
85                .map_err(|_| SubXError::config("Max offset must be a number"))?;
86            validate_range(offset, 0.1, 3600.0)?;
87        }
88        "sync.vad.enabled" => {
89            parse_bool(value)?;
90        }
91        "sync.vad.sensitivity" => {
92            let sensitivity: f32 = value
93                .parse()
94                .map_err(|_| SubXError::config("VAD sensitivity must be a number"))?;
95            validate_range(sensitivity, 0.0, 1.0)?;
96        }
97        "sync.vad.padding_chunks" => {
98            let chunks: u32 = value
99                .parse()
100                .map_err(|_| SubXError::config("Padding chunks must be a non-negative integer"))?;
101            validate_range(chunks, 0, 10)?;
102        }
103        "sync.vad.min_speech_duration_ms" => {
104            let _duration: u32 = value.parse().map_err(|_| {
105                SubXError::config("Min speech duration must be a non-negative integer")
106            })?;
107            // Non-negative validation is implicit in u32 parsing
108        }
109
110        // Formats configuration fields
111        "formats.default_output" => {
112            validate_enum(value, &["srt", "ass", "vtt", "webvtt"])?;
113        }
114        "formats.preserve_styling" => {
115            parse_bool(value)?;
116        }
117        "formats.default_encoding" => {
118            validate_enum(value, &["utf-8", "gbk", "big5", "shift_jis"])?;
119        }
120        "formats.encoding_detection_confidence" => {
121            let confidence: f32 = value
122                .parse()
123                .map_err(|_| SubXError::config("Encoding detection confidence must be a number"))?;
124            validate_range(confidence, 0.0, 1.0)?;
125        }
126
127        // General configuration fields
128        "general.backup_enabled" => {
129            parse_bool(value)?;
130        }
131        "general.max_concurrent_jobs" => {
132            let jobs: usize = value
133                .parse()
134                .map_err(|_| SubXError::config("Max concurrent jobs must be a positive integer"))?;
135            validate_range(jobs, 1, 64)?;
136        }
137        "general.task_timeout_seconds" => {
138            let timeout: u64 = value
139                .parse()
140                .map_err(|_| SubXError::config("Task timeout must be a positive integer"))?;
141            validate_range(timeout, 30, 3600)?;
142        }
143        "general.enable_progress_bar" => {
144            parse_bool(value)?;
145        }
146        "general.worker_idle_timeout_seconds" => {
147            let timeout: u64 = value
148                .parse()
149                .map_err(|_| SubXError::config("Worker idle timeout must be a positive integer"))?;
150            validate_range(timeout, 10, 3600)?;
151        }
152
153        // Parallel configuration fields
154        "parallel.max_workers" => {
155            let workers: usize = value
156                .parse()
157                .map_err(|_| SubXError::config("Max workers must be a positive integer"))?;
158            validate_range(workers, 1, 64)?;
159        }
160        "parallel.task_queue_size" => {
161            let size: usize = value
162                .parse()
163                .map_err(|_| SubXError::config("Task queue size must be a positive integer"))?;
164            validate_range(size, 100, 10000)?;
165        }
166        "parallel.enable_task_priorities" => {
167            parse_bool(value)?;
168        }
169        "parallel.auto_balance_workers" => {
170            parse_bool(value)?;
171        }
172        "parallel.overflow_strategy" => {
173            validate_enum(value, &["Block", "Drop", "Expand"])?;
174        }
175
176        _ => {
177            return Err(SubXError::config(format!(
178                "Unknown configuration key: {}",
179                key
180            )));
181        }
182    }
183
184    Ok(())
185}
186
187/// Get a user-friendly description for a configuration field.
188pub fn get_field_description(key: &str) -> &'static str {
189    match key {
190        "ai.provider" => "AI service provider (e.g., 'openai')",
191        "ai.model" => "AI model name (e.g., 'gpt-4.1-mini')",
192        "ai.api_key" => "API key for the AI service",
193        "ai.base_url" => "Custom API endpoint URL (optional)",
194        "ai.temperature" => "AI response randomness (0.0-2.0)",
195        "ai.max_tokens" => "Maximum tokens in AI response",
196        "ai.max_sample_length" => "Maximum sample length for AI processing",
197        "ai.retry_attempts" => "Number of retry attempts for AI requests",
198        "ai.retry_delay_ms" => "Delay between retry attempts in milliseconds",
199        "ai.request_timeout_seconds" => "Request timeout in seconds",
200
201        "sync.default_method" => "Synchronization method ('auto', 'vad', or 'manual')",
202        "sync.max_offset_seconds" => "Maximum allowed time offset in seconds",
203        "sync.vad.enabled" => "Enable voice activity detection",
204        "sync.vad.sensitivity" => "Voice activity detection threshold (0.0-1.0)",
205        "sync.vad.chunk_size" => "VAD processing chunk size (must be power of 2)",
206        "sync.vad.sample_rate" => "Audio sample rate for VAD processing",
207        "sync.vad.padding_chunks" => "Number of padding chunks for VAD",
208        "sync.vad.min_speech_duration_ms" => "Minimum speech duration in milliseconds",
209
210        "formats.default_output" => "Default output format for subtitles",
211        "formats.preserve_styling" => "Preserve subtitle styling information",
212        "formats.default_encoding" => "Default character encoding",
213        "formats.encoding_detection_confidence" => "Confidence threshold for encoding detection",
214
215        "general.backup_enabled" => "Enable automatic backup creation",
216        "general.max_concurrent_jobs" => "Maximum number of concurrent jobs",
217        "general.task_timeout_seconds" => "Task timeout in seconds",
218        "general.enable_progress_bar" => "Enable progress bar display",
219        "general.worker_idle_timeout_seconds" => "Worker idle timeout in seconds",
220
221        "parallel.max_workers" => "Maximum number of worker threads",
222        "parallel.task_queue_size" => "Size of the task queue",
223        "parallel.enable_task_priorities" => "Enable task priority system",
224        "parallel.auto_balance_workers" => "Enable automatic worker load balancing",
225        "parallel.overflow_strategy" => "Strategy for handling queue overflow",
226
227        _ => "Configuration field",
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_validate_ai_fields() {
237        // Valid cases
238        assert!(validate_field("ai.provider", "openai").is_ok());
239        assert!(validate_field("ai.temperature", "0.8").is_ok());
240        assert!(validate_field("ai.max_tokens", "4000").is_ok());
241
242        // Invalid cases
243        assert!(validate_field("ai.provider", "invalid").is_err());
244        assert!(validate_field("ai.temperature", "3.0").is_err());
245        assert!(validate_field("ai.max_tokens", "0").is_err());
246    }
247
248    #[test]
249    fn test_validate_sync_fields() {
250        // Valid cases
251        assert!(validate_field("sync.default_method", "vad").is_ok());
252        assert!(validate_field("sync.vad.sensitivity", "0.5").is_ok());
253        assert!(validate_field("sync.vad.padding_chunks", "3").is_ok());
254
255        // Invalid cases
256        assert!(validate_field("sync.default_method", "invalid").is_err());
257        assert!(validate_field("sync.vad.sensitivity", "1.5").is_err());
258        assert!(validate_field("sync.vad.padding_chunks", "11").is_err()); // Exceeds max allowed padding_chunks
259    }
260
261    #[test]
262    fn test_validate_formats_fields() {
263        // Valid cases
264        assert!(validate_field("formats.default_output", "srt").is_ok());
265        assert!(validate_field("formats.preserve_styling", "true").is_ok());
266
267        // Invalid cases
268        assert!(validate_field("formats.default_output", "invalid").is_err());
269        assert!(validate_field("formats.preserve_styling", "maybe").is_err());
270    }
271
272    #[test]
273    fn test_validate_unknown_field() {
274        assert!(validate_field("unknown.field", "value").is_err());
275    }
276
277    #[test]
278    fn test_get_field_description() {
279        assert!(!get_field_description("ai.provider").is_empty());
280        assert!(!get_field_description("sync.vad.sensitivity").is_empty());
281        assert_eq!(
282            get_field_description("unknown.field"),
283            "Configuration field"
284        );
285    }
286}