Skip to main content

subx_cli/cli/
translate_args.rs

1//! Subtitle translation command-line arguments and validation.
2//!
3//! This module defines the [`TranslateArgs`] structure for the `translate`
4//! subcommand. It mirrors the conventions used by the existing mutating
5//! subtitle commands (`convert`, `sync`) and supports positional inputs,
6//! repeated `-i/--input`, recursive traversal, and archive expansion through
7//! the shared [`InputPathHandler`].
8//!
9//! # Examples
10//!
11//! ```bash
12//! # Translate a single SRT file into Traditional Chinese
13//! subx translate movie.srt --target-language zh-TW
14//!
15//! # Batch translate a directory recursively with a glossary and tone hint
16//! subx translate ./subs --recursive \
17//!     --target-language ja \
18//!     --glossary glossary.txt \
19//!     --context "Use formal tone"
20//! ```
21#![allow(clippy::needless_borrows_for_generic_args)]
22
23use crate::cli::InputPathHandler;
24use crate::error::SubXError;
25use clap::Args;
26use std::path::PathBuf;
27
28/// Command-line arguments for AI-assisted subtitle translation.
29///
30/// All input collection flags follow the same conventions as the other
31/// mutating subtitle commands. Validation rules are intentionally enforced
32/// before any AI call so that user mistakes (missing glossary file, empty
33/// `--target-language`, etc.) do not consume API quota.
34#[derive(Args, Debug, Clone)]
35pub struct TranslateArgs {
36    /// Positional file or directory paths to translate.
37    #[arg(value_name = "PATH", num_args = 0..)]
38    pub paths: Vec<PathBuf>,
39
40    /// Specify file or directory paths to process via repeated `-i/--input`.
41    #[arg(short = 'i', long = "input", value_name = "PATH")]
42    pub input_paths: Vec<PathBuf>,
43
44    /// Recursively process subdirectories.
45    #[arg(short, long)]
46    pub recursive: bool,
47
48    /// Required target language code or name (for example, `zh-TW`, `ja`,
49    /// `English`). When omitted on the CLI, the value falls back to
50    /// `translation.default_target_language` from configuration; if neither
51    /// is provided the command fails with a usage-style error.
52    #[arg(
53        short = 't',
54        long = "target-language",
55        value_name = "LANG",
56        help = "Target language code or name (e.g. zh-TW, ja, English)"
57    )]
58    pub target_language: Option<String>,
59
60    /// Optional source language hint. When omitted, the AI provider is asked
61    /// to detect or accept the source language automatically.
62    #[arg(
63        short = 's',
64        long = "source-language",
65        value_name = "LANG",
66        help = "Optional source language code (default: auto-detect)"
67    )]
68    pub source_language: Option<String>,
69
70    /// Path to a UTF-8 text glossary file. Glossary entries take precedence
71    /// over the AI-generated terminology map.
72    #[arg(long = "glossary", value_name = "PATH")]
73    pub glossary: Option<PathBuf>,
74
75    /// Inline context guidance forwarded to the translation prompt (for
76    /// example, `"Use formal business tone"`).
77    #[arg(long = "context", value_name = "TEXT")]
78    pub context: Option<String>,
79
80    /// Output file (single-input mode) or directory (batch mode) for the
81    /// translated subtitle(s).
82    #[arg(short = 'o', long = "output", value_name = "PATH")]
83    pub output: Option<PathBuf>,
84
85    /// Disable automatic archive extraction for `-i` inputs.
86    #[arg(long, default_value_t = false)]
87    pub no_extract: bool,
88
89    /// Overwrite existing translated output files.
90    ///
91    /// `--overwrite` is accepted as a visible alias for consistency with the
92    /// user-facing command documentation.
93    #[arg(
94        long,
95        visible_alias = "overwrite",
96        default_value_t = false,
97        conflicts_with = "replace"
98    )]
99    pub force: bool,
100
101    /// Replace each source subtitle file with its translated content.
102    /// Existing backup settings still apply.
103    #[arg(long, default_value_t = false, conflicts_with = "force")]
104    pub replace: bool,
105}
106
107impl TranslateArgs {
108    /// Validate the user-supplied arguments before invoking the translation
109    /// engine.
110    ///
111    /// Note: presence of a target language is validated by the command
112    /// handler after combining CLI input with configured defaults; this
113    /// function only validates that an explicitly provided `--target-language`
114    /// is non-empty, along with the other guidance options.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`SubXError::CommandExecution`] when:
119    ///
120    /// - `--target-language` is provided but empty after trimming.
121    /// - `--source-language` is provided but empty after trimming.
122    /// - `--context` is provided but empty after trimming.
123    /// - `--glossary` points to a path that does not exist or is not a file.
124    pub fn validate(&self) -> Result<(), SubXError> {
125        if self.force && self.replace {
126            return Err(SubXError::CommandExecution(
127                "--force/--overwrite cannot be used with --replace".to_string(),
128            ));
129        }
130
131        if let Some(target) = &self.target_language {
132            if target.trim().is_empty() {
133                return Err(SubXError::CommandExecution(
134                    "--target-language must not be empty".to_string(),
135                ));
136            }
137        }
138
139        if let Some(src) = &self.source_language {
140            if src.trim().is_empty() {
141                return Err(SubXError::CommandExecution(
142                    "--source-language must not be empty when provided".to_string(),
143                ));
144            }
145        }
146
147        if let Some(context) = &self.context {
148            if context.trim().is_empty() {
149                return Err(SubXError::CommandExecution(
150                    "--context must not be empty when provided".to_string(),
151                ));
152            }
153        }
154
155        if let Some(glossary) = &self.glossary {
156            if !glossary.exists() {
157                return Err(SubXError::CommandExecution(format!(
158                    "Glossary file does not exist: {}",
159                    glossary.display()
160                )));
161            }
162            if !glossary.is_file() {
163                return Err(SubXError::CommandExecution(format!(
164                    "Glossary path is not a regular file: {}",
165                    glossary.display()
166                )));
167            }
168        }
169
170        Ok(())
171    }
172
173    /// Build an [`InputPathHandler`] from positional and `-i` paths,
174    /// scoped to the subtitle file extensions supported by the rest of the
175    /// pipeline.
176    pub fn get_input_handler(&self) -> Result<InputPathHandler, SubXError> {
177        let string_paths: Vec<String> = self
178            .paths
179            .iter()
180            .map(|p| p.to_string_lossy().to_string())
181            .collect();
182        let merged = InputPathHandler::merge_paths_from_multiple_sources(
183            &[],
184            &self.input_paths,
185            &string_paths,
186        )?;
187        Ok(InputPathHandler::from_args(&merged, self.recursive)?
188            .with_extensions(&["srt", "ass", "vtt", "sub", "ssa"])
189            .with_no_extract(self.no_extract))
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::cli::{Cli, Commands};
197    use clap::Parser;
198    use std::path::PathBuf;
199
200    fn parse(args: &[&str]) -> TranslateArgs {
201        let cli = Cli::try_parse_from(args).expect("parse should succeed");
202        match cli.command {
203            Commands::Translate(a) => a,
204            _ => panic!("Expected Translate command"),
205        }
206    }
207
208    #[test]
209    fn test_target_language_optional_at_clap_level() {
210        // Clap allows omission; presence is enforced by command handler so
211        // the configured default can take effect.
212        let args = parse(&["subx-cli", "translate", "movie.srt"]);
213        assert!(args.target_language.is_none());
214    }
215
216    #[test]
217    fn test_basic_invocation_parses_target_language() {
218        let args = parse(&[
219            "subx-cli",
220            "translate",
221            "movie.srt",
222            "--target-language",
223            "zh-TW",
224        ]);
225        assert_eq!(args.paths, vec![PathBuf::from("movie.srt")]);
226        assert_eq!(args.target_language.as_deref(), Some("zh-TW"));
227        assert!(args.source_language.is_none());
228        assert!(args.glossary.is_none());
229        assert!(args.context.is_none());
230        assert!(args.output.is_none());
231        assert!(!args.recursive);
232        assert!(!args.no_extract);
233        assert!(!args.force);
234        assert!(!args.replace);
235    }
236
237    #[test]
238    fn test_repeated_input_and_optional_flags() {
239        let args = parse(&[
240            "subx-cli",
241            "translate",
242            "-i",
243            "d1",
244            "-i",
245            "f.srt",
246            "--recursive",
247            "--target-language",
248            "ja",
249            "--source-language",
250            "en",
251            "--glossary",
252            "glossary.txt",
253            "--context",
254            "Use formal tone",
255            "--output",
256            "out/",
257            "--no-extract",
258            "--force",
259        ]);
260        assert!(args.paths.is_empty());
261        assert_eq!(
262            args.input_paths,
263            vec![PathBuf::from("d1"), PathBuf::from("f.srt")]
264        );
265        assert!(args.recursive);
266        assert_eq!(args.target_language.as_deref(), Some("ja"));
267        assert_eq!(args.source_language.as_deref(), Some("en"));
268        assert_eq!(args.glossary, Some(PathBuf::from("glossary.txt")));
269        assert_eq!(args.context.as_deref(), Some("Use formal tone"));
270        assert_eq!(args.output, Some(PathBuf::from("out/")));
271        assert!(args.no_extract);
272        assert!(args.force);
273        assert!(!args.replace);
274    }
275
276    #[test]
277    fn test_validate_rejects_empty_target_language() {
278        let args = TranslateArgs {
279            paths: vec![PathBuf::from("a.srt")],
280            input_paths: vec![],
281            recursive: false,
282            target_language: Some("   ".to_string()),
283            source_language: None,
284            glossary: None,
285            context: None,
286            output: None,
287            no_extract: false,
288            force: false,
289            replace: false,
290        };
291        assert!(args.validate().is_err());
292    }
293
294    #[test]
295    fn test_validate_rejects_empty_context() {
296        let args = TranslateArgs {
297            paths: vec![PathBuf::from("a.srt")],
298            input_paths: vec![],
299            recursive: false,
300            target_language: Some("ja".to_string()),
301            source_language: None,
302            glossary: None,
303            context: Some("   ".to_string()),
304            output: None,
305            no_extract: false,
306            force: false,
307            replace: false,
308        };
309        assert!(args.validate().is_err());
310    }
311
312    #[test]
313    fn test_validate_rejects_missing_glossary() {
314        let args = TranslateArgs {
315            paths: vec![PathBuf::from("a.srt")],
316            input_paths: vec![],
317            recursive: false,
318            target_language: Some("ja".to_string()),
319            source_language: None,
320            glossary: Some(PathBuf::from(
321                "/nonexistent_subx_translate_glossary_file.txt",
322            )),
323            context: None,
324            output: None,
325            no_extract: false,
326            force: false,
327            replace: false,
328        };
329        let err = args.validate().expect_err("missing glossary should fail");
330        let msg = format!("{err:?}");
331        assert!(msg.contains("Glossary"), "unexpected error: {msg}");
332    }
333
334    #[test]
335    fn test_validate_accepts_no_glossary() {
336        let args = TranslateArgs {
337            paths: vec![PathBuf::from("a.srt")],
338            input_paths: vec![],
339            recursive: false,
340            target_language: Some("zh-TW".to_string()),
341            source_language: Some("en".to_string()),
342            glossary: None,
343            context: Some("Tone".to_string()),
344            output: None,
345            no_extract: false,
346            force: false,
347            replace: false,
348        };
349        assert!(args.validate().is_ok());
350    }
351}