1use 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct FileMetadata {
70 pub name: String,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub display_name: Option<String>,
76
77 pub mime_type: String,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub size_bytes: Option<String>,
83
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub create_time: Option<DateTime<Utc>>,
87
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub expiration_time: Option<DateTime<Utc>>,
91
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub sha256_hash: Option<String>,
95
96 #[serde(default)]
98 pub uri: String,
99
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub state: Option<FileState>,
103
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub error: Option<FileError>,
107
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub video_metadata: Option<VideoMetadata>,
111}
112
113impl FileMetadata {
114 #[must_use]
116 pub fn is_processing(&self) -> bool {
117 matches!(self.state, Some(FileState::Processing))
118 }
119
120 #[must_use]
122 pub fn is_active(&self) -> bool {
123 matches!(self.state, Some(FileState::Active))
124 }
125
126 #[must_use]
128 pub fn is_failed(&self) -> bool {
129 matches!(self.state, Some(FileState::Failed))
130 }
131
132 #[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#[derive(Clone, Debug, PartialEq)]
169#[non_exhaustive]
170pub enum FileState {
171 Processing,
173 Active,
175 Failed,
177 Unknown {
185 state_type: String,
187 data: serde_json::Value,
189 },
190}
191
192impl FileState {
193 #[must_use]
195 pub const fn is_unknown(&self) -> bool {
196 matches!(self, Self::Unknown { .. })
197 }
198
199 #[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 #[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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
278pub struct FileError {
279 #[serde(skip_serializing_if = "Option::is_none")]
281 pub code: Option<i32>,
282 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct VideoMetadata {
302 #[serde(skip_serializing_if = "Option::is_none")]
304 pub video_duration: Option<String>,
305}
306
307#[derive(Clone, Debug, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct ListFilesResponse {
311 #[serde(default)]
313 pub files: Vec<FileMetadata>,
314
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub next_page_token: Option<String>,
318}
319
320#[derive(Clone, Debug, Serialize, Deserialize)]
322pub struct FileUploadResponse {
323 pub file: FileMetadata,
325}
326
327const BASE_URL: &str = "https://generativelanguage.googleapis.com";
330const UPLOAD_URL: &str = "https://generativelanguage.googleapis.com/upload/v1beta/files";
331const API_VERSION: &str = "v1beta";
332const MAX_FILE_SIZE: u64 = 2_147_483_648;
334
335pub 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 if file_data.is_empty() {
357 return Err(GenaiError::InvalidInput(
358 "Cannot upload empty file".to_string(),
359 ));
360 }
361
362 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 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 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 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 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(request_id, &file_response.file.uri);
446
447 Ok(file_response.file)
448}
449
450#[derive(Clone, Debug)]
485pub struct ResumableUpload {
486 upload_url: String,
488 file_size: u64,
490 mime_type: String,
492}
493
494impl ResumableUpload {
495 #[must_use]
497 pub fn upload_url(&self) -> &str {
498 &self.upload_url
499 }
500
501 #[must_use]
503 pub fn file_size(&self) -> u64 {
504 self.file_size
505 }
506
507 #[must_use]
509 pub fn mime_type(&self) -> &str {
510 &self.mime_type
511 }
512
513 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 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 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 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 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
622pub const DEFAULT_CHUNK_SIZE: usize = 8 * 1024 * 1024; pub 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
701pub 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 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 if file_size == 0 {
743 return Err(GenaiError::InvalidInput(
744 "Cannot upload empty file".to_string(),
745 ));
746 }
747
748 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 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 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 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 let resumable_upload = ResumableUpload {
806 upload_url: upload_url.clone(),
807 file_size,
808 mime_type: mime_type.to_string(),
809 };
810
811 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 let stream = ReaderStream::with_capacity(file, chunk_size);
819 let body = reqwest::Body::wrap_stream(stream);
820
821 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(request_id, &file_response.file.uri);
844
845 Ok((file_response.file, resumable_upload))
846}
847
848pub 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 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(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(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
894pub 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 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 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(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(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
958pub 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 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(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 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 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 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 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 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 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 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 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 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 let error = FileError {
1176 code: Some(500),
1177 message: None,
1178 };
1179 assert_eq!(error.to_string(), "error 500");
1180
1181 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 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 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 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 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 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()), 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 }
1267
1268#[cfg(test)]
1270mod proptest_tests {
1271 use super::*;
1272 use chrono::TimeZone;
1273 use proptest::prelude::*;
1274
1275 fn arb_datetime() -> impl Strategy<Value = DateTime<Utc>> {
1278 (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 #[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 ("[A-Z_]{4,20}".prop_map(|state_type| FileState::Unknown {
1295 state_type,
1296 data: serde_json::Value::Null,
1297 })),
1298 ]
1299 }
1300
1301 #[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 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 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 fn arb_file_metadata() -> impl Strategy<Value = FileMetadata> {
1328 (
1329 "files/[a-zA-Z0-9_]+", prop::option::of(".{1,50}"), "[a-z]+/[a-z0-9+-]+", prop::option::of("[0-9]+"), prop::option::of(arb_datetime()), prop::option::of(arb_datetime()), prop::option::of("[a-f0-9]{64}"), "https?://[a-z]+\\.[a-z]+/[a-z]+", prop::option::of(arb_file_state()), 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 #[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 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 #[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 #[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 #[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 }
1419 }
1420}