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)
}