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 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/// 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 [`subx_core::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)?.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
266/// Execute the `translate` command with an owned configuration service.
267///
268/// Mirrors the convention used by the other subcommands so the dispatcher
269/// can route both `Arc<dyn ConfigService>` and `&dyn ConfigService` paths.
270pub 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    // The archive-origin base-directory chain lives in core as
388    // CollectedFiles::default_output_dir.
389    collected
390        .default_output_dir(input_path)
391        .join(translated_file_name(input_path, target_language))
392}
393
394// Open question recorded by expose-core-orchestration-apis (design.md
395// Open Questions, seventh duplication): `translated_file_name` and
396// `backup_path` below are mirrored almost verbatim in the GUI at
397// ../subx/src-tauri/src/commands/translate.rs:704-708 and :735-742.
398// Candidate for a future core extraction; deliberately not relocated here.
399fn 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        // The configured default should satisfy target-language resolution.
465        // This fixture uses a nonexistent input, so the command fails later
466        // during input collection instead of failing target-language resolution.
467        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}