Skip to main content

omni_dev/drive/
types.rs

1//! Wire types for the Drive v3 REST API.
2//!
3//! Field naming follows Drive's camelCase JSON via per-field
4//! `#[serde(rename = "...")]`, mirroring `src/gmail/types.rs`. `size` is
5//! Drive's own wire format: a **decimal string**, not a JSON number —
6//! present only for binary files with actual byte content; absent for
7//! folders and Google-native documents (Docs/Sheets/Slides/...), which have
8//! no fixed byte size. The content-hash fields (`md5Checksum`/
9//! `sha1Checksum`/`sha256Checksum`) share that same binary-content-only
10//! availability but, unlike `size`, are plain lowercase hex strings with no
11//! wire-format quirk of their own.
12
13use std::collections::HashMap;
14use std::io::Write;
15
16use anyhow::Result;
17use serde::{Deserialize, Serialize};
18
19use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
20
21/// An owner of a Drive file, as embedded in `files.list`/`files.get`'s
22/// `owners[]` field (requested via the `fields` param's
23/// `owners(displayName,emailAddress)` sub-selector — see `files_api.rs`).
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
25pub struct Owner {
26    /// The owner's display name.
27    #[serde(
28        default,
29        skip_serializing_if = "Option::is_none",
30        rename = "displayName"
31    )]
32    pub display_name: Option<String>,
33    /// The owner's email address.
34    #[serde(
35        default,
36        skip_serializing_if = "Option::is_none",
37        rename = "emailAddress"
38    )]
39    pub email_address: Option<String>,
40}
41
42/// A Drive file (or folder, or Google-native document) — the `files`
43/// resource returned by `files.list`/`files.get`.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
45pub struct DriveFile {
46    /// Drive file id.
47    pub id: String,
48    /// Display name.
49    pub name: String,
50    /// MIME type. `application/vnd.google-apps.*` marks a Google-native
51    /// document (Docs/Sheets/Slides/...) with no fixed byte content.
52    #[serde(default, rename = "mimeType")]
53    pub mime_type: String,
54    /// Size in bytes, as a decimal string. Absent for folders and
55    /// Google-native documents.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub size: Option<String>,
58    /// MD5 checksum of the file's content, as a lowercase hex string.
59    /// Absent for folders and Google-native documents. Has the broadest
60    /// historical coverage of the three checksum fields — sha1/sha256 were
61    /// added to the Drive API later, so a very old, untouched file may lack
62    /// them while still carrying this one.
63    #[serde(
64        default,
65        skip_serializing_if = "Option::is_none",
66        rename = "md5Checksum"
67    )]
68    pub md5_checksum: Option<String>,
69    /// SHA-1 checksum of the file's content, as a lowercase hex string.
70    /// Same availability caveats as [`Self::md5_checksum`].
71    #[serde(
72        default,
73        skip_serializing_if = "Option::is_none",
74        rename = "sha1Checksum"
75    )]
76    pub sha1_checksum: Option<String>,
77    /// SHA-256 checksum of the file's content, as a lowercase hex string.
78    /// Same availability caveats as [`Self::md5_checksum`].
79    #[serde(
80        default,
81        skip_serializing_if = "Option::is_none",
82        rename = "sha256Checksum"
83    )]
84    pub sha256_checksum: Option<String>,
85    /// Last modification time (RFC 3339).
86    #[serde(
87        default,
88        skip_serializing_if = "Option::is_none",
89        rename = "modifiedTime"
90    )]
91    pub modified_time: Option<String>,
92    /// Ids of the parent folders containing this file.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub parents: Vec<String>,
95    /// A link for opening this file in a relevant Google editor or viewer.
96    #[serde(
97        default,
98        skip_serializing_if = "Option::is_none",
99        rename = "webViewLink"
100    )]
101    pub web_view_link: Option<String>,
102    /// The file's owners.
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub owners: Vec<Owner>,
105    /// Id of the shared drive this file lives on, if any.
106    #[serde(default, skip_serializing_if = "Option::is_none", rename = "driveId")]
107    pub drive_id: Option<String>,
108    /// Export links for a Google-native document, keyed by export MIME
109    /// type. Present only on `files.get` (requested in `GET_FIELDS`;
110    /// deliberately **not** requested by `files.list`'s `LIST_FIELDS` — it's
111    /// irrelevant to a search-result table and would bloat every list
112    /// response). Values are export URLs (unused — Drive's `files.export`
113    /// endpoint is called directly instead); `drive read`'s content-export
114    /// error path lists these keys when a Google-native file has no default
115    /// export MIME type.
116    #[serde(
117        default,
118        skip_serializing_if = "Option::is_none",
119        rename = "exportLinks"
120    )]
121    pub export_links: Option<HashMap<String, String>>,
122}
123
124impl DriveFile {
125    /// Whether this is a Google-native document (Docs/Sheets/Slides/Forms/
126    /// Drawings/...) with no fixed byte content — must be fetched via
127    /// `files.export`, never `files.get?alt=media`.
128    #[must_use]
129    pub fn is_google_native(&self) -> bool {
130        self.mime_type.starts_with("application/vnd.google-apps.")
131    }
132}
133
134/// Response envelope for `GET /drive/v3/files`.
135#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
136pub struct FileListResponse {
137    /// Matching files on this page.
138    #[serde(default)]
139    pub files: Vec<DriveFile>,
140    /// Cursor for the next page, when more results are available.
141    /// [`crate::drive::files_api::FilesApi::search_all`] clears this rather
142    /// than leaving it pointing past files it discarded when a
143    /// caller-supplied limit truncates the result — `None` here means
144    /// either no more results exist upstream, or the search was capped,
145    /// never a false invitation to keep paging.
146    #[serde(
147        default,
148        skip_serializing_if = "Option::is_none",
149        rename = "nextPageToken"
150    )]
151    pub next_page_token: Option<String>,
152    /// Whether the search process was incomplete (partial results returned
153    /// due to a transient issue on Google's side). Also cleared by
154    /// [`crate::drive::files_api::FilesApi::search_all`] when truncation
155    /// discards fetched files, for the same reason as
156    /// [`Self::next_page_token`].
157    #[serde(
158        default,
159        skip_serializing_if = "Option::is_none",
160        rename = "incompleteSearch"
161    )]
162    pub incomplete_search: Option<bool>,
163}
164
165impl JsonlSerialize for DriveFile {
166    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
167        write_scalar_jsonl(self, out)
168    }
169}
170
171/// A Drive permission, as returned by `permissions.list(fileId)`.
172///
173/// The building block `crate::drive::visibility` diffs to detect a move's
174/// effect on a file's visibility. Fetched by
175/// `crate::drive::permissions_api::PermissionsApi::list_all`.
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
177pub struct DrivePermission {
178    /// Permission id.
179    #[serde(default)]
180    pub id: String,
181    /// `"user"` / `"group"` / `"domain"` / `"anyone"`.
182    #[serde(default, rename = "type")]
183    pub permission_type: String,
184    /// The granted role (`"reader"`/`"writer"`/`"owner"`/...). Not used by
185    /// the visibility-diff algorithm — `crate::drive::visibility::Principal`
186    /// deliberately excludes role from its identity — kept only for
187    /// informational logging.
188    #[serde(default)]
189    pub role: String,
190    /// The user's or group's email address (`type: "user"`/`"group"` only).
191    #[serde(
192        default,
193        skip_serializing_if = "Option::is_none",
194        rename = "emailAddress"
195    )]
196    pub email_address: Option<String>,
197    /// The Workspace domain (`type: "domain"` only).
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub domain: Option<String>,
200}
201
202#[cfg(test)]
203#[allow(clippy::unwrap_used, clippy::expect_used)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn file_list_response_deserializes_a_realistic_fixture() {
209        let json = serde_json::json!({
210            "files": [
211                {
212                    "id": "f1",
213                    "name": "report.pdf",
214                    "mimeType": "application/pdf",
215                    "size": "12345",
216                    "md5Checksum": "5d41402abc4b2a76b9719d911017c592",
217                    "sha1Checksum": "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d",
218                    "sha256Checksum": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
219                    "modifiedTime": "2026-01-01T00:00:00.000Z",
220                    "parents": ["folder1"],
221                    "webViewLink": "https://drive.google.com/file/d/f1/view",
222                    "owners": [{"displayName": "Alice", "emailAddress": "alice@example.com"}],
223                    "driveId": "shared1",
224                },
225            ],
226            "nextPageToken": "page2",
227            "incompleteSearch": false,
228        });
229        let response: FileListResponse = serde_json::from_value(json).unwrap();
230        assert_eq!(response.files.len(), 1);
231        let file = &response.files[0];
232        assert_eq!(file.id, "f1");
233        assert_eq!(file.size.as_deref(), Some("12345"));
234        assert_eq!(
235            file.md5_checksum.as_deref(),
236            Some("5d41402abc4b2a76b9719d911017c592")
237        );
238        assert_eq!(
239            file.sha1_checksum.as_deref(),
240            Some("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d")
241        );
242        assert_eq!(
243            file.sha256_checksum.as_deref(),
244            Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
245        );
246        assert_eq!(
247            file.owners[0].email_address.as_deref(),
248            Some("alice@example.com")
249        );
250        assert_eq!(file.drive_id.as_deref(), Some("shared1"));
251        assert_eq!(response.next_page_token.as_deref(), Some("page2"));
252    }
253
254    #[test]
255    fn checksums_are_none_when_absent() {
256        let json = serde_json::json!({"id": "f1", "name": "n"});
257        let file: DriveFile = serde_json::from_value(json).unwrap();
258        assert!(file.md5_checksum.is_none());
259        assert!(file.sha1_checksum.is_none());
260        assert!(file.sha256_checksum.is_none());
261    }
262
263    #[test]
264    fn size_round_trips_as_a_string_not_a_number() {
265        let file = DriveFile {
266            id: "f1".to_string(),
267            name: "n".to_string(),
268            size: Some("999".to_string()),
269            ..Default::default()
270        };
271        let value = serde_json::to_value(&file).unwrap();
272        assert_eq!(value["size"], serde_json::json!("999"));
273    }
274
275    #[test]
276    fn is_google_native_true_for_google_apps_mime_type() {
277        let file = DriveFile {
278            mime_type: "application/vnd.google-apps.document".to_string(),
279            ..Default::default()
280        };
281        assert!(file.is_google_native());
282    }
283
284    #[test]
285    fn is_google_native_false_for_ordinary_mime_type() {
286        let file = DriveFile {
287            mime_type: "application/pdf".to_string(),
288            ..Default::default()
289        };
290        assert!(!file.is_google_native());
291    }
292
293    #[test]
294    fn drive_file_deserializes_from_minimal_fields() {
295        let json = serde_json::json!({"id": "f1", "name": "n"});
296        let file: DriveFile = serde_json::from_value(json).unwrap();
297        assert_eq!(file.id, "f1");
298        assert_eq!(file.mime_type, "");
299        assert!(file.size.is_none());
300        assert!(file.export_links.is_none());
301    }
302
303    #[test]
304    fn deserializing_unmodeled_extra_field_still_succeeds() {
305        let json = serde_json::json!({
306            "id": "f1",
307            "name": "n",
308            "somethingNew": {"nested": true},
309        });
310        let file: DriveFile = serde_json::from_value(json).unwrap();
311        assert_eq!(file.id, "f1");
312    }
313
314    #[test]
315    fn export_links_parses_google_native_export_map() {
316        let json = serde_json::json!({
317            "id": "f1",
318            "name": "doc",
319            "mimeType": "application/vnd.google-apps.document",
320            "exportLinks": {
321                "text/markdown": "https://export.example/md",
322                "application/pdf": "https://export.example/pdf",
323            },
324        });
325        let file: DriveFile = serde_json::from_value(json).unwrap();
326        let links = file.export_links.unwrap();
327        assert_eq!(links.len(), 2);
328        assert!(links.contains_key("text/markdown"));
329    }
330
331    #[test]
332    fn drive_file_write_jsonl_emits_exactly_one_line() {
333        let file = DriveFile {
334            id: "f1".to_string(),
335            name: "n".to_string(),
336            ..Default::default()
337        };
338        let mut buf = Vec::new();
339        file.write_jsonl(&mut buf).unwrap();
340        let text = String::from_utf8(buf).unwrap();
341        assert_eq!(text.lines().count(), 1);
342        assert!(text.contains("f1"));
343    }
344}