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::gmail::helpers::create_client_for;
use crate::gmail::account;
use crate::gmail::auth;
use crate::gmail::client::GmailClient;
use crate::gmail::labels_api::LabelsApi;
use crate::gmail::messages_api::{MessageFormat, MessagesApi, DEFAULT_SEARCH_LIMIT};
use crate::gmail::threads_api::{ThreadFormat, ThreadsApi};
use crate::utils::settings::Settings;
use super::error::tool_error;
use super::git_tools::build_truncated_result;
use super::output_file::write_to_file_yaml;
use super::server::OmniDevServer;
macro_rules! account_param_doc {
() => {
"Selects a named Gmail account instead of the ambient \
`--account`/`OMNI_DEV_GMAIL_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 `gmail_account_list` to discover configured names."
};
}
#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
pub struct GmailAuthStatusParams {
#[doc = account_param_doc!()]
#[serde(default)]
pub account: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GmailSearchParams {
pub query: String,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub enrich: Option<bool>,
#[serde(default)]
pub concurrency: Option<usize>,
#[doc = account_param_doc!()]
#[serde(default)]
pub account: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GmailMessageReadParams {
pub message_id: String,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub output_file: Option<String>,
#[doc = account_param_doc!()]
#[serde(default)]
pub account: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GmailThreadReadParams {
pub thread_id: String,
#[doc = account_param_doc!()]
#[serde(default)]
pub account: Option<String>,
}
#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
pub struct GmailLabelListParams {
#[doc = account_param_doc!()]
#[serde(default)]
pub account: Option<String>,
}
#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
pub struct GmailAccountListParams {}
#[allow(missing_docs)] #[tool_router(router = gmail_tool_router, vis = "pub")]
impl OmniDevServer {
#[tool(
description = "Report whether Gmail OAuth2 credentials are configured \
(GMAIL_CLIENT_ID/GMAIL_CLIENT_SECRET/refresh token present) and which \
scope was granted at login (readonly vs. modify). Returns presence flags \
and the granted scope only — NEVER the client secret, refresh token, or \
access token. Unlike the CLI `omni-dev gmail auth status`, this tool does \
not call the Gmail API and cannot confirm the refresh token is still \
accepted (a testing-mode Google Cloud project's refresh tokens expire \
after 7 days — use the CLI status command to actually verify). \
Read-only. Mirrors `omni-dev gmail auth status`."
)]
pub async fn gmail_auth_status(
&self,
Parameters(params): Parameters<GmailAuthStatusParams>,
) -> 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 Gmail messages with a Gmail query (same syntax as the Gmail \
search box, e.g. `label:finance after:2026/01/01`). Returns only \
id/threadId per hit by default (ids-only, quota-safe). Set `enrich: true` \
to add From/Subject/Date/snippet — this costs one extra `messages.get` \
request per hit, bounded by `concurrency` (default 4). `limit` defaults \
to 50 when omitted; pass `0` explicitly to auto-paginate up to a hard cap \
(10000) — expensive combined with `enrich: true`, use deliberately. \
Read-only. Mirrors `omni-dev gmail search`. Output is YAML."
)]
pub async fn gmail_search(
&self,
Parameters(params): Parameters<GmailSearchParams>,
) -> 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 = "Read a single Gmail message by id. `format` is `minimal` (ids/labels \
only), `metadata` (headers + snippet only), `full` (default; parsed MIME \
structure), or `raw` (base64url-encoded RFC 2822 source) — Gmail's own \
wire values verbatim. When `output_file` is set, writes the rendered \
message to that path and returns a short YAML summary instead of the \
inline body — use it for large messages or ones with attachments that \
would exceed the response size limit. Read-only. \
Mirrors `omni-dev gmail read`. Output is YAML."
)]
pub async fn gmail_message_read(
&self,
Parameters(params): Parameters<GmailMessageReadParams>,
) -> 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_message_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 = "Read a full Gmail thread (conversation) by id, including every message \
in it. A thread is N messages, each potentially carrying attachments — \
the single highest-risk payload on the whole Gmail surface for exceeding \
the response size limit, so large threads are automatically truncated \
with a marker. Read-only. \
Mirrors `omni-dev gmail thread`. Output is YAML."
)]
pub async fn gmail_thread_read(
&self,
Parameters(params): Parameters<GmailThreadReadParams>,
) -> Result<CallToolResult, McpError> {
let client = create_client_for(params.account.as_deref()).map_err(tool_error)?;
let yaml = run_thread_read(&client, ¶ms)
.await
.map_err(tool_error)?;
Ok(build_truncated_result(yaml))
}
#[tool(
description = "List every label on the Gmail mailbox (system labels like INBOX/TRASH \
and user-created ones), with unread/total message counts. Adding or \
removing labels on messages is CLI-only in this release \
(`omni-dev gmail label add`/`remove`) — no MCP tool mutates labels yet. \
Read-only. \
Mirrors `omni-dev gmail label list`. Output is YAML."
)]
pub async fn gmail_label_list(
&self,
Parameters(params): Parameters<GmailLabelListParams>,
) -> Result<CallToolResult, McpError> {
let client = create_client_for(params.account.as_deref()).map_err(tool_error)?;
let yaml = run_label_list(&client).await.map_err(tool_error)?;
Ok(build_truncated_result(yaml))
}
#[tool(
description = "List Gmail 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 `gmail_search`/`gmail_message_read`/`gmail_thread_read`/\
`gmail_label_list`/`gmail_auth_status`. Never returns a secret. \
Read-only, no parameters. Mirrors `omni-dev gmail account list`."
)]
pub async fn gmail_account_list(
&self,
Parameters(_params): Parameters<GmailAccountListParams>,
) -> 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 Gmail auth status")
}
fn run_account_list() -> Result<String> {
let settings = Settings::load().unwrap_or_default();
let accounts = account::list_accounts(&settings.gmail);
yaml_result(&accounts)
}
async fn run_search(client: &GmailClient, params: &GmailSearchParams) -> Result<String> {
let limit = params.limit.unwrap_or(DEFAULT_SEARCH_LIMIT);
let api = MessagesApi::new(client);
if params.enrich.unwrap_or(false) {
let concurrency = params.concurrency.unwrap_or(4);
let summaries = api
.search_summaries(Some(¶ms.query), &[], limit, concurrency)
.await?;
yaml_result(&summaries)
} else {
let list = api.search_all(Some(¶ms.query), &[], limit).await?;
yaml_result(&list.messages)
}
}
async fn run_message_read(client: &GmailClient, params: &GmailMessageReadParams) -> Result<String> {
let format = parse_message_format(params.format.as_deref())?;
let message = MessagesApi::new(client)
.get(¶ms.message_id, format, &[])
.await?;
let yaml = yaml_result(&message)?;
match params.output_file.as_deref() {
Some(path) => write_to_file_yaml(path, &yaml, message_format_label(format)),
None => Ok(yaml),
}
}
async fn run_thread_read(client: &GmailClient, params: &GmailThreadReadParams) -> Result<String> {
let thread = ThreadsApi::new(client)
.get(¶ms.thread_id, ThreadFormat::Full)
.await?;
yaml_result(&thread)
}
async fn run_label_list(client: &GmailClient) -> Result<String> {
let response = LabelsApi::new(client).list().await?;
yaml_result(&response.labels)
}
fn parse_message_format(raw: Option<&str>) -> Result<MessageFormat> {
match raw.map(str::to_ascii_lowercase).as_deref() {
None | Some("full") => Ok(MessageFormat::Full),
Some("minimal") => Ok(MessageFormat::Minimal),
Some("metadata") => Ok(MessageFormat::Metadata),
Some("raw") => Ok(MessageFormat::Raw),
Some(other) => anyhow::bail!(
"unknown format {other:?} (expected 'minimal', 'metadata', 'full', or 'raw')"
),
}
}
fn message_format_label(format: MessageFormat) -> &'static str {
match format {
MessageFormat::Minimal => "minimal",
MessageFormat::Metadata => "metadata",
MessageFormat::Full => "full",
MessageFormat::Raw => "raw",
}
}
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::gmail::auth::{GmailCredentials, GmailScope};
use crate::gmail::test_support::EnvGuard;
use crate::utils::secret::Secret;
fn test_credentials() -> GmailCredentials {
GmailCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: GmailScope::ReadOnly,
}
}
async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
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 = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
crate::gmail::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_message_format_defaults_to_full() {
assert!(matches!(
parse_message_format(None).unwrap(),
MessageFormat::Full
));
}
#[test]
fn parse_message_format_accepts_known_strings() {
assert!(matches!(
parse_message_format(Some("minimal")).unwrap(),
MessageFormat::Minimal
));
assert!(matches!(
parse_message_format(Some("metadata")).unwrap(),
MessageFormat::Metadata
));
assert!(matches!(
parse_message_format(Some("full")).unwrap(),
MessageFormat::Full
));
assert!(matches!(
parse_message_format(Some("raw")).unwrap(),
MessageFormat::Raw
));
}
#[test]
fn parse_message_format_is_case_insensitive() {
assert!(matches!(
parse_message_format(Some("METADATA")).unwrap(),
MessageFormat::Metadata
));
}
#[test]
fn parse_message_format_rejects_unknown_value() {
let err = parse_message_format(Some("meta")).unwrap_err();
assert!(err.to_string().contains("format"));
}
#[test]
fn message_format_label_matches_wire_values() {
assert_eq!(message_format_label(MessageFormat::Minimal), "minimal");
assert_eq!(message_format_label(MessageFormat::Metadata), "metadata");
assert_eq!(message_format_label(MessageFormat::Full), "full");
assert_eq!(message_format_label(MessageFormat::Raw), "raw");
}
#[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":{
"GMAIL_CLIENT_ID":"client-visible",
"GMAIL_CLIENT_SECRET":"sekret-do-not-leak",
"GMAIL_REFRESH_TOKEN":"sekret-refresh-do-not-leak",
"GMAIL_SCOPE":"https://www.googleapis.com/auth/gmail.readonly"
}}"#,
)
.unwrap();
std::env::remove_var(auth::GMAIL_CLIENT_ID);
std::env::remove_var(auth::GMAIL_CLIENT_SECRET);
std::env::remove_var(auth::GMAIL_REFRESH_TOKEN);
std::env::remove_var(auth::GMAIL_SCOPE);
let yaml = run_auth_status(None).unwrap();
assert!(yaml.contains("has_client_id: true"));
assert!(yaml.contains("client-visible") || 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_defaults_to_ids_only_and_makes_no_hydration_call() {
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("/gmail/v1/users/me/messages"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"messages": [{"id": "m1", "threadId": "t1"}]
})),
)
.expect(1)
.mount(&server)
.await;
let yaml = run_search(
&client,
&GmailSearchParams {
query: "label:finance".to_string(),
limit: Some(10),
enrich: None,
concurrency: None,
account: None,
},
)
.await
.unwrap();
assert!(yaml.contains("id: m1"));
assert!(yaml.contains("threadId: t1"));
}
#[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("/gmail/v1/users/me/messages"))
.and(wiremock::matchers::query_param("maxResults", "50"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"messages": [{"id": "m1", "threadId": "t1"}]
})),
)
.expect(1)
.mount(&server)
.await;
let yaml = run_search(
&client,
&GmailSearchParams {
query: "label:finance".to_string(),
limit: None,
enrich: None,
concurrency: None,
account: None,
},
)
.await
.unwrap();
assert!(yaml.contains("id: m1"));
}
#[tokio::test]
async fn run_search_enrich_true_hydrates_each_hit() {
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("/gmail/v1/users/me/messages"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"messages": [{"id": "m1", "threadId": "t1"}]
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/gmail/v1/users/me/messages/m1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "m1",
"snippet": "Hi there",
})),
)
.expect(1)
.mount(&server)
.await;
let yaml = run_search(
&client,
&GmailSearchParams {
query: "label:finance".to_string(),
limit: Some(10),
enrich: Some(true),
concurrency: Some(2),
account: None,
},
)
.await
.unwrap();
assert!(yaml.contains("Hi there"));
}
#[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("/gmail/v1/users/me/messages"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("forbidden"))
.mount(&server)
.await;
let err = run_search(
&client,
&GmailSearchParams {
query: "*".to_string(),
limit: None,
enrich: None,
concurrency: None,
account: None,
},
)
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn run_message_read_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("/gmail/v1/users/me/messages/m1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "m1",
"snippet": "Hi",
})),
)
.expect(1)
.mount(&server)
.await;
let yaml = run_message_read(
&client,
&GmailMessageReadParams {
message_id: "m1".to_string(),
format: None,
output_file: None,
account: None,
},
)
.await
.unwrap();
assert!(yaml.contains("id: m1"));
}
#[tokio::test]
async fn run_message_read_writes_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("/gmail/v1/users/me/messages/m1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m1"})),
)
.mount(&server)
.await;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("message.yaml");
let summary_yaml = run_message_read(
&client,
&GmailMessageReadParams {
message_id: "m1".to_string(),
format: None,
output_file: Some(path.to_str().unwrap().to_string()),
account: None,
},
)
.await
.unwrap();
assert!(summary_yaml.contains("bytes:"));
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("id: m1"));
}
#[tokio::test]
async fn run_message_read_rejects_invalid_format() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let err = run_message_read(
&client,
&GmailMessageReadParams {
message_id: "m1".to_string(),
format: Some("bogus".to_string()),
output_file: None,
account: None,
},
)
.await
.unwrap_err();
assert!(err.to_string().contains("format"));
}
#[tokio::test]
async fn run_thread_read_returns_yaml_with_messages() {
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("/gmail/v1/users/me/threads/t1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "t1",
"messages": [{"id": "m1", "threadId": "t1"}],
})),
)
.expect(1)
.mount(&server)
.await;
let yaml = run_thread_read(
&client,
&GmailThreadReadParams {
thread_id: "t1".to_string(),
account: None,
},
)
.await
.unwrap();
assert!(yaml.contains("id: t1"));
}
#[tokio::test]
async fn run_label_list_returns_yaml_array() {
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("/gmail/v1/users/me/labels"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"labels": [{"id": "INBOX", "name": "INBOX", "type": "system"}]
})),
)
.expect(1)
.mount(&server)
.await;
let yaml = run_label_list(&client).await.unwrap();
assert!(yaml.contains("INBOX"));
}
#[tokio::test(flavor = "current_thread")]
async fn gmail_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":{
"GMAIL_CLIENT_ID":"client-1",
"GMAIL_CLIENT_SECRET":"sekret-secret",
"GMAIL_REFRESH_TOKEN":"sekret-refresh"
}}"#,
)
.unwrap();
std::env::remove_var(auth::GMAIL_CLIENT_ID);
std::env::remove_var(auth::GMAIL_CLIENT_SECRET);
std::env::remove_var(auth::GMAIL_REFRESH_TOKEN);
let server = OmniDevServer::new();
let result = server
.gmail_auth_status(Parameters(GmailAuthStatusParams::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 gmail_search_handler_propagates_credentials_error() {
let guard = EnvGuard::take();
let _dir = guard.clear_credentials();
let server = OmniDevServer::new();
let err = server
.gmail_search(Parameters(GmailSearchParams {
query: "*".to_string(),
limit: None,
enrich: None,
concurrency: None,
account: None,
}))
.await
.unwrap_err();
assert!(err.message.contains("not configured"));
}
#[tokio::test(flavor = "current_thread")]
async fn gmail_message_read_handler_rejects_invalid_format() {
let guard = EnvGuard::take();
let _dir = guard.clear_credentials();
let server = OmniDevServer::new();
let err = server
.gmail_message_read(Parameters(GmailMessageReadParams {
message_id: "m1".to_string(),
format: Some("bogus".to_string()),
output_file: None,
account: None,
}))
.await
.unwrap_err();
assert!(err.message.contains("not configured"));
}
#[tokio::test(flavor = "current_thread")]
async fn gmail_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_gmail_account(
&settings_path,
"work",
&[("client_id", serde_json::Value::String("id".to_string()))],
)
.unwrap();
let server = OmniDevServer::new();
let err = server
.gmail_search(Parameters(GmailSearchParams {
query: "*".to_string(),
limit: None,
enrich: None,
concurrency: None,
account: Some("bogus".to_string()),
}))
.await
.unwrap_err();
assert!(err.message.contains("unknown Gmail account 'bogus'"));
}
#[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_gmail_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("readonly".to_string())),
],
)
.unwrap();
Settings::set_gmail_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 gmail_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_gmail_account(
&settings_path,
"work",
&[("client_id", serde_json::Value::String("id".to_string()))],
)
.unwrap();
let server = OmniDevServer::new();
let result = server
.gmail_account_list(Parameters(GmailAccountListParams::default()))
.await
.unwrap();
let body = handler_text(&result);
assert!(body.contains("work"));
}
}