Skip to main content

subx_cli/commands/
convert_command.rs

1//! Subtitle format conversion command implementation.
2//!
3//! This module provides comprehensive subtitle format conversion capabilities,
4//! transforming subtitle files between different standards while preserving
5//! timing information, styling, and encoding. It supports both single file
6//! and batch directory processing with intelligent format detection.
7//!
8//! # Supported Conversions
9//!
10//! The conversion system supports transformation between major subtitle formats:
11//!
12//! ## Input Formats (Auto-detected)
13//! - **SRT (SubRip)**: Most common subtitle format
14//! - **ASS/SSA (Advanced SubStation Alpha)**: Rich formatting support
15//! - **VTT (WebVTT)**: Web-optimized subtitle format
16//! - **SUB (MicroDVD)**: Frame-based subtitle format
17//! - **SMI (SAMI)**: Microsoft subtitle format
18//! - **LRC (Lyrics)**: Simple lyric format
19//!
20//! ## Output Formats (User-specified)
21//! - **SRT**: Universal compatibility and simplicity
22//! - **ASS**: Advanced styling and positioning
23//! - **VTT**: HTML5 video and web applications
24//! - **SUB**: Legacy system compatibility
25//!
26//! # Conversion Features
27//!
28//! - **Format Detection**: Automatic input format recognition
29//! - **Styling Preservation**: Maintain formatting where possible
30//! - **Encoding Conversion**: Handle various character encodings
31//! - **Batch Processing**: Convert multiple files efficiently
32//! - **Quality Validation**: Verify output format integrity
33//! - **Backup Creation**: Preserve original files optionally
34//!
35//! # Quality Assurance
36//!
37//! Each conversion undergoes comprehensive validation:
38//! - **Timing Integrity**: Verify timestamp accuracy and ordering
39//! - **Content Preservation**: Ensure no text loss during conversion
40//! - **Format Compliance**: Validate output meets format specifications
41//! - **Encoding Correctness**: Verify character encoding consistency
42//! - **Styling Translation**: Map styles between format capabilities
43//!
44//! # Examples
45//!
46//! ```rust,ignore
47//! use subx_cli::cli::{ConvertArgs, OutputSubtitleFormat};
48//! use subx_cli::commands::convert_command;
49//! use std::path::PathBuf;
50//!
51//! // Convert single SRT file to ASS format
52//! let args = ConvertArgs {
53//!     input: PathBuf::from("input.srt"),
54//!     format: Some(OutputSubtitleFormat::Ass),
55//!     output: Some(PathBuf::from("output.ass")),
56//!     keep_original: true,
57//!     encoding: "utf-8".to_string(),
58//! };
59//!
60//! convert_command::execute(args).await?;
61//!
62//! // Batch convert directory with default settings
63//! let batch_args = ConvertArgs {
64//!     input: PathBuf::from("./subtitles/"),
65//!     format: Some(OutputSubtitleFormat::Vtt),
66//!     output: None, // Use default naming
67//!     keep_original: true,
68//!     encoding: "utf-8".to_string(),
69//! };
70//!
71//! convert_command::execute(batch_args).await?;
72//! ```
73
74use std::path::{Path, PathBuf};
75
76use serde::Serialize;
77
78use crate::cli::output::{active_mode, emit_success};
79use crate::cli::{ConvertArgs, OutputSubtitleFormat};
80use crate::config::ConfigService;
81use crate::core::file_manager::FileManager;
82use crate::core::formats::converter::{ConversionConfig, FormatConverter};
83use crate::error::SubXError;
84
85// ─── JSON payload types (machine-readable-output capability) ─────────────
86
87/// Per-item error embedded in [`ConvertItem::error`].
88///
89/// Mirrors the top-level error envelope's `error` field minus
90/// `exit_code` (per the `machine-readable-output` spec's "Per-Item
91/// Status Semantics" requirement).
92#[derive(Debug, Serialize)]
93pub struct ConvertItemError {
94    /// Stable snake_case category from [`crate::error::SubXError::category`].
95    pub category: String,
96    /// Stable upper-snake-case machine code from
97    /// [`crate::error::SubXError::machine_code`].
98    pub code: String,
99    /// Human-readable message (English).
100    pub message: String,
101}
102
103impl ConvertItemError {
104    fn from_error(err: &SubXError) -> Self {
105        Self {
106            category: err.category().to_string(),
107            code: err.machine_code().to_string(),
108            message: err.user_friendly_message(),
109        }
110    }
111
112    fn synthetic(category: &str, code: &str, message: String) -> Self {
113        Self {
114            category: category.to_string(),
115            code: code.to_string(),
116            message,
117        }
118    }
119}
120
121/// Per-file conversion record emitted in the `data.conversions` array
122/// of the JSON envelope.
123///
124/// Field naming follows
125/// `openspec/changes/add-machine-readable-output/specs/format-conversion/spec.md`.
126/// `entry_count` is an additive enrichment (subtitle entries serialized
127/// to the output) that consumers MAY ignore on older schema versions.
128#[derive(Debug, Serialize)]
129pub struct ConvertItem {
130    /// Source file path as provided to the converter.
131    pub input: String,
132    /// Resolved output file path.
133    pub output: String,
134    /// Lowercase source format identifier (e.g. `"srt"`, `"ass"`,
135    /// `"vtt"`, `"sub"`). `null` when the file failed before parsing.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub source_format: Option<String>,
138    /// Lowercase target format identifier.
139    pub target_format: String,
140    /// Output encoding label (e.g. `"UTF-8"`).
141    pub encoding: String,
142    /// Whether the conversion was applied to disk.
143    pub applied: bool,
144    /// Number of subtitle entries successfully converted, when known.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub entry_count: Option<usize>,
147    /// `"ok"` or `"error"`.
148    pub status: &'static str,
149    /// Populated only when `status == "error"`.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub error: Option<ConvertItemError>,
152}
153
154/// Top-level `data` payload for `convert` in JSON mode.
155#[derive(Debug, Serialize)]
156pub struct ConvertPayload {
157    /// One entry per processed file (single-input invocations produce a
158    /// single-element array).
159    pub conversions: Vec<ConvertItem>,
160}
161
162/// Execute subtitle format conversion with comprehensive validation and error handling.
163///
164/// This function orchestrates the complete conversion workflow, from configuration
165/// loading through final output validation. It supports both single file and batch
166/// directory processing with intelligent format detection and preservation of
167/// subtitle quality.
168///
169/// # Conversion Process
170///
171/// 1. **Configuration Loading**: Load application and conversion settings
172/// 2. **Format Detection**: Automatically detect input subtitle format
173/// 3. **Conversion Setup**: Configure converter with user preferences
174/// 4. **Processing**: Transform subtitle content to target format
175/// 5. **Validation**: Verify output quality and format compliance
176/// 6. **File Management**: Handle backups and output file creation
177///
178/// # Format Mapping
179///
180/// The conversion process intelligently maps features between formats:
181///
182/// ## SRT to ASS
183/// - Basic text → Advanced styling capabilities
184/// - Simple timing → Precise timing control
185/// - Limited formatting → Rich formatting options
186///
187/// ## ASS to SRT
188/// - Rich styling → Basic formatting preservation
189/// - Advanced timing → Standard timing format
190/// - Complex layouts → Simplified text positioning
191///
192/// ## Any to VTT
193/// - Format-specific features → Web-compatible equivalents
194/// - Custom styling → CSS-like styling syntax
195/// - Traditional timing → WebVTT timing format
196///
197/// # Configuration Integration
198///
199/// The function respects multiple configuration sources:
200/// ```toml
201/// [formats]
202/// default_output = "srt"           # Default output format
203/// preserve_styling = true          # Maintain formatting where possible
204/// validate_output = true           # Perform output validation
205/// backup_enabled = true            # Create backups before conversion
206/// ```
207///
208/// # Arguments
209///
210/// * `args` - Conversion arguments containing:
211///   - `input`: Source file or directory path
212///   - `format`: Target output format (SRT, ASS, VTT, SUB)
213///   - `output`: Optional output path (auto-generated if not specified)
214///   - `keep_original`: Whether to preserve original files
215///   - `encoding`: Character encoding for input/output files
216///
217/// # Returns
218///
219/// Returns `Ok(())` on successful conversion, or an error describing:
220/// - Configuration loading failures
221/// - Input file access or format problems
222/// - Conversion processing errors
223/// - Output file creation or validation issues
224///
225/// # Error Handling
226///
227/// Comprehensive error handling covers:
228/// - **Input Validation**: File existence, format detection, accessibility
229/// - **Processing Errors**: Conversion failures, content corruption
230/// - **Output Issues**: Write permissions, disk space, format validation
231/// - **Configuration Problems**: Invalid settings, missing dependencies
232///
233/// # File Safety
234///
235/// The conversion process ensures file safety through:
236/// - **Atomic Operations**: Complete conversion or no changes
237/// - **Backup Creation**: Original files preserved when requested
238/// - **Validation**: Output quality verification before finalization
239/// - **Rollback Capability**: Ability to undo changes if problems occur
240///
241/// # Examples
242///
243/// ```rust,ignore
244/// use subx_cli::cli::{ConvertArgs, OutputSubtitleFormat};
245/// use subx_cli::commands::convert_command;
246/// use std::path::PathBuf;
247///
248/// // Convert with explicit output path
249/// let explicit_args = ConvertArgs {
250///     input: PathBuf::from("movie.srt"),
251///     format: Some(OutputSubtitleFormat::Ass),
252///     output: Some(PathBuf::from("movie_styled.ass")),
253///     keep_original: true,
254///     encoding: "utf-8".to_string(),
255/// };
256/// convert_command::execute(explicit_args).await?;
257///
258/// // Convert with automatic output naming
259/// let auto_args = ConvertArgs {
260///     input: PathBuf::from("episode.srt"),
261///     format: Some(OutputSubtitleFormat::Vtt),
262///     output: None, // Will become "episode.vtt"
263///     keep_original: false,
264///     encoding: "utf-8".to_string(),
265/// };
266/// convert_command::execute(auto_args).await?;
267///
268/// // Batch convert directory
269/// let batch_args = ConvertArgs {
270///     input: PathBuf::from("./season1_subtitles/"),
271///     format: Some(OutputSubtitleFormat::Srt),
272///     output: None,
273///     keep_original: true,
274///     encoding: "utf-8".to_string(),
275/// };
276/// convert_command::execute(batch_args).await?;
277/// ```
278///
279/// # Performance Considerations
280///
281/// - **Memory Efficiency**: Streaming processing for large subtitle files
282/// - **Disk I/O Optimization**: Efficient file access patterns
283/// - **Batch Processing**: Optimized for multiple file operations
284/// - **Validation Caching**: Avoid redundant quality checks
285pub async fn execute(args: ConvertArgs, config_service: &dyn ConfigService) -> crate::Result<()> {
286    // Load application configuration for conversion settings
287    let app_config = config_service.get_config()?;
288
289    // Configure conversion engine with user preferences and application defaults
290    let config = ConversionConfig {
291        preserve_styling: app_config.formats.preserve_styling,
292        target_encoding: args.encoding.clone(),
293        keep_original: args.keep_original,
294        validate_output: true,
295    };
296    let converter = FormatConverter::new(config);
297
298    // Determine output format from arguments or configuration defaults
299    let default_output = match app_config.formats.default_output.as_str() {
300        "srt" => OutputSubtitleFormat::Srt,
301        "ass" => OutputSubtitleFormat::Ass,
302        "vtt" => OutputSubtitleFormat::Vtt,
303        "sub" => OutputSubtitleFormat::Sub,
304        other => {
305            return Err(SubXError::config(format!(
306                "Unknown default output format: {other}"
307            )));
308        }
309    };
310    let output_format = args.format.clone().unwrap_or(default_output);
311
312    // Collect input files using InputPathHandler
313    let handler = args
314        .get_input_handler()
315        .map_err(|e| SubXError::CommandExecution(e.to_string()))?;
316    let collected = handler
317        .collect_files()
318        .map_err(|e| SubXError::CommandExecution(e.to_string()))?;
319    if collected.is_empty() {
320        // Nothing to do — emit an empty success envelope in JSON mode so
321        // callers always receive a valid document.
322        let mode = active_mode();
323        if mode.is_json() {
324            emit_success(
325                mode,
326                "convert",
327                ConvertPayload {
328                    conversions: Vec::new(),
329                },
330            );
331        }
332        return Ok(());
333    }
334
335    let mode = active_mode();
336    let json_mode = mode.is_json();
337    let single_input = collected.len() == 1;
338
339    // Accumulate per-file results for the JSON payload.
340    let mut items: Vec<ConvertItem> = Vec::with_capacity(collected.len());
341    // Captures the first fatal error in single-input mode so we can
342    // bubble it up as a top-level error envelope (per the
343    // format-conversion spec's "single-input fatal error" scenario).
344    let mut single_input_fatal: Option<SubXError> = None;
345
346    // Process each file
347    for input_path in collected.iter() {
348        let fmt = output_format.to_string();
349        let output_path: PathBuf = if let Some(ref o) = args.output {
350            let mut p = o.clone();
351            // Append per-file name when output is a directory and there are
352            // multiple files (either from multiple inputs or archive expansion)
353            #[allow(clippy::collapsible_if)]
354            if p.is_dir()
355                && (handler.paths.len() != 1 || handler.paths[0].is_dir() || collected.len() > 1)
356            {
357                if let Some(stem) = input_path.file_stem().and_then(|s| s.to_str()) {
358                    p.push(format!("{stem}.{fmt}"));
359                }
360            }
361            p
362        } else if let Some(archive_path) = collected.archive_origin(input_path) {
363            // File came from an archive: write output beside the archive
364            let archive_dir = archive_path.parent().unwrap_or(Path::new("."));
365            let stem = input_path
366                .file_stem()
367                .and_then(|s| s.to_str())
368                .unwrap_or("output");
369            archive_dir.join(format!("{stem}.{fmt}"))
370        } else {
371            input_path.with_extension(fmt.clone())
372        };
373
374        match converter.convert_file(input_path, &output_path, &fmt).await {
375            Ok(result) => {
376                if result.success {
377                    if !json_mode {
378                        println!(
379                            "✓ Conversion completed: {} -> {}",
380                            input_path.display(),
381                            output_path.display()
382                        );
383                    }
384                    if !args.keep_original {
385                        let _ = FileManager::new().remove_file(input_path);
386                    }
387                    items.push(ConvertItem {
388                        input: input_path.display().to_string(),
389                        output: output_path.display().to_string(),
390                        source_format: Some(result.input_format.to_lowercase()),
391                        target_format: result.output_format.to_lowercase(),
392                        encoding: args.encoding.clone(),
393                        applied: true,
394                        entry_count: Some(result.converted_entries),
395                        status: "ok",
396                        error: None,
397                    });
398                } else {
399                    if !json_mode {
400                        eprintln!("✗ Conversion failed for {}", input_path.display());
401                        for err in &result.errors {
402                            eprintln!("  Error: {err}");
403                        }
404                    }
405                    let message = if result.errors.is_empty() {
406                        "Conversion produced an unsuccessful result".to_string()
407                    } else {
408                        result.errors.join("; ")
409                    };
410                    items.push(ConvertItem {
411                        input: input_path.display().to_string(),
412                        output: output_path.display().to_string(),
413                        source_format: Some(result.input_format.to_lowercase()),
414                        target_format: result.output_format.to_lowercase(),
415                        encoding: args.encoding.clone(),
416                        applied: false,
417                        entry_count: None,
418                        status: "error",
419                        error: Some(ConvertItemError::synthetic(
420                            "subtitle_format",
421                            "E_SUBTITLE_FORMAT",
422                            message,
423                        )),
424                    });
425                }
426            }
427            Err(e) => {
428                if !json_mode {
429                    eprintln!("✗ Conversion error for {}: {}", input_path.display(), e);
430                }
431                let item_err = ConvertItemError::from_error(&e);
432                items.push(ConvertItem {
433                    input: input_path.display().to_string(),
434                    output: output_path.display().to_string(),
435                    source_format: None,
436                    target_format: fmt.clone(),
437                    encoding: args.encoding.clone(),
438                    applied: false,
439                    entry_count: None,
440                    status: "error",
441                    error: Some(item_err),
442                });
443                if single_input && single_input_fatal.is_none() {
444                    single_input_fatal = Some(e);
445                }
446            }
447        }
448    }
449
450    // Single-input fatal: per spec's "Single-input fatal error produces
451    // top-level error envelope" scenario, propagate the error so
452    // `main.rs` renders the top-level error envelope and exits with the
453    // matching exit code.
454    if let Some(err) = single_input_fatal {
455        return Err(err);
456    }
457
458    // Batch / multi-input: top-level envelope SHALL be `status == "ok"`
459    // whenever the loop completed (per the "Per-File Error Isolation"
460    // requirement). Per-file failures live inside `items`.
461    if json_mode {
462        emit_success(mode, "convert", ConvertPayload { conversions: items });
463    }
464    Ok(())
465}
466
467/// Execute subtitle format conversion with injected configuration service.
468///
469/// This function provides the new dependency injection interface for the convert command,
470/// accepting a configuration service instead of loading configuration globally.
471///
472/// # Arguments
473///
474/// * `args` - Conversion arguments including input/output paths and format options
475/// * `config_service` - Configuration service providing access to conversion settings
476///
477/// # Returns
478///
479/// Returns `Ok(())` on successful completion, or an error if conversion fails.
480pub async fn execute_with_config(
481    args: ConvertArgs,
482    config_service: std::sync::Arc<dyn ConfigService>,
483) -> crate::Result<()> {
484    execute(args, config_service.as_ref()).await
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::config::{TestConfigBuilder, TestConfigService};
491    use std::fs;
492    use std::sync::Arc;
493    use tempfile::TempDir;
494
495    #[tokio::test]
496    async fn test_convert_srt_to_vtt() -> crate::Result<()> {
497        // Create test configuration
498        let config_service = Arc::new(TestConfigService::with_defaults());
499
500        let temp_dir = TempDir::new().unwrap();
501        let input_file = temp_dir.path().join("test.srt");
502        let output_file = temp_dir.path().join("test.vtt");
503
504        fs::write(
505            &input_file,
506            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle\n\n",
507        )
508        .unwrap();
509
510        let args = ConvertArgs {
511            input: Some(input_file.clone()),
512            input_paths: Vec::new(),
513            recursive: false,
514            format: Some(OutputSubtitleFormat::Vtt),
515            output: Some(output_file.clone()),
516            keep_original: false,
517            encoding: String::from("utf-8"),
518            no_extract: false,
519        };
520
521        execute_with_config(args, config_service).await?;
522
523        let content = fs::read_to_string(&output_file).unwrap();
524        assert!(content.contains("WEBVTT"));
525        assert!(content.contains("00:00:01.000 --> 00:00:02.000"));
526
527        Ok(())
528    }
529
530    #[tokio::test]
531    async fn test_convert_batch_processing() -> crate::Result<()> {
532        // Create test configuration
533        let config_service = Arc::new(TestConfigService::with_defaults());
534
535        let temp_dir = TempDir::new().unwrap();
536        for i in 1..=3 {
537            let file = temp_dir.path().join(format!("test{}.srt", i));
538            fs::write(
539                &file,
540                format!(
541                    "1\n00:00:0{},000 --> 00:00:0{},000\nTest {}\n\n",
542                    i,
543                    i + 1,
544                    i
545                ),
546            )
547            .unwrap();
548        }
549
550        let args = ConvertArgs {
551            input: Some(temp_dir.path().to_path_buf()),
552            input_paths: Vec::new(),
553            recursive: false,
554            format: Some(OutputSubtitleFormat::Vtt),
555            output: Some(temp_dir.path().join("output")),
556            keep_original: false,
557            encoding: String::from("utf-8"),
558            no_extract: false,
559        };
560
561        // Only check execution result, do not verify actual file generation,
562        // as converter behavior is controlled by external modules
563        execute_with_config(args, config_service).await?;
564
565        Ok(())
566    }
567
568    #[tokio::test]
569    async fn test_convert_unsupported_format() {
570        // Create test configuration
571        let config_service = Arc::new(TestConfigService::with_defaults());
572
573        let temp_dir = TempDir::new().unwrap();
574        let input_file = temp_dir.path().join("test.unknown");
575        fs::write(&input_file, "not a subtitle").unwrap();
576
577        let args = ConvertArgs {
578            input: Some(input_file),
579            input_paths: Vec::new(),
580            recursive: false,
581            format: Some(OutputSubtitleFormat::Srt),
582            output: None,
583            keep_original: false,
584            encoding: String::from("utf-8"),
585            no_extract: false,
586        };
587
588        let result = execute_with_config(args, config_service).await;
589        // The function should succeed but individual file conversion may fail
590        // This tests the overall command execution flow
591        assert!(result.is_ok());
592    }
593
594    #[tokio::test]
595    async fn test_convert_with_different_config() {
596        // Create test configuration with custom settings
597        let config = TestConfigBuilder::new()
598            .with_ai_provider("test")
599            .with_ai_model("test-model")
600            .build_config();
601        let config_service = Arc::new(TestConfigService::new(config));
602
603        let temp_dir = TempDir::new().unwrap();
604        let input_file = temp_dir.path().join("test.srt");
605        let output_file = temp_dir.path().join("test.vtt");
606
607        fs::write(
608            &input_file,
609            "1\n00:00:01,000 --> 00:00:02,000\nCustom test\n\n",
610        )
611        .unwrap();
612
613        let args = ConvertArgs {
614            input: Some(input_file.clone()),
615            input_paths: Vec::new(),
616            recursive: false,
617            format: Some(OutputSubtitleFormat::Vtt),
618            output: Some(output_file.clone()),
619            keep_original: true,
620            encoding: String::from("utf-8"),
621            no_extract: false,
622        };
623
624        let result = execute_with_config(args, config_service).await;
625
626        // Should work with custom configuration
627        if result.is_err() {
628            println!("Test with custom config failed as expected due to external dependencies");
629        }
630    }
631}