use anyhow::{Context, Result};
use url::Url;
use crate::drive::client::DriveClient;
use crate::drive::error::DriveError;
use crate::drive::types::{DriveFile, FileListResponse};
pub const MAX_PAGE_LIMIT: usize = 1000;
pub const HARD_CAP: usize = 10_000;
pub const DEFAULT_SEARCH_LIMIT: usize = 50;
const MAX_DOWNLOAD_BYTES: u64 = 500 * 1024 * 1024;
const LIST_FIELDS: &str = "nextPageToken,incompleteSearch,files(id,name,mimeType,size,\
md5Checksum,sha1Checksum,sha256Checksum,modifiedTime,parents,webViewLink,\
owners(displayName,emailAddress),driveId)";
const GET_FIELDS: &str = "id,name,mimeType,size,md5Checksum,sha1Checksum,sha256Checksum,\
modifiedTime,parents,webViewLink,owners(displayName,emailAddress),driveId,exportLinks";
#[derive(Debug)]
pub struct FilesApi<'a> {
client: &'a DriveClient,
}
impl<'a> FilesApi<'a> {
#[must_use]
pub fn new(client: &'a DriveClient) -> Self {
Self { client }
}
pub async fn search(
&self,
query: Option<&str>,
limit: usize,
page_token: Option<&str>,
) -> Result<FileListResponse> {
if limit > MAX_PAGE_LIMIT {
return Err(anyhow::anyhow!(
"`limit` must be <= {MAX_PAGE_LIMIT} (Drive files.list per-page cap; use \
`search_all` to auto-paginate)"
));
}
let url = build_files_list_url(self.client.base_url(), query, limit, page_token)?;
self.client
.get_parsed(url.as_str(), "Failed to parse files.list response")
.await
}
pub async fn search_all(&self, query: Option<&str>, limit: usize) -> Result<FileListResponse> {
self.paginate(query, effective_cap(limit)).await
}
async fn paginate(&self, query: Option<&str>, cap: usize) -> Result<FileListResponse> {
let mut acc: Option<FileListResponse> = None;
let mut page_token: Option<String> = None;
loop {
let collected = acc.as_ref().map_or(0, |r| r.files.len());
let page_size = (cap - collected).min(MAX_PAGE_LIMIT);
let page = self.search(query, page_size, page_token.as_deref()).await?;
let next_token = page.next_page_token.clone();
match acc.as_mut() {
Some(existing) => {
existing.files.extend(page.files);
existing.next_page_token = page.next_page_token;
existing.incomplete_search = page.incomplete_search;
}
None => acc = Some(page),
}
let collected = acc.as_ref().map_or(0, |r| r.files.len());
if collected >= cap || next_token.is_none() {
break;
}
page_token = next_token;
}
let mut result = acc.unwrap_or_default();
if result.files.len() > cap {
result.files.truncate(cap);
result.next_page_token = None;
result.incomplete_search = None;
}
Ok(result)
}
pub async fn get_metadata(&self, file_id: &str) -> Result<DriveFile> {
let url = build_file_get_url(self.client.base_url(), file_id)?;
self.client
.get_parsed(url.as_str(), "Failed to parse files.get response")
.await
}
pub async fn export(&self, file_id: &str, export_mime_type: &str) -> Result<Vec<u8>> {
let url = build_export_url(self.client.base_url(), file_id, export_mime_type)?;
self.fetch_bytes(&url).await
}
pub async fn download(&self, file_id: &str) -> Result<Vec<u8>> {
let url = build_download_url(self.client.base_url(), file_id)?;
self.fetch_bytes(&url).await
}
pub async fn rename(&self, file_id: &str, new_name: &str) -> Result<DriveFile> {
let url = build_file_update_url(self.client.base_url(), file_id, None, None)?;
let response = self
.client
.patch_json(url.as_str(), &serde_json::json!({ "name": new_name }))
.await?;
self.client
.parse_response(response, "Failed to parse files.update response")
.await
.map_err(append_write_scope_hint)
}
pub async fn move_to(
&self,
file_id: &str,
add_parents: &str,
remove_parents: &str,
) -> Result<DriveFile> {
let url = build_file_update_url(
self.client.base_url(),
file_id,
Some(add_parents),
Some(remove_parents),
)?;
let response = self
.client
.patch_json(url.as_str(), &serde_json::json!({}))
.await?;
self.client
.parse_response(response, "Failed to parse files.update response")
.await
.map_err(append_write_scope_hint)
}
async fn fetch_bytes(&self, url: &Url) -> Result<Vec<u8>> {
let response = self.client.get_bytes(url.as_str()).await?;
if !response.status().is_success() {
return Err(DriveClient::response_to_error(response).await.into());
}
check_download_size(response.content_length())?;
let bytes = response
.bytes()
.await
.context("Failed to read response body")?;
Ok(bytes.to_vec())
}
}
fn check_download_size(content_length: Option<u64>) -> Result<()> {
if let Some(len) = content_length {
anyhow::ensure!(
len <= MAX_DOWNLOAD_BYTES,
"refusing to load {len} bytes into memory (limit: {MAX_DOWNLOAD_BYTES} bytes); \
this file is too large for `drive read --content`"
);
}
Ok(())
}
fn build_files_list_url(
base_url: &str,
query: Option<&str>,
limit: usize,
page_token: Option<&str>,
) -> Result<Url> {
let mut url = DriveClient::api_url(base_url, "/drive/v3/files")?;
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("supportsAllDrives", "true");
pairs.append_pair("includeItemsFromAllDrives", "true");
pairs.append_pair("fields", LIST_FIELDS);
if let Some(q) = query.filter(|q| !q.is_empty()) {
pairs.append_pair("q", q);
}
if limit > 0 {
pairs.append_pair("pageSize", &limit.to_string());
}
if let Some(token) = page_token {
pairs.append_pair("pageToken", token);
}
}
Ok(url)
}
fn build_file_get_url(base_url: &str, file_id: &str) -> Result<Url> {
let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}"))?;
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("fields", GET_FIELDS);
pairs.append_pair("supportsAllDrives", "true");
}
Ok(url)
}
fn build_export_url(base_url: &str, file_id: &str, mime_type: &str) -> Result<Url> {
let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}/export"))?;
url.query_pairs_mut().append_pair("mimeType", mime_type);
Ok(url)
}
fn build_download_url(base_url: &str, file_id: &str) -> Result<Url> {
let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}"))?;
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("alt", "media");
pairs.append_pair("supportsAllDrives", "true");
}
Ok(url)
}
fn build_file_update_url(
base_url: &str,
file_id: &str,
add_parents: Option<&str>,
remove_parents: Option<&str>,
) -> Result<Url> {
let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}"))?;
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("fields", GET_FIELDS);
pairs.append_pair("supportsAllDrives", "true");
if let Some(add) = add_parents {
pairs.append_pair("addParents", add);
}
if let Some(remove) = remove_parents {
pairs.append_pair("removeParents", remove);
}
}
Ok(url)
}
fn append_write_scope_hint(err: anyhow::Error) -> anyhow::Error {
let is_insufficient_permissions = matches!(
err.downcast_ref::<DriveError>(),
Some(DriveError::ApiRequestFailed {
reason: Some(reason),
..
}) if reason == "insufficientPermissions"
);
if is_insufficient_permissions {
return err.context(
"Run `omni-dev drive auth login --write` to grant the drive.metadata scope needed \
for rename/move",
);
}
err
}
fn effective_cap(limit: usize) -> usize {
if limit == 0 {
HARD_CAP
} else {
limit.min(HARD_CAP)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::auth::{DriveCredentials, DriveScope};
use crate::drive::types::Owner;
use crate::utils::secret::Secret;
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveScope::ReadOnly,
}
}
async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token",
"expires_in": 3600,
})),
)
.mount(server)
.await;
let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut client,
&test_credentials(),
&format!("{}/token", server.uri()),
);
client
}
fn fully_populated_drive_file() -> DriveFile {
DriveFile {
id: "f1".to_string(),
name: "n".to_string(),
mime_type: "application/pdf".to_string(),
size: Some("1".to_string()),
md5_checksum: Some("5d41402abc4b2a76b9719d911017c592".to_string()),
sha1_checksum: Some("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d".to_string()),
sha256_checksum: Some(
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08".to_string(),
),
modified_time: Some("2026-01-01T00:00:00Z".to_string()),
parents: vec!["p1".to_string()],
web_view_link: Some("https://example.com/view".to_string()),
owners: vec![Owner {
display_name: Some("Alice".to_string()),
email_address: Some("alice@example.com".to_string()),
}],
drive_id: Some("d1".to_string()),
export_links: Some(std::collections::HashMap::from([(
"text/markdown".to_string(),
"https://export.example.com/md".to_string(),
)])),
}
}
#[test]
fn get_fields_requests_every_drive_file_field() {
let json = serde_json::to_value(fully_populated_drive_file()).unwrap();
for key in json.as_object().unwrap().keys() {
assert!(
GET_FIELDS.contains(key.as_str()),
"GET_FIELDS is missing `{key}` — add it, or update this test if that's \
deliberate"
);
}
}
#[test]
fn list_fields_requests_every_drive_file_field_except_export_links() {
let json = serde_json::to_value(fully_populated_drive_file()).unwrap();
for key in json.as_object().unwrap().keys() {
if key == "exportLinks" {
continue;
}
assert!(
LIST_FIELDS.contains(key.as_str()),
"LIST_FIELDS is missing `{key}` — add it, or update this test if that's \
deliberate"
);
}
}
#[test]
fn fields_selectors_request_every_owner_field() {
let owner = Owner {
display_name: Some("Alice".to_string()),
email_address: Some("alice@example.com".to_string()),
};
let json = serde_json::to_value(owner).unwrap();
for key in json.as_object().unwrap().keys() {
assert!(
GET_FIELDS.contains(key.as_str()),
"GET_FIELDS' owners() selector is missing `{key}`"
);
assert!(
LIST_FIELDS.contains(key.as_str()),
"LIST_FIELDS' owners() selector is missing `{key}`"
);
}
}
#[test]
fn build_files_list_url_with_only_provided_filters() {
let url = build_files_list_url("https://www.googleapis.com", None, 0, None).unwrap();
assert!(url.as_str().contains("supportsAllDrives=true"));
assert!(url.as_str().contains("includeItemsFromAllDrives=true"));
assert!(url.as_str().contains("fields="));
assert!(!url.as_str().contains("q="));
assert!(!url.as_str().contains("pageSize="));
assert!(!url.as_str().contains("pageToken="));
}
#[test]
fn build_files_list_url_with_full_filter_set() {
let url = build_files_list_url(
"https://www.googleapis.com",
Some("name contains 'x'"),
10,
Some("token1"),
)
.unwrap();
assert!(url.as_str().contains("supportsAllDrives=true"));
assert!(url.as_str().contains("includeItemsFromAllDrives=true"));
assert!(url.as_str().contains("q=name"));
assert!(url.as_str().contains("pageSize=10"));
assert!(url.as_str().contains("pageToken=token1"));
}
#[test]
fn build_files_list_url_rejects_invalid_base_url() {
let err = build_files_list_url("not a url", None, 0, None).unwrap_err();
assert!(err.to_string().contains("Invalid Drive base URL"));
}
#[test]
fn build_file_get_url_includes_fields_and_supports_all_drives() {
let url = build_file_get_url("https://www.googleapis.com", "f1").unwrap();
assert!(url.as_str().contains("/drive/v3/files/f1"));
assert!(url.as_str().contains("fields="));
assert!(url.as_str().contains("supportsAllDrives=true"));
}
#[test]
fn build_export_url_includes_mime_type_and_omits_supports_all_drives() {
let url = build_export_url("https://www.googleapis.com", "f1", "text/markdown").unwrap();
assert!(url.as_str().contains("/drive/v3/files/f1/export"));
assert!(url.as_str().contains("mimeType=text%2Fmarkdown"));
assert!(!url.as_str().contains("supportsAllDrives"));
}
#[test]
fn build_download_url_includes_alt_media_and_supports_all_drives() {
let url = build_download_url("https://www.googleapis.com", "f1").unwrap();
assert!(url.as_str().contains("/drive/v3/files/f1"));
assert!(url.as_str().contains("alt=media"));
assert!(url.as_str().contains("supportsAllDrives=true"));
}
#[tokio::test]
async fn search_rejects_limit_above_max_page_limit_client_side() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let err = FilesApi::new(&client)
.search(None, MAX_PAGE_LIMIT + 1, None)
.await
.unwrap_err();
assert!(err.to_string().contains("must be <="));
}
#[tokio::test]
async fn search_propagates_api_errors() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let err = FilesApi::new(&client)
.search(None, 10, None)
.await
.unwrap_err();
assert!(err.to_string().contains("500"));
}
#[tokio::test]
async fn search_errors_on_malformed_response() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
.mount(&server)
.await;
let err = FilesApi::new(&client)
.search(None, 10, None)
.await
.unwrap_err();
assert!(err.to_string().contains("Failed to parse"));
}
#[tokio::test]
async fn search_all_single_page_when_no_next_token() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"files": [{"id": "f1", "name": "a"}],
})),
)
.expect(1)
.mount(&server)
.await;
let result = FilesApi::new(&client).search_all(None, 10).await.unwrap();
assert_eq!(result.files.len(), 1);
}
#[tokio::test]
async fn search_all_follows_next_page_token_to_exhaustion() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files"))
.and(wiremock::matchers::query_param_is_missing("pageToken"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"files": [{"id": "f1", "name": "a"}],
"nextPageToken": "page2",
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files"))
.and(wiremock::matchers::query_param("pageToken", "page2"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"files": [{"id": "f2", "name": "b"}],
})),
)
.mount(&server)
.await;
let result = FilesApi::new(&client).search_all(None, 0).await.unwrap();
assert_eq!(result.files.len(), 2);
assert_eq!(result.files[0].id, "f1");
assert_eq!(result.files[1].id, "f2");
}
#[tokio::test]
async fn search_all_stops_at_explicit_limit() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"files": [
{"id": "f1", "name": "a"},
{"id": "f2", "name": "b"},
{"id": "f3", "name": "c"},
],
"nextPageToken": "page2",
"incompleteSearch": true,
})),
)
.mount(&server)
.await;
let result = FilesApi::new(&client).search_all(None, 2).await.unwrap();
assert_eq!(result.files.len(), 2);
assert_eq!(result.next_page_token, None);
assert_eq!(result.incomplete_search, None);
}
#[tokio::test]
async fn search_all_preserves_next_page_token_at_exact_cap() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"files": [
{"id": "f1", "name": "a"},
{"id": "f2", "name": "b"},
{"id": "f3", "name": "c"},
],
"nextPageToken": "page2",
"incompleteSearch": true,
})),
)
.mount(&server)
.await;
let result = FilesApi::new(&client).search_all(None, 3).await.unwrap();
assert_eq!(result.files.len(), 3);
assert_eq!(result.next_page_token.as_deref(), Some("page2"));
assert_eq!(result.incomplete_search, Some(true));
}
#[tokio::test]
async fn search_all_continues_past_empty_page_with_a_valid_next_page_token() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files"))
.and(wiremock::matchers::query_param_is_missing("pageToken"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"files": [],
"nextPageToken": "page2",
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files"))
.and(wiremock::matchers::query_param("pageToken", "page2"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"files": [{"id": "f1", "name": "a"}],
})),
)
.mount(&server)
.await;
let result = FilesApi::new(&client).search_all(None, 0).await.unwrap();
assert_eq!(result.files.len(), 1);
}
#[tokio::test]
async fn search_all_truncates_to_hard_cap() {
assert_eq!(effective_cap(0), HARD_CAP);
}
#[tokio::test]
async fn get_metadata_sends_fields_query_param() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("fields", GET_FIELDS))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "a",
})),
)
.expect(1)
.mount(&server)
.await;
let file = FilesApi::new(&client).get_metadata("f1").await.unwrap();
assert_eq!(file.id, "f1");
}
#[tokio::test]
async fn get_metadata_parses_export_links() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1",
"name": "doc",
"mimeType": "application/vnd.google-apps.document",
"exportLinks": {"text/markdown": "https://export.example/md"},
})),
)
.mount(&server)
.await;
let file = FilesApi::new(&client).get_metadata("f1").await.unwrap();
assert!(file.export_links.unwrap().contains_key("text/markdown"));
}
#[tokio::test]
async fn get_metadata_propagates_api_errors() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/missing"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let err = FilesApi::new(&client)
.get_metadata("missing")
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn export_sends_mime_type_query_param_and_returns_bytes() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1/export"))
.and(wiremock::matchers::query_param("mimeType", "text/markdown"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"# Title".to_vec()))
.expect(1)
.mount(&server)
.await;
let bytes = FilesApi::new(&client)
.export("f1", "text/markdown")
.await
.unwrap();
assert_eq!(bytes, b"# Title");
}
#[tokio::test]
async fn export_propagates_api_errors() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1/export"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let err = FilesApi::new(&client)
.export("f1", "text/markdown")
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn download_sends_alt_media_and_returns_bytes() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"binary".to_vec()))
.expect(1)
.mount(&server)
.await;
let bytes = FilesApi::new(&client).download("f1").await.unwrap();
assert_eq!(bytes, b"binary");
}
#[tokio::test]
async fn download_propagates_api_errors() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let err = FilesApi::new(&client).download("f1").await.unwrap_err();
assert!(err.to_string().contains("500"));
}
#[tokio::test]
async fn rename_sends_name_body_and_no_parents_params() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("fields", GET_FIELDS))
.and(wiremock::matchers::body_json(
serde_json::json!({"name": "New Name"}),
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "New Name",
})),
)
.expect(1)
.mount(&server)
.await;
let file = FilesApi::new(&client)
.rename("f1", "New Name")
.await
.unwrap();
assert_eq!(file.name, "New Name");
let requests = server.received_requests().await.unwrap();
let req = requests
.iter()
.find(|r| r.method.as_str() == "PATCH")
.unwrap();
assert!(req.url.query_pairs().all(|(k, _)| k != "addParents"));
assert!(req.url.query_pairs().all(|(k, _)| k != "removeParents"));
}
#[tokio::test]
async fn rename_propagates_api_errors() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let err = FilesApi::new(&client)
.rename("f1", "New Name")
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn rename_appends_write_scope_hint_on_insufficient_permissions() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(
wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
"error": {
"message": "Insufficient Permission",
"errors": [{"reason": "insufficientPermissions"}],
}
})),
)
.mount(&server)
.await;
let err = FilesApi::new(&client)
.rename("f1", "New Name")
.await
.unwrap_err();
assert!(
err.to_string().contains("drive auth login --write"),
"{err}"
);
}
#[tokio::test]
async fn move_to_sends_add_and_remove_parents_query_params() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.and(wiremock::matchers::query_param("addParents", "dest"))
.and(wiremock::matchers::query_param("removeParents", "src"))
.and(wiremock::matchers::body_json(serde_json::json!({})))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "a", "parents": ["dest"],
})),
)
.expect(1)
.mount(&server)
.await;
let file = FilesApi::new(&client)
.move_to("f1", "dest", "src")
.await
.unwrap();
assert_eq!(file.parents, vec!["dest".to_string()]);
}
#[tokio::test]
async fn move_to_propagates_api_errors() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let err = FilesApi::new(&client)
.move_to("f1", "dest", "src")
.await
.unwrap_err();
assert!(err.to_string().contains("500"));
}
#[tokio::test]
async fn move_to_appends_write_scope_hint_on_insufficient_permissions() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/f1"))
.respond_with(
wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
"error": {
"message": "Insufficient Permission",
"errors": [{"reason": "insufficientPermissions"}],
}
})),
)
.mount(&server)
.await;
let err = FilesApi::new(&client)
.move_to("f1", "dest", "src")
.await
.unwrap_err();
assert!(
err.to_string().contains("drive auth login --write"),
"{err}"
);
}
#[test]
fn append_write_scope_hint_leaves_other_errors_unchanged() {
let err = anyhow::anyhow!("some other failure");
let msg = append_write_scope_hint(err).to_string();
assert_eq!(msg, "some other failure");
}
#[test]
fn check_download_size_rejects_a_length_over_the_cap() {
let err = check_download_size(Some(MAX_DOWNLOAD_BYTES + 1)).unwrap_err();
assert!(err.to_string().contains("refusing to load"), "{err}");
}
#[test]
fn check_download_size_allows_a_length_at_the_cap() {
assert!(check_download_size(Some(MAX_DOWNLOAD_BYTES)).is_ok());
}
#[test]
fn check_download_size_allows_a_missing_length() {
assert!(check_download_size(None).is_ok());
}
#[test]
fn effective_cap_zero_is_hard_cap() {
assert_eq!(effective_cap(0), HARD_CAP);
}
#[test]
fn effective_cap_clamps_above_hard_cap() {
assert_eq!(effective_cap(HARD_CAP + 1000), HARD_CAP);
}
#[test]
fn effective_cap_passes_through_small_limits() {
assert_eq!(effective_cap(5), 5);
}
}