1use crate::cli::TranslateArgs;
34use std::path::{Path, PathBuf};
35
36use crate::cli::output::{active_mode, emit_success, is_quiet};
37use serde::Serialize;
38use subx_core::config::ConfigService;
39use subx_core::core::ComponentFactory;
40use subx_core::core::translation::{TranslationRequest, parse_glossary_text};
41use subx_core::error::SubXError;
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)?.with_reporter(
178 crate::cli::terminal_reporter_with_progress_bar(config.general.enable_progress_bar),
179 );
180 let engine = factory.create_translation_engine()?;
181 let mut failures = Vec::new();
182 let mut items: Vec<TranslatedFile> = Vec::with_capacity(collected.len());
183
184 for input_path in collected.iter() {
185 let output = match resolve_output_path(
186 input_path,
187 &collected,
188 &execution.args,
189 &execution.target_language,
190 ) {
191 Ok(output) => output,
192 Err(err) => {
193 if !json_mode && !quiet {
194 eprintln!(
195 "✗ Translation setup failed for {}: {}",
196 input_path.display(),
197 err
198 );
199 }
200 failures.push(format!("{}: {err}", input_path.display()));
201 items.push(TranslatedFile {
202 input: input_path.display().to_string(),
203 output: String::new(),
204 applied: false,
205 });
206 continue;
207 }
208 };
209
210 if let Err(err) = translate_one_file(
211 &engine,
212 input_path,
213 &output,
214 &execution,
215 glossary_text.clone(),
216 glossary_entries.clone(),
217 config.general.backup_enabled,
218 )
219 .await
220 {
221 if !json_mode && !quiet {
222 eprintln!("✗ Translation failed for {}: {}", input_path.display(), err);
223 }
224 failures.push(format!("{}: {err}", input_path.display()));
225 items.push(TranslatedFile {
226 input: input_path.display().to_string(),
227 output: output.path.display().to_string(),
228 applied: false,
229 });
230 } else {
231 if !json_mode {
232 println!(
233 "✓ Translation completed: {} -> {}",
234 input_path.display(),
235 output.path.display()
236 );
237 }
238 items.push(TranslatedFile {
239 input: input_path.display().to_string(),
240 output: output.path.display().to_string(),
241 applied: true,
242 });
243 }
244 }
245
246 if failures.is_empty() {
247 if json_mode {
248 emit_success(
249 mode,
250 "translate",
251 TranslatePayload {
252 translated_files: items,
253 },
254 );
255 }
256 Ok(())
257 } else {
258 Err(SubXError::CommandExecution(format!(
259 "{} translation job(s) failed: {}",
260 failures.len(),
261 failures.join("; ")
262 )))
263 }
264}
265
266pub async fn execute_with_config(
271 args: TranslateArgs,
272 config_service: std::sync::Arc<dyn ConfigService>,
273) -> crate::Result<()> {
274 execute(args, config_service.as_ref()).await
275}
276
277async fn translate_one_file(
278 engine: &subx_core::core::translation::TranslationEngine,
279 input_path: &Path,
280 output: &ResolvedOutput,
281 execution: &TranslateExecution,
282 glossary_text: Option<String>,
283 glossary_entries: Vec<subx_core::core::translation::GlossaryEntry>,
284 backup_enabled: bool,
285) -> crate::Result<()> {
286 if output.path.exists() && !execution.args.force && !output.replaces_source {
287 return Err(SubXError::FileAlreadyExists(
288 output.path.display().to_string(),
289 ));
290 }
291
292 let subtitle = engine.format_manager().load_subtitle(input_path)?;
293 let request = TranslationRequest {
294 target_language: execution.target_language.clone(),
295 source_language: execution.args.source_language.clone(),
296 glossary_text,
297 context: execution.args.context.clone(),
298 glossary_entries,
299 };
300 let result = engine.translate_subtitle(subtitle, &request).await?;
301
302 if output.replaces_source && backup_enabled {
303 let backup = backup_path(input_path);
304 std::fs::copy(input_path, &backup).map_err(|e| {
305 SubXError::FileOperationFailed(format!(
306 "Failed to create backup {}: {e}",
307 backup.display()
308 ))
309 })?;
310 }
311
312 if let Some(parent) = output.path.parent() {
313 std::fs::create_dir_all(parent).map_err(|e| {
314 SubXError::FileOperationFailed(format!(
315 "Failed to create output directory {}: {e}",
316 parent.display()
317 ))
318 })?;
319 }
320 engine
321 .format_manager()
322 .save_subtitle(&result.subtitle, &output.path)
323}
324
325fn resolve_output_path(
326 input_path: &Path,
327 collected: &subx_core::core::input::CollectedFiles,
328 args: &TranslateArgs,
329 target_language: &str,
330) -> crate::Result<ResolvedOutput> {
331 if args.replace {
332 if collected.archive_origin(input_path).is_some() {
333 return Err(SubXError::CommandExecution(
334 "--replace cannot be used for subtitles extracted from archives".to_string(),
335 ));
336 }
337 return Ok(ResolvedOutput {
338 path: input_path.to_path_buf(),
339 replaces_source: true,
340 });
341 }
342
343 let path = match &args.output {
344 Some(output) => explicit_output_path(output, input_path, collected.len(), target_language)?,
345 None => default_output_path(input_path, collected, target_language),
346 };
347 Ok(ResolvedOutput {
348 path,
349 replaces_source: false,
350 })
351}
352
353fn explicit_output_path(
354 output: &Path,
355 input_path: &Path,
356 input_count: usize,
357 target_language: &str,
358) -> crate::Result<PathBuf> {
359 if input_count > 1 {
360 if output.exists() && !output.is_dir() {
361 return Err(SubXError::CommandExecution(format!(
362 "Batch translation output must be a directory: {}",
363 output.display()
364 )));
365 }
366 if output.extension().is_some() {
367 return Err(SubXError::CommandExecution(format!(
368 "Batch translation output must be a directory: {}",
369 output.display()
370 )));
371 }
372 return Ok(output.join(translated_file_name(input_path, target_language)));
373 }
374
375 if output.is_dir() {
376 Ok(output.join(translated_file_name(input_path, target_language)))
377 } else {
378 Ok(output.to_path_buf())
379 }
380}
381
382fn default_output_path(
383 input_path: &Path,
384 collected: &subx_core::core::input::CollectedFiles,
385 target_language: &str,
386) -> PathBuf {
387 collected
390 .default_output_dir(input_path)
391 .join(translated_file_name(input_path, target_language))
392}
393
394fn translated_file_name(input_path: &Path, target_language: &str) -> String {
400 let stem = input_path
401 .file_stem()
402 .and_then(|s| s.to_str())
403 .unwrap_or("subtitle");
404 let ext = input_path
405 .extension()
406 .and_then(|s| s.to_str())
407 .unwrap_or("srt");
408 format!("{stem}.{target_language}.{ext}")
409}
410
411fn backup_path(input_path: &Path) -> PathBuf {
412 let ext = input_path
413 .extension()
414 .and_then(|s| s.to_str())
415 .unwrap_or("");
416 if ext.is_empty() {
417 input_path.with_extension("backup")
418 } else {
419 input_path.with_extension(format!("{ext}.backup"))
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use std::path::PathBuf;
427 use subx_core::config::TestConfigBuilder;
428
429 fn base_args() -> TranslateArgs {
430 TranslateArgs {
431 paths: vec![PathBuf::from("nonexistent.srt")],
432 input_paths: vec![],
433 recursive: false,
434 target_language: Some("zh-TW".to_string()),
435 source_language: None,
436 glossary: None,
437 context: None,
438 output: None,
439 no_extract: false,
440 force: false,
441 replace: false,
442 }
443 }
444
445 #[tokio::test]
446 async fn test_validation_runs_before_execution() {
447 let mut args = base_args();
448 args.target_language = Some(" ".to_string());
449 let config_service = TestConfigBuilder::new().build_service();
450 let err = execute(args, &config_service)
451 .await
452 .expect_err("empty target language must fail");
453 let msg = format!("{err:?}");
454 assert!(msg.contains("target-language"), "unexpected error: {msg}");
455 }
456
457 #[tokio::test]
458 async fn test_uses_configured_default_target_language() {
459 let mut args = base_args();
460 args.target_language = None;
461 let config_service = TestConfigBuilder::new()
462 .with_translation_default_target_language("ja")
463 .build_service();
464 let err = execute(args, &config_service)
468 .await
469 .expect_err("input collection error");
470 let msg = format!("{err:?}");
471 assert!(msg.contains("Path not found"), "unexpected: {msg}");
472 }
473
474 #[tokio::test]
475 async fn test_missing_default_and_cli_target_language_fails() {
476 let mut args = base_args();
477 args.target_language = None;
478 let config_service = TestConfigBuilder::new().build_service();
479 let err = execute(args, &config_service)
480 .await
481 .expect_err("no target language must fail");
482 let msg = format!("{err:?}");
483 assert!(msg.contains("No target language"), "unexpected: {msg}");
484 }
485}