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