genai_rs/http/
files.rs

1//! Files API module for uploading and managing files with Google's Generative AI.
2//!
3//! The Files API allows uploading large files once and referencing them across multiple
4//! interactions, reducing bandwidth and improving performance.
5//!
6//! # Overview
7//!
8//! Files are uploaded to Google's servers and can be referenced by their URI in
9//! subsequent API calls. Files are automatically deleted after 48 hours.
10//!
11//! # Limits
12//!
13//! - Maximum file size: 2 GB
14//! - Storage capacity: 20 GB per project
15//! - File retention: 48 hours
16//!
17//! # Implementation Notes
18//!
19//! The current implementation uses Google's resumable upload protocol but completes
20//! the upload in a single request. True resumable uploads (where you can retry from
21//! an offset after network failure) are not implemented. For most use cases under
22//! the 2 GB limit, this single-request approach works reliably. If you need to
23//! upload very large files in unreliable network conditions, consider implementing
24//! chunked upload logic with the resumable upload URI.
25//!
26//! # Example
27//!
28//! ```ignore
29//! use genai_rs::Client;
30//!
31//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
32//! let client = Client::new("api-key".to_string());
33//!
34//! // Upload a file
35//! let file = client.upload_file("video.mp4").await?;
36//! println!("Uploaded: {} ({})", file.display_name.as_deref().unwrap_or(""), file.uri);
37//!
38//! // Use in interaction
39//! let response = client.interaction()
40//!     .with_model("gemini-3-flash-preview")
41//!     .add_file(&file)
42//!     .with_text("Describe this video")
43//!     .create()
44//!     .await?;
45//!
46//! // Clean up when done
47//! client.delete_file(&file.name).await?;
48//! # Ok(())
49//! # }
50//! ```
51
52use super::common::API_KEY_HEADER;
53use super::error_helpers::{check_response, deserialize_with_context};
54use super::loud_wire;
55use crate::errors::GenaiError;
56use chrono::{DateTime, Utc};
57use reqwest::Client as ReqwestClient;
58use serde::{Deserialize, Serialize};
59use std::path::Path;
60use tokio::io::AsyncRead;
61use tokio_util::io::ReaderStream;
62
63/// Represents an uploaded file in the Files API.
64///
65/// Files are stored on Google's servers for 48 hours and can be referenced
66/// in interactions by their URI.
67#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct FileMetadata {
70    /// The resource name of the file (e.g., "files/abc123")
71    pub name: String,
72
73    /// User-provided display name for the file
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub display_name: Option<String>,
76
77    /// MIME type of the file
78    pub mime_type: String,
79
80    /// Size of the file in bytes
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub size_bytes: Option<String>,
83
84    /// Timestamp when the file was created (ISO 8601 UTC)
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub create_time: Option<DateTime<Utc>>,
87
88    /// Timestamp when the file will be automatically deleted (ISO 8601 UTC)
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub expiration_time: Option<DateTime<Utc>>,
91
92    /// SHA256 hash of the file contents
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub sha256_hash: Option<String>,
95
96    /// URI to reference this file in API calls
97    #[serde(default)]
98    pub uri: String,
99
100    /// Processing state of the file
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub state: Option<FileState>,
103
104    /// Error information if processing failed
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub error: Option<FileError>,
107
108    /// Video metadata (if this is a video file)
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub video_metadata: Option<VideoMetadata>,
111}
112
113impl FileMetadata {
114    /// Returns true if the file is still being processed.
115    #[must_use]
116    pub fn is_processing(&self) -> bool {
117        matches!(self.state, Some(FileState::Processing))
118    }
119
120    /// Returns true if the file is ready to use.
121    #[must_use]
122    pub fn is_active(&self) -> bool {
123        matches!(self.state, Some(FileState::Active))
124    }
125
126    /// Returns true if file processing failed.
127    #[must_use]
128    pub fn is_failed(&self) -> bool {
129        matches!(self.state, Some(FileState::Failed))
130    }
131
132    /// Parses the size_bytes field as a u64, if present and valid.
133    ///
134    /// The API returns file sizes as strings in the JSON response.
135    /// This helper parses that string into a numeric type for convenience.
136    ///
137    /// # Returns
138    ///
139    /// - `Some(size)` if size_bytes is present and can be parsed as u64
140    /// - `None` if size_bytes is absent or cannot be parsed
141    ///
142    /// # Example
143    ///
144    /// ```
145    /// # use genai_rs::FileMetadata;
146    /// # let file: FileMetadata = serde_json::from_str(r#"{"name":"files/abc","mimeType":"video/mp4","uri":"","sizeBytes":"1234567"}"#).unwrap();
147    /// if let Some(size) = file.size_bytes_as_u64() {
148    ///     println!("File size: {} bytes", size);
149    /// }
150    /// ```
151    #[must_use]
152    pub fn size_bytes_as_u64(&self) -> Option<u64> {
153        self.size_bytes.as_ref().and_then(|s| s.parse().ok())
154    }
155}
156
157/// Processing state of an uploaded file.
158///
159/// This enum is marked `#[non_exhaustive]` for forward compatibility.
160/// New state values may be added by the API in future versions.
161///
162/// # Unknown State Handling
163///
164/// When the API returns a state value that this library doesn't recognize,
165/// it will be captured in the `Unknown` variant with the original state
166/// string preserved. This follows the Evergreen philosophy of graceful
167/// degradation and data preservation.
168#[derive(Clone, Debug, PartialEq)]
169#[non_exhaustive]
170pub enum FileState {
171    /// File is being processed
172    Processing,
173    /// File is ready to use
174    Active,
175    /// File processing failed
176    Failed,
177    /// Unknown state (for forward compatibility).
178    ///
179    /// This variant captures any unrecognized state values from the API,
180    /// allowing the library to handle new states gracefully.
181    ///
182    /// The `state_type` field contains the unrecognized state string,
183    /// and `data` contains the JSON value (typically the same string).
184    Unknown {
185        /// The unrecognized state string from the API
186        state_type: String,
187        /// The raw JSON value, preserved for debugging
188        data: serde_json::Value,
189    },
190}
191
192impl FileState {
193    /// Check if this is an unknown state.
194    #[must_use]
195    pub const fn is_unknown(&self) -> bool {
196        matches!(self, Self::Unknown { .. })
197    }
198
199    /// Returns the state type name if this is an unknown state.
200    ///
201    /// Returns `None` for known states.
202    #[must_use]
203    pub fn unknown_state_type(&self) -> Option<&str> {
204        match self {
205            Self::Unknown { state_type, .. } => Some(state_type),
206            _ => None,
207        }
208    }
209
210    /// Returns the raw JSON data if this is an unknown state.
211    ///
212    /// Returns `None` for known states.
213    #[must_use]
214    pub fn unknown_data(&self) -> Option<&serde_json::Value> {
215        match self {
216            Self::Unknown { data, .. } => Some(data),
217            _ => None,
218        }
219    }
220}
221
222impl Serialize for FileState {
223    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
224    where
225        S: serde::Serializer,
226    {
227        match self {
228            Self::Processing => serializer.serialize_str("PROCESSING"),
229            Self::Active => serializer.serialize_str("ACTIVE"),
230            Self::Failed => serializer.serialize_str("FAILED"),
231            Self::Unknown { state_type, .. } => serializer.serialize_str(state_type),
232        }
233    }
234}
235
236impl<'de> Deserialize<'de> for FileState {
237    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
238    where
239        D: serde::Deserializer<'de>,
240    {
241        let value = serde_json::Value::deserialize(deserializer)?;
242
243        match value.as_str() {
244            Some("PROCESSING") => Ok(Self::Processing),
245            Some("ACTIVE") => Ok(Self::Active),
246            Some("FAILED") => Ok(Self::Failed),
247            Some(other) => {
248                tracing::warn!(
249                    "Encountered unknown FileState '{}'. \
250                     This may indicate a new API feature. \
251                     The state will be preserved in the Unknown variant.",
252                    other
253                );
254                Ok(Self::Unknown {
255                    state_type: other.to_string(),
256                    data: value,
257                })
258            }
259            None => {
260                // Non-string value - preserve it in Unknown
261                let state_type = format!("<non-string: {}>", value);
262                tracing::warn!(
263                    "FileState received non-string value: {}. \
264                     Preserving in Unknown variant.",
265                    value
266                );
267                Ok(Self::Unknown {
268                    state_type,
269                    data: value,
270                })
271            }
272        }
273    }
274}
275
276/// Error information for failed file operations.
277#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
278pub struct FileError {
279    /// Error code
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub code: Option<i32>,
282    /// Error message
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub message: Option<String>,
285}
286
287impl std::fmt::Display for FileError {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        match (&self.code, &self.message) {
290            (Some(code), Some(msg)) => write!(f, "error {}: {}", code, msg),
291            (Some(code), None) => write!(f, "error {}", code),
292            (None, Some(msg)) => write!(f, "{}", msg),
293            (None, None) => write!(f, "unknown error"),
294        }
295    }
296}
297
298/// Metadata for video files.
299#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct VideoMetadata {
302    /// Duration of the video
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub video_duration: Option<String>,
305}
306
307/// Response from listing files.
308#[derive(Clone, Debug, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct ListFilesResponse {
311    /// List of files
312    #[serde(default)]
313    pub files: Vec<FileMetadata>,
314
315    /// Token for retrieving the next page of results
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub next_page_token: Option<String>,
318}
319
320/// Wrapper for file upload response.
321#[derive(Clone, Debug, Serialize, Deserialize)]
322pub struct FileUploadResponse {
323    /// The uploaded file metadata
324    pub file: FileMetadata,
325}
326
327// --- API Functions ---
328
329const BASE_URL: &str = "https://generativelanguage.googleapis.com";
330const UPLOAD_URL: &str = "https://generativelanguage.googleapis.com/upload/v1beta/files";
331const API_VERSION: &str = "v1beta";
332/// Maximum file size for uploads (2 GB)
333const MAX_FILE_SIZE: u64 = 2_147_483_648;
334
335/// Uploads a file to the Files API using the resumable upload protocol.
336///
337/// # Arguments
338///
339/// * `http_client` - The HTTP client to use
340/// * `api_key` - API key for authentication
341/// * `file_data` - Raw bytes of the file
342/// * `mime_type` - MIME type of the file
343/// * `display_name` - Optional display name for the file
344///
345/// # Errors
346///
347/// Returns an error if the upload fails or the response cannot be parsed.
348pub async fn upload_file(
349    http_client: &ReqwestClient,
350    api_key: &str,
351    file_data: Vec<u8>,
352    mime_type: &str,
353    display_name: Option<&str>,
354) -> Result<FileMetadata, GenaiError> {
355    // Validate file is not empty
356    if file_data.is_empty() {
357        return Err(GenaiError::InvalidInput(
358            "Cannot upload empty file".to_string(),
359        ));
360    }
361
362    // Validate file size doesn't exceed API limit (2 GB)
363    let file_size = file_data.len() as u64;
364    if file_size > MAX_FILE_SIZE {
365        return Err(GenaiError::InvalidInput(format!(
366            "File size {} bytes exceeds maximum allowed size of {} bytes (2 GB)",
367            file_size, MAX_FILE_SIZE
368        )));
369    }
370
371    tracing::debug!(
372        "Uploading file: size={} bytes, mime_type={}, display_name={:?}",
373        file_size,
374        mime_type,
375        display_name
376    );
377
378    // LOUD_WIRE: Log upload start
379    // Note: This function receives raw bytes, not a file path, so we can only use
380    // the display_name if provided. For file path context, use the chunked upload
381    // variants which preserve and log the original file path.
382    let request_id = loud_wire::next_request_id();
383    loud_wire::log_upload_start(
384        request_id,
385        display_name.unwrap_or("(unnamed)"),
386        mime_type,
387        file_size,
388    );
389
390    // Step 1: Start the resumable upload
391    let metadata = if let Some(name) = display_name {
392        serde_json::json!({ "file": { "displayName": name } })
393    } else {
394        serde_json::json!({ "file": {} })
395    };
396
397    let start_response = http_client
398        .post(UPLOAD_URL)
399        .header(API_KEY_HEADER, api_key)
400        .header("X-Goog-Upload-Protocol", "resumable")
401        .header("X-Goog-Upload-Command", "start")
402        .header("X-Goog-Upload-Header-Content-Length", file_size.to_string())
403        .header("X-Goog-Upload-Header-Content-Type", mime_type)
404        .header("Content-Type", "application/json")
405        .json(&metadata)
406        .send()
407        .await?;
408
409    let start_response = check_response(start_response).await?;
410
411    // Extract the upload URL from the response headers
412    let upload_url = start_response
413        .headers()
414        .get("x-goog-upload-url")
415        .and_then(|v| v.to_str().ok())
416        .ok_or_else(|| {
417            GenaiError::InvalidInput("Missing upload URL in response headers".to_string())
418        })?
419        .to_string();
420
421    tracing::debug!("Got upload URL, uploading file data...");
422
423    // Step 2: Upload the file bytes
424    let upload_response = http_client
425        .post(&upload_url)
426        .header("X-Goog-Upload-Offset", "0")
427        .header("X-Goog-Upload-Command", "upload, finalize")
428        .header("Content-Length", file_size.to_string())
429        .body(file_data)
430        .send()
431        .await?;
432
433    let upload_response = check_response(upload_response).await?;
434    let response_text = upload_response.text().await.map_err(GenaiError::Http)?;
435    let file_response: FileUploadResponse =
436        deserialize_with_context(&response_text, "FileUploadResponse")?;
437
438    tracing::debug!(
439        "File uploaded successfully: name={}, uri={}",
440        file_response.file.name,
441        file_response.file.uri
442    );
443
444    // LOUD_WIRE: Log upload complete
445    loud_wire::log_upload_complete(request_id, &file_response.file.uri);
446
447    Ok(file_response.file)
448}
449
450/// Handle to a resumable upload session.
451///
452/// This struct represents an active upload session with Google's resumable upload protocol.
453/// It can be used to resume an interrupted upload from the last successfully uploaded offset.
454///
455/// # Session Expiration
456///
457/// Upload sessions expire after approximately **1 week** of inactivity. If you attempt to
458/// resume an expired session, `query_offset()` or `resume()` will return an error.
459/// For long-running uploads, start a new session rather than relying on old handles.
460///
461/// # Thread Safety
462///
463/// While this struct is `Clone`, **concurrent calls to `resume()` on cloned handles are
464/// not supported** and may result in upload failures. Use a single handle per upload
465/// session, or coordinate access externally.
466///
467/// # Example
468///
469/// ```ignore
470/// use genai_rs::{ResumableUpload, upload_file_chunked};
471/// use std::time::Duration;
472///
473/// // Start a streaming upload
474/// let (file, upload) = upload_file_chunked(
475///     &http_client,
476///     "api-key",
477///     "large_video.mp4",
478///     "video/mp4",
479///     Some("my-video"),
480/// ).await?;
481///
482/// // If the upload was interrupted, you could resume it using upload.resume()
483/// ```
484#[derive(Clone, Debug)]
485pub struct ResumableUpload {
486    /// The resumable upload URL returned by the API
487    upload_url: String,
488    /// Total file size in bytes
489    file_size: u64,
490    /// MIME type of the file
491    mime_type: String,
492}
493
494impl ResumableUpload {
495    /// Returns the upload URL for this session.
496    #[must_use]
497    pub fn upload_url(&self) -> &str {
498        &self.upload_url
499    }
500
501    /// Returns the total file size.
502    #[must_use]
503    pub fn file_size(&self) -> u64 {
504        self.file_size
505    }
506
507    /// Returns the MIME type.
508    #[must_use]
509    pub fn mime_type(&self) -> &str {
510        &self.mime_type
511    }
512
513    /// Queries the current upload offset from the server.
514    ///
515    /// This is useful for resuming an interrupted upload. The returned offset
516    /// indicates how many bytes have been successfully uploaded.
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if:
521    /// - The query request fails
522    /// - The upload session has expired (sessions expire after ~1 week)
523    /// - The server response is missing the expected offset header
524    pub async fn query_offset(&self, http_client: &ReqwestClient) -> Result<u64, GenaiError> {
525        let response = http_client
526            .post(&self.upload_url)
527            .header("X-Goog-Upload-Command", "query")
528            .header("Content-Length", "0")
529            .send()
530            .await?;
531
532        let response = check_response(response).await?;
533
534        // Extract the current offset from the response headers
535        let offset = response
536            .headers()
537            .get("x-goog-upload-size-received")
538            .and_then(|v| v.to_str().ok())
539            .and_then(|s| s.parse().ok())
540            .ok_or_else(|| {
541                tracing::warn!(
542                    "Missing or invalid x-goog-upload-size-received header in query response"
543                );
544                GenaiError::InvalidInput(
545                    "Upload session query failed: missing offset header. \
546                     The session may have expired (sessions expire after ~1 week)."
547                        .to_string(),
548                )
549            })?;
550
551        tracing::debug!("Query offset: {} bytes uploaded", offset);
552
553        Ok(offset)
554    }
555
556    /// Resumes an upload from the specified offset.
557    ///
558    /// This reads the file from the given offset and uploads the remaining bytes.
559    /// The `reader` must be positioned at the offset (e.g., by seeking or skipping).
560    ///
561    /// # Arguments
562    ///
563    /// * `http_client` - The HTTP client to use
564    /// * `reader` - An async reader positioned at the resume offset
565    /// * `offset` - The byte offset to resume from
566    /// * `chunk_size` - Size of chunks to stream (default: 8MB)
567    ///
568    /// # Errors
569    ///
570    /// Returns an error if the upload fails or the response cannot be parsed.
571    pub async fn resume<R: AsyncRead + Unpin + Send + Sync + 'static>(
572        &self,
573        http_client: &ReqwestClient,
574        reader: R,
575        offset: u64,
576        chunk_size: Option<usize>,
577    ) -> Result<FileMetadata, GenaiError> {
578        let remaining_size = self.file_size.saturating_sub(offset);
579
580        if remaining_size == 0 {
581            return Err(GenaiError::InvalidInput(
582                "Upload already complete (offset equals file size)".to_string(),
583            ));
584        }
585
586        tracing::debug!(
587            "Resuming upload from offset {} ({} bytes remaining)",
588            offset,
589            remaining_size
590        );
591
592        // Create a streaming body from the reader
593        let chunk_size = chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
594        let stream = ReaderStream::with_capacity(reader, chunk_size);
595        let body = reqwest::Body::wrap_stream(stream);
596
597        // Resume the upload
598        let upload_response = http_client
599            .post(&self.upload_url)
600            .header("X-Goog-Upload-Offset", offset.to_string())
601            .header("X-Goog-Upload-Command", "upload, finalize")
602            .header("Content-Length", remaining_size.to_string())
603            .body(body)
604            .send()
605            .await?;
606
607        let upload_response = check_response(upload_response).await?;
608        let response_text = upload_response.text().await.map_err(GenaiError::Http)?;
609        let file_response: FileUploadResponse =
610            deserialize_with_context(&response_text, "FileUploadResponse")?;
611
612        tracing::debug!(
613            "Upload resumed successfully: name={}, uri={}",
614            file_response.file.name,
615            file_response.file.uri
616        );
617
618        Ok(file_response.file)
619    }
620}
621
622/// Default chunk size for chunked uploads (8 MB).
623///
624/// This balances memory usage with network efficiency. Smaller chunks use less
625/// memory but may have higher overhead; larger chunks are more efficient but
626/// require more memory for buffering.
627pub const DEFAULT_CHUNK_SIZE: usize = 8 * 1024 * 1024; // 8 MB
628
629/// Uploads a file to the Files API using chunked transfer to minimize memory usage.
630///
631/// Unlike `upload_file`, this function streams the file from disk in chunks,
632/// never loading the entire file into memory. This is ideal for large files
633/// (500MB-2GB) or memory-constrained environments.
634///
635/// # Arguments
636///
637/// * `http_client` - The HTTP client to use
638/// * `api_key` - API key for authentication
639/// * `path` - Path to the file to upload
640/// * `mime_type` - MIME type of the file
641/// * `display_name` - Optional display name for the file
642///
643/// # Returns
644///
645/// Returns a tuple of:
646/// - `FileMetadata`: The uploaded file's metadata
647/// - `ResumableUpload`: A handle that can be used to resume if the upload is interrupted
648///
649/// # Errors
650///
651/// Returns an error if:
652/// - The file cannot be opened or read
653/// - The upload initiation fails
654/// - The upload itself fails
655///
656/// # Memory Usage
657///
658/// This function uses approximately `chunk_size` (default 8MB) of memory for
659/// buffering, regardless of the file size. A 2GB file uses the same memory
660/// as a 10MB file.
661///
662/// # Example
663///
664/// ```ignore
665/// use genai_rs::upload_file_chunked;
666///
667/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
668/// let http_client = reqwest::Client::new();
669///
670/// // Upload a large video file without loading it all into memory
671/// let (file, _upload_handle) = upload_file_chunked(
672///     &http_client,
673///     "api-key",
674///     "large_video.mp4",
675///     "video/mp4",
676///     Some("my-video"),
677/// ).await?;
678///
679/// println!("Uploaded: {}", file.uri);
680/// # Ok(())
681/// # }
682/// ```
683pub async fn upload_file_chunked(
684    http_client: &ReqwestClient,
685    api_key: &str,
686    path: impl AsRef<Path>,
687    mime_type: &str,
688    display_name: Option<&str>,
689) -> Result<(FileMetadata, ResumableUpload), GenaiError> {
690    upload_file_chunked_with_chunk_size(
691        http_client,
692        api_key,
693        path,
694        mime_type,
695        display_name,
696        DEFAULT_CHUNK_SIZE,
697    )
698    .await
699}
700
701/// Uploads a file using chunked transfer with a custom chunk size.
702///
703/// This is the same as `upload_file_chunked` but allows specifying the chunk
704/// size. Larger chunks are more efficient for fast networks, while smaller
705/// chunks use less memory.
706///
707/// # Arguments
708///
709/// * `http_client` - The HTTP client to use
710/// * `api_key` - API key for authentication
711/// * `path` - Path to the file to upload
712/// * `mime_type` - MIME type of the file
713/// * `display_name` - Optional display name for the file
714/// * `chunk_size` - Size of chunks to stream in bytes
715///
716/// # Errors
717///
718/// Returns an error if the file cannot be read or the upload fails.
719pub async fn upload_file_chunked_with_chunk_size(
720    http_client: &ReqwestClient,
721    api_key: &str,
722    path: impl AsRef<Path>,
723    mime_type: &str,
724    display_name: Option<&str>,
725    chunk_size: usize,
726) -> Result<(FileMetadata, ResumableUpload), GenaiError> {
727    let path = path.as_ref();
728
729    // Get file metadata to check size
730    let metadata = tokio::fs::metadata(path).await.map_err(|e| {
731        tracing::warn!(
732            "Failed to get file metadata for '{}': {}",
733            path.display(),
734            e
735        );
736        GenaiError::InvalidInput(format!("Failed to access file '{}': {}", path.display(), e))
737    })?;
738
739    let file_size = metadata.len();
740
741    // Validate file is not empty
742    if file_size == 0 {
743        return Err(GenaiError::InvalidInput(
744            "Cannot upload empty file".to_string(),
745        ));
746    }
747
748    // Validate file size doesn't exceed API limit (2 GB)
749    if file_size > MAX_FILE_SIZE {
750        return Err(GenaiError::InvalidInput(format!(
751            "File size {} bytes exceeds maximum allowed size of {} bytes (2 GB)",
752            file_size, MAX_FILE_SIZE
753        )));
754    }
755
756    tracing::debug!(
757        "Streaming upload: path={}, size={} bytes, mime_type={}, chunk_size={} bytes",
758        path.display(),
759        file_size,
760        mime_type,
761        chunk_size
762    );
763
764    // LOUD_WIRE: Log chunked upload start
765    let request_id = loud_wire::next_request_id();
766    let loud_wire_name = display_name
767        .map(|s| s.to_string())
768        .unwrap_or_else(|| path.to_string_lossy().into_owned());
769    loud_wire::log_upload_start(request_id, &loud_wire_name, mime_type, file_size);
770
771    // Step 1: Start the resumable upload session
772    let metadata_json = if let Some(name) = display_name {
773        serde_json::json!({ "file": { "displayName": name } })
774    } else {
775        serde_json::json!({ "file": {} })
776    };
777
778    let start_response = http_client
779        .post(UPLOAD_URL)
780        .header(API_KEY_HEADER, api_key)
781        .header("X-Goog-Upload-Protocol", "resumable")
782        .header("X-Goog-Upload-Command", "start")
783        .header("X-Goog-Upload-Header-Content-Length", file_size.to_string())
784        .header("X-Goog-Upload-Header-Content-Type", mime_type)
785        .header("Content-Type", "application/json")
786        .json(&metadata_json)
787        .send()
788        .await?;
789
790    let start_response = check_response(start_response).await?;
791
792    // Extract the upload URL from the response headers
793    let upload_url = start_response
794        .headers()
795        .get("x-goog-upload-url")
796        .and_then(|v| v.to_str().ok())
797        .ok_or_else(|| {
798            GenaiError::InvalidInput("Missing upload URL in response headers".to_string())
799        })?
800        .to_string();
801
802    tracing::debug!("Got upload URL, streaming file data...");
803
804    // Create the resumable upload handle
805    let resumable_upload = ResumableUpload {
806        upload_url: upload_url.clone(),
807        file_size,
808        mime_type: mime_type.to_string(),
809    };
810
811    // Step 2: Open the file and create a streaming body
812    let file = tokio::fs::File::open(path).await.map_err(|e| {
813        tracing::warn!("Failed to open file '{}': {}", path.display(), e);
814        GenaiError::InvalidInput(format!("Failed to open file '{}': {}", path.display(), e))
815    })?;
816
817    // Create a stream directly from the file - ReaderStream already buffers internally
818    let stream = ReaderStream::with_capacity(file, chunk_size);
819    let body = reqwest::Body::wrap_stream(stream);
820
821    // Step 3: Upload the file bytes using streaming
822    let upload_response = http_client
823        .post(&upload_url)
824        .header("X-Goog-Upload-Offset", "0")
825        .header("X-Goog-Upload-Command", "upload, finalize")
826        .header("Content-Length", file_size.to_string())
827        .body(body)
828        .send()
829        .await?;
830
831    let upload_response = check_response(upload_response).await?;
832    let response_text = upload_response.text().await.map_err(GenaiError::Http)?;
833    let file_response: FileUploadResponse =
834        deserialize_with_context(&response_text, "FileUploadResponse")?;
835
836    tracing::debug!(
837        "File streamed successfully: name={}, uri={}",
838        file_response.file.name,
839        file_response.file.uri
840    );
841
842    // LOUD_WIRE: Log upload complete
843    loud_wire::log_upload_complete(request_id, &file_response.file.uri);
844
845    Ok((file_response.file, resumable_upload))
846}
847
848/// Gets metadata for a specific file.
849///
850/// # Arguments
851///
852/// * `http_client` - The HTTP client to use
853/// * `api_key` - API key for authentication
854/// * `file_name` - The resource name of the file (e.g., "files/abc123")
855///
856/// # Errors
857///
858/// Returns an error if the request fails or the file doesn't exist.
859pub async fn get_file(
860    http_client: &ReqwestClient,
861    api_key: &str,
862    file_name: &str,
863) -> Result<FileMetadata, GenaiError> {
864    tracing::debug!("Getting file metadata: {}", file_name);
865
866    let url = format!("{BASE_URL}/{API_VERSION}/{file_name}");
867
868    // LOUD_WIRE: Log outgoing request
869    let request_id = loud_wire::next_request_id();
870    loud_wire::log_request(request_id, "GET", &url, None);
871
872    let response = http_client
873        .get(&url)
874        .header(API_KEY_HEADER, api_key)
875        .send()
876        .await?;
877
878    // LOUD_WIRE: Log response status
879    loud_wire::log_response_status(request_id, response.status().as_u16());
880
881    let response = check_response(response).await?;
882    let response_text = response.text().await.map_err(GenaiError::Http)?;
883
884    // LOUD_WIRE: Log response body
885    loud_wire::log_response_body(request_id, &response_text);
886
887    let file: FileMetadata = deserialize_with_context(&response_text, "FileMetadata")?;
888
889    tracing::debug!("Got file: state={:?}", file.state);
890
891    Ok(file)
892}
893
894/// Lists all uploaded files.
895///
896/// # Arguments
897///
898/// * `http_client` - The HTTP client to use
899/// * `api_key` - API key for authentication
900/// * `page_size` - Optional maximum number of files to return
901/// * `page_token` - Optional token for pagination
902///
903/// # Errors
904///
905/// Returns an error if the request fails.
906pub async fn list_files(
907    http_client: &ReqwestClient,
908    api_key: &str,
909    page_size: Option<u32>,
910    page_token: Option<&str>,
911) -> Result<ListFilesResponse, GenaiError> {
912    tracing::debug!(
913        "Listing files: page_size={:?}, page_token={:?}",
914        page_size,
915        page_token
916    );
917
918    let mut url = format!("{BASE_URL}/{API_VERSION}/files");
919
920    // Add query parameters
921    let mut has_params = false;
922    if let Some(size) = page_size {
923        url.push_str(&format!("?pageSize={size}"));
924        has_params = true;
925    }
926    if let Some(token) = page_token {
927        let separator = if has_params { "&" } else { "?" };
928        url.push_str(&format!("{separator}pageToken={token}"));
929    }
930
931    // LOUD_WIRE: Log outgoing request
932    let request_id = loud_wire::next_request_id();
933    loud_wire::log_request(request_id, "GET", &url, None);
934
935    let response = http_client
936        .get(&url)
937        .header(API_KEY_HEADER, api_key)
938        .send()
939        .await?;
940
941    // LOUD_WIRE: Log response status
942    loud_wire::log_response_status(request_id, response.status().as_u16());
943
944    let response = check_response(response).await?;
945    let response_text = response.text().await.map_err(GenaiError::Http)?;
946
947    // LOUD_WIRE: Log response body
948    loud_wire::log_response_body(request_id, &response_text);
949
950    let list_response: ListFilesResponse =
951        deserialize_with_context(&response_text, "ListFilesResponse")?;
952
953    tracing::debug!("Listed {} files", list_response.files.len());
954
955    Ok(list_response)
956}
957
958/// Deletes an uploaded file.
959///
960/// # Arguments
961///
962/// * `http_client` - The HTTP client to use
963/// * `api_key` - API key for authentication
964/// * `file_name` - The resource name of the file to delete (e.g., "files/abc123")
965///
966/// # Errors
967///
968/// Returns an error if the request fails or the file doesn't exist.
969pub async fn delete_file(
970    http_client: &ReqwestClient,
971    api_key: &str,
972    file_name: &str,
973) -> Result<(), GenaiError> {
974    tracing::debug!("Deleting file: {}", file_name);
975
976    let url = format!("{BASE_URL}/{API_VERSION}/{file_name}");
977
978    // LOUD_WIRE: Log outgoing request
979    let request_id = loud_wire::next_request_id();
980    loud_wire::log_request(request_id, "DELETE", &url, None);
981
982    let response = http_client
983        .delete(&url)
984        .header(API_KEY_HEADER, api_key)
985        .send()
986        .await?;
987
988    // LOUD_WIRE: Log response status
989    loud_wire::log_response_status(request_id, response.status().as_u16());
990
991    check_response(response).await?;
992
993    tracing::debug!("File deleted successfully");
994
995    Ok(())
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001
1002    #[test]
1003    fn test_file_metadata_deserialization() {
1004        let json = r#"{
1005            "name": "files/abc123",
1006            "displayName": "test.mp4",
1007            "mimeType": "video/mp4",
1008            "sizeBytes": "1234567",
1009            "createTime": "2024-01-01T00:00:00Z",
1010            "expirationTime": "2024-01-03T00:00:00Z",
1011            "uri": "https://generativelanguage.googleapis.com/v1beta/files/abc123",
1012            "state": "ACTIVE"
1013        }"#;
1014
1015        let file: FileMetadata = serde_json::from_str(json).unwrap();
1016        assert_eq!(file.name, "files/abc123");
1017        assert_eq!(file.display_name.as_deref(), Some("test.mp4"));
1018        assert_eq!(file.mime_type, "video/mp4");
1019        assert!(file.is_active());
1020        assert!(!file.is_processing());
1021    }
1022
1023    #[test]
1024    fn test_file_state_processing() {
1025        let json =
1026            r#"{"name": "files/test", "mimeType": "video/mp4", "state": "PROCESSING", "uri": ""}"#;
1027        let file: FileMetadata = serde_json::from_str(json).unwrap();
1028        assert!(file.is_processing());
1029        assert!(!file.is_active());
1030    }
1031
1032    #[test]
1033    fn test_file_state_failed() {
1034        let json =
1035            r#"{"name": "files/test", "mimeType": "video/mp4", "state": "FAILED", "uri": ""}"#;
1036        let file: FileMetadata = serde_json::from_str(json).unwrap();
1037        assert!(file.is_failed());
1038        assert!(!file.is_active());
1039    }
1040
1041    #[test]
1042    fn test_list_files_response_deserialization() {
1043        let json = r#"{
1044            "files": [
1045                {"name": "files/a", "mimeType": "video/mp4", "uri": ""},
1046                {"name": "files/b", "mimeType": "image/png", "uri": ""}
1047            ],
1048            "nextPageToken": "token123"
1049        }"#;
1050
1051        let response: ListFilesResponse = serde_json::from_str(json).unwrap();
1052        assert_eq!(response.files.len(), 2);
1053        assert_eq!(response.next_page_token.as_deref(), Some("token123"));
1054    }
1055
1056    #[test]
1057    fn test_empty_list_files_response() {
1058        let json = r#"{}"#;
1059        let response: ListFilesResponse = serde_json::from_str(json).unwrap();
1060        assert!(response.files.is_empty());
1061        assert!(response.next_page_token.is_none());
1062    }
1063
1064    #[test]
1065    fn test_file_state_unknown_preserves_data() {
1066        // Test that unknown states preserve the original value
1067        let json =
1068            r#"{"name": "files/test", "mimeType": "video/mp4", "state": "UPLOADING", "uri": ""}"#;
1069        let file: FileMetadata = serde_json::from_str(json).unwrap();
1070
1071        assert!(!file.is_active());
1072        assert!(!file.is_processing());
1073        assert!(!file.is_failed());
1074
1075        // Check the Unknown variant captured the state
1076        if let Some(FileState::Unknown { state_type, data }) = &file.state {
1077            assert_eq!(state_type, "UPLOADING");
1078            assert_eq!(data.as_str(), Some("UPLOADING"));
1079        } else {
1080            panic!("Expected FileState::Unknown variant, got {:?}", file.state);
1081        }
1082    }
1083
1084    #[test]
1085    fn test_file_state_unknown_helper_methods() {
1086        let unknown = FileState::Unknown {
1087            state_type: "NEW_STATE".to_string(),
1088            data: serde_json::json!("NEW_STATE"),
1089        };
1090
1091        assert!(unknown.is_unknown());
1092        assert_eq!(unknown.unknown_state_type(), Some("NEW_STATE"));
1093        assert_eq!(
1094            unknown.unknown_data(),
1095            Some(&serde_json::json!("NEW_STATE"))
1096        );
1097
1098        // Known states should return None for unknown helpers
1099        let active = FileState::Active;
1100        assert!(!active.is_unknown());
1101        assert_eq!(active.unknown_state_type(), None);
1102        assert_eq!(active.unknown_data(), None);
1103    }
1104
1105    #[test]
1106    fn test_file_state_roundtrip_serialization() {
1107        // Known state roundtrips
1108        let active = FileState::Active;
1109        let json = serde_json::to_string(&active).unwrap();
1110        assert_eq!(json, r#""ACTIVE""#);
1111        let deserialized: FileState = serde_json::from_str(&json).unwrap();
1112        assert_eq!(deserialized, FileState::Active);
1113
1114        // Unknown state roundtrips
1115        let unknown = FileState::Unknown {
1116            state_type: "QUEUED".to_string(),
1117            data: serde_json::json!("QUEUED"),
1118        };
1119        let json = serde_json::to_string(&unknown).unwrap();
1120        assert_eq!(json, r#""QUEUED""#);
1121    }
1122
1123    #[test]
1124    fn test_file_metadata_failed_state_with_error() {
1125        let json = r#"{
1126            "name": "files/failed123",
1127            "mimeType": "video/mp4",
1128            "state": "FAILED",
1129            "uri": "",
1130            "error": {
1131                "code": 400,
1132                "message": "Unsupported video codec"
1133            }
1134        }"#;
1135        let file: FileMetadata = serde_json::from_str(json).unwrap();
1136        assert!(file.is_failed());
1137        assert!(file.error.is_some());
1138
1139        let error = file.error.unwrap();
1140        assert_eq!(error.code, Some(400));
1141        assert_eq!(error.message.as_deref(), Some("Unsupported video codec"));
1142    }
1143
1144    #[test]
1145    fn test_file_error_partial_fields() {
1146        // Error with only code
1147        let json = r#"{"code": 500}"#;
1148        let error: FileError = serde_json::from_str(json).unwrap();
1149        assert_eq!(error.code, Some(500));
1150        assert_eq!(error.message, None);
1151
1152        // Error with only message
1153        let json = r#"{"message": "Something went wrong"}"#;
1154        let error: FileError = serde_json::from_str(json).unwrap();
1155        assert_eq!(error.code, None);
1156        assert_eq!(error.message.as_deref(), Some("Something went wrong"));
1157
1158        // Empty error (edge case)
1159        let json = r#"{}"#;
1160        let error: FileError = serde_json::from_str(json).unwrap();
1161        assert_eq!(error.code, None);
1162        assert_eq!(error.message, None);
1163    }
1164
1165    #[test]
1166    fn test_file_error_display() {
1167        // Both code and message
1168        let error = FileError {
1169            code: Some(400),
1170            message: Some("Invalid file format".to_string()),
1171        };
1172        assert_eq!(error.to_string(), "error 400: Invalid file format");
1173
1174        // Only code
1175        let error = FileError {
1176            code: Some(500),
1177            message: None,
1178        };
1179        assert_eq!(error.to_string(), "error 500");
1180
1181        // Only message
1182        let error = FileError {
1183            code: None,
1184            message: Some("Something went wrong".to_string()),
1185        };
1186        assert_eq!(error.to_string(), "Something went wrong");
1187
1188        // Neither code nor message
1189        let error = FileError {
1190            code: None,
1191            message: None,
1192        };
1193        assert_eq!(error.to_string(), "unknown error");
1194    }
1195
1196    #[test]
1197    fn test_size_bytes_as_u64() {
1198        // Valid size_bytes parses correctly
1199        let file = FileMetadata {
1200            name: "files/test".to_string(),
1201            display_name: None,
1202            mime_type: "video/mp4".to_string(),
1203            size_bytes: Some("1234567890".to_string()),
1204            create_time: None,
1205            expiration_time: None,
1206            sha256_hash: None,
1207            uri: "".to_string(),
1208            state: None,
1209            error: None,
1210            video_metadata: None,
1211        };
1212        assert_eq!(file.size_bytes_as_u64(), Some(1234567890));
1213
1214        // None size_bytes returns None
1215        let file = FileMetadata {
1216            name: "files/test".to_string(),
1217            display_name: None,
1218            mime_type: "video/mp4".to_string(),
1219            size_bytes: None,
1220            create_time: None,
1221            expiration_time: None,
1222            sha256_hash: None,
1223            uri: "".to_string(),
1224            state: None,
1225            error: None,
1226            video_metadata: None,
1227        };
1228        assert_eq!(file.size_bytes_as_u64(), None);
1229
1230        // Invalid size_bytes (non-numeric) returns None
1231        let file = FileMetadata {
1232            name: "files/test".to_string(),
1233            display_name: None,
1234            mime_type: "video/mp4".to_string(),
1235            size_bytes: Some("not a number".to_string()),
1236            create_time: None,
1237            expiration_time: None,
1238            sha256_hash: None,
1239            uri: "".to_string(),
1240            state: None,
1241            error: None,
1242            video_metadata: None,
1243        };
1244        assert_eq!(file.size_bytes_as_u64(), None);
1245
1246        // Large file size (2GB+) parses correctly
1247        let file = FileMetadata {
1248            name: "files/test".to_string(),
1249            display_name: None,
1250            mime_type: "video/mp4".to_string(),
1251            size_bytes: Some("2147483648".to_string()), // 2GB
1252            create_time: None,
1253            expiration_time: None,
1254            sha256_hash: None,
1255            uri: "".to_string(),
1256            state: None,
1257            error: None,
1258            video_metadata: None,
1259        };
1260        assert_eq!(file.size_bytes_as_u64(), Some(2147483648));
1261    }
1262
1263    // Note: Tests for upload_file validation (empty file, max size) are in
1264    // tests/files_api_tests.rs as integration tests since they require mocking
1265    // the HTTP client or hitting the real API.
1266}
1267
1268/// Property-based tests for serialization roundtrips using proptest.
1269#[cfg(test)]
1270mod proptest_tests {
1271    use super::*;
1272    use chrono::TimeZone;
1273    use proptest::prelude::*;
1274
1275    /// Strategy for generating DateTime<Utc> values.
1276    /// Uses second precision to ensure reliable roundtrip.
1277    fn arb_datetime() -> impl Strategy<Value = DateTime<Utc>> {
1278        // Generate timestamps between 2020-01-01 and 2030-01-01
1279        (0i64..315_360_000).prop_map(|offset_secs| {
1280            Utc.timestamp_opt(1_577_836_800 + offset_secs, 0)
1281                .single()
1282                .expect("valid timestamp")
1283        })
1284    }
1285
1286    /// Strategy for generating FileState variants.
1287    #[cfg(not(feature = "strict-unknown"))]
1288    fn arb_file_state() -> impl Strategy<Value = FileState> {
1289        prop_oneof![
1290            Just(FileState::Processing),
1291            Just(FileState::Active),
1292            Just(FileState::Failed),
1293            // Include Unknown variant for graceful handling
1294            ("[A-Z_]{4,20}".prop_map(|state_type| FileState::Unknown {
1295                state_type,
1296                data: serde_json::Value::Null,
1297            })),
1298        ]
1299    }
1300
1301    /// Strategy for FileState - no Unknown in strict mode.
1302    #[cfg(feature = "strict-unknown")]
1303    fn arb_file_state() -> impl Strategy<Value = FileState> {
1304        prop_oneof![
1305            Just(FileState::Processing),
1306            Just(FileState::Active),
1307            Just(FileState::Failed),
1308        ]
1309    }
1310
1311    /// Strategy for generating FileError.
1312    fn arb_file_error() -> impl Strategy<Value = FileError> {
1313        (
1314            prop::option::of(any::<i32>()),
1315            prop::option::of(".{0,100}".prop_map(String::from)),
1316        )
1317            .prop_map(|(code, message)| FileError { code, message })
1318    }
1319
1320    /// Strategy for generating VideoMetadata.
1321    fn arb_video_metadata() -> impl Strategy<Value = VideoMetadata> {
1322        prop::option::of("[0-9]+s".prop_map(String::from))
1323            .prop_map(|video_duration| VideoMetadata { video_duration })
1324    }
1325
1326    /// Strategy for generating FileMetadata.
1327    fn arb_file_metadata() -> impl Strategy<Value = FileMetadata> {
1328        (
1329            "files/[a-zA-Z0-9_]+",              // name
1330            prop::option::of(".{1,50}"),        // display_name
1331            "[a-z]+/[a-z0-9+-]+",               // mime_type
1332            prop::option::of("[0-9]+"),         // size_bytes
1333            prop::option::of(arb_datetime()),   // create_time
1334            prop::option::of(arb_datetime()),   // expiration_time
1335            prop::option::of("[a-f0-9]{64}"),   // sha256_hash (API returns raw hash, no prefix)
1336            "https?://[a-z]+\\.[a-z]+/[a-z]+",  // uri
1337            prop::option::of(arb_file_state()), // state is Option<FileState>
1338            prop::option::of(arb_file_error()),
1339            prop::option::of(arb_video_metadata()),
1340        )
1341            .prop_map(
1342                |(
1343                    name,
1344                    display_name,
1345                    mime_type,
1346                    size_bytes,
1347                    create_time,
1348                    expiration_time,
1349                    sha256_hash,
1350                    uri,
1351                    state,
1352                    error,
1353                    video_metadata,
1354                )| {
1355                    FileMetadata {
1356                        name,
1357                        display_name,
1358                        mime_type,
1359                        size_bytes,
1360                        create_time,
1361                        expiration_time,
1362                        sha256_hash,
1363                        uri,
1364                        state,
1365                        error,
1366                        video_metadata,
1367                    }
1368                },
1369            )
1370    }
1371
1372    proptest! {
1373        /// Verify FileState roundtrips through JSON serialization.
1374        #[test]
1375        fn file_state_roundtrip(state in arb_file_state()) {
1376            let json = serde_json::to_string(&state).expect("serialize");
1377            let parsed: FileState = serde_json::from_str(&json).expect("deserialize");
1378            // For Unknown variants, we can't do exact equality since data may be different
1379            // Just verify it roundtrips to a valid state
1380            match (&state, &parsed) {
1381                (FileState::Processing, FileState::Processing) => {}
1382                (FileState::Active, FileState::Active) => {}
1383                (FileState::Failed, FileState::Failed) => {}
1384                (FileState::Unknown { .. }, FileState::Unknown { .. }) => {}
1385                _ => panic!("State changed during roundtrip: {:?} -> {:?}", state, parsed),
1386            }
1387        }
1388
1389        /// Verify FileError roundtrips through JSON serialization.
1390        #[test]
1391        fn file_error_roundtrip(error in arb_file_error()) {
1392            let json = serde_json::to_string(&error).expect("serialize");
1393            let parsed: FileError = serde_json::from_str(&json).expect("deserialize");
1394            prop_assert_eq!(error.code, parsed.code);
1395            prop_assert_eq!(error.message, parsed.message);
1396        }
1397
1398        /// Verify VideoMetadata roundtrips through JSON serialization.
1399        #[test]
1400        fn video_metadata_roundtrip(metadata in arb_video_metadata()) {
1401            let json = serde_json::to_string(&metadata).expect("serialize");
1402            let parsed: VideoMetadata = serde_json::from_str(&json).expect("deserialize");
1403            prop_assert_eq!(metadata.video_duration, parsed.video_duration);
1404        }
1405
1406        /// Verify FileMetadata roundtrips through JSON serialization.
1407        #[test]
1408        fn file_metadata_roundtrip(metadata in arb_file_metadata()) {
1409            let json = serde_json::to_string(&metadata).expect("serialize");
1410            let parsed: FileMetadata = serde_json::from_str(&json).expect("deserialize");
1411
1412            prop_assert_eq!(&metadata.name, &parsed.name);
1413            prop_assert_eq!(&metadata.display_name, &parsed.display_name);
1414            prop_assert_eq!(&metadata.mime_type, &parsed.mime_type);
1415            prop_assert_eq!(&metadata.size_bytes, &parsed.size_bytes);
1416            prop_assert_eq!(&metadata.uri, &parsed.uri);
1417            // Note: state comparison is relaxed for Unknown variants
1418        }
1419    }
1420}