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