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};
15
16/// Maximum `pageSize` accepted by `GET /drive/v3/files`.
17///
18/// Drive REST API reference: acceptable values are 1 to 1000, inclusive.
19/// Unlike Gmail's 500 (`crate::gmail::messages_api::MAX_PAGE_LIMIT`),
20/// Drive's cap is 1000.
21pub const MAX_PAGE_LIMIT: usize = 1000;
22
23/// Per-call upper bound on [`FilesApi::search_all`], even when the caller
24/// passes `limit = 0`.
25///
26/// Mirrors `crate::gmail::messages_api::HARD_CAP` verbatim — same safety
27/// rationale, no Drive-specific derivation.
28pub const HARD_CAP: usize = 10_000;
29
30/// Default `limit` for `drive search` when `--limit` is omitted. Mirrors
31/// `crate::gmail::messages_api::DEFAULT_SEARCH_LIMIT`.
32pub const DEFAULT_SEARCH_LIMIT: usize = 50;
33
34/// Refuses to buffer a [`FilesApi::download`] response body larger than
35/// this into memory. `files.export` already has Drive's own 10 MB cap, but
36/// `files.get?alt=media` has none — an unbounded read of a very large file
37/// risks exhausting process memory.
38const MAX_DOWNLOAD_BYTES: u64 = 500 * 1024 * 1024;
39
40/// `fields` value for `files.list` — enough for `drive search`'s
41/// table/JSON output with zero follow-up calls per hit.
42const LIST_FIELDS: &str = "nextPageToken,incompleteSearch,files(id,name,mimeType,size,\
43    md5Checksum,sha1Checksum,sha256Checksum,modifiedTime,parents,webViewLink,\
44    owners(displayName,emailAddress),driveId)";
45
46/// `fields` value for `files.get` — additionally includes `exportLinks` so
47/// `drive read`'s content-export error path can list which MIME types a
48/// Google-native file actually supports exporting to.
49const GET_FIELDS: &str = "id,name,mimeType,size,md5Checksum,sha1Checksum,sha256Checksum,\
50    modifiedTime,parents,webViewLink,owners(displayName,emailAddress),driveId,exportLinks";
51
52/// Files API façade.
53#[derive(Debug)]
54pub struct FilesApi<'a> {
55    client: &'a DriveClient,
56}
57
58impl<'a> FilesApi<'a> {
59    /// Wraps an existing [`DriveClient`] for file operations.
60    #[must_use]
61    pub fn new(client: &'a DriveClient) -> Self {
62        Self { client }
63    }
64
65    /// Searches files matching `query`, returning a single page.
66    ///
67    /// `limit` is rejected client-side when it exceeds [`MAX_PAGE_LIMIT`];
68    /// use [`Self::search_all`] to auto-paginate across pages.
69    pub async fn search(
70        &self,
71        query: Option<&str>,
72        limit: usize,
73        page_token: Option<&str>,
74    ) -> Result<FileListResponse> {
75        if limit > MAX_PAGE_LIMIT {
76            return Err(anyhow::anyhow!(
77                "`limit` must be <= {MAX_PAGE_LIMIT} (Drive files.list per-page cap; use \
78                 `search_all` to auto-paginate)"
79            ));
80        }
81        let url = build_files_list_url(self.client.base_url(), query, limit, page_token)?;
82        self.client
83            .get_parsed(url.as_str(), "Failed to parse files.list response")
84            .await
85    }
86
87    /// Searches files, auto-paginating via cursor as needed.
88    ///
89    /// `limit == 0` means "fetch every match up to [`HARD_CAP`]" — a
90    /// deliberate safety limit for this interactive surface.
91    pub async fn search_all(&self, query: Option<&str>, limit: usize) -> Result<FileListResponse> {
92        self.paginate(query, effective_cap(limit)).await
93    }
94
95    /// Pagination loop backing [`Self::search_all`] — structurally
96    /// identical to `crate::gmail::messages_api::MessagesApi::paginate`,
97    /// `messages` renamed to `files`, `result_size_estimate` renamed to
98    /// `incomplete_search` (Drive has no result-count estimate field),
99    /// with one deliberate divergence: when the accumulated total exceeds
100    /// `cap`, this method clears `next_page_token`/`incomplete_search`
101    /// before truncating `files`, so a caller can never resume pagination
102    /// past files that were fetched but then discarded (#1536). Gmail's
103    /// `paginate` still has the matching bug — issue #1536 was scoped to
104    /// Drive only.
105    async fn paginate(&self, query: Option<&str>, cap: usize) -> Result<FileListResponse> {
106        let mut acc: Option<FileListResponse> = None;
107        let mut page_token: Option<String> = None;
108        loop {
109            let collected = acc.as_ref().map_or(0, |r| r.files.len());
110            let page_size = (cap - collected).min(MAX_PAGE_LIMIT);
111            let page = self.search(query, page_size, page_token.as_deref()).await?;
112            let next_token = page.next_page_token.clone();
113            match acc.as_mut() {
114                Some(existing) => {
115                    existing.files.extend(page.files);
116                    existing.next_page_token = page.next_page_token;
117                    existing.incomplete_search = page.incomplete_search;
118                }
119                None => acc = Some(page),
120            }
121            let collected = acc.as_ref().map_or(0, |r| r.files.len());
122            if collected >= cap || next_token.is_none() {
123                break;
124            }
125            page_token = next_token;
126        }
127        let mut result = acc.unwrap_or_default();
128        if result.files.len() > cap {
129            result.files.truncate(cap);
130            result.next_page_token = None;
131            result.incomplete_search = None;
132        }
133        Ok(result)
134    }
135
136    /// Fetches a single file's metadata (`files.get`), including
137    /// `exportLinks`.
138    pub async fn get_metadata(&self, file_id: &str) -> Result<DriveFile> {
139        let url = build_file_get_url(self.client.base_url(), file_id)?;
140        self.client
141            .get_parsed(url.as_str(), "Failed to parse files.get response")
142            .await
143    }
144
145    /// Exports a Google-native file's content as `export_mime_type`
146    /// (`files.export`). Drive limits export responses to 10 MB — a larger
147    /// document surfaces as an `ApiRequestFailed` from
148    /// [`DriveClient::response_to_error`].
149    pub async fn export(&self, file_id: &str, export_mime_type: &str) -> Result<Vec<u8>> {
150        let url = build_export_url(self.client.base_url(), file_id, export_mime_type)?;
151        self.fetch_bytes(&url).await
152    }
153
154    /// Downloads a non-Google-native file's raw bytes
155    /// (`files.get?alt=media`).
156    pub async fn download(&self, file_id: &str) -> Result<Vec<u8>> {
157        let url = build_download_url(self.client.base_url(), file_id)?;
158        self.fetch_bytes(&url).await
159    }
160
161    /// Renames a file (`files.update` with a `name` body, no `parents`
162    /// change — renaming never affects visibility, see ADR-0070). Requires
163    /// the `drive.metadata` scope (`drive auth login --write`).
164    pub async fn rename(&self, file_id: &str, new_name: &str) -> Result<DriveFile> {
165        let url = build_file_update_url(self.client.base_url(), file_id, None, None)?;
166        let response = self
167            .client
168            .patch_json(url.as_str(), &serde_json::json!({ "name": new_name }))
169            .await?;
170        self.client
171            .parse_response(response, "Failed to parse files.update response")
172            .await
173            .map_err(append_write_scope_hint)
174    }
175
176    /// Moves a file between folders (`files.update` with `addParents`/
177    /// `removeParents` query params, comma-separated file ids — Drive v3 has
178    /// no separate move endpoint). Requires the `drive.metadata` scope
179    /// (`drive auth login --write`).
180    pub async fn move_to(
181        &self,
182        file_id: &str,
183        add_parents: &str,
184        remove_parents: &str,
185    ) -> Result<DriveFile> {
186        let url = build_file_update_url(
187            self.client.base_url(),
188            file_id,
189            Some(add_parents),
190            Some(remove_parents),
191        )?;
192        let response = self
193            .client
194            .patch_json(url.as_str(), &serde_json::json!({}))
195            .await?;
196        self.client
197            .parse_response(response, "Failed to parse files.update response")
198            .await
199            .map_err(append_write_scope_hint)
200    }
201
202    /// Shared GET-then-check-status-then-collect-bytes body for
203    /// [`Self::export`]/[`Self::download`] — both go through
204    /// [`DriveClient::get_bytes`], not `get_json`/`get_parsed`.
205    async fn fetch_bytes(&self, url: &Url) -> Result<Vec<u8>> {
206        let response = self.client.get_bytes(url.as_str()).await?;
207        if !response.status().is_success() {
208            return Err(DriveClient::response_to_error(response).await.into());
209        }
210        check_download_size(response.content_length())?;
211        let bytes = response
212            .bytes()
213            .await
214            .context("Failed to read response body")?;
215        Ok(bytes.to_vec())
216    }
217}
218
219/// Refuses a download whose declared `Content-Length` exceeds
220/// [`MAX_DOWNLOAD_BYTES`]. A missing length (e.g. chunked transfer
221/// encoding) is allowed through — there's nothing to check up front in
222/// that case.
223fn check_download_size(content_length: Option<u64>) -> Result<()> {
224    if let Some(len) = content_length {
225        anyhow::ensure!(
226            len <= MAX_DOWNLOAD_BYTES,
227            "refusing to load {len} bytes into memory (limit: {MAX_DOWNLOAD_BYTES} bytes); \
228             this file is too large for `drive read --content`"
229        );
230    }
231    Ok(())
232}
233
234fn build_files_list_url(
235    base_url: &str,
236    query: Option<&str>,
237    limit: usize,
238    page_token: Option<&str>,
239) -> Result<Url> {
240    let mut url = DriveClient::api_url(base_url, "/drive/v3/files")?;
241    {
242        let mut pairs = url.query_pairs_mut();
243        // Always sent — issue requirement, not opt-in — so shared-drive
244        // files are visible/searchable by default.
245        pairs.append_pair("supportsAllDrives", "true");
246        pairs.append_pair("includeItemsFromAllDrives", "true");
247        pairs.append_pair("fields", LIST_FIELDS);
248        if let Some(q) = query.filter(|q| !q.is_empty()) {
249            pairs.append_pair("q", q);
250        }
251        if limit > 0 {
252            pairs.append_pair("pageSize", &limit.to_string());
253        }
254        if let Some(token) = page_token {
255            pairs.append_pair("pageToken", token);
256        }
257    }
258    Ok(url)
259}
260
261fn build_file_get_url(base_url: &str, file_id: &str) -> Result<Url> {
262    let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}"))?;
263    {
264        let mut pairs = url.query_pairs_mut();
265        pairs.append_pair("fields", GET_FIELDS);
266        pairs.append_pair("supportsAllDrives", "true");
267    }
268    Ok(url)
269}
270
271fn build_export_url(base_url: &str, file_id: &str, mime_type: &str) -> Result<Url> {
272    // `supportsAllDrives` is not a documented `files.export` parameter
273    // (unlike `files.get`/`files.list`) — deliberately omitted rather than
274    // sent speculatively.
275    let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}/export"))?;
276    url.query_pairs_mut().append_pair("mimeType", mime_type);
277    Ok(url)
278}
279
280fn build_download_url(base_url: &str, file_id: &str) -> Result<Url> {
281    let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}"))?;
282    {
283        let mut pairs = url.query_pairs_mut();
284        pairs.append_pair("alt", "media");
285        pairs.append_pair("supportsAllDrives", "true");
286    }
287    Ok(url)
288}
289
290/// `files.update` URL for [`FilesApi::rename`]/[`FilesApi::move_to`].
291/// `add_parents`/`remove_parents` are Drive's `addParents`/`removeParents`
292/// query params (comma-separated file ids) — omitted entirely for a plain
293/// rename, present for a move.
294fn build_file_update_url(
295    base_url: &str,
296    file_id: &str,
297    add_parents: Option<&str>,
298    remove_parents: Option<&str>,
299) -> Result<Url> {
300    let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}"))?;
301    {
302        let mut pairs = url.query_pairs_mut();
303        pairs.append_pair("fields", GET_FIELDS);
304        pairs.append_pair("supportsAllDrives", "true");
305        if let Some(add) = add_parents {
306            pairs.append_pair("addParents", add);
307        }
308        if let Some(remove) = remove_parents {
309            pairs.append_pair("removeParents", remove);
310        }
311    }
312    Ok(url)
313}
314
315/// Appends an actionable hint to a `files.update` failure caused by an
316/// insufficient OAuth scope — the default `drive.readonly` scope cannot
317/// call `files.update` at all; only `--write`'s `drive.metadata` scope can.
318/// No client-side scope pre-check exists (mirrors Gmail's label-mutation
319/// commands): the PATCH is always attempted, and Google's 403 is made
320/// actionable here instead.
321fn append_write_scope_hint(err: anyhow::Error) -> anyhow::Error {
322    let is_insufficient_permissions = matches!(
323        err.downcast_ref::<DriveError>(),
324        Some(DriveError::ApiRequestFailed {
325            reason: Some(reason),
326            ..
327        }) if reason == "insufficientPermissions"
328    );
329    if is_insufficient_permissions {
330        return err.context(
331            "Run `omni-dev drive auth login --write` to grant the drive.metadata scope needed \
332             for rename/move",
333        );
334    }
335    err
336}
337
338/// Clamps a caller-supplied limit to [`HARD_CAP`], treating `0` as "fetch
339/// as many as the cap allows".
340fn effective_cap(limit: usize) -> usize {
341    if limit == 0 {
342        HARD_CAP
343    } else {
344        limit.min(HARD_CAP)
345    }
346}
347
348#[cfg(test)]
349#[allow(clippy::unwrap_used, clippy::expect_used)]
350mod tests {
351    use super::*;
352    use crate::drive::auth::{DriveCredentials, DriveScope};
353    use crate::drive::types::Owner;
354    use crate::utils::secret::Secret;
355
356    fn test_credentials() -> DriveCredentials {
357        DriveCredentials {
358            client_id: "client-1".to_string(),
359            client_secret: Secret::new("secret-1"),
360            refresh_token: Secret::new("refresh-1"),
361            scope: DriveScope::ReadOnly,
362        }
363    }
364
365    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
366        wiremock::Mock::given(wiremock::matchers::method("POST"))
367            .and(wiremock::matchers::path("/token"))
368            .respond_with(
369                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
370                    "access_token": "test-token",
371                    "expires_in": 3600,
372                })),
373            )
374            .mount(server)
375            .await;
376
377        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
378        crate::drive::client::test_support::replace_session(
379            &mut client,
380            &test_credentials(),
381            &format!("{}/token", server.uri()),
382        );
383        client
384    }
385
386    // ── fields= selector coverage ────────────────────────────────────
387    //
388    // LIST_FIELDS/GET_FIELDS are hand-maintained `fields=` selector
389    // strings with no compiler-enforced link to DriveFile/Owner. These
390    // tests construct every field explicitly (no `..Default::default()`),
391    // so adding a struct field forces a compile error here until the test
392    // — and, by extension, the selector strings — are updated to match.
393
394    fn fully_populated_drive_file() -> DriveFile {
395        DriveFile {
396            id: "f1".to_string(),
397            name: "n".to_string(),
398            mime_type: "application/pdf".to_string(),
399            size: Some("1".to_string()),
400            md5_checksum: Some("5d41402abc4b2a76b9719d911017c592".to_string()),
401            sha1_checksum: Some("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d".to_string()),
402            sha256_checksum: Some(
403                "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08".to_string(),
404            ),
405            modified_time: Some("2026-01-01T00:00:00Z".to_string()),
406            parents: vec!["p1".to_string()],
407            web_view_link: Some("https://example.com/view".to_string()),
408            owners: vec![Owner {
409                display_name: Some("Alice".to_string()),
410                email_address: Some("alice@example.com".to_string()),
411            }],
412            drive_id: Some("d1".to_string()),
413            export_links: Some(std::collections::HashMap::from([(
414                "text/markdown".to_string(),
415                "https://export.example.com/md".to_string(),
416            )])),
417        }
418    }
419
420    #[test]
421    fn get_fields_requests_every_drive_file_field() {
422        let json = serde_json::to_value(fully_populated_drive_file()).unwrap();
423        for key in json.as_object().unwrap().keys() {
424            assert!(
425                GET_FIELDS.contains(key.as_str()),
426                "GET_FIELDS is missing `{key}` — add it, or update this test if that's \
427                 deliberate"
428            );
429        }
430    }
431
432    #[test]
433    fn list_fields_requests_every_drive_file_field_except_export_links() {
434        let json = serde_json::to_value(fully_populated_drive_file()).unwrap();
435        for key in json.as_object().unwrap().keys() {
436            if key == "exportLinks" {
437                // Deliberately excluded — see DriveFile::export_links' doc comment.
438                continue;
439            }
440            assert!(
441                LIST_FIELDS.contains(key.as_str()),
442                "LIST_FIELDS is missing `{key}` — add it, or update this test if that's \
443                 deliberate"
444            );
445        }
446    }
447
448    #[test]
449    fn fields_selectors_request_every_owner_field() {
450        let owner = Owner {
451            display_name: Some("Alice".to_string()),
452            email_address: Some("alice@example.com".to_string()),
453        };
454        let json = serde_json::to_value(owner).unwrap();
455        for key in json.as_object().unwrap().keys() {
456            assert!(
457                GET_FIELDS.contains(key.as_str()),
458                "GET_FIELDS' owners() selector is missing `{key}`"
459            );
460            assert!(
461                LIST_FIELDS.contains(key.as_str()),
462                "LIST_FIELDS' owners() selector is missing `{key}`"
463            );
464        }
465    }
466
467    // ── URL builders ─────────────────────────────────────────────────
468
469    #[test]
470    fn build_files_list_url_with_only_provided_filters() {
471        let url = build_files_list_url("https://www.googleapis.com", None, 0, None).unwrap();
472        assert!(url.as_str().contains("supportsAllDrives=true"));
473        assert!(url.as_str().contains("includeItemsFromAllDrives=true"));
474        assert!(url.as_str().contains("fields="));
475        assert!(!url.as_str().contains("q="));
476        assert!(!url.as_str().contains("pageSize="));
477        assert!(!url.as_str().contains("pageToken="));
478    }
479
480    #[test]
481    fn build_files_list_url_with_full_filter_set() {
482        let url = build_files_list_url(
483            "https://www.googleapis.com",
484            Some("name contains 'x'"),
485            10,
486            Some("token1"),
487        )
488        .unwrap();
489        assert!(url.as_str().contains("supportsAllDrives=true"));
490        assert!(url.as_str().contains("includeItemsFromAllDrives=true"));
491        assert!(url.as_str().contains("q=name"));
492        assert!(url.as_str().contains("pageSize=10"));
493        assert!(url.as_str().contains("pageToken=token1"));
494    }
495
496    #[test]
497    fn build_files_list_url_rejects_invalid_base_url() {
498        let err = build_files_list_url("not a url", None, 0, None).unwrap_err();
499        assert!(err.to_string().contains("Invalid Drive base URL"));
500    }
501
502    #[test]
503    fn build_file_get_url_includes_fields_and_supports_all_drives() {
504        let url = build_file_get_url("https://www.googleapis.com", "f1").unwrap();
505        assert!(url.as_str().contains("/drive/v3/files/f1"));
506        assert!(url.as_str().contains("fields="));
507        assert!(url.as_str().contains("supportsAllDrives=true"));
508    }
509
510    #[test]
511    fn build_export_url_includes_mime_type_and_omits_supports_all_drives() {
512        let url = build_export_url("https://www.googleapis.com", "f1", "text/markdown").unwrap();
513        assert!(url.as_str().contains("/drive/v3/files/f1/export"));
514        assert!(url.as_str().contains("mimeType=text%2Fmarkdown"));
515        assert!(!url.as_str().contains("supportsAllDrives"));
516    }
517
518    #[test]
519    fn build_download_url_includes_alt_media_and_supports_all_drives() {
520        let url = build_download_url("https://www.googleapis.com", "f1").unwrap();
521        assert!(url.as_str().contains("/drive/v3/files/f1"));
522        assert!(url.as_str().contains("alt=media"));
523        assert!(url.as_str().contains("supportsAllDrives=true"));
524    }
525
526    // ── search ───────────────────────────────────────────────────────
527
528    #[tokio::test]
529    async fn search_rejects_limit_above_max_page_limit_client_side() {
530        let server = wiremock::MockServer::start().await;
531        let client = client_with_bootstrapped_token(&server).await;
532        let err = FilesApi::new(&client)
533            .search(None, MAX_PAGE_LIMIT + 1, None)
534            .await
535            .unwrap_err();
536        assert!(err.to_string().contains("must be <="));
537    }
538
539    #[tokio::test]
540    async fn search_propagates_api_errors() {
541        let server = wiremock::MockServer::start().await;
542        let client = client_with_bootstrapped_token(&server).await;
543        wiremock::Mock::given(wiremock::matchers::method("GET"))
544            .and(wiremock::matchers::path("/drive/v3/files"))
545            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
546            .mount(&server)
547            .await;
548
549        let err = FilesApi::new(&client)
550            .search(None, 10, None)
551            .await
552            .unwrap_err();
553        assert!(err.to_string().contains("500"));
554    }
555
556    #[tokio::test]
557    async fn search_errors_on_malformed_response() {
558        let server = wiremock::MockServer::start().await;
559        let client = client_with_bootstrapped_token(&server).await;
560        wiremock::Mock::given(wiremock::matchers::method("GET"))
561            .and(wiremock::matchers::path("/drive/v3/files"))
562            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
563            .mount(&server)
564            .await;
565
566        let err = FilesApi::new(&client)
567            .search(None, 10, None)
568            .await
569            .unwrap_err();
570        assert!(err.to_string().contains("Failed to parse"));
571    }
572
573    // ── search_all ───────────────────────────────────────────────────
574
575    #[tokio::test]
576    async fn search_all_single_page_when_no_next_token() {
577        let server = wiremock::MockServer::start().await;
578        let client = client_with_bootstrapped_token(&server).await;
579        wiremock::Mock::given(wiremock::matchers::method("GET"))
580            .and(wiremock::matchers::path("/drive/v3/files"))
581            .respond_with(
582                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
583                    "files": [{"id": "f1", "name": "a"}],
584                })),
585            )
586            .expect(1)
587            .mount(&server)
588            .await;
589
590        let result = FilesApi::new(&client).search_all(None, 10).await.unwrap();
591        assert_eq!(result.files.len(), 1);
592    }
593
594    #[tokio::test]
595    async fn search_all_follows_next_page_token_to_exhaustion() {
596        let server = wiremock::MockServer::start().await;
597        let client = client_with_bootstrapped_token(&server).await;
598        wiremock::Mock::given(wiremock::matchers::method("GET"))
599            .and(wiremock::matchers::path("/drive/v3/files"))
600            .and(wiremock::matchers::query_param_is_missing("pageToken"))
601            .respond_with(
602                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
603                    "files": [{"id": "f1", "name": "a"}],
604                    "nextPageToken": "page2",
605                })),
606            )
607            .mount(&server)
608            .await;
609        wiremock::Mock::given(wiremock::matchers::method("GET"))
610            .and(wiremock::matchers::path("/drive/v3/files"))
611            .and(wiremock::matchers::query_param("pageToken", "page2"))
612            .respond_with(
613                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
614                    "files": [{"id": "f2", "name": "b"}],
615                })),
616            )
617            .mount(&server)
618            .await;
619
620        let result = FilesApi::new(&client).search_all(None, 0).await.unwrap();
621        assert_eq!(result.files.len(), 2);
622        assert_eq!(result.files[0].id, "f1");
623        assert_eq!(result.files[1].id, "f2");
624    }
625
626    #[tokio::test]
627    async fn search_all_stops_at_explicit_limit() {
628        let server = wiremock::MockServer::start().await;
629        let client = client_with_bootstrapped_token(&server).await;
630        wiremock::Mock::given(wiremock::matchers::method("GET"))
631            .and(wiremock::matchers::path("/drive/v3/files"))
632            .respond_with(
633                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
634                    "files": [
635                        {"id": "f1", "name": "a"},
636                        {"id": "f2", "name": "b"},
637                        {"id": "f3", "name": "c"},
638                    ],
639                    "nextPageToken": "page2",
640                    "incompleteSearch": true,
641                })),
642            )
643            .mount(&server)
644            .await;
645
646        let result = FilesApi::new(&client).search_all(None, 2).await.unwrap();
647        assert_eq!(result.files.len(), 2);
648        // Truncation discarded fetched-but-unreturned files, so the cursor
649        // fields must be cleared rather than pointing past them (#1536).
650        assert_eq!(result.next_page_token, None);
651        assert_eq!(result.incomplete_search, None);
652    }
653
654    #[tokio::test]
655    async fn search_all_preserves_next_page_token_at_exact_cap() {
656        let server = wiremock::MockServer::start().await;
657        let client = client_with_bootstrapped_token(&server).await;
658        wiremock::Mock::given(wiremock::matchers::method("GET"))
659            .and(wiremock::matchers::path("/drive/v3/files"))
660            .respond_with(
661                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
662                    "files": [
663                        {"id": "f1", "name": "a"},
664                        {"id": "f2", "name": "b"},
665                        {"id": "f3", "name": "c"},
666                    ],
667                    "nextPageToken": "page2",
668                    "incompleteSearch": true,
669                })),
670            )
671            .mount(&server)
672            .await;
673
674        let result = FilesApi::new(&client).search_all(None, 3).await.unwrap();
675        assert_eq!(result.files.len(), 3);
676        // No files were discarded, so the cursor is still accurate and
677        // must be preserved, not cleared.
678        assert_eq!(result.next_page_token.as_deref(), Some("page2"));
679        assert_eq!(result.incomplete_search, Some(true));
680    }
681
682    #[tokio::test]
683    async fn search_all_continues_past_empty_page_with_a_valid_next_page_token() {
684        let server = wiremock::MockServer::start().await;
685        let client = client_with_bootstrapped_token(&server).await;
686        wiremock::Mock::given(wiremock::matchers::method("GET"))
687            .and(wiremock::matchers::path("/drive/v3/files"))
688            .and(wiremock::matchers::query_param_is_missing("pageToken"))
689            .respond_with(
690                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
691                    "files": [],
692                    "nextPageToken": "page2",
693                })),
694            )
695            .mount(&server)
696            .await;
697        wiremock::Mock::given(wiremock::matchers::method("GET"))
698            .and(wiremock::matchers::path("/drive/v3/files"))
699            .and(wiremock::matchers::query_param("pageToken", "page2"))
700            .respond_with(
701                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
702                    "files": [{"id": "f1", "name": "a"}],
703                })),
704            )
705            .mount(&server)
706            .await;
707
708        let result = FilesApi::new(&client).search_all(None, 0).await.unwrap();
709        assert_eq!(result.files.len(), 1);
710    }
711
712    #[tokio::test]
713    async fn search_all_truncates_to_hard_cap() {
714        assert_eq!(effective_cap(0), HARD_CAP);
715    }
716
717    // ── get_metadata ─────────────────────────────────────────────────
718
719    #[tokio::test]
720    async fn get_metadata_sends_fields_query_param() {
721        let server = wiremock::MockServer::start().await;
722        let client = client_with_bootstrapped_token(&server).await;
723        wiremock::Mock::given(wiremock::matchers::method("GET"))
724            .and(wiremock::matchers::path("/drive/v3/files/f1"))
725            .and(wiremock::matchers::query_param("fields", GET_FIELDS))
726            .respond_with(
727                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
728                    "id": "f1", "name": "a",
729                })),
730            )
731            .expect(1)
732            .mount(&server)
733            .await;
734
735        let file = FilesApi::new(&client).get_metadata("f1").await.unwrap();
736        assert_eq!(file.id, "f1");
737    }
738
739    #[tokio::test]
740    async fn get_metadata_parses_export_links() {
741        let server = wiremock::MockServer::start().await;
742        let client = client_with_bootstrapped_token(&server).await;
743        wiremock::Mock::given(wiremock::matchers::method("GET"))
744            .and(wiremock::matchers::path("/drive/v3/files/f1"))
745            .respond_with(
746                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
747                    "id": "f1",
748                    "name": "doc",
749                    "mimeType": "application/vnd.google-apps.document",
750                    "exportLinks": {"text/markdown": "https://export.example/md"},
751                })),
752            )
753            .mount(&server)
754            .await;
755
756        let file = FilesApi::new(&client).get_metadata("f1").await.unwrap();
757        assert!(file.export_links.unwrap().contains_key("text/markdown"));
758    }
759
760    #[tokio::test]
761    async fn get_metadata_propagates_api_errors() {
762        let server = wiremock::MockServer::start().await;
763        let client = client_with_bootstrapped_token(&server).await;
764        wiremock::Mock::given(wiremock::matchers::method("GET"))
765            .and(wiremock::matchers::path("/drive/v3/files/missing"))
766            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
767            .mount(&server)
768            .await;
769
770        let err = FilesApi::new(&client)
771            .get_metadata("missing")
772            .await
773            .unwrap_err();
774        assert!(err.to_string().contains("404"));
775    }
776
777    // ── export / download ────────────────────────────────────────────
778
779    #[tokio::test]
780    async fn export_sends_mime_type_query_param_and_returns_bytes() {
781        let server = wiremock::MockServer::start().await;
782        let client = client_with_bootstrapped_token(&server).await;
783        wiremock::Mock::given(wiremock::matchers::method("GET"))
784            .and(wiremock::matchers::path("/drive/v3/files/f1/export"))
785            .and(wiremock::matchers::query_param("mimeType", "text/markdown"))
786            .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"# Title".to_vec()))
787            .expect(1)
788            .mount(&server)
789            .await;
790
791        let bytes = FilesApi::new(&client)
792            .export("f1", "text/markdown")
793            .await
794            .unwrap();
795        assert_eq!(bytes, b"# Title");
796    }
797
798    #[tokio::test]
799    async fn export_propagates_api_errors() {
800        let server = wiremock::MockServer::start().await;
801        let client = client_with_bootstrapped_token(&server).await;
802        wiremock::Mock::given(wiremock::matchers::method("GET"))
803            .and(wiremock::matchers::path("/drive/v3/files/f1/export"))
804            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
805            .mount(&server)
806            .await;
807
808        let err = FilesApi::new(&client)
809            .export("f1", "text/markdown")
810            .await
811            .unwrap_err();
812        assert!(err.to_string().contains("404"));
813    }
814
815    #[tokio::test]
816    async fn download_sends_alt_media_and_returns_bytes() {
817        let server = wiremock::MockServer::start().await;
818        let client = client_with_bootstrapped_token(&server).await;
819        wiremock::Mock::given(wiremock::matchers::method("GET"))
820            .and(wiremock::matchers::path("/drive/v3/files/f1"))
821            .and(wiremock::matchers::query_param("alt", "media"))
822            .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"binary".to_vec()))
823            .expect(1)
824            .mount(&server)
825            .await;
826
827        let bytes = FilesApi::new(&client).download("f1").await.unwrap();
828        assert_eq!(bytes, b"binary");
829    }
830
831    #[tokio::test]
832    async fn download_propagates_api_errors() {
833        let server = wiremock::MockServer::start().await;
834        let client = client_with_bootstrapped_token(&server).await;
835        wiremock::Mock::given(wiremock::matchers::method("GET"))
836            .and(wiremock::matchers::path("/drive/v3/files/f1"))
837            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
838            .mount(&server)
839            .await;
840
841        let err = FilesApi::new(&client).download("f1").await.unwrap_err();
842        assert!(err.to_string().contains("500"));
843    }
844
845    // ── rename / move_to ─────────────────────────────────────────────
846
847    #[tokio::test]
848    async fn rename_sends_name_body_and_no_parents_params() {
849        let server = wiremock::MockServer::start().await;
850        let client = client_with_bootstrapped_token(&server).await;
851        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
852            .and(wiremock::matchers::path("/drive/v3/files/f1"))
853            .and(wiremock::matchers::query_param("fields", GET_FIELDS))
854            .and(wiremock::matchers::body_json(
855                serde_json::json!({"name": "New Name"}),
856            ))
857            .respond_with(
858                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
859                    "id": "f1", "name": "New Name",
860                })),
861            )
862            .expect(1)
863            .mount(&server)
864            .await;
865
866        let file = FilesApi::new(&client)
867            .rename("f1", "New Name")
868            .await
869            .unwrap();
870        assert_eq!(file.name, "New Name");
871
872        let requests = server.received_requests().await.unwrap();
873        let req = requests
874            .iter()
875            .find(|r| r.method.as_str() == "PATCH")
876            .unwrap();
877        assert!(req.url.query_pairs().all(|(k, _)| k != "addParents"));
878        assert!(req.url.query_pairs().all(|(k, _)| k != "removeParents"));
879    }
880
881    #[tokio::test]
882    async fn rename_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("PATCH"))
886            .and(wiremock::matchers::path("/drive/v3/files/f1"))
887            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
888            .mount(&server)
889            .await;
890
891        let err = FilesApi::new(&client)
892            .rename("f1", "New Name")
893            .await
894            .unwrap_err();
895        assert!(err.to_string().contains("404"));
896    }
897
898    #[tokio::test]
899    async fn rename_appends_write_scope_hint_on_insufficient_permissions() {
900        let server = wiremock::MockServer::start().await;
901        let client = client_with_bootstrapped_token(&server).await;
902        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
903            .and(wiremock::matchers::path("/drive/v3/files/f1"))
904            .respond_with(
905                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
906                    "error": {
907                        "message": "Insufficient Permission",
908                        "errors": [{"reason": "insufficientPermissions"}],
909                    }
910                })),
911            )
912            .mount(&server)
913            .await;
914
915        let err = FilesApi::new(&client)
916            .rename("f1", "New Name")
917            .await
918            .unwrap_err();
919        assert!(
920            err.to_string().contains("drive auth login --write"),
921            "{err}"
922        );
923    }
924
925    #[tokio::test]
926    async fn move_to_sends_add_and_remove_parents_query_params() {
927        let server = wiremock::MockServer::start().await;
928        let client = client_with_bootstrapped_token(&server).await;
929        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
930            .and(wiremock::matchers::path("/drive/v3/files/f1"))
931            .and(wiremock::matchers::query_param("addParents", "dest"))
932            .and(wiremock::matchers::query_param("removeParents", "src"))
933            .and(wiremock::matchers::body_json(serde_json::json!({})))
934            .respond_with(
935                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
936                    "id": "f1", "name": "a", "parents": ["dest"],
937                })),
938            )
939            .expect(1)
940            .mount(&server)
941            .await;
942
943        let file = FilesApi::new(&client)
944            .move_to("f1", "dest", "src")
945            .await
946            .unwrap();
947        assert_eq!(file.parents, vec!["dest".to_string()]);
948    }
949
950    #[tokio::test]
951    async fn move_to_propagates_api_errors() {
952        let server = wiremock::MockServer::start().await;
953        let client = client_with_bootstrapped_token(&server).await;
954        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
955            .and(wiremock::matchers::path("/drive/v3/files/f1"))
956            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
957            .mount(&server)
958            .await;
959
960        let err = FilesApi::new(&client)
961            .move_to("f1", "dest", "src")
962            .await
963            .unwrap_err();
964        assert!(err.to_string().contains("500"));
965    }
966
967    #[tokio::test]
968    async fn move_to_appends_write_scope_hint_on_insufficient_permissions() {
969        let server = wiremock::MockServer::start().await;
970        let client = client_with_bootstrapped_token(&server).await;
971        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
972            .and(wiremock::matchers::path("/drive/v3/files/f1"))
973            .respond_with(
974                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
975                    "error": {
976                        "message": "Insufficient Permission",
977                        "errors": [{"reason": "insufficientPermissions"}],
978                    }
979                })),
980            )
981            .mount(&server)
982            .await;
983
984        let err = FilesApi::new(&client)
985            .move_to("f1", "dest", "src")
986            .await
987            .unwrap_err();
988        assert!(
989            err.to_string().contains("drive auth login --write"),
990            "{err}"
991        );
992    }
993
994    #[test]
995    fn append_write_scope_hint_leaves_other_errors_unchanged() {
996        let err = anyhow::anyhow!("some other failure");
997        let msg = append_write_scope_hint(err).to_string();
998        assert_eq!(msg, "some other failure");
999    }
1000
1001    // ── check_download_size ─────────────────────────────────────────
1002
1003    #[test]
1004    fn check_download_size_rejects_a_length_over_the_cap() {
1005        let err = check_download_size(Some(MAX_DOWNLOAD_BYTES + 1)).unwrap_err();
1006        assert!(err.to_string().contains("refusing to load"), "{err}");
1007    }
1008
1009    #[test]
1010    fn check_download_size_allows_a_length_at_the_cap() {
1011        assert!(check_download_size(Some(MAX_DOWNLOAD_BYTES)).is_ok());
1012    }
1013
1014    #[test]
1015    fn check_download_size_allows_a_missing_length() {
1016        assert!(check_download_size(None).is_ok());
1017    }
1018
1019    // ── effective_cap ────────────────────────────────────────────────
1020
1021    #[test]
1022    fn effective_cap_zero_is_hard_cap() {
1023        assert_eq!(effective_cap(0), HARD_CAP);
1024    }
1025
1026    #[test]
1027    fn effective_cap_clamps_above_hard_cap() {
1028        assert_eq!(effective_cap(HARD_CAP + 1000), HARD_CAP);
1029    }
1030
1031    #[test]
1032    fn effective_cap_passes_through_small_limits() {
1033        assert_eq!(effective_cap(5), 5);
1034    }
1035}