Files
tour/tests/integration.rs
Adam French 507c61fe5f 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>
2026-03-05 21:23:39 +00:00

476 lines
15 KiB
Rust

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);
}