Skip to main content

subx_core/core/
factory.rs

1//! Component factory for creating configured instances of core components.
2//!
3//! This module provides a centralized factory for creating instances of core
4//! components with proper configuration injection, eliminating the need for
5//! global configuration access within individual components.
6
7use crate::services::ai::openai::OpenAIClient;
8use crate::services::ai::openrouter::OpenRouterClient;
9use crate::services::vad::{LocalVadDetector, VadAudioProcessor, VadSyncDetector};
10use crate::{
11    Result,
12    config::{Config, ConfigService},
13    core::{file_manager::FileManager, matcher::engine::MatchEngine},
14    error::SubXError,
15    services::ai::AIProvider,
16};
17
18/// Component factory for creating configured instances.
19///
20/// This factory provides a centralized way to create core components
21/// with proper configuration injection, ensuring consistent component
22/// initialization across the application.
23///
24/// # Examples
25///
26/// ```rust
27/// use subx_core::core::ComponentFactory;
28/// use subx_core::config::ProductionConfigService;
29/// use std::sync::Arc;
30///
31/// # async fn example() -> subx_core::Result<()> {
32/// let config_service = Arc::new(ProductionConfigService::new()?);
33/// let factory = ComponentFactory::new(config_service.as_ref())?;
34///
35/// // Create components with proper configuration
36/// let match_engine = factory.create_match_engine()?;
37/// let file_manager = factory.create_file_manager();
38/// let ai_provider = factory.create_ai_provider()?;
39/// # Ok(())
40/// # }
41/// ```
42pub struct ComponentFactory {
43    config: Config,
44    reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
45}
46
47impl ComponentFactory {
48    /// Create a new component factory with the given configuration service.
49    ///
50    /// # Arguments
51    ///
52    /// * `config_service` - Configuration service to load configuration from
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if configuration loading fails.
57    pub fn new(config_service: &dyn ConfigService) -> Result<Self> {
58        let config = config_service.get_config()?;
59        Ok(Self {
60            config,
61            reporter: crate::core::report::noop(),
62        })
63    }
64
65    /// Attach a reporting sink, consuming and returning the factory.
66    ///
67    /// The reporter is propagated into every component this factory builds,
68    /// so one call at a command boundary wires an entire command.
69    ///
70    /// # Arguments
71    ///
72    /// * `reporter` - Sink shared by every component created afterwards.
73    pub fn with_reporter(
74        mut self,
75        reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
76    ) -> Self {
77        self.reporter = reporter;
78        self
79    }
80
81    /// Clone the factory's reporting sink for attachment to a component.
82    ///
83    /// Public so callers that construct a component outside the factory's
84    /// control (e.g. a `MatchEngine` around an externally supplied AI
85    /// client) can still wire the command's single reporter — the CLI's
86    /// `match` command does exactly this while adopting
87    /// [`ComponentFactory::match_config`].
88    pub fn reporter(&self) -> std::sync::Arc<dyn crate::core::report::Reporter> {
89        std::sync::Arc::clone(&self.reporter)
90    }
91
92    /// Return the [`MatchConfig`](crate::core::matcher::MatchConfig) this
93    /// factory's loaded [`Config`] implies.
94    ///
95    /// The returned value carries the four configuration-derived fields read
96    /// from the loaded config (`max_sample_length`, `ai_model`,
97    /// `backup_enabled`, `max_subtitle_bytes`) plus the four defaults this
98    /// factory pins for every caller: `confidence_threshold` 0.8 (the
99    /// default value, kept configurable by overriding it here rather than in
100    /// the config file), `enable_content_analysis: true`,
101    /// `relocation_mode: FileRelocationMode::None` and
102    /// `conflict_resolution: ConflictResolution::AutoRename`.
103    ///
104    /// `confidence_threshold`, `backup_enabled`, `relocation_mode` and
105    /// `conflict_resolution` are caller-controlled: every `MatchConfig`
106    /// field is public, so callers SHOULD mutate the returned value (two
107    /// lines: `let mut c = factory.match_config(); c.relocation_mode = mode;`)
108    /// instead of writing a `MatchConfig` struct literal — a literal must be
109    /// edited whenever the struct gains a ninth field, this route never
110    /// needs touching.
111    ///
112    /// # Returns
113    ///
114    /// A freshly built `MatchConfig`; identical in every field to the one
115    /// [`ComponentFactory::create_match_engine`] constructs.
116    ///
117    /// # Examples
118    ///
119    /// ```
120    /// use subx_core::config::TestConfigBuilder;
121    /// use subx_core::core::ComponentFactory;
122    ///
123    /// # fn example() -> subx_core::Result<()> {
124    /// let config_service = TestConfigBuilder::new().build_service();
125    /// let factory = ComponentFactory::new(&config_service)?;
126    /// let mut match_config = factory.match_config();
127    /// match_config.confidence_threshold = 0.9;
128    /// assert_eq!(match_config.confidence_threshold, 0.9);
129    /// # Ok(())
130    /// # }
131    /// ```
132    pub fn match_config(&self) -> crate::core::matcher::MatchConfig {
133        crate::core::matcher::MatchConfig {
134            confidence_threshold: 0.8, // Default value, can be configurable
135            max_sample_length: self.config.ai.max_sample_length,
136            enable_content_analysis: true,
137            backup_enabled: self.config.general.backup_enabled,
138            relocation_mode: crate::core::matcher::engine::FileRelocationMode::None,
139            conflict_resolution: crate::core::matcher::engine::ConflictResolution::AutoRename,
140            ai_model: self.config.ai.model.clone(),
141            max_subtitle_bytes: self.config.general.max_subtitle_bytes,
142        }
143    }
144
145    /// Create a match engine from a caller-supplied configuration.
146    ///
147    /// Builds the AI provider exactly as every other `create_*` method does
148    /// (through [`ComponentFactory::create_ai_provider`]), uses `config`
149    /// unmodified, and attaches the factory's reporter to the produced
150    /// engine — the same propagation terms as
151    /// [`ComponentFactory::create_match_engine`].
152    ///
153    /// # Arguments
154    ///
155    /// * `config` - The match configuration to use verbatim. Prefer deriving
156    ///   it from [`ComponentFactory::match_config`] and overwriting the
157    ///   caller-controlled fields.
158    ///
159    /// # Returns
160    ///
161    /// A `MatchEngine` wired with the factory's AI provider, the supplied
162    /// config, and the factory's reporter.
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if AI provider creation fails.
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// use subx_core::config::TestConfigBuilder;
172    /// use subx_core::core::ComponentFactory;
173    /// use subx_core::core::matcher::engine::FileRelocationMode;
174    ///
175    /// # fn example() -> subx_core::Result<()> {
176    /// let config_service = TestConfigBuilder::new().build_service();
177    /// let factory = ComponentFactory::new(&config_service)?;
178    /// // Two-line relocation-mode override:
179    /// let mut match_config = factory.match_config();
180    /// match_config.relocation_mode = FileRelocationMode::Copy;
181    /// let engine = factory.create_match_engine_with(match_config)?;
182    /// # let _ = engine;
183    /// # Ok(())
184    /// # }
185    /// ```
186    pub fn create_match_engine_with(
187        &self,
188        config: crate::core::matcher::MatchConfig,
189    ) -> Result<MatchEngine> {
190        let ai_provider = self.create_ai_provider()?;
191        Ok(MatchEngine::new(ai_provider, config).with_reporter(self.reporter()))
192    }
193
194    /// Create a match engine with AI configuration.
195    ///
196    /// Returns a properly configured MatchEngine instance using
197    /// the AI configuration section. Equivalent to
198    /// `create_match_engine_with(match_config())`; see
199    /// [`ComponentFactory::match_config`] for the field-by-field contract.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if AI provider creation fails.
204    pub fn create_match_engine(&self) -> Result<MatchEngine> {
205        self.create_match_engine_with(self.match_config())
206    }
207
208    /// Create a file manager with general configuration.
209    ///
210    /// Returns a properly configured FileManager instance using
211    /// the general configuration section.
212    pub fn create_file_manager(&self) -> FileManager {
213        // For now, FileManager doesn't take configuration in its constructor
214        // This will be updated when FileManager is refactored to accept config
215        FileManager::new().with_reporter(self.reporter())
216    }
217
218    /// Create an AI provider with AI configuration.
219    ///
220    /// Returns a properly configured AI provider instance based on
221    /// the provider type specified in the AI configuration.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if the provider type is unsupported or
226    /// provider creation fails.
227    pub fn create_ai_provider(&self) -> Result<Box<dyn AIProvider>> {
228        create_ai_provider_with_reporter(&self.config.ai, self.reporter())
229    }
230
231    /// Get a reference to the current configuration.
232    ///
233    /// Returns a reference to the configuration used by this factory.
234    pub fn config(&self) -> &Config {
235        &self.config
236    }
237
238    /// Create a VAD sync detector with VAD configuration.
239    ///
240    /// Returns a properly configured VadSyncDetector instance using the VAD settings.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if VAD sync detector creation fails.
245    pub fn create_vad_sync_detector(&self) -> Result<VadSyncDetector> {
246        VadSyncDetector::new(self.config.sync.vad.clone())
247    }
248
249    /// Create a local VAD detector for audio processing.
250    ///
251    /// Returns a properly configured LocalVadDetector instance.
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if local VAD detector initialization fails.
256    pub fn create_vad_detector(&self) -> Result<LocalVadDetector> {
257        LocalVadDetector::new(self.config.sync.vad.clone())
258    }
259
260    /// Create an audio processor for VAD operations.
261    ///
262    /// Returns a properly configured VadAudioProcessor instance.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if audio processor initialization fails.
267    pub fn create_audio_processor(&self) -> Result<VadAudioProcessor> {
268        VadAudioProcessor::new()
269    }
270
271    /// Create a translation engine using the configured AI provider and
272    /// translation settings.
273    ///
274    /// # Errors
275    ///
276    /// Returns an error when AI provider creation fails or the configured
277    /// translation batch size is invalid.
278    pub fn create_translation_engine(&self) -> Result<crate::core::translation::TranslationEngine> {
279        let ai_provider: std::sync::Arc<dyn AIProvider> =
280            std::sync::Arc::from(self.create_ai_provider()?);
281        let engine = crate::core::translation::TranslationEngine::new(
282            ai_provider,
283            self.config.translation.batch_size,
284        )?;
285        Ok(engine.with_reporter(self.reporter()))
286    }
287}
288
289/// Create an AI provider from AI configuration.
290///
291/// This function creates the appropriate AI provider based on the
292/// provider type specified in the configuration.
293///
294/// # Arguments
295///
296/// * `ai_config` - AI configuration containing provider settings
297///
298/// # Errors
299///
300/// Returns an error if the provider type is unsupported or creation fails.
301/// Validate AI configuration parameters.
302fn validate_ai_config(ai_config: &crate::config::AIConfig) -> Result<()> {
303    let canonical = crate::config::field_validator::normalize_ai_provider(&ai_config.provider);
304    let is_local = canonical == "local";
305
306    // The `local` provider treats `api_key` as optional because most local
307    // OpenAI-compatible runtimes (Ollama, LM Studio, llama.cpp `llama-server`)
308    // accept unauthenticated requests. All hosted providers still require
309    // an api_key.
310    if !is_local && ai_config.api_key.as_deref().unwrap_or("").trim().is_empty() {
311        return Err(SubXError::config(
312            "AI API key is required. Set ai.api_key in configuration or use environment variable."
313                .to_string(),
314        ));
315    }
316    if ai_config.model.trim().is_empty() {
317        return Err(SubXError::config(
318            "AI model is required. Set ai.model in configuration.".to_string(),
319        ));
320    }
321    if ai_config.temperature < 0.0 || ai_config.temperature > 2.0 {
322        return Err(SubXError::config(
323            "AI temperature must be between 0.0 and 2.0.".to_string(),
324        ));
325    }
326    if ai_config.max_tokens == 0 {
327        return Err(SubXError::config(
328            "AI max_tokens must be greater than 0.".to_string(),
329        ));
330    }
331    Ok(())
332}
333
334/// Create an AI provider from AI configuration.
335///
336/// This function creates the appropriate AI provider based on the
337/// provider type specified in the configuration.
338pub fn create_ai_provider_with_reporter(
339    ai_config: &crate::config::AIConfig,
340    reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
341) -> Result<Box<dyn AIProvider>> {
342    let canonical = crate::config::field_validator::normalize_ai_provider(&ai_config.provider);
343    match canonical.as_str() {
344        "openai" => {
345            validate_ai_config(ai_config)?;
346            let client = OpenAIClient::from_config(ai_config)?.with_reporter(reporter);
347            Ok(Box::new(client))
348        }
349        "openrouter" => {
350            validate_ai_config(ai_config)?;
351            let client = OpenRouterClient::from_config(ai_config)?.with_reporter(reporter);
352            Ok(Box::new(client))
353        }
354        "azure-openai" => {
355            validate_ai_config(ai_config)?;
356            let client =
357                crate::services::ai::azure_openai::AzureOpenAIClient::from_config(ai_config)?
358                    .with_reporter(reporter);
359            Ok(Box::new(client))
360        }
361        "local" => {
362            validate_ai_config(ai_config)?;
363            let client = crate::services::ai::local::LocalLLMClient::from_config(ai_config)?
364                .with_reporter(reporter);
365            Ok(Box::new(client))
366        }
367        other => Err(SubXError::config(format!(
368            "Unsupported AI provider: {}. Supported providers: openai, openrouter, anthropic, azure-openai, local",
369            other
370        ))),
371    }
372}
373
374/// Create an AI provider from AI configuration, reporting through a no-op
375/// sink.
376///
377/// Convenience wrapper over [`create_ai_provider_with_reporter`] for callers
378/// with no reporting sink to attach.
379pub fn create_ai_provider(ai_config: &crate::config::AIConfig) -> Result<Box<dyn AIProvider>> {
380    create_ai_provider_with_reporter(ai_config, crate::core::report::noop())
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::config::builder::TestConfigBuilder;
387    use crate::config::test_service::TestConfigService;
388
389    #[test]
390    fn test_component_factory_creation() {
391        let config_service = TestConfigService::default();
392        let factory = ComponentFactory::new(&config_service);
393        assert!(factory.is_ok());
394    }
395
396    #[test]
397    fn test_factory_creation() {
398        let config_service = TestConfigService::default();
399        let factory = ComponentFactory::new(&config_service);
400        assert!(factory.is_ok());
401    }
402
403    #[test]
404    fn test_create_file_manager() {
405        let config_service = TestConfigService::default();
406        let factory = ComponentFactory::new(&config_service).unwrap();
407
408        let _file_manager = factory.create_file_manager();
409        // Basic validation that file manager was created
410        // FileManager doesn't expose config yet, so just verify creation succeeds
411    }
412
413    #[test]
414    fn test_unsupported_ai_provider() {
415        let mut config = crate::config::Config::default();
416        config.ai.provider = "unsupported".to_string();
417
418        let result: Result<Box<dyn AIProvider>> = create_ai_provider(&config.ai);
419        assert!(result.is_err());
420
421        match result {
422            Err(e) => {
423                let error_msg = e.to_string();
424                assert!(error_msg.contains("Unsupported AI provider"));
425                // The error message must enumerate all supported providers,
426                // including the new `local` provider added by the
427                // add-local-llm-provider change.
428                assert!(error_msg.contains("openai"), "missing openai: {error_msg}");
429                assert!(
430                    error_msg.contains("openrouter"),
431                    "missing openrouter: {error_msg}"
432                );
433                assert!(
434                    error_msg.contains("azure-openai"),
435                    "missing azure-openai: {error_msg}"
436                );
437                assert!(error_msg.contains("local"), "missing local: {error_msg}");
438            }
439            Ok(_) => panic!("Expected error for unsupported provider"),
440        }
441    }
442
443    #[test]
444    fn test_create_vad_sync_detector() {
445        let config_service = TestConfigService::default();
446        let factory = ComponentFactory::new(&config_service).unwrap();
447        let result = factory.create_vad_sync_detector();
448        assert!(result.is_ok());
449    }
450
451    #[test]
452    fn test_create_vad_detector() {
453        let config_service = TestConfigService::default();
454        let factory = ComponentFactory::new(&config_service).unwrap();
455        let result = factory.create_vad_detector();
456        assert!(result.is_ok());
457    }
458
459    #[test]
460    fn test_create_audio_processor() {
461        let config_service = TestConfigService::default();
462        let factory = ComponentFactory::new(&config_service).unwrap();
463        let result = factory.create_audio_processor();
464        assert!(result.is_ok());
465    }
466
467    #[test]
468    fn test_create_ai_provider_openai_success() {
469        let config_service = TestConfigService::default();
470        config_service.set_ai_settings_and_key("openai", "gpt-4.1-mini", "test-api-key");
471        let factory = ComponentFactory::new(&config_service).unwrap();
472        let result = factory.create_ai_provider();
473        assert!(result.is_ok());
474    }
475
476    #[test]
477    fn test_create_ai_provider_missing_api_key() {
478        let config_service = TestConfigService::default();
479        config_service.set_ai_settings_and_key("openai", "gpt-4.1-mini", "");
480        let factory = ComponentFactory::new(&config_service).unwrap();
481        let result = factory.create_ai_provider();
482        assert!(result.is_err());
483        let error_msg = result.err().unwrap().to_string();
484        assert!(error_msg.contains("API key is required"));
485    }
486
487    #[test]
488    fn test_create_ai_provider_unsupported_provider() {
489        let config_service = TestConfigService::default();
490        config_service.set_ai_settings_and_key("unsupported-provider", "model", "key");
491        let factory = ComponentFactory::new(&config_service).unwrap();
492        let result = factory.create_ai_provider();
493        assert!(result.is_err());
494        let error_msg = result.err().unwrap().to_string();
495        assert!(error_msg.contains("Unsupported AI provider"));
496    }
497
498    #[test]
499    fn test_create_ai_provider_with_custom_base_url() {
500        let config_service = TestConfigService::default();
501        config_service.set_ai_settings_and_key("openai", "gpt-4.1-mini", "test-api-key");
502        config_service.config_mut().ai.base_url = "https://custom-api.com/v1".to_string();
503        let factory = ComponentFactory::new(&config_service).unwrap();
504        let result = factory.create_ai_provider();
505        assert!(result.is_ok());
506    }
507
508    #[test]
509    fn test_create_ai_provider_openrouter_success() {
510        let config_service = TestConfigService::default();
511        config_service.set_ai_settings_and_key(
512            "openrouter",
513            "deepseek/deepseek-r1-0528:free",
514            "test-openrouter-key",
515        );
516        let factory = ComponentFactory::new(&config_service).unwrap();
517        let result = factory.create_ai_provider();
518        assert!(result.is_ok());
519    }
520
521    #[test]
522    fn test_create_ai_provider_azure_openai_success() {
523        let mut config = crate::config::Config::default();
524        config.ai.provider = "azure-openai".to_string();
525        config.ai.api_key = Some("azure-key-123".to_string());
526        config.ai.model = "dep123".to_string();
527        config.ai.api_version = Some("2025-04-01-preview".to_string());
528        config.ai.base_url = "https://example.openai.azure.com".to_string();
529        let result = create_ai_provider(&config.ai);
530        assert!(result.is_ok());
531    }
532
533    #[test]
534    fn test_create_ai_provider_local_success() {
535        // Mirror `test_create_ai_provider_openai_success`: build a
536        // `TestConfigService` via `TestConfigBuilder` configured for the
537        // local provider, with NO api_key (intentionally absent) and an
538        // OpenAI-compatible Ollama-style base URL. The factory must accept
539        // this and return a working AI provider.
540        use crate::config::builder::TestConfigBuilder;
541        let config_service = TestConfigBuilder::new()
542            .with_ai_provider("local")
543            .with_ai_model("llama3.1")
544            .with_ai_base_url("http://localhost:11434/v1")
545            .build_service();
546        let factory = ComponentFactory::new(&config_service).unwrap();
547        let result = factory.create_ai_provider();
548        assert!(
549            result.is_ok(),
550            "local provider must construct without api_key: {:?}",
551            result.err()
552        );
553
554        // The `ollama` alias normalizes to `local` and must take the same
555        // factory path.
556        let alias_service = TestConfigBuilder::new()
557            .with_ai_provider("ollama")
558            .with_ai_model("llama3.1")
559            .with_ai_base_url("http://localhost:11434/v1")
560            .build_service();
561        let alias_factory = ComponentFactory::new(&alias_service).unwrap();
562        assert!(
563            alias_factory.create_ai_provider().is_ok(),
564            "`ollama` alias must reach the local arm"
565        );
566    }
567
568    fn local_factory() -> ComponentFactory {
569        let config_service = TestConfigBuilder::new()
570            .with_ai_provider("local")
571            .with_ai_model("llama3.1")
572            .with_ai_base_url("http://localhost:11434/v1")
573            .build_service();
574        ComponentFactory::new(&config_service).unwrap()
575    }
576
577    #[test]
578    fn match_config_matches_field_for_field_contract() {
579        // The field-for-field contract: config-derived fields read the
580        // loaded config; the other four are the factory's pinned defaults.
581        let factory = local_factory();
582        let config = factory.match_config();
583        assert_eq!(config.confidence_threshold, 0.8);
584        assert!(config.enable_content_analysis);
585        assert_eq!(
586            config.relocation_mode,
587            crate::core::matcher::engine::FileRelocationMode::None
588        );
589        assert!(matches!(
590            config.conflict_resolution,
591            crate::core::matcher::engine::ConflictResolution::AutoRename
592        ));
593        // Config-derived fields must mirror the loaded config the factory
594        // was built from (local provider, default general section).
595        assert_eq!(config.ai_model, "llama3.1");
596        assert_eq!(
597            config.max_sample_length,
598            crate::config::Config::default().ai.max_sample_length
599        );
600        assert_eq!(
601            config.backup_enabled,
602            crate::config::Config::default().general.backup_enabled
603        );
604        assert_eq!(
605            config.max_subtitle_bytes,
606            crate::config::Config::default().general.max_subtitle_bytes
607        );
608    }
609
610    #[test]
611    fn match_config_tracks_the_loaded_config() {
612        // A backup-enabled config must surface in match_config(): the
613        // method reads the loaded config, it does not re-derive defaults.
614        let config_service = TestConfigBuilder::new()
615            .with_ai_provider("local")
616            .with_ai_base_url("http://localhost:11434/v1")
617            .with_backup_enabled(true)
618            .with_max_sample_length(4321)
619            .build_service();
620        let factory = ComponentFactory::new(&config_service).unwrap();
621        let config = factory.match_config();
622        assert!(config.backup_enabled);
623        assert_eq!(config.max_sample_length, 4321);
624    }
625
626    #[test]
627    fn create_match_engine_uses_match_config_values() {
628        // Behavioural proof of the `new == create_match_engine_with(
629        // match_config())` identity: create_match_engine builds its engine
630        // through the same two methods, and the observable config half of
631        // that engine is exercised end-to-end in
632        // tests/factory_match_engine_tests.rs (relocation_mode reaching
633        // MatchOperation needs a wired AI client). Here, the factory path
634        // must at least construct successfully with the local provider.
635        let factory = local_factory();
636        assert!(factory.create_match_engine().is_ok());
637        let engine = factory.create_match_engine_with(factory.match_config());
638        assert!(engine.is_ok(), "{:?}", engine.err());
639    }
640}