concord 2.6.0

A terminal user interface client for Discord
use serde::{Deserialize, Serialize};

use super::{
    TranslationError, TranslationFuture, TranslationProvider, TranslationRequest,
    TranslationResult, parse_endpoint,
};

const PROVIDER: &str = "LibreTranslate";

pub(super) struct LibreTranslateProvider {
    http: reqwest::Client,
    endpoint: reqwest::Url,
    api_key: Option<String>,
}

impl LibreTranslateProvider {
    pub(super) fn new(
        http: reqwest::Client,
        endpoint: &str,
        api_key: Option<String>,
    ) -> Result<Self, TranslationError> {
        Ok(Self {
            http,
            endpoint: parse_endpoint(PROVIDER, endpoint)?,
            api_key,
        })
    }

    fn request(&self, request: TranslationRequest<'_>) -> reqwest::RequestBuilder {
        self.http
            .post(self.endpoint.clone())
            .json(&LibreTranslateRequest {
                q: request.text,
                source: "auto",
                target: request.target_language,
                format: "text",
                api_key: self.api_key.as_deref(),
            })
    }
}

impl TranslationProvider for LibreTranslateProvider {
    fn translate<'a>(&'a self, request: TranslationRequest<'a>) -> TranslationFuture<'a> {
        Box::pin(async move {
            let response =
                self.request(request)
                    .send()
                    .await
                    .map_err(|source| TranslationError::Request {
                        provider: PROVIDER,
                        source,
                    })?;
            if !response.status().is_success() {
                return Err(TranslationError::Http {
                    provider: PROVIDER,
                    status: response.status(),
                });
            }
            let response = response
                .json::<LibreTranslateResponse>()
                .await
                .map_err(|source| TranslationError::InvalidResponse {
                    provider: PROVIDER,
                    source,
                })?;
            if response.translated_text.trim().is_empty() {
                return Err(TranslationError::EmptyResponse { provider: PROVIDER });
            }
            Ok(TranslationResult {
                text: response.translated_text,
            })
        })
    }
}

#[derive(Serialize)]
struct LibreTranslateRequest<'a> {
    q: &'a str,
    source: &'static str,
    target: &'a str,
    format: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    api_key: Option<&'a str>,
}

#[derive(Deserialize)]
struct LibreTranslateResponse {
    #[serde(rename = "translatedText")]
    translated_text: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn request_and_response_follow_libretranslate_contract() {
        let _ = rustls::crypto::ring::default_provider().install_default();
        let provider = LibreTranslateProvider::new(
            reqwest::Client::new(),
            "http://127.0.0.1:5000/translate",
            Some("secret".to_owned()),
        )
        .expect("test endpoint is valid");

        let request = provider
            .request(TranslationRequest {
                text: "hello",
                target_language: "ko",
            })
            .build()
            .expect("request should build");

        let body: serde_json::Value = serde_json::from_slice(
            request
                .body()
                .and_then(reqwest::Body::as_bytes)
                .expect("JSON body"),
        )
        .expect("body should be JSON");
        assert_eq!(
            body,
            serde_json::json!({
                "q": "hello",
                "source": "auto",
                "target": "ko",
                "format": "text",
                "api_key": "secret"
            })
        );

        let response: LibreTranslateResponse = serde_json::from_value(serde_json::json!({
            "translatedText": "안녕하세요",
            "detectedLanguage": { "confidence": 100.0, "language": "en" }
        }))
        .expect("response should parse");

        assert_eq!(response.translated_text, "안녕하세요");
    }
}