Skip to main content

gog_drive/
types.rs

1// gog-drive types module
2// Drive API types: DriveFile, FileList, Permission, DriveError.
3// Ported from the Go internal/drive package.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9// ---------------------------------------------------------------------------
10// Google MIME type constants
11// ---------------------------------------------------------------------------
12
13pub 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// ---------------------------------------------------------------------------
19// DriveFile
20// ---------------------------------------------------------------------------
21
22/// Represents a file or folder in Google Drive.
23#[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    /// File size in bytes. None for Google native docs (Docs, Sheets, Slides).
31    #[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    /// Parent folder IDs.
41    #[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    /// Returns true if this file is a Google Drive folder.
62    pub fn is_folder(&self) -> bool {
63        self.mime_type == MIME_FOLDER
64    }
65
66    /// Returns true if this file is a Google native document (Docs, Sheets, Slides).
67    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    /// Returns a human-readable file size string.
75    ///
76    /// - 0 bytes → "0 B"
77    /// - < 1 KB  → "{n} B"
78    /// - < 1 MB  → "{n.1} KB"
79    /// - < 1 GB  → "{n.1} MB"
80    /// - else    → "{n.2} GB"
81    ///
82    /// Returns `"-"` when the size field is absent (e.g., Google native docs).
83    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
94/// Format a byte count as a human-readable string.
95pub 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
118/// Return a friendly label for Google MIME types.
119/// Unknown MIME types are returned as-is.
120pub 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// ---------------------------------------------------------------------------
136// Owner
137// ---------------------------------------------------------------------------
138
139/// A file owner (embedded in DriveFile).
140#[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// ---------------------------------------------------------------------------
149// Permission
150// ---------------------------------------------------------------------------
151
152/// A sharing permission on a Drive file.
153#[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// ---------------------------------------------------------------------------
167// FileList
168// ---------------------------------------------------------------------------
169
170/// A paginated list of Drive files, as returned by the `files.list` API.
171#[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// ---------------------------------------------------------------------------
181// DriveError
182// ---------------------------------------------------------------------------
183
184#[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// ---------------------------------------------------------------------------
203// Tests
204// ---------------------------------------------------------------------------
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    // -----------------------------------------------------------------------
211    // DriveFile deserialization
212    // -----------------------------------------------------------------------
213
214    #[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    // -----------------------------------------------------------------------
252    // FileList deserialization
253    // -----------------------------------------------------------------------
254
255    #[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    // -----------------------------------------------------------------------
290    // is_folder
291    // -----------------------------------------------------------------------
292
293    #[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    // -----------------------------------------------------------------------
303    // is_google_doc
304    // -----------------------------------------------------------------------
305
306    #[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    // -----------------------------------------------------------------------
325    // display_size
326    // -----------------------------------------------------------------------
327
328    #[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        // Bytes
337        let f = minimal_file_with_size("f", "f", "application/pdf", "512");
338        assert_eq!(f.display_size(), "512 B");
339
340        // Kilobytes
341        let f = minimal_file_with_size("f", "f", "application/pdf", "2048");
342        assert_eq!(f.display_size(), "2.0 KB");
343
344        // Megabytes
345        let f = minimal_file_with_size("f", "f", "application/pdf", "5242880"); // 5 MB
346        assert_eq!(f.display_size(), "5.0 MB");
347
348        // Gigabytes
349        let f = minimal_file_with_size("f", "f", "application/pdf", "1073741824"); // 1 GB
350        assert_eq!(f.display_size(), "1.00 GB");
351    }
352
353    #[test]
354    fn test_display_size_no_size_field() {
355        // Google native docs have no size
356        let file = minimal_file("d1", "Doc", MIME_DOCUMENT);
357        assert_eq!(file.display_size(), "-");
358    }
359
360    // -----------------------------------------------------------------------
361    // mime_type_label
362    // -----------------------------------------------------------------------
363
364    #[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    // -----------------------------------------------------------------------
380    // Permission deserialization
381    // -----------------------------------------------------------------------
382
383    #[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    // -----------------------------------------------------------------------
416    // trashed field
417    // -----------------------------------------------------------------------
418
419    #[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    // -----------------------------------------------------------------------
445    // display_size_bytes helper
446    // -----------------------------------------------------------------------
447
448    #[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    // -----------------------------------------------------------------------
460    // Helpers
461    // -----------------------------------------------------------------------
462
463    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}