use std::process::Command;
use tempfile::TempDir;
fn get_grubble_bin() -> String {
if let Ok(output) = Command::new("which").arg("grubble").output() {
if output.status.success() {
return String::from_utf8_lossy(&output.stdout).trim().to_string();
}
}
let cargo_target = std::env::var("CARGO_MANIFEST_DIR")
.ok()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap())
.join("target")
.join("debug")
.join("grubble");
if cargo_target.exists() {
return cargo_target.to_string_lossy().to_string();
}
let cargo_target_release = std::env::var("CARGO_MANIFEST_DIR")
.ok()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap())
.join("target")
.join("release")
.join("grubble");
if cargo_target_release.exists() {
return cargo_target_release.to_string_lossy().to_string();
}
panic!("Could not find grubble binary. Build with 'cargo build' first.");
}
fn setup_test_repo() -> (TempDir, Command) {
let temp_dir = TempDir::new().unwrap();
Command::new("git")
.args(["init"])
.current_dir(&temp_dir)
.output()
.expect("Failed to init git");
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&temp_dir)
.output()
.expect("Failed to set git email");
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&temp_dir)
.output()
.expect("Failed to set git name");
Command::new("git")
.args(["commit", "--allow-empty", "-m", "chore: initial commit"])
.current_dir(&temp_dir)
.output()
.expect("Failed to create initial commit");
Command::new("git")
.args(["tag", "v1.0.0"])
.current_dir(&temp_dir)
.output()
.expect("Failed to create tag");
let mut cmd = Command::new(get_grubble_bin());
cmd.current_dir(&temp_dir);
(temp_dir, cmd)
}
#[test]
fn test_bump_type_no_commits() {
let (_dir, mut cmd) = setup_test_repo();
cmd.arg("--bump-type");
let output = cmd.output().expect("Failed to run grubble");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.trim(), "none");
}
#[test]
fn test_bump_type_patch() {
let (dir, mut cmd) = setup_test_repo();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "fix: resolve bug"])
.current_dir(&dir)
.output()
.expect("Failed to create fix commit");
cmd.arg("--bump-type");
let output = cmd.output().expect("Failed to run grubble");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.trim(), "patch");
}
#[test]
fn test_bump_type_minor() {
let (dir, mut cmd) = setup_test_repo();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "feat: add new feature"])
.current_dir(&dir)
.output()
.expect("Failed to create feat commit");
cmd.arg("--bump-type");
let output = cmd.output().expect("Failed to run grubble");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.trim(), "minor");
}
#[test]
fn test_bump_type_major() {
let (dir, mut cmd) = setup_test_repo();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "feat!: breaking change"])
.current_dir(&dir)
.output()
.expect("Failed to create breaking commit");
cmd.arg("--bump-type");
let output = cmd.output().expect("Failed to run grubble");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.trim(), "major");
}
#[test]
fn test_dry_run_no_bump_exit_code() {
let (_dir, mut cmd) = setup_test_repo();
cmd.arg("--dry-run");
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
}
#[test]
fn test_dry_run_bump_needed_exit_code() {
let (dir, mut cmd) = setup_test_repo();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "fix: resolve bug"])
.current_dir(&dir)
.output()
.expect("Failed to create fix commit");
cmd.arg("--dry-run");
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
}
#[test]
fn test_dry_run_does_not_modify_files() {
let (dir, mut cmd) = setup_test_repo();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "fix: resolve bug"])
.current_dir(&dir)
.output()
.expect("Failed to create fix commit");
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = \"1.0.0\"\n",
)
.unwrap();
cmd.arg("--dry-run");
cmd.arg("--preset");
cmd.arg("rust");
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
let cargo_content = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(cargo_content.contains("version = \"1.0.0\""));
}
#[test]
fn test_dry_run_does_not_create_tags() {
let (dir, mut cmd) = setup_test_repo();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "fix: resolve bug"])
.current_dir(&dir)
.output()
.expect("Failed to create fix commit");
cmd.arg("--dry-run");
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
let tags_output = Command::new("git")
.args(["tag", "-l"])
.current_dir(&dir)
.output()
.expect("Failed to list tags");
let tags = String::from_utf8_lossy(&tags_output.stdout);
assert_eq!(tags.trim(), "v1.0.0"); }
#[test]
fn test_dry_run_verbose_output() {
let (dir, mut cmd) = setup_test_repo();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "fix: resolve bug"])
.current_dir(&dir)
.output()
.expect("Failed to create fix commit");
Command::new("git")
.args(["commit", "--allow-empty", "-m", "feat: add feature"])
.current_dir(&dir)
.output()
.expect("Failed to create feat commit");
cmd.arg("--dry-run");
let output = cmd.output().expect("Failed to run grubble");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Current version"));
assert!(stdout.contains("Version bump"));
}
#[test]
fn test_normal_run_no_bump_exit_code() {
let (_dir, mut cmd) = setup_test_repo();
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!stderr.starts_with("Error:"));
}
#[test]
fn test_raw_no_further_bump_exit_code() {
let (dir, mut cmd) = setup_test_repo();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = \"1.0.0\"\n",
)
.unwrap();
cmd.arg("--raw");
cmd.arg("--preset");
cmd.arg("rust");
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.trim(), "1.0.0");
}
#[test]
fn test_error_exit_code() {
let (dir, mut cmd) = setup_test_repo();
cmd.arg("--preset");
cmd.arg("rust");
Command::new("git")
.args(["commit", "--allow-empty", "-m", "fix: something"])
.current_dir(&dir)
.output()
.expect("Failed to create fix commit");
let output = cmd.output().expect("Failed to run grubble");
assert_ne!(output.status.code(), Some(0));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Error:"),
"expected error on stderr, got: {}",
stderr
);
}
#[test]
fn test_raw_with_rust_preset_reads_cargo_toml() {
let (dir, mut cmd) = setup_test_repo();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = \"0.1.0\"\n",
)
.unwrap();
cmd.arg("--raw");
cmd.arg("--preset");
cmd.arg("rust");
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.trim(), "0.1.0");
}
#[test]
fn test_raw_with_node_preset_reads_package_json() {
let (dir, mut cmd) = setup_test_repo();
std::fs::write(
dir.path().join("package.json"),
"{\"name\": \"demo\", \"version\": \"2.3.4\"}\n",
)
.unwrap();
cmd.arg("--raw");
cmd.arg("--preset");
cmd.arg("node");
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.trim(), "2.3.4");
}
#[test]
fn test_raw_with_git_preset_unchanged() {
let (_dir, mut cmd) = setup_test_repo();
cmd.arg("--raw");
cmd.arg("--preset");
cmd.arg("git");
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.trim(), "1.0.0");
}
#[test]
fn test_bump_type_json_output() {
let (dir, mut cmd) = setup_test_repo();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "feat: add thing"])
.current_dir(&dir)
.output()
.expect("Failed to create feat commit");
cmd.arg("--bump-type");
cmd.arg("--output");
cmd.arg("json");
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.trim();
let parsed: serde_json::Value = serde_json::from_str(stdout)
.unwrap_or_else(|e| panic!("stdout is not valid JSON '{}': {}", stdout, e));
assert_eq!(parsed["bump_type"], "minor");
assert!(parsed["current_version"].is_string());
assert!(parsed["triggering_commits"].is_array());
assert!(parsed["unknown_commits"].is_array());
}
#[test]
fn test_raw_json_output() {
let (dir, mut cmd) = setup_test_repo();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = \"1.0.0\"\n",
)
.unwrap();
cmd.arg("--raw");
cmd.arg("--preset");
cmd.arg("rust");
cmd.arg("--output");
cmd.arg("json");
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.trim();
let parsed: serde_json::Value = serde_json::from_str(stdout)
.unwrap_or_else(|e| panic!("stdout is not valid JSON '{}': {}", stdout, e));
assert_eq!(parsed["version"], "1.0.0");
assert_eq!(parsed["preset"], "rust");
}
#[test]
fn test_json_output_invalid_with_dry_run() {
let (_dir, mut cmd) = setup_test_repo();
cmd.arg("--dry-run");
cmd.arg("--output");
cmd.arg("json");
let output = cmd.output().expect("Failed to run grubble");
assert_ne!(output.status.code(), Some(0));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Invalid configuration") || stderr.contains("--output json"),
"expected validation error on stderr, got: {}",
stderr
);
}
#[test]
fn test_json_output_invalid_with_normal_run() {
let (_dir, mut cmd) = setup_test_repo();
cmd.arg("--output");
cmd.arg("json");
let output = cmd.output().expect("Failed to run grubble");
assert_ne!(output.status.code(), Some(0));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Invalid configuration") || stderr.contains("--output json"),
"expected validation error on stderr, got: {}",
stderr
);
}
#[test]
fn test_file_ahead_of_tag_fails() {
let (dir, mut cmd) = setup_test_repo();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "fix: something"])
.current_dir(&dir)
.output()
.expect("Failed to create fix commit");
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = \"5.0.0\"\n",
)
.unwrap();
cmd.arg("--preset");
cmd.arg("rust");
let output = cmd.output().expect("Failed to run grubble");
assert_ne!(output.status.code(), Some(0));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("5.0.0"),
"expected file version in error, got: {}",
stderr
);
assert!(
stderr.contains("1.0.0"),
"expected tag version in error, got: {}",
stderr
);
assert!(
stderr.contains("revert") || stderr.contains("tag"),
"expected fix-path keyword in error, got: {}",
stderr
);
}
#[test]
fn test_file_behind_tag_syncs() {
let (dir, mut cmd) = setup_test_repo();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = \"0.5.0\"\n",
)
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "fix: something"])
.current_dir(&dir)
.output()
.expect("Failed to create fix commit");
cmd.arg("--preset");
cmd.arg("rust");
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
let cargo_content = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(
cargo_content.contains("1.0.1"),
"expected file synced+bumped to 1.0.1, got: {}",
cargo_content
);
}
#[test]
fn test_file_ahead_of_tag_succeeds_in_raw_mode() {
let (dir, mut cmd) = setup_test_repo();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = \"5.0.0\"\n",
)
.unwrap();
cmd.arg("--raw");
cmd.arg("--preset");
cmd.arg("rust");
let output = cmd.output().expect("Failed to run grubble");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.trim(), "5.0.0");
}
fn setup_test_repo_with_remote() -> (TempDir, TempDir, Command) {
let remote_dir = TempDir::new().unwrap();
let work_dir = TempDir::new().unwrap();
Command::new("git")
.args(["init", "--bare", "--initial-branch=main"])
.current_dir(&remote_dir)
.output()
.expect("Failed to init bare remote");
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&work_dir)
.output()
.expect("Failed to init working repo");
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&work_dir)
.output()
.expect("Failed to set git email");
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&work_dir)
.output()
.expect("Failed to set git name");
let remote_path = remote_dir.path().to_str().unwrap();
Command::new("git")
.args(["remote", "add", "origin", remote_path])
.current_dir(&work_dir)
.output()
.expect("Failed to add remote");
Command::new("git")
.args(["commit", "--allow-empty", "-m", "chore: initial commit"])
.current_dir(&work_dir)
.output()
.expect("Failed to create initial commit");
Command::new("git")
.args(["tag", "v1.0.0"])
.current_dir(&work_dir)
.output()
.expect("Failed to create tag");
let mut cmd = Command::new(get_grubble_bin());
cmd.current_dir(&work_dir);
(work_dir, remote_dir, cmd)
}
#[test]
fn test_force_push_requires_git_branch() {
let (_dir, mut cmd) = setup_test_repo();
cmd.arg("--force-push");
cmd.arg("--push");
let output = cmd.output().expect("Failed to run grubble");
assert_ne!(output.status.code(), Some(0));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--git-branch"),
"expected clap validation error naming --git-branch on stderr, got: {}",
stderr
);
}
#[test]
fn test_force_push_without_push_succeeds_arg_layer() {
let (dir, mut cmd) = setup_test_repo();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "fix: something"])
.current_dir(&dir)
.output()
.expect("Failed to create fix commit");
cmd.arg("--force-push");
cmd.arg("--git-branch");
cmd.arg("release/v9.9.9");
let output = cmd.output().expect("Failed to run grubble");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "grubble failed: {}", stderr);
}
#[test]
fn test_push_to_branch() {
let (work_dir, remote_dir, mut cmd) = setup_test_repo_with_remote();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "feat: new feature"])
.current_dir(&work_dir)
.output()
.expect("Failed to create feat commit");
cmd.arg("--push");
cmd.arg("--tag");
cmd.arg("--git-branch");
cmd.arg("release/v1.1.0");
let output = cmd.output().expect("Failed to run grubble");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "grubble failed: {}", stderr);
let ls_remote_output = Command::new("git")
.args([
"ls-remote",
"--heads",
remote_dir.path().to_str().unwrap(),
"release/v1.1.0",
])
.output()
.expect("Failed to ls-remote");
let ls_remote = String::from_utf8_lossy(&ls_remote_output.stdout);
assert!(
ls_remote.contains("refs/heads/release/v1.1.0"),
"expected release/v1.1.0 on remote, got: {}",
ls_remote
);
let ls_tags_output = Command::new("git")
.args([
"ls-remote",
"--tags",
remote_dir.path().to_str().unwrap(),
"v1.1.0",
])
.output()
.expect("Failed to ls-remote tags");
let ls_tags = String::from_utf8_lossy(&ls_tags_output.stdout);
assert!(
ls_tags.contains("refs/tags/v1.1.0"),
"expected v1.1.0 tag on remote, got: {}",
ls_tags
);
let ls_main_output = Command::new("git")
.args([
"ls-remote",
"--heads",
remote_dir.path().to_str().unwrap(),
"main",
])
.output()
.expect("Failed to ls-remote main");
let ls_main = String::from_utf8_lossy(&ls_main_output.stdout);
assert!(
!ls_main.contains("refs/heads/main"),
"main should not have been pushed to, got: {}",
ls_main
);
}
#[test]
fn test_push_to_branch_with_force_tags() {
let (work_dir, remote_dir, mut cmd) = setup_test_repo_with_remote();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "feat: new feature"])
.current_dir(&work_dir)
.output()
.expect("Failed to create feat commit");
cmd.arg("--push");
cmd.arg("--tag");
cmd.arg("--update-minor-tag");
cmd.arg("--git-branch");
cmd.arg("release/v1.1");
let output = cmd.output().expect("Failed to run grubble");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "grubble failed: {}", stderr);
let ls_remote_output = Command::new("git")
.args([
"ls-remote",
"--heads",
remote_dir.path().to_str().unwrap(),
"release/v1.1",
])
.output()
.expect("Failed to ls-remote");
let ls_remote = String::from_utf8_lossy(&ls_remote_output.stdout);
assert!(
ls_remote.contains("refs/heads/release/v1.1"),
"expected release/v1.1 on remote, got: {}",
ls_remote
);
let ls_tags_output = Command::new("git")
.args([
"ls-remote",
"--tags",
remote_dir.path().to_str().unwrap(),
"v1.1",
])
.output()
.expect("Failed to ls-remote tags");
let ls_tags = String::from_utf8_lossy(&ls_tags_output.stdout);
assert!(
ls_tags.contains("refs/tags/v1.1"),
"expected v1.1 minor tag on remote, got: {}",
ls_tags
);
}