use anyhow::{Context, Result};
use rmcp::{
handler::server::wrapper::Parameters,
model::{CallToolResult, ContentBlock as Content},
schemars, tool, tool_router, ErrorData as McpError,
};
use serde::{Deserialize, Serialize};
use crate::cli::drive::dedupe::group_duplicates;
use crate::cli::drive::helpers::create_client_for;
use crate::cli::drive::read::{
is_texty, resolve_export_mime_type, verify_sha256_checksum, GOOGLE_FOLDER, GOOGLE_SHORTCUT,
};
use crate::drive::account;
use crate::drive::auth;
use crate::drive::client::DriveClient;
use crate::drive::files_api::{FilesApi, DEFAULT_SEARCH_LIMIT};
use crate::utils::settings::Settings;
use super::error::tool_error;
use super::git_tools::build_truncated_result;
use super::output_file::WriteFileSummary;
use super::server::OmniDevServer;
macro_rules! account_param_doc {
() => {
"Selects a named Drive account instead of the ambient \
`--account`/`OMNI_DEV_DRIVE_ACCOUNT` resolution — e.g. `work`. Omit to use the \
resolved default account (or the legacy single-account credentials, if no named \
accounts are configured). Call `drive_account_list` to discover configured names."
};
}
#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
pub struct DriveAuthStatusParams {
#[doc = account_param_doc!()]
#[serde(default)]
pub account: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DriveSearchParams {
pub query: String,
#[serde(default)]
pub limit: Option<usize>,
#[doc = account_param_doc!()]
#[serde(default)]
pub account: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DriveDedupeParams {
pub query: String,
#[serde(default)]
pub limit: Option<usize>,
#[doc = account_param_doc!()]
#[serde(default)]
pub account: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DriveFileReadParams {
pub file_id: String,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub export_mime_type: Option<String>,
#[serde(default)]
pub output_file: Option<String>,
#[serde(default)]
pub verify: Option<bool>,
#[doc = account_param_doc!()]
#[serde(default)]
pub account: Option<String>,
}
#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
pub struct DriveAccountListParams {}
#[allow(missing_docs)] #[tool_router(router = drive_tool_router, vis = "pub")]
impl OmniDevServer {
#[tool(
description = "Report whether Drive OAuth2 credentials are configured \
(DRIVE_CLIENT_ID/DRIVE_CLIENT_SECRET/refresh token present) and which \
scope was granted at login. Returns presence flags and the granted \
scope only — NEVER the client secret, refresh token, or access token. \
Unlike the CLI `omni-dev drive auth status`, this tool does not call the \
Drive API and cannot confirm the refresh token is still accepted — use \
the CLI status command to actually verify. \
Read-only. Mirrors `omni-dev drive auth status`."
)]
pub async fn drive_auth_status(
&self,
Parameters(params): Parameters<DriveAuthStatusParams>,
) -> Result<CallToolResult, McpError> {
let yaml = run_auth_status(params.account.as_deref()).map_err(tool_error)?;
Ok(CallToolResult::success(vec![Content::text(yaml)]))
}
#[tool(
description = "Search Drive files with a Drive query (same syntax as `drive search`'s \
query argument, e.g. `name contains 'report' and mimeType = \
'application/pdf'`). Returns id/name/mimeType/size/md5Checksum/\
sha1Checksum/sha256Checksum/modifiedTime/parents/webViewLink/owners per \
hit — `files.list` returns full metadata in one call, so there's no \
separate hydration step. Checksum fields are present only for \
binary-content files (absent for folders and Google-native docs). \
Always searches shared drives too. `limit` defaults to 50 when omitted; \
pass `0` explicitly to auto-paginate up to a hard cap (10000). \
Read-only. Mirrors `omni-dev drive search`. Output is YAML."
)]
pub async fn drive_search(
&self,
Parameters(params): Parameters<DriveSearchParams>,
) -> Result<CallToolResult, McpError> {
let client = create_client_for(params.account.as_deref()).map_err(tool_error)?;
let yaml = run_search(&client, ¶ms).await.map_err(tool_error)?;
Ok(build_truncated_result(yaml))
}
#[tool(
description = "Find Drive files sharing the same content hash, within the results of a \
Drive query (same syntax as `drive_search`'s `query`, e.g. `'<folder-id>' \
in parents` to dedupe within one folder). Reuses the same bulk-search \
path as `drive_search` — no per-file follow-up call. Groups by \
md5Checksum (the broadest-coverage checksum field); files with no \
checksum (folders, Google-native documents) are skipped, and groups of \
one are omitted. `limit` defaults to 50 when omitted; pass `0` explicitly \
to scan up to a hard cap (10000). \
Read-only. Mirrors `omni-dev drive dedupe`. Output is YAML."
)]
pub async fn drive_dedupe(
&self,
Parameters(params): Parameters<DriveDedupeParams>,
) -> Result<CallToolResult, McpError> {
let client = create_client_for(params.account.as_deref()).map_err(tool_error)?;
let yaml = run_dedupe(&client, ¶ms).await.map_err(tool_error)?;
Ok(build_truncated_result(yaml))
}
#[tool(
description = "Read a single Drive file by id. `format: \"metadata\"` (default) \
returns only metadata (including md5Checksum/sha1Checksum/\
sha256Checksum when available); `format: \"content\"` additionally \
fetches the file's actual content — exported for Google-native files \
(Docs/Sheets/Slides/...; see `export_mime_type`), downloaded as-is \
otherwise. Folders and shortcuts are rejected with an actionable error \
in content mode. Text content is returned inline; binary content is \
refused inline and requires `output_file`. When `output_file` is set, \
writes the content to that path and returns a short YAML summary \
instead of the inline body. Set `verify: true` (only with `format: \
\"content\"`, only for non-Google-native files) to locally recompute the \
SHA-256 checksum of the fetched bytes and check it against Drive's \
reported sha256Checksum, failing clearly on a mismatch. \
Read-only. Mirrors `omni-dev drive read`. Output is YAML."
)]
pub async fn drive_file_read(
&self,
Parameters(params): Parameters<DriveFileReadParams>,
) -> Result<CallToolResult, McpError> {
let client = create_client_for(params.account.as_deref()).map_err(tool_error)?;
let wrote_to_file = params.output_file.is_some();
let text = run_file_read(&client, ¶ms).await.map_err(tool_error)?;
if wrote_to_file {
Ok(CallToolResult::success(vec![Content::text(text)]))
} else {
Ok(build_truncated_result(text))
}
}
#[tool(
description = "List Drive accounts configured in ~/.omni-dev/settings.json — name, \
cached email address (if known), granted scope, and which one is the \
default. Call this first to discover valid `account` values before \
passing one to `drive_search`/`drive_file_read`/`drive_auth_status`. \
Never returns a secret. \
Read-only, no parameters. Mirrors `omni-dev drive account list`."
)]
pub async fn drive_account_list(
&self,
Parameters(_params): Parameters<DriveAccountListParams>,
) -> Result<CallToolResult, McpError> {
let yaml = run_account_list().map_err(tool_error)?;
Ok(build_truncated_result(yaml))
}
}
fn run_auth_status(account: Option<&str>) -> Result<String> {
let status = auth::status_for(account)?;
serde_yaml::to_string(&status).context("Failed to serialize Drive auth status")
}
fn run_account_list() -> Result<String> {
let settings = Settings::load().unwrap_or_default();
let accounts = account::list_accounts(&settings.drive);
yaml_result(&accounts)
}
async fn run_search(client: &DriveClient, params: &DriveSearchParams) -> Result<String> {
let limit = params.limit.unwrap_or(DEFAULT_SEARCH_LIMIT);
let list = FilesApi::new(client)
.search_all(Some(¶ms.query), limit)
.await?;
yaml_result(&list.files)
}
async fn run_dedupe(client: &DriveClient, params: &DriveDedupeParams) -> Result<String> {
let limit = params.limit.unwrap_or(DEFAULT_SEARCH_LIMIT);
let list = FilesApi::new(client)
.search_all(Some(¶ms.query), limit)
.await?;
yaml_result(&group_duplicates(&list.files))
}
async fn run_file_read(client: &DriveClient, params: &DriveFileReadParams) -> Result<String> {
let format = parse_read_format(params.format.as_deref())?;
let api = FilesApi::new(client);
match format {
ReadFormat::Metadata => {
anyhow::ensure!(
params.output_file.is_none(),
"output_file requires format: \"content\"; metadata is always returned inline"
);
anyhow::ensure!(
!params.verify.unwrap_or(false),
"verify requires format: \"content\"; metadata is always returned inline"
);
let meta = api.get_metadata(¶ms.file_id).await?;
yaml_result(&meta)
}
ReadFormat::Content => run_file_read_content(&api, params).await,
}
}
async fn run_file_read_content(api: &FilesApi<'_>, params: &DriveFileReadParams) -> Result<String> {
let meta = api.get_metadata(¶ms.file_id).await?;
if meta.mime_type == GOOGLE_FOLDER {
anyhow::bail!(
"'{}' is a folder; folders have no content to read — use drive_search to list what \
it contains",
meta.name
);
}
if meta.mime_type == GOOGLE_SHORTCUT {
anyhow::bail!(
"'{}' is a shortcut; drive_file_read doesn't follow shortcuts to their target file \
— resolve the target file's id and read that instead",
meta.name
);
}
let verify = params.verify.unwrap_or(false);
if verify && meta.is_google_native() {
anyhow::bail!(
"verify is not supported for Google-native files: Drive does not return a checksum \
for exported content"
);
}
let (bytes, content_mime_type) = if meta.is_google_native() {
let export_mime = resolve_export_mime_type(&meta, params.export_mime_type.as_deref())?;
let bytes = api.export(¶ms.file_id, &export_mime).await?;
(bytes, export_mime)
} else {
let bytes = api.download(¶ms.file_id).await?;
(bytes, meta.mime_type.clone())
};
if verify {
verify_sha256_checksum(&bytes, meta.sha256_checksum.as_deref())?;
}
match params.output_file.as_deref() {
Some(path) => write_bytes_to_file_yaml(path, &bytes, &content_mime_type),
None => inline_content(&bytes, &content_mime_type),
}
}
fn write_bytes_to_file_yaml(path: &str, bytes: &[u8], format: &str) -> Result<String> {
std::fs::write(path, bytes).with_context(|| format!("Failed to write to {path}"))?;
let summary = WriteFileSummary {
path: path.to_string(),
bytes: bytes.len(),
format: format.to_string(),
};
serde_yaml::to_string(&summary).context("Failed to serialize write summary as YAML")
}
fn inline_content(bytes: &[u8], content_mime_type: &str) -> Result<String> {
if is_texty(content_mime_type) {
return String::from_utf8(bytes.to_vec()).with_context(|| {
format!(
"content (mimeType: {content_mime_type}) claims to be text but is not valid \
UTF-8; set output_file to save it"
)
});
}
Err(anyhow::anyhow!(
"refusing to return binary content (mimeType: {content_mime_type}) inline; set \
output_file to save it to disk"
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReadFormat {
Metadata,
Content,
}
fn parse_read_format(raw: Option<&str>) -> Result<ReadFormat> {
match raw.map(str::to_ascii_lowercase).as_deref() {
None | Some("metadata") => Ok(ReadFormat::Metadata),
Some("content") => Ok(ReadFormat::Content),
Some(other) => {
anyhow::bail!("unknown format {other:?} (expected 'metadata' or 'content')")
}
}
}
fn yaml_result<T: Serialize>(data: &T) -> Result<String> {
serde_yaml::to_string(data).context("Failed to serialize result as YAML")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use rmcp::handler::server::wrapper::Parameters;
use super::*;
use crate::drive::auth::{DriveCredentials, DriveScope, SCOPE_READONLY};
use crate::drive::test_support::EnvGuard;
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 handler_text(result: &CallToolResult) -> String {
assert!(!result.is_error.unwrap_or(false), "tool returned error");
result.content[0]
.as_text()
.expect("expected text content")
.text
.clone()
}
#[test]
fn parse_read_format_defaults_to_metadata() {
assert_eq!(parse_read_format(None).unwrap(), ReadFormat::Metadata);
}
#[test]
fn parse_read_format_accepts_known_strings() {
assert_eq!(
parse_read_format(Some("metadata")).unwrap(),
ReadFormat::Metadata
);
assert_eq!(
parse_read_format(Some("content")).unwrap(),
ReadFormat::Content
);
}
#[test]
fn parse_read_format_is_case_insensitive() {
assert_eq!(
parse_read_format(Some("CONTENT")).unwrap(),
ReadFormat::Content
);
}
#[test]
fn parse_read_format_rejects_unknown_value() {
let err = parse_read_format(Some("bogus")).unwrap_err();
assert!(err.to_string().contains("format"));
}
#[test]
fn run_auth_status_reports_unconfigured_state() {
let guard = EnvGuard::take();
let _dir = guard.clear_credentials();
let yaml = run_auth_status(None).unwrap();
assert!(yaml.contains("has_client_id: false"));
assert!(yaml.contains("has_refresh_token: false"));
}
#[test]
fn run_auth_status_never_emits_secret_values() {
let guard = EnvGuard::take();
let dir = guard.clear_credentials();
let omni_dir = dir.path().join(".omni-dev");
std::fs::create_dir_all(&omni_dir).unwrap();
std::fs::write(
omni_dir.join("settings.json"),
r#"{"env":{
"DRIVE_CLIENT_ID":"client-visible",
"DRIVE_CLIENT_SECRET":"sekret-do-not-leak",
"DRIVE_REFRESH_TOKEN":"sekret-refresh-do-not-leak",
"DRIVE_SCOPE":"https://www.googleapis.com/auth/drive.readonly"
}}"#,
)
.unwrap();
std::env::remove_var(auth::DRIVE_CLIENT_ID);
std::env::remove_var(auth::DRIVE_CLIENT_SECRET);
std::env::remove_var(auth::DRIVE_REFRESH_TOKEN);
std::env::remove_var(auth::DRIVE_SCOPE);
let yaml = run_auth_status(None).unwrap();
assert!(yaml.contains("has_client_id: true"));
assert!(!yaml.contains("sekret-do-not-leak"));
assert!(!yaml.contains("sekret-refresh-do-not-leak"));
}
#[tokio::test]
async fn run_search_returns_hits_as_yaml() {
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": "report.pdf", "mimeType": "application/pdf"}],
})),
)
.expect(1)
.mount(&server)
.await;
let yaml = run_search(
&client,
&DriveSearchParams {
query: "name contains 'report'".to_string(),
limit: Some(10),
account: None,
},
)
.await
.unwrap();
assert!(yaml.contains("id: f1"));
assert!(yaml.contains("mimeType: application/pdf"));
}
#[tokio::test]
async fn run_search_omitted_limit_defaults_to_50_not_hard_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"))
.and(wiremock::matchers::query_param("pageSize", "50"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"files": [{"id": "f1", "name": "a"}],
})),
)
.expect(1)
.mount(&server)
.await;
let yaml = run_search(
&client,
&DriveSearchParams {
query: "name contains 'a'".to_string(),
limit: None,
account: None,
},
)
.await
.unwrap();
assert!(yaml.contains("id: f1"));
}
#[tokio::test]
async fn run_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(403).set_body_string("forbidden"))
.mount(&server)
.await;
let err = run_search(
&client,
&DriveSearchParams {
query: "*".to_string(),
limit: None,
account: None,
},
)
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn run_dedupe_groups_files_sharing_a_checksum() {
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", "md5Checksum": "hash-a"},
{"id": "f2", "name": "b", "md5Checksum": "hash-a"},
{"id": "f3", "name": "c", "md5Checksum": "hash-b"},
],
})),
)
.expect(1)
.mount(&server)
.await;
let yaml = run_dedupe(
&client,
&DriveDedupeParams {
query: "name contains 'x'".to_string(),
limit: Some(10),
account: None,
},
)
.await
.unwrap();
assert!(yaml.contains("checksum: hash-a"));
assert!(yaml.contains("id: f1"));
assert!(yaml.contains("id: f2"));
assert!(!yaml.contains("hash-b"));
}
#[tokio::test]
async fn run_dedupe_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 = run_dedupe(
&client,
&DriveDedupeParams {
query: "*".to_string(),
limit: None,
account: None,
},
)
.await
.unwrap_err();
assert!(err.to_string().contains("500"));
}
fn read_params(
file_id: &str,
format: Option<&str>,
export_mime_type: Option<&str>,
output_file: Option<&str>,
) -> DriveFileReadParams {
DriveFileReadParams {
file_id: file_id.to_string(),
format: format.map(str::to_string),
export_mime_type: export_mime_type.map(str::to_string),
output_file: output_file.map(str::to_string),
verify: None,
account: None,
}
}
#[tokio::test]
async fn run_file_read_metadata_returns_yaml_object() {
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": "n",
})),
)
.expect(1)
.mount(&server)
.await;
let yaml = run_file_read(&client, &read_params("f1", None, None, None))
.await
.unwrap();
assert!(yaml.contains("id: f1"));
}
#[tokio::test]
async fn run_file_read_metadata_rejects_output_file() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let err = run_file_read(
&client,
&read_params("f1", None, None, Some("/tmp/out.txt")),
)
.await
.unwrap_err();
assert!(err.to_string().contains("requires format: \"content\""));
}
#[tokio::test]
async fn run_file_read_rejects_invalid_format() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let err = run_file_read(&client, &read_params("f1", Some("bogus"), None, None))
.await
.unwrap_err();
assert!(err.to_string().contains("format"));
}
#[tokio::test]
async fn run_file_read_content_non_google_native_downloads_via_alt_media() {
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_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "text/plain",
})),
)
.mount(&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"hello".to_vec()))
.expect(1)
.mount(&server)
.await;
let text = run_file_read(&client, &read_params("f1", Some("content"), None, None))
.await
.unwrap();
assert_eq!(text, "hello");
}
#[tokio::test]
async fn run_file_read_content_rejects_folder_with_actionable_error() {
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": "My Folder", "mimeType": GOOGLE_FOLDER,
})),
)
.mount(&server)
.await;
let err = run_file_read(&client, &read_params("f1", Some("content"), None, None))
.await
.unwrap_err();
let message = err.to_string();
assert!(message.contains("'My Folder' is a folder"), "{message}");
assert!(message.contains("drive_search"), "{message}");
}
#[tokio::test]
async fn run_file_read_content_rejects_shortcut_with_actionable_error() {
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": "My Shortcut", "mimeType": GOOGLE_SHORTCUT,
})),
)
.mount(&server)
.await;
let err = run_file_read(&client, &read_params("f1", Some("content"), None, None))
.await
.unwrap_err();
assert!(err.to_string().contains("'My Shortcut' is a shortcut"));
}
#[tokio::test]
async fn run_file_read_content_google_doc_default_exports_text_markdown() {
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": "n", "mimeType": "application/vnd.google-apps.document",
})),
)
.mount(&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 text = run_file_read(&client, &read_params("f1", Some("content"), None, None))
.await
.unwrap();
assert_eq!(text, "# Title");
}
#[tokio::test]
async fn run_file_read_content_explicit_export_mime_type_overrides_default() {
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": "n", "mimeType": "application/vnd.google-apps.document",
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1/export"))
.and(wiremock::matchers::query_param(
"mimeType",
"application/pdf",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"%PDF".to_vec()))
.expect(1)
.mount(&server)
.await;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("doc.pdf");
let summary = run_file_read(
&client,
&read_params(
"f1",
Some("content"),
Some("application/pdf"),
Some(path.to_str().unwrap()),
),
)
.await
.unwrap();
assert!(summary.contains("bytes:"));
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF");
}
#[tokio::test]
async fn run_file_read_content_writes_binary_to_output_file() {
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_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "application/pdf",
})),
)
.mount(&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"%PDF-1.4".to_vec()))
.mount(&server)
.await;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("out.pdf");
let summary = run_file_read(
&client,
&read_params("f1", Some("content"), None, Some(path.to_str().unwrap())),
)
.await
.unwrap();
assert!(summary.contains("bytes: 8"));
assert!(summary.contains("format: application/pdf"));
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.4");
}
#[tokio::test]
async fn run_file_read_content_refuses_binary_content_without_output_file() {
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_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "application/pdf",
})),
)
.mount(&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"%PDF".to_vec()))
.mount(&server)
.await;
let err = run_file_read(&client, &read_params("f1", Some("content"), None, None))
.await
.unwrap_err();
assert!(err
.to_string()
.contains("refusing to return binary content"));
}
#[tokio::test]
async fn run_file_read_content_other_google_native_without_export_mime_type_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(200).set_body_json(serde_json::json!({
"id": "f1",
"name": "myform",
"mimeType": "application/vnd.google-apps.form",
"exportLinks": {"application/zip": "url"},
})),
)
.mount(&server)
.await;
let err = run_file_read(&client, &read_params("f1", Some("content"), None, None))
.await
.unwrap_err();
assert!(err.to_string().contains("--export-mime-type"));
assert!(err.to_string().contains("export_mime_type"));
assert!(err.to_string().contains("application/zip"));
}
#[tokio::test]
async fn run_file_read_content_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 = run_file_read(
&client,
&read_params("missing", Some("content"), None, None),
)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn run_file_read_content_verify_succeeds_on_matching_checksum() {
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_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "text/plain",
"sha256Checksum": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
})),
)
.mount(&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"hello".to_vec()))
.mount(&server)
.await;
let mut params = read_params("f1", Some("content"), None, None);
params.verify = Some(true);
run_file_read(&client, ¶ms).await.unwrap();
}
#[tokio::test]
async fn run_file_read_content_verify_fails_clearly_on_mismatch() {
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_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "text/plain",
"sha256Checksum": "not-the-real-hash",
})),
)
.mount(&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"hello".to_vec()))
.mount(&server)
.await;
let mut params = read_params("f1", Some("content"), None, None);
params.verify = Some(true);
let err = run_file_read(&client, ¶ms).await.unwrap_err();
assert!(err.to_string().contains("checksum mismatch"), "{err}");
}
#[tokio::test]
async fn run_file_read_content_verify_rejects_google_native_file_before_exporting() {
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_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "f1", "name": "n", "mimeType": "application/vnd.google-apps.document",
})),
)
.mount(&server)
.await;
let mut params = read_params("f1", Some("content"), None, None);
params.verify = Some(true);
let err = run_file_read(&client, ¶ms).await.unwrap_err();
assert!(
err.to_string()
.contains("verify is not supported for Google-native files"),
"{err}"
);
}
#[tokio::test]
async fn run_file_read_metadata_rejects_verify() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let mut params = read_params("f1", None, None, None);
params.verify = Some(true);
let err = run_file_read(&client, ¶ms).await.unwrap_err();
assert!(err.to_string().contains("verify requires format"), "{err}");
}
#[tokio::test(flavor = "current_thread")]
async fn drive_auth_status_handler_returns_yaml_no_secrets() {
let guard = EnvGuard::take();
let dir = guard.clear_credentials();
let omni_dir = dir.path().join(".omni-dev");
std::fs::create_dir_all(&omni_dir).unwrap();
std::fs::write(
omni_dir.join("settings.json"),
r#"{"env":{
"DRIVE_CLIENT_ID":"client-1",
"DRIVE_CLIENT_SECRET":"sekret-secret",
"DRIVE_REFRESH_TOKEN":"sekret-refresh"
}}"#,
)
.unwrap();
std::env::remove_var(auth::DRIVE_CLIENT_ID);
std::env::remove_var(auth::DRIVE_CLIENT_SECRET);
std::env::remove_var(auth::DRIVE_REFRESH_TOKEN);
let server = OmniDevServer::new();
let result = server
.drive_auth_status(Parameters(DriveAuthStatusParams::default()))
.await
.unwrap();
let body = handler_text(&result);
assert!(body.contains("has_client_id: true"));
assert!(!body.contains("sekret-secret"));
assert!(!body.contains("sekret-refresh"));
}
#[tokio::test(flavor = "current_thread")]
async fn drive_search_handler_propagates_credentials_error() {
let guard = EnvGuard::take();
let _dir = guard.clear_credentials();
let server = OmniDevServer::new();
let err = server
.drive_search(Parameters(DriveSearchParams {
query: "*".to_string(),
limit: None,
account: None,
}))
.await
.unwrap_err();
assert!(err.message.contains("not configured"));
}
#[tokio::test(flavor = "current_thread")]
async fn drive_search_handler_honors_named_account_param() {
let guard = EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
Settings::upsert_drive_account(
&settings_path,
"work",
&[("client_id", serde_json::Value::String("id".to_string()))],
)
.unwrap();
let server = OmniDevServer::new();
let err = server
.drive_search(Parameters(DriveSearchParams {
query: "*".to_string(),
limit: None,
account: Some("bogus".to_string()),
}))
.await
.unwrap_err();
assert!(err.message.contains("unknown Drive account 'bogus'"));
}
#[tokio::test(flavor = "current_thread")]
async fn drive_dedupe_handler_propagates_credentials_error() {
let guard = EnvGuard::take();
let _dir = guard.clear_credentials();
let server = OmniDevServer::new();
let err = server
.drive_dedupe(Parameters(DriveDedupeParams {
query: "*".to_string(),
limit: None,
account: None,
}))
.await
.unwrap_err();
assert!(err.message.contains("not configured"));
}
#[test]
fn run_account_list_renders_configured_accounts() {
let guard = EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
Settings::upsert_drive_account(
&settings_path,
"work",
&[
("client_id", serde_json::Value::String("id".to_string())),
(
"email_address",
serde_json::Value::String("me@work.com".to_string()),
),
(
"scope",
serde_json::Value::String(SCOPE_READONLY.to_string()),
),
],
)
.unwrap();
Settings::set_drive_default_account(&settings_path, Some("work")).unwrap();
let yaml = run_account_list().unwrap();
assert!(yaml.contains("work"));
assert!(yaml.contains("me@work.com"));
assert!(yaml.contains("is_default: true"));
}
#[test]
fn run_account_list_empty_when_none_configured() {
let guard = EnvGuard::take();
let _dir = guard.clear_credentials();
let yaml = run_account_list().unwrap();
assert_eq!(yaml.trim(), "[]");
}
#[tokio::test(flavor = "current_thread")]
async fn drive_account_list_handler_returns_yaml_no_client_needed() {
let guard = EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
Settings::upsert_drive_account(
&settings_path,
"work",
&[("client_id", serde_json::Value::String("id".to_string()))],
)
.unwrap();
let server = OmniDevServer::new();
let result = server
.drive_account_list(Parameters(DriveAccountListParams::default()))
.await
.unwrap();
let body = handler_text(&result);
assert!(body.contains("work"));
}
}