Skip to main content

subx_cli/commands/
translate_command.rs

1//! Subtitle translation command implementation.
2//!
3//! This module wires CLI argument validation, configuration loading, input
4//! collection, safe output resolution, and translation engine invocation.
5//!
6//! # Examples
7//!
8//! ```rust,ignore
9//! use subx_cli::cli::TranslateArgs;
10//! use subx_cli::commands::translate_command;
11//! use subx_cli::config::TestConfigService;
12//!
13//! # async fn example() -> subx_cli::Result<()> {
14//! let args = TranslateArgs {
15//!     paths: vec!["movie.srt".into()],
16//!     input_paths: vec![],
17//!     recursive: false,
18//!     target_language: Some("zh-TW".to_string()),
19//!     source_language: None,
20//!     glossary: None,
21//!     context: None,
22//!     output: None,
23//!     no_extract: false,
24//!     force: false,
25//!     replace: false,
26//! };
27//! let config_service = TestConfigService::with_defaults();
28//! translate_command::execute(args, &config_service).await?;
29//! # Ok(())
30//! # }
31//! ```
32
33use 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/// Per-file record reported in the `translate` JSON envelope.
44///
45/// Each entry corresponds to one input subtitle file processed by the
46/// command. `applied` is `true` when the translated output was
47/// successfully written to disk.
48#[derive(Debug, Serialize)]
49pub struct TranslatedFile {
50    /// Source subtitle path as supplied to the command.
51    pub input: String,
52    /// Effective output path for the translated subtitle (may equal
53    /// `input` when `--replace` is active).
54    pub output: String,
55    /// Whether the translated content was written successfully.
56    pub applied: bool,
57}
58
59/// Top-level payload for the `translate` command JSON envelope.
60#[derive(Debug, Serialize)]
61pub struct TranslatePayload {
62    /// Per-file outcomes for every collected input.
63    pub translated_files: Vec<TranslatedFile>,
64}
65
66/// Resolved translate command inputs after CLI/config defaulting.
67///
68/// This struct is filled in by [`execute`] before delegating to the
69/// translation engine implemented in the `core` slice. It is exposed so the
70/// engine slice can consume a stable, validated value type.
71#[derive(Debug, Clone)]
72pub struct TranslateExecution {
73    /// Validated CLI arguments.
74    pub args: TranslateArgs,
75    /// Effective target language (`args.target_language` if non-empty,
76    /// otherwise [`crate::config::TranslationConfig::default_target_language`]).
77    pub target_language: String,
78    /// Configured translation batch size.
79    pub batch_size: usize,
80}
81
82struct ResolvedOutput {
83    path: PathBuf,
84    replaces_source: bool,
85}
86
87/// Resolve the effective target language using CLI > config precedence.
88///
89/// CLI `--target-language` always wins when non-empty. Otherwise, the
90/// configured `translation.default_target_language` is used. If neither is
91/// available, the function returns an error so the caller can surface a
92/// usage-style failure before any AI request is sent.
93fn 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
113/// Execute the `translate` command.
114///
115/// # Arguments
116///
117/// * `args` - Parsed CLI arguments.
118/// * `config_service` - Configuration service providing translation defaults.
119///
120/// # Errors
121///
122/// Returns an error if argument validation, input collection, AI translation,
123/// or output writing fails.
124pub 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        // Nothing to do — emit an empty success envelope in JSON mode so
150        // callers always receive a valid document.
151        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
264/// Execute the `translate` command with an owned configuration service.
265///
266/// Mirrors the convention used by the other subcommands so the dispatcher
267/// can route both `Arc<dyn ConfigService>` and `&dyn ConfigService` paths.
268pub 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        // The configured default should satisfy target-language resolution.
462        // This fixture uses a nonexistent input, so the command fails later
463        // during input collection instead of failing target-language resolution.
464        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}