Fix bugs and improve robustness across the codebase
- Fix staged files being silently cleared when commit uses inline files - Refactor step navigation to use direct go_to_step instead of fragile delta math - Change step numbers from i32 to u32 (reject negative values at parse time) - Add tour rm command to mark files for removal during carry-forward - Add tour reset command to clear session and remove tracked files - Consolidate duplicate recursive copy functions into shared copy_tree in utils - Validate step directories are sequential (detect corruption) - Detect binary files in diffs instead of showing garbage - Use /// doc comments on enum variants so clap generates proper help text - Remove custom Help subcommand in favor of clap's built-in --help - Add CorruptedTour error variant for integrity checks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
475
tests/integration.rs
Normal file
475
tests/integration.rs
Normal file
@@ -0,0 +1,475 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn tour_cmd() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_tour"))
|
||||
}
|
||||
|
||||
fn setup_dir() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().expect("failed to create temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
fn init_tour(dir: &Path) {
|
||||
let status = tour_cmd()
|
||||
.arg("init")
|
||||
.current_dir(dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
use std::io::Write;
|
||||
if let Some(ref mut stdin) = child.stdin {
|
||||
writeln!(stdin, "test-author")?;
|
||||
writeln!(stdin, "test-desc")?;
|
||||
writeln!(stdin, "rust")?;
|
||||
}
|
||||
child.wait()
|
||||
})
|
||||
.expect("failed to run init");
|
||||
assert!(status.success(), "tour init failed");
|
||||
}
|
||||
|
||||
fn create_test_file(dir: &Path, name: &str, content: &str) {
|
||||
let path = dir.join(name);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&path, content).unwrap();
|
||||
}
|
||||
|
||||
// -- init tests --
|
||||
|
||||
#[test]
|
||||
fn test_init_creates_tour_dir() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
|
||||
assert!(dir.path().join(".tour").exists());
|
||||
assert!(dir.path().join(".tour/steps").exists());
|
||||
assert!(dir.path().join(".tour/session").exists());
|
||||
assert!(dir.path().join(".tour/info").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commands_fail_without_init() {
|
||||
let dir = setup_dir();
|
||||
|
||||
let output = tour_cmd()
|
||||
.args(["commit", "foo.rs", "-m", "msg"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("No tour found") || stderr.contains("tour init") || stderr.contains("NoTour"),
|
||||
"Expected helpful error, got: {}", stderr);
|
||||
}
|
||||
|
||||
// -- add / commit tests --
|
||||
|
||||
#[test]
|
||||
fn test_add_and_commit() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
create_test_file(dir.path(), "hello.txt", "hello world");
|
||||
|
||||
// Add
|
||||
let output = tour_cmd()
|
||||
.args(["add", "hello.txt"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "add failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
|
||||
// Commit from staged
|
||||
let output = tour_cmd()
|
||||
.args(["commit", "-m", "first step"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "commit failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
|
||||
// Step dir should exist with the file
|
||||
let step_dir = dir.path().join(".tour/steps/0");
|
||||
assert!(step_dir.exists());
|
||||
assert!(step_dir.join("hello.txt").exists());
|
||||
assert!(step_dir.join("message").exists());
|
||||
|
||||
// Staged should be cleared
|
||||
let staged = fs::read_to_string(dir.path().join(".tour/staged")).unwrap();
|
||||
assert!(staged.trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_deduplicates() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
create_test_file(dir.path(), "a.txt", "content");
|
||||
|
||||
// Add twice
|
||||
tour_cmd().args(["add", "a.txt"]).current_dir(dir.path()).output().unwrap();
|
||||
tour_cmd().args(["add", "a.txt"]).current_dir(dir.path()).output().unwrap();
|
||||
|
||||
let staged = fs::read_to_string(dir.path().join(".tour/staged")).unwrap();
|
||||
let lines: Vec<&str> = staged.lines().filter(|l| !l.is_empty()).collect();
|
||||
assert_eq!(lines.len(), 1, "expected 1 staged file, got: {:?}", lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_with_inline_files() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
create_test_file(dir.path(), "main.rs", "fn main() {}");
|
||||
|
||||
let output = tour_cmd()
|
||||
.args(["commit", "main.rs", "-m", "add main"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
assert!(dir.path().join(".tour/steps/0/main.rs").exists());
|
||||
}
|
||||
|
||||
// -- end tests --
|
||||
|
||||
#[test]
|
||||
fn test_end_tour() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
create_test_file(dir.path(), "f.txt", "data");
|
||||
tour_cmd().args(["commit", "f.txt", "-m", "step"]).current_dir(dir.path()).output().unwrap();
|
||||
|
||||
let output = tour_cmd()
|
||||
.args(["end", "-m", "done"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "end failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(dir.path().join(".tour/ended").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_end_prevents_further_commits() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
create_test_file(dir.path(), "f.txt", "data");
|
||||
tour_cmd().args(["commit", "f.txt", "-m", "step"]).current_dir(dir.path()).output().unwrap();
|
||||
tour_cmd().args(["end", "-m", "done"]).current_dir(dir.path()).output().unwrap();
|
||||
|
||||
create_test_file(dir.path(), "g.txt", "more");
|
||||
let output = tour_cmd()
|
||||
.args(["commit", "g.txt", "-m", "nope"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_end_without_steps_fails() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
|
||||
let output = tour_cmd()
|
||||
.args(["end", "-m", "empty"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
}
|
||||
|
||||
// -- step navigation tests --
|
||||
|
||||
#[test]
|
||||
fn test_start_and_next_prev() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
|
||||
create_test_file(dir.path(), "f.txt", "step0");
|
||||
tour_cmd().args(["commit", "f.txt", "-m", "first"]).current_dir(dir.path()).output().unwrap();
|
||||
|
||||
create_test_file(dir.path(), "f.txt", "step1");
|
||||
tour_cmd().args(["commit", "f.txt", "-m", "second"]).current_dir(dir.path()).output().unwrap();
|
||||
|
||||
// Start
|
||||
let output = tour_cmd()
|
||||
.arg("start")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "start failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
|
||||
let content = fs::read_to_string(dir.path().join("f.txt")).unwrap();
|
||||
assert_eq!(content, "step0");
|
||||
|
||||
// Next
|
||||
let output = tour_cmd()
|
||||
.arg("next")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "next failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
|
||||
let content = fs::read_to_string(dir.path().join("f.txt")).unwrap();
|
||||
assert_eq!(content, "step1");
|
||||
|
||||
// Prev
|
||||
let output = tour_cmd()
|
||||
.arg("prev")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "prev failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
|
||||
let content = fs::read_to_string(dir.path().join("f.txt")).unwrap();
|
||||
assert_eq!(content, "step0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_step_n_jumps_to_step() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
|
||||
create_test_file(dir.path(), "f.txt", "v0");
|
||||
tour_cmd().args(["commit", "f.txt", "-m", "s0"]).current_dir(dir.path()).output().unwrap();
|
||||
create_test_file(dir.path(), "f.txt", "v1");
|
||||
tour_cmd().args(["commit", "f.txt", "-m", "s1"]).current_dir(dir.path()).output().unwrap();
|
||||
create_test_file(dir.path(), "f.txt", "v2");
|
||||
tour_cmd().args(["commit", "f.txt", "-m", "s2"]).current_dir(dir.path()).output().unwrap();
|
||||
|
||||
// Start at step 1, then jump to step 3 (1-based)
|
||||
tour_cmd().arg("start").current_dir(dir.path()).output().unwrap();
|
||||
let output = tour_cmd()
|
||||
.args(["step", "3"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "step 3 failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
|
||||
let content = fs::read_to_string(dir.path().join("f.txt")).unwrap();
|
||||
assert_eq!(content, "v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_step_out_of_range() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
|
||||
create_test_file(dir.path(), "f.txt", "v0");
|
||||
tour_cmd().args(["commit", "f.txt", "-m", "s0"]).current_dir(dir.path()).output().unwrap();
|
||||
tour_cmd().arg("start").current_dir(dir.path()).output().unwrap();
|
||||
|
||||
// Step 0 is out of range (1-based, only 1 step)
|
||||
let output = tour_cmd()
|
||||
.args(["step", "0"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
|
||||
// Step 2 is out of range (only 1 step)
|
||||
let output = tour_cmd()
|
||||
.args(["step", "2"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
}
|
||||
|
||||
// -- unstage tests --
|
||||
|
||||
#[test]
|
||||
fn test_unstage() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
create_test_file(dir.path(), "a.txt", "aaa");
|
||||
create_test_file(dir.path(), "b.txt", "bbb");
|
||||
|
||||
tour_cmd().args(["add", "a.txt", "b.txt"]).current_dir(dir.path()).output().unwrap();
|
||||
|
||||
let output = tour_cmd()
|
||||
.args(["unstage", "a.txt"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "unstage failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
|
||||
let staged = fs::read_to_string(dir.path().join(".tour/staged")).unwrap();
|
||||
assert!(!staged.contains("a.txt"), "a.txt should be unstaged");
|
||||
assert!(staged.contains("b.txt"), "b.txt should still be staged");
|
||||
}
|
||||
|
||||
// -- list tests --
|
||||
|
||||
#[test]
|
||||
fn test_list_steps() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
|
||||
create_test_file(dir.path(), "f.txt", "v0");
|
||||
tour_cmd().args(["commit", "f.txt", "-m", "first step"]).current_dir(dir.path()).output().unwrap();
|
||||
create_test_file(dir.path(), "f.txt", "v1");
|
||||
tour_cmd().args(["commit", "f.txt", "-m", "second step"]).current_dir(dir.path()).output().unwrap();
|
||||
|
||||
let output = tour_cmd()
|
||||
.arg("list")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("first step"), "expected 'first step' in: {}", stdout);
|
||||
assert!(stdout.contains("second step"), "expected 'second step' in: {}", stdout);
|
||||
}
|
||||
|
||||
// -- carry-forward tests --
|
||||
|
||||
#[test]
|
||||
fn test_files_carry_forward() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
|
||||
create_test_file(dir.path(), "a.txt", "aaa");
|
||||
create_test_file(dir.path(), "b.txt", "bbb");
|
||||
tour_cmd().args(["commit", "a.txt", "b.txt", "-m", "step 1"]).current_dir(dir.path()).output().unwrap();
|
||||
|
||||
// Second step only modifies a.txt
|
||||
create_test_file(dir.path(), "a.txt", "aaa modified");
|
||||
tour_cmd().args(["commit", "a.txt", "-m", "step 2"]).current_dir(dir.path()).output().unwrap();
|
||||
|
||||
// b.txt should be carried forward to step 2
|
||||
assert!(dir.path().join(".tour/steps/1/b.txt").exists(), "b.txt should be carried forward");
|
||||
|
||||
// Navigate to step 2 and verify both files exist
|
||||
tour_cmd().arg("start").current_dir(dir.path()).output().unwrap();
|
||||
tour_cmd().arg("next").current_dir(dir.path()).output().unwrap();
|
||||
|
||||
let a = fs::read_to_string(dir.path().join("a.txt")).unwrap();
|
||||
assert_eq!(a, "aaa modified");
|
||||
let b = fs::read_to_string(dir.path().join("b.txt")).unwrap();
|
||||
assert_eq!(b, "bbb");
|
||||
}
|
||||
|
||||
// -- init guard tests --
|
||||
|
||||
#[test]
|
||||
fn test_init_twice_fails() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
|
||||
let output = tour_cmd()
|
||||
.arg("init")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success(), "second init should fail");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("already exists"), "expected 'already exists', got: {}", stderr);
|
||||
}
|
||||
|
||||
// -- gitignore tests --
|
||||
|
||||
#[test]
|
||||
fn test_init_creates_gitignore() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
|
||||
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
||||
assert!(gitignore.contains(".tour/session"), "gitignore should contain .tour/session");
|
||||
assert!(gitignore.contains(".tour/staged"), "gitignore should contain .tour/staged");
|
||||
}
|
||||
|
||||
// -- add validation tests --
|
||||
|
||||
#[test]
|
||||
fn test_add_nonexistent_file() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
|
||||
let output = tour_cmd()
|
||||
.args(["add", "nonexistent.txt"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("not found") || stderr.contains("No such file") || stderr.contains("FileNotFound"),
|
||||
"Expected file not found error, got: {}", stderr);
|
||||
}
|
||||
|
||||
// -- subdirectory tests --
|
||||
|
||||
#[test]
|
||||
fn test_subdirectory_files() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
|
||||
create_test_file(dir.path(), "src/main.rs", "fn main() {}");
|
||||
create_test_file(dir.path(), "src/lib.rs", "pub fn hello() {}");
|
||||
|
||||
let output = tour_cmd()
|
||||
.args(["commit", "src/main.rs", "src/lib.rs", "-m", "add source"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "commit failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
|
||||
// Verify step directory has subdirectory structure
|
||||
assert!(dir.path().join(".tour/steps/0/src/main.rs").exists());
|
||||
assert!(dir.path().join(".tour/steps/0/src/lib.rs").exists());
|
||||
|
||||
// Modify one file and commit step 2
|
||||
create_test_file(dir.path(), "src/main.rs", "fn main() { println!(\"hi\"); }");
|
||||
let output = tour_cmd()
|
||||
.args(["commit", "src/main.rs", "-m", "modify main"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
|
||||
// lib.rs should be carried forward
|
||||
assert!(dir.path().join(".tour/steps/1/src/lib.rs").exists());
|
||||
|
||||
// Start at step 1 and verify
|
||||
tour_cmd().arg("start").current_dir(dir.path()).output().unwrap();
|
||||
let content = fs::read_to_string(dir.path().join("src/main.rs")).unwrap();
|
||||
assert_eq!(content, "fn main() {}");
|
||||
let content = fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
|
||||
assert_eq!(content, "pub fn hello() {}");
|
||||
|
||||
// Go to step 2 and verify modified file + carried-forward file
|
||||
tour_cmd().arg("next").current_dir(dir.path()).output().unwrap();
|
||||
let content = fs::read_to_string(dir.path().join("src/main.rs")).unwrap();
|
||||
assert_eq!(content, "fn main() { println!(\"hi\"); }");
|
||||
let content = fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
|
||||
assert_eq!(content, "pub fn hello() {}");
|
||||
|
||||
// Go back to step 1 and verify cleanup
|
||||
tour_cmd().arg("prev").current_dir(dir.path()).output().unwrap();
|
||||
let content = fs::read_to_string(dir.path().join("src/main.rs")).unwrap();
|
||||
assert_eq!(content, "fn main() {}");
|
||||
}
|
||||
|
||||
// -- status tests --
|
||||
|
||||
#[test]
|
||||
fn test_status_shows_info() {
|
||||
let dir = setup_dir();
|
||||
init_tour(dir.path());
|
||||
|
||||
create_test_file(dir.path(), "f.txt", "data");
|
||||
tour_cmd().args(["commit", "f.txt", "-m", "step"]).current_dir(dir.path()).output().unwrap();
|
||||
|
||||
let output = tour_cmd()
|
||||
.arg("status")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("1 steps"), "expected step count in: {}", stdout);
|
||||
assert!(stdout.contains("not started"), "expected 'not started' in: {}", stdout);
|
||||
}
|
||||
Reference in New Issue
Block a user