use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::cli::output::{active_mode, emit_success};
use crate::cli::{ConvertArgs, OutputSubtitleFormat};
use crate::config::ConfigService;
use crate::core::file_manager::FileManager;
use crate::core::formats::converter::{ConversionConfig, FormatConverter};
use crate::error::SubXError;
#[derive(Debug, Serialize)]
pub struct ConvertItemError {
pub category: String,
pub code: String,
pub message: String,
}
impl ConvertItemError {
fn from_error(err: &SubXError) -> Self {
Self {
category: err.category().to_string(),
code: err.machine_code().to_string(),
message: err.user_friendly_message(),
}
}
fn synthetic(category: &str, code: &str, message: String) -> Self {
Self {
category: category.to_string(),
code: code.to_string(),
message,
}
}
}
#[derive(Debug, Serialize)]
pub struct ConvertItem {
pub input: String,
pub output: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_format: Option<String>,
pub target_format: String,
pub encoding: String,
pub applied: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub entry_count: Option<usize>,
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ConvertItemError>,
}
#[derive(Debug, Serialize)]
pub struct ConvertPayload {
pub conversions: Vec<ConvertItem>,
}
pub async fn execute(args: ConvertArgs, config_service: &dyn ConfigService) -> crate::Result<()> {
let app_config = config_service.get_config()?;
let config = ConversionConfig {
preserve_styling: app_config.formats.preserve_styling,
target_encoding: args.encoding.clone(),
keep_original: args.keep_original,
validate_output: true,
};
let converter = FormatConverter::new(config);
let default_output = match app_config.formats.default_output.as_str() {
"srt" => OutputSubtitleFormat::Srt,
"ass" => OutputSubtitleFormat::Ass,
"vtt" => OutputSubtitleFormat::Vtt,
"sub" => OutputSubtitleFormat::Sub,
other => {
return Err(SubXError::config(format!(
"Unknown default output format: {other}"
)));
}
};
let output_format = args.format.clone().unwrap_or(default_output);
let handler = args
.get_input_handler()
.map_err(|e| SubXError::CommandExecution(e.to_string()))?;
let collected = handler
.collect_files()
.map_err(|e| SubXError::CommandExecution(e.to_string()))?;
if collected.is_empty() {
let mode = active_mode();
if mode.is_json() {
emit_success(
mode,
"convert",
ConvertPayload {
conversions: Vec::new(),
},
);
}
return Ok(());
}
let mode = active_mode();
let json_mode = mode.is_json();
let single_input = collected.len() == 1;
let mut items: Vec<ConvertItem> = Vec::with_capacity(collected.len());
let mut single_input_fatal: Option<SubXError> = None;
for input_path in collected.iter() {
let fmt = output_format.to_string();
let output_path: PathBuf = if let Some(ref o) = args.output {
let mut p = o.clone();
#[allow(clippy::collapsible_if)]
if p.is_dir()
&& (handler.paths.len() != 1 || handler.paths[0].is_dir() || collected.len() > 1)
{
if let Some(stem) = input_path.file_stem().and_then(|s| s.to_str()) {
p.push(format!("{stem}.{fmt}"));
}
}
p
} else if let Some(archive_path) = collected.archive_origin(input_path) {
let archive_dir = archive_path.parent().unwrap_or(Path::new("."));
let stem = input_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("output");
archive_dir.join(format!("{stem}.{fmt}"))
} else {
input_path.with_extension(fmt.clone())
};
match converter.convert_file(input_path, &output_path, &fmt).await {
Ok(result) => {
if result.success {
if !json_mode {
println!(
"✓ Conversion completed: {} -> {}",
input_path.display(),
output_path.display()
);
}
if !args.keep_original {
let _ = FileManager::new().remove_file(input_path);
}
items.push(ConvertItem {
input: input_path.display().to_string(),
output: output_path.display().to_string(),
source_format: Some(result.input_format.to_lowercase()),
target_format: result.output_format.to_lowercase(),
encoding: args.encoding.clone(),
applied: true,
entry_count: Some(result.converted_entries),
status: "ok",
error: None,
});
} else {
if !json_mode {
eprintln!("✗ Conversion failed for {}", input_path.display());
for err in &result.errors {
eprintln!(" Error: {err}");
}
}
let message = if result.errors.is_empty() {
"Conversion produced an unsuccessful result".to_string()
} else {
result.errors.join("; ")
};
items.push(ConvertItem {
input: input_path.display().to_string(),
output: output_path.display().to_string(),
source_format: Some(result.input_format.to_lowercase()),
target_format: result.output_format.to_lowercase(),
encoding: args.encoding.clone(),
applied: false,
entry_count: None,
status: "error",
error: Some(ConvertItemError::synthetic(
"subtitle_format",
"E_SUBTITLE_FORMAT",
message,
)),
});
}
}
Err(e) => {
if !json_mode {
eprintln!("✗ Conversion error for {}: {}", input_path.display(), e);
}
let item_err = ConvertItemError::from_error(&e);
items.push(ConvertItem {
input: input_path.display().to_string(),
output: output_path.display().to_string(),
source_format: None,
target_format: fmt.clone(),
encoding: args.encoding.clone(),
applied: false,
entry_count: None,
status: "error",
error: Some(item_err),
});
if single_input && single_input_fatal.is_none() {
single_input_fatal = Some(e);
}
}
}
}
if let Some(err) = single_input_fatal {
return Err(err);
}
if json_mode {
emit_success(mode, "convert", ConvertPayload { conversions: items });
}
Ok(())
}
pub async fn execute_with_config(
args: ConvertArgs,
config_service: std::sync::Arc<dyn ConfigService>,
) -> crate::Result<()> {
execute(args, config_service.as_ref()).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{TestConfigBuilder, TestConfigService};
use std::fs;
use std::sync::Arc;
use tempfile::TempDir;
#[tokio::test]
async fn test_convert_srt_to_vtt() -> crate::Result<()> {
let config_service = Arc::new(TestConfigService::with_defaults());
let temp_dir = TempDir::new().unwrap();
let input_file = temp_dir.path().join("test.srt");
let output_file = temp_dir.path().join("test.vtt");
fs::write(
&input_file,
"1\n00:00:01,000 --> 00:00:02,000\nTest subtitle\n\n",
)
.unwrap();
let args = ConvertArgs {
input: Some(input_file.clone()),
input_paths: Vec::new(),
recursive: false,
format: Some(OutputSubtitleFormat::Vtt),
output: Some(output_file.clone()),
keep_original: false,
encoding: String::from("utf-8"),
no_extract: false,
};
execute_with_config(args, config_service).await?;
let content = fs::read_to_string(&output_file).unwrap();
assert!(content.contains("WEBVTT"));
assert!(content.contains("00:00:01.000 --> 00:00:02.000"));
Ok(())
}
#[tokio::test]
async fn test_convert_batch_processing() -> crate::Result<()> {
let config_service = Arc::new(TestConfigService::with_defaults());
let temp_dir = TempDir::new().unwrap();
for i in 1..=3 {
let file = temp_dir.path().join(format!("test{}.srt", i));
fs::write(
&file,
format!(
"1\n00:00:0{},000 --> 00:00:0{},000\nTest {}\n\n",
i,
i + 1,
i
),
)
.unwrap();
}
let args = ConvertArgs {
input: Some(temp_dir.path().to_path_buf()),
input_paths: Vec::new(),
recursive: false,
format: Some(OutputSubtitleFormat::Vtt),
output: Some(temp_dir.path().join("output")),
keep_original: false,
encoding: String::from("utf-8"),
no_extract: false,
};
execute_with_config(args, config_service).await?;
Ok(())
}
#[tokio::test]
async fn test_convert_unsupported_format() {
let config_service = Arc::new(TestConfigService::with_defaults());
let temp_dir = TempDir::new().unwrap();
let input_file = temp_dir.path().join("test.unknown");
fs::write(&input_file, "not a subtitle").unwrap();
let args = ConvertArgs {
input: Some(input_file),
input_paths: Vec::new(),
recursive: false,
format: Some(OutputSubtitleFormat::Srt),
output: None,
keep_original: false,
encoding: String::from("utf-8"),
no_extract: false,
};
let result = execute_with_config(args, config_service).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_convert_with_different_config() {
let config = TestConfigBuilder::new()
.with_ai_provider("test")
.with_ai_model("test-model")
.build_config();
let config_service = Arc::new(TestConfigService::new(config));
let temp_dir = TempDir::new().unwrap();
let input_file = temp_dir.path().join("test.srt");
let output_file = temp_dir.path().join("test.vtt");
fs::write(
&input_file,
"1\n00:00:01,000 --> 00:00:02,000\nCustom test\n\n",
)
.unwrap();
let args = ConvertArgs {
input: Some(input_file.clone()),
input_paths: Vec::new(),
recursive: false,
format: Some(OutputSubtitleFormat::Vtt),
output: Some(output_file.clone()),
keep_original: true,
encoding: String::from("utf-8"),
no_extract: false,
};
let result = execute_with_config(args, config_service).await;
if result.is_err() {
println!("Test with custom config failed as expected due to external dependencies");
}
}
}