1use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9pub const MIME_FOLDER: &str = "application/vnd.google-apps.folder";
14pub const MIME_DOCUMENT: &str = "application/vnd.google-apps.document";
15pub const MIME_SPREADSHEET: &str = "application/vnd.google-apps.spreadsheet";
16pub const MIME_PRESENTATION: &str = "application/vnd.google-apps.presentation";
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct DriveFile {
26 pub id: String,
27 pub name: String,
28 pub mime_type: String,
29
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub size: Option<String>,
33
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub created_time: Option<DateTime<Utc>>,
36
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub modified_time: Option<DateTime<Utc>>,
39
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub parents: Vec<String>,
43
44 #[serde(default)]
45 pub shared: bool,
46
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub web_view_link: Option<String>,
49
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub owners: Vec<Owner>,
52
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub permissions: Vec<Permission>,
55
56 #[serde(default)]
57 pub trashed: bool,
58}
59
60impl DriveFile {
61 pub fn is_folder(&self) -> bool {
63 self.mime_type == MIME_FOLDER
64 }
65
66 pub fn is_google_doc(&self) -> bool {
68 matches!(
69 self.mime_type.as_str(),
70 MIME_DOCUMENT | MIME_SPREADSHEET | MIME_PRESENTATION
71 )
72 }
73
74 pub fn display_size(&self) -> String {
84 match &self.size {
85 None => "-".to_string(),
86 Some(raw) => {
87 let bytes: u64 = raw.parse().unwrap_or(0);
88 display_size_bytes(bytes)
89 }
90 }
91 }
92}
93
94pub fn display_size_bytes(bytes: u64) -> String {
96 const KB: u64 = 1024;
97 const MB: u64 = 1024 * KB;
98 const GB: u64 = 1024 * MB;
99
100 if bytes == 0 {
101 return "0 B".to_string();
102 }
103 if bytes < KB {
104 return format!("{} B", bytes);
105 }
106 if bytes < MB {
107 let kb = bytes as f64 / KB as f64;
108 return format!("{:.1} KB", kb);
109 }
110 if bytes < GB {
111 let mb = bytes as f64 / MB as f64;
112 return format!("{:.1} MB", mb);
113 }
114 let gb = bytes as f64 / GB as f64;
115 format!("{:.2} GB", gb)
116}
117
118pub fn mime_type_label(mime: &str) -> &str {
121 match mime {
122 MIME_FOLDER => "Folder",
123 MIME_DOCUMENT => "Google Docs",
124 MIME_SPREADSHEET => "Google Sheets",
125 MIME_PRESENTATION => "Google Slides",
126 "application/pdf" => "PDF",
127 "image/jpeg" => "JPEG",
128 "image/png" => "PNG",
129 "text/plain" => "Plain Text",
130 "application/zip" => "ZIP",
131 other => other,
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct Owner {
143 pub display_name: String,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub email_address: Option<String>,
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct Permission {
156 pub id: String,
157 pub role: String,
158 #[serde(rename = "type")]
159 pub type_: String,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub email_address: Option<String>,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub display_name: Option<String>,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct FileList {
174 #[serde(default)]
175 pub files: Vec<DriveFile>,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub next_page_token: Option<String>,
178}
179
180#[derive(Error, Debug)]
185pub enum DriveError {
186 #[error("Drive API error ({status}): {message}")]
187 Api { status: u16, message: String },
188
189 #[error(transparent)]
190 Http(#[from] reqwest::Error),
191
192 #[error(transparent)]
193 Json(#[from] serde_json::Error),
194
195 #[error("IO error: {0}")]
196 Io(#[from] std::io::Error),
197
198 #[error("missing field: {0}")]
199 MissingField(String),
200}
201
202#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
215 fn test_drive_file_deserialize() {
216 let json = r#"{
217 "id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
218 "name": "My Document",
219 "mimeType": "application/vnd.google-apps.document",
220 "size": null,
221 "createdTime": "2024-01-15T10:30:00Z",
222 "modifiedTime": "2024-02-20T14:45:00Z",
223 "parents": ["0AMt2lbOlyFrKUk9PVA"],
224 "shared": true,
225 "webViewLink": "https://docs.google.com/document/d/1BxiMVs0/edit",
226 "owners": [
227 {"displayName": "Alice Smith", "emailAddress": "alice@example.com"}
228 ],
229 "trashed": false
230 }"#;
231
232 let file: DriveFile = serde_json::from_str(json).expect("deserialize DriveFile");
233 assert_eq!(file.id, "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms");
234 assert_eq!(file.name, "My Document");
235 assert_eq!(file.mime_type, MIME_DOCUMENT);
236 assert!(file.shared);
237 assert!(!file.trashed);
238 assert_eq!(file.parents.len(), 1);
239 assert_eq!(file.owners.len(), 1);
240 assert_eq!(file.owners[0].display_name, "Alice Smith");
241 assert_eq!(
242 file.owners[0].email_address.as_deref(),
243 Some("alice@example.com")
244 );
245 assert_eq!(
246 file.web_view_link.as_deref(),
247 Some("https://docs.google.com/document/d/1BxiMVs0/edit")
248 );
249 }
250
251 #[test]
256 fn test_file_list_deserialize() {
257 let json = r#"{
258 "files": [
259 {
260 "id": "file1",
261 "name": "Folder A",
262 "mimeType": "application/vnd.google-apps.folder"
263 },
264 {
265 "id": "file2",
266 "name": "Report.pdf",
267 "mimeType": "application/pdf",
268 "size": "204800"
269 }
270 ],
271 "nextPageToken": "page-token-abc"
272 }"#;
273
274 let list: FileList = serde_json::from_str(json).expect("deserialize FileList");
275 assert_eq!(list.files.len(), 2);
276 assert_eq!(list.next_page_token.as_deref(), Some("page-token-abc"));
277 assert_eq!(list.files[0].name, "Folder A");
278 assert_eq!(list.files[1].name, "Report.pdf");
279 }
280
281 #[test]
282 fn test_file_list_deserialize_no_paging() {
283 let json = r#"{"files": []}"#;
284 let list: FileList = serde_json::from_str(json).expect("deserialize empty FileList");
285 assert!(list.files.is_empty());
286 assert!(list.next_page_token.is_none());
287 }
288
289 #[test]
294 fn test_is_folder() {
295 let mut file = minimal_file("folder-id", "My Folder", MIME_FOLDER);
296 assert!(file.is_folder(), "expected folder MIME type to be detected");
297
298 file.mime_type = "application/pdf".to_string();
299 assert!(!file.is_folder(), "PDF should not be a folder");
300 }
301
302 #[test]
307 fn test_is_google_doc() {
308 let doc = minimal_file("d1", "Doc", MIME_DOCUMENT);
309 assert!(doc.is_google_doc(), "Docs MIME should be google doc");
310
311 let sheet = minimal_file("s1", "Sheet", MIME_SPREADSHEET);
312 assert!(sheet.is_google_doc(), "Sheets MIME should be google doc");
313
314 let slide = minimal_file("p1", "Slides", MIME_PRESENTATION);
315 assert!(slide.is_google_doc(), "Presentation MIME should be google doc");
316
317 let folder = minimal_file("f1", "Folder", MIME_FOLDER);
318 assert!(!folder.is_google_doc(), "Folder should not be google doc");
319
320 let pdf = minimal_file("pdf1", "Report", "application/pdf");
321 assert!(!pdf.is_google_doc(), "PDF should not be google doc");
322 }
323
324 #[test]
329 fn test_display_size_zero() {
330 let file = minimal_file_with_size("f", "f", "application/pdf", "0");
331 assert_eq!(file.display_size(), "0 B");
332 }
333
334 #[test]
335 fn test_display_size() {
336 let f = minimal_file_with_size("f", "f", "application/pdf", "512");
338 assert_eq!(f.display_size(), "512 B");
339
340 let f = minimal_file_with_size("f", "f", "application/pdf", "2048");
342 assert_eq!(f.display_size(), "2.0 KB");
343
344 let f = minimal_file_with_size("f", "f", "application/pdf", "5242880"); assert_eq!(f.display_size(), "5.0 MB");
347
348 let f = minimal_file_with_size("f", "f", "application/pdf", "1073741824"); assert_eq!(f.display_size(), "1.00 GB");
351 }
352
353 #[test]
354 fn test_display_size_no_size_field() {
355 let file = minimal_file("d1", "Doc", MIME_DOCUMENT);
357 assert_eq!(file.display_size(), "-");
358 }
359
360 #[test]
365 fn test_mime_type_label() {
366 assert_eq!(mime_type_label(MIME_DOCUMENT), "Google Docs");
367 assert_eq!(mime_type_label(MIME_SPREADSHEET), "Google Sheets");
368 assert_eq!(mime_type_label(MIME_PRESENTATION), "Google Slides");
369 assert_eq!(mime_type_label(MIME_FOLDER), "Folder");
370 assert_eq!(mime_type_label("application/pdf"), "PDF");
371 }
372
373 #[test]
374 fn test_mime_type_label_unknown() {
375 let unknown = "application/x-custom-format";
376 assert_eq!(mime_type_label(unknown), unknown);
377 }
378
379 #[test]
384 fn test_permission_deserialize() {
385 let json = r#"{
386 "id": "perm-001",
387 "role": "reader",
388 "type": "user",
389 "emailAddress": "bob@example.com",
390 "displayName": "Bob Jones"
391 }"#;
392
393 let perm: Permission = serde_json::from_str(json).expect("deserialize Permission");
394 assert_eq!(perm.id, "perm-001");
395 assert_eq!(perm.role, "reader");
396 assert_eq!(perm.type_, "user");
397 assert_eq!(perm.email_address.as_deref(), Some("bob@example.com"));
398 assert_eq!(perm.display_name.as_deref(), Some("Bob Jones"));
399 }
400
401 #[test]
402 fn test_permission_deserialize_anyone() {
403 let json = r#"{
404 "id": "perm-002",
405 "role": "commenter",
406 "type": "anyone"
407 }"#;
408
409 let perm: Permission = serde_json::from_str(json).expect("deserialize anyone Permission");
410 assert_eq!(perm.type_, "anyone");
411 assert!(perm.email_address.is_none());
412 assert!(perm.display_name.is_none());
413 }
414
415 #[test]
420 fn test_file_is_trashed() {
421 let json = r#"{
422 "id": "trashed-file-id",
423 "name": "Old File",
424 "mimeType": "application/pdf",
425 "trashed": true
426 }"#;
427
428 let file: DriveFile = serde_json::from_str(json).expect("deserialize trashed DriveFile");
429 assert!(file.trashed, "expected trashed to be true");
430 }
431
432 #[test]
433 fn test_file_not_trashed_by_default() {
434 let json = r#"{
435 "id": "active-file",
436 "name": "Active File",
437 "mimeType": "application/pdf"
438 }"#;
439
440 let file: DriveFile = serde_json::from_str(json).expect("deserialize DriveFile");
441 assert!(!file.trashed, "trashed should default to false");
442 }
443
444 #[test]
449 fn test_display_size_bytes_boundaries() {
450 assert_eq!(display_size_bytes(0), "0 B");
451 assert_eq!(display_size_bytes(1), "1 B");
452 assert_eq!(display_size_bytes(1023), "1023 B");
453 assert_eq!(display_size_bytes(1024), "1.0 KB");
454 assert_eq!(display_size_bytes(1024 * 1024 - 1), "1024.0 KB");
455 assert_eq!(display_size_bytes(1024 * 1024), "1.0 MB");
456 assert_eq!(display_size_bytes(1024 * 1024 * 1024), "1.00 GB");
457 }
458
459 fn minimal_file(id: &str, name: &str, mime: &str) -> DriveFile {
464 DriveFile {
465 id: id.to_string(),
466 name: name.to_string(),
467 mime_type: mime.to_string(),
468 size: None,
469 created_time: None,
470 modified_time: None,
471 parents: vec![],
472 shared: false,
473 web_view_link: None,
474 owners: vec![],
475 permissions: vec![],
476 trashed: false,
477 }
478 }
479
480 fn minimal_file_with_size(id: &str, name: &str, mime: &str, size: &str) -> DriveFile {
481 let mut f = minimal_file(id, name, mime);
482 f.size = Some(size.to_string());
483 f
484 }
485}