1use rayon::{ThreadPoolBuilder, prelude::*};
29use std::fmt;
30use std::process::Command;
31use std::sync::Arc;
32
33#[derive(Debug)]
35pub enum TranslateError {
36 CommandFailed(String),
37 Utf8Error(String),
38 ParseError(String),
39 EmptyResponse,
40 RateLimited,
41}
42
43impl fmt::Display for TranslateError {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 match self {
46 TranslateError::CommandFailed(e) => write!(f, "Command failed: {}", e),
47 TranslateError::Utf8Error(e) => write!(f, "UTF-8 decode failed: {}", e),
48 TranslateError::ParseError(e) => write!(f, "Parse error: {}", e),
49 TranslateError::EmptyResponse => write!(f, "Empty response from server"),
50 TranslateError::RateLimited => write!(f, "Rate limited by Google Translate"),
51 }
52 }
53}
54
55impl std::error::Error for TranslateError {}
56
57pub fn translate(text: &str, from: &str, to: &str) -> Result<String, TranslateError> {
65 let q = url_encode(text);
66 let url = format!(
67 "https://translate.googleapis.com/translate_a/single?client=gtx&sl={}&tl={}&dt=t&q={}",
68 from, to, q
69 );
70
71 let output = Command::new("curl")
72 .arg("-s")
73 .arg(&url)
74 .output()
75 .map_err(|e| TranslateError::CommandFailed(e.to_string()))?;
76
77 if !output.status.success() {
78 return Err(TranslateError::CommandFailed(format!(
79 "curl exited with: {:?}",
80 output.status.code()
81 )));
82 }
83
84 let body =
85 String::from_utf8(output.stdout).map_err(|e| TranslateError::Utf8Error(e.to_string()))?;
86
87 if body.trim().is_empty() {
88 return Err(TranslateError::EmptyResponse);
89 }
90
91 if body.contains("<html>") || body.contains("503") || body == "[]" {
93 return Err(TranslateError::RateLimited);
94 }
95
96 parse_translation(&body)
97}
98
99pub fn translate_vec(texts: &[&str], from: &str, to: &str) -> Vec<Result<String, TranslateError>> {
108 translate_vec_with_threads(texts, from, to, 4)
109}
110
111pub fn translate_vec_with_threads(
138 texts: &[&str],
139 from: &str,
140 to: &str,
141 num_threads: usize,
142) -> Vec<Result<String, TranslateError>> {
143 let pool = ThreadPoolBuilder::new()
144 .num_threads(num_threads)
145 .build()
146 .expect("Failed to create thread pool");
147
148 let texts = Arc::new(texts.to_vec());
149
150 pool.install(|| {
151 texts
152 .par_iter()
153 .map(|text| translate(text, from, to))
154 .collect()
155 })
156}
157
158fn parse_translation(body: &str) -> Result<String, TranslateError> {
159 if let Some(start) = body.find("[[[\"") {
160 let after = &body[start + 4..];
161 if let Some(end) = after.find('"') {
162 let translated = &after[..end];
163 if translated.trim().is_empty() {
164 return Err(TranslateError::EmptyResponse);
165 }
166 return Ok(translated.to_string());
167 }
168 }
169 Err(TranslateError::ParseError(format!(
170 "Unexpected response format: {}",
171 &body[..body.len().min(120)]
172 )))
173}
174
175fn url_encode(input: &str) -> String {
176 input
177 .bytes()
178 .map(|b| match b {
179 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
180 (b as char).to_string()
181 }
182 _ => format!("%{:02X}", b),
183 })
184 .collect()
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn test_url_encode_basic() {
193 assert_eq!(url_encode("Hello world!"), "Hello%20world%21");
194 }
195
196 #[test]
197 fn test_parse_translation_valid() {
198 let json = r#"[[["Xin chà o","Hello",null,null,3,null,null,[[]]]],null,"en"]"#;
199 let result = parse_translation(json).unwrap();
200 assert_eq!(result, "Xin chà o");
201 }
202
203 #[test]
204 fn test_parse_translation_invalid() {
205 let json = "INVALID";
206 assert!(parse_translation(json).is_err());
207 }
208
209 #[test]
210 fn test_empty_body_error() {
211 let err = translate("", "auto", "vi").unwrap_err();
212 assert!(matches!(
213 err,
214 TranslateError::EmptyResponse
215 | TranslateError::RateLimited
216 | TranslateError::ParseError(_)
217 ));
218 }
219}