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}