Skip to main content

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/// Normalize an `ai.provider` value to its canonical form.
17///
18/// This is the **single** alias-resolution point in the codebase. It
19/// lowercases and trims its input and maps the alias `"ollama"` to the
20/// canonical identifier `"local"`. All other recognized providers
21/// (`openai`, `openrouter`, `azure-openai`, `local`) pass through after
22/// trimming and lowercasing. Unknown values pass through unchanged so
23/// downstream allow-list validation still rejects them.
24///
25/// Every component that reads or writes `ai.provider` SHALL invoke this
26/// helper before using the value:
27///
28/// 1. `subx config set ai.provider <value>` — the persisted on-disk value
29///    is the canonical form.
30/// 2. `subx config get ai.provider` — returns the canonical form.
31/// 3. `ProductionConfigService` env-var loading — `SUBX_AI_PROVIDER=ollama`
32///    is normalized before any precedence or scoping decision (including
33///    the hosted-provider env-var carve-out for `local`).
34/// 4. `validate_ai_config` — validation arms key off the canonical value.
35/// 5. `ComponentFactory::create_ai_provider` — the dispatch match arm
36///    uses the canonical value.
37///
38/// # Examples
39///
40/// ```
41/// use subx_cli::config::field_validator::normalize_ai_provider;
42///
43/// assert_eq!(normalize_ai_provider("ollama"), "local");
44/// assert_eq!(normalize_ai_provider("OLLAMA"), "local");
45/// assert_eq!(normalize_ai_provider(" ollama "), "local");
46/// assert_eq!(normalize_ai_provider("openai"), "openai");
47/// assert_eq!(normalize_ai_provider("Azure-OpenAI"), "azure-openai");
48/// // Unknown values are returned trimmed+lowercased so the allow-list
49/// // can still reject them with the existing error.
50/// assert_eq!(normalize_ai_provider("grok"), "grok");
51/// ```
52pub fn normalize_ai_provider(value: &str) -> String {
53    let trimmed = value.trim().to_ascii_lowercase();
54    if trimmed == "ollama" {
55        "local".to_string()
56    } else {
57        trimmed
58    }
59}
60
61/// Validate and parse a configuration field based on its key.
62///
63/// This function handles the validation logic that was previously
64/// embedded in ProductionConfigService::validate_and_set_value.
65///
66/// # Arguments
67/// * `key` - The configuration key (e.g., "ai.temperature")
68/// * `value` - The string value to validate and parse
69///
70/// # Returns
71/// Returns Ok(()) if validation passes, or an error describing the validation failure.
72pub fn validate_field(key: &str, value: &str) -> Result<()> {
73    match key {
74        // AI configuration fields
75        "ai.provider" => {
76            validate_non_empty_string(value, "AI provider")?;
77            validate_enum(
78                value,
79                &[
80                    "openai",
81                    "anthropic",
82                    "local",
83                    "ollama",
84                    "openrouter",
85                    "azure-openai",
86                ],
87            )?;
88        }
89        "ai.model" => validate_ai_model(value)?,
90        "ai.api_key" => {
91            if !value.is_empty() {
92                validate_api_key(value)?;
93            }
94        }
95        "ai.base_url" => validate_url_format(value)?,
96        "ai.temperature" => {
97            let temp: f32 = value
98                .parse()
99                .map_err(|_| SubXError::config("Temperature must be a number"))?;
100            validate_temperature(temp)?;
101        }
102        "ai.max_tokens" => {
103            let tokens: u32 = value
104                .parse()
105                .map_err(|_| SubXError::config("Max tokens must be a positive integer"))?;
106            validate_positive_number(tokens as f64)?;
107        }
108        "ai.max_sample_length" => {
109            let length: usize = value
110                .parse()
111                .map_err(|_| SubXError::config("Max sample length must be a positive integer"))?;
112            validate_range(length, 100, 10000)?;
113        }
114        "ai.retry_attempts" => {
115            let attempts: u32 = value
116                .parse()
117                .map_err(|_| SubXError::config("Retry attempts must be a positive integer"))?;
118            validate_range(attempts, 1, 10)?;
119        }
120        "ai.retry_delay_ms" => {
121            let delay: u64 = value
122                .parse()
123                .map_err(|_| SubXError::config("Retry delay must be a positive integer"))?;
124            validate_range(delay, 100, 30000)?;
125        }
126        "ai.request_timeout_seconds" => {
127            let timeout: u64 = value
128                .parse()
129                .map_err(|_| SubXError::config("Request timeout must be a positive integer"))?;
130            validate_range(timeout, 10, 600)?;
131        }
132
133        // Azure OpenAI specific fields
134        "ai.api_version" => {
135            validate_non_empty_string(value, "Azure OpenAI API version")?;
136        }
137
138        // Sync configuration fields
139        "sync.default_method" => {
140            validate_enum(value, &["auto", "vad", "manual"])?;
141        }
142        "sync.max_offset_seconds" => {
143            let offset: f32 = value
144                .parse()
145                .map_err(|_| SubXError::config("Max offset must be a number"))?;
146            validate_range(offset, 0.1, 3600.0)?;
147        }
148        "sync.vad.enabled" => {
149            parse_bool(value)?;
150        }
151        "sync.vad.sensitivity" => {
152            let sensitivity: f32 = value
153                .parse()
154                .map_err(|_| SubXError::config("VAD sensitivity must be a number"))?;
155            validate_range(sensitivity, 0.0, 1.0)?;
156        }
157        "sync.vad.padding_chunks" => {
158            let chunks: u32 = value
159                .parse()
160                .map_err(|_| SubXError::config("Padding chunks must be a non-negative integer"))?;
161            validate_range(chunks, 0, 10)?;
162        }
163        "sync.vad.min_speech_duration_ms" => {
164            let _duration: u32 = value.parse().map_err(|_| {
165                SubXError::config("Min speech duration must be a non-negative integer")
166            })?;
167            // Non-negative validation is implicit in u32 parsing
168        }
169
170        // Formats configuration fields
171        "formats.default_output" => {
172            validate_enum(value, &["srt", "ass", "vtt", "webvtt"])?;
173        }
174        "formats.preserve_styling" => {
175            parse_bool(value)?;
176        }
177        "formats.default_encoding" => {
178            validate_enum(value, &["utf-8", "gbk", "big5", "shift_jis"])?;
179        }
180        "formats.encoding_detection_confidence" => {
181            let confidence: f32 = value
182                .parse()
183                .map_err(|_| SubXError::config("Encoding detection confidence must be a number"))?;
184            validate_range(confidence, 0.0, 1.0)?;
185        }
186
187        // General configuration fields
188        "general.backup_enabled" => {
189            parse_bool(value)?;
190        }
191        "general.max_concurrent_jobs" => {
192            let jobs: usize = value
193                .parse()
194                .map_err(|_| SubXError::config("Max concurrent jobs must be a positive integer"))?;
195            validate_range(jobs, 1, 64)?;
196        }
197        "general.task_timeout_seconds" => {
198            let timeout: u64 = value
199                .parse()
200                .map_err(|_| SubXError::config("Task timeout must be a positive integer"))?;
201            validate_range(timeout, 30, 3600)?;
202        }
203        "general.enable_progress_bar" => {
204            parse_bool(value)?;
205        }
206        "general.worker_idle_timeout_seconds" => {
207            let timeout: u64 = value
208                .parse()
209                .map_err(|_| SubXError::config("Worker idle timeout must be a positive integer"))?;
210            validate_range(timeout, 10, 3600)?;
211        }
212        "general.max_subtitle_bytes" => {
213            let bytes: u64 = value
214                .parse()
215                .map_err(|_| SubXError::config("max_subtitle_bytes must be a positive integer"))?;
216            validate_range(bytes, 1024_u64, 1_073_741_824_u64)?;
217        }
218        "general.max_audio_bytes" => {
219            let bytes: u64 = value
220                .parse()
221                .map_err(|_| SubXError::config("max_audio_bytes must be a positive integer"))?;
222            validate_range(bytes, 1024_u64, 10_737_418_240_u64)?;
223        }
224
225        // Parallel configuration fields
226        "parallel.max_workers" => {
227            let workers: usize = value
228                .parse()
229                .map_err(|_| SubXError::config("Max workers must be a positive integer"))?;
230            validate_range(workers, 1, 64)?;
231        }
232        "parallel.task_queue_size" => {
233            let size: usize = value
234                .parse()
235                .map_err(|_| SubXError::config("Task queue size must be a positive integer"))?;
236            validate_range(size, 100, 10000)?;
237        }
238        "parallel.enable_task_priorities" => {
239            parse_bool(value)?;
240        }
241        "parallel.auto_balance_workers" => {
242            parse_bool(value)?;
243        }
244        "parallel.overflow_strategy" => {
245            validate_enum(value, &["Block", "Drop", "Expand"])?;
246        }
247
248        // Translation configuration fields
249        "translation.batch_size" => {
250            let size: usize = value.parse().map_err(|_| {
251                SubXError::config("Translation batch size must be a positive integer")
252            })?;
253            validate_range(size, 1, 1000)?;
254        }
255        "translation.default_target_language" => {
256            if !value.is_empty() {
257                validate_non_empty_string(value, "translation.default_target_language")?;
258            }
259        }
260
261        _ => {
262            return Err(SubXError::config(format!(
263                "Unknown configuration key: {key}"
264            )));
265        }
266    }
267
268    Ok(())
269}
270
271/// Validate every field of a fully-deserialized
272/// [`Config`](crate::config::Config) using the
273/// same per-key rules as [`validate_field`]. This is used by
274/// `ConfigService::load_for_repair` so that a TOML-parsed but
275/// strict-invalid config still has its individual field invariants
276/// enforced before being handed to the `config set/get/list` handlers.
277///
278/// Cross-section validation (e.g. provider/scheme pairings) is **not**
279/// performed here — that lives in [`crate::config::validator::validate_config`].
280///
281/// # Errors
282///
283/// Returns the first per-field validation error encountered.
284pub fn validate_all_fields(config: &crate::config::Config) -> Result<()> {
285    let ai = &config.ai;
286    validate_field("ai.provider", &ai.provider)?;
287    validate_field("ai.model", &ai.model)?;
288    if let Some(ref key) = ai.api_key {
289        validate_field("ai.api_key", key)?;
290    }
291    validate_field("ai.base_url", &ai.base_url)?;
292    validate_field("ai.temperature", &ai.temperature.to_string())?;
293    validate_field("ai.max_tokens", &ai.max_tokens.to_string())?;
294    validate_field("ai.max_sample_length", &ai.max_sample_length.to_string())?;
295    validate_field("ai.retry_attempts", &ai.retry_attempts.to_string())?;
296    validate_field("ai.retry_delay_ms", &ai.retry_delay_ms.to_string())?;
297    validate_field(
298        "ai.request_timeout_seconds",
299        &ai.request_timeout_seconds.to_string(),
300    )?;
301    if let Some(ref v) = ai.api_version {
302        validate_field("ai.api_version", v)?;
303    }
304
305    let s = &config.sync;
306    validate_field("sync.default_method", &s.default_method)?;
307    validate_field("sync.max_offset_seconds", &s.max_offset_seconds.to_string())?;
308    validate_field("sync.vad.enabled", &s.vad.enabled.to_string())?;
309    validate_field("sync.vad.sensitivity", &s.vad.sensitivity.to_string())?;
310    validate_field("sync.vad.padding_chunks", &s.vad.padding_chunks.to_string())?;
311    validate_field(
312        "sync.vad.min_speech_duration_ms",
313        &s.vad.min_speech_duration_ms.to_string(),
314    )?;
315
316    let f = &config.formats;
317    validate_field("formats.default_output", &f.default_output)?;
318    validate_field("formats.preserve_styling", &f.preserve_styling.to_string())?;
319    validate_field("formats.default_encoding", &f.default_encoding)?;
320    validate_field(
321        "formats.encoding_detection_confidence",
322        &f.encoding_detection_confidence.to_string(),
323    )?;
324
325    let g = &config.general;
326    validate_field("general.backup_enabled", &g.backup_enabled.to_string())?;
327    validate_field(
328        "general.max_concurrent_jobs",
329        &g.max_concurrent_jobs.to_string(),
330    )?;
331    validate_field(
332        "general.task_timeout_seconds",
333        &g.task_timeout_seconds.to_string(),
334    )?;
335    validate_field(
336        "general.enable_progress_bar",
337        &g.enable_progress_bar.to_string(),
338    )?;
339    validate_field(
340        "general.worker_idle_timeout_seconds",
341        &g.worker_idle_timeout_seconds.to_string(),
342    )?;
343    validate_field(
344        "general.max_subtitle_bytes",
345        &g.max_subtitle_bytes.to_string(),
346    )?;
347    validate_field("general.max_audio_bytes", &g.max_audio_bytes.to_string())?;
348
349    let p = &config.parallel;
350    validate_field("parallel.max_workers", &p.max_workers.to_string())?;
351    validate_field("parallel.task_queue_size", &p.task_queue_size.to_string())?;
352    validate_field(
353        "parallel.enable_task_priorities",
354        &p.enable_task_priorities.to_string(),
355    )?;
356    validate_field(
357        "parallel.auto_balance_workers",
358        &p.auto_balance_workers.to_string(),
359    )?;
360    validate_field(
361        "parallel.overflow_strategy",
362        &format!("{:?}", p.overflow_strategy),
363    )?;
364
365    let t = &config.translation;
366    validate_field("translation.batch_size", &t.batch_size.to_string())?;
367    if let Some(ref lang) = t.default_target_language {
368        validate_field("translation.default_target_language", lang)?;
369    }
370
371    Ok(())
372}
373
374/// Get a user-friendly description for a configuration field.
375pub fn get_field_description(key: &str) -> &'static str {
376    match key {
377        "ai.provider" => {
378            "AI service provider ('openai', 'openrouter', 'azure-openai', or 'local'; 'ollama' is accepted as an alias for 'local')"
379        }
380        "ai.model" => "AI model name (e.g., 'gpt-4.1-mini')",
381        "ai.api_key" => "API key for the AI service",
382        "ai.base_url" => "Custom API endpoint URL (optional)",
383        "ai.temperature" => "AI response randomness (0.0-2.0)",
384        "ai.max_tokens" => "Maximum tokens in AI response",
385        "ai.max_sample_length" => "Maximum sample length for AI processing",
386        "ai.retry_attempts" => "Number of retry attempts for AI requests",
387        "ai.retry_delay_ms" => "Delay between retry attempts in milliseconds",
388        "ai.request_timeout_seconds" => "Request timeout in seconds",
389        "ai.api_version" => "Azure OpenAI API version (optional, defaults to latest)",
390
391        "sync.default_method" => "Synchronization method ('auto', 'vad', or 'manual')",
392        "sync.max_offset_seconds" => "Maximum allowed time offset in seconds",
393        "sync.vad.enabled" => "Enable voice activity detection",
394        "sync.vad.sensitivity" => "Voice activity detection threshold (0.0-1.0)",
395        "sync.vad.chunk_size" => "VAD processing chunk size (must be power of 2)",
396        "sync.vad.sample_rate" => "Audio sample rate for VAD processing",
397        "sync.vad.padding_chunks" => "Number of padding chunks for VAD",
398        "sync.vad.min_speech_duration_ms" => "Minimum speech duration in milliseconds",
399
400        "formats.default_output" => "Default output format for subtitles",
401        "formats.preserve_styling" => "Preserve subtitle styling information",
402        "formats.default_encoding" => "Default character encoding",
403        "formats.encoding_detection_confidence" => "Confidence threshold for encoding detection",
404
405        "general.backup_enabled" => "Enable automatic backup creation",
406        "general.max_concurrent_jobs" => "Maximum number of concurrent jobs",
407        "general.task_timeout_seconds" => "Task timeout in seconds",
408        "general.enable_progress_bar" => "Enable progress bar display",
409        "general.worker_idle_timeout_seconds" => "Worker idle timeout in seconds",
410        "general.max_subtitle_bytes" => "Maximum subtitle file size in bytes",
411        "general.max_audio_bytes" => "Maximum audio file size in bytes",
412
413        "parallel.max_workers" => "Maximum number of worker threads",
414        "parallel.task_queue_size" => "Size of the task queue",
415        "parallel.enable_task_priorities" => "Enable task priority system",
416        "parallel.auto_balance_workers" => "Enable automatic worker load balancing",
417        "parallel.overflow_strategy" => "Strategy for handling queue overflow",
418
419        "translation.batch_size" => "Maximum subtitle cues per AI translation request (1-1000)",
420        "translation.default_target_language" => {
421            "Default target language used when --target-language is omitted"
422        }
423
424        _ => "Configuration field",
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    // ── ai.provider ──────────────────────────────────────────────────────────
433
434    #[test]
435    fn test_ai_provider_valid_all_enum_values() {
436        for v in &[
437            "openai",
438            "anthropic",
439            "local",
440            "ollama",
441            "openrouter",
442            "azure-openai",
443        ] {
444            assert!(validate_field("ai.provider", v).is_ok(), "provider={v}");
445        }
446    }
447
448    // ── normalize_ai_provider ────────────────────────────────────────────────
449
450    #[test]
451    fn test_normalize_ai_provider_ollama_alias() {
452        assert_eq!(normalize_ai_provider("ollama"), "local");
453        assert_eq!(normalize_ai_provider("OLLAMA"), "local");
454        assert_eq!(normalize_ai_provider(" ollama "), "local");
455        assert_eq!(normalize_ai_provider("\tOllama\n"), "local");
456    }
457
458    #[test]
459    fn test_normalize_ai_provider_canonical_pass_through() {
460        for v in &["openai", "openrouter", "azure-openai", "local"] {
461            assert_eq!(normalize_ai_provider(v), *v, "canonical input {v}");
462        }
463    }
464
465    #[test]
466    fn test_normalize_ai_provider_unknown_pass_through_lowercased() {
467        // Unknown values are returned (trimmed + lowercased) so the
468        // downstream allow-list still rejects them with the existing error.
469        assert_eq!(normalize_ai_provider("grok"), "grok");
470        assert_eq!(normalize_ai_provider("GROK"), "grok");
471        assert_eq!(
472            normalize_ai_provider("  unknown-provider  "),
473            "unknown-provider"
474        );
475    }
476
477    #[test]
478    fn test_ai_provider_invalid_value() {
479        assert!(validate_field("ai.provider", "grok").is_err());
480        assert!(validate_field("ai.provider", "OPENAI").is_err()); // case-sensitive
481    }
482
483    #[test]
484    fn test_ai_provider_empty_value() {
485        assert!(validate_field("ai.provider", "").is_err());
486    }
487
488    // ── ai.model ─────────────────────────────────────────────────────────────
489
490    #[test]
491    fn test_ai_model_valid() {
492        assert!(validate_field("ai.model", "gpt-4.1-mini").is_ok());
493        assert!(validate_field("ai.model", &"a".repeat(100)).is_ok()); // max length
494    }
495
496    #[test]
497    fn test_ai_model_empty() {
498        assert!(validate_field("ai.model", "").is_err());
499    }
500
501    #[test]
502    fn test_ai_model_too_long() {
503        assert!(validate_field("ai.model", &"a".repeat(101)).is_err());
504    }
505
506    // ── ai.api_key ────────────────────────────────────────────────────────────
507
508    #[test]
509    fn test_ai_api_key_empty_is_ok() {
510        // Empty api_key is explicitly allowed (optional)
511        assert!(validate_field("ai.api_key", "").is_ok());
512    }
513
514    #[test]
515    fn test_ai_api_key_valid() {
516        assert!(validate_field("ai.api_key", "sk-abcdefghij1234").is_ok());
517    }
518
519    #[test]
520    fn test_ai_api_key_too_short() {
521        assert!(validate_field("ai.api_key", "short").is_err()); // < 10 chars
522    }
523
524    // ── ai.base_url ───────────────────────────────────────────────────────────
525
526    #[test]
527    fn test_ai_base_url_valid() {
528        assert!(validate_field("ai.base_url", "https://api.openai.com/v1").is_ok());
529        assert!(validate_field("ai.base_url", "http://localhost:8080").is_ok());
530    }
531
532    #[test]
533    fn test_ai_base_url_empty_is_ok() {
534        assert!(validate_field("ai.base_url", "").is_ok());
535        assert!(validate_field("ai.base_url", "   ").is_ok());
536    }
537
538    #[test]
539    fn test_ai_base_url_invalid() {
540        assert!(validate_field("ai.base_url", "not-a-url").is_err());
541        assert!(validate_field("ai.base_url", "://missing-scheme").is_err());
542    }
543
544    // ── ai.temperature ────────────────────────────────────────────────────────
545
546    #[test]
547    fn test_ai_temperature_valid_boundaries() {
548        assert!(validate_field("ai.temperature", "0.0").is_ok());
549        assert!(validate_field("ai.temperature", "2.0").is_ok());
550        assert!(validate_field("ai.temperature", "1.0").is_ok());
551    }
552
553    #[test]
554    fn test_ai_temperature_out_of_range() {
555        assert!(validate_field("ai.temperature", "-0.1").is_err());
556        assert!(validate_field("ai.temperature", "2.1").is_err());
557    }
558
559    #[test]
560    fn test_ai_temperature_parse_error() {
561        assert!(validate_field("ai.temperature", "hot").is_err());
562        assert!(validate_field("ai.temperature", "").is_err());
563    }
564
565    // ── ai.max_tokens ─────────────────────────────────────────────────────────
566
567    #[test]
568    fn test_ai_max_tokens_valid() {
569        assert!(validate_field("ai.max_tokens", "1").is_ok());
570        assert!(validate_field("ai.max_tokens", "4096").is_ok());
571    }
572
573    #[test]
574    fn test_ai_max_tokens_zero_is_err() {
575        assert!(validate_field("ai.max_tokens", "0").is_err());
576    }
577
578    #[test]
579    fn test_ai_max_tokens_parse_error() {
580        assert!(validate_field("ai.max_tokens", "lots").is_err());
581        assert!(validate_field("ai.max_tokens", "-1").is_err());
582    }
583
584    // ── ai.max_sample_length ─────────────────────────────────────────────────
585
586    #[test]
587    fn test_ai_max_sample_length_valid_boundaries() {
588        assert!(validate_field("ai.max_sample_length", "100").is_ok());
589        assert!(validate_field("ai.max_sample_length", "10000").is_ok());
590        assert!(validate_field("ai.max_sample_length", "5000").is_ok());
591    }
592
593    #[test]
594    fn test_ai_max_sample_length_out_of_range() {
595        assert!(validate_field("ai.max_sample_length", "99").is_err());
596        assert!(validate_field("ai.max_sample_length", "10001").is_err());
597    }
598
599    #[test]
600    fn test_ai_max_sample_length_parse_error() {
601        assert!(validate_field("ai.max_sample_length", "big").is_err());
602    }
603
604    // ── ai.retry_attempts ─────────────────────────────────────────────────────
605
606    #[test]
607    fn test_ai_retry_attempts_valid_boundaries() {
608        assert!(validate_field("ai.retry_attempts", "1").is_ok());
609        assert!(validate_field("ai.retry_attempts", "10").is_ok());
610        assert!(validate_field("ai.retry_attempts", "3").is_ok());
611    }
612
613    #[test]
614    fn test_ai_retry_attempts_out_of_range() {
615        assert!(validate_field("ai.retry_attempts", "0").is_err());
616        assert!(validate_field("ai.retry_attempts", "11").is_err());
617    }
618
619    #[test]
620    fn test_ai_retry_attempts_parse_error() {
621        assert!(validate_field("ai.retry_attempts", "many").is_err());
622    }
623
624    // ── ai.retry_delay_ms ─────────────────────────────────────────────────────
625
626    #[test]
627    fn test_ai_retry_delay_ms_valid_boundaries() {
628        assert!(validate_field("ai.retry_delay_ms", "100").is_ok());
629        assert!(validate_field("ai.retry_delay_ms", "30000").is_ok());
630        assert!(validate_field("ai.retry_delay_ms", "1000").is_ok());
631    }
632
633    #[test]
634    fn test_ai_retry_delay_ms_out_of_range() {
635        assert!(validate_field("ai.retry_delay_ms", "99").is_err());
636        assert!(validate_field("ai.retry_delay_ms", "30001").is_err());
637    }
638
639    #[test]
640    fn test_ai_retry_delay_ms_parse_error() {
641        assert!(validate_field("ai.retry_delay_ms", "fast").is_err());
642    }
643
644    // ── ai.request_timeout_seconds ────────────────────────────────────────────
645
646    #[test]
647    fn test_ai_request_timeout_seconds_valid_boundaries() {
648        assert!(validate_field("ai.request_timeout_seconds", "10").is_ok());
649        assert!(validate_field("ai.request_timeout_seconds", "600").is_ok());
650        assert!(validate_field("ai.request_timeout_seconds", "60").is_ok());
651    }
652
653    #[test]
654    fn test_ai_request_timeout_seconds_out_of_range() {
655        assert!(validate_field("ai.request_timeout_seconds", "9").is_err());
656        assert!(validate_field("ai.request_timeout_seconds", "601").is_err());
657    }
658
659    #[test]
660    fn test_ai_request_timeout_seconds_parse_error() {
661        assert!(validate_field("ai.request_timeout_seconds", "now").is_err());
662    }
663
664    // ── ai.api_version ────────────────────────────────────────────────────────
665
666    #[test]
667    fn test_ai_api_version_valid() {
668        assert!(validate_field("ai.api_version", "2024-02-15-preview").is_ok());
669        assert!(validate_field("ai.api_version", "v1").is_ok());
670    }
671
672    #[test]
673    fn test_ai_api_version_empty_is_err() {
674        assert!(validate_field("ai.api_version", "").is_err());
675        assert!(validate_field("ai.api_version", "   ").is_err());
676    }
677
678    // ── sync.default_method ───────────────────────────────────────────────────
679
680    #[test]
681    fn test_sync_default_method_valid_all_values() {
682        assert!(validate_field("sync.default_method", "auto").is_ok());
683        assert!(validate_field("sync.default_method", "vad").is_ok());
684        assert!(validate_field("sync.default_method", "manual").is_ok());
685    }
686
687    #[test]
688    fn test_sync_default_method_invalid() {
689        assert!(validate_field("sync.default_method", "force").is_err());
690        assert!(validate_field("sync.default_method", "").is_err());
691    }
692
693    // ── sync.max_offset_seconds ───────────────────────────────────────────────
694
695    #[test]
696    fn test_sync_max_offset_seconds_valid_boundaries() {
697        assert!(validate_field("sync.max_offset_seconds", "0.1").is_ok());
698        assert!(validate_field("sync.max_offset_seconds", "3600.0").is_ok());
699        assert!(validate_field("sync.max_offset_seconds", "10.0").is_ok());
700    }
701
702    #[test]
703    fn test_sync_max_offset_seconds_out_of_range() {
704        assert!(validate_field("sync.max_offset_seconds", "0.0").is_err());
705        assert!(validate_field("sync.max_offset_seconds", "3601.0").is_err());
706    }
707
708    #[test]
709    fn test_sync_max_offset_seconds_parse_error() {
710        assert!(validate_field("sync.max_offset_seconds", "abit").is_err());
711    }
712
713    // ── sync.vad.enabled ──────────────────────────────────────────────────────
714
715    #[test]
716    fn test_sync_vad_enabled_valid() {
717        assert!(validate_field("sync.vad.enabled", "true").is_ok());
718        assert!(validate_field("sync.vad.enabled", "false").is_ok());
719        assert!(validate_field("sync.vad.enabled", "1").is_ok());
720        assert!(validate_field("sync.vad.enabled", "0").is_ok());
721    }
722
723    #[test]
724    fn test_sync_vad_enabled_invalid() {
725        assert!(validate_field("sync.vad.enabled", "maybe").is_err());
726    }
727
728    // ── sync.vad.sensitivity ──────────────────────────────────────────────────
729
730    #[test]
731    fn test_sync_vad_sensitivity_valid_boundaries() {
732        assert!(validate_field("sync.vad.sensitivity", "0.0").is_ok());
733        assert!(validate_field("sync.vad.sensitivity", "1.0").is_ok());
734        assert!(validate_field("sync.vad.sensitivity", "0.5").is_ok());
735    }
736
737    #[test]
738    fn test_sync_vad_sensitivity_out_of_range() {
739        assert!(validate_field("sync.vad.sensitivity", "-0.1").is_err());
740        assert!(validate_field("sync.vad.sensitivity", "1.1").is_err());
741    }
742
743    #[test]
744    fn test_sync_vad_sensitivity_parse_error() {
745        assert!(validate_field("sync.vad.sensitivity", "high").is_err());
746    }
747
748    // ── sync.vad.padding_chunks ───────────────────────────────────────────────
749
750    #[test]
751    fn test_sync_vad_padding_chunks_valid_boundaries() {
752        assert!(validate_field("sync.vad.padding_chunks", "0").is_ok());
753        assert!(validate_field("sync.vad.padding_chunks", "10").is_ok());
754        assert!(validate_field("sync.vad.padding_chunks", "5").is_ok());
755    }
756
757    #[test]
758    fn test_sync_vad_padding_chunks_out_of_range() {
759        assert!(validate_field("sync.vad.padding_chunks", "11").is_err());
760    }
761
762    #[test]
763    fn test_sync_vad_padding_chunks_parse_error() {
764        assert!(validate_field("sync.vad.padding_chunks", "some").is_err());
765        assert!(validate_field("sync.vad.padding_chunks", "-1").is_err());
766    }
767
768    // ── sync.vad.min_speech_duration_ms ───────────────────────────────────────
769
770    #[test]
771    fn test_sync_vad_min_speech_duration_ms_valid() {
772        assert!(validate_field("sync.vad.min_speech_duration_ms", "0").is_ok());
773        assert!(validate_field("sync.vad.min_speech_duration_ms", "250").is_ok());
774        assert!(validate_field("sync.vad.min_speech_duration_ms", "4294967295").is_ok()); // u32::MAX
775    }
776
777    #[test]
778    fn test_sync_vad_min_speech_duration_ms_parse_error() {
779        assert!(validate_field("sync.vad.min_speech_duration_ms", "long").is_err());
780        assert!(validate_field("sync.vad.min_speech_duration_ms", "-1").is_err());
781    }
782
783    // ── formats.default_output ────────────────────────────────────────────────
784
785    #[test]
786    fn test_formats_default_output_valid_all_values() {
787        for v in &["srt", "ass", "vtt", "webvtt"] {
788            assert!(
789                validate_field("formats.default_output", v).is_ok(),
790                "format={v}"
791            );
792        }
793    }
794
795    #[test]
796    fn test_formats_default_output_invalid() {
797        assert!(validate_field("formats.default_output", "txt").is_err());
798        assert!(validate_field("formats.default_output", "SRT").is_err());
799    }
800
801    // ── formats.preserve_styling ──────────────────────────────────────────────
802
803    #[test]
804    fn test_formats_preserve_styling_valid() {
805        assert!(validate_field("formats.preserve_styling", "true").is_ok());
806        assert!(validate_field("formats.preserve_styling", "false").is_ok());
807        assert!(validate_field("formats.preserve_styling", "yes").is_ok());
808        assert!(validate_field("formats.preserve_styling", "no").is_ok());
809    }
810
811    #[test]
812    fn test_formats_preserve_styling_invalid() {
813        assert!(validate_field("formats.preserve_styling", "maybe").is_err());
814    }
815
816    // ── formats.default_encoding ──────────────────────────────────────────────
817
818    #[test]
819    fn test_formats_default_encoding_valid_all_values() {
820        for v in &["utf-8", "gbk", "big5", "shift_jis"] {
821            assert!(
822                validate_field("formats.default_encoding", v).is_ok(),
823                "enc={v}"
824            );
825        }
826    }
827
828    #[test]
829    fn test_formats_default_encoding_invalid() {
830        assert!(validate_field("formats.default_encoding", "latin1").is_err());
831        assert!(validate_field("formats.default_encoding", "UTF-8").is_err()); // case-sensitive
832    }
833
834    // ── formats.encoding_detection_confidence ────────────────────────────────
835
836    #[test]
837    fn test_formats_encoding_detection_confidence_valid_boundaries() {
838        assert!(validate_field("formats.encoding_detection_confidence", "0.0").is_ok());
839        assert!(validate_field("formats.encoding_detection_confidence", "1.0").is_ok());
840        assert!(validate_field("formats.encoding_detection_confidence", "0.75").is_ok());
841    }
842
843    #[test]
844    fn test_formats_encoding_detection_confidence_out_of_range() {
845        assert!(validate_field("formats.encoding_detection_confidence", "-0.1").is_err());
846        assert!(validate_field("formats.encoding_detection_confidence", "1.1").is_err());
847    }
848
849    #[test]
850    fn test_formats_encoding_detection_confidence_parse_error() {
851        assert!(validate_field("formats.encoding_detection_confidence", "high").is_err());
852    }
853
854    // ── general.backup_enabled ────────────────────────────────────────────────
855
856    #[test]
857    fn test_general_backup_enabled_valid() {
858        assert!(validate_field("general.backup_enabled", "true").is_ok());
859        assert!(validate_field("general.backup_enabled", "false").is_ok());
860        assert!(validate_field("general.backup_enabled", "on").is_ok());
861        assert!(validate_field("general.backup_enabled", "off").is_ok());
862    }
863
864    #[test]
865    fn test_general_backup_enabled_invalid() {
866        assert!(validate_field("general.backup_enabled", "yeah").is_err());
867    }
868
869    // ── general.max_concurrent_jobs ───────────────────────────────────────────
870
871    #[test]
872    fn test_general_max_concurrent_jobs_valid_boundaries() {
873        assert!(validate_field("general.max_concurrent_jobs", "1").is_ok());
874        assert!(validate_field("general.max_concurrent_jobs", "64").is_ok());
875        assert!(validate_field("general.max_concurrent_jobs", "8").is_ok());
876    }
877
878    #[test]
879    fn test_general_max_concurrent_jobs_out_of_range() {
880        assert!(validate_field("general.max_concurrent_jobs", "0").is_err());
881        assert!(validate_field("general.max_concurrent_jobs", "65").is_err());
882    }
883
884    #[test]
885    fn test_general_max_concurrent_jobs_parse_error() {
886        assert!(validate_field("general.max_concurrent_jobs", "many").is_err());
887    }
888
889    // ── general.task_timeout_seconds ──────────────────────────────────────────
890
891    #[test]
892    fn test_general_task_timeout_seconds_valid_boundaries() {
893        assert!(validate_field("general.task_timeout_seconds", "30").is_ok());
894        assert!(validate_field("general.task_timeout_seconds", "3600").is_ok());
895        assert!(validate_field("general.task_timeout_seconds", "300").is_ok());
896    }
897
898    #[test]
899    fn test_general_task_timeout_seconds_out_of_range() {
900        assert!(validate_field("general.task_timeout_seconds", "29").is_err());
901        assert!(validate_field("general.task_timeout_seconds", "3601").is_err());
902    }
903
904    #[test]
905    fn test_general_task_timeout_seconds_parse_error() {
906        assert!(validate_field("general.task_timeout_seconds", "forever").is_err());
907    }
908
909    // ── general.enable_progress_bar ───────────────────────────────────────────
910
911    #[test]
912    fn test_general_enable_progress_bar_valid() {
913        assert!(validate_field("general.enable_progress_bar", "true").is_ok());
914        assert!(validate_field("general.enable_progress_bar", "false").is_ok());
915    }
916
917    #[test]
918    fn test_general_enable_progress_bar_invalid() {
919        assert!(validate_field("general.enable_progress_bar", "show").is_err());
920    }
921
922    // ── general.worker_idle_timeout_seconds ───────────────────────────────────
923
924    #[test]
925    fn test_general_worker_idle_timeout_seconds_valid_boundaries() {
926        assert!(validate_field("general.worker_idle_timeout_seconds", "10").is_ok());
927        assert!(validate_field("general.worker_idle_timeout_seconds", "3600").is_ok());
928        assert!(validate_field("general.worker_idle_timeout_seconds", "60").is_ok());
929    }
930
931    #[test]
932    fn test_general_worker_idle_timeout_seconds_out_of_range() {
933        assert!(validate_field("general.worker_idle_timeout_seconds", "9").is_err());
934        assert!(validate_field("general.worker_idle_timeout_seconds", "3601").is_err());
935    }
936
937    #[test]
938    fn test_general_worker_idle_timeout_seconds_parse_error() {
939        assert!(validate_field("general.worker_idle_timeout_seconds", "soon").is_err());
940    }
941
942    // ── general.max_subtitle_bytes ────────────────────────────────────────────
943
944    #[test]
945    fn test_general_max_subtitle_bytes_valid_boundaries() {
946        assert!(validate_field("general.max_subtitle_bytes", "1024").is_ok());
947        assert!(validate_field("general.max_subtitle_bytes", "1073741824").is_ok());
948        assert!(validate_field("general.max_subtitle_bytes", "10485760").is_ok());
949    }
950
951    #[test]
952    fn test_general_max_subtitle_bytes_out_of_range() {
953        assert!(validate_field("general.max_subtitle_bytes", "1023").is_err());
954        assert!(validate_field("general.max_subtitle_bytes", "1073741825").is_err());
955    }
956
957    #[test]
958    fn test_general_max_subtitle_bytes_parse_error() {
959        assert!(validate_field("general.max_subtitle_bytes", "huge").is_err());
960    }
961
962    // ── general.max_audio_bytes ───────────────────────────────────────────────
963
964    #[test]
965    fn test_general_max_audio_bytes_valid_boundaries() {
966        assert!(validate_field("general.max_audio_bytes", "1024").is_ok());
967        assert!(validate_field("general.max_audio_bytes", "10737418240").is_ok());
968        assert!(validate_field("general.max_audio_bytes", "104857600").is_ok());
969    }
970
971    #[test]
972    fn test_general_max_audio_bytes_out_of_range() {
973        assert!(validate_field("general.max_audio_bytes", "1023").is_err());
974        assert!(validate_field("general.max_audio_bytes", "10737418241").is_err());
975    }
976
977    #[test]
978    fn test_general_max_audio_bytes_parse_error() {
979        assert!(validate_field("general.max_audio_bytes", "big").is_err());
980    }
981
982    // ── parallel.max_workers ──────────────────────────────────────────────────
983
984    #[test]
985    fn test_parallel_max_workers_valid_boundaries() {
986        assert!(validate_field("parallel.max_workers", "1").is_ok());
987        assert!(validate_field("parallel.max_workers", "64").is_ok());
988        assert!(validate_field("parallel.max_workers", "4").is_ok());
989    }
990
991    #[test]
992    fn test_parallel_max_workers_out_of_range() {
993        assert!(validate_field("parallel.max_workers", "0").is_err());
994        assert!(validate_field("parallel.max_workers", "65").is_err());
995    }
996
997    #[test]
998    fn test_parallel_max_workers_parse_error() {
999        assert!(validate_field("parallel.max_workers", "auto").is_err());
1000    }
1001
1002    // ── parallel.task_queue_size ──────────────────────────────────────────────
1003
1004    #[test]
1005    fn test_parallel_task_queue_size_valid_boundaries() {
1006        assert!(validate_field("parallel.task_queue_size", "100").is_ok());
1007        assert!(validate_field("parallel.task_queue_size", "10000").is_ok());
1008        assert!(validate_field("parallel.task_queue_size", "1000").is_ok());
1009    }
1010
1011    #[test]
1012    fn test_parallel_task_queue_size_out_of_range() {
1013        assert!(validate_field("parallel.task_queue_size", "99").is_err());
1014        assert!(validate_field("parallel.task_queue_size", "10001").is_err());
1015    }
1016
1017    #[test]
1018    fn test_parallel_task_queue_size_parse_error() {
1019        assert!(validate_field("parallel.task_queue_size", "large").is_err());
1020    }
1021
1022    // ── parallel.enable_task_priorities ───────────────────────────────────────
1023
1024    #[test]
1025    fn test_parallel_enable_task_priorities_valid() {
1026        assert!(validate_field("parallel.enable_task_priorities", "true").is_ok());
1027        assert!(validate_field("parallel.enable_task_priorities", "false").is_ok());
1028    }
1029
1030    #[test]
1031    fn test_parallel_enable_task_priorities_invalid() {
1032        assert!(validate_field("parallel.enable_task_priorities", "yes_please").is_err());
1033    }
1034
1035    // ── parallel.auto_balance_workers ─────────────────────────────────────────
1036
1037    #[test]
1038    fn test_parallel_auto_balance_workers_valid() {
1039        assert!(validate_field("parallel.auto_balance_workers", "true").is_ok());
1040        assert!(validate_field("parallel.auto_balance_workers", "false").is_ok());
1041    }
1042
1043    #[test]
1044    fn test_parallel_auto_balance_workers_invalid() {
1045        assert!(validate_field("parallel.auto_balance_workers", "auto").is_err());
1046    }
1047
1048    // ── parallel.overflow_strategy ────────────────────────────────────────────
1049
1050    #[test]
1051    fn test_parallel_overflow_strategy_valid_all_values() {
1052        for v in &["Block", "Drop", "Expand"] {
1053            assert!(
1054                validate_field("parallel.overflow_strategy", v).is_ok(),
1055                "strategy={v}"
1056            );
1057        }
1058    }
1059
1060    #[test]
1061    fn test_parallel_overflow_strategy_invalid() {
1062        assert!(validate_field("parallel.overflow_strategy", "block").is_err()); // case-sensitive
1063        assert!(validate_field("parallel.overflow_strategy", "Ignore").is_err());
1064    }
1065
1066    // ── unknown key ───────────────────────────────────────────────────────────
1067
1068    #[test]
1069    fn test_validate_unknown_field_returns_error() {
1070        assert!(validate_field("unknown.field", "value").is_err());
1071        assert!(validate_field("ai", "openai").is_err());
1072        assert!(validate_field("", "value").is_err());
1073    }
1074
1075    // ── get_field_description ────────────────────────────────────────────────
1076
1077    #[test]
1078    fn test_get_field_description_all_known_keys() {
1079        let known_keys = [
1080            "ai.provider",
1081            "ai.model",
1082            "ai.api_key",
1083            "ai.base_url",
1084            "ai.temperature",
1085            "ai.max_tokens",
1086            "ai.max_sample_length",
1087            "ai.retry_attempts",
1088            "ai.retry_delay_ms",
1089            "ai.request_timeout_seconds",
1090            "ai.api_version",
1091            "sync.default_method",
1092            "sync.max_offset_seconds",
1093            "sync.vad.enabled",
1094            "sync.vad.sensitivity",
1095            "sync.vad.chunk_size",
1096            "sync.vad.sample_rate",
1097            "sync.vad.padding_chunks",
1098            "sync.vad.min_speech_duration_ms",
1099            "formats.default_output",
1100            "formats.preserve_styling",
1101            "formats.default_encoding",
1102            "formats.encoding_detection_confidence",
1103            "general.backup_enabled",
1104            "general.max_concurrent_jobs",
1105            "general.task_timeout_seconds",
1106            "general.enable_progress_bar",
1107            "general.worker_idle_timeout_seconds",
1108            "general.max_subtitle_bytes",
1109            "general.max_audio_bytes",
1110            "parallel.max_workers",
1111            "parallel.task_queue_size",
1112            "parallel.enable_task_priorities",
1113            "parallel.auto_balance_workers",
1114            "parallel.overflow_strategy",
1115        ];
1116        for key in &known_keys {
1117            let desc = get_field_description(key);
1118            assert!(!desc.is_empty(), "empty description for {key}");
1119            assert_ne!(desc, "Configuration field", "generic fallback for {key}");
1120        }
1121    }
1122
1123    #[test]
1124    fn test_get_field_description_unknown_key_returns_fallback() {
1125        assert_eq!(
1126            get_field_description("unknown.field"),
1127            "Configuration field"
1128        );
1129        assert_eq!(get_field_description(""), "Configuration field");
1130    }
1131
1132    // ── validate_all_fields ──────────────────────────────────────────────────
1133
1134    #[test]
1135    fn validate_all_fields_accepts_default_config() {
1136        let cfg = crate::config::Config::default();
1137        assert!(validate_all_fields(&cfg).is_ok());
1138    }
1139
1140    #[test]
1141    fn validate_all_fields_rejects_out_of_range_max_sample_length() {
1142        let mut cfg = crate::config::Config::default();
1143        cfg.ai.max_sample_length = 10;
1144        assert!(validate_all_fields(&cfg).is_err());
1145    }
1146
1147    #[test]
1148    fn validate_all_fields_rejects_out_of_range_retry_delay_ms() {
1149        let mut cfg = crate::config::Config::default();
1150        cfg.ai.retry_delay_ms = 50;
1151        assert!(validate_all_fields(&cfg).is_err());
1152    }
1153
1154    #[test]
1155    fn validate_all_fields_rejects_out_of_range_max_subtitle_bytes() {
1156        let mut cfg = crate::config::Config::default();
1157        cfg.general.max_subtitle_bytes = 10;
1158        assert!(validate_all_fields(&cfg).is_err());
1159    }
1160
1161    #[test]
1162    fn validate_all_fields_rejects_oversized_task_queue_size() {
1163        let mut cfg = crate::config::Config::default();
1164        cfg.parallel.task_queue_size = 999_999;
1165        assert!(validate_all_fields(&cfg).is_err());
1166    }
1167}