lingva 0.1.0

Standalone Lingva localization CLI
use std::{
    fs,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result, bail};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Credentials {
    pub access_token: Option<String>,
    pub api_key: Option<String>,
    pub api_key_preview: Option<String>,
    pub api_url: Option<String>,
    pub client_id: Option<String>,
    pub expires_at: Option<String>,
    pub id_token: Option<String>,
    pub obtained_at: String,
    pub platform_url: Option<String>,
    pub refresh_token: Option<String>,
    pub scope: Option<String>,
    pub project_id: Option<String>,
    pub token_type: Option<String>,
    pub token_url: Option<String>,
}

pub fn default_path() -> Result<PathBuf> {
    dirs::home_dir()
        .map(|home| home.join(".lingva/credentials.json"))
        .context("Cannot resolve the home directory for Lingva credentials.")
}

pub fn load(path: &Path) -> Result<Option<Credentials>> {
    if !path.exists() {
        return Ok(None);
    }
    let source = fs::read_to_string(path)?;
    Ok(Some(serde_json::from_str(&source).with_context(|| {
        format!("Lingva credentials file \"{}\" is invalid.", path.display())
    })?))
}

pub fn save(path: &Path, credentials: &Credentials) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(
        path,
        format!("{}\n", serde_json::to_string_pretty(credentials)?),
    )?;
    #[cfg(unix)]
    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
    Ok(())
}

pub fn clear(path: &Path) -> Result<()> {
    if path.exists() {
        fs::remove_file(path)?;
    }
    Ok(())
}

pub fn token(credentials: Option<&Credentials>) -> Option<String> {
    let credentials = credentials?;
    let access_valid = credentials.access_token.as_ref().is_some_and(|_| {
        credentials
            .expires_at
            .as_ref()
            .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
            .is_none_or(|expires| expires.with_timezone(&Utc) > Utc::now() + Duration::minutes(1))
    });
    if access_valid {
        return credentials.access_token.clone();
    }
    credentials
        .api_key
        .clone()
        .filter(|value| !value.is_empty())
}

pub fn resolve_token(path: &Path) -> Result<Option<String>> {
    let Some(mut credentials) = load(path)? else {
        return Ok(None);
    };
    if let Some(value) = token(Some(&credentials)) {
        return Ok(Some(value));
    }
    let Some(refresh_token) = credentials.refresh_token.as_deref() else {
        return Ok(None);
    };
    let token_url = credentials
        .token_url
        .as_deref()
        .context("Lingva auth refresh requires a token URL.")?;
    let client_id = credentials
        .client_id
        .as_deref()
        .context("Lingva auth refresh requires a client ID.")?;
    let response = reqwest::blocking::Client::new()
        .post(token_url)
        .form(&[
            ("client_id", client_id),
            ("grant_type", "refresh_token"),
            ("refresh_token", refresh_token),
        ])
        .send()?;
    let status = response.status();
    let body = response.json::<Value>()?;
    let access_token = body
        .get("access_token")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty());
    if !status.is_success() || access_token.is_none() {
        bail!(
            "{}",
            body.get("error_description")
                .and_then(Value::as_str)
                .unwrap_or("Lingva auth refresh failed.")
        );
    }
    let now = Utc::now();
    credentials.access_token = access_token.map(str::to_owned);
    credentials.obtained_at = now.to_rfc3339();
    credentials.expires_at = body
        .get("expires_in")
        .and_then(Value::as_i64)
        .map(|seconds| (now + Duration::seconds(seconds)).to_rfc3339());
    credentials.id_token = body
        .get("id_token")
        .and_then(Value::as_str)
        .map(str::to_owned)
        .or(credentials.id_token);
    credentials.refresh_token = body
        .get("refresh_token")
        .and_then(Value::as_str)
        .map(str::to_owned)
        .or(credentials.refresh_token);
    credentials.scope = body
        .get("scope")
        .and_then(Value::as_str)
        .map(str::to_owned)
        .or(credentials.scope);
    credentials.token_type = body
        .get("token_type")
        .and_then(Value::as_str)
        .map(str::to_owned)
        .or(credentials.token_type);
    save(path, &credentials)?;
    Ok(credentials.access_token)
}

pub fn store_api_key(
    path: &Path,
    api_key: &str,
    api_url: Option<String>,
    project_id: Option<String>,
) -> Result<Credentials> {
    let api_key = api_key.trim();
    if api_key.is_empty() {
        bail!("Lingva auth use-key requires a non-empty --api-key value.");
    }
    let preview = if api_key.len() > 12 {
        format!("{}...{}", &api_key[..8], &api_key[api_key.len() - 4..])
    } else {
        "stored-key".into()
    };
    let credentials = Credentials {
        api_key: Some(api_key.into()),
        api_key_preview: Some(preview),
        api_url: api_url.filter(|value| !value.trim().is_empty()),
        project_id: project_id.filter(|value| !value.trim().is_empty()),
        obtained_at: Utc::now().to_rfc3339(),
        ..Default::default()
    };
    save(path, &credentials)?;
    Ok(credentials)
}