Skip to main content

subx_cli/config/
mod.rs

1// src/config/mod.rs
2#![allow(deprecated)]
3//! Configuration management module for SubX.
4//!
5//! This module provides the complete configuration service system with
6//! dependency injection support and comprehensive type definitions.
7//!
8//! # Key Components
9//!
10//! - [`Config`] - Main configuration structure containing all settings
11//! - [`ConfigService`] - Service interface for configuration management
12//! - [`ProductionConfigService`] - Production implementation with file I/O
13//! - [`TestConfigService`] - Test implementation with controlled behavior
14//! - [`TestConfigBuilder`] - Builder pattern for test configurations
15//!
16//! # Validation System
17//!
18//! The configuration system provides a layered validation architecture:
19//!
20//! - [`validation`] - Low-level validation functions for individual values
21//! - [`validator`] - High-level configuration section validators
22//! - [`field_validator`] - Key-value validation for configuration service
23//!
24//! ## Architecture
25//!
26//! ```text
27//! ConfigService
28//!      ↓
29//! field_validator (key-value validation)
30//!      ↓
31//! validation (primitive validation functions)
32//!
33//! validator (section validation)
34//!      ↓
35//! validation (primitive validation functions)
36//! ```
37//!
38//! # Examples
39//!
40//! ```rust,no_run
41//! use subx_cli::config::{Config, ConfigService, ProductionConfigService};
42//!
43//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
44//! // Create a production configuration service
45//! let config_service = ProductionConfigService::new()?;
46//!
47//! // Load configuration
48//! let config = config_service.get_config()?;
49//! println!("AI Provider: {}", config.ai.provider);
50//! # Ok(())
51//! # }
52//! ```
53//!
54//! # Architecture
55//!
56//! The configuration system uses dependency injection to provide testable
57//! and maintainable configuration management. All configuration access
58//! should go through the [`ConfigService`] trait.
59
60use serde::{Deserialize, Serialize};
61use std::fmt;
62use std::path::PathBuf;
63
64// Configuration service system
65pub mod builder;
66pub mod environment;
67pub mod field_validator;
68pub mod masking;
69pub mod service;
70pub mod test_macros;
71pub mod test_service;
72pub mod validation;
73pub mod validator;
74
75pub use masking::mask_sensitive_value;
76
77// ============================================================================
78// Configuration Type Definitions
79// ============================================================================
80
81/// Full application configuration for SubX.
82///
83/// This struct aggregates all settings for AI integration, subtitle format
84/// conversion, synchronization, general options, and parallel execution.
85///
86/// # Examples
87///
88/// ```rust
89/// use subx_cli::config::Config;
90///
91/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
92/// let config = Config::default();
93/// assert_eq!(config.ai.provider, "openai");
94/// assert_eq!(config.formats.default_output, "srt");
95/// # Ok(())
96/// # }
97/// ```
98///
99/// # Serialization
100///
101/// This struct can be serialized to/from TOML format for configuration files.
102///
103/// ```rust
104/// use subx_cli::config::Config;
105///
106/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
107/// let config = Config::default();
108/// let toml_str = toml::to_string(&config)?;
109/// assert!(toml_str.contains("[ai]"));
110/// # Ok(())
111/// # }
112/// ```
113#[derive(Debug, Serialize, Deserialize, Clone, Default)]
114pub struct Config {
115    /// AI service configuration parameters.
116    pub ai: AIConfig,
117    /// Subtitle format conversion settings.
118    pub formats: FormatsConfig,
119    /// Audio-subtitle synchronization options.
120    pub sync: SyncConfig,
121    /// General runtime options (e.g., backup enabled, job limits).
122    pub general: GeneralConfig,
123    /// Parallel processing parameters.
124    pub parallel: ParallelConfig,
125    /// Subtitle translation configuration parameters.
126    #[serde(default)]
127    pub translation: TranslationConfig,
128    /// Optional file path from which the configuration was loaded.
129    pub loaded_from: Option<PathBuf>,
130}
131
132/// AI service configuration parameters.
133///
134/// This structure defines all configuration options for AI providers,
135/// including authentication, model parameters, retry behavior, and timeouts.
136///
137/// # Examples
138///
139/// Creating a default configuration:
140/// ```rust
141/// use subx_cli::config::AIConfig;
142///
143/// let ai_config = AIConfig::default();
144/// assert_eq!(ai_config.provider, "openai");
145/// assert_eq!(ai_config.model, "gpt-4.1-mini");
146/// assert_eq!(ai_config.temperature, 0.3);
147/// ```
148#[derive(Serialize, Deserialize, Clone)]
149pub struct AIConfig {
150    /// AI provider name.
151    ///
152    /// Supported canonical values:
153    /// - `"openai"` — hosted OpenAI API
154    /// - `"openrouter"` — hosted OpenRouter API
155    /// - `"azure-openai"` — hosted Azure OpenAI deployments
156    /// - `"local"` — any OpenAI-compatible local, LAN, or VPN endpoint
157    ///   (Ollama, LM Studio, llama.cpp `llama-server`, vLLM, etc.)
158    ///
159    /// The string `"ollama"` is accepted as an input alias and is
160    /// normalized to `"local"` by
161    /// [`crate::config::field_validator::normalize_ai_provider`]; the
162    /// persisted on-disk value is always the canonical form.
163    pub provider: String,
164    /// API key for authentication.
165    pub api_key: Option<String>,
166    /// AI model name to use.
167    pub model: String,
168    /// API base URL.
169    pub base_url: String,
170    /// Maximum sample length per request.
171    pub max_sample_length: usize,
172    /// AI generation creativity parameter (0.0-1.0).
173    pub temperature: f32,
174    /// Maximum tokens in response.
175    pub max_tokens: u32,
176    /// Number of retries on request failure.
177    pub retry_attempts: u32,
178    /// Retry interval in milliseconds.
179    pub retry_delay_ms: u64,
180    /// HTTP request timeout in seconds.
181    /// This controls how long to wait for a response from the AI service.
182    /// For slow networks or complex requests, you may need to increase this value.
183    pub request_timeout_seconds: u64,
184
185    /// Azure OpenAI API version (optional, defaults to latest)
186    #[serde(default)]
187    pub api_version: Option<String>,
188}
189
190impl fmt::Debug for AIConfig {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        f.debug_struct("AIConfig")
193            .field("provider", &self.provider)
194            .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
195            .field("model", &self.model)
196            .field("base_url", &self.base_url)
197            .field("max_sample_length", &self.max_sample_length)
198            .field("temperature", &self.temperature)
199            .field("max_tokens", &self.max_tokens)
200            .field("retry_attempts", &self.retry_attempts)
201            .field("retry_delay_ms", &self.retry_delay_ms)
202            .field("request_timeout_seconds", &self.request_timeout_seconds)
203            .field("api_version", &self.api_version)
204            .finish()
205    }
206}
207
208impl Default for AIConfig {
209    fn default() -> Self {
210        Self {
211            provider: "openai".to_string(),
212            api_key: None,
213            model: "gpt-4.1-mini".to_string(),
214            base_url: "https://api.openai.com/v1".to_string(),
215            max_sample_length: 3000,
216            temperature: 0.3,
217            max_tokens: 10000,
218            retry_attempts: 3,
219            retry_delay_ms: 1000,
220            // Set to 120 seconds to handle slow networks and complex AI requests
221            // This is especially important for users with high-latency connections
222            request_timeout_seconds: 120,
223            api_version: None,
224        }
225    }
226}
227
228/// Subtitle format related configuration.
229///
230/// Controls how subtitle files are processed, including format conversion,
231/// encoding detection, and style preservation.
232///
233/// # Examples
234///
235/// ```rust
236/// use subx_cli::config::FormatsConfig;
237///
238/// let formats = FormatsConfig::default();
239/// assert_eq!(formats.default_output, "srt");
240/// assert_eq!(formats.default_encoding, "utf-8");
241/// assert!(!formats.preserve_styling);
242/// ```
243#[derive(Debug, Serialize, Deserialize, Clone)]
244pub struct FormatsConfig {
245    /// Default output format (e.g. "srt", "ass", "vtt").
246    pub default_output: String,
247    /// Whether to preserve style information during format conversion.
248    pub preserve_styling: bool,
249    /// Default character encoding (e.g. "utf-8", "gbk").
250    pub default_encoding: String,
251    /// Encoding detection confidence threshold (0.0-1.0).
252    pub encoding_detection_confidence: f32,
253}
254
255impl Default for FormatsConfig {
256    fn default() -> Self {
257        Self {
258            default_output: "srt".to_string(),
259            preserve_styling: false,
260            default_encoding: "utf-8".to_string(),
261            encoding_detection_confidence: 0.8,
262        }
263    }
264}
265
266/// Audio synchronization configuration supporting VAD speech detection.
267///
268/// This configuration struct defines settings for subtitle-audio synchronization,
269/// including method selection, timing constraints, and VAD-specific parameters.
270#[derive(Debug, Serialize, Deserialize, Clone)]
271pub struct SyncConfig {
272    /// Default synchronization method ("vad", "auto")
273    pub default_method: String,
274    /// Maximum allowed time offset in seconds
275    pub max_offset_seconds: f32,
276    /// Local VAD related settings
277    pub vad: VadConfig,
278
279    // Deprecated legacy fields, preserved for backward compatibility
280    /// Deprecated: correlation threshold for audio analysis
281    #[deprecated]
282    #[serde(skip)]
283    pub correlation_threshold: f32,
284    /// Deprecated: dialogue detection threshold
285    #[deprecated]
286    #[serde(skip)]
287    pub dialogue_detection_threshold: f32,
288    /// Deprecated: minimum dialogue duration in milliseconds
289    #[deprecated]
290    #[serde(skip)]
291    pub min_dialogue_duration_ms: u32,
292    /// Deprecated: dialogue merge gap in milliseconds
293    #[deprecated]
294    #[serde(skip)]
295    pub dialogue_merge_gap_ms: u32,
296    /// Deprecated: enable dialogue detection flag
297    #[deprecated]
298    #[serde(skip)]
299    pub enable_dialogue_detection: bool,
300    /// Deprecated: audio sample rate
301    #[deprecated]
302    #[serde(skip)]
303    pub audio_sample_rate: u32,
304    /// Deprecated: auto-detect sample rate flag  
305    #[deprecated]
306    #[serde(skip)]
307    pub auto_detect_sample_rate: bool,
308}
309
310/// Local Voice Activity Detection configuration.
311///
312/// This struct defines parameters for local VAD processing, including sensitivity,
313/// audio chunking, and speech segment filtering. Adjust these fields to control
314/// how strictly speech is detected and how short segments are filtered out.
315///
316/// # Fields
317///
318/// - `enabled`: Whether local VAD is enabled
319/// - `sensitivity`: Speech detection sensitivity (0.0-1.0). Lower values are stricter and less likely to classify audio as speech.
320/// - `padding_chunks`: Number of non-speech chunks to include before and after detected speech
321/// - `min_speech_duration_ms`: Minimum duration (ms) for a segment to be considered valid speech
322///
323/// # Examples
324///
325/// ```rust
326/// use subx_cli::config::VadConfig;
327///
328/// let vad = VadConfig::default();
329/// assert!(vad.enabled);
330/// assert_eq!(vad.sensitivity, 0.25);
331/// ```
332#[derive(Debug, Serialize, Deserialize, Clone)]
333pub struct VadConfig {
334    /// Whether to enable local VAD method.
335    pub enabled: bool,
336    /// Speech detection sensitivity (0.0-1.0).
337    ///
338    /// Lower values are stricter: a smaller value means the detector is less likely to classify a chunk as speech.
339    /// For example, 0.25 is more strict than 0.75.
340    pub sensitivity: f32,
341    /// Number of non-speech chunks to pad before and after detected speech.
342    pub padding_chunks: u32,
343    /// Minimum speech duration in milliseconds.
344    ///
345    /// Segments shorter than this value will be discarded as noise or non-speech.
346    pub min_speech_duration_ms: u32,
347}
348
349#[allow(deprecated)]
350impl Default for SyncConfig {
351    fn default() -> Self {
352        Self {
353            default_method: "auto".to_string(),
354            max_offset_seconds: 60.0,
355            vad: VadConfig::default(),
356            correlation_threshold: 0.8,
357            dialogue_detection_threshold: 0.6,
358            min_dialogue_duration_ms: 500,
359            dialogue_merge_gap_ms: 200,
360            enable_dialogue_detection: true,
361            audio_sample_rate: 44100,
362            auto_detect_sample_rate: true,
363        }
364    }
365}
366
367impl Default for VadConfig {
368    fn default() -> Self {
369        Self {
370            enabled: true,
371            sensitivity: 0.25, // Default changed to 0.25, the smaller the value, the stricter the detection
372            padding_chunks: 3,
373            min_speech_duration_ms: 300,
374        }
375    }
376}
377
378/// General configuration settings for the SubX CLI tool.
379///
380/// This struct contains general settings that control the overall behavior
381/// of the application, including backup policies, processing limits, and
382/// user interface preferences.
383///
384/// # Examples
385///
386/// ```rust
387/// use subx_cli::config::GeneralConfig;
388///
389/// let config = GeneralConfig::default();
390/// assert_eq!(config.max_concurrent_jobs, 4);
391/// assert!(!config.backup_enabled);
392/// ```
393#[derive(Debug, Serialize, Deserialize, Clone)]
394pub struct GeneralConfig {
395    /// Enable automatic backup of original files.
396    pub backup_enabled: bool,
397    /// Maximum number of concurrent processing jobs.
398    pub max_concurrent_jobs: usize,
399    /// Task timeout in seconds.
400    pub task_timeout_seconds: u64,
401    /// Workspace directory for CLI commands (override current working directory).
402    pub workspace: std::path::PathBuf,
403    /// Enable progress bar display.
404    pub enable_progress_bar: bool,
405    /// Worker idle timeout in seconds.
406    pub worker_idle_timeout_seconds: u64,
407    /// Maximum subtitle file size in bytes (default 50 MiB).
408    pub max_subtitle_bytes: u64,
409    /// Maximum audio file size in bytes (default 2 GiB).
410    pub max_audio_bytes: u64,
411}
412
413impl Default for GeneralConfig {
414    fn default() -> Self {
415        Self {
416            backup_enabled: false,
417            max_concurrent_jobs: 4,
418            task_timeout_seconds: 300,
419            // Default workspace is current directory
420            workspace: std::path::PathBuf::from("."),
421            enable_progress_bar: true,
422            worker_idle_timeout_seconds: 60,
423            max_subtitle_bytes: 52_428_800,
424            max_audio_bytes: 2_147_483_648,
425        }
426    }
427}
428
429/// Parallel processing configuration.
430///
431/// Controls how parallel processing is performed, including worker
432/// management, task distribution, and overflow handling strategies.
433///
434/// # Examples
435///
436/// ```rust
437/// use subx_cli::config::{ParallelConfig, OverflowStrategy};
438///
439/// let parallel = ParallelConfig::default();
440/// assert!(parallel.max_workers > 0);
441/// assert_eq!(parallel.overflow_strategy, OverflowStrategy::Block);
442/// ```
443#[derive(Debug, Serialize, Deserialize, Clone)]
444pub struct ParallelConfig {
445    /// Maximum number of worker threads.
446    pub max_workers: usize,
447    /// Strategy for handling task overflow when queues are full.
448    ///
449    /// Determines the behavior when the task queue reaches capacity.
450    /// - [`OverflowStrategy::Block`] - Block until space is available
451    /// - [`OverflowStrategy::Drop`] - Drop new tasks when full
452    /// - [`OverflowStrategy::Expand`] - Dynamically expand queue size
453    pub overflow_strategy: OverflowStrategy,
454    /// Task queue size.
455    pub task_queue_size: usize,
456    /// Enable task priorities.
457    pub enable_task_priorities: bool,
458    /// Auto-balance workers.
459    pub auto_balance_workers: bool,
460}
461
462impl Default for ParallelConfig {
463    fn default() -> Self {
464        Self {
465            max_workers: num_cpus::get(),
466            overflow_strategy: OverflowStrategy::Block,
467            task_queue_size: 1000,
468            enable_task_priorities: false,
469            auto_balance_workers: true,
470        }
471    }
472}
473
474/// Strategy for handling overflow when all workers are busy.
475///
476/// This enum defines different strategies for handling situations where
477/// all worker threads are occupied and new tasks arrive.
478///
479/// # Examples
480///
481/// ```rust
482/// use subx_cli::config::OverflowStrategy;
483///
484/// let strategy = OverflowStrategy::Block;
485/// assert_eq!(strategy, OverflowStrategy::Block);
486///
487/// // Comparison and serialization
488/// let strategies = vec![
489///     OverflowStrategy::Block,
490///     OverflowStrategy::Drop,
491///     OverflowStrategy::Expand,
492/// ];
493/// assert_eq!(strategies.len(), 3);
494/// ```
495#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
496pub enum OverflowStrategy {
497    /// Block until a worker becomes available.
498    ///
499    /// This is the safest option as it ensures all tasks are processed,
500    /// but may cause the application to become unresponsive.
501    Block,
502    /// Drop new tasks when all workers are busy.
503    ///
504    /// Use this when task loss is acceptable and responsiveness is critical.
505    Drop,
506    /// Create additional temporary workers.
507    ///
508    /// This can help with load spikes but may consume excessive resources.
509    Expand,
510    /// Drop oldest tasks in queue.
511    ///
512    /// Prioritizes recent tasks over older ones in the queue.
513    DropOldest,
514    /// Reject new tasks.
515    ///
516    /// Similar to Drop but may provide error feedback to the caller.
517    Reject,
518}
519
520/// Subtitle translation configuration parameters.
521///
522/// Controls translation behavior shared by the `translate` command, the
523/// translation engine, and AI prompt builders. Validation rules ensure
524/// translation requests do not silently degrade to nonsensical values.
525///
526/// # Examples
527///
528/// ```rust
529/// use subx_cli::config::TranslationConfig;
530///
531/// let cfg = TranslationConfig::default();
532/// assert!(cfg.batch_size > 0);
533/// assert!(cfg.default_target_language.is_none());
534/// ```
535#[derive(Debug, Serialize, Deserialize, Clone)]
536pub struct TranslationConfig {
537    /// Maximum number of subtitle cues per AI translation request.
538    ///
539    /// Larger batches reduce the number of round-trips but risk hitting
540    /// per-request token limits. Must be greater than zero.
541    pub batch_size: usize,
542
543    /// Optional default target language used when the user omits
544    /// `--target-language` on the CLI.
545    #[serde(default)]
546    pub default_target_language: Option<String>,
547}
548
549impl Default for TranslationConfig {
550    fn default() -> Self {
551        Self {
552            batch_size: 40,
553            default_target_language: None,
554        }
555    }
556}
557
558// ============================================================================
559// Configuration Tests
560// ============================================================================
561
562#[cfg(test)]
563mod config_tests {
564    use super::*;
565
566    #[test]
567    fn test_ai_config_debug_redacts_api_key() {
568        let ai = AIConfig {
569            api_key: Some("sk-topsecret-1234567890".to_string()),
570            ..AIConfig::default()
571        };
572        let rendered = format!("{:?}", ai);
573        assert!(
574            !rendered.contains("sk-topsecret-1234567890"),
575            "Debug output must not leak API key: {rendered}"
576        );
577        assert!(
578            rendered.contains("[REDACTED]"),
579            "Debug output should mark api_key as redacted: {rendered}"
580        );
581    }
582
583    #[test]
584    fn test_ai_config_debug_none_api_key_is_none() {
585        let ai = AIConfig::default();
586        let rendered = format!("{:?}", ai);
587        assert!(rendered.contains("api_key: None"), "got: {rendered}");
588    }
589
590    #[test]
591    fn test_default_config_creation() {
592        let config = Config::default();
593        assert_eq!(config.ai.provider, "openai");
594        assert_eq!(config.ai.model, "gpt-4.1-mini");
595        assert_eq!(config.formats.default_output, "srt");
596        assert!(!config.general.backup_enabled);
597        assert_eq!(config.general.max_concurrent_jobs, 4);
598    }
599
600    #[test]
601    fn test_ai_config_defaults() {
602        let ai_config = AIConfig::default();
603        assert_eq!(ai_config.provider, "openai");
604        assert_eq!(ai_config.model, "gpt-4.1-mini");
605        assert_eq!(ai_config.temperature, 0.3);
606        assert_eq!(ai_config.max_sample_length, 3000);
607        assert_eq!(ai_config.max_tokens, 10000);
608    }
609
610    #[test]
611    fn test_ai_config_max_tokens_configuration() {
612        let mut ai_config = AIConfig {
613            max_tokens: 5000,
614            ..Default::default()
615        };
616        assert_eq!(ai_config.max_tokens, 5000);
617
618        // Test with different value
619        ai_config.max_tokens = 20000;
620        assert_eq!(ai_config.max_tokens, 20000);
621    }
622
623    #[test]
624    fn test_new_sync_config_defaults() {
625        let sync = SyncConfig::default();
626        assert_eq!(sync.default_method, "auto");
627        assert_eq!(sync.max_offset_seconds, 60.0);
628        assert!(sync.vad.enabled);
629    }
630
631    #[test]
632    fn test_sync_config_validation() {
633        let mut sync = SyncConfig::default();
634
635        // Valid configuration should pass validation
636        assert!(sync.validate().is_ok());
637
638        // Invalid default_method
639        sync.default_method = "invalid".to_string();
640        assert!(sync.validate().is_err());
641
642        // Reset and test other invalid values
643        sync = SyncConfig::default();
644        sync.max_offset_seconds = -1.0;
645        assert!(sync.validate().is_err());
646    }
647
648    #[test]
649    fn test_vad_config_validation() {
650        let mut vad = VadConfig::default();
651
652        // Valid configuration
653        assert!(vad.validate().is_ok());
654
655        // Invalid sensitivity
656        vad.sensitivity = 1.5;
657        assert!(vad.validate().is_err());
658    }
659
660    #[test]
661    fn test_config_serialization_with_new_sync() {
662        let config = Config::default();
663        let toml_str = toml::to_string(&config).unwrap();
664
665        // Ensure new configuration structure exists in serialized output
666        assert!(toml_str.contains("[sync]"));
667        assert!(toml_str.contains("[sync.vad]"));
668        assert!(toml_str.contains("default_method"));
669        // Whisper-related fields removed, should not appear in serialized output
670        assert!(!toml_str.contains("[sync.whisper]"));
671        assert!(!toml_str.contains("analysis_window_seconds"));
672    }
673}
674
675// ============================================================================
676// Public API Re-exports
677// ============================================================================
678
679// Re-export the configuration service system
680pub use builder::TestConfigBuilder;
681pub use environment::{EnvironmentProvider, SystemEnvironmentProvider, TestEnvironmentProvider};
682pub use service::{ConfigService, ProductionConfigService};
683pub use test_service::TestConfigService;
684
685// Re-export commonly used validation functions
686pub use field_validator::validate_field;
687pub use validator::validate_config;