1use 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
18pub const MAX_PAGE_LIMIT: usize = 1000;
24
25pub const HARD_CAP: usize = 10_000;
31
32pub const DEFAULT_SEARCH_LIMIT: usize = 50;
35
36const MAX_DOWNLOAD_BYTES: u64 = 500 * 1024 * 1024;
41
42pub(crate) const MAX_UPLOAD_BYTES: u64 = 5 * 1024 * 1024;
50
51const LIST_FIELDS: &str = "nextPageToken,incompleteSearch,files(id,name,mimeType,size,\
54 md5Checksum,sha1Checksum,sha256Checksum,modifiedTime,parents,webViewLink,\
55 owners(displayName,emailAddress),driveId)";
56
57const GET_FIELDS: &str = "id,name,mimeType,size,md5Checksum,sha1Checksum,sha256Checksum,\
61 modifiedTime,parents,webViewLink,owners(displayName,emailAddress),driveId,exportLinks";
62
63#[derive(Debug)]
65pub struct FilesApi<'a> {
66 client: &'a DriveClient,
67}
68
69impl<'a> FilesApi<'a> {
70 #[must_use]
72 pub fn new(client: &'a DriveClient) -> Self {
73 Self { client }
74 }
75
76 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 pub async fn search_all(&self, query: Option<&str>, limit: usize) -> Result<FileListResponse> {
103 self.paginate(query, effective_cap(limit)).await
104 }
105
106 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 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 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 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 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 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 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 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 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 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
338fn 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
353pub(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
368fn 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 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
424fn 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
436fn 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
450fn 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
463fn 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
474fn 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 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
522fn 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
547pub(crate) enum WriteCapability {
552 Metadata,
554 CreateOrUpload,
557 EditContent,
562}
563
564fn 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
596fn 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 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 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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[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 #[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 #[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 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 #[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 #[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}