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}