use rayon::{ThreadPoolBuilder, prelude::*};
use std::fmt;
use std::process::Command;
use std::sync::Arc;
#[derive(Debug)]
pub enum TranslateError {
CommandFailed(String),
Utf8Error(String),
ParseError(String),
EmptyResponse,
RateLimited,
}
impl fmt::Display for TranslateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TranslateError::CommandFailed(e) => write!(f, "Command failed: {}", e),
TranslateError::Utf8Error(e) => write!(f, "UTF-8 decode failed: {}", e),
TranslateError::ParseError(e) => write!(f, "Parse error: {}", e),
TranslateError::EmptyResponse => write!(f, "Empty response from server"),
TranslateError::RateLimited => write!(f, "Rate limited by Google Translate"),
}
}
}
impl std::error::Error for TranslateError {}
pub fn translate(text: &str, from: &str, to: &str) -> Result<String, TranslateError> {
let q = url_encode(text);
let url = format!(
"https://translate.googleapis.com/translate_a/single?client=gtx&sl={}&tl={}&dt=t&q={}",
from, to, q
);
let output = Command::new("curl")
.arg("-s")
.arg(&url)
.output()
.map_err(|e| TranslateError::CommandFailed(e.to_string()))?;
if !output.status.success() {
return Err(TranslateError::CommandFailed(format!(
"curl exited with: {:?}",
output.status.code()
)));
}
let body =
String::from_utf8(output.stdout).map_err(|e| TranslateError::Utf8Error(e.to_string()))?;
if body.trim().is_empty() {
return Err(TranslateError::EmptyResponse);
}
if body.contains("<html>") || body.contains("503") || body == "[]" {
return Err(TranslateError::RateLimited);
}
parse_translation(&body)
}
pub fn translate_vec(texts: &[&str], from: &str, to: &str) -> Vec<Result<String, TranslateError>> {
translate_vec_with_threads(texts, from, to, 4)
}
pub fn translate_vec_with_threads(
texts: &[&str],
from: &str,
to: &str,
num_threads: usize,
) -> Vec<Result<String, TranslateError>> {
let pool = ThreadPoolBuilder::new()
.num_threads(num_threads)
.build()
.expect("Failed to create thread pool");
let texts = Arc::new(texts.to_vec());
pool.install(|| {
texts
.par_iter()
.map(|text| translate(text, from, to))
.collect()
})
}
fn parse_translation(body: &str) -> Result<String, TranslateError> {
if let Some(start) = body.find("[[[\"") {
let after = &body[start + 4..];
if let Some(end) = after.find('"') {
let translated = &after[..end];
if translated.trim().is_empty() {
return Err(TranslateError::EmptyResponse);
}
return Ok(translated.to_string());
}
}
Err(TranslateError::ParseError(format!(
"Unexpected response format: {}",
&body[..body.len().min(120)]
)))
}
fn url_encode(input: &str) -> String {
input
.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{:02X}", b),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_encode_basic() {
assert_eq!(url_encode("Hello world!"), "Hello%20world%21");
}
#[test]
fn test_parse_translation_valid() {
let json = r#"[[["Xin chà o","Hello",null,null,3,null,null,[[]]]],null,"en"]"#;
let result = parse_translation(json).unwrap();
assert_eq!(result, "Xin chà o");
}
#[test]
fn test_parse_translation_invalid() {
let json = "INVALID";
assert!(parse_translation(json).is_err());
}
#[test]
fn test_empty_body_error() {
let err = translate("", "auto", "vi").unwrap_err();
assert!(matches!(
err,
TranslateError::EmptyResponse
| TranslateError::RateLimited
| TranslateError::ParseError(_)
));
}
}