use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use tempfile::TempDir;
use tokio::process::Command as AsyncCommand;
mod test_utils {
use super::*;
pub struct CliTestFixture {
pub temp_dir: TempDir,
pub config_dir: PathBuf,
pub binary_path: PathBuf,
}
impl CliTestFixture {
pub fn new() -> std::io::Result<Self> {
let temp_dir = TempDir::new()?;
let config_dir = temp_dir.path().join(".config").join("cull-gmail");
fs::create_dir_all(&config_dir)?;
let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_cull-gmail"));
if !binary_path.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("CLI binary not found at path: {binary_path:?}"),
));
}
Ok(Self {
temp_dir,
config_dir,
binary_path,
})
}
pub fn create_config_file(&self, content: &str) -> std::io::Result<PathBuf> {
let config_file = self.config_dir.join("config.toml");
fs::write(&config_file, content)?;
Ok(config_file)
}
pub fn create_credentials_file(&self, content: &str) -> std::io::Result<PathBuf> {
let creds_file = self.config_dir.join("client_secret.json");
fs::write(&creds_file, content)?;
Ok(creds_file)
}
pub fn execute_cli(
&self,
args: &[&str],
env_vars: Option<HashMap<&str, &str>>,
) -> std::io::Result<std::process::Output> {
let mut cmd = Command::new(&self.binary_path);
cmd.args(args);
cmd.env("HOME", self.temp_dir.path());
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
if let Some(env) = env_vars {
for (key, value) in env {
cmd.env(key, value);
}
}
cmd.output()
}
pub async fn execute_cli_async(
&self,
args: &[&str],
env_vars: Option<HashMap<&str, &str>>,
) -> std::io::Result<std::process::Output> {
let mut cmd = AsyncCommand::new(&self.binary_path);
cmd.args(args);
cmd.env("HOME", self.temp_dir.path());
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
if let Some(env) = env_vars {
for (key, value) in env {
cmd.env(key, value);
}
}
cmd.output().await
}
}
pub fn mock_credentials_json() -> &'static str {
r#"{
"installed": {
"client_id": "test-client-id.googleusercontent.com",
"project_id": "test-project",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": "test-client-secret",
"redirect_uris": ["http://localhost"]
}
}"#
}
pub fn mock_config_toml() -> &'static str {
r#"
[client]
client_id = "test-client-id"
client_secret = "test-client-secret"
max_results = "100"
[[rules]]
name = "old_promotions"
query = "category:promotions older_than:30d"
action = "delete"
enabled = true
[[rules]]
name = "old_social"
query = "category:social older_than:60d"
action = "trash"
enabled = false
"#
}
}
mod argument_parsing_tests {
use super::test_utils::*;
#[test]
fn test_cli_help_output() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["--help"], None)
.expect("Failed to execute CLI");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("cull-gmail"));
assert!(stdout.contains("USAGE:") || stdout.contains("Usage:"));
assert!(stdout.contains("labels"));
assert!(stdout.contains("messages"));
assert!(stdout.contains("rules"));
}
#[test]
fn test_cli_version_output() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["--version"], None)
.expect("Failed to execute CLI");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("cull-gmail"));
assert!(stdout.contains("0.0.10") || stdout.split_whitespace().count() >= 2);
}
#[test]
fn test_verbosity_flags() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let verbosity_tests = [
(vec!["-v", "labels"], "WARN"),
(vec!["-vv", "labels"], "INFO"),
(vec!["-vvv", "labels"], "DEBUG"),
(vec!["-vvvv", "labels"], "TRACE"),
];
for (args, _expected_level) in verbosity_tests {
let output = fixture
.execute_cli(&args, None)
.expect("Failed to execute CLI");
let exit_code = output.status.code().unwrap_or(0);
assert!(
exit_code != 2,
"Exit code 2 indicates argument parsing error, got: {exit_code}"
);
}
}
#[test]
fn test_invalid_subcommand() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["invalid-command"], None)
.expect("Failed to execute CLI");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("error:") || stderr.contains("unrecognized"));
}
}
mod labels_tests {
use super::test_utils::*;
#[test]
fn test_labels_help() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["labels", "--help"], None)
.expect("Failed to execute CLI");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("labels") || stdout.contains("List Gmail labels"));
}
#[test]
fn test_labels_without_credentials() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["labels"], None)
.expect("Failed to execute CLI");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
assert!(
stderr.contains("config")
|| stderr.contains("credentials")
|| stderr.contains("authentication")
|| stderr.contains("client_secret")
|| stderr.contains("OAuth")
|| stderr.contains("token")
);
}
}
#[test]
fn test_labels_with_mock_config() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
fixture
.create_config_file(mock_config_toml())
.expect("Failed to create config file");
fixture
.create_credentials_file(mock_credentials_json())
.expect("Failed to create credentials file");
let output = fixture
.execute_cli(&["labels"], None)
.expect("Failed to execute CLI");
let stderr = String::from_utf8_lossy(&output.stderr);
let config_found =
!stderr.contains("config file not found") && !stderr.contains("No such file");
let auth_related_failure = stderr.contains("OAuth")
|| stderr.contains("authentication")
|| stderr.contains("token")
|| stderr.contains("credentials")
|| stderr.contains("client");
assert!(
output.status.success() || config_found || auth_related_failure,
"Command failed unexpectedly. Exit code: {:?}, stderr: {}",
output.status.code(),
stderr
);
}
}
mod messages_tests {
use super::test_utils::*;
#[test]
fn test_messages_help() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["messages", "--help"], None)
.expect("Failed to execute CLI");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("messages"));
assert!(stdout.contains("query") || stdout.contains("QUERY"));
}
#[test]
fn test_messages_list_action() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["messages", "--query", "in:inbox", "list"], None)
.expect("Failed to execute CLI");
let exit_code = output.status.code().unwrap_or(0);
assert!(
exit_code != 2,
"Exit code 2 indicates argument parsing error, got: {exit_code}"
);
}
#[test]
fn test_messages_trash_action() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["messages", "--query", "in:spam", "trash"], None)
.expect("Failed to execute CLI");
let exit_code = output.status.code().unwrap_or(0);
assert!(
exit_code != 2,
"Exit code 2 indicates argument parsing error, got: {exit_code}"
);
}
#[test]
fn test_messages_pagination_options() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(
&[
"messages",
"--query",
"in:inbox",
"--max-results",
"50",
"--pages",
"2",
"list",
],
None,
)
.expect("Failed to execute CLI");
let exit_code = output.status.code().unwrap_or(0);
assert!(
exit_code != 2,
"Exit code 2 indicates argument parsing error, got: {exit_code}"
);
}
#[test]
fn test_messages_invalid_action() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["messages", "--query", "test", "invalid-action"], None)
.expect("Failed to execute CLI");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("error:") || stderr.contains("invalid"));
}
#[test]
fn test_messages_without_query() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["messages", "list"], None)
.expect("Failed to execute CLI");
let exit_code = output.status.code().unwrap_or(0);
assert!(
exit_code != 2,
"Exit code 2 indicates argument parsing error, got: {exit_code}"
);
}
}
mod rules_tests {
use super::test_utils::*;
use std::collections::HashMap;
use std::fs;
#[test]
fn test_rules_help() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["rules", "--help"], None)
.expect("Failed to execute CLI");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("rules"));
assert!(stdout.contains("config") || stdout.contains("run"));
}
#[test]
fn test_rules_config_subcommand() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["rules", "config"], None)
.expect("Failed to execute CLI");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success()
|| stdout.contains("config")
|| stderr.contains("config")
|| stdout.contains("toml")
|| stderr.contains("toml")
);
}
#[test]
fn test_rules_run_without_config() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["rules", "run"], None)
.expect("Failed to execute CLI");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("config") || stderr.contains("file") || stderr.contains("not found")
);
}
#[test]
#[ignore = "This test requires OAuth and may hang in CI environments"]
fn test_rules_run_with_config() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
fixture
.create_config_file(mock_config_toml())
.expect("Failed to create config file");
fixture
.create_credentials_file(mock_credentials_json())
.expect("Failed to create credentials file");
let legacy_dir = fixture.temp_dir.path().join(".cull-gmail");
fs::create_dir_all(&legacy_dir).expect("Failed to create legacy config directory");
let legacy_config_path = legacy_dir.join("cull-gmail.toml");
fs::write(&legacy_config_path, mock_config_toml()).expect("Failed to write legacy config");
let legacy_creds_path = legacy_dir.join("credential.json");
fs::write(&legacy_creds_path, mock_credentials_json())
.expect("Failed to write legacy credentials");
let mut env_vars = HashMap::new();
env_vars.insert("HTTP_TIMEOUT", "5");
env_vars.insert("CONNECT_TIMEOUT", "3");
let output = fixture
.execute_cli(&["rules", "run"], Some(env_vars))
.expect("Failed to execute CLI");
let stderr = String::from_utf8_lossy(&output.stderr);
let exit_code = output.status.code().unwrap_or(0);
let config_processed =
!stderr.contains("config file not found") && !stderr.contains("No such file");
let auth_failure = stderr.contains("credentials")
|| stderr.contains("authentication")
|| stderr.contains("OAuth")
|| stderr.contains("token");
let credential_issue = stderr.contains("could not read path");
assert!(
output.status.success() || auth_failure || config_processed || credential_issue,
"Rules command failed unexpectedly. Exit code: {exit_code}, stderr: {stderr}"
);
}
#[test]
fn test_rules_run_execution() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
fixture
.create_config_file(mock_config_toml())
.expect("Failed to create config file");
let output = fixture
.execute_cli(&["rules", "run"], None)
.expect("Failed to execute CLI");
let exit_code = output.status.code().unwrap_or(0);
assert!(
exit_code != 2,
"Exit code 2 indicates argument parsing error, got: {exit_code}"
);
}
#[test]
fn test_rules_config_validation() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
fixture
.create_config_file(mock_config_toml())
.expect("Failed to create config file");
let output = fixture
.execute_cli(&["rules", "config"], None)
.expect("Failed to execute CLI");
let exit_code = output.status.code().unwrap_or(0);
assert!(
exit_code != 139, "Rules config command crashed. Exit code: {exit_code}"
);
}
}
mod configuration_tests {
use super::test_utils::*;
use std::collections::HashMap;
#[test]
fn test_config_file_hierarchy() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let config_content = r#"
[client]
client_id = "test-from-config"
client_secret = "secret-from-config"
"#;
fixture
.create_config_file(config_content)
.expect("Failed to create config");
let output = fixture
.execute_cli(&["labels"], None)
.expect("Failed to execute CLI");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!stderr.contains("config file not found"));
}
#[test]
fn test_environment_variable_precedence() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let mut env_vars = HashMap::new();
env_vars.insert("CULL_GMAIL_CLIENT_ID", "env-client-id");
env_vars.insert("CULL_GMAIL_CLIENT_SECRET", "env-secret");
let output = fixture
.execute_cli(&["labels"], Some(env_vars))
.expect("Failed to execute CLI");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!stderr.contains("client_id"));
}
#[test]
fn test_invalid_config_format() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
fixture
.create_config_file("invalid toml content [[[")
.expect("Failed to create config");
let output = fixture
.execute_cli(&["labels"], None)
.expect("Failed to execute CLI");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("config") || stderr.contains("parse") || stderr.contains("toml"));
}
}
mod error_handling_tests {
use super::test_utils::*;
use std::collections::HashMap;
use std::fs;
#[test]
fn test_graceful_keyboard_interrupt() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let output = fixture
.execute_cli(&["messages", "--query", "test", "list"], None)
.expect("Failed to execute CLI");
let exit_code = output.status.code().unwrap_or(0);
assert!(
exit_code != 139,
"Segmentation fault detected, got exit code: {exit_code}"
);
}
#[test]
fn test_invalid_query_syntax() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
fixture
.create_config_file(mock_config_toml())
.expect("Failed to create config");
fixture
.create_credentials_file(mock_credentials_json())
.expect("Failed to create credentials");
let output = fixture
.execute_cli(
&["messages", "--query", "invalid:query:syntax:::", "list"],
None,
)
.expect("Failed to execute CLI");
let exit_code = output.status.code().unwrap_or(0);
assert!(
exit_code != 139,
"Segmentation fault detected, got exit code: {exit_code}"
);
}
#[test]
fn test_network_timeout_simulation() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let mut env_vars = HashMap::new();
env_vars.insert("HTTP_TIMEOUT", "1");
env_vars.insert("CONNECT_TIMEOUT", "1");
fixture
.create_config_file(mock_config_toml())
.expect("Failed to create config");
fixture
.create_credentials_file(mock_credentials_json())
.expect("Failed to create credentials");
let output = fixture
.execute_cli(&["labels"], Some(env_vars))
.expect("Failed to execute CLI");
let stderr = String::from_utf8_lossy(&output.stderr);
let exit_code = output.status.code().unwrap_or(0);
assert!(
exit_code != 139, "Command crashed with segfault. Exit code: {exit_code}, stderr: {stderr}"
);
let has_expected_errors = output.status.success()
|| stderr.contains("timeout")
|| stderr.contains("network")
|| stderr.contains("connection")
|| stderr.contains("authentication")
|| stderr.contains("OAuth")
|| stderr.contains("credentials");
if !has_expected_errors {
eprintln!("Warning: Unexpected error type. Exit code: {exit_code}, stderr: {stderr}");
}
}
#[test]
fn test_permission_denied_scenarios() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let config_path = fixture
.create_config_file(mock_config_toml())
.expect("Failed to create config");
let legacy_dir = fixture.temp_dir.path().join(".cull-gmail");
fs::create_dir_all(&legacy_dir).expect("Failed to create legacy config directory");
let legacy_config_path = legacy_dir.join("cull-gmail.toml");
fs::write(&legacy_config_path, mock_config_toml()).expect("Failed to write legacy config");
let permission_change_worked = {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let restrict_both = [
fs::metadata(&config_path).ok().and_then(|metadata| {
let mut perms = metadata.permissions();
perms.set_mode(0o000);
fs::set_permissions(&config_path, perms).ok()
}),
fs::metadata(&legacy_config_path).ok().and_then(|metadata| {
let mut perms = metadata.permissions();
perms.set_mode(0o000);
fs::set_permissions(&legacy_config_path, perms).ok()
}),
];
restrict_both.iter().any(|result| result.is_some())
}
#[cfg(not(unix))]
{
false }
};
let output = fixture
.execute_cli(&["labels"], None)
.expect("Failed to execute CLI");
let stderr = String::from_utf8_lossy(&output.stderr);
let exit_code = output.status.code().unwrap_or(0);
if permission_change_worked {
assert!(
!output.status.success()
&& (stderr.contains("permission")
|| stderr.contains("access")
|| stderr.contains("denied")
|| stderr.contains("Permission denied")),
"Expected permission error when config file is unreadable. Exit code: {exit_code}, stderr: {stderr}"
);
} else {
assert!(
exit_code != 139, "Command should not crash even if permission test cannot run. Exit code: {exit_code}, stderr: {stderr}"
);
}
}
}
mod async_integration_tests {
use super::test_utils::*;
#[tokio::test]
async fn test_concurrent_cli_executions() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
fixture
.create_config_file(mock_config_toml())
.expect("Failed to create config");
let tasks = vec![
fixture.execute_cli_async(&["labels", "--help"], None),
fixture.execute_cli_async(&["messages", "--help"], None),
fixture.execute_cli_async(&["rules", "--help"], None),
];
let results = futures::future::join_all(tasks).await;
for result in results {
let output = result.expect("Failed to execute CLI");
assert!(output.status.success());
}
}
#[tokio::test]
async fn test_async_command_timeout() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
fixture
.create_config_file(mock_config_toml())
.expect("Failed to create config");
fixture
.create_credentials_file(mock_credentials_json())
.expect("Failed to create credentials");
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
fixture.execute_cli_async(&["labels"], None),
)
.await;
match result {
Ok(output) => {
let output = output.expect("Failed to execute CLI");
let exit_code = output.status.code().unwrap_or(0);
assert!(
exit_code != 139,
"Segmentation fault detected, got exit code: {exit_code}"
);
}
Err(_) => {
}
}
}
}
#[cfg(test)]
mod rules_validate_tests {
use super::test_utils::CliTestFixture;
use std::fs;
fn valid_rules_toml() -> &'static str {
r#"
[rules."1"]
id = 1
retention = "d:30"
labels = ["test-label"]
action = "Trash"
"#
}
fn invalid_rules_toml() -> &'static str {
r#"
[rules."1"]
id = 1
retention = ""
labels = []
action = "bad-action"
"#
}
fn duplicate_label_rules_toml() -> &'static str {
r#"
[rules."1"]
id = 1
retention = "d:30"
labels = ["shared"]
action = "Trash"
[rules."2"]
id = 2
retention = "d:60"
labels = ["shared"]
action = "Trash"
"#
}
#[test]
fn test_rules_validate_valid_file_exits_zero() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let rules_file = fixture.temp_dir.path().join("rules.toml");
fs::write(&rules_file, valid_rules_toml()).unwrap();
let output = fixture
.execute_cli(&["rules", rules_file.to_str().unwrap(), "validate"], None)
.expect("Failed to execute CLI");
assert!(
output.status.success(),
"Expected exit 0 for valid rules, got: {}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
#[test]
fn test_rules_validate_invalid_file_exits_nonzero() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let rules_file = fixture.temp_dir.path().join("rules.toml");
fs::write(&rules_file, invalid_rules_toml()).unwrap();
let output = fixture
.execute_cli(&["rules", rules_file.to_str().unwrap(), "validate"], None)
.expect("Failed to execute CLI");
assert!(
!output.status.success(),
"Expected non-zero exit for invalid rules"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("Rule #1"),
"Expected issue output mentioning rule #1, got: {combined}"
);
}
#[test]
fn test_rules_validate_duplicate_label_exits_nonzero() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let rules_file = fixture.temp_dir.path().join("rules.toml");
fs::write(&rules_file, duplicate_label_rules_toml()).unwrap();
let output = fixture
.execute_cli(&["rules", rules_file.to_str().unwrap(), "validate"], None)
.expect("Failed to execute CLI");
assert!(
!output.status.success(),
"Expected non-zero exit for duplicate label rules"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("shared"),
"Expected output mentioning 'shared' label, got: {combined}"
);
}
#[test]
fn test_rules_validate_missing_file_exits_nonzero() {
let fixture = CliTestFixture::new().expect("Failed to create test fixture");
let rules_file = fixture.temp_dir.path().join("nonexistent.toml");
let output = fixture
.execute_cli(&["rules", rules_file.to_str().unwrap(), "validate"], None)
.expect("Failed to execute CLI");
assert!(
!output.status.success(),
"Expected non-zero exit for missing rules file"
);
}
}