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/// MIME type marking a Google Sheet.
33///
34/// Engine-layer copy, for the same reason [`GOOGLE_FOLDER_MIME_TYPE`] is one:
35/// `crate::cli::drive::read`'s `GOOGLE_SHEET` is a CLI-layer constant, and an
36/// engine module depending on it would invert the layering. Not a rename of
37/// that one — both stay.
38pub(crate) const GOOGLE_SHEET_MIME_TYPE: &str = "application/vnd.google-apps.spreadsheet";
39
40/// MIME type marking a Google Doc.
41///
42/// Engine-layer copy, for the same reason [`GOOGLE_FOLDER_MIME_TYPE`] and
43/// [`GOOGLE_SHEET_MIME_TYPE`] are: `crate::cli::drive::read`'s `GOOGLE_DOC`
44/// is a CLI-layer constant, and an engine module depending on it would
45/// invert the layering. Not a rename of that one — both stay.
46pub(crate) const GOOGLE_DOC_MIME_TYPE: &str = "application/vnd.google-apps.document";
47
48/// MIME type marking a Google Slides presentation.
49///
50/// Used by Slides target classification and native-document refusals.
51pub(crate) const GOOGLE_SLIDES_MIME_TYPE: &str = "application/vnd.google-apps.presentation";
52
53/// MIME type marking a Drive shortcut.
54///
55/// Shortcuts are never followed: a shortcut to a spreadsheet is refused with
56/// its own message rather than being silently resolved, matching
57/// `drive read --content`'s existing behaviour.
58pub(crate) const GOOGLE_SHORTCUT_MIME_TYPE: &str = "application/vnd.google-apps.shortcut";
59
60/// The refusals every Sheets-mutating engine (`drive sheets
61/// write`/`append`/`clear`, and `drive sheets add-sheet`/`rename-sheet`/
62/// `insert-rows`/`insert-columns`) can reach for a target: two classified
63/// **before** the write-permission gate ever runs (a shortcut, a
64/// non-spreadsheet), and one — [`Self::NoVisibleParents`] — that the gate
65/// itself reports, since a `file_id` rule must be consulted before "no
66/// visible parent" can be the real story (issue #1612).
67///
68/// Shared via [`classify_sheet_target`] so the two pre-gate checks and
69/// their order are written once rather than copied by hand into each
70/// engine, where they would otherwise be free to quietly drift apart.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub(crate) enum SheetTargetRefusal {
73    /// The target is a shortcut, which no Sheets engine follows.
74    Shortcut,
75    /// The target is not a Google Sheet at all.
76    NotASpreadsheet {
77        /// The target's actual MIME type.
78        mime_type: String,
79    },
80    /// The target has no parents this account can see **and** no `file_id`
81    /// rule named it, so no rule the operator could write would ever grant
82    /// it. Not produced by [`classify_sheet_target`] — an empty
83    /// `parents` list alone is not conclusive, since a `file_id` rule
84    /// sitting at depth −1 could still grant the target; only the gate
85    /// step (which checks that rule first) can tell the two apart.
86    NoVisibleParents,
87}
88
89/// Classifies `target` against the two pre-gate checks above. `Ok(())`
90/// means neither applies and the caller should proceed to the
91/// write-permission gate next — which may still report
92/// [`SheetTargetRefusal::NoVisibleParents`] itself.
93pub(crate) fn classify_sheet_target(target: &DriveFile) -> Result<(), SheetTargetRefusal> {
94    if target.mime_type == GOOGLE_SHORTCUT_MIME_TYPE {
95        return Err(SheetTargetRefusal::Shortcut);
96    }
97    if target.mime_type != GOOGLE_SHEET_MIME_TYPE {
98        return Err(SheetTargetRefusal::NotASpreadsheet {
99            mime_type: target.mime_type.clone(),
100        });
101    }
102    Ok(())
103}
104
105/// An owner of a Drive file, as embedded in `files.list`/`files.get`'s
106/// `owners[]` field (requested via the `fields` param's
107/// `owners(displayName,emailAddress)` sub-selector — see `files_api.rs`).
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
109pub struct Owner {
110    /// The owner's display name.
111    #[serde(
112        default,
113        skip_serializing_if = "Option::is_none",
114        rename = "displayName"
115    )]
116    pub display_name: Option<String>,
117    /// The owner's email address.
118    #[serde(
119        default,
120        skip_serializing_if = "Option::is_none",
121        rename = "emailAddress"
122    )]
123    pub email_address: Option<String>,
124}
125
126/// A Drive file (or folder, or Google-native document) — the `files`
127/// resource returned by `files.list`/`files.get`.
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
129pub struct DriveFile {
130    /// Drive file id.
131    pub id: String,
132    /// Display name.
133    pub name: String,
134    /// MIME type. `application/vnd.google-apps.*` marks a Google-native
135    /// document (Docs/Sheets/Slides/...) with no fixed byte content.
136    #[serde(default, rename = "mimeType")]
137    pub mime_type: String,
138    /// Whether the file is in Trash, explicitly or through a trashed parent.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub trashed: Option<bool>,
141    /// Size in bytes, as a decimal string. Absent for folders and
142    /// Google-native documents.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub size: Option<String>,
145    /// MD5 checksum of the file's content, as a lowercase hex string.
146    /// Absent for folders and Google-native documents. Has the broadest
147    /// historical coverage of the three checksum fields — sha1/sha256 were
148    /// added to the Drive API later, so a very old, untouched file may lack
149    /// them while still carrying this one.
150    #[serde(
151        default,
152        skip_serializing_if = "Option::is_none",
153        rename = "md5Checksum"
154    )]
155    pub md5_checksum: Option<String>,
156    /// SHA-1 checksum of the file's content, as a lowercase hex string.
157    /// Same availability caveats as [`Self::md5_checksum`].
158    #[serde(
159        default,
160        skip_serializing_if = "Option::is_none",
161        rename = "sha1Checksum"
162    )]
163    pub sha1_checksum: Option<String>,
164    /// SHA-256 checksum of the file's content, as a lowercase hex string.
165    /// Same availability caveats as [`Self::md5_checksum`].
166    #[serde(
167        default,
168        skip_serializing_if = "Option::is_none",
169        rename = "sha256Checksum"
170    )]
171    pub sha256_checksum: Option<String>,
172    /// Last modification time (RFC 3339).
173    #[serde(
174        default,
175        skip_serializing_if = "Option::is_none",
176        rename = "modifiedTime"
177    )]
178    pub modified_time: Option<String>,
179    /// A monotonically increasing version number for the file, incremented
180    /// on every change including ones that do not modify content (e.g. a
181    /// permission change) — Drive's own opaque revision counter, requested
182    /// so the Drive write lease ([ADR-0080](../../docs/adrs/adr-0080.md)
183    /// §6) can detect that a file moved since a lease was acquired or last
184    /// written under. An arbitrary-precision decimal string per Drive's own
185    /// documentation, so it is compared for equality only, never parsed as
186    /// a number.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub version: Option<String>,
189    /// Ids of the parent folders containing this file.
190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191    pub parents: Vec<String>,
192    /// A link for opening this file in a relevant Google editor or viewer.
193    #[serde(
194        default,
195        skip_serializing_if = "Option::is_none",
196        rename = "webViewLink"
197    )]
198    pub web_view_link: Option<String>,
199    /// The file's owners.
200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
201    pub owners: Vec<Owner>,
202    /// Id of the shared drive this file lives on, if any.
203    #[serde(default, skip_serializing_if = "Option::is_none", rename = "driveId")]
204    pub drive_id: Option<String>,
205    /// Export links for a Google-native document, keyed by export MIME
206    /// type. Present only on `files.get` (requested in `GET_FIELDS`;
207    /// deliberately **not** requested by `files.list`'s `LIST_FIELDS` — it's
208    /// irrelevant to a search-result table and would bloat every list
209    /// response). Values are export URLs (unused — Drive's `files.export`
210    /// endpoint is called directly instead); `drive read`'s content-export
211    /// error path lists these keys when a Google-native file has no default
212    /// export MIME type.
213    #[serde(
214        default,
215        skip_serializing_if = "Option::is_none",
216        rename = "exportLinks"
217    )]
218    pub export_links: Option<HashMap<String, String>>,
219}
220
221impl DriveFile {
222    /// Whether this is a Google-native document (Docs/Sheets/Slides/Forms/
223    /// Drawings/...) with no fixed byte content — must be fetched via
224    /// `files.export`, never `files.get?alt=media`.
225    #[must_use]
226    pub fn is_google_native(&self) -> bool {
227        self.mime_type.starts_with("application/vnd.google-apps.")
228    }
229}
230
231/// Response envelope for `GET /drive/v3/files`.
232#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
233pub struct FileListResponse {
234    /// Matching files on this page.
235    #[serde(default)]
236    pub files: Vec<DriveFile>,
237    /// Cursor for the next page, when more results are available.
238    /// [`crate::drive::files_api::FilesApi::search_all`] clears this rather
239    /// than leaving it pointing past files it discarded when a
240    /// caller-supplied limit truncates the result — `None` here means
241    /// either no more results exist upstream, or the search was capped,
242    /// never a false invitation to keep paging.
243    #[serde(
244        default,
245        skip_serializing_if = "Option::is_none",
246        rename = "nextPageToken"
247    )]
248    pub next_page_token: Option<String>,
249    /// Whether the search process was incomplete (partial results returned
250    /// due to a transient issue on Google's side). Also cleared by
251    /// [`crate::drive::files_api::FilesApi::search_all`] when truncation
252    /// discards fetched files, for the same reason as
253    /// [`Self::next_page_token`].
254    #[serde(
255        default,
256        skip_serializing_if = "Option::is_none",
257        rename = "incompleteSearch"
258    )]
259    pub incomplete_search: Option<bool>,
260}
261
262impl JsonlSerialize for DriveFile {
263    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
264        write_scalar_jsonl(self, out)
265    }
266}
267
268/// A Drive permission, as returned by `permissions.list(fileId)`.
269///
270/// The building block `crate::drive::visibility` diffs to detect a move's
271/// effect on a file's visibility. Fetched by
272/// `crate::drive::permissions_api::PermissionsApi::list_all`.
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
274pub struct DrivePermission {
275    /// Permission id.
276    #[serde(default)]
277    pub id: String,
278    /// `"user"` / `"group"` / `"domain"` / `"anyone"`.
279    #[serde(default, rename = "type")]
280    pub permission_type: String,
281    /// The granted role (`"reader"`/`"writer"`/`"owner"`/...). Not used by
282    /// the visibility-diff algorithm — `crate::drive::visibility::Principal`
283    /// deliberately excludes role from its identity — kept only for
284    /// informational logging.
285    #[serde(default)]
286    pub role: String,
287    /// The user's or group's email address (`type: "user"`/`"group"` only).
288    #[serde(
289        default,
290        skip_serializing_if = "Option::is_none",
291        rename = "emailAddress"
292    )]
293    pub email_address: Option<String>,
294    /// The Workspace domain (`type: "domain"` only).
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub domain: Option<String>,
297}
298
299#[cfg(test)]
300#[allow(clippy::unwrap_used, clippy::expect_used)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn file_list_response_deserializes_a_realistic_fixture() {
306        let json = serde_json::json!({
307            "files": [
308                {
309                    "id": "f1",
310                    "name": "report.pdf",
311                    "mimeType": "application/pdf",
312                    "size": "12345",
313                    "md5Checksum": "5d41402abc4b2a76b9719d911017c592",
314                    "sha1Checksum": "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d",
315                    "sha256Checksum": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
316                    "modifiedTime": "2026-01-01T00:00:00.000Z",
317                    "parents": ["folder1"],
318                    "webViewLink": "https://drive.google.com/file/d/f1/view",
319                    "owners": [{"displayName": "Alice", "emailAddress": "alice@example.com"}],
320                    "driveId": "shared1",
321                },
322            ],
323            "nextPageToken": "page2",
324            "incompleteSearch": false,
325        });
326        let response: FileListResponse = serde_json::from_value(json).unwrap();
327        assert_eq!(response.files.len(), 1);
328        let file = &response.files[0];
329        assert_eq!(file.id, "f1");
330        assert_eq!(file.size.as_deref(), Some("12345"));
331        assert_eq!(
332            file.md5_checksum.as_deref(),
333            Some("5d41402abc4b2a76b9719d911017c592")
334        );
335        assert_eq!(
336            file.sha1_checksum.as_deref(),
337            Some("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d")
338        );
339        assert_eq!(
340            file.sha256_checksum.as_deref(),
341            Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
342        );
343        assert_eq!(
344            file.owners[0].email_address.as_deref(),
345            Some("alice@example.com")
346        );
347        assert_eq!(file.drive_id.as_deref(), Some("shared1"));
348        assert_eq!(response.next_page_token.as_deref(), Some("page2"));
349    }
350
351    #[test]
352    fn checksums_are_none_when_absent() {
353        let json = serde_json::json!({"id": "f1", "name": "n"});
354        let file: DriveFile = serde_json::from_value(json).unwrap();
355        assert!(file.md5_checksum.is_none());
356        assert!(file.sha1_checksum.is_none());
357        assert!(file.sha256_checksum.is_none());
358    }
359
360    #[test]
361    fn size_round_trips_as_a_string_not_a_number() {
362        let file = DriveFile {
363            id: "f1".to_string(),
364            name: "n".to_string(),
365            size: Some("999".to_string()),
366            ..Default::default()
367        };
368        let value = serde_json::to_value(&file).unwrap();
369        assert_eq!(value["size"], serde_json::json!("999"));
370    }
371
372    #[test]
373    fn is_google_native_true_for_google_apps_mime_type() {
374        let file = DriveFile {
375            mime_type: "application/vnd.google-apps.document".to_string(),
376            ..Default::default()
377        };
378        assert!(file.is_google_native());
379    }
380
381    #[test]
382    fn is_google_native_false_for_ordinary_mime_type() {
383        let file = DriveFile {
384            mime_type: "application/pdf".to_string(),
385            ..Default::default()
386        };
387        assert!(!file.is_google_native());
388    }
389
390    #[test]
391    fn drive_file_deserializes_from_minimal_fields() {
392        let json = serde_json::json!({"id": "f1", "name": "n"});
393        let file: DriveFile = serde_json::from_value(json).unwrap();
394        assert_eq!(file.id, "f1");
395        assert_eq!(file.mime_type, "");
396        assert!(file.size.is_none());
397        assert!(file.export_links.is_none());
398    }
399
400    #[test]
401    fn deserializing_unmodeled_extra_field_still_succeeds() {
402        let json = serde_json::json!({
403            "id": "f1",
404            "name": "n",
405            "somethingNew": {"nested": true},
406        });
407        let file: DriveFile = serde_json::from_value(json).unwrap();
408        assert_eq!(file.id, "f1");
409    }
410
411    #[test]
412    fn export_links_parses_google_native_export_map() {
413        let json = serde_json::json!({
414            "id": "f1",
415            "name": "doc",
416            "mimeType": "application/vnd.google-apps.document",
417            "exportLinks": {
418                "text/markdown": "https://export.example/md",
419                "application/pdf": "https://export.example/pdf",
420            },
421        });
422        let file: DriveFile = serde_json::from_value(json).unwrap();
423        let links = file.export_links.unwrap();
424        assert_eq!(links.len(), 2);
425        assert!(links.contains_key("text/markdown"));
426    }
427
428    #[test]
429    fn drive_file_write_jsonl_emits_exactly_one_line() {
430        let file = DriveFile {
431            id: "f1".to_string(),
432            name: "n".to_string(),
433            ..Default::default()
434        };
435        let mut buf = Vec::new();
436        file.write_jsonl(&mut buf).unwrap();
437        let text = String::from_utf8(buf).unwrap();
438        assert_eq!(text.lines().count(), 1);
439        assert!(text.contains("f1"));
440    }
441
442    // ── classify_sheet_target ───────────────────────────────────────────
443
444    fn sheet_target(parents: &[&str]) -> DriveFile {
445        DriveFile {
446            id: "sheet-1".to_string(),
447            name: "Budget".to_string(),
448            mime_type: GOOGLE_SHEET_MIME_TYPE.to_string(),
449            parents: parents.iter().map(ToString::to_string).collect(),
450            ..Default::default()
451        }
452    }
453
454    #[test]
455    fn classify_sheet_target_allows_a_spreadsheet_with_a_visible_parent() {
456        assert_eq!(classify_sheet_target(&sheet_target(&["parent-1"])), Ok(()));
457    }
458
459    #[test]
460    fn classify_sheet_target_refuses_a_shortcut_even_to_a_spreadsheet() {
461        let mut target = sheet_target(&["parent-1"]);
462        target.mime_type = GOOGLE_SHORTCUT_MIME_TYPE.to_string();
463        assert_eq!(
464            classify_sheet_target(&target),
465            Err(SheetTargetRefusal::Shortcut)
466        );
467    }
468
469    #[test]
470    fn classify_sheet_target_refuses_a_non_spreadsheet() {
471        let mut target = sheet_target(&["parent-1"]);
472        target.mime_type = "application/pdf".to_string();
473        assert_eq!(
474            classify_sheet_target(&target),
475            Err(SheetTargetRefusal::NotASpreadsheet {
476                mime_type: "application/pdf".to_string()
477            })
478        );
479    }
480
481    #[test]
482    fn classify_sheet_target_allows_a_spreadsheet_with_no_visible_parents() {
483        // `NoVisibleParents` is now reported by the gate step, not here —
484        // an empty `parents` list alone isn't conclusive, since a
485        // `file_id` rule could still grant the target (issue #1612). See
486        // `target_gate.rs`'s tests for that decision.
487        assert_eq!(classify_sheet_target(&sheet_target(&[])), Ok(()));
488    }
489}