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}