use std::io::{self, BufRead, Write};
use anyhow::Result;
use clap::Parser;
use crate::cli::confirm::{guard_destructive_with_io, GuardOptions, GuardOutcome};
use crate::gmail::client::GmailClient;
use crate::gmail::messages_api::MessagesApi;
#[derive(Parser)]
pub struct RemoveCommand {
#[arg(required = true)]
pub message_ids: Vec<String>,
#[arg(long)]
pub label: String,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub dry_run: bool,
}
impl RemoveCommand {
pub async fn execute(self, client: &GmailClient) -> Result<()> {
let mut stdin = io::BufReader::new(io::stdin());
let mut stdout = io::stdout();
run_remove(
client,
&self.message_ids,
&self.label,
self.force,
self.dry_run,
&mut stdin,
&mut stdout,
)
.await
}
}
#[allow(clippy::too_many_arguments)]
async fn run_remove(
client: &GmailClient,
message_ids: &[String],
label: &str,
force: bool,
dry_run: bool,
reader: &mut (dyn BufRead + Send),
writer: &mut (dyn Write + Send),
) -> Result<()> {
let prompt = format!(
"Remove label '{label}' from {} message(s)? [y/N] ",
message_ids.len()
);
let dry_run_message = format!(
"Would remove label '{label}' from {} message(s).",
message_ids.len()
);
let opts = GuardOptions {
prompt: &prompt,
dry_run_message: &dry_run_message,
force,
dry_run,
};
match guard_destructive_with_io(&opts, reader, writer)? {
GuardOutcome::Cancelled | GuardOutcome::DryRun => Ok(()),
GuardOutcome::Proceed => {
let ids: Vec<&str> = message_ids.iter().map(String::as_str).collect();
MessagesApi::new(client)
.batch_modify(&ids, &[], &[label])
.await?;
writeln!(
writer,
"Removed label '{label}' from {} message(s).",
ids.len()
)?;
Ok(())
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::gmail::auth::{GmailCredentials, GmailScope};
use crate::utils::secret::Secret;
use std::io::Cursor;
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::Modify,
}
}
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
}
#[tokio::test]
async fn run_remove_dry_run_makes_no_api_call() {
let client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
let mut input = Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
run_remove(
&client,
&["m1".to_string()],
"IMPORTANT",
false,
true,
&mut input,
&mut output,
)
.await
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("Would remove label 'IMPORTANT'"));
}
#[tokio::test]
async fn run_remove_cancelled_makes_no_api_call() {
let client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
let mut input = Cursor::new(b"n\n".to_vec());
let mut output = Vec::<u8>::new();
run_remove(
&client,
&["m1".to_string()],
"IMPORTANT",
false,
false,
&mut input,
&mut output,
)
.await
.unwrap();
assert!(String::from_utf8(output).unwrap().contains("Cancelled."));
}
#[tokio::test]
async fn run_remove_force_posts_batch_modify_with_remove_label() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/gmail/v1/users/me/messages/batchModify",
))
.and(wiremock::matchers::body_json(serde_json::json!({
"ids": ["m1", "m2"],
"removeLabelIds": ["UNREAD"],
})))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let mut input = Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
run_remove(
&client,
&["m1".to_string(), "m2".to_string()],
"UNREAD",
true,
false,
&mut input,
&mut output,
)
.await
.unwrap();
assert!(String::from_utf8(output)
.unwrap()
.contains("Removed label 'UNREAD'"));
}
#[tokio::test]
async fn run_remove_yes_prompt_proceeds() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/gmail/v1/users/me/messages/batchModify",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let mut input = Cursor::new(b"y\n".to_vec());
let mut output = Vec::<u8>::new();
run_remove(
&client,
&["m1".to_string()],
"UNREAD",
false,
false,
&mut input,
&mut output,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_remove_propagates_api_errors() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/gmail/v1/users/me/messages/batchModify",
))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let mut input = Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
let err = run_remove(
&client,
&["m1".to_string()],
"UNREAD",
true,
false,
&mut input,
&mut output,
)
.await
.unwrap_err();
assert!(err.to_string().contains("500"));
}
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::other("simulated write failure"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn run_remove_force_propagates_success_message_write_errors() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/gmail/v1/users/me/messages/batchModify",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.mount(&server)
.await;
let mut input = Cursor::new(Vec::<u8>::new());
let err = run_remove(
&client,
&["m1".to_string()],
"UNREAD",
true,
false,
&mut input,
&mut FailingWriter,
)
.await
.unwrap_err();
assert!(err.to_string().contains("simulated write failure"));
}
#[tokio::test]
async fn execute_dry_run_makes_no_api_call_and_never_reads_stdin() {
let client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
let cmd = RemoveCommand {
message_ids: vec!["m1".to_string()],
label: "IMPORTANT".to_string(),
force: true,
dry_run: true,
};
cmd.execute(&client).await.unwrap();
}
#[tokio::test]
async fn dry_run_wins_over_force_no_api_call() {
let client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
let mut input = Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
run_remove(
&client,
&["m1".to_string()],
"IMPORTANT",
true,
true,
&mut input,
&mut output,
)
.await
.unwrap();
assert!(String::from_utf8(output).unwrap().contains("Would remove"));
}
}