use assert_cmd::cargo::cargo_bin_cmd; use predicates::prelude::*; use std::fs; use std::path::PathBuf; use tempfile::TempDir; fn create_git_repo(dir: &PathBuf) -> std::process::Output { std::process::Command::new("git") .args(&["init"]) .current_dir(dir) .output() .expect("Failed to init git repo") } fn configure_git_user(dir: &PathBuf) { std::process::Command::new("git") .args(&["config", "user.name", "Test User"]) .current_dir(dir) .output() .expect("Failed to configure git user name"); std::process::Command::new("git") .args(&["config", "user.email", "test@example.com"]) .current_dir(dir) .output() .expect("Failed to configure git user email"); } fn create_test_file(dir: &PathBuf, name: &str, content: &str) { let file_path = dir.join(name); fs::write(&file_path, content).expect("Failed to create test file"); } fn stage_file(dir: &PathBuf, name: &str) { std::process::Command::new("git") .args(&["add", name]) .current_dir(dir) .output() .expect("Failed to stage file"); } fn create_commit(dir: &PathBuf, message: &str) { std::process::Command::new("git") .args(&["commit", "-m", message]) .current_dir(dir) .output() .expect("Failed to create commit"); } fn setup_git_repo(dir: &PathBuf) { create_git_repo(dir); configure_git_user(dir); } fn setup_test_repo_with_file(dir: &PathBuf, file_name: &str, file_content: &str) { setup_git_repo(dir); create_test_file(dir, file_name, file_content); stage_file(dir, file_name); } fn init_quicommit(dir: &PathBuf, config_path: &PathBuf) { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]) .current_dir(dir); cmd.assert().success(); } mod cli_basic { use super::*; #[test] fn test_help() { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.arg("--help"); cmd.assert() .success() .stdout(predicate::str::contains("QuiCommit")) .stdout(predicate::str::contains("AI-powered Git assistant")) .stdout(predicate::str::contains("Usage:")) .stdout(predicate::str::contains("Commands:")) .stdout(predicate::str::contains("Options:")); } #[test] fn test_version() { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.arg("--version"); cmd.assert() .success() .stdout(predicate::str::contains("quicommit")); } #[test] fn test_no_args_shows_help() { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.assert() .failure() .stderr(predicate::str::contains("Usage:")); } #[test] fn test_verbose_flag() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); let config_path = repo_path.join("config.toml"); create_git_repo(&repo_path); configure_git_user(&repo_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "-vv", "init", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert().success(); } } mod init_command { use super::*; #[test] fn test_init_quick() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert() .success() .stdout(predicate::str::contains("initialized successfully")); } #[test] fn test_init_creates_config_file() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); assert!(config_path.exists(), "Config file should be created"); } #[test] fn test_init_in_git_repo() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); create_git_repo(&repo_path); configure_git_user(&repo_path); let config_path = repo_path.join("test_config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]) .current_dir(&repo_path); cmd.assert().success(); } #[test] fn test_init_reset() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "init", "--yes", "--reset", "--config", config_path.to_str().unwrap(), ]); cmd.assert() .success() .stdout(predicate::str::contains("initialized successfully")); } } mod profile_command { use super::*; #[test] fn test_profile_list_empty() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["profile", "list", "--config", config_path.to_str().unwrap()]); cmd.assert() .success() .stdout(predicate::str::contains("No profiles")); } #[test] fn test_profile_list_with_profile() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["profile", "list", "--config", config_path.to_str().unwrap()]); cmd.assert() .success() .stdout(predicate::str::contains("default")); } } mod config_command { use super::*; #[test] fn test_config_show() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["config", "show", "--config", config_path.to_str().unwrap()]); cmd.assert() .success() .stdout(predicate::str::contains("Configuration")); } #[test] fn test_config_path() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["config", "path", "--config", config_path.to_str().unwrap()]); cmd.assert() .success() .stdout(predicate::str::contains("config.toml")); } #[test] fn test_no_color_disables_ansi() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "config", "show", "--no-color", "--config", config_path.to_str().unwrap(), ]); let output = cmd.output().unwrap(); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( !stdout.contains("\x1b["), "ANSI color codes present despite --no-color" ); } #[test] fn test_emoji_switch_precedence() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.env_remove("NO_COLOR"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); let set_format = |extra: &[&str], cfg: &std::path::Path| { let mut c = cargo_bin_cmd!("quicommit"); c.env_remove("NO_COLOR"); c.args(&["config", "set", "commit.format", "conventional"]) .args(extra) .args(&["--config", cfg.to_str().unwrap()]); c.output().unwrap() }; // Default (config true): decorations shown let out = set_format(&[], &config_path); assert!(out.status.success()); assert!(String::from_utf8_lossy(&out.stdout).contains("✓")); // Config output.emoji=false: no decorations let mut cmd = cargo_bin_cmd!("quicommit"); cmd.env_remove("NO_COLOR"); cmd.args(&[ "config", "set", "output.emoji", "false", "--config", config_path.to_str().unwrap(), ]); cmd.assert().success(); let out = set_format(&[], &config_path); assert!(out.status.success()); assert!(!String::from_utf8_lossy(&out.stdout).contains("✓")); // Explicit --emoji overrides config false let out = set_format(&["--emoji"], &config_path); assert!(out.status.success()); assert!(String::from_utf8_lossy(&out.stdout).contains("✓")); // Config back to true, then --no-emoji overrides let mut cmd = cargo_bin_cmd!("quicommit"); cmd.env_remove("NO_COLOR"); cmd.args(&[ "config", "set", "output.emoji", "true", "--config", config_path.to_str().unwrap(), ]); cmd.assert().success(); let out = set_format(&["--no-emoji"], &config_path); assert!(out.status.success()); assert!(!String::from_utf8_lossy(&out.stdout).contains("✓")); } #[test] fn test_chinese_output_smoke() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_test_repo_with_file(&repo_path, "test.txt", "content"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.env_remove("NO_COLOR"); cmd.args(&[ "config", "set", "language.output_language", "zh", "--config", config_path.to_str().unwrap(), ]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.env_remove("NO_COLOR"); cmd.args(&[ "commit", "--manual", "-m", "feat: 中文冒烟", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("试运行")); } #[test] fn test_rust_log_env() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.env_remove("NO_COLOR"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.env_remove("NO_COLOR"); cmd.env("RUST_LOG", "debug"); cmd.args(&["config", "path", "--config", config_path.to_str().unwrap()]); cmd.assert() .success() .stdout(predicate::str::contains("Starting quicommit")); } #[test] fn test_auto_generate_defaults_true() { // Documents the unified --yes semantics (issue 05): with default // config, --yes still AI-generates; deterministic output needs // --no-generate (changelog) or -m (tag). let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.env_remove("NO_COLOR"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); for key in [ "commit.auto_generate", "tag.auto_generate", "changelog.auto_generate", ] { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.env_remove("NO_COLOR"); cmd.args(&[ "config", "get", key, "--config", config_path.to_str().unwrap(), ]); cmd.assert() .success() .stdout(predicate::str::contains("true")); } } } mod commit_command { use super::*; #[test] fn test_commit_no_repo() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(temp_dir.path()); cmd.assert() .failure() .stderr(predicate::str::contains("git").or(predicate::str::contains("repository"))); } #[test] fn test_commit_no_changes() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_git_repo(&repo_path); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "test: empty", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("Dry run")); } #[test] fn test_commit_with_staged_changes() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "test: add test file", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("Dry run")); } #[test] fn test_commit_date_mode() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_test_repo_with_file(&repo_path, "daily.txt", "Daily update"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--date", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("Dry run")); } #[test] fn test_commit_with_think_flag() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--think", "--manual", "-m", "test: think flag", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert().success(); } } mod tag_command { use super::*; #[test] fn test_tag_no_repo() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "tag", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(temp_dir.path()); cmd.assert() .failure() .stderr(predicate::str::contains("git").or(predicate::str::contains("repository"))); } #[test] fn test_tag_list_empty() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_git_repo(&repo_path); create_test_file(&repo_path, "test.txt", "content"); stage_file(&repo_path, "test.txt"); create_commit(&repo_path, "feat: initial commit"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "tag", "--name", "v0.1.0", "-m", "Release v0.1.0", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("v0.1.0")); } #[test] fn test_tag_with_think_flag() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_git_repo(&repo_path); create_test_file(&repo_path, "test.txt", "content"); stage_file(&repo_path, "test.txt"); create_commit(&repo_path, "feat: initial commit"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "tag", "--think", "--name", "v0.2.0", "-m", "Release v0.2.0", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert().success(); } } mod changelog_command { use super::*; #[test] fn test_changelog_init() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_git_repo(&repo_path); let config_path = repo_path.join("config.toml"); let changelog_path = repo_path.join("CHANGELOG.md"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "changelog", "--init", "--output", changelog_path.to_str().unwrap(), "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert().success(); assert!(changelog_path.exists(), "Changelog file should be created"); } #[test] fn test_changelog_dry_run() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_git_repo(&repo_path); create_test_file(&repo_path, "test.txt", "content"); stage_file(&repo_path, "test.txt"); create_commit(&repo_path, "feat: add feature"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "changelog", "--dry-run", "--yes", "--no-generate", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert().success(); } } mod cross_platform { use super::*; #[test] fn test_path_handling_windows_style() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("subdir").join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); assert!(config_path.exists()); } #[test] fn test_config_with_spaces_in_path() { let temp_dir = TempDir::new().unwrap(); let space_dir = temp_dir.path().join("path with spaces"); fs::create_dir_all(&space_dir).unwrap(); let config_path = space_dir.join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); assert!(config_path.exists()); } #[test] fn test_config_with_unicode_path() { let temp_dir = TempDir::new().unwrap(); let unicode_dir = temp_dir.path().join("路径测试"); fs::create_dir_all(&unicode_dir).unwrap(); let config_path = unicode_dir.join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); assert!(config_path.exists()); } } mod git_operations { use super::*; #[test] fn test_git_repo_detection() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); create_git_repo(&repo_path); configure_git_user(&repo_path); let git_dir = repo_path.join(".git"); assert!(git_dir.exists(), ".git directory should exist"); } #[test] fn test_git_status_clean() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); create_git_repo(&repo_path); configure_git_user(&repo_path); let output = std::process::Command::new("git") .args(&["status", "--porcelain"]) .current_dir(&repo_path) .output() .expect("Failed to run git status"); assert!(output.status.success()); assert!(String::from_utf8_lossy(&output.stdout).is_empty()); } #[test] fn test_git_commit_creation() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); create_git_repo(&repo_path); configure_git_user(&repo_path); create_test_file(&repo_path, "test.txt", "content"); stage_file(&repo_path, "test.txt"); create_commit(&repo_path, "feat: initial commit"); let output = std::process::Command::new("git") .args(&["log", "--oneline"]) .current_dir(&repo_path) .output() .expect("Failed to run git log"); assert!(output.status.success()); let log = String::from_utf8_lossy(&output.stdout); assert!(log.contains("initial commit")); } } mod validators { use super::*; #[test] fn test_commit_message_validation() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_test_repo_with_file(&repo_path, "test.txt", "content"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "invalid commit message without type", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .failure() .stderr(predicate::str::contains("Invalid").or(predicate::str::contains("format"))); } #[test] fn test_valid_conventional_commit() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_test_repo_with_file(&repo_path, "test.txt", "content"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "feat: add new feature", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("Dry run")); } } mod subcommands { use super::*; #[test] fn test_commit_alias() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_test_repo_with_file(&repo_path, "test.txt", "content"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "c", "--manual", "-m", "fix: test", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("Dry run")); } #[test] fn test_init_alias() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["i", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert() .success() .stdout(predicate::str::contains("initialized successfully")); } #[test] fn test_profile_alias() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["p", "list", "--config", config_path.to_str().unwrap()]); cmd.assert() .success() .stdout(predicate::str::contains("default")); } } mod edge_cases { use super::*; #[test] fn test_config_file_not_found() { let temp_dir = TempDir::new().unwrap(); let non_existent_config = temp_dir.path().join("non_existent_config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "config", "show", "--config", non_existent_config.to_str().unwrap(), ]); cmd.assert() .success() .stdout(predicate::str::contains("QuiCommit Configuration")) .stdout(predicate::str::contains("Default profile: (none)")) .stdout(predicate::str::contains("Profiles: 0")); } #[test] fn test_invalid_git_repo() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); let config_path = repo_path.join("config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .failure() .stderr(predicate::str::contains("git").or(predicate::str::contains("repository"))); } #[test] fn test_empty_commit_message() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_test_repo_with_file(&repo_path, "test.txt", "content"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "", "--dry-run", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert().failure().stderr(predicate::str::contains( "Invalid conventional commit format", )); } } fn create_bare_remote(path: &std::path::Path) { std::fs::create_dir(path).expect("Failed to create remote directory"); std::process::Command::new("git") .args(["init", "--bare"]) .current_dir(path) .output() .expect("Failed to init bare remote"); } fn add_remote(dir: &PathBuf, name: &str, url: &str) { std::process::Command::new("git") .args(["remote", "add", name, url]) .current_dir(dir) .output() .expect("Failed to add remote"); } fn bare_head_commit(bare_path: &std::path::Path) -> Option { let output = std::process::Command::new("git") .arg("--git-dir") .arg(bare_path) .args(["rev-parse", "HEAD"]) .output() .expect("Failed to read bare repo HEAD"); if !output.status.success() { return None; } Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn bare_tag_exists(bare_path: &std::path::Path, tag: &str) -> bool { std::process::Command::new("git") .arg("--git-dir") .arg(bare_path) .args(["rev-parse", &format!("refs/tags/{}", tag)]) .output() .expect("Failed to read remote tag") .status .success() } fn repo_head_commit(dir: &PathBuf) -> String { let output = std::process::Command::new("git") .args(["rev-parse", "HEAD"]) .current_dir(dir) .output() .expect("Failed to read repo HEAD"); String::from_utf8_lossy(&output.stdout).trim().to_string() } mod remote_aware_push { use super::*; #[test] fn test_commit_no_remote_skips_push_prompt() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "test: no remote", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("No remote configured, skipping push.")); } #[test] fn test_commit_no_remote_with_push_flag_skips_instead_of_failing() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "test: no remote with push", "--yes", "--push", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("No remote configured, skipping push.")) .stdout(predicate::str::contains("Push failed").not()); } #[test] fn test_commit_with_remote_still_pushes_to_origin() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); let remote_path = temp_dir.path().join("remote.git"); create_bare_remote(&remote_path); setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!"); add_remote(&repo_path, "origin", remote_path.to_str().unwrap()); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "test: push to origin", "--yes", "--push", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("Pushed branch")); assert_eq!( bare_head_commit(&remote_path), Some(repo_head_commit(&repo_path)), "bare remote should contain the pushed commit" ); } #[test] fn test_tag_no_remote_skips_push_prompt() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_git_repo(&repo_path); create_test_file(&repo_path, "test.txt", "content"); stage_file(&repo_path, "test.txt"); create_commit(&repo_path, "feat: initial commit"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "tag", "--name", "v0.1.0", "-m", "Release v0.1.0", "--yes", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("No remote configured, skipping push.")); } #[test] fn test_tag_with_remote_still_pushes_to_origin() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); let remote_path = temp_dir.path().join("remote.git"); create_bare_remote(&remote_path); setup_git_repo(&repo_path); create_test_file(&repo_path, "test.txt", "content"); stage_file(&repo_path, "test.txt"); create_commit(&repo_path, "feat: initial commit"); add_remote(&repo_path, "origin", remote_path.to_str().unwrap()); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "tag", "--name", "v0.1.0", "-m", "Release v0.1.0", "--yes", "--push", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("Pushed tag")); assert!( bare_tag_exists(&remote_path, "v0.1.0"), "remote should contain the pushed tag" ); } #[test] fn test_commit_falls_back_to_first_available_remote() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); let remote_path = temp_dir.path().join("upstream.git"); create_bare_remote(&remote_path); setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!"); add_remote(&repo_path, "upstream", remote_path.to_str().unwrap()); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "test: fallback push", "--yes", "--push", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains( "Remote 'origin' not found, pushing to 'upstream' instead.", )) .stdout(predicate::str::contains("Pushed branch")); assert_eq!( bare_head_commit(&remote_path), Some(repo_head_commit(&repo_path)), "fallback remote should contain the pushed commit" ); } #[test] fn test_commit_falls_back_when_custom_remote_missing() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); let remote_path = temp_dir.path().join("origin.git"); create_bare_remote(&remote_path); setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!"); add_remote(&repo_path, "origin", remote_path.to_str().unwrap()); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "test: custom remote missing", "--yes", "--push", "--remote", "nonexistent", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains( "Remote 'nonexistent' not found, pushing to 'origin' instead.", )) .stdout(predicate::str::contains("Pushed branch")); } #[test] fn test_tag_falls_back_to_first_available_remote() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); let remote_path = temp_dir.path().join("upstream.git"); create_bare_remote(&remote_path); setup_git_repo(&repo_path); create_test_file(&repo_path, "test.txt", "content"); stage_file(&repo_path, "test.txt"); create_commit(&repo_path, "feat: initial commit"); add_remote(&repo_path, "upstream", remote_path.to_str().unwrap()); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "tag", "--name", "v0.1.0", "-m", "Release v0.1.0", "--yes", "--push", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains( "Remote 'origin' not found, pushing to 'upstream' instead.", )) .stdout(predicate::str::contains("Pushed tag")); assert!( bare_tag_exists(&remote_path, "v0.1.0"), "fallback remote should contain the pushed tag" ); } #[test] fn test_commit_select_remote_yes_pushes_only_remote_target() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); let origin_path = temp_dir.path().join("origin.git"); let upstream_path = temp_dir.path().join("upstream.git"); create_bare_remote(&origin_path); create_bare_remote(&upstream_path); setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!"); add_remote(&repo_path, "origin", origin_path.to_str().unwrap()); add_remote(&repo_path, "upstream", upstream_path.to_str().unwrap()); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); // --yes outranks --select-remote: no interaction, push only --remote target let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "test: select remote with yes", "--yes", "--push", "--select-remote", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert().success(); assert_eq!( bare_head_commit(&origin_path), Some(repo_head_commit(&repo_path)), "origin should receive the push" ); assert_eq!( bare_head_commit(&upstream_path), None, "upstream should not receive the push under --yes" ); } #[test] fn test_commit_select_remote_no_remote_skips_push() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "test: select remote without remotes", "--yes", "--select-remote", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("No remote configured, skipping push.")); } #[test] fn test_commit_select_remote_single_remote_with_yes_pushes_directly() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); let origin_path = temp_dir.path().join("origin.git"); create_bare_remote(&origin_path); setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!"); add_remote(&repo_path, "origin", origin_path.to_str().unwrap()); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "test: single remote select", "--yes", "--push", "--select-remote", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("Pushed branch")); assert_eq!( bare_head_commit(&origin_path), Some(repo_head_commit(&repo_path)) ); } #[test] fn test_commit_push_failure_reports_error() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); // Remote points to a path that is not a git repository let broken_path = temp_dir.path().join("not-a-repo"); std::fs::create_dir(&broken_path).unwrap(); setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!"); add_remote(&repo_path, "origin", broken_path.to_str().unwrap()); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "commit", "--manual", "-m", "test: push failure", "--yes", "--push", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .failure() .stderr(predicate::str::contains("Push failed")); } #[test] fn test_tag_select_remote_yes_pushes_only_remote_target() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); let origin_path = temp_dir.path().join("origin.git"); let upstream_path = temp_dir.path().join("upstream.git"); create_bare_remote(&origin_path); create_bare_remote(&upstream_path); setup_git_repo(&repo_path); create_test_file(&repo_path, "test.txt", "content"); stage_file(&repo_path, "test.txt"); create_commit(&repo_path, "feat: initial commit"); add_remote(&repo_path, "origin", origin_path.to_str().unwrap()); add_remote(&repo_path, "upstream", upstream_path.to_str().unwrap()); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "tag", "--name", "v0.1.0", "-m", "Release v0.1.0", "--yes", "--push", "--select-remote", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert().success(); assert!( bare_tag_exists(&origin_path, "v0.1.0"), "origin should have the tag" ); assert!( !bare_tag_exists(&upstream_path, "v0.1.0"), "upstream should not receive the tag under --yes" ); } #[test] fn test_tag_no_remote_with_push_flag_skips_instead_of_failing() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); setup_git_repo(&repo_path); create_test_file(&repo_path, "test.txt", "content"); stage_file(&repo_path, "test.txt"); create_commit(&repo_path, "feat: initial commit"); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "tag", "--name", "v0.1.0", "-m", "Release v0.1.0", "--yes", "--push", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .success() .stdout(predicate::str::contains("No remote configured, skipping push.")); } #[test] fn test_tag_push_failure_reports_error() { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().to_path_buf(); // Remote points to a path that is not a git repository let broken_path = temp_dir.path().join("not-a-repo"); std::fs::create_dir(&broken_path).unwrap(); setup_git_repo(&repo_path); create_test_file(&repo_path, "test.txt", "content"); stage_file(&repo_path, "test.txt"); create_commit(&repo_path, "feat: initial commit"); add_remote(&repo_path, "origin", broken_path.to_str().unwrap()); let config_path = repo_path.join("config.toml"); init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ "tag", "--name", "v0.1.0", "-m", "Release v0.1.0", "--yes", "--push", "--config", config_path.to_str().unwrap(), ]) .current_dir(&repo_path); cmd.assert() .failure() .stderr(predicate::str::contains("Push failed")); } }