From 2cfd16c8ac5383bbf2cdc9a54f0f4adfb8e57ba8 Mon Sep 17 00:00:00 2001 From: Jeremiah Russell Date: Tue, 21 Oct 2025 11:50:38 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20test(init):=20unit=20tests=20for?= =?UTF-8?q?=20planning=20and=20file=20IO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/init_cli.rs | 39 ++-- src/cli/init_cli/tests.rs | 372 ++++++++++++++++++++++++++++++++ tests/init_integration_tests.rs | 319 +++++++++++++++++++++++++++ 3 files changed, 712 insertions(+), 18 deletions(-) create mode 100644 src/cli/init_cli/tests.rs create mode 100644 tests/init_integration_tests.rs diff --git a/src/cli/init_cli.rs b/src/cli/init_cli.rs index 8c9f08b..ad51f36 100644 --- a/src/cli/init_cli.rs +++ b/src/cli/init_cli.rs @@ -417,13 +417,13 @@ impl InitCli { .with_prompt("Do you have a credential file to set up now?") .default(true) .interact() - .map_err(|e| Error::FileIo(format!("Interactive prompt failed: {}", e)))?; + .map_err(|e| Error::FileIo(format!("Interactive prompt failed: {e}")))?; if should_provide { let cred_path: String = Input::new() .with_prompt("Path to credential JSON file") .interact_text() - .map_err(|e| Error::FileIo(format!("Interactive input failed: {}", e)))?; + .map_err(|e| Error::FileIo(format!("Interactive input failed: {e}")))?; let cred_file = PathBuf::from(cred_path); self.validate_credential_file(&cred_file)?; @@ -446,11 +446,11 @@ impl InitCli { } let content = fs::read_to_string(path) - .map_err(|e| Error::FileIo(format!("Cannot read credential file: {}", e)))?; + .map_err(|e| Error::FileIo(format!("Cannot read credential file: {e}")))?; // Try to parse as ConsoleApplicationSecret to validate format serde_json::from_str::(&content).map_err(|e| { - Error::SerializationError(format!("Invalid credential file format: {}", e)) + Error::SerializationError(format!("Invalid credential file format: {e}")) })?; log::info!("Credential file validated: {}", path.display()); @@ -588,7 +588,7 @@ impl InitCli { for (i, operation) in operations.iter().enumerate() { pb.set_position(i as u64); - pb.set_message(format!("{}", operation)); + pb.set_message(format!("{operation}")); self.execute_operation(operation).await?; @@ -608,7 +608,7 @@ impl InitCli { Operation::CreateDir { path, .. } => { log::info!("Creating directory: {}", path.display()); fs::create_dir_all(path) - .map_err(|e| Error::FileIo(format!("Failed to create directory: {}", e)))?; + .map_err(|e| Error::FileIo(format!("Failed to create directory: {e}")))?; #[cfg(unix)] if let Some(mode) = operation.get_mode() { @@ -626,10 +626,10 @@ impl InitCli { self.create_backup(to)?; } else if to.exists() && self.interactive { let should_overwrite = Confirm::new() - .with_prompt(&format!("Overwrite existing file {}?", to.display())) + .with_prompt(format!("Overwrite existing file {}?", to.display())) .default(false) .interact() - .map_err(|e| Error::FileIo(format!("Interactive prompt failed: {}", e)))?; + .map_err(|e| Error::FileIo(format!("Interactive prompt failed: {e}")))?; if !should_overwrite { log::info!("Skipping file copy due to user choice"); @@ -641,7 +641,7 @@ impl InitCli { log::info!("Copying file: {} → {}", from.display(), to.display()); fs::copy(from, to) - .map_err(|e| Error::FileIo(format!("Failed to copy file: {}", e)))?; + .map_err(|e| Error::FileIo(format!("Failed to copy file: {e}")))?; #[cfg(unix)] if let Some(mode) = operation.get_mode() { @@ -659,10 +659,10 @@ impl InitCli { self.create_backup(path)?; } else if path.exists() && self.interactive { let should_overwrite = Confirm::new() - .with_prompt(&format!("Overwrite existing file {}?", path.display())) + .with_prompt(format!("Overwrite existing file {}?", path.display())) .default(false) .interact() - .map_err(|e| Error::FileIo(format!("Interactive prompt failed: {}", e)))?; + .map_err(|e| Error::FileIo(format!("Interactive prompt failed: {e}")))?; if !should_overwrite { log::info!("Skipping file write due to user choice"); @@ -674,7 +674,7 @@ impl InitCli { log::info!("Writing file: {}", path.display()); fs::write(path, contents) - .map_err(|e| Error::FileIo(format!("Failed to write file: {}", e)))?; + .map_err(|e| Error::FileIo(format!("Failed to write file: {e}")))?; #[cfg(unix)] if let Some(mode) = operation.get_mode() { @@ -685,7 +685,7 @@ impl InitCli { Operation::EnsureTokenDir { path, .. } => { log::info!("Ensuring token directory: {}", path.display()); fs::create_dir_all(path).map_err(|e| { - Error::FileIo(format!("Failed to create token directory: {}", e)) + Error::FileIo(format!("Failed to create token directory: {e}")) })?; #[cfg(unix)] @@ -713,7 +713,7 @@ impl InitCli { /// Create a timestamped backup of a file. fn create_backup(&self, file_path: &Path) -> Result<()> { let timestamp = Local::now().format("%Y%m%d%H%M%S"); - let backup_path = file_path.with_extension(format!("bak-{}", timestamp)); + let backup_path = file_path.with_extension(format!("bak-{timestamp}")); log::info!( "Creating backup: {} → {}", @@ -721,7 +721,7 @@ impl InitCli { backup_path.display() ); fs::copy(file_path, &backup_path) - .map_err(|e| Error::FileIo(format!("Failed to create backup: {}", e)))?; + .map_err(|e| Error::FileIo(format!("Failed to create backup: {e}")))?; Ok(()) } @@ -732,13 +732,13 @@ impl InitCli { use std::os::unix::fs::PermissionsExt; let metadata = fs::metadata(path) - .map_err(|e| Error::FileIo(format!("Failed to get file metadata: {}", e)))?; + .map_err(|e| Error::FileIo(format!("Failed to get file metadata: {e}")))?; let mut permissions = metadata.permissions(); permissions.set_mode(mode); fs::set_permissions(path, permissions) - .map_err(|e| Error::FileIo(format!("Failed to set file permissions: {}", e)))?; + .map_err(|e| Error::FileIo(format!("Failed to set file permissions: {e}")))?; log::debug!("Set permissions {:o} on {}", mode, path.display()); Ok(()) @@ -760,7 +760,7 @@ impl InitCli { // Initialize Gmail client which will trigger OAuth flow if needed let client = GmailClient::new_with_config(client_config) .await - .map_err(|e| Error::FileIo(format!("OAuth2 authentication failed: {}", e)))?; + .map_err(|e| Error::FileIo(format!("OAuth2 authentication failed: {e}")))?; // The client initialization already verified the connection by fetching labels // We can just show some labels to confirm it's working @@ -821,3 +821,6 @@ impl Operation { } } } + +#[cfg(test)] +mod tests; diff --git a/src/cli/init_cli/tests.rs b/src/cli/init_cli/tests.rs new file mode 100644 index 0000000..d4ee9df --- /dev/null +++ b/src/cli/init_cli/tests.rs @@ -0,0 +1,372 @@ +//! Unit tests for init CLI functionality. + +#[cfg(test)] +mod unit_tests { + use super::super::*; + use tempfile::TempDir; + use std::fs; + use std::path::Path; + + /// Test helper to create a mock credential file + fn create_mock_credential_file(dir: &Path) -> std::io::Result<()> { + let credential_content = r#"{ + "installed": { + "client_id": "test-client-id.googleusercontent.com", + "client_secret": "test-client-secret", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "redirect_uris": ["http://localhost"] + } + }"#; + fs::write(dir.join("credential.json"), credential_content) + } + + #[test] + fn test_parse_config_root_home() { + let result = parse_config_root("h:.test-config"); + let home = env::home_dir().unwrap_or_default(); + assert_eq!(result, home.join(".test-config")); + } + + #[test] + fn test_parse_config_root_current() { + let result = parse_config_root("c:.test-config"); + let current = env::current_dir().unwrap_or_default(); + assert_eq!(result, current.join(".test-config")); + } + + #[test] + fn test_parse_config_root_root() { + let result = parse_config_root("r:etc/cull-gmail"); + assert_eq!(result, std::path::PathBuf::from("/etc/cull-gmail")); + } + + #[test] + fn test_parse_config_root_no_prefix() { + let result = parse_config_root("/absolute/path"); + assert_eq!(result, std::path::PathBuf::from("/absolute/path")); + } + + #[test] + fn test_init_defaults() { + assert_eq!(InitDefaults::credential_filename(), "credential.json"); + assert_eq!(InitDefaults::config_filename(), "cull-gmail.toml"); + assert_eq!(InitDefaults::rules_filename(), "rules.toml"); + assert_eq!(InitDefaults::token_dir_name(), "gmail1"); + + // Test that config content contains expected keys + let config_content = InitDefaults::CONFIG_FILE_CONTENT; + assert!(config_content.contains("credential_file = \"credential.json\"")); + assert!(config_content.contains("config_root = \"h:.cull-gmail\"")); + assert!(config_content.contains("execute = false")); + + // Test that rules content is a valid template + let rules_content = InitDefaults::RULES_FILE_CONTENT; + assert!(rules_content.contains("# Example rules for cull-gmail")); + assert!(rules_content.contains("older_than:30d")); + } + + #[test] + fn test_validate_credential_file_success() { + let temp_dir = TempDir::new().unwrap(); + create_mock_credential_file(temp_dir.path()).unwrap(); + + let init_cli = InitCli { + config_dir: "test".to_string(), + credential_file: None, + force: false, + dry_run: false, + interactive: false, + }; + + let credential_path = temp_dir.path().join("credential.json"); + let result = init_cli.validate_credential_file(&credential_path); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_credential_file_not_found() { + let temp_dir = TempDir::new().unwrap(); + let init_cli = InitCli { + config_dir: "test".to_string(), + credential_file: None, + force: false, + dry_run: false, + interactive: false, + }; + + let nonexistent_path = temp_dir.path().join("nonexistent.json"); + let result = init_cli.validate_credential_file(&nonexistent_path); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not found")); + } + + #[test] + fn test_validate_credential_file_invalid_json() { + let temp_dir = TempDir::new().unwrap(); + let credential_path = temp_dir.path().join("invalid.json"); + fs::write(&credential_path, "invalid json content").unwrap(); + + let init_cli = InitCli { + config_dir: "test".to_string(), + credential_file: None, + force: false, + dry_run: false, + interactive: false, + }; + + let result = init_cli.validate_credential_file(&credential_path); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Invalid credential file format")); + } + + #[test] + fn test_plan_operations_new_setup() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("new-config"); + + let init_cli = InitCli { + config_dir: "test".to_string(), + credential_file: None, + force: false, + dry_run: false, + interactive: false, + }; + + let operations = init_cli.plan_operations(&config_path, None).unwrap(); + + // Should have: CreateDir, WriteFile (config), WriteFile (rules), EnsureTokenDir + assert_eq!(operations.len(), 4); + + match &operations[0] { + Operation::CreateDir { path, .. } => { + assert_eq!(path, &config_path); + } + _ => panic!("Expected CreateDir operation"), + } + + match &operations[1] { + Operation::WriteFile { path, contents, .. } => { + assert_eq!(path, &config_path.join("cull-gmail.toml")); + assert!(contents.contains("credential_file = \"credential.json\"")); + } + _ => panic!("Expected WriteFile operation for config"), + } + + match &operations[2] { + Operation::WriteFile { path, contents, .. } => { + assert_eq!(path, &config_path.join("rules.toml")); + assert!(contents.contains("# Example rules for cull-gmail")); + } + _ => panic!("Expected WriteFile operation for rules"), + } + + match &operations[3] { + Operation::EnsureTokenDir { path, .. } => { + assert_eq!(path, &config_path.join("gmail1")); + } + _ => panic!("Expected EnsureTokenDir operation"), + } + } + + #[test] + fn test_plan_operations_with_credential_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("new-config"); + let cred_path = temp_dir.path().join("cred.json"); + create_mock_credential_file(temp_dir.path()).unwrap(); + fs::rename(temp_dir.path().join("credential.json"), &cred_path).unwrap(); + + let init_cli = InitCli { + config_dir: "test".to_string(), + credential_file: None, + force: false, + dry_run: false, + interactive: false, + }; + + let operations = init_cli.plan_operations(&config_path, Some(&cred_path)).unwrap(); + + // Should have: CreateDir, CopyFile (credential), WriteFile (config), WriteFile (rules), EnsureTokenDir, RunOAuth2 + assert_eq!(operations.len(), 6); + + // Check that CopyFile operation exists + let copy_op = operations.iter().find(|op| matches!(op, Operation::CopyFile { .. })); + assert!(copy_op.is_some()); + + // Check that RunOAuth2 operation exists + let oauth_op = operations.iter().find(|op| matches!(op, Operation::RunOAuth2 { .. })); + assert!(oauth_op.is_some()); + } + + #[test] + fn test_plan_operations_existing_config_no_force() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("existing-config"); + fs::create_dir_all(&config_path).unwrap(); + + // Create existing config file + fs::write(config_path.join("cull-gmail.toml"), "existing config").unwrap(); + + let init_cli = InitCli { + config_dir: "test".to_string(), + credential_file: None, + force: false, + dry_run: false, + interactive: false, + }; + + let result = init_cli.plan_operations(&config_path, None); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("already exists")); + } + + #[test] + fn test_plan_operations_existing_config_with_force() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("existing-config"); + fs::create_dir_all(&config_path).unwrap(); + + // Create existing config file + fs::write(config_path.join("cull-gmail.toml"), "existing config").unwrap(); + fs::write(config_path.join("rules.toml"), "existing rules").unwrap(); + + let init_cli = InitCli { + config_dir: "test".to_string(), + credential_file: None, + force: true, + dry_run: false, + interactive: false, + }; + + let operations = init_cli.plan_operations(&config_path, None).unwrap(); + + // Should succeed and plan backup operations + let config_op = operations.iter().find(|op| { + if let Operation::WriteFile { path, backup_if_exists, .. } = op { + path.file_name().unwrap() == "cull-gmail.toml" && *backup_if_exists + } else { + false + } + }); + assert!(config_op.is_some()); + } + + #[test] + fn test_create_backup() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.txt"); + fs::write(&test_file, "test content").unwrap(); + + let init_cli = InitCli { + config_dir: "test".to_string(), + credential_file: None, + force: false, + dry_run: false, + interactive: false, + }; + + let result = init_cli.create_backup(&test_file); + assert!(result.is_ok()); + + // Check that a backup file was created + let backup_files: Vec<_> = fs::read_dir(temp_dir.path()) + .unwrap() + .filter_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("test.bak-") { + Some(name) + } else { + None + } + }) + .collect(); + + assert_eq!(backup_files.len(), 1); + + // Verify backup content + let backup_path = temp_dir.path().join(&backup_files[0]); + let backup_content = fs::read_to_string(backup_path).unwrap(); + assert_eq!(backup_content, "test content"); + } + + #[cfg(unix)] + #[test] + fn test_set_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.txt"); + fs::write(&test_file, "test content").unwrap(); + + let init_cli = InitCli { + config_dir: "test".to_string(), + credential_file: None, + force: false, + dry_run: false, + interactive: false, + }; + + let result = init_cli.set_permissions(&test_file, 0o600); + assert!(result.is_ok()); + + let metadata = fs::metadata(&test_file).unwrap(); + let permissions = metadata.permissions(); + assert_eq!(permissions.mode() & 0o777, 0o600); + } + + #[test] + fn test_operation_display() { + let temp_path = std::path::PathBuf::from("/tmp/test"); + + let create_dir_op = Operation::CreateDir { + path: temp_path.clone(), + #[cfg(unix)] + mode: Some(0o755), + }; + assert_eq!(format!("{create_dir_op}"), "Create directory: /tmp/test"); + + let copy_file_op = Operation::CopyFile { + from: temp_path.clone(), + to: temp_path.join("dest"), + #[cfg(unix)] + mode: Some(0o600), + backup_if_exists: false, + }; + assert_eq!(format!("{copy_file_op}"), "Copy file: /tmp/test → /tmp/test/dest"); + + let write_file_op = Operation::WriteFile { + path: temp_path.clone(), + contents: "content".to_string(), + #[cfg(unix)] + mode: Some(0o644), + backup_if_exists: false, + }; + assert_eq!(format!("{write_file_op}"), "Write file: /tmp/test"); + + let oauth_op = Operation::RunOAuth2 { + config_root: "h:.config".to_string(), + credential_file: Some("cred.json".to_string()), + }; + assert_eq!(format!("{oauth_op}"), "Run OAuth2 authentication flow"); + } + + #[cfg(unix)] + #[test] + fn test_operation_get_mode() { + let temp_path = std::path::PathBuf::from("/tmp/test"); + + let create_dir_op = Operation::CreateDir { + path: temp_path.clone(), + mode: Some(0o755), + }; + assert_eq!(create_dir_op.get_mode(), Some(0o755)); + + let oauth_op = Operation::RunOAuth2 { + config_root: "h:.config".to_string(), + credential_file: Some("cred.json".to_string()), + }; + assert_eq!(oauth_op.get_mode(), None); + } +} \ No newline at end of file diff --git a/tests/init_integration_tests.rs b/tests/init_integration_tests.rs new file mode 100644 index 0000000..31aa282 --- /dev/null +++ b/tests/init_integration_tests.rs @@ -0,0 +1,319 @@ +//! Integration tests for the init CLI command. + +use assert_cmd::prelude::*; +use assert_fs::prelude::*; +use predicates::prelude::*; +use std::process::Command; + +#[test] +fn test_init_help() { + let mut cmd = Command::cargo_bin("cull-gmail").unwrap(); + cmd.args(["init", "--help"]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Initialize cull-gmail configuration")) + .stdout(predicate::str::contains("--config-dir")) + .stdout(predicate::str::contains("--credential-file")) + .stdout(predicate::str::contains("--dry-run")) + .stdout(predicate::str::contains("--interactive")) + .stdout(predicate::str::contains("--force")); +} + +#[test] +fn test_init_dry_run_new_setup() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + let config_dir = temp_dir.path().join("test-config"); + + let mut cmd = Command::cargo_bin("cull-gmail").unwrap(); + cmd.args([ + "init", + "--config-dir", + &format!("c:{}", config_dir.to_string_lossy()), + "--dry-run" + ]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("DRY RUN: No changes will be made")) + .stdout(predicate::str::contains("Planned operations:")) + .stdout(predicate::str::contains("Create directory:")) + .stdout(predicate::str::contains("Write file:")) + .stdout(predicate::str::contains("cull-gmail.toml")) + .stdout(predicate::str::contains("rules.toml")) + .stdout(predicate::str::contains("Ensure token directory:")) + .stdout(predicate::str::contains("gmail1")) + .stdout(predicate::str::contains("OAuth2 authentication skipped")) + .stdout(predicate::str::contains("To apply these changes, run without --dry-run")); + + // Verify no files were actually created + assert!(!config_dir.exists()); + + temp_dir.close().unwrap(); +} + +#[test] +fn test_init_dry_run_with_credential_file() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + let config_dir = temp_dir.path().join("test-config"); + let credential_file = temp_dir.child("credential.json"); + + // Create a mock credential file + credential_file.write_str(r#"{ + "installed": { + "client_id": "test-client-id.googleusercontent.com", + "client_secret": "test-client-secret", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "redirect_uris": ["http://localhost"] + } + }"#).unwrap(); + + let mut cmd = Command::cargo_bin("cull-gmail").unwrap(); + cmd.args([ + "init", + "--config-dir", + &format!("c:{}", config_dir.to_string_lossy()), + "--credential-file", + credential_file.path().to_str().unwrap(), + "--dry-run" + ]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("DRY RUN: No changes will be made")) + .stdout(predicate::str::contains("Planned operations:")) + .stdout(predicate::str::contains("Copy file:")) + .stdout(predicate::str::contains("credential.json")) + .stdout(predicate::str::contains("OAuth2 authentication would open")); + + // Verify no files were actually created + assert!(!config_dir.exists()); + + temp_dir.close().unwrap(); +} + +#[test] +fn test_init_actual_execution() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + let config_dir = temp_dir.path().join("test-config"); + + let mut cmd = Command::cargo_bin("cull-gmail").unwrap(); + cmd.args([ + "init", + "--config-dir", + &format!("c:{}", config_dir.to_string_lossy()), + ]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Initialization completed successfully!")) + .stdout(predicate::str::contains("Configuration directory:")) + .stdout(predicate::str::contains("Files created:")) + .stdout(predicate::str::contains("cull-gmail.toml")) + .stdout(predicate::str::contains("rules.toml")) + .stdout(predicate::str::contains("Next steps:")); + + // Verify files were actually created + assert!(config_dir.exists()); + assert!(config_dir.join("cull-gmail.toml").exists()); + assert!(config_dir.join("rules.toml").exists()); + assert!(config_dir.join("gmail1").exists()); + + // Verify file contents + let config_content = std::fs::read_to_string(config_dir.join("cull-gmail.toml")).unwrap(); + assert!(config_content.contains("credential_file = \"credential.json\"")); + assert!(config_content.contains("execute = false")); + + let rules_content = std::fs::read_to_string(config_dir.join("rules.toml")).unwrap(); + assert!(rules_content.contains("# Example rules for cull-gmail")); + + temp_dir.close().unwrap(); +} + +#[test] +fn test_init_force_overwrite() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + let config_dir = temp_dir.path().join("test-config"); + + // Create config directory and file first + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write(config_dir.join("cull-gmail.toml"), "old config").unwrap(); + + let mut cmd = Command::cargo_bin("cull-gmail").unwrap(); + cmd.args([ + "init", + "--config-dir", + &format!("c:{}", config_dir.to_string_lossy()), + "--force", + "--dry-run" + ]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("DRY RUN: No changes will be made")) + .stdout(predicate::str::contains("(with backup)")); + + temp_dir.close().unwrap(); +} + +#[test] +fn test_init_existing_config_no_force_fails() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + let config_dir = temp_dir.path().join("test-config"); + + // Create config directory and file first + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write(config_dir.join("cull-gmail.toml"), "existing config").unwrap(); + + let mut cmd = Command::cargo_bin("cull-gmail").unwrap(); + cmd.args([ + "init", + "--config-dir", + &format!("c:{}", config_dir.to_string_lossy()), + ]); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("already exists")) + .stderr(predicate::str::contains("--force")) + .stderr(predicate::str::contains("--interactive")); + + temp_dir.close().unwrap(); +} + +#[test] +fn test_init_with_credential_file_copy() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + let config_dir = temp_dir.path().join("test-config"); + let credential_file = temp_dir.child("source_credential.json"); + + // Create a mock credential file + credential_file.write_str(r#"{ + "installed": { + "client_id": "test-client-id.googleusercontent.com", + "client_secret": "test-client-secret", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "redirect_uris": ["http://localhost"] + } + }"#).unwrap(); + + let mut cmd = Command::cargo_bin("cull-gmail").unwrap(); + cmd.args([ + "init", + "--config-dir", + &format!("c:{}", config_dir.to_string_lossy()), + "--credential-file", + credential_file.path().to_str().unwrap(), + // Skip OAuth for this test since we don't have real credentials + ]); + + // This will fail at OAuth step, but we can check that files were created correctly + let _output = cmd.output().unwrap(); + + // Verify files were created up to the OAuth step + assert!(config_dir.exists()); + assert!(config_dir.join("cull-gmail.toml").exists()); + assert!(config_dir.join("rules.toml").exists()); + assert!(config_dir.join("credential.json").exists()); + assert!(config_dir.join("gmail1").exists()); + + // Verify credential file was copied + let copied_credential_content = std::fs::read_to_string(config_dir.join("credential.json")).unwrap(); + assert!(copied_credential_content.contains("test-client-id.googleusercontent.com")); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Verify credential file has secure permissions + let metadata = std::fs::metadata(config_dir.join("credential.json")).unwrap(); + let permissions = metadata.permissions(); + assert_eq!(permissions.mode() & 0o777, 0o600); + } + + temp_dir.close().unwrap(); +} + +#[test] +fn test_init_invalid_credential_file() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + let config_dir = temp_dir.path().join("test-config"); + let credential_file = temp_dir.child("invalid_credential.json"); + + // Create an invalid credential file + credential_file.write_str("invalid json content").unwrap(); + + let mut cmd = Command::cargo_bin("cull-gmail").unwrap(); + cmd.args([ + "init", + "--config-dir", + &format!("c:{}", config_dir.to_string_lossy()), + "--credential-file", + credential_file.path().to_str().unwrap(), + ]); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("Invalid credential file format")); + + temp_dir.close().unwrap(); +} + +#[test] +fn test_init_nonexistent_credential_file() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + let config_dir = temp_dir.path().join("test-config"); + let nonexistent_file = temp_dir.path().join("nonexistent.json"); + + let mut cmd = Command::cargo_bin("cull-gmail").unwrap(); + cmd.args([ + "init", + "--config-dir", + &format!("c:{}", config_dir.to_string_lossy()), + "--credential-file", + nonexistent_file.to_str().unwrap(), + ]); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("not found")); + + temp_dir.close().unwrap(); +} + +// This test would require real Gmail credentials and should be ignored by default +#[test] +#[ignore = "requires real Gmail OAuth2 credentials"] +fn test_init_oauth_integration() { + // This test should only run when CULL_GMAIL_TEST_CREDENTIAL_FILE is set + let credential_file = std::env::var("CULL_GMAIL_TEST_CREDENTIAL_FILE") + .expect("CULL_GMAIL_TEST_CREDENTIAL_FILE must be set for OAuth integration test"); + + let temp_dir = assert_fs::TempDir::new().unwrap(); + let config_dir = temp_dir.path().join("test-config"); + + let mut cmd = Command::cargo_bin("cull-gmail").unwrap(); + cmd.args([ + "init", + "--config-dir", + &format!("c:{}", config_dir.to_string_lossy()), + "--credential-file", + &credential_file, + ]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("OAuth2 authentication successful!")) + .stdout(predicate::str::contains("gmail1/ (OAuth2 token cache)")); + + // Verify token files were created + assert!(config_dir.join("gmail1").exists()); + + // Check if there are token-related files in the gmail1 directory + let gmail_dir_contents = std::fs::read_dir(config_dir.join("gmail1")).unwrap(); + let has_token_files = gmail_dir_contents.count() > 0; + assert!(has_token_files, "Expected token files to be created in gmail1 directory"); + + temp_dir.close().unwrap(); +} \ No newline at end of file