1#![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#[derive(Args, Debug, Clone)]
35pub struct TranslateArgs {
36 #[arg(value_name = "PATH", num_args = 0..)]
38 pub paths: Vec<PathBuf>,
39
40 #[arg(short = 'i', long = "input", value_name = "PATH")]
42 pub input_paths: Vec<PathBuf>,
43
44 #[arg(short, long)]
46 pub recursive: bool,
47
48 #[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 #[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 #[arg(long = "glossary", value_name = "PATH")]
73 pub glossary: Option<PathBuf>,
74
75 #[arg(long = "context", value_name = "TEXT")]
78 pub context: Option<String>,
79
80 #[arg(short = 'o', long = "output", value_name = "PATH")]
83 pub output: Option<PathBuf>,
84
85 #[arg(long, default_value_t = false)]
87 pub no_extract: bool,
88
89 #[arg(
94 long,
95 visible_alias = "overwrite",
96 default_value_t = false,
97 conflicts_with = "replace"
98 )]
99 pub force: bool,
100
101 #[arg(long, default_value_t = false, conflicts_with = "force")]
104 pub replace: bool,
105}
106
107impl TranslateArgs {
108 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 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 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}