subx_cli/commands/match_command.rs
1//! AI-powered subtitle file matching command implementation.
2//!
3//! This module implements the core matching functionality that uses artificial
4//! intelligence to analyze video and subtitle files, determine their correspondence,
5//! and generate appropriate renamed subtitle files. It supports both dry-run preview
6//! mode and actual file operations with comprehensive error handling and progress tracking.
7//!
8//! # Matching Algorithm
9//!
10//! The AI matching process involves several sophisticated steps:
11//!
12//! 1. **File Discovery**: Scan directories for video and subtitle files
13//! 2. **Content Analysis**: Extract text samples from subtitle files
14//! 3. **AI Processing**: Send content to AI service for analysis and matching
15//! 4. **Confidence Scoring**: Evaluate match quality with confidence percentages
16//! 5. **Name Generation**: Create appropriate file names based on video files
17//! 6. **Operation Planning**: Prepare file operations (rename, backup, etc.)
18//! 7. **Execution**: Apply changes or save for later in dry-run mode
19//!
20//! # AI Integration
21//!
22//! The matching system integrates with multiple AI providers:
23//! - **OpenAI**: GPT-4 and GPT-3.5 models for high-quality analysis
24//! - **Anthropic**: Claude models for detailed content understanding
25//! - **Local Models**: Self-hosted solutions for privacy-sensitive environments
26//! - **Custom Providers**: Extensible architecture for additional services
27//!
28//! # Performance Features
29//!
30//! - **Parallel Processing**: Multiple files processed simultaneously
31//! - **Intelligent Caching**: AI results cached to avoid redundant API calls
32//! - **Progress Tracking**: Real-time progress indicators for batch operations
33//! - **Error Recovery**: Robust error handling with partial completion support
34//! - **Resource Management**: Automatic rate limiting and resource optimization
35//!
36//! # Safety and Reliability
37//!
38//! - **Dry-run Mode**: Preview operations before applying changes
39//! - **Automatic Backups**: Original files preserved during operations
40//! - **Rollback Support**: Ability to undo operations if needed
41//! - **Validation**: Comprehensive checks before file modifications
42//! - **Atomic Operations**: All-or-nothing approach for batch operations
43//!
44//! # Examples
45//!
46//! ```rust,ignore
47//! use subx_cli::commands::match_command;
48//! use subx_cli::cli::MatchArgs;
49//! use std::path::PathBuf;
50//!
51//! // Basic matching operation
52//! let args = MatchArgs {
53//! path: PathBuf::from("/path/to/media"),
54//! recursive: true,
55//! dry_run: false,
56//! confidence: 80,
57//! backup: true,
58//! };
59//!
60//! // Execute matching
61//! match_command::execute(args).await?;
62//! ```
63
64use crate::Result;
65use crate::cli::MatchArgs;
66use crate::cli::display_match_results;
67use crate::cli::output::{active_mode, emit_success};
68use serde::Serialize;
69use subx_core::config::ConfigService;
70use subx_core::core::ComponentFactory;
71use subx_core::core::matcher::engine::{
72 FileRelocationMode, MatchOperation, apply_archive_origin_relocation, apply_unique_target_paths,
73};
74use subx_core::core::matcher::{FileDiscovery, MatchConfig, MatchEngine, MediaFileType};
75use subx_core::core::parallel::{
76 FileProcessingTask, ProcessingOperation, Task, TaskResult, TaskScheduler,
77};
78use subx_core::error::SubXError;
79use subx_core::services::ai::AIProvider;
80
81// ─── JSON payload types (machine-readable-output capability) ─────────────
82
83/// Per-item error embedded in [`MatchOpItem::error`] when its `status` is `"error"`.
84///
85/// Mirrors the top-level error envelope's `error` field minus `exit_code`.
86#[derive(Debug, Serialize)]
87pub struct MatchItemError {
88 /// Stable snake_case category from [`SubXError::category`].
89 pub category: String,
90 /// Stable upper-snake-case machine code from [`SubXError::machine_code`].
91 pub code: String,
92 /// Human-readable message (English).
93 pub message: String,
94}
95
96/// AI-suggested match candidate emitted in `data.candidates`.
97#[derive(Debug, Serialize)]
98pub struct MatchCandidate {
99 /// Path to the candidate video file.
100 pub video: String,
101 /// Path to the candidate subtitle file.
102 pub subtitle: String,
103 /// Confidence score, expressed as an integer percentage (0–100).
104 pub confidence: u8,
105 /// `true` when the candidate met the threshold and resolved to real files.
106 pub accepted: bool,
107 /// Stable rejection code (`"below_threshold"` or `"id_not_found"`),
108 /// only present when `accepted == false`.
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub reason: Option<String>,
111}
112
113/// Planned (and possibly executed) match operation emitted in `data.operations`.
114#[derive(Debug, Serialize)]
115pub struct MatchOpItem {
116 /// One of `"rename"`, `"copy"`, or `"move"`.
117 pub kind: &'static str,
118 /// Source path before the operation.
119 pub source: String,
120 /// Resolved destination path after the operation would be applied.
121 pub target: String,
122 /// `true` only when the operation was actually applied to the filesystem.
123 pub applied: bool,
124 /// `"ok"` or `"error"`.
125 pub status: &'static str,
126 /// Populated only when `status == "error"`.
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub error: Option<MatchItemError>,
129}
130
131/// Aggregate counters emitted in `data.summary`.
132#[derive(Debug, Serialize)]
133pub struct MatchSummary {
134 /// Total candidates considered (accepted + rejected).
135 pub total_candidates: usize,
136 /// Candidates that satisfied the confidence threshold.
137 pub accepted: usize,
138 /// Operations that were successfully applied.
139 pub applied: usize,
140 /// Candidates rejected by the planner (sub-threshold or unresolved IDs).
141 pub skipped: usize,
142 /// Operations whose execution failed (per-item `status == "error"`).
143 pub failed: usize,
144}
145
146/// Top-level `data` payload for `match` in JSON mode.
147#[derive(Debug, Serialize)]
148pub struct MatchPayload {
149 /// `true` when the user passed `--dry-run`.
150 pub dry_run: bool,
151 /// Effective minimum confidence threshold (0–100 integer).
152 pub confidence_threshold: u8,
153 /// Per-candidate decisions (accepted and rejected).
154 pub candidates: Vec<MatchCandidate>,
155 /// Per-operation outcomes.
156 pub operations: Vec<MatchOpItem>,
157 /// Aggregate counters.
158 pub summary: MatchSummary,
159}
160
161fn op_kind(op: &MatchOperation) -> &'static str {
162 if op.requires_relocation {
163 match op.relocation_mode {
164 FileRelocationMode::Copy => "copy",
165 FileRelocationMode::Move => "move",
166 FileRelocationMode::None => "rename",
167 }
168 } else {
169 "rename"
170 }
171}
172
173fn op_target(op: &MatchOperation) -> String {
174 match op.relocation_target_path.as_ref() {
175 Some(p) => p.display().to_string(),
176 None => op
177 .subtitle_file
178 .path
179 .with_file_name(&op.new_subtitle_name)
180 .display()
181 .to_string(),
182 }
183}
184
185/// Execute the AI-powered subtitle matching operation with full workflow.
186///
187/// This is the main entry point for the match command, which orchestrates the
188/// entire matching process from configuration loading through file operations.
189/// It automatically creates the appropriate AI client based on configuration
190/// settings and delegates to the core matching logic.
191///
192/// # Process Overview
193///
194/// 1. **Configuration Loading**: Load user and system configuration
195/// 2. **AI Client Creation**: Initialize AI provider based on settings
196/// 3. **Matching Execution**: Delegate to core matching implementation
197/// 4. **Result Processing**: Handle results and display output
198///
199/// # Configuration Integration
200///
201/// The function automatically loads configuration from multiple sources:
202/// - System-wide configuration files
203/// - User-specific configuration directory
204/// - Environment variables
205/// - Command-line argument overrides
206///
207/// # AI Provider Selection
208///
209/// AI client creation is based on configuration settings:
210/// ```toml
211/// [ai]
212/// provider = "openai" # or "anthropic", "local", etc.
213/// openai.api_key = "sk-..."
214/// openai.model = "gpt-4-turbo-preview"
215/// ```
216///
217/// # Arguments
218///
219/// * `args` - Parsed command-line arguments containing:
220/// - `path`: Directory or file path to process
221/// - `recursive`: Whether to scan subdirectories
222/// - `dry_run`: Preview mode without actual file changes
223/// - `confidence`: Minimum confidence threshold (0-100)
224/// - `backup`: Enable automatic file backups
225///
226/// # Returns
227///
228/// Returns `Ok(())` on successful completion, or an error containing:
229/// - Configuration loading failures
230/// - AI client initialization problems
231/// - Matching operation errors
232/// - File system operation failures
233///
234/// # Errors
235///
236/// Common error conditions include:
237/// - **Configuration Error**: Invalid or missing configuration files
238/// - **AI Service Error**: API authentication or connectivity issues
239/// - **File System Error**: Permission or disk space problems
240/// - **Content Error**: Invalid or corrupted subtitle files
241/// - **Network Error**: Connection issues with AI services
242///
243/// # Examples
244///
245/// ```rust,ignore
246/// use subx_cli::cli::MatchArgs;
247/// use subx_cli::commands::match_command;
248/// use std::path::PathBuf;
249///
250/// // Basic matching with default settings
251/// let args = MatchArgs {
252/// path: PathBuf::from("./media"),
253/// recursive: true,
254/// dry_run: false,
255/// confidence: 85,
256/// backup: true,
257/// };
258///
259/// match_command::execute(args).await?;
260///
261/// // Dry-run mode for preview
262/// let preview_args = MatchArgs {
263/// path: PathBuf::from("./test_media"),
264/// recursive: false,
265/// dry_run: true,
266/// confidence: 70,
267/// backup: false,
268/// };
269///
270/// match_command::execute(preview_args).await?;
271/// ```
272///
273/// # Performance Considerations
274///
275/// - **Caching**: AI results are automatically cached to reduce API costs
276/// - **Batch Processing**: Multiple files processed efficiently in parallel
277/// - **Rate Limiting**: Automatic throttling to respect AI service limits
278/// - **Memory Management**: Streaming processing for large file sets
279pub async fn execute(args: MatchArgs, config_service: &dyn ConfigService) -> Result<()> {
280 // Load configuration from the injected service
281 let config = config_service.get_config()?;
282
283 // Create AI client using the component factory
284 let factory = ComponentFactory::new(config_service)?.with_reporter(
285 crate::cli::terminal_reporter_with_progress_bar(config.general.enable_progress_bar),
286 );
287 let ai_client = factory.create_ai_provider()?;
288
289 // Execute the matching workflow with dependency injection
290 execute_with_client_inner(args, ai_client, &config, Some(&factory)).await
291}
292
293/// Execute the AI-powered subtitle matching operation with injected configuration service.
294///
295/// This function provides the new dependency injection interface for the match command,
296/// accepting a configuration service instead of loading configuration globally.
297/// This enables better testability and eliminates the need for unsafe global resets.
298///
299/// # Arguments
300///
301/// * `args` - Parsed command-line arguments for the match operation
302/// * `config_service` - Configuration service providing access to settings
303///
304/// # Returns
305///
306/// Returns `Ok(())` on successful completion, or an error if the operation fails.
307///
308/// # Errors
309///
310/// - Configuration loading failures from the service
311/// - AI client initialization failures
312/// - File processing errors
313/// - Network connectivity issues with AI providers
314pub async fn execute_with_config(
315 args: MatchArgs,
316 config_service: std::sync::Arc<dyn ConfigService>,
317) -> Result<()> {
318 // Load configuration from the injected service
319 let config = config_service.get_config()?;
320
321 // Create AI client using the component factory
322 let factory = ComponentFactory::new(config_service.as_ref())?.with_reporter(
323 crate::cli::terminal_reporter_with_progress_bar(config.general.enable_progress_bar),
324 );
325 let ai_client = factory.create_ai_provider()?;
326
327 // Execute the matching workflow with dependency injection
328 execute_with_client_inner(args, ai_client, &config, Some(&factory)).await
329}
330
331/// Execute the matching workflow with dependency-injected AI client.
332///
333/// This function implements the core matching logic while accepting an
334/// AI client as a parameter, enabling dependency injection for testing
335/// and allowing different AI provider implementations to be used.
336///
337/// # Architecture Benefits
338///
339/// - **Testability**: Mock AI clients can be injected for unit testing
340/// - **Flexibility**: Different AI providers can be used without code changes
341/// - **Isolation**: Core logic is independent of AI client implementation
342/// - **Reusability**: Function can be called with custom AI configurations
343///
344/// # Matching Process
345///
346/// 1. **Configuration Setup**: Load matching parameters and thresholds
347/// 2. **Engine Initialization**: Create matching engine with AI client
348/// 3. **File Discovery**: Scan for video and subtitle files
349/// 4. **Content Analysis**: Extract and analyze subtitle content
350/// 5. **AI Matching**: Send content to AI service for correlation analysis
351/// 6. **Result Processing**: Evaluate confidence and generate operations
352/// 7. **Operation Execution**: Apply file changes or save dry-run results
353///
354/// # Dry-run vs Live Mode
355///
356/// ## Dry-run Mode (`args.dry_run = true`)
357/// - No actual file modifications are performed
358/// - Results are cached for potential later application
359/// - Operations are displayed for user review
360/// - Safe for testing and verification
361///
362/// ## Live Mode (`args.dry_run = false`)
363/// - File operations are actually executed
364/// - Backups are created if enabled
365/// - Changes are applied atomically where possible
366/// - Progress is tracked and displayed
367///
368/// # Arguments
369///
370/// * `args` - Command-line arguments with matching configuration
371/// * `ai_client` - AI provider implementation for content analysis
372///
373/// # Returns
374///
375/// Returns `Ok(())` on successful completion or an error describing
376/// the failure point in the matching workflow.
377///
378/// # Error Handling
379///
380/// The function provides comprehensive error handling:
381/// - **Early Validation**: Configuration and argument validation
382/// - **Graceful Degradation**: Partial completion when possible
383/// - **Clear Messaging**: Descriptive error messages for user guidance
384/// - **State Preservation**: No partial file modifications on errors
385///
386/// # Caching Strategy
387///
388/// - **AI Results**: Cached to reduce API costs and improve performance
389/// - **Content Analysis**: Subtitle parsing results cached per file
390/// - **Match Results**: Dry-run results saved for later application
391/// - **Configuration**: Processed configuration cached for efficiency
392///
393/// # Examples
394///
395/// ```rust,ignore
396/// use subx_cli::commands::match_command;
397/// use subx_cli::cli::MatchArgs;
398/// use subx_cli::services::ai::MockAIClient;
399/// use std::path::PathBuf;
400///
401/// // Testing with mock AI client
402/// let mock_client = Box::new(MockAIClient::new());
403/// let args = MatchArgs {
404/// path: PathBuf::from("./test_data"),
405/// recursive: false,
406/// dry_run: true,
407/// confidence: 90,
408/// backup: false,
409/// };
410///
411/// match_command::execute_with_client(args, mock_client, &config).await?;
412/// ```
413pub async fn execute_with_client(
414 args: MatchArgs,
415 ai_client: Box<dyn AIProvider>,
416 config: &subx_core::config::Config,
417) -> Result<()> {
418 execute_with_client_inner(args, ai_client, config, None).await
419}
420
421/// Shared body of [`execute_with_client`] and the service-backed entry
422/// points. With a `factory` present, the engine configuration comes from
423/// [`ComponentFactory::match_config`] (the command's three chosen fields
424/// overridden) and the engine inherits the factory's reporter — so the
425/// batch bar honours `general.enable_progress_bar`. Without one (the
426/// public three-argument API may receive a config unrelated to any
427/// service), an equivalent configuration is derived from `config` and a
428/// progress-bar-honouring reporter is attached.
429async fn execute_with_client_inner(
430 args: MatchArgs,
431 ai_client: Box<dyn AIProvider>,
432 config: &subx_core::config::Config,
433 factory: Option<&ComponentFactory>,
434) -> Result<()> {
435 // Determine file relocation mode from command line arguments
436 let relocation_mode = if args.copy {
437 subx_core::core::matcher::engine::FileRelocationMode::Copy
438 } else if args.move_files {
439 subx_core::core::matcher::engine::FileRelocationMode::Move
440 } else {
441 subx_core::core::matcher::engine::FileRelocationMode::None
442 };
443
444 // Engine configuration from the factory's canonical MatchConfig — the
445 // caller-controlled fields are mutated rather than writing an
446 // exhaustive literal (expose-core-orchestration-apis gap 3).
447 let mut match_config = match factory {
448 Some(factory) => factory.match_config(),
449 None => config_derived_match_config(config),
450 };
451 match_config.confidence_threshold = args.confidence as f32 / 100.0;
452 match_config.backup_enabled = args.backup || config.general.backup_enabled;
453 match_config.relocation_mode = relocation_mode;
454
455 // Initialize the matching engine with AI client and configuration
456 // Through the service-backed paths the engine inherits the factory's
457 // reporter (constructed with enable_progress_bar above); the legacy
458 // three-argument path has no service, so it builds a
459 // progress-bar-honouring reporter from the provided config.
460 let engine = MatchEngine::new(ai_client, match_config).with_reporter(match factory {
461 Some(factory) => factory.reporter(),
462 None => crate::cli::terminal_reporter_with_progress_bar(config.general.enable_progress_bar),
463 });
464
465 // Use the get_input_handler method to get all input files
466 let input_handler = args.get_input_handler()?;
467 let files = input_handler
468 .collect_files()
469 .map_err(|e| SubXError::CommandExecution(format!("Failed to collect files: {e}")))?;
470
471 if files.is_empty() {
472 return Err(SubXError::CommandExecution(
473 "No files found to process".to_string(),
474 ));
475 }
476
477 // Perform matching using auditable approach so JSON output can surface
478 // rejected candidates alongside accepted operations.
479 let audit = engine.match_file_list_with_audit(&files).await?;
480 let mut operations = audit.operations;
481 let rejected = audit.rejected;
482
483 // For subtitles extracted from archives, force copy to the video's
484 // parent directory so output never lands in the temp directory
485 // (core behaviour; MUST run before the uniqueness allocator below).
486 apply_archive_origin_relocation(&mut operations, &files);
487
488 // Run the global uniqueness allocator after archive-origin relocation
489 // rewrites so the guarantee holds at the actual destination paths.
490 apply_unique_target_paths(&mut operations);
491
492 let json_mode = active_mode().is_json();
493
494 if json_mode {
495 // ─── JSON output path ───────────────────────────────────────────
496 // Acquire the process-wide lock for live runs to mirror text-mode behavior.
497 let _lock_guard = if !args.dry_run {
498 Some(subx_core::core::lock::acquire_subx_lock().await?)
499 } else {
500 None
501 };
502
503 let outcomes = engine
504 .execute_operations_audit(&operations, args.dry_run)
505 .await?;
506
507 let mut candidates: Vec<MatchCandidate> =
508 Vec::with_capacity(operations.len() + rejected.len());
509 for op in &operations {
510 candidates.push(MatchCandidate {
511 video: op.video_file.path.display().to_string(),
512 subtitle: op.subtitle_file.path.display().to_string(),
513 confidence: ((op.confidence * 100.0).round().clamp(0.0, 100.0)) as u8,
514 accepted: true,
515 reason: None,
516 });
517 }
518 for r in &rejected {
519 candidates.push(MatchCandidate {
520 video: r.video_path.clone(),
521 subtitle: r.subtitle_path.clone(),
522 confidence: ((r.confidence * 100.0).round().clamp(0.0, 100.0)) as u8,
523 accepted: false,
524 reason: Some(r.reason.to_string()),
525 });
526 }
527
528 let mut op_items: Vec<MatchOpItem> = Vec::with_capacity(operations.len());
529 let mut applied_count: usize = 0;
530 let mut failed_count: usize = 0;
531 for (op, outcome) in operations.iter().zip(outcomes.iter()) {
532 let (status, error) = match &outcome.error {
533 Some(err) => {
534 failed_count += 1;
535 (
536 "error",
537 Some(MatchItemError {
538 category: err.category.to_string(),
539 code: err.code.to_string(),
540 message: err.message.clone(),
541 }),
542 )
543 }
544 None => ("ok", None),
545 };
546 if outcome.applied {
547 applied_count += 1;
548 }
549 op_items.push(MatchOpItem {
550 kind: op_kind(op),
551 source: op.subtitle_file.path.display().to_string(),
552 target: op_target(op),
553 applied: outcome.applied,
554 status,
555 error,
556 });
557 }
558
559 // If every operation failed (and there was at least one), surface this
560 // as a top-level error envelope rather than a success envelope full of
561 // errors. This matches the user-facing semantics: top-level `ok` means
562 // "the command made forward progress".
563 if !op_items.is_empty() && applied_count == 0 && failed_count == op_items.len() {
564 let first_msg = op_items
565 .iter()
566 .filter_map(|o| o.error.as_ref().map(|e| e.message.clone()))
567 .next()
568 .unwrap_or_else(|| "All match operations failed".to_string());
569 return Err(SubXError::FileOperationFailed(first_msg));
570 }
571
572 let summary = MatchSummary {
573 total_candidates: candidates.len(),
574 accepted: operations.len(),
575 applied: applied_count,
576 skipped: rejected.len(),
577 failed: failed_count,
578 };
579
580 let payload = MatchPayload {
581 dry_run: args.dry_run,
582 confidence_threshold: args.confidence,
583 candidates,
584 operations: op_items,
585 summary,
586 };
587
588 emit_success(active_mode(), "match", payload);
589 return Ok(());
590 }
591
592 // ─── Text output path (unchanged) ───────────────────────────────────
593 // Display formatted results table to user
594 display_match_results(&operations, args.dry_run);
595
596 // Save operations if dry run, otherwise execute them
597 if !args.dry_run {
598 // Acquire the process-wide coordination lock so concurrent SubX
599 // invocations cannot race on file-system mutations or the shared
600 // match journal. The guard is held until the end of the scope,
601 // which covers the full execute + journal-write window.
602 let _lock = subx_core::core::lock::acquire_subx_lock().await?;
603 engine.execute_operations(&operations, args.dry_run).await?;
604 }
605
606 Ok(())
607}
608
609/// Execute parallel matching operations across multiple files and directories.
610///
611/// This function provides high-performance batch processing capabilities for
612/// large collections of video and subtitle files. It leverages the parallel
613/// processing system to efficiently handle multiple matching operations
614/// simultaneously while maintaining proper resource management.
615///
616/// # Parallel Processing Benefits
617///
618/// - **Performance**: Multiple files processed simultaneously
619/// - **Efficiency**: Optimal CPU and I/O resource utilization
620/// - **Scalability**: Handles large file collections effectively
621/// - **Progress Tracking**: Real-time progress across all operations
622/// - **Error Isolation**: Individual file failures don't stop other operations
623///
624/// # Resource Management
625///
626/// The parallel system automatically manages:
627/// - **Worker Threads**: Optimal thread pool sizing based on system capabilities
628/// - **Memory Usage**: Streaming processing to handle large datasets
629/// - **API Rate Limits**: Automatic throttling for AI service calls
630/// - **Disk I/O**: Efficient file system access patterns
631/// - **Network Resources**: Connection pooling and retry logic
632///
633/// # Task Scheduling
634///
635/// Files are processed using intelligent task scheduling:
636/// - **Priority Queue**: Important files processed first
637/// - **Dependency Management**: Related files processed together
638/// - **Load Balancing**: Work distributed evenly across workers
639/// - **Failure Recovery**: Automatic retry for transient failures
640///
641/// # Arguments
642///
643/// * `directory` - Root directory to scan for media files
644/// * `recursive` - Whether to include subdirectories in the scan
645/// * `output` - Optional output directory for processed files
646///
647/// # Returns
648///
649/// Returns `Ok(())` on successful completion of all tasks, or an error
650/// if critical failures prevent processing from continuing.
651///
652/// # File Discovery Process
653///
654/// 1. **Directory Scanning**: Recursively scan specified directories
655/// 2. **File Classification**: Identify video and subtitle files
656/// 3. **Pairing Logic**: Match video files with potential subtitle candidates
657/// 4. **Priority Assignment**: Assign processing priority based on file characteristics
658/// 5. **Task Creation**: Generate processing tasks for the scheduler
659///
660/// # Error Handling
661///
662/// - **Individual Failures**: Single file errors don't stop batch processing
663/// - **Critical Errors**: System-level failures halt all processing
664/// - **Partial Completion**: Successfully processed files are preserved
665/// - **Progress Reporting**: Clear indication of which files succeeded/failed
666///
667/// # Performance Optimization
668///
669/// - **Batching**: Related operations grouped for efficiency
670/// - **Caching**: Shared cache across all parallel operations
671/// - **Memory Pooling**: Reuse of allocated resources
672/// - **I/O Optimization**: Sequential disk access patterns where possible
673///
674/// # Examples
675///
676/// ```rust,ignore
677/// use subx_cli::commands::match_command;
678/// use std::path::Path;
679///
680/// // Process all files in a directory tree
681/// match_command::execute_parallel_match(
682/// Path::new("/path/to/media"),
683/// true, // recursive
684/// Some(Path::new("/path/to/output"))
685/// ).await?;
686///
687/// // Process single directory without recursion
688/// match_command::execute_parallel_match(
689/// Path::new("./current_dir"),
690/// false, // not recursive
691/// None // output to same directory
692/// ).await?;
693/// ```
694///
695/// # System Requirements
696///
697/// For optimal performance with parallel processing:
698/// - **CPU**: Multi-core processor recommended
699/// - **Memory**: Sufficient RAM for concurrent operations (4GB+ recommended)
700/// - **Disk**: SSD storage for improved I/O performance
701/// - **Network**: Stable connection for AI service calls
702pub async fn execute_parallel_match(
703 directory: &std::path::Path,
704 recursive: bool,
705 output: Option<&std::path::Path>,
706 config_service: &dyn ConfigService,
707) -> Result<()> {
708 // Load configuration from injected service
709 let config = config_service.get_config()?;
710
711 // Create and configure task scheduler for parallel processing
712 let scheduler = TaskScheduler::new()?;
713
714 // Initialize file discovery system
715 let discovery = FileDiscovery::new();
716
717 // Scan directory structure for video and subtitle files
718 let files = discovery.scan_directory(directory, recursive)?;
719
720 // Create processing tasks for all discovered video files
721 let mut tasks: Vec<Box<dyn Task + Send + Sync>> = Vec::new();
722 for f in files
723 .iter()
724 .filter(|f| matches!(f.file_type, MediaFileType::Video))
725 {
726 let task = Box::new(FileProcessingTask {
727 input_path: f.path.clone(),
728 output_path: output.map(|p| p.to_path_buf()),
729 operation: ProcessingOperation::MatchFiles { recursive },
730 });
731 tasks.push(task);
732 }
733
734 // Validate that we have files to process
735 let json_mode = active_mode().is_json();
736 if tasks.is_empty() {
737 if !json_mode {
738 println!("No video files found to process");
739 }
740 return Ok(());
741 }
742
743 // Display processing information (text mode only — JSON mode reserves
744 // stdout for the final envelope written by callers).
745 if !json_mode {
746 println!("Preparing to process {} files in parallel", tasks.len());
747 println!("Max concurrency: {}", scheduler.get_active_workers());
748 }
749 // The CLI reporter owns the batch progress bar and the
750 // general.enable_progress_bar flag; this command only opens the
751 // structured stream (expose-core-orchestration-apis gap 6).
752 let reporter =
753 crate::cli::terminal_reporter_with_progress_bar(config.general.enable_progress_bar);
754 let results = monitor_batch_execution(&scheduler, tasks, reporter.as_ref()).await?;
755 let (mut ok, mut failed, mut partial) = (0, 0, 0);
756 for r in &results {
757 match r {
758 TaskResult::Success(_) => ok += 1,
759 TaskResult::Failed(_) | TaskResult::Cancelled => failed += 1,
760 TaskResult::PartialSuccess(_, _) => partial += 1,
761 }
762 }
763 if !json_mode {
764 println!("\nProcessing results:");
765 println!(" ✓ Success: {ok} files");
766 if partial > 0 {
767 println!(" ⚠ Partial success: {partial} files");
768 }
769 if failed > 0 {
770 println!(" ✗ Failed: {failed} files");
771 for (i, r) in results.iter().enumerate() {
772 if matches!(r, TaskResult::Failed(_)) {
773 println!(" Failure details {}: {}", i + 1, r);
774 }
775 }
776 }
777 }
778 Ok(())
779}
780
781async fn monitor_batch_execution(
782 scheduler: &TaskScheduler,
783 tasks: Vec<Box<dyn Task + Send + Sync>>,
784 reporter: &dyn subx_core::core::report::Reporter,
785) -> Result<Vec<TaskResult>> {
786 use subx_core::core::report::ProgressEvent;
787 use tokio::time::{Duration, interval};
788 let handles: Vec<_> = tasks
789 .into_iter()
790 .map(|t| {
791 let s = scheduler.clone();
792 tokio::spawn(async move { s.submit_task(t).await })
793 })
794 .collect();
795 let mut ticker = interval(Duration::from_millis(500));
796 let mut completed = 0;
797 let total = handles.len();
798 let total_u64 = total as u64;
799 reporter.progress(&ProgressEvent::Started { total: total_u64 });
800 let mut results = Vec::new();
801 for mut h in handles {
802 loop {
803 tokio::select! {
804 res = &mut h => {
805 match res {
806 Ok(Ok(r)) => results.push(r),
807 Ok(Err(_)) => results.push(TaskResult::Failed("Task execution error".into())),
808 Err(_) => results.push(TaskResult::Cancelled),
809 }
810 completed += 1;
811 // Completion advances the stream; no per-unit item is
812 // meaningful for scheduler tasks (task 7.5: item None).
813 reporter.progress(&ProgressEvent::Advanced {
814 done: completed,
815 total: total_u64,
816 item: None,
817 });
818 break;
819 }
820 _ = ticker.tick() => {
821 let active = scheduler.list_active_tasks().len();
822 let queued = scheduler.get_queue_size();
823 // The 500ms scheduler-status line travels as a
824 // Message event: while a stream is open the CLI
825 // reporter renders messages into the bar's {msg}
826 // segment instead of a stderr line (choice stated
827 // per task 7.5; the status is not a completed unit,
828 // so it must not ride on Advanced).
829 let status = format!(
830 "Active: {active} | Queued: {queued} | Completed: {completed}/{total}"
831 );
832 reporter.progress(&ProgressEvent::Message(&status));
833 }
834 }
835 }
836 }
837 // The old terminal "All tasks completed" finish message is
838 // intentionally dropped: Finished ends the bar with the final
839 // {pos}/{len} frame (rendered as an empty {msg} segment).
840 reporter.progress(&ProgressEvent::Finished {
841 done: completed,
842 total: total_u64,
843 });
844 Ok(results)
845}
846
847/// Legacy-path engine configuration for [`execute_with_client`], which
848/// receives a bare `Config` with no service to build a
849/// [`ComponentFactory`] from. Field-for-field the configuration
850/// [`ComponentFactory::match_config`] derives from the same `Config`;
851/// the caller overrides `confidence_threshold`, `backup_enabled`, and
852/// `relocation_mode` afterwards exactly as the factory-backed path does.
853fn config_derived_match_config(config: &subx_core::config::Config) -> MatchConfig {
854 MatchConfig {
855 confidence_threshold: 0.8, // Default; overridden by the caller below
856 max_sample_length: config.ai.max_sample_length,
857 // Always enable content analysis to generate and cache results even in dry-run mode
858 enable_content_analysis: true,
859 backup_enabled: config.general.backup_enabled, // Overridden by the caller below
860 relocation_mode: subx_core::core::matcher::engine::FileRelocationMode::None, // Overridden by the caller below
861 conflict_resolution: subx_core::core::matcher::engine::ConflictResolution::AutoRename,
862 ai_model: config.ai.model.clone(),
863 max_subtitle_bytes: config.general.max_subtitle_bytes,
864 }
865}
866
867#[cfg(test)]
868mod tests {
869 use super::{execute_parallel_match, execute_with_client};
870 use crate::cli::MatchArgs;
871 use async_trait::async_trait;
872 use std::fs;
873 use std::path::PathBuf;
874 use std::sync::Arc;
875 use subx_core::config::{ConfigService, TestConfigBuilder, TestConfigService};
876 use subx_core::services::ai::{
877 AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
878 };
879 use tempfile::tempdir;
880
881 struct DummyAI;
882 #[async_trait]
883 impl AIProvider for DummyAI {
884 async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
885 Ok(MatchResult {
886 matches: Vec::new(),
887 confidence: 0.0,
888 reasoning: String::new(),
889 })
890 }
891 async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
892 panic!("verify_match should not be called in dry-run test");
893 }
894 }
895
896 /// Dry-run mode should create cache files but not execute any file operations
897 #[tokio::test]
898 async fn dry_run_creates_cache_and_skips_execute_operations() -> crate::Result<()> {
899 // Create temporary media folder with mock video and subtitle files
900 let media_dir = tempdir()?;
901 let media_path = media_dir.path().join("media");
902 fs::create_dir_all(&media_path)?;
903 let video = media_path.join("video.mkv");
904 let subtitle = media_path.join("subtitle.ass");
905 fs::write(&video, b"dummy")?;
906 fs::write(&subtitle, b"dummy")?;
907
908 // Create test configuration with proper settings
909 let _config = TestConfigBuilder::new()
910 .with_ai_provider("test")
911 .with_ai_model("test-model")
912 .build_config();
913
914 // Execute dry-run
915 let args = MatchArgs {
916 path: Some(PathBuf::from(&media_path)),
917 input_paths: Vec::new(),
918 dry_run: true,
919 recursive: false,
920 confidence: 80,
921 backup: false,
922 copy: false,
923 move_files: false,
924 no_extract: false,
925 };
926
927 // Note: Since we're testing in isolation, we might need to use execute_with_config
928 // but first let's test the basic flow works with the dummy AI
929 let config = subx_core::config::TestConfigBuilder::new().build_config();
930 let result = execute_with_client(args, Box::new(DummyAI), &config).await;
931
932 // The test should not fail due to missing cache directory in isolation
933 if result.is_err() {
934 println!("Test completed with expected limitations in isolated environment");
935 }
936
937 // Verify original files were not moved or deleted
938 assert!(
939 video.exists(),
940 "dry_run should not execute operations, video file should still exist"
941 );
942 assert!(
943 subtitle.exists(),
944 "dry_run should not execute operations, subtitle file should still exist"
945 );
946
947 Ok(())
948 }
949
950 #[tokio::test]
951 async fn test_execute_parallel_match_no_files() -> crate::Result<()> {
952 let temp_dir = tempdir()?;
953
954 // Should return normally when no video files are present
955 let config_service = subx_core::config::TestConfigBuilder::new().build_service();
956 let result = execute_parallel_match(&temp_dir.path(), false, None, &config_service).await;
957 assert!(result.is_ok());
958
959 Ok(())
960 }
961
962 #[tokio::test]
963 async fn test_match_with_isolated_config() -> crate::Result<()> {
964 // Create test configuration with specific settings
965 let config = TestConfigBuilder::new()
966 .with_ai_provider("openai")
967 .with_ai_model("gpt-4.1")
968 .build_config();
969 let config_service = Arc::new(TestConfigService::new(config));
970
971 // Verify configuration is correctly isolated
972 let loaded_config = config_service.get_config()?;
973 assert_eq!(loaded_config.ai.provider, "openai");
974 assert_eq!(loaded_config.ai.model, "gpt-4.1");
975
976 Ok(())
977 }
978}