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