use std::process::{Command, Stdio};
fn bin() -> String {
env!("CARGO_BIN_EXE_semantic-diff").to_string()
}
fn setup_repo() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("create tempdir");
let repo = tmp.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "test@test.com"]);
run_git(repo, &["config", "user.name", "Test"]);
std::fs::write(repo.join("file_a.txt"), "line 1\nline 2\nline 3\n").unwrap();
std::fs::write(repo.join("file_b.txt"), "alpha\nbeta\ngamma\n").unwrap();
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", "initial commit"]);
std::fs::write(repo.join("file_a.txt"), "line 1\nline 2 modified\nline 3\n").unwrap();
std::fs::write(
repo.join("src/main.rs"),
"fn main() {\n println!(\"hello\");\n}\n",
)
.unwrap();
tmp
}
fn setup_repo_with_branches() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("create tempdir");
let repo = tmp.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "test@test.com"]);
run_git(repo, &["config", "user.name", "Test"]);
std::fs::write(repo.join("shared.txt"), "base content\n").unwrap();
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", "base commit"]);
run_git(repo, &["checkout", "-b", "feature"]);
std::fs::write(repo.join("feature.txt"), "feature work\n").unwrap();
std::fs::write(repo.join("shared.txt"), "base content\nfeature addition\n").unwrap();
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", "feature commit"]);
run_git(repo, &["checkout", "master"]).or_else(|| run_git_opt(repo, &["checkout", "main"]));
std::fs::write(repo.join("main_only.txt"), "main work\n").unwrap();
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", "main diverge"]);
tmp
}
fn run_git(dir: &std::path::Path, args: &[&str]) -> Option<()> {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("git command failed to execute");
if output.status.success() {
Some(())
} else {
None
}
}
fn run_git_opt(dir: &std::path::Path, args: &[&str]) -> Option<()> {
run_git(dir, args)
}
fn run_semantic_diff(dir: &std::path::Path, args: &[&str]) -> (i32, String, String) {
let output = Command::new(bin())
.args(args)
.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("semantic-diff binary failed to execute");
let code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(code, stdout, stderr)
}
#[test]
fn e2e_no_args_shows_unstaged_changes() {
let tmp = setup_repo();
let (_code, _stdout, stderr) = run_semantic_diff(tmp.path(), &[]);
assert!(
!stderr.contains("No changes detected"),
"Should detect unstaged changes, stderr: {stderr}"
);
}
#[test]
fn e2e_head_arg() {
let tmp = setup_repo();
let (_code, _stdout, stderr) = run_semantic_diff(tmp.path(), &["HEAD"]);
assert!(
!stderr.contains("No changes detected"),
"HEAD should show changes, stderr: {stderr}"
);
}
#[test]
fn e2e_staged_flag() {
let tmp = setup_repo();
let repo = tmp.path();
run_git(repo, &["add", "file_a.txt"]);
let (_code, _stdout, stderr) = run_semantic_diff(repo, &["--staged"]);
assert!(
!stderr.contains("No changes detected"),
"--staged should show staged changes, stderr: {stderr}"
);
}
#[test]
fn e2e_cached_flag() {
let tmp = setup_repo();
let repo = tmp.path();
run_git(repo, &["add", "file_a.txt"]);
let (_code, _stdout, stderr) = run_semantic_diff(repo, &["--cached"]);
assert!(
!stderr.contains("No changes detected"),
"--cached should show staged changes, stderr: {stderr}"
);
}
#[test]
fn e2e_staged_no_changes() {
let tmp = setup_repo();
let (code, _stdout, stderr) = run_semantic_diff(tmp.path(), &["--staged"]);
assert!(
stderr.contains("No changes detected"),
"--staged with nothing staged should show no changes, stderr: {stderr}"
);
assert_eq!(code, 0, "Should exit cleanly");
}
#[test]
fn e2e_two_dot_range() {
let tmp = setup_repo_with_branches();
let repo = tmp.path();
let default_branch = detect_default_branch(repo);
let range = format!("{default_branch}..feature");
let (_code, _stdout, stderr) = run_semantic_diff(repo, &[&range]);
assert!(
!stderr.contains("No changes detected"),
"Two-dot range should show changes, stderr: {stderr}"
);
}
#[test]
fn e2e_three_dot_range() {
let tmp = setup_repo_with_branches();
let repo = tmp.path();
let default_branch = detect_default_branch(repo);
let range = format!("{default_branch}...feature");
let (_code, _stdout, stderr) = run_semantic_diff(repo, &[&range]);
assert!(
!stderr.contains("No changes detected"),
"Three-dot range should show changes, stderr: {stderr}"
);
}
#[test]
fn e2e_two_refs() {
let tmp = setup_repo_with_branches();
let repo = tmp.path();
let default_branch = detect_default_branch(repo);
let (_code, _stdout, stderr) = run_semantic_diff(repo, &[&default_branch, "feature"]);
assert!(
!stderr.contains("No changes detected"),
"Two refs should show changes, stderr: {stderr}"
);
}
#[test]
fn e2e_path_limiter_matching_file() {
let tmp = setup_repo();
let (_code, _stdout, stderr) = run_semantic_diff(tmp.path(), &["HEAD", "--", "file_a.txt"]);
assert!(
!stderr.contains("No changes detected"),
"Path limiter with matching file should show changes, stderr: {stderr}"
);
}
#[test]
fn e2e_path_limiter_no_match() {
let tmp = setup_repo();
let (code, _stdout, stderr) =
run_semantic_diff(tmp.path(), &["HEAD", "--", "nonexistent/"]);
assert!(
stderr.contains("No changes detected"),
"Path limiter with no match should show no changes, stderr: {stderr}"
);
assert_eq!(code, 0);
}
#[test]
fn e2e_path_limiter_directory() {
let tmp = setup_repo();
let (_code, _stdout, stderr) = run_semantic_diff(tmp.path(), &["HEAD", "--", "src/"]);
assert!(
!stderr.contains("No changes detected"),
"Path limiter with src/ should show changes, stderr: {stderr}"
);
}
#[test]
fn e2e_multiple_path_limiters() {
let tmp = setup_repo();
let (_code, _stdout, stderr) = run_semantic_diff(
tmp.path(),
&["HEAD", "--", "file_a.txt", "src/main.rs"],
);
assert!(
!stderr.contains("No changes detected"),
"Multiple path limiters should show changes, stderr: {stderr}"
);
}
#[test]
fn e2e_invalid_ref_no_panic() {
let tmp = setup_repo();
let (_code, _stdout, stderr) = run_semantic_diff(tmp.path(), &["nonexistent_branch_xyz"]);
assert_no_unexpected_panic(&stderr, "invalid ref");
}
#[test]
fn e2e_non_git_directory() {
let tmp = tempfile::tempdir().unwrap();
let (_code, _stdout, stderr) = run_semantic_diff(tmp.path(), &[]);
assert_no_unexpected_panic(&stderr, "non-git directory");
}
#[test]
fn e2e_version_flag() {
let tmp = setup_repo();
let (code, _stdout, stderr) = run_semantic_diff(tmp.path(), &["--version"]);
assert_eq!(code, 0, "Should exit 0 for --version");
assert_no_unexpected_panic(&stderr, "--version");
}
#[test]
fn e2e_help_flag() {
let tmp = setup_repo();
let (code, _stdout, stderr) = run_semantic_diff(tmp.path(), &["--help"]);
assert_eq!(code, 0, "Should exit 0 for --help");
assert_no_unexpected_panic(&stderr, "--help");
}
#[test]
fn e2e_head_tilde() {
let tmp = setup_repo();
let repo = tmp.path();
std::fs::write(repo.join("file_b.txt"), "alpha\nbeta modified\ngamma\n").unwrap();
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", "second commit"]);
std::fs::write(repo.join("file_b.txt"), "alpha\nbeta modified again\ngamma\n").unwrap();
let (_code, _stdout, stderr) = run_semantic_diff(repo, &["HEAD~1"]);
assert!(
!stderr.contains("No changes detected"),
"HEAD~1 should show changes, stderr: {stderr}"
);
}
#[test]
fn e2e_head_tilde_range() {
let tmp = setup_repo();
let repo = tmp.path();
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", "second commit"]);
std::fs::write(repo.join("file_b.txt"), "modified\n").unwrap();
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", "third commit"]);
let (_code, _stdout, stderr) = run_semantic_diff(repo, &["HEAD~2..HEAD"]);
assert!(
!stderr.contains("No changes detected"),
"HEAD~2..HEAD should show changes, stderr: {stderr}"
);
}
#[test]
fn e2e_commit_to_commit() {
let tmp = setup_repo();
let repo = tmp.path();
let first_hash = git_output(repo, &["rev-parse", "HEAD"]);
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", "second commit"]);
let second_hash = git_output(repo, &["rev-parse", "HEAD"]);
let (_code, _stdout, stderr) =
run_semantic_diff(repo, &[&first_hash, &second_hash]);
assert!(
!stderr.contains("No changes detected"),
"Commit-to-commit should show changes, stderr: {stderr}"
);
}
#[test]
fn e2e_no_commits_no_args() {
let tmp = tempfile::tempdir().unwrap();
run_git(tmp.path(), &["init"]);
let (_code, _stdout, stderr) = run_semantic_diff(tmp.path(), &[]);
assert_no_unexpected_panic(&stderr, "no commits, no args");
}
#[test]
fn e2e_no_commits_head_arg() {
let tmp = tempfile::tempdir().unwrap();
run_git(tmp.path(), &["init"]);
let (_code, _stdout, stderr) = run_semantic_diff(tmp.path(), &["HEAD"]);
assert_no_unexpected_panic(&stderr, "no commits, HEAD arg");
}
#[test]
fn e2e_many_files_with_head() {
let tmp = setup_repo();
let repo = tmp.path();
for i in 0..200 {
std::fs::write(
repo.join(format!("gen_{i}.txt")),
format!("content {i}\n"),
)
.unwrap();
}
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", "add many files"]);
for i in 0..200 {
std::fs::write(
repo.join(format!("gen_{i}.txt")),
format!("modified content {i}\n"),
)
.unwrap();
}
let (_code, _stdout, stderr) = run_semantic_diff(repo, &["HEAD"]);
assert!(
!stderr.contains("No changes detected"),
"200 modified files should show changes, stderr: {stderr}"
);
assert_no_unexpected_panic(&stderr, "many files with HEAD");
}
#[test]
fn e2e_staged_with_ref() {
let tmp = setup_repo();
let repo = tmp.path();
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", "second commit"]);
std::fs::write(repo.join("file_a.txt"), "totally new content\n").unwrap();
run_git(repo, &["add", "file_a.txt"]);
let (_code, _stdout, stderr) = run_semantic_diff(repo, &["--staged", "HEAD~1"]);
assert!(
!stderr.contains("No changes detected"),
"--staged HEAD~1 should show changes, stderr: {stderr}"
);
}
fn assert_no_unexpected_panic(stderr: &str, context: &str) {
if stderr.contains("panicked") {
let is_tui_panic = stderr.contains("failed to initialize terminal")
|| stderr.contains("reader source not set");
assert!(
is_tui_panic,
"{context}: unexpected panic (not TUI-related), stderr: {stderr}"
);
}
}
fn detect_default_branch(repo: &std::path::Path) -> String {
let output = Command::new("git")
.args(["branch", "--list", "main"])
.current_dir(repo)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("main") {
"main".to_string()
} else {
"master".to_string()
}
}
fn git_output(repo: &std::path::Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.unwrap();
String::from_utf8_lossy(&output.stdout).trim().to_string()
}