use std::time::SystemTime;
#[cfg(not(target_arch = "wasm32"))]
use async_fs;
use serde::{Deserialize, Serialize};
use std::fmt::Write as _;
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use zenwave::{Client, client, header};
use crate::config::{AuthMode, GeminiConfig, USER_AGENT};
use crate::error::GeminiError;
const UPLOAD_BASE_URL: &str = "https://generativelanguage.googleapis.com/upload/v1beta/files";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum FileState {
Processing,
Active,
Failed,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GeminiFile {
pub name: String,
#[serde(default)]
pub display_name: String,
pub mime_type: String,
#[serde(default)]
pub size_bytes: String,
#[serde(default)]
pub create_time: String,
#[serde(default)]
pub update_time: String,
#[serde(default)]
pub expiration_time: String,
#[serde(default)]
pub uri: String,
pub state: FileState,
#[serde(default)]
pub sha256_hash: String,
}
impl GeminiFile {
#[must_use]
pub fn expiration(&self) -> Option<SystemTime> {
parse_rfc3339(&self.expiration_time)
}
#[must_use]
pub fn is_ready(&self) -> bool {
self.state == FileState::Active
}
}
#[derive(Debug, Deserialize)]
pub struct UploadFileResponse {
pub file: GeminiFile,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListFilesResponse {
#[serde(default)]
pub files: Vec<GeminiFile>,
#[serde(default)]
pub next_page_token: Option<String>,
}
pub async fn upload_bytes(
cfg: &GeminiConfig,
file_name: &str,
mime_type: &str,
data: Vec<u8>,
) -> Result<GeminiFile, GeminiError> {
let upload_url = build_upload_url(cfg);
let metadata = serde_json::json!({
"file": {
"displayName": file_name
}
});
let mut backend = client();
let mut builder = backend
.post(&upload_url)
.map_err(GeminiError::from_http)?
.header(header::USER_AGENT.as_str(), USER_AGENT)
.map_err(GeminiError::from_http)?
.header("X-Goog-Upload-Protocol", "multipart")
.map_err(GeminiError::from_http)?;
if cfg.auth == AuthMode::Header {
builder = builder
.header("x-goog-api-key", cfg.api_key.clone())
.map_err(GeminiError::from_http)?;
}
let boundary = format!(
"----aither{:x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let mut body = Vec::new();
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(b"Content-Type: application/json; charset=UTF-8\r\n\r\n");
let metadata_json = serde_json::to_string(&metadata).map_err(GeminiError::Json)?;
body.extend_from_slice(metadata_json.as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(format!("Content-Type: {mime_type}\r\n\r\n").as_bytes());
body.extend_from_slice(&data);
body.extend_from_slice(b"\r\n");
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
let content_type = format!("multipart/related; boundary={boundary}");
let builder = builder
.header(header::CONTENT_TYPE.as_str(), &content_type)
.map_err(GeminiError::from_http)?
.bytes_body(body);
let response: UploadFileResponse = builder.json().await.map_err(GeminiError::from_http)?;
Ok(response.file)
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload_file(cfg: &GeminiConfig, path: &Path) -> Result<GeminiFile, GeminiError> {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
let mime_type = crate::mime::mime_from_path(path).unwrap_or("application/octet-stream");
let data = async_fs::read(path)
.await
.map_err(|e| GeminiError::Parse(format!("Failed to read file: {e}")))?;
upload_bytes(cfg, file_name, mime_type, data).await
}
pub async fn delete_file(cfg: &GeminiConfig, name: &str) -> Result<(), GeminiError> {
let url = cfg.endpoint(name);
let mut backend = client();
let mut builder = backend
.delete(&url)
.map_err(GeminiError::from_http)?
.header(header::USER_AGENT.as_str(), USER_AGENT)
.map_err(GeminiError::from_http)?;
if cfg.auth == AuthMode::Header {
builder = builder
.header("x-goog-api-key", cfg.api_key.clone())
.map_err(GeminiError::from_http)?;
}
let _response = builder.await.map_err(GeminiError::from_http)?;
Ok(())
}
pub async fn get_file(cfg: &GeminiConfig, name: &str) -> Result<GeminiFile, GeminiError> {
let url = cfg.endpoint(name);
let mut backend = client();
let mut builder = backend
.get(&url)
.map_err(GeminiError::from_http)?
.header(header::USER_AGENT.as_str(), USER_AGENT)
.map_err(GeminiError::from_http)?;
if cfg.auth == AuthMode::Header {
builder = builder
.header("x-goog-api-key", cfg.api_key.clone())
.map_err(GeminiError::from_http)?;
}
builder.json().await.map_err(GeminiError::from_http)
}
pub async fn list_files(
cfg: &GeminiConfig,
page_size: Option<u32>,
page_token: Option<&str>,
) -> Result<ListFilesResponse, GeminiError> {
let mut url = cfg.endpoint("files");
if let Some(size) = page_size {
write!(url, "&pageSize={size}").expect("write to String cannot fail");
}
if let Some(token) = page_token {
write!(url, "&pageToken={token}").expect("write to String cannot fail");
}
let mut backend = client();
let mut builder = backend
.get(&url)
.map_err(GeminiError::from_http)?
.header(header::USER_AGENT.as_str(), USER_AGENT)
.map_err(GeminiError::from_http)?;
if cfg.auth == AuthMode::Header {
builder = builder
.header("x-goog-api-key", cfg.api_key.clone())
.map_err(GeminiError::from_http)?;
}
builder.json().await.map_err(GeminiError::from_http)
}
fn build_upload_url(cfg: &GeminiConfig) -> String {
let mut url = UPLOAD_BASE_URL.to_string();
if cfg.auth == AuthMode::Query {
url.push_str("?key=");
url.push_str(&cfg.api_key);
}
url
}
fn parse_rfc3339(s: &str) -> Option<SystemTime> {
let timestamp: jiff::Timestamp = s.trim().parse().ok()?;
Some(SystemTime::from(timestamp))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_rfc3339_expiration() {
let parsed = parse_rfc3339("2024-01-15T10:30:00Z").expect("should parse");
let secs = parsed
.duration_since(std::time::UNIX_EPOCH)
.expect("after the epoch")
.as_secs();
assert_eq!(secs, 1_705_314_600);
assert!(parse_rfc3339("2024-01-15T10:30:00.123456Z").is_some());
assert!(parse_rfc3339(" 2024-01-15T11:30:00+01:00 ").is_some());
assert!(parse_rfc3339("").is_none());
assert!(parse_rfc3339("not a timestamp").is_none());
}
#[test]
fn test_mime_from_path() {
assert_eq!(
crate::mime::mime_from_path(Path::new("/path/to/image.png")),
Some("image/png")
);
assert_eq!(
crate::mime::mime_from_path(Path::new("/path/to/video.mp4")),
Some("video/mp4")
);
assert_eq!(
crate::mime::mime_from_path(Path::new("/path/to/audio.mp3")),
Some("audio/mpeg")
);
assert_eq!(
crate::mime::mime_from_path(Path::new("/path/to/doc.pdf")),
Some("application/pdf")
);
assert_eq!(
crate::mime::mime_from_path(Path::new("/path/to/unknown.aither_unknown")),
None
);
}
}