1use anyhow::{Context, Result};
10use url::Url;
11
12use crate::drive::client::DriveClient;
13use crate::drive::error::DriveError;
14use crate::drive::types::{DriveFile, FileListResponse};
15
16pub const MAX_PAGE_LIMIT: usize = 1000;
22
23pub const HARD_CAP: usize = 10_000;
29
30pub const DEFAULT_SEARCH_LIMIT: usize = 50;
33
34const MAX_DOWNLOAD_BYTES: u64 = 500 * 1024 * 1024;
39
40const LIST_FIELDS: &str = "nextPageToken,incompleteSearch,files(id,name,mimeType,size,\
43 md5Checksum,sha1Checksum,sha256Checksum,modifiedTime,parents,webViewLink,\
44 owners(displayName,emailAddress),driveId)";
45
46const GET_FIELDS: &str = "id,name,mimeType,size,md5Checksum,sha1Checksum,sha256Checksum,\
50 modifiedTime,parents,webViewLink,owners(displayName,emailAddress),driveId,exportLinks";
51
52#[derive(Debug)]
54pub struct FilesApi<'a> {
55 client: &'a DriveClient,
56}
57
58impl<'a> FilesApi<'a> {
59 #[must_use]
61 pub fn new(client: &'a DriveClient) -> Self {
62 Self { client }
63 }
64
65 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 pub async fn search_all(&self, query: Option<&str>, limit: usize) -> Result<FileListResponse> {
92 self.paginate(query, effective_cap(limit)).await
93 }
94
95 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 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 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 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 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 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 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
219fn 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 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 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
290fn 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
315fn 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
338fn 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 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 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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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}