1use crate::cli::TranslateArgs;
34use std::path::{Path, PathBuf};
35
36use crate::cli::output::{active_mode, emit_success, is_quiet};
37use crate::config::ConfigService;
38use crate::core::ComponentFactory;
39use crate::core::translation::{TranslationRequest, parse_glossary_text};
40use crate::error::SubXError;
41use serde::Serialize;
42
43#[derive(Debug, Serialize)]
49pub struct TranslatedFile {
50 pub input: String,
52 pub output: String,
55 pub applied: bool,
57}
58
59#[derive(Debug, Serialize)]
61pub struct TranslatePayload {
62 pub translated_files: Vec<TranslatedFile>,
64}
65
66#[derive(Debug, Clone)]
72pub struct TranslateExecution {
73 pub args: TranslateArgs,
75 pub target_language: String,
78 pub batch_size: usize,
80}
81
82struct ResolvedOutput {
83 path: PathBuf,
84 replaces_source: bool,
85}
86
87fn resolve_target_language(
94 args: &TranslateArgs,
95 default: Option<&str>,
96) -> Result<String, SubXError> {
97 if let Some(cli) = args.target_language.as_deref() {
98 let trimmed = cli.trim();
99 if !trimmed.is_empty() {
100 return Ok(trimmed.to_string());
101 }
102 }
103 match default {
104 Some(d) if !d.trim().is_empty() => Ok(d.trim().to_string()),
105 _ => Err(SubXError::CommandExecution(
106 "No target language provided. Pass --target-language or set \
107 translation.default_target_language in the configuration."
108 .to_string(),
109 )),
110 }
111}
112
113pub async fn execute(args: TranslateArgs, config_service: &dyn ConfigService) -> crate::Result<()> {
125 args.validate()
126 .map_err(|e| SubXError::CommandExecution(e.to_string()))?;
127
128 let config = config_service.get_config()?;
129 let target_language =
130 resolve_target_language(&args, config.translation.default_target_language.as_deref())?;
131
132 let execution = TranslateExecution {
133 args: args.clone(),
134 target_language: target_language.clone(),
135 batch_size: config.translation.batch_size,
136 };
137
138 let handler = execution
139 .args
140 .get_input_handler()
141 .map_err(|e| SubXError::CommandExecution(e.to_string()))?;
142 let collected = handler
143 .collect_files()
144 .map_err(|e| SubXError::CommandExecution(e.to_string()))?;
145 let mode = active_mode();
146 let json_mode = mode.is_json();
147 let quiet = is_quiet();
148 if collected.is_empty() {
149 if json_mode {
152 emit_success(
153 mode,
154 "translate",
155 TranslatePayload {
156 translated_files: Vec::new(),
157 },
158 );
159 }
160 return Ok(());
161 }
162
163 let glossary_text = match &execution.args.glossary {
164 Some(path) => Some(std::fs::read_to_string(path).map_err(|e| {
165 SubXError::FileOperationFailed(format!(
166 "Failed to read glossary file {}: {e}",
167 path.display()
168 ))
169 })?),
170 None => None,
171 };
172 let glossary_entries = glossary_text
173 .as_deref()
174 .map(parse_glossary_text)
175 .unwrap_or_default();
176
177 let factory = ComponentFactory::new(config_service)?;
178 let engine = factory.create_translation_engine()?;
179 let mut failures = Vec::new();
180 let mut items: Vec<TranslatedFile> = Vec::with_capacity(collected.len());
181
182 for input_path in collected.iter() {
183 let output = match resolve_output_path(
184 input_path,
185 &collected,
186 &execution.args,
187 &execution.target_language,
188 ) {
189 Ok(output) => output,
190 Err(err) => {
191 if !json_mode && !quiet {
192 eprintln!(
193 "✗ Translation setup failed for {}: {}",
194 input_path.display(),
195 err
196 );
197 }
198 failures.push(format!("{}: {err}", input_path.display()));
199 items.push(TranslatedFile {
200 input: input_path.display().to_string(),
201 output: String::new(),
202 applied: false,
203 });
204 continue;
205 }
206 };
207
208 if let Err(err) = translate_one_file(
209 &engine,
210 input_path,
211 &output,
212 &execution,
213 glossary_text.clone(),
214 glossary_entries.clone(),
215 config.general.backup_enabled,
216 )
217 .await
218 {
219 if !json_mode && !quiet {
220 eprintln!("✗ Translation failed for {}: {}", input_path.display(), err);
221 }
222 failures.push(format!("{}: {err}", input_path.display()));
223 items.push(TranslatedFile {
224 input: input_path.display().to_string(),
225 output: output.path.display().to_string(),
226 applied: false,
227 });
228 } else {
229 if !json_mode {
230 println!(
231 "✓ Translation completed: {} -> {}",
232 input_path.display(),
233 output.path.display()
234 );
235 }
236 items.push(TranslatedFile {
237 input: input_path.display().to_string(),
238 output: output.path.display().to_string(),
239 applied: true,
240 });
241 }
242 }
243
244 if failures.is_empty() {
245 if json_mode {
246 emit_success(
247 mode,
248 "translate",
249 TranslatePayload {
250 translated_files: items,
251 },
252 );
253 }
254 Ok(())
255 } else {
256 Err(SubXError::CommandExecution(format!(
257 "{} translation job(s) failed: {}",
258 failures.len(),
259 failures.join("; ")
260 )))
261 }
262}
263
264pub async fn execute_with_config(
269 args: TranslateArgs,
270 config_service: std::sync::Arc<dyn ConfigService>,
271) -> crate::Result<()> {
272 execute(args, config_service.as_ref()).await
273}
274
275async fn translate_one_file(
276 engine: &crate::core::translation::TranslationEngine,
277 input_path: &Path,
278 output: &ResolvedOutput,
279 execution: &TranslateExecution,
280 glossary_text: Option<String>,
281 glossary_entries: Vec<crate::core::translation::GlossaryEntry>,
282 backup_enabled: bool,
283) -> crate::Result<()> {
284 if output.path.exists() && !execution.args.force && !output.replaces_source {
285 return Err(SubXError::FileAlreadyExists(
286 output.path.display().to_string(),
287 ));
288 }
289
290 let subtitle = engine.format_manager().load_subtitle(input_path)?;
291 let request = TranslationRequest {
292 target_language: execution.target_language.clone(),
293 source_language: execution.args.source_language.clone(),
294 glossary_text,
295 context: execution.args.context.clone(),
296 glossary_entries,
297 };
298 let result = engine.translate_subtitle(subtitle, &request).await?;
299
300 if output.replaces_source && backup_enabled {
301 let backup = backup_path(input_path);
302 std::fs::copy(input_path, &backup).map_err(|e| {
303 SubXError::FileOperationFailed(format!(
304 "Failed to create backup {}: {e}",
305 backup.display()
306 ))
307 })?;
308 }
309
310 if let Some(parent) = output.path.parent() {
311 std::fs::create_dir_all(parent).map_err(|e| {
312 SubXError::FileOperationFailed(format!(
313 "Failed to create output directory {}: {e}",
314 parent.display()
315 ))
316 })?;
317 }
318 engine
319 .format_manager()
320 .save_subtitle(&result.subtitle, &output.path)
321}
322
323fn resolve_output_path(
324 input_path: &Path,
325 collected: &crate::cli::CollectedFiles,
326 args: &TranslateArgs,
327 target_language: &str,
328) -> crate::Result<ResolvedOutput> {
329 if args.replace {
330 if collected.archive_origin(input_path).is_some() {
331 return Err(SubXError::CommandExecution(
332 "--replace cannot be used for subtitles extracted from archives".to_string(),
333 ));
334 }
335 return Ok(ResolvedOutput {
336 path: input_path.to_path_buf(),
337 replaces_source: true,
338 });
339 }
340
341 let path = match &args.output {
342 Some(output) => explicit_output_path(output, input_path, collected.len(), target_language)?,
343 None => default_output_path(
344 input_path,
345 collected.archive_origin(input_path),
346 target_language,
347 ),
348 };
349 Ok(ResolvedOutput {
350 path,
351 replaces_source: false,
352 })
353}
354
355fn explicit_output_path(
356 output: &Path,
357 input_path: &Path,
358 input_count: usize,
359 target_language: &str,
360) -> crate::Result<PathBuf> {
361 if input_count > 1 {
362 if output.exists() && !output.is_dir() {
363 return Err(SubXError::CommandExecution(format!(
364 "Batch translation output must be a directory: {}",
365 output.display()
366 )));
367 }
368 if output.extension().is_some() {
369 return Err(SubXError::CommandExecution(format!(
370 "Batch translation output must be a directory: {}",
371 output.display()
372 )));
373 }
374 return Ok(output.join(translated_file_name(input_path, target_language)));
375 }
376
377 if output.is_dir() {
378 Ok(output.join(translated_file_name(input_path, target_language)))
379 } else {
380 Ok(output.to_path_buf())
381 }
382}
383
384fn default_output_path(
385 input_path: &Path,
386 archive_origin: Option<&Path>,
387 target_language: &str,
388) -> PathBuf {
389 let base_dir = archive_origin
390 .and_then(Path::parent)
391 .or_else(|| input_path.parent())
392 .unwrap_or_else(|| Path::new("."));
393 base_dir.join(translated_file_name(input_path, target_language))
394}
395
396fn translated_file_name(input_path: &Path, target_language: &str) -> String {
397 let stem = input_path
398 .file_stem()
399 .and_then(|s| s.to_str())
400 .unwrap_or("subtitle");
401 let ext = input_path
402 .extension()
403 .and_then(|s| s.to_str())
404 .unwrap_or("srt");
405 format!("{stem}.{target_language}.{ext}")
406}
407
408fn backup_path(input_path: &Path) -> PathBuf {
409 let ext = input_path
410 .extension()
411 .and_then(|s| s.to_str())
412 .unwrap_or("");
413 if ext.is_empty() {
414 input_path.with_extension("backup")
415 } else {
416 input_path.with_extension(format!("{ext}.backup"))
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use crate::config::TestConfigBuilder;
424 use std::path::PathBuf;
425
426 fn base_args() -> TranslateArgs {
427 TranslateArgs {
428 paths: vec![PathBuf::from("nonexistent.srt")],
429 input_paths: vec![],
430 recursive: false,
431 target_language: Some("zh-TW".to_string()),
432 source_language: None,
433 glossary: None,
434 context: None,
435 output: None,
436 no_extract: false,
437 force: false,
438 replace: false,
439 }
440 }
441
442 #[tokio::test]
443 async fn test_validation_runs_before_execution() {
444 let mut args = base_args();
445 args.target_language = Some(" ".to_string());
446 let config_service = TestConfigBuilder::new().build_service();
447 let err = execute(args, &config_service)
448 .await
449 .expect_err("empty target language must fail");
450 let msg = format!("{err:?}");
451 assert!(msg.contains("target-language"), "unexpected error: {msg}");
452 }
453
454 #[tokio::test]
455 async fn test_uses_configured_default_target_language() {
456 let mut args = base_args();
457 args.target_language = None;
458 let config_service = TestConfigBuilder::new()
459 .with_translation_default_target_language("ja")
460 .build_service();
461 let err = execute(args, &config_service)
465 .await
466 .expect_err("input collection error");
467 let msg = format!("{err:?}");
468 assert!(msg.contains("Path not found"), "unexpected: {msg}");
469 }
470
471 #[tokio::test]
472 async fn test_missing_default_and_cli_target_language_fails() {
473 let mut args = base_args();
474 args.target_language = None;
475 let config_service = TestConfigBuilder::new().build_service();
476 let err = execute(args, &config_service)
477 .await
478 .expect_err("no target language must fail");
479 let msg = format!("{err:?}");
480 assert!(msg.contains("No target language"), "unexpected: {msg}");
481 }
482}