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