mod backend;
mod dictionary;
mod lang;
mod memo;
#[cfg(feature = "translation-offline")]
mod opusmt;
#[cfg(any(feature = "translation-offline", test))]
mod split;
mod translatable;
pub use backend::{TranslationBackend, set_backend};
pub use lang::Lang;
#[cfg(feature = "translation-offline")]
pub use opusmt::preload;
pub use translatable::Translatable;
use crate::error::Result;
use dictionary::DictLang;
pub async fn translate<T: Translatable + ?Sized>(value: &mut T, lang: &str) -> Result<()> {
let lang = Lang::parse(lang)?;
translate_with(value, &lang).await
}
pub async fn translate_with<T: Translatable + ?Sized>(value: &mut T, lang: &Lang) -> Result<()> {
if lang.is_english() {
return Ok(());
}
let mut texts: Vec<String> = Vec::new();
value.visit_translatable(&mut |s| texts.push(s.clone()));
if texts.is_empty() {
return Ok(());
}
let translated = translate_texts(&texts, lang).await?;
let mut iter = translated.into_iter();
value.visit_translatable(&mut |s| {
if let Some(t) = iter.next() {
*s = t;
}
});
value.after_translate();
Ok(())
}
pub async fn translate_texts(texts: &[String], lang: &Lang) -> Result<Vec<String>> {
if lang.is_english() {
return Ok(texts.to_vec());
}
let lang_code = lang.code();
let dict_lang = DictLang::from_lang(lang);
let mut out: Vec<Option<String>> = vec![None; texts.len()];
let mut pending: Vec<String> = Vec::new();
let mut pending_slots: std::collections::HashMap<String, Vec<usize>> =
std::collections::HashMap::new();
for (i, text) in texts.iter().enumerate() {
let trimmed = text.trim();
if trimmed.is_empty() {
out[i] = Some(text.clone());
continue;
}
if let Some(dl) = dict_lang
&& let Some(hit) = dictionary::lookup(dl, trimmed)
{
out[i] = Some(hit.to_string());
continue;
}
if let Some(hit) = memo::get(&lang_code, trimmed) {
out[i] = Some(hit);
continue;
}
let slots = pending_slots.entry(trimmed.to_string()).or_default();
if slots.is_empty() {
pending.push(trimmed.to_string());
}
slots.push(i);
}
if !pending.is_empty() {
match backend::active_backend() {
Some(backend) if backend.supports(lang) => {
let translated = backend.translate_batch(&pending, lang).await?;
for (source, translated) in pending.iter().zip(translated) {
memo::insert(&lang_code, source, &translated);
if let Some(slots) = pending_slots.get(source) {
for &i in slots {
out[i] = Some(translated.clone());
}
}
}
}
Some(_) => {
tracing::debug!(
lang = %lang_code,
count = pending.len(),
"translation backend does not support this language; leaving free-form text untranslated"
);
}
None => {
tracing::debug!(
lang = %lang_code,
count = pending.len(),
"no translation backend available; leaving free-form text untranslated"
);
}
}
}
Ok(out
.into_iter()
.zip(texts)
.map(|(translated, original)| translated.unwrap_or_else(|| original.clone()))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
struct Profile {
sector: Option<String>,
summary: Option<String>,
}
impl Translatable for Profile {
fn visit_translatable(&mut self, visit: &mut dyn FnMut(&mut String)) {
if let Some(s) = &mut self.sector {
visit(s);
}
if let Some(s) = &mut self.summary {
visit(s);
}
}
}
#[tokio::test]
async fn english_is_a_no_op() {
let mut p = Profile {
sector: Some("Technology".into()),
summary: Some("A company.".into()),
};
translate(&mut p, "en-US").await.unwrap();
assert_eq!(p.sector.as_deref(), Some("Technology"));
}
#[tokio::test]
async fn dictionary_terms_translate_without_backend() {
let mut p = Profile {
sector: Some("Technology".into()),
summary: None,
};
translate(&mut p, "ja").await.unwrap();
assert_eq!(p.sector.as_deref(), Some("テクノロジー"));
}
#[cfg(not(feature = "translation-offline"))]
#[tokio::test]
async fn free_form_passes_through_without_backend() {
let mut p = Profile {
sector: Some("Healthcare".into()),
summary: Some("Designs and sells devices.".into()),
};
translate(&mut p, "de").await.unwrap();
assert_eq!(p.sector.as_deref(), Some("Gesundheitswesen"));
assert_eq!(p.summary.as_deref(), Some("Designs and sells devices."));
}
#[tokio::test]
async fn invalid_lang_errors() {
let mut p = Profile {
sector: None,
summary: None,
};
assert!(translate(&mut p, "not a lang!").await.is_err());
}
#[cfg(feature = "translation-offline")]
#[tokio::test]
async fn unsupported_offline_language_degrades_gracefully() {
let mut p = Profile {
sector: Some("Technology".into()),
summary: Some("Designs and sells devices.".into()),
};
translate(&mut p, "sw").await.unwrap();
assert_eq!(p.sector.as_deref(), Some("Technology"));
assert_eq!(p.summary.as_deref(), Some("Designs and sells devices."));
}
#[tokio::test]
async fn translate_texts_dedups_and_preserves_order() {
let lang = Lang::parse("fr").unwrap();
let texts = vec![
"Technology".to_string(),
"Energy".to_string(),
"Technology".to_string(),
];
let out = translate_texts(&texts, &lang).await.unwrap();
assert_eq!(out, vec!["Technologie", "Énergie", "Technologie"]);
}
}