Skip to main content

omni_dev/drive/
files_api.rs

1//! Drive Files API wrapper.
2//!
3//! `files.list` returns full metadata per hit in one call via the `fields`
4//! parameter — unlike Gmail's `messages.list` (which needs a follow-up
5//! `messages.get` per hit), so there is no ids-then-enrich split and no
6//! `--enrich`/`search_summaries` analog (`src/gmail/messages_api.rs`'s
7//! sibling concept has nothing to mirror here).
8
9use anyhow::{Context, Result};
10use url::Url;
11
12use crate::drive::client::DriveClient;
13use crate::drive::error::DriveError;
14use crate::drive::types::{DriveFile, FileListResponse};
15use crate::utils::multipart;
16
17/// Maximum `pageSize` accepted by `GET /drive/v3/files`.
18///
19/// Drive REST API reference: acceptable values are 1 to 1000, inclusive.
20/// Unlike Gmail's 500 (`crate::gmail::messages_api::MAX_PAGE_LIMIT`),
21/// Drive's cap is 1000.
22pub const MAX_PAGE_LIMIT: usize = 1000;
23
24/// Per-call upper bound on [`FilesApi::search_all`], even when the caller
25/// passes `limit = 0`.
26///
27/// Mirrors `crate::gmail::messages_api::HARD_CAP` verbatim — same safety
28/// rationale, no Drive-specific derivation.
29pub const HARD_CAP: usize = 10_000;
30
31/// Default `limit` for `drive search` when `--limit` is omitted. Mirrors
32/// `crate::gmail::messages_api::DEFAULT_SEARCH_LIMIT`.
33pub const DEFAULT_SEARCH_LIMIT: usize = 50;
34
35/// Refuses to buffer a [`FilesApi::download`] response body larger than
36/// this into memory. `files.export` already has Drive's own 10 MB cap, but
37/// `files.get?alt=media` has none — an unbounded read of a very large file
38/// risks exhausting process memory.
39const MAX_DOWNLOAD_BYTES: u64 = 500 * 1024 * 1024;
40
41/// Refuses to buffer local content larger than this for
42/// [`FilesApi::upload`]/[`FilesApi::edit_content`] — Google's documented
43/// cap on `uploadType=multipart`/`uploadType=media` simple-upload request
44/// bodies. Content above this needs a resumable upload session (chunked,
45/// with its own restart/retry bookkeeping), explicitly out of scope for
46/// v1 — refused outright rather than silently degrading, the same
47/// documented-boundary posture ADR-0070 §8 took for shallow folder moves.
48pub(crate) const MAX_UPLOAD_BYTES: u64 = 5 * 1024 * 1024;
49
50/// `fields` value for `files.list` — enough for `drive search`'s
51/// table/JSON output with zero follow-up calls per hit.
52const LIST_FIELDS: &str = "nextPageToken,incompleteSearch,files(id,name,mimeType,size,\
53    md5Checksum,sha1Checksum,sha256Checksum,modifiedTime,parents,webViewLink,\
54    owners(displayName,emailAddress),driveId)";
55
56/// `fields` value for `files.get` — additionally includes `exportLinks` so
57/// `drive read`'s content-export error path can list which MIME types a
58/// Google-native file actually supports exporting to, and `version` for the
59/// Drive write lease's staleness check (ADR-0080 §6). Also requested by
60/// [`FilesApi::edit_content`]'s own response, so a leased binary write
61/// refreshes its ledger row from the write's own reply rather than a
62/// second `files.get`.
63const GET_FIELDS: &str = "id,name,mimeType,size,md5Checksum,sha1Checksum,sha256Checksum,\
64    modifiedTime,parents,webViewLink,owners(displayName,emailAddress),driveId,exportLinks,\
65    version";
66
67/// Files API façade.
68#[derive(Debug)]
69pub struct FilesApi<'a> {
70    client: &'a DriveClient,
71}
72
73impl<'a> FilesApi<'a> {
74    /// Wraps an existing [`DriveClient`] for file operations.
75    #[must_use]
76    pub fn new(client: &'a DriveClient) -> Self {
77        Self { client }
78    }
79
80    /// Searches files matching `query`, returning a single page.
81    ///
82    /// `limit` is rejected client-side when it exceeds [`MAX_PAGE_LIMIT`];
83    /// use [`Self::search_all`] to auto-paginate across pages.
84    pub async fn search(
85        &self,
86        query: Option<&str>,
87        limit: usize,
88        page_token: Option<&str>,
89    ) -> Result<FileListResponse> {
90        if limit > MAX_PAGE_LIMIT {
91            return Err(anyhow::anyhow!(
92                "`limit` must be <= {MAX_PAGE_LIMIT} (Drive files.list per-page cap; use \
93                 `search_all` to auto-paginate)"
94            ));
95        }
96        let url = build_files_list_url(self.client.base_url(), query, limit, page_token)?;
97        self.client
98            .get_parsed(url.as_str(), "Failed to parse files.list response")
99            .await
100    }
101
102    /// Searches files, auto-paginating via cursor as needed.
103    ///
104    /// `limit == 0` means "fetch every match up to [`HARD_CAP`]" — a
105    /// deliberate safety limit for this interactive surface.
106    pub async fn search_all(&self, query: Option<&str>, limit: usize) -> Result<FileListResponse> {
107        self.paginate(query, effective_cap(limit)).await
108    }
109
110    /// Pagination loop backing [`Self::search_all`] — structurally
111    /// identical to `crate::gmail::messages_api::MessagesApi::paginate`,
112    /// `messages` renamed to `files`, `result_size_estimate` renamed to
113    /// `incomplete_search` (Drive has no result-count estimate field),
114    /// with one deliberate divergence: when the accumulated total exceeds
115    /// `cap`, this method clears `next_page_token`/`incomplete_search`
116    /// before truncating `files`, so a caller can never resume pagination
117    /// past files that were fetched but then discarded (#1536). Gmail's
118    /// `paginate` still has the matching bug — issue #1536 was scoped to
119    /// Drive only.
120    async fn paginate(&self, query: Option<&str>, cap: usize) -> Result<FileListResponse> {
121        let mut acc: Option<FileListResponse> = None;
122        let mut page_token: Option<String> = None;
123        loop {
124            let collected = acc.as_ref().map_or(0, |r| r.files.len());
125            let page_size = (cap - collected).min(MAX_PAGE_LIMIT);
126            let page = self.search(query, page_size, page_token.as_deref()).await?;
127            let next_token = page.next_page_token.clone();
128            match acc.as_mut() {
129                Some(existing) => {
130                    existing.files.extend(page.files);
131                    existing.next_page_token = page.next_page_token;
132                    existing.incomplete_search = page.incomplete_search;
133                }
134                None => acc = Some(page),
135            }
136            let collected = acc.as_ref().map_or(0, |r| r.files.len());
137            if collected >= cap || next_token.is_none() {
138                break;
139            }
140            page_token = next_token;
141        }
142        let mut result = acc.unwrap_or_default();
143        if result.files.len() > cap {
144            result.files.truncate(cap);
145            result.next_page_token = None;
146            result.incomplete_search = None;
147        }
148        Ok(result)
149    }
150
151    /// Fetches a single file's metadata (`files.get`), including
152    /// `exportLinks`.
153    pub async fn get_metadata(&self, file_id: &str) -> Result<DriveFile> {
154        let url = build_file_get_url(self.client.base_url(), file_id)?;
155        self.client
156            .get_parsed(url.as_str(), "Failed to parse files.get response")
157            .await
158    }
159
160    /// Exports a Google-native file's content as `export_mime_type`
161    /// (`files.export`). Drive limits export responses to 10 MB — a larger
162    /// document surfaces as an `ApiRequestFailed` from
163    /// [`DriveClient::response_to_error`].
164    pub async fn export(&self, file_id: &str, export_mime_type: &str) -> Result<Vec<u8>> {
165        let url = build_export_url(self.client.base_url(), file_id, export_mime_type)?;
166        self.fetch_bytes(&url).await
167    }
168
169    /// Downloads a non-Google-native file's raw bytes
170    /// (`files.get?alt=media`).
171    pub async fn download(&self, file_id: &str) -> Result<Vec<u8>> {
172        let url = build_download_url(self.client.base_url(), file_id)?;
173        self.fetch_bytes(&url).await
174    }
175
176    /// Renames a file (`files.update` with a `name` body, no `parents`
177    /// change — renaming never affects visibility, see ADR-0070). Requires
178    /// the `drive.metadata` scope (`drive auth login --write`).
179    pub async fn rename(&self, file_id: &str, new_name: &str) -> Result<DriveFile> {
180        let url = build_file_update_url(self.client.base_url(), file_id, None, None)?;
181        let response = self
182            .client
183            .patch_json(url.as_str(), &serde_json::json!({ "name": new_name }))
184            .await?;
185        self.client
186            .parse_response(response, "Failed to parse files.update response")
187            .await
188            .map_err(|err| append_write_scope_hint(err, WriteCapability::Metadata))
189    }
190
191    /// Moves a file between folders (`files.update` with `addParents`/
192    /// `removeParents` query params, comma-separated file ids — Drive v3 has
193    /// no separate move endpoint). Requires the `drive.metadata` scope
194    /// (`drive auth login --write`).
195    pub async fn move_to(
196        &self,
197        file_id: &str,
198        add_parents: &str,
199        remove_parents: &str,
200    ) -> Result<DriveFile> {
201        let url = build_file_update_url(
202            self.client.base_url(),
203            file_id,
204            Some(add_parents),
205            Some(remove_parents),
206        )?;
207        let response = self
208            .client
209            .patch_json(url.as_str(), &serde_json::json!({}))
210            .await?;
211        self.client
212            .parse_response(response, "Failed to parse files.update response")
213            .await
214            .map_err(|err| append_write_scope_hint(err, WriteCapability::Metadata))
215    }
216
217    /// Moves a file to Drive Trash (`files.update` with `trashed: true`) —
218    /// the `drive lease prune` (#1678) mechanism for dropping a
219    /// [`LeaseBackup::DriveCopy`](crate::drive::lease::ledger::LeaseBackup::DriveCopy)
220    /// backup together with the ledger row that points at it. Deliberately
221    /// trash, not permanent delete: this is the integration's first
222    /// delete-adjacent capability, and trashing keeps it reversible (Drive
223    /// Trash, auto-purged after ~30 days, or recoverable by hand before
224    /// then) rather than adding the one irreversible Drive mutation this
225    /// codebase has otherwise never needed. Requires the `drive.metadata`
226    /// scope (`drive auth login --write`) — same as [`Self::rename`], since
227    /// a backup copy is always a file `omni-dev` itself created via
228    /// [`Self::copy`]; uses [`WriteCapability::Metadata`] rather than a
229    /// dedicated variant, since the scope it needs is identical.
230    ///
231    /// Restricted to `crate::drive` — only `crate::drive::lease::prune` may
232    /// call this, never an ungated CLI command directly (same restriction
233    /// [`Self::copy`]/[`Self::create`] carry).
234    pub(in crate::drive) async fn trash(&self, file_id: &str) -> Result<DriveFile> {
235        let url = build_file_update_url(self.client.base_url(), file_id, None, None)?;
236        let response = self
237            .client
238            .patch_json(url.as_str(), &serde_json::json!({ "trashed": true }))
239            .await?;
240        self.client
241            .parse_response(response, "Failed to parse files.update (trash) response")
242            .await
243            .map_err(|err| append_write_scope_hint(err, WriteCapability::Metadata))
244    }
245
246    /// Creates a new file or folder (`files.create`, metadata-only — no
247    /// content). Requires the `drive.file` or `drive` scope (`drive auth
248    /// login --write-file`/`--write-full`).
249    ///
250    /// Restricted to `crate::drive`: every mutating call here must run
251    /// through `write_gate::resolve` first (issue #1574's folder-permission
252    /// gate), and that gate is only ever invoked by the engine modules
253    /// (`crate::drive::{create,upload,content_edit}`), never this façade
254    /// itself. This visibility is the actual enforcement of "no bypass by
255    /// construction" — a caller outside `crate::drive` (a new CLI command, a
256    /// future MCP tool) cannot even compile a direct call to this method; it
257    /// has to go through the gated engine function instead.
258    pub(in crate::drive) async fn create(
259        &self,
260        name: &str,
261        parent_folder_id: &str,
262        mime_type: &str,
263    ) -> Result<DriveFile> {
264        let url = build_file_create_url(self.client.base_url())?;
265        let response = self
266            .client
267            .post_json(
268                url.as_str(),
269                &serde_json::json!({
270                    "name": name,
271                    "mimeType": mime_type,
272                    "parents": [parent_folder_id],
273                }),
274            )
275            .await?;
276        self.client
277            .parse_response(response, "Failed to parse files.create response")
278            .await
279            .map_err(|err| append_write_scope_hint(err, WriteCapability::CreateOrUpload))
280    }
281
282    /// Copies an existing file (`files.copy`) into `parent_folder_id`,
283    /// naming the copy `name` — the native-document backup mechanism for
284    /// the Drive write lease ([ADR-0080](../../docs/adrs/adr-0080.md) §3):
285    /// a Google Sheet/Doc/Slide has no bytes [`FilesApi::download`] can
286    /// read, so a lossless, Drive-side copy is the only backup a native
287    /// document can have. Requires the unrestricted `drive` scope — a copy
288    /// reads a file `omni-dev` did not necessarily create, which
289    /// `drive.file` cannot do.
290    ///
291    /// Restricted to `crate::drive`, for the identical reason
292    /// [`Self::create`]'s doc comment gives: only
293    /// `crate::drive::lease::acquire` may call this, never an ungated CLI
294    /// command directly.
295    pub(in crate::drive) async fn copy(
296        &self,
297        file_id: &str,
298        parent_folder_id: &str,
299        name: &str,
300    ) -> Result<DriveFile> {
301        let url = build_file_copy_url(self.client.base_url(), file_id)?;
302        let response = self
303            .client
304            .post_json(
305                url.as_str(),
306                &serde_json::json!({
307                    "name": name,
308                    "parents": [parent_folder_id],
309                }),
310            )
311            .await?;
312        self.client
313            .parse_response(response, "Failed to parse files.copy response")
314            .await
315            .map_err(|err| append_write_scope_hint(err, WriteCapability::CopyForBackup))
316    }
317
318    /// Uploads `content` as a new file (`files.create` with
319    /// `uploadType=multipart`, Drive's simple upload endpoint — no
320    /// resumable-session support). Requires the `drive.file` or `drive`
321    /// scope (`drive auth login --write-file`/`--write-full`).
322    ///
323    /// Refuses content over [`MAX_UPLOAD_BYTES`] before ever building the
324    /// request body.
325    ///
326    /// Restricted to `crate::drive` — see [`Self::create`]'s doc comment
327    /// for why.
328    pub(in crate::drive) async fn upload(
329        &self,
330        name: &str,
331        parent_folder_id: &str,
332        content: &[u8],
333        content_type: &str,
334    ) -> Result<DriveFile> {
335        check_upload_size(content.len() as u64)?;
336        check_content_type(content_type)?;
337        let boundary = multipart::generate_boundary();
338        let metadata = serde_json::json!({
339            "name": name,
340            "parents": [parent_folder_id],
341        });
342        let body = multipart::build_related_body(&metadata, content, content_type, &boundary);
343        let url = build_file_upload_url(self.client.base_url())?;
344        let response = self
345            .client
346            .post_bytes(
347                url.as_str(),
348                &body,
349                &format!("multipart/related; boundary={boundary}"),
350            )
351            .await?;
352        self.client
353            .parse_response(
354                response,
355                "Failed to parse files.create (multipart) response",
356            )
357            .await
358            .map_err(|err| append_write_scope_hint(err, WriteCapability::CreateOrUpload))
359    }
360
361    /// Replaces an existing file's content (`files.update` with
362    /// `uploadType=media` — content-only, no multipart envelope since
363    /// there's no accompanying metadata change). Requires the `drive.file`
364    /// scope if `omni-dev` created `file_id`, or the unrestricted `drive`
365    /// scope for any pre-existing file.
366    ///
367    /// Refuses content over [`MAX_UPLOAD_BYTES`] before ever sending it.
368    ///
369    /// Restricted to `crate::drive` — see [`Self::create`]'s doc comment
370    /// for why.
371    pub(in crate::drive) async fn edit_content(
372        &self,
373        file_id: &str,
374        content: &[u8],
375        content_type: &str,
376    ) -> Result<DriveFile> {
377        check_upload_size(content.len() as u64)?;
378        check_content_type(content_type)?;
379        let url = build_file_edit_content_url(self.client.base_url(), file_id)?;
380        let response = self
381            .client
382            .patch_bytes(url.as_str(), content, content_type)
383            .await?;
384        self.client
385            .parse_response(response, "Failed to parse files.update (media) response")
386            .await
387            .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
388    }
389
390    /// Shared GET-then-check-status-then-collect-bytes body for
391    /// [`Self::export`]/[`Self::download`] — both go through
392    /// [`DriveClient::get_bytes`], not `get_json`/`get_parsed`.
393    async fn fetch_bytes(&self, url: &Url) -> Result<Vec<u8>> {
394        let response = self.client.get_bytes(url.as_str()).await?;
395        if !response.status().is_success() {
396            return Err(DriveClient::response_to_error(response).await.into());
397        }
398        check_download_size(response.content_length())?;
399        let bytes = response
400            .bytes()
401            .await
402            .context("Failed to read response body")?;
403        Ok(bytes.to_vec())
404    }
405}
406
407/// Refuses a download whose declared `Content-Length` exceeds
408/// [`MAX_DOWNLOAD_BYTES`]. A missing length (e.g. chunked transfer
409/// encoding) is allowed through — there's nothing to check up front in
410/// that case.
411fn check_download_size(content_length: Option<u64>) -> Result<()> {
412    if let Some(len) = content_length {
413        anyhow::ensure!(
414            len <= MAX_DOWNLOAD_BYTES,
415            "refusing to load {len} bytes into memory (limit: {MAX_DOWNLOAD_BYTES} bytes); \
416             this file is too large for `drive read --content`"
417        );
418    }
419    Ok(())
420}
421
422/// Refuses to upload content larger than [`MAX_UPLOAD_BYTES`]. Unlike
423/// [`check_download_size`] (which checks a caller-*reported*
424/// `Content-Length` that might be absent), a caller-supplied local
425/// buffer's length is always known up front, so this takes a plain `u64`,
426/// no `Option`.
427pub(crate) fn check_upload_size(len: u64) -> Result<()> {
428    anyhow::ensure!(
429        len <= MAX_UPLOAD_BYTES,
430        "refusing to upload {len} bytes (limit: {MAX_UPLOAD_BYTES} bytes); Drive's simple \
431         upload endpoint caps requests at 5 MB — larger content needs resumable upload, not \
432         supported by `drive upload`/`drive edit` yet"
433    );
434    Ok(())
435}
436
437/// Refuses a `Content-Type` value containing a CR or LF byte.
438///
439/// [`FilesApi::upload`] splices `content_type` directly into a
440/// hand-assembled `multipart/related` body header line
441/// ([`multipart::build_related_body`]), which bypasses the CRLF rejection
442/// `reqwest`'s own `header()` already applies to a real HTTP header value
443/// (the mechanism protecting [`FilesApi::edit_content`]'s plain
444/// `Content-Type` header) — an unchecked value here could inject an extra
445/// multipart boundary/part into the request Google receives. Applied to
446/// both mutating call sites for a consistent, clearly-worded refusal
447/// rather than relying on two different enforcement mechanisms.
448fn check_content_type(content_type: &str) -> Result<()> {
449    anyhow::ensure!(
450        !content_type.contains(['\r', '\n']),
451        "refusing content type {content_type:?}: must not contain a CR or LF byte"
452    );
453    Ok(())
454}
455
456fn build_files_list_url(
457    base_url: &str,
458    query: Option<&str>,
459    limit: usize,
460    page_token: Option<&str>,
461) -> Result<Url> {
462    let mut url = DriveClient::api_url(base_url, "/drive/v3/files")?;
463    {
464        let mut pairs = url.query_pairs_mut();
465        // Always sent — issue requirement, not opt-in — so shared-drive
466        // files are visible/searchable by default.
467        pairs.append_pair("supportsAllDrives", "true");
468        pairs.append_pair("includeItemsFromAllDrives", "true");
469        pairs.append_pair("fields", LIST_FIELDS);
470        if let Some(q) = query.filter(|q| !q.is_empty()) {
471            pairs.append_pair("q", q);
472        }
473        if limit > 0 {
474            pairs.append_pair("pageSize", &limit.to_string());
475        }
476        if let Some(token) = page_token {
477            pairs.append_pair("pageToken", token);
478        }
479    }
480    Ok(url)
481}
482
483fn build_file_get_url(base_url: &str, file_id: &str) -> Result<Url> {
484    let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}"))?;
485    {
486        let mut pairs = url.query_pairs_mut();
487        pairs.append_pair("fields", GET_FIELDS);
488        pairs.append_pair("supportsAllDrives", "true");
489    }
490    Ok(url)
491}
492
493/// `files.create` URL for [`FilesApi::create`] — metadata-only, `fields`
494/// selects the same response shape `files.get` returns.
495fn build_file_create_url(base_url: &str) -> Result<Url> {
496    let mut url = DriveClient::api_url(base_url, "/drive/v3/files")?;
497    {
498        let mut pairs = url.query_pairs_mut();
499        pairs.append_pair("fields", GET_FIELDS);
500        pairs.append_pair("supportsAllDrives", "true");
501    }
502    Ok(url)
503}
504
505/// `files.copy` URL for [`FilesApi::copy`] — `fields` selects the same
506/// response shape `files.get` returns, so the copy's own metadata
507/// (including its new `id`) comes back in one call.
508fn build_file_copy_url(base_url: &str, file_id: &str) -> Result<Url> {
509    let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}/copy"))?;
510    {
511        let mut pairs = url.query_pairs_mut();
512        pairs.append_pair("fields", GET_FIELDS);
513        pairs.append_pair("supportsAllDrives", "true");
514    }
515    Ok(url)
516}
517
518/// `files.create` URL for [`FilesApi::upload`], on Drive's separate
519/// `/upload/` path prefix (`uploadType=multipart`, Google's simple-upload
520/// endpoint — no resumable-session support here).
521fn build_file_upload_url(base_url: &str) -> Result<Url> {
522    let mut url = DriveClient::api_url(base_url, "/upload/drive/v3/files")?;
523    {
524        let mut pairs = url.query_pairs_mut();
525        pairs.append_pair("uploadType", "multipart");
526        pairs.append_pair("fields", GET_FIELDS);
527        pairs.append_pair("supportsAllDrives", "true");
528    }
529    Ok(url)
530}
531
532/// `files.update` URL for [`FilesApi::edit_content`], on Drive's `/upload/`
533/// path prefix (`uploadType=media` — content-only, no multipart envelope).
534fn build_file_edit_content_url(base_url: &str, file_id: &str) -> Result<Url> {
535    let mut url = DriveClient::api_url(base_url, &format!("/upload/drive/v3/files/{file_id}"))?;
536    {
537        let mut pairs = url.query_pairs_mut();
538        pairs.append_pair("uploadType", "media");
539        pairs.append_pair("fields", GET_FIELDS);
540        pairs.append_pair("supportsAllDrives", "true");
541    }
542    Ok(url)
543}
544
545fn build_export_url(base_url: &str, file_id: &str, mime_type: &str) -> Result<Url> {
546    // `supportsAllDrives` is not a documented `files.export` parameter
547    // (unlike `files.get`/`files.list`) — deliberately omitted rather than
548    // sent speculatively.
549    let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}/export"))?;
550    url.query_pairs_mut().append_pair("mimeType", mime_type);
551    Ok(url)
552}
553
554fn build_download_url(base_url: &str, file_id: &str) -> Result<Url> {
555    let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}"))?;
556    {
557        let mut pairs = url.query_pairs_mut();
558        pairs.append_pair("alt", "media");
559        pairs.append_pair("supportsAllDrives", "true");
560    }
561    Ok(url)
562}
563
564/// `files.update` URL for [`FilesApi::rename`]/[`FilesApi::move_to`].
565/// `add_parents`/`remove_parents` are Drive's `addParents`/`removeParents`
566/// query params (comma-separated file ids) — omitted entirely for a plain
567/// rename, present for a move.
568fn build_file_update_url(
569    base_url: &str,
570    file_id: &str,
571    add_parents: Option<&str>,
572    remove_parents: Option<&str>,
573) -> Result<Url> {
574    let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}"))?;
575    {
576        let mut pairs = url.query_pairs_mut();
577        pairs.append_pair("fields", GET_FIELDS);
578        pairs.append_pair("supportsAllDrives", "true");
579        if let Some(add) = add_parents {
580            pairs.append_pair("addParents", add);
581        }
582        if let Some(remove) = remove_parents {
583            pairs.append_pair("removeParents", remove);
584        }
585    }
586    Ok(url)
587}
588
589/// Which write capability a mutating call needed — parameterizes
590/// [`append_write_scope_hint`] so one function serves every mutating verb's
591/// 403 instead of hardcoding a single hint (issue #1574 generalization of
592/// [ADR-0070](../../docs/adrs/adr-0070.md) §2's rename/move-only hint).
593pub(crate) enum WriteCapability {
594    /// `files.update` on `name`/`parents` (rename/move) or `trashed`
595    /// (`drive lease prune`, #1678) — `drive.metadata`. One variant covers
596    /// all three verbs since they share the identical scope; only the hint
597    /// wording below distinguishes them.
598    Metadata,
599    /// Creating a new file/folder, or uploading new content — `drive.file`
600    /// or `drive`.
601    CreateOrUpload,
602    /// Editing an existing file's content — `drive.file` if `omni-dev`
603    /// created it, `drive` (unrestricted) for any pre-existing file. The
604    /// client has no cheap way to know which a given file id is, so the
605    /// hint names both.
606    EditContent,
607    /// Copying an existing file for a native-document lease backup
608    /// ([ADR-0080](../../docs/adrs/adr-0080.md) §3) — always the
609    /// unrestricted `drive` scope: unlike [`Self::EditContent`], there is
610    /// no app-created-it case to consider, since the lease exists
611    /// precisely to back up files `omni-dev` did not create.
612    CopyForBackup,
613}
614
615/// Appends an actionable hint to a mutating-call failure caused by an
616/// insufficient OAuth scope. No client-side scope pre-check exists (mirrors
617/// Gmail's label-mutation commands): the mutating call is always attempted,
618/// and Google's 403 is made actionable here instead.
619///
620/// Matches **both** spellings Google ships for the same condition:
621/// `insufficientPermissions` in Drive v3's legacy `error.errors[].reason`
622/// envelope, and `PERMISSION_DENIED` in the `google.rpc` `error.status`
623/// envelope newer services use — Sheets v4 among them (issue #1589). Matching
624/// only the legacy string would leave every Sheets 403 hint-less, and would do
625/// so silently: the call still fails, just without telling the operator which
626/// login flag fixes it. See `crate::drive::api_client::error_reason`.
627pub(in crate::drive) fn append_write_scope_hint(
628    err: anyhow::Error,
629    capability: WriteCapability,
630) -> anyhow::Error {
631    let is_insufficient_permissions = matches!(
632        err.downcast_ref::<DriveError>(),
633        Some(DriveError::ApiRequestFailed {
634            reason: Some(reason),
635            ..
636        }) if reason == "insufficientPermissions" || reason == "PERMISSION_DENIED"
637    );
638    if !is_insufficient_permissions {
639        return err;
640    }
641    let hint = match capability {
642        WriteCapability::Metadata => {
643            "Run `omni-dev drive auth login --write` to grant the drive.metadata scope needed \
644             for rename/move/trash"
645        }
646        WriteCapability::CreateOrUpload => {
647            "Run `omni-dev drive auth login --write-file` (or `--write-full`) to grant the \
648             scope needed to create files/folders and upload content"
649        }
650        WriteCapability::EditContent => {
651            "Run `omni-dev drive auth login --write-file` if this file was created by \
652             omni-dev, or `--write-full` to edit any pre-existing file's content, then retry"
653        }
654        WriteCapability::CopyForBackup => {
655            "Run `omni-dev drive auth login --write-full` to grant the unrestricted scope \
656             needed to back up a native document before a leased write, then retry"
657        }
658    };
659    err.context(hint)
660}
661
662/// Clamps a caller-supplied limit to [`HARD_CAP`], treating `0` as "fetch
663/// as many as the cap allows".
664fn effective_cap(limit: usize) -> usize {
665    if limit == 0 {
666        HARD_CAP
667    } else {
668        limit.min(HARD_CAP)
669    }
670}
671
672#[cfg(test)]
673#[allow(clippy::unwrap_used, clippy::expect_used)]
674mod tests {
675    use super::*;
676    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
677    use crate::drive::types::Owner;
678    use crate::utils::secret::Secret;
679
680    fn test_credentials() -> DriveCredentials {
681        DriveCredentials {
682            client_id: "client-1".to_string(),
683            client_secret: Secret::new("secret-1"),
684            refresh_token: Secret::new("refresh-1"),
685            scope: DriveGrantedScopes::READONLY,
686        }
687    }
688
689    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
690        wiremock::Mock::given(wiremock::matchers::method("POST"))
691            .and(wiremock::matchers::path("/token"))
692            .respond_with(
693                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
694                    "access_token": "test-token",
695                    "expires_in": 3600,
696                })),
697            )
698            .mount(server)
699            .await;
700
701        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
702        crate::drive::client::test_support::replace_session(
703            &mut client,
704            &test_credentials(),
705            &format!("{}/token", server.uri()),
706        );
707        client
708    }
709
710    // ── fields= selector coverage ────────────────────────────────────
711    //
712    // LIST_FIELDS/GET_FIELDS are hand-maintained `fields=` selector
713    // strings with no compiler-enforced link to DriveFile/Owner. These
714    // tests construct every field explicitly (no `..Default::default()`),
715    // so adding a struct field forces a compile error here until the test
716    // — and, by extension, the selector strings — are updated to match.
717
718    fn fully_populated_drive_file() -> DriveFile {
719        DriveFile {
720            id: "f1".to_string(),
721            name: "n".to_string(),
722            mime_type: "application/pdf".to_string(),
723            size: Some("1".to_string()),
724            md5_checksum: Some("5d41402abc4b2a76b9719d911017c592".to_string()),
725            sha1_checksum: Some("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d".to_string()),
726            sha256_checksum: Some(
727                "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08".to_string(),
728            ),
729            modified_time: Some("2026-01-01T00:00:00Z".to_string()),
730            parents: vec!["p1".to_string()],
731            web_view_link: Some("https://example.com/view".to_string()),
732            owners: vec![Owner {
733                display_name: Some("Alice".to_string()),
734                email_address: Some("alice@example.com".to_string()),
735            }],
736            drive_id: Some("d1".to_string()),
737            export_links: Some(std::collections::HashMap::from([(
738                "text/markdown".to_string(),
739                "https://export.example.com/md".to_string(),
740            )])),
741            version: Some("1".to_string()),
742        }
743    }
744
745    #[test]
746    fn get_fields_requests_every_drive_file_field() {
747        let json = serde_json::to_value(fully_populated_drive_file()).unwrap();
748        for key in json.as_object().unwrap().keys() {
749            assert!(
750                GET_FIELDS.contains(key.as_str()),
751                "GET_FIELDS is missing `{key}` — add it, or update this test if that's \
752                 deliberate"
753            );
754        }
755    }
756
757    #[test]
758    fn list_fields_requests_every_drive_file_field_except_export_links() {
759        let json = serde_json::to_value(fully_populated_drive_file()).unwrap();
760        for key in json.as_object().unwrap().keys() {
761            if key == "exportLinks" || key == "version" {
762                // Deliberately excluded — see DriveFile::export_links'/version's
763                // own doc comments. `version` is a lease-staleness concern
764                // (ADR-0080 §6), irrelevant to a search-result table and would
765                // bloat every list response.
766                continue;
767            }
768            assert!(
769                LIST_FIELDS.contains(key.as_str()),
770                "LIST_FIELDS is missing `{key}` — add it, or update this test if that's \
771                 deliberate"
772            );
773        }
774    }
775
776    #[test]
777    fn fields_selectors_request_every_owner_field() {
778        let owner = Owner {
779            display_name: Some("Alice".to_string()),
780            email_address: Some("alice@example.com".to_string()),
781        };
782        let json = serde_json::to_value(owner).unwrap();
783        for key in json.as_object().unwrap().keys() {
784            assert!(
785                GET_FIELDS.contains(key.as_str()),
786                "GET_FIELDS' owners() selector is missing `{key}`"
787            );
788            assert!(
789                LIST_FIELDS.contains(key.as_str()),
790                "LIST_FIELDS' owners() selector is missing `{key}`"
791            );
792        }
793    }
794
795    // ── URL builders ─────────────────────────────────────────────────
796
797    #[test]
798    fn build_files_list_url_with_only_provided_filters() {
799        let url = build_files_list_url("https://www.googleapis.com", None, 0, None).unwrap();
800        assert!(url.as_str().contains("supportsAllDrives=true"));
801        assert!(url.as_str().contains("includeItemsFromAllDrives=true"));
802        assert!(url.as_str().contains("fields="));
803        assert!(!url.as_str().contains("q="));
804        assert!(!url.as_str().contains("pageSize="));
805        assert!(!url.as_str().contains("pageToken="));
806    }
807
808    #[test]
809    fn build_files_list_url_with_full_filter_set() {
810        let url = build_files_list_url(
811            "https://www.googleapis.com",
812            Some("name contains 'x'"),
813            10,
814            Some("token1"),
815        )
816        .unwrap();
817        assert!(url.as_str().contains("supportsAllDrives=true"));
818        assert!(url.as_str().contains("includeItemsFromAllDrives=true"));
819        assert!(url.as_str().contains("q=name"));
820        assert!(url.as_str().contains("pageSize=10"));
821        assert!(url.as_str().contains("pageToken=token1"));
822    }
823
824    #[test]
825    fn build_files_list_url_rejects_invalid_base_url() {
826        let err = build_files_list_url("not a url", None, 0, None).unwrap_err();
827        assert!(err.to_string().contains("Invalid Drive base URL"));
828    }
829
830    #[test]
831    fn build_file_get_url_includes_fields_and_supports_all_drives() {
832        let url = build_file_get_url("https://www.googleapis.com", "f1").unwrap();
833        assert!(url.as_str().contains("/drive/v3/files/f1"));
834        assert!(url.as_str().contains("fields="));
835        assert!(url.as_str().contains("supportsAllDrives=true"));
836    }
837
838    #[test]
839    fn build_file_create_url_includes_fields_and_supports_all_drives() {
840        let url = build_file_create_url("https://www.googleapis.com").unwrap();
841        assert!(url.path().ends_with("/drive/v3/files"));
842        assert!(url.as_str().contains("fields="));
843        assert!(url.as_str().contains("supportsAllDrives=true"));
844    }
845
846    #[test]
847    fn build_export_url_includes_mime_type_and_omits_supports_all_drives() {
848        let url = build_export_url("https://www.googleapis.com", "f1", "text/markdown").unwrap();
849        assert!(url.as_str().contains("/drive/v3/files/f1/export"));
850        assert!(url.as_str().contains("mimeType=text%2Fmarkdown"));
851        assert!(!url.as_str().contains("supportsAllDrives"));
852    }
853
854    #[test]
855    fn build_download_url_includes_alt_media_and_supports_all_drives() {
856        let url = build_download_url("https://www.googleapis.com", "f1").unwrap();
857        assert!(url.as_str().contains("/drive/v3/files/f1"));
858        assert!(url.as_str().contains("alt=media"));
859        assert!(url.as_str().contains("supportsAllDrives=true"));
860    }
861
862    // ── search ───────────────────────────────────────────────────────
863
864    #[tokio::test]
865    async fn search_rejects_limit_above_max_page_limit_client_side() {
866        let server = wiremock::MockServer::start().await;
867        let client = client_with_bootstrapped_token(&server).await;
868        let err = FilesApi::new(&client)
869            .search(None, MAX_PAGE_LIMIT + 1, None)
870            .await
871            .unwrap_err();
872        assert!(err.to_string().contains("must be <="));
873    }
874
875    #[tokio::test]
876    async fn search_propagates_api_errors() {
877        let server = wiremock::MockServer::start().await;
878        let client = client_with_bootstrapped_token(&server).await;
879        wiremock::Mock::given(wiremock::matchers::method("GET"))
880            .and(wiremock::matchers::path("/drive/v3/files"))
881            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
882            .mount(&server)
883            .await;
884
885        let err = FilesApi::new(&client)
886            .search(None, 10, None)
887            .await
888            .unwrap_err();
889        assert!(err.to_string().contains("500"));
890    }
891
892    #[tokio::test]
893    async fn search_errors_on_malformed_response() {
894        let server = wiremock::MockServer::start().await;
895        let client = client_with_bootstrapped_token(&server).await;
896        wiremock::Mock::given(wiremock::matchers::method("GET"))
897            .and(wiremock::matchers::path("/drive/v3/files"))
898            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
899            .mount(&server)
900            .await;
901
902        let err = FilesApi::new(&client)
903            .search(None, 10, None)
904            .await
905            .unwrap_err();
906        assert!(err.to_string().contains("Failed to parse"));
907    }
908
909    // ── search_all ───────────────────────────────────────────────────
910
911    #[tokio::test]
912    async fn search_all_single_page_when_no_next_token() {
913        let server = wiremock::MockServer::start().await;
914        let client = client_with_bootstrapped_token(&server).await;
915        wiremock::Mock::given(wiremock::matchers::method("GET"))
916            .and(wiremock::matchers::path("/drive/v3/files"))
917            .respond_with(
918                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
919                    "files": [{"id": "f1", "name": "a"}],
920                })),
921            )
922            .expect(1)
923            .mount(&server)
924            .await;
925
926        let result = FilesApi::new(&client).search_all(None, 10).await.unwrap();
927        assert_eq!(result.files.len(), 1);
928    }
929
930    #[tokio::test]
931    async fn search_all_follows_next_page_token_to_exhaustion() {
932        let server = wiremock::MockServer::start().await;
933        let client = client_with_bootstrapped_token(&server).await;
934        wiremock::Mock::given(wiremock::matchers::method("GET"))
935            .and(wiremock::matchers::path("/drive/v3/files"))
936            .and(wiremock::matchers::query_param_is_missing("pageToken"))
937            .respond_with(
938                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
939                    "files": [{"id": "f1", "name": "a"}],
940                    "nextPageToken": "page2",
941                })),
942            )
943            .mount(&server)
944            .await;
945        wiremock::Mock::given(wiremock::matchers::method("GET"))
946            .and(wiremock::matchers::path("/drive/v3/files"))
947            .and(wiremock::matchers::query_param("pageToken", "page2"))
948            .respond_with(
949                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
950                    "files": [{"id": "f2", "name": "b"}],
951                })),
952            )
953            .mount(&server)
954            .await;
955
956        let result = FilesApi::new(&client).search_all(None, 0).await.unwrap();
957        assert_eq!(result.files.len(), 2);
958        assert_eq!(result.files[0].id, "f1");
959        assert_eq!(result.files[1].id, "f2");
960    }
961
962    #[tokio::test]
963    async fn search_all_stops_at_explicit_limit() {
964        let server = wiremock::MockServer::start().await;
965        let client = client_with_bootstrapped_token(&server).await;
966        wiremock::Mock::given(wiremock::matchers::method("GET"))
967            .and(wiremock::matchers::path("/drive/v3/files"))
968            .respond_with(
969                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
970                    "files": [
971                        {"id": "f1", "name": "a"},
972                        {"id": "f2", "name": "b"},
973                        {"id": "f3", "name": "c"},
974                    ],
975                    "nextPageToken": "page2",
976                    "incompleteSearch": true,
977                })),
978            )
979            .mount(&server)
980            .await;
981
982        let result = FilesApi::new(&client).search_all(None, 2).await.unwrap();
983        assert_eq!(result.files.len(), 2);
984        // Truncation discarded fetched-but-unreturned files, so the cursor
985        // fields must be cleared rather than pointing past them (#1536).
986        assert_eq!(result.next_page_token, None);
987        assert_eq!(result.incomplete_search, None);
988    }
989
990    #[tokio::test]
991    async fn search_all_preserves_next_page_token_at_exact_cap() {
992        let server = wiremock::MockServer::start().await;
993        let client = client_with_bootstrapped_token(&server).await;
994        wiremock::Mock::given(wiremock::matchers::method("GET"))
995            .and(wiremock::matchers::path("/drive/v3/files"))
996            .respond_with(
997                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
998                    "files": [
999                        {"id": "f1", "name": "a"},
1000                        {"id": "f2", "name": "b"},
1001                        {"id": "f3", "name": "c"},
1002                    ],
1003                    "nextPageToken": "page2",
1004                    "incompleteSearch": true,
1005                })),
1006            )
1007            .mount(&server)
1008            .await;
1009
1010        let result = FilesApi::new(&client).search_all(None, 3).await.unwrap();
1011        assert_eq!(result.files.len(), 3);
1012        // No files were discarded, so the cursor is still accurate and
1013        // must be preserved, not cleared.
1014        assert_eq!(result.next_page_token.as_deref(), Some("page2"));
1015        assert_eq!(result.incomplete_search, Some(true));
1016    }
1017
1018    #[tokio::test]
1019    async fn search_all_continues_past_empty_page_with_a_valid_next_page_token() {
1020        let server = wiremock::MockServer::start().await;
1021        let client = client_with_bootstrapped_token(&server).await;
1022        wiremock::Mock::given(wiremock::matchers::method("GET"))
1023            .and(wiremock::matchers::path("/drive/v3/files"))
1024            .and(wiremock::matchers::query_param_is_missing("pageToken"))
1025            .respond_with(
1026                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1027                    "files": [],
1028                    "nextPageToken": "page2",
1029                })),
1030            )
1031            .mount(&server)
1032            .await;
1033        wiremock::Mock::given(wiremock::matchers::method("GET"))
1034            .and(wiremock::matchers::path("/drive/v3/files"))
1035            .and(wiremock::matchers::query_param("pageToken", "page2"))
1036            .respond_with(
1037                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1038                    "files": [{"id": "f1", "name": "a"}],
1039                })),
1040            )
1041            .mount(&server)
1042            .await;
1043
1044        let result = FilesApi::new(&client).search_all(None, 0).await.unwrap();
1045        assert_eq!(result.files.len(), 1);
1046    }
1047
1048    #[tokio::test]
1049    async fn search_all_truncates_to_hard_cap() {
1050        assert_eq!(effective_cap(0), HARD_CAP);
1051    }
1052
1053    // ── get_metadata ─────────────────────────────────────────────────
1054
1055    #[tokio::test]
1056    async fn get_metadata_sends_fields_query_param() {
1057        let server = wiremock::MockServer::start().await;
1058        let client = client_with_bootstrapped_token(&server).await;
1059        wiremock::Mock::given(wiremock::matchers::method("GET"))
1060            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1061            .and(wiremock::matchers::query_param("fields", GET_FIELDS))
1062            .respond_with(
1063                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1064                    "id": "f1", "name": "a",
1065                })),
1066            )
1067            .expect(1)
1068            .mount(&server)
1069            .await;
1070
1071        let file = FilesApi::new(&client).get_metadata("f1").await.unwrap();
1072        assert_eq!(file.id, "f1");
1073    }
1074
1075    #[tokio::test]
1076    async fn get_metadata_parses_export_links() {
1077        let server = wiremock::MockServer::start().await;
1078        let client = client_with_bootstrapped_token(&server).await;
1079        wiremock::Mock::given(wiremock::matchers::method("GET"))
1080            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1081            .respond_with(
1082                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1083                    "id": "f1",
1084                    "name": "doc",
1085                    "mimeType": "application/vnd.google-apps.document",
1086                    "exportLinks": {"text/markdown": "https://export.example/md"},
1087                })),
1088            )
1089            .mount(&server)
1090            .await;
1091
1092        let file = FilesApi::new(&client).get_metadata("f1").await.unwrap();
1093        assert!(file.export_links.unwrap().contains_key("text/markdown"));
1094    }
1095
1096    #[tokio::test]
1097    async fn get_metadata_propagates_api_errors() {
1098        let server = wiremock::MockServer::start().await;
1099        let client = client_with_bootstrapped_token(&server).await;
1100        wiremock::Mock::given(wiremock::matchers::method("GET"))
1101            .and(wiremock::matchers::path("/drive/v3/files/missing"))
1102            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1103            .mount(&server)
1104            .await;
1105
1106        let err = FilesApi::new(&client)
1107            .get_metadata("missing")
1108            .await
1109            .unwrap_err();
1110        assert!(err.to_string().contains("404"));
1111    }
1112
1113    // ── export / download ────────────────────────────────────────────
1114
1115    #[tokio::test]
1116    async fn export_sends_mime_type_query_param_and_returns_bytes() {
1117        let server = wiremock::MockServer::start().await;
1118        let client = client_with_bootstrapped_token(&server).await;
1119        wiremock::Mock::given(wiremock::matchers::method("GET"))
1120            .and(wiremock::matchers::path("/drive/v3/files/f1/export"))
1121            .and(wiremock::matchers::query_param("mimeType", "text/markdown"))
1122            .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"# Title".to_vec()))
1123            .expect(1)
1124            .mount(&server)
1125            .await;
1126
1127        let bytes = FilesApi::new(&client)
1128            .export("f1", "text/markdown")
1129            .await
1130            .unwrap();
1131        assert_eq!(bytes, b"# Title");
1132    }
1133
1134    #[tokio::test]
1135    async fn export_propagates_api_errors() {
1136        let server = wiremock::MockServer::start().await;
1137        let client = client_with_bootstrapped_token(&server).await;
1138        wiremock::Mock::given(wiremock::matchers::method("GET"))
1139            .and(wiremock::matchers::path("/drive/v3/files/f1/export"))
1140            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1141            .mount(&server)
1142            .await;
1143
1144        let err = FilesApi::new(&client)
1145            .export("f1", "text/markdown")
1146            .await
1147            .unwrap_err();
1148        assert!(err.to_string().contains("404"));
1149    }
1150
1151    #[tokio::test]
1152    async fn download_sends_alt_media_and_returns_bytes() {
1153        let server = wiremock::MockServer::start().await;
1154        let client = client_with_bootstrapped_token(&server).await;
1155        wiremock::Mock::given(wiremock::matchers::method("GET"))
1156            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1157            .and(wiremock::matchers::query_param("alt", "media"))
1158            .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"binary".to_vec()))
1159            .expect(1)
1160            .mount(&server)
1161            .await;
1162
1163        let bytes = FilesApi::new(&client).download("f1").await.unwrap();
1164        assert_eq!(bytes, b"binary");
1165    }
1166
1167    #[tokio::test]
1168    async fn download_propagates_api_errors() {
1169        let server = wiremock::MockServer::start().await;
1170        let client = client_with_bootstrapped_token(&server).await;
1171        wiremock::Mock::given(wiremock::matchers::method("GET"))
1172            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1173            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1174            .mount(&server)
1175            .await;
1176
1177        let err = FilesApi::new(&client).download("f1").await.unwrap_err();
1178        assert!(err.to_string().contains("500"));
1179    }
1180
1181    // ── rename / move_to ─────────────────────────────────────────────
1182
1183    #[tokio::test]
1184    async fn rename_sends_name_body_and_no_parents_params() {
1185        let server = wiremock::MockServer::start().await;
1186        let client = client_with_bootstrapped_token(&server).await;
1187        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1188            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1189            .and(wiremock::matchers::query_param("fields", GET_FIELDS))
1190            .and(wiremock::matchers::body_json(
1191                serde_json::json!({"name": "New Name"}),
1192            ))
1193            .respond_with(
1194                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1195                    "id": "f1", "name": "New Name",
1196                })),
1197            )
1198            .expect(1)
1199            .mount(&server)
1200            .await;
1201
1202        let file = FilesApi::new(&client)
1203            .rename("f1", "New Name")
1204            .await
1205            .unwrap();
1206        assert_eq!(file.name, "New Name");
1207
1208        let requests = server.received_requests().await.unwrap();
1209        let req = requests
1210            .iter()
1211            .find(|r| r.method.as_str() == "PATCH")
1212            .unwrap();
1213        assert!(req.url.query_pairs().all(|(k, _)| k != "addParents"));
1214        assert!(req.url.query_pairs().all(|(k, _)| k != "removeParents"));
1215    }
1216
1217    #[tokio::test]
1218    async fn rename_propagates_api_errors() {
1219        let server = wiremock::MockServer::start().await;
1220        let client = client_with_bootstrapped_token(&server).await;
1221        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1222            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1223            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1224            .mount(&server)
1225            .await;
1226
1227        let err = FilesApi::new(&client)
1228            .rename("f1", "New Name")
1229            .await
1230            .unwrap_err();
1231        assert!(err.to_string().contains("404"));
1232    }
1233
1234    #[tokio::test]
1235    async fn rename_appends_write_scope_hint_on_insufficient_permissions() {
1236        let server = wiremock::MockServer::start().await;
1237        let client = client_with_bootstrapped_token(&server).await;
1238        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1239            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1240            .respond_with(
1241                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1242                    "error": {
1243                        "message": "Insufficient Permission",
1244                        "errors": [{"reason": "insufficientPermissions"}],
1245                    }
1246                })),
1247            )
1248            .mount(&server)
1249            .await;
1250
1251        let err = FilesApi::new(&client)
1252            .rename("f1", "New Name")
1253            .await
1254            .unwrap_err();
1255        assert!(
1256            err.to_string().contains("drive auth login --write"),
1257            "{err}"
1258        );
1259    }
1260
1261    #[tokio::test]
1262    async fn move_to_sends_add_and_remove_parents_query_params() {
1263        let server = wiremock::MockServer::start().await;
1264        let client = client_with_bootstrapped_token(&server).await;
1265        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1266            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1267            .and(wiremock::matchers::query_param("addParents", "dest"))
1268            .and(wiremock::matchers::query_param("removeParents", "src"))
1269            .and(wiremock::matchers::body_json(serde_json::json!({})))
1270            .respond_with(
1271                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1272                    "id": "f1", "name": "a", "parents": ["dest"],
1273                })),
1274            )
1275            .expect(1)
1276            .mount(&server)
1277            .await;
1278
1279        let file = FilesApi::new(&client)
1280            .move_to("f1", "dest", "src")
1281            .await
1282            .unwrap();
1283        assert_eq!(file.parents, vec!["dest".to_string()]);
1284    }
1285
1286    #[tokio::test]
1287    async fn move_to_propagates_api_errors() {
1288        let server = wiremock::MockServer::start().await;
1289        let client = client_with_bootstrapped_token(&server).await;
1290        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1291            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1292            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1293            .mount(&server)
1294            .await;
1295
1296        let err = FilesApi::new(&client)
1297            .move_to("f1", "dest", "src")
1298            .await
1299            .unwrap_err();
1300        assert!(err.to_string().contains("500"));
1301    }
1302
1303    #[tokio::test]
1304    async fn move_to_appends_write_scope_hint_on_insufficient_permissions() {
1305        let server = wiremock::MockServer::start().await;
1306        let client = client_with_bootstrapped_token(&server).await;
1307        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1308            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1309            .respond_with(
1310                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1311                    "error": {
1312                        "message": "Insufficient Permission",
1313                        "errors": [{"reason": "insufficientPermissions"}],
1314                    }
1315                })),
1316            )
1317            .mount(&server)
1318            .await;
1319
1320        let err = FilesApi::new(&client)
1321            .move_to("f1", "dest", "src")
1322            .await
1323            .unwrap_err();
1324        assert!(
1325            err.to_string().contains("drive auth login --write"),
1326            "{err}"
1327        );
1328    }
1329
1330    // ── trash ────────────────────────────────────────────────────────
1331
1332    #[tokio::test]
1333    async fn trash_sends_a_trashed_true_body() {
1334        let server = wiremock::MockServer::start().await;
1335        let client = client_with_bootstrapped_token(&server).await;
1336        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1337            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1338            .and(wiremock::matchers::body_json(
1339                serde_json::json!({"trashed": true}),
1340            ))
1341            .respond_with(
1342                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1343                    "id": "f1", "name": "backup", "trashed": true,
1344                })),
1345            )
1346            .expect(1)
1347            .mount(&server)
1348            .await;
1349
1350        let file = FilesApi::new(&client).trash("f1").await.unwrap();
1351        assert_eq!(file.id, "f1");
1352    }
1353
1354    #[tokio::test]
1355    async fn trash_propagates_api_errors() {
1356        let server = wiremock::MockServer::start().await;
1357        let client = client_with_bootstrapped_token(&server).await;
1358        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1359            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1360            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1361            .mount(&server)
1362            .await;
1363
1364        let err = FilesApi::new(&client).trash("f1").await.unwrap_err();
1365        assert!(err.to_string().contains("404"));
1366    }
1367
1368    #[tokio::test]
1369    async fn trash_appends_write_scope_hint_on_insufficient_permissions() {
1370        let server = wiremock::MockServer::start().await;
1371        let client = client_with_bootstrapped_token(&server).await;
1372        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1373            .and(wiremock::matchers::path("/drive/v3/files/f1"))
1374            .respond_with(
1375                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1376                    "error": {
1377                        "message": "Insufficient Permission",
1378                        "errors": [{"reason": "insufficientPermissions"}],
1379                    }
1380                })),
1381            )
1382            .mount(&server)
1383            .await;
1384
1385        let err = FilesApi::new(&client).trash("f1").await.unwrap_err();
1386        assert!(
1387            err.to_string().contains("drive auth login --write"),
1388            "{err}"
1389        );
1390    }
1391
1392    // ── create ──────────────────────────────────────────────────────
1393
1394    #[tokio::test]
1395    async fn create_sends_name_mime_type_and_parents() {
1396        let server = wiremock::MockServer::start().await;
1397        let client = client_with_bootstrapped_token(&server).await;
1398        wiremock::Mock::given(wiremock::matchers::method("POST"))
1399            .and(wiremock::matchers::path("/drive/v3/files"))
1400            .and(wiremock::matchers::query_param("fields", GET_FIELDS))
1401            .and(wiremock::matchers::body_json(serde_json::json!({
1402                "name": "New File",
1403                "mimeType": "text/plain",
1404                "parents": ["parent-1"],
1405            })))
1406            .respond_with(
1407                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1408                    "id": "f1", "name": "New File", "mimeType": "text/plain",
1409                })),
1410            )
1411            .expect(1)
1412            .mount(&server)
1413            .await;
1414
1415        let file = FilesApi::new(&client)
1416            .create("New File", "parent-1", "text/plain")
1417            .await
1418            .unwrap();
1419        assert_eq!(file.id, "f1");
1420        assert_eq!(file.name, "New File");
1421    }
1422
1423    #[tokio::test]
1424    async fn create_propagates_api_errors() {
1425        let server = wiremock::MockServer::start().await;
1426        let client = client_with_bootstrapped_token(&server).await;
1427        wiremock::Mock::given(wiremock::matchers::method("POST"))
1428            .and(wiremock::matchers::path("/drive/v3/files"))
1429            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1430            .mount(&server)
1431            .await;
1432
1433        let err = FilesApi::new(&client)
1434            .create("New File", "parent-1", "text/plain")
1435            .await
1436            .unwrap_err();
1437        assert!(err.to_string().contains("404"));
1438    }
1439
1440    #[tokio::test]
1441    async fn create_appends_write_scope_hint_on_insufficient_permissions() {
1442        let server = wiremock::MockServer::start().await;
1443        let client = client_with_bootstrapped_token(&server).await;
1444        wiremock::Mock::given(wiremock::matchers::method("POST"))
1445            .and(wiremock::matchers::path("/drive/v3/files"))
1446            .respond_with(
1447                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1448                    "error": {
1449                        "message": "Insufficient Permission",
1450                        "errors": [{"reason": "insufficientPermissions"}],
1451                    }
1452                })),
1453            )
1454            .mount(&server)
1455            .await;
1456
1457        let err = FilesApi::new(&client)
1458            .create("New File", "parent-1", "text/plain")
1459            .await
1460            .unwrap_err();
1461        assert!(err.to_string().contains("--write-file"), "{err}");
1462        assert!(err.to_string().contains("--write-full"), "{err}");
1463    }
1464
1465    // ── upload ──────────────────────────────────────────────────────
1466
1467    #[tokio::test]
1468    async fn upload_sends_multipart_related_content_type_and_returns_file() {
1469        let server = wiremock::MockServer::start().await;
1470        let client = client_with_bootstrapped_token(&server).await;
1471        wiremock::Mock::given(wiremock::matchers::method("POST"))
1472            .and(wiremock::matchers::path("/upload/drive/v3/files"))
1473            .and(wiremock::matchers::query_param("uploadType", "multipart"))
1474            .and(wiremock::matchers::header_regex(
1475                "content-type",
1476                "^multipart/related; boundary=omnidev-",
1477            ))
1478            .respond_with(
1479                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1480                    "id": "new-upload-1", "name": "photo.jpg",
1481                })),
1482            )
1483            .expect(1)
1484            .mount(&server)
1485            .await;
1486
1487        let file = FilesApi::new(&client)
1488            .upload("photo.jpg", "parent-1", b"JPEGDATA", "image/jpeg")
1489            .await
1490            .unwrap();
1491        assert_eq!(file.id, "new-upload-1");
1492    }
1493
1494    #[tokio::test]
1495    async fn upload_refuses_oversized_content_before_any_network_call() {
1496        let server = wiremock::MockServer::start().await;
1497        let client = client_with_bootstrapped_token(&server).await;
1498        // Deliberately no mock mounted — an oversized upload must be
1499        // refused before ever building/sending the request.
1500        let oversized = vec![0u8; (MAX_UPLOAD_BYTES + 1) as usize];
1501
1502        let err = FilesApi::new(&client)
1503            .upload(
1504                "big.bin",
1505                "parent-1",
1506                &oversized,
1507                "application/octet-stream",
1508            )
1509            .await
1510            .unwrap_err();
1511        assert!(err.to_string().contains("refusing to upload"), "{err}");
1512    }
1513
1514    #[tokio::test]
1515    async fn upload_refuses_content_type_containing_crlf_before_any_network_call() {
1516        let server = wiremock::MockServer::start().await;
1517        let client = client_with_bootstrapped_token(&server).await;
1518        // Deliberately no mock mounted — a CRLF in content_type must be
1519        // refused before ever building the multipart body, where it would
1520        // otherwise splice raw bytes into Drive's request.
1521        let err = FilesApi::new(&client)
1522            .upload(
1523                "f.txt",
1524                "parent-1",
1525                b"content",
1526                "text/plain\r\n--boundary\r\nX-Injected: yes",
1527            )
1528            .await
1529            .unwrap_err();
1530        assert!(err.to_string().contains("refusing content type"), "{err}");
1531    }
1532
1533    #[tokio::test]
1534    async fn upload_propagates_api_errors() {
1535        let server = wiremock::MockServer::start().await;
1536        let client = client_with_bootstrapped_token(&server).await;
1537        wiremock::Mock::given(wiremock::matchers::method("POST"))
1538            .and(wiremock::matchers::path("/upload/drive/v3/files"))
1539            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1540            .mount(&server)
1541            .await;
1542
1543        let err = FilesApi::new(&client)
1544            .upload("f.txt", "parent-1", b"content", "text/plain")
1545            .await
1546            .unwrap_err();
1547        assert!(err.to_string().contains("404"));
1548    }
1549
1550    #[tokio::test]
1551    async fn upload_appends_write_scope_hint_on_insufficient_permissions() {
1552        let server = wiremock::MockServer::start().await;
1553        let client = client_with_bootstrapped_token(&server).await;
1554        wiremock::Mock::given(wiremock::matchers::method("POST"))
1555            .and(wiremock::matchers::path("/upload/drive/v3/files"))
1556            .respond_with(
1557                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1558                    "error": {
1559                        "message": "Insufficient Permission",
1560                        "errors": [{"reason": "insufficientPermissions"}],
1561                    }
1562                })),
1563            )
1564            .mount(&server)
1565            .await;
1566
1567        let err = FilesApi::new(&client)
1568            .upload("f.txt", "parent-1", b"content", "text/plain")
1569            .await
1570            .unwrap_err();
1571        assert!(err.to_string().contains("--write-file"), "{err}");
1572        assert!(err.to_string().contains("--write-full"), "{err}");
1573    }
1574
1575    // ── edit_content ────────────────────────────────────────────────
1576
1577    #[tokio::test]
1578    async fn edit_content_sends_media_upload_type_and_returns_file() {
1579        let server = wiremock::MockServer::start().await;
1580        let client = client_with_bootstrapped_token(&server).await;
1581        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1582            .and(wiremock::matchers::path("/upload/drive/v3/files/f1"))
1583            .and(wiremock::matchers::query_param("uploadType", "media"))
1584            .and(wiremock::matchers::header("content-type", "text/plain"))
1585            .and(wiremock::matchers::body_bytes(b"new content".to_vec()))
1586            .respond_with(
1587                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1588                    "id": "f1", "name": "existing.txt",
1589                })),
1590            )
1591            .expect(1)
1592            .mount(&server)
1593            .await;
1594
1595        let file = FilesApi::new(&client)
1596            .edit_content("f1", b"new content", "text/plain")
1597            .await
1598            .unwrap();
1599        assert_eq!(file.id, "f1");
1600    }
1601
1602    #[tokio::test]
1603    async fn edit_content_refuses_oversized_content_before_any_network_call() {
1604        let server = wiremock::MockServer::start().await;
1605        let client = client_with_bootstrapped_token(&server).await;
1606        let oversized = vec![0u8; (MAX_UPLOAD_BYTES + 1) as usize];
1607
1608        let err = FilesApi::new(&client)
1609            .edit_content("f1", &oversized, "application/octet-stream")
1610            .await
1611            .unwrap_err();
1612        assert!(err.to_string().contains("refusing to upload"), "{err}");
1613    }
1614
1615    #[tokio::test]
1616    async fn edit_content_refuses_content_type_containing_crlf_before_any_network_call() {
1617        let server = wiremock::MockServer::start().await;
1618        let client = client_with_bootstrapped_token(&server).await;
1619        // Deliberately no mock mounted.
1620
1621        let err = FilesApi::new(&client)
1622            .edit_content("f1", b"content", "text/plain\r\nX-Injected: yes")
1623            .await
1624            .unwrap_err();
1625        assert!(err.to_string().contains("refusing content type"), "{err}");
1626    }
1627
1628    #[tokio::test]
1629    async fn edit_content_propagates_api_errors() {
1630        let server = wiremock::MockServer::start().await;
1631        let client = client_with_bootstrapped_token(&server).await;
1632        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1633            .and(wiremock::matchers::path("/upload/drive/v3/files/f1"))
1634            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1635            .mount(&server)
1636            .await;
1637
1638        let err = FilesApi::new(&client)
1639            .edit_content("f1", b"content", "text/plain")
1640            .await
1641            .unwrap_err();
1642        assert!(err.to_string().contains("404"));
1643    }
1644
1645    #[tokio::test]
1646    async fn edit_content_appends_write_scope_hint_on_insufficient_permissions() {
1647        let server = wiremock::MockServer::start().await;
1648        let client = client_with_bootstrapped_token(&server).await;
1649        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1650            .and(wiremock::matchers::path("/upload/drive/v3/files/f1"))
1651            .respond_with(
1652                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1653                    "error": {
1654                        "message": "Insufficient Permission",
1655                        "errors": [{"reason": "insufficientPermissions"}],
1656                    }
1657                })),
1658            )
1659            .mount(&server)
1660            .await;
1661
1662        let err = FilesApi::new(&client)
1663            .edit_content("f1", b"content", "text/plain")
1664            .await
1665            .unwrap_err();
1666        assert!(err.to_string().contains("--write-file"), "{err}");
1667        assert!(err.to_string().contains("--write-full"), "{err}");
1668    }
1669
1670    #[test]
1671    fn build_file_edit_content_url_includes_upload_type_fields_and_supports_all_drives() {
1672        let url = build_file_edit_content_url("https://www.googleapis.com", "f1").unwrap();
1673        assert!(url.path().ends_with("/upload/drive/v3/files/f1"));
1674        assert!(url.as_str().contains("uploadType=media"));
1675        assert!(url.as_str().contains("fields="));
1676        assert!(url.as_str().contains("supportsAllDrives=true"));
1677    }
1678
1679    #[test]
1680    fn append_write_scope_hint_leaves_other_errors_unchanged() {
1681        let err = anyhow::anyhow!("some other failure");
1682        let msg = append_write_scope_hint(err, WriteCapability::Metadata).to_string();
1683        assert_eq!(msg, "some other failure");
1684    }
1685
1686    fn insufficient_permissions_error() -> anyhow::Error {
1687        DriveError::ApiRequestFailed {
1688            api: "Drive",
1689            status: 403,
1690            body: String::new(),
1691            reason: Some("insufficientPermissions".to_string()),
1692        }
1693        .into()
1694    }
1695
1696    /// The same condition as [`insufficient_permissions_error`], spelled the
1697    /// way the `google.rpc` envelope spells it — what Sheets v4 returns.
1698    fn permission_denied_error() -> anyhow::Error {
1699        DriveError::ApiRequestFailed {
1700            api: "Drive",
1701            status: 403,
1702            body: String::new(),
1703            reason: Some("PERMISSION_DENIED".to_string()),
1704        }
1705        .into()
1706    }
1707
1708    #[test]
1709    fn append_write_scope_hint_also_matches_the_google_rpc_permission_denied_spelling() {
1710        // Regression guard for issue #1589: Sheets v4 has no
1711        // `error.errors[].reason`, so a hint keyed only on
1712        // "insufficientPermissions" would never fire for it — and would fail
1713        // silently, leaving the operator a bare 403 with no flag to run.
1714        let msg = append_write_scope_hint(permission_denied_error(), WriteCapability::EditContent)
1715            .to_string();
1716        assert!(msg.contains("--write-file"), "{msg}");
1717        assert!(msg.contains("--write-full"), "{msg}");
1718    }
1719
1720    #[test]
1721    fn append_write_scope_hint_ignores_an_unrelated_reason_code() {
1722        let err: anyhow::Error = DriveError::ApiRequestFailed {
1723            api: "Drive",
1724            status: 404,
1725            body: "gone".to_string(),
1726            reason: Some("notFound".to_string()),
1727        }
1728        .into();
1729        let msg = append_write_scope_hint(err, WriteCapability::EditContent).to_string();
1730        assert!(!msg.contains("--write-file"), "{msg}");
1731    }
1732
1733    #[test]
1734    fn append_write_scope_hint_metadata_names_write_flag() {
1735        let msg =
1736            append_write_scope_hint(insufficient_permissions_error(), WriteCapability::Metadata)
1737                .to_string();
1738        assert!(msg.contains("--write"), "{msg}");
1739        assert!(msg.contains("rename/move/trash"), "{msg}");
1740    }
1741
1742    #[test]
1743    fn append_write_scope_hint_create_or_upload_names_write_file_flag() {
1744        let msg = append_write_scope_hint(
1745            insufficient_permissions_error(),
1746            WriteCapability::CreateOrUpload,
1747        )
1748        .to_string();
1749        assert!(msg.contains("--write-file"), "{msg}");
1750        assert!(msg.contains("--write-full"), "{msg}");
1751    }
1752
1753    #[test]
1754    fn append_write_scope_hint_edit_content_names_both_flags() {
1755        let msg = append_write_scope_hint(
1756            insufficient_permissions_error(),
1757            WriteCapability::EditContent,
1758        )
1759        .to_string();
1760        assert!(msg.contains("--write-file"), "{msg}");
1761        assert!(msg.contains("--write-full"), "{msg}");
1762    }
1763
1764    #[test]
1765    fn append_write_scope_hint_copy_for_backup_names_write_full_only() {
1766        let msg = append_write_scope_hint(
1767            insufficient_permissions_error(),
1768            WriteCapability::CopyForBackup,
1769        )
1770        .to_string();
1771        assert!(msg.contains("--write-full"), "{msg}");
1772        assert!(msg.contains("back up a native document"), "{msg}");
1773        assert!(!msg.contains("--write-file"), "{msg}");
1774    }
1775
1776    // ── check_download_size ─────────────────────────────────────────
1777
1778    #[test]
1779    fn check_download_size_rejects_a_length_over_the_cap() {
1780        let err = check_download_size(Some(MAX_DOWNLOAD_BYTES + 1)).unwrap_err();
1781        assert!(err.to_string().contains("refusing to load"), "{err}");
1782    }
1783
1784    #[test]
1785    fn check_download_size_allows_a_length_at_the_cap() {
1786        assert!(check_download_size(Some(MAX_DOWNLOAD_BYTES)).is_ok());
1787    }
1788
1789    #[test]
1790    fn check_download_size_allows_a_missing_length() {
1791        assert!(check_download_size(None).is_ok());
1792    }
1793
1794    // ── check_upload_size ───────────────────────────────────────────
1795
1796    #[test]
1797    fn check_upload_size_rejects_a_length_over_the_cap() {
1798        let err = check_upload_size(MAX_UPLOAD_BYTES + 1).unwrap_err();
1799        assert!(err.to_string().contains("refusing to upload"), "{err}");
1800    }
1801
1802    #[test]
1803    fn check_upload_size_allows_a_length_at_the_cap() {
1804        assert!(check_upload_size(MAX_UPLOAD_BYTES).is_ok());
1805    }
1806
1807    // `build_related_body` / `generate_boundary` moved to
1808    // `crate::utils::multipart` (shared with `gmail insert`); their
1809    // byte-level tests moved with them.
1810
1811    // ── build_file_upload_url ───────────────────────────────────────
1812
1813    #[test]
1814    fn build_file_upload_url_includes_upload_type_fields_and_supports_all_drives() {
1815        let url = build_file_upload_url("https://www.googleapis.com").unwrap();
1816        assert!(url.path().ends_with("/upload/drive/v3/files"));
1817        assert!(url.as_str().contains("uploadType=multipart"));
1818        assert!(url.as_str().contains("fields="));
1819        assert!(url.as_str().contains("supportsAllDrives=true"));
1820    }
1821
1822    // ── effective_cap ────────────────────────────────────────────────
1823
1824    #[test]
1825    fn effective_cap_zero_is_hard_cap() {
1826        assert_eq!(effective_cap(0), HARD_CAP);
1827    }
1828
1829    #[test]
1830    fn effective_cap_clamps_above_hard_cap() {
1831        assert_eq!(effective_cap(HARD_CAP + 1000), HARD_CAP);
1832    }
1833
1834    #[test]
1835    fn effective_cap_passes_through_small_limits() {
1836        assert_eq!(effective_cap(5), 5);
1837    }
1838}