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