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}