rtranslate/
lib.rs

1//! # rtranslate 🦀
2//!
3//! A minimal, dependency-free Rust wrapper for Google Translate web API.
4//!
5//! ```
6//! use rtranslate::translate;
7//!
8//! fn main() {
9//!     let result = translate("Hello", "auto", "vi").unwrap();
10//!     println!("Translated: {}", result);
11//! }
12//! ```
13//!
14//! Also supports batch translation:
15//!
16//! ```
17//! use rtranslate::translate_vec;
18//!
19//! fn main() {
20//!     let phrases = ["Good morning", "Rust is great"];
21//!     let results = translate_vec(&phrases, "auto", "vi");
22//!     for r in results {
23//!         println!("{:?}", r);
24//!     }
25//! }
26//! ```
27
28use rayon::{ThreadPoolBuilder, prelude::*};
29use std::fmt;
30use std::process::Command;
31use std::sync::Arc;
32
33/// Error type for rtranslate
34#[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
57/// Translate a single string.
58///
59/// # Example
60/// ```
61/// let translated = rtranslate::translate("Hello world", "auto", "vi").unwrap();
62/// println!("Translated: {}", translated);
63/// ```
64pub 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    // Detect rate limit or block
92    if body.contains("<html>") || body.contains("503") || body == "[]" {
93        return Err(TranslateError::RateLimited);
94    }
95
96    parse_translation(&body)
97}
98
99/// Convenience function: translate multiple strings with **default 4 threads**.
100///
101/// # Example
102///
103/// ```
104/// let phrases = ["Good morning", "Rust is great"];
105/// let results = rtranslate::translate_vec(&phrases, "auto", "vi");
106/// ```
107pub fn translate_vec(texts: &[&str], from: &str, to: &str) -> Vec<Result<String, TranslateError>> {
108    translate_vec_with_threads(texts, from, to, 4)
109}
110
111/// Translate multiple strings in parallel with a configurable number of threads.
112///
113/// # Arguments
114///
115/// * `texts` - A slice of string slices to translate.
116/// * `from` - Source language code (e.g., `"en"`). Use `"auto"` for automatic detection.
117/// * `to` - Target language code (e.g., `"vi"` for Vietnamese).
118/// * `num_threads` - Number of threads to use for parallel translation (default 4).
119///
120/// # Returns
121///
122/// Returns a `Vec<Result<String, TranslateError>>`, with each result corresponding
123/// to the translation of the input text at the same index.
124///
125/// # Example
126///
127/// ```
128/// let phrases = ["Good morning", "Rust is great", "Faith and Gratitude"];
129/// let results = rtranslate::translate_vec_with_threads(&phrases, "auto", "vi", 6);
130/// for (i, res) in results.iter().enumerate() {
131///     match res {
132///         Ok(t) => println!("{} → {}", phrases[i], t),
133///         Err(e) => println!("{} → ERROR: {}", phrases[i], e),
134///     }
135/// }
136/// ```
137pub 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}