use assert_cmd::Command;
use std::path::Path;
use std::process::Output;
fn init_repo(dir: &Path) {
Command::new("git")
.args(["init", "-b", "main"])
.arg(dir)
.ok()
.unwrap();
Command::new("git")
.args(["config", "user.email", "e2e@test"])
.current_dir(dir)
.ok()
.unwrap();
Command::new("git")
.args(["config", "user.name", "E2E Test"])
.current_dir(dir)
.ok()
.unwrap();
for d in &[".plan/epics", ".plan/stories", ".plan/tasks"] {
std::fs::create_dir_all(dir.join(d)).unwrap();
}
std::fs::write(dir.join(".plan/.gitkeep"), "").unwrap();
let out = Command::new("git")
.args(["add", ".plan"])
.current_dir(dir)
.output()
.unwrap();
assert!(
out.status.success(),
"git add failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = Command::new("git")
.args(["commit", "-m", "seed plan"])
.current_dir(dir)
.output()
.unwrap();
assert!(
out.status.success(),
"git commit failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn planr(dir: &Path, args: &[&str]) -> Output {
Command::cargo_bin("planr")
.unwrap()
.args(args)
.current_dir(dir)
.output()
.unwrap()
}
fn planr_ok(dir: &Path, args: &[&str]) -> String {
let out = planr(dir, args);
assert!(
out.status.success(),
"planr {args:?} failed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn planr_err(dir: &Path, args: &[&str]) -> String {
let out = planr(dir, args);
assert!(!out.status.success(), "planr {args:?} expected failure");
String::from_utf8(out.stderr).unwrap().trim().to_string()
}
fn planr_ok_both(dir: &Path, args: &[&str]) -> (String, String) {
let out = planr(dir, args);
assert!(
out.status.success(),
"planr {args:?} failed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
(
String::from_utf8(out.stdout).unwrap().trim().to_string(),
String::from_utf8(out.stderr).unwrap().trim().to_string(),
)
}
#[test]
fn test_e2e_new_ticket_dangling_parent() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let err = planr_err(
td.path(),
&["new", "task", "my-task", "Title", "nonexistent"],
);
assert!(
err.contains("create the parent first"),
"dangling parent: {err}"
);
}
#[test]
fn test_e2e_new_ticket_bad_slug_uppercase() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let err = planr_err(td.path(), &["new", "task", "Bad-Slug", "Title", "parent"]);
assert!(err.contains("bad slug"), "bad slug: {err}");
}
#[test]
fn test_e2e_new_ticket_bad_slug_trailing_hyphen() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let err = planr_err(td.path(), &["new", "task", "foo-", "Title", "parent"]);
assert!(err.contains("bad slug"));
}
#[test]
fn test_e2e_new_ticket_bad_slug_double_hyphen() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let err = planr_err(td.path(), &["new", "task", "foo--bar", "Title", "parent"]);
assert!(err.contains("bad slug"));
}
#[test]
fn test_e2e_happy_path() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let out = planr_ok(td.path(), &["new", "epic", "v1", "Version One"]);
assert!(out.contains(".plan/epics/"), "epic path: {out}");
let s1 = planr_ok(td.path(), &["new", "story", "net", "Networking", "v1"]);
assert!(s1.contains(".plan/stories/"));
let s2 = planr_ok(td.path(), &["new", "story", "db", "Database", "v1"]);
assert!(s2.contains(".plan/stories/"));
let t1 = planr_ok(
td.path(),
&["new", "task", "http-proxy", "HTTP Proxy", "net"],
);
assert!(t1.contains(".plan/tasks/"));
Command::new("git")
.args(["add", ".plan"])
.current_dir(td.path())
.ok()
.unwrap();
Command::new("git")
.args(["commit", "-m", "seed"])
.current_dir(td.path())
.ok()
.unwrap();
let content = std::fs::read_to_string(td.path().join(&t1)).unwrap();
assert!(content.contains("aliases: [http-proxy]"), "aliases filled");
}
fn seed_lint_repo(dir: &Path) {
init_repo(dir);
planr_ok(dir, &["new", "epic", "e1", "Epic One"]);
planr_ok(dir, &["new", "story", "s1", "Story One", "e1"]);
planr_ok(dir, &["new", "task", "t1", "Task One", "s1"]);
Command::new("git")
.args(["add", ".plan"])
.current_dir(dir)
.ok()
.unwrap();
Command::new("git")
.args(["commit", "-m", "clean backlog"])
.current_dir(dir)
.ok()
.unwrap();
}
#[test]
fn test_e2e_lint_clean() {
let td = tempfile::tempdir().unwrap();
seed_lint_repo(td.path());
let out = planr_ok(td.path(), &["lint"]);
assert!(
out.is_empty() || out.contains("0 error(s)"),
"expected clean lint: '{out}'"
);
}
#[test]
fn test_e2e_lint_ref_clean() {
let td = tempfile::tempdir().unwrap();
seed_lint_repo(td.path());
let out = planr_ok(td.path(), &["lint", "main"]);
assert!(
out.is_empty() || out.contains("0 error(s)"),
"ref lint: '{out}'"
);
}
#[test]
fn test_e2e_lint_dangling_dep() {
let td = tempfile::tempdir().unwrap();
seed_lint_repo(td.path());
let t2_path = planr_ok(td.path(), &["new", "task", "t2", "Task Two", "s1"]);
let path = td.path().join(&t2_path);
let content = std::fs::read_to_string(&path).unwrap();
let new_content = content.replace("depends_on: []", "depends_on: [nonexistent]");
std::fs::write(&path, new_content).unwrap();
let out = planr(td.path(), &["lint"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("nonexistent"), "dangling dep: {stdout}");
assert!(
stdout.contains("could never be satisfied"),
"dep message: {stdout}"
);
}
#[test]
fn test_e2e_board_summary() {
let td = tempfile::tempdir().unwrap();
seed_lint_repo(td.path());
let out = planr_ok(td.path(), &["board"]);
assert!(out.contains("## summary"), "board missing summary");
assert!(out.contains("total"), "board missing total");
assert!(out.contains("## epics"), "board missing epics");
assert!(out.contains("## stories"), "board missing stories");
assert!(out.contains("## tasks"), "board missing tasks");
}
#[test]
fn test_e2e_abandon_task_skips_review_and_blocks_dependents() {
let td = tempfile::tempdir().unwrap();
seed_lint_repo(td.path());
let dependent_path = planr_ok(td.path(), &["new", "task", "dependent", "Dependent", "s1"]);
let dependent = std::fs::read_to_string(td.path().join(&dependent_path)).unwrap();
std::fs::write(
td.path().join(&dependent_path),
dependent.replace("depends_on: []", "depends_on: [t1]"),
)
.unwrap();
Command::new("git")
.args(["add", ".plan"])
.current_dir(td.path())
.ok()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add dependent"])
.current_dir(td.path())
.ok()
.unwrap();
let msg = "OBE — no longer needed";
let out = planr_ok(td.path(), &["abandon", "task", "t1", msg]);
assert!(out.contains("abandoned task t1"), "abandon output: {out}");
let t1_path = format!(".plan/tasks/{}", find_task_slug(td.path(), "t1"));
let content = std::fs::read_to_string(td.path().join(&t1_path)).unwrap();
assert!(content.contains("status: abandoned"));
assert!(content.contains("## Reason Abandoned"));
assert!(content.contains("OBE — no longer needed"));
assert!(!content.contains("verdict: approved"));
assert!(!content.contains("\nreason:"));
let err = planr_err(td.path(), &["claim", "dependent"]);
assert!(err.contains("t1(abandoned)"), "dependency blocker: {err}");
let err = planr_err(td.path(), &["close", "task", "t1"]);
assert!(err.contains("no such branch"), "close gate: {err}");
let err = planr_err(td.path(), &["abandon", "task", "t1", "wont-do even now"]);
assert!(err.contains("already abandoned"), "repeat abandon: {err}");
let content = std::fs::read_to_string(td.path().join(&t1_path)).unwrap();
assert!(!content.contains("wont-do"));
}
#[test]
fn test_e2e_abandon_story_and_epic_are_visible_on_board() {
let td = tempfile::tempdir().unwrap();
seed_lint_repo(td.path());
planr_ok(
td.path(),
&["abandon", "story", "s1", "wont-do — spec changed"],
);
planr_ok(td.path(), &["abandon", "epic", "e1", "OBE"]);
let story_path = format!(
".plan/stories/{}",
find_ticket_filename(td.path(), "stories", "s1")
);
let epic_path = format!(
".plan/epics/{}",
find_ticket_filename(td.path(), "epics", "e1")
);
let story = std::fs::read_to_string(td.path().join(story_path)).unwrap();
let epic = std::fs::read_to_string(td.path().join(epic_path)).unwrap();
assert!(story.contains("status: abandoned"));
assert!(story.contains("## Reason Abandoned"));
assert!(story.contains("wont-do — spec changed"));
assert!(epic.contains("status: abandoned"));
assert!(epic.contains("## Reason Abandoned"));
assert!(epic.contains("OBE"));
let board = planr_ok(td.path(), &["board"]);
assert!(
board.lines().any(|line| {
let fields: Vec<_> = line.split_whitespace().collect();
fields.first() == Some(&"s1") && fields.get(1) == Some(&"abandoned")
}),
"story board row: {board}"
);
assert!(
board.lines().any(|line| {
let fields: Vec<_> = line.split_whitespace().collect();
fields.first() == Some(&"e1") && fields.get(1) == Some(&"abandoned")
}),
"epic board row: {board}"
);
assert!(
board.lines().any(|line| {
line.starts_with("abandoned") && line.split_whitespace().last() == Some("2")
}),
"board summary: {board}"
);
assert!(planr_ok(td.path(), &["lint"]).is_empty());
}
#[test]
fn test_e2e_abandon_rejects_invalid_reason_and_active_branch() {
let td = tempfile::tempdir().unwrap();
seed_lint_repo(td.path());
let wt_abs = td.path().join("wt-t1");
planr_ok(
td.path(),
&["claim", "t1", "--worktree", &wt_abs.to_string_lossy()],
);
let err = planr_err(td.path(), &["abandon", "task", "t1", "some reason"]);
assert!(
err.contains("active branch plan/t1"),
"active branch: {err}"
);
assert!(wt_abs.exists(), "active worktree must remain");
let branches = Command::new("git")
.args(["branch", "--list"])
.current_dir(td.path())
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&branches.stdout).contains("plan/t1"),
"active branch must remain: {}",
String::from_utf8_lossy(&branches.stdout)
);
}
#[test]
fn test_e2e_claim_close_task() {
let td = tempfile::tempdir().unwrap();
seed_lint_repo(td.path());
let wt_rel = "wt-t1";
let wt_abs = td.path().join(wt_rel);
let wt = planr_ok(
td.path(),
&["claim", "t1", "--worktree", &wt_abs.to_string_lossy()],
);
assert!(wt.contains("wt-t1"), "worktree path: {wt}");
let task_file = format!(".plan/tasks/{}", find_task_slug(td.path(), "t1"));
let content = std::fs::read_to_string(wt_abs.join(&task_file)).unwrap();
assert!(
content.contains("status: in_progress"),
"flipped to in_progress"
);
let review_content = content.replace("status: in_progress", "status: review")
+ "\n\n## Review\n\nverdict: approved\nreviewer: test\ndate: 2026-09-05\n";
std::fs::write(wt_abs.join(&task_file), review_content).unwrap();
Command::new("git")
.args(["add", &task_file])
.current_dir(&wt_abs)
.ok()
.unwrap();
Command::new("git")
.args(["commit", "-m", "review: t1"])
.current_dir(&wt_abs)
.ok()
.unwrap();
let close_out = planr_ok(td.path(), &["close", "task", "t1"]);
assert!(close_out.contains("merged"), "close output: {close_out}");
assert!(close_out.contains("t1 done"), "close output: {close_out}");
let branches = Command::new("git")
.args(["branch", "--list"])
.current_dir(td.path())
.output()
.unwrap();
let branches_str = String::from_utf8(branches.stdout).unwrap();
assert!(
!branches_str.contains("plan/t1"),
"branch should be deleted: {branches_str}"
);
}
fn find_ticket_filename(plan_dir: &Path, kind_dir: &str, slug: &str) -> String {
let tickets_dir = plan_dir.join(format!(".plan/{kind_dir}"));
for entry in std::fs::read_dir(tickets_dir).unwrap() {
let e = entry.unwrap();
let name = e.file_name().into_string().unwrap();
if name.ends_with(&format!("-{slug}.md")) {
return name;
}
}
panic!("{kind_dir}/{slug} not found");
}
fn find_task_slug(plan_dir: &Path, slug: &str) -> String {
find_ticket_filename(plan_dir, "tasks", slug)
}
#[test]
fn test_e2e_parallel_new_ticket() {
use std::sync::Arc;
let td = Arc::new(tempfile::tempdir().unwrap());
init_repo(td.path());
planr_ok(td.path(), &["new", "epic", "parent-epic", "Parent"]);
let mut handles = Vec::new();
for i in 0..3 {
let td = td.clone();
handles.push(std::thread::spawn(move || {
let out = Command::cargo_bin("planr")
.unwrap()
.args([
"new",
"task",
&format!("task-{i}"),
&format!("Task {i}"),
"parent-epic",
])
.current_dir(td.path())
.output()
.unwrap();
assert!(
out.status.success(),
"parallel new {i} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap().trim().to_string();
assert!(!stdout.is_empty(), "parallel new {i} empty stdout");
stdout
}));
}
let mut paths: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
paths.sort();
paths.dedup();
assert_eq!(paths.len(), 3, "expected 3 distinct paths: {paths:?}");
let lint_out = planr_ok(td.path(), &["lint"]);
assert!(
lint_out.is_empty() || lint_out.contains("0 error(s)"),
"lint after parallel: '{lint_out}'"
);
}
#[test]
fn test_e2e_close_story_refuses_open_tasks() {
let td = tempfile::tempdir().unwrap();
seed_lint_repo(td.path());
let err = planr_err(td.path(), &["close", "story", "s1"]);
assert!(
err.contains("unfinished"),
"expected unfinished tasks: {err}"
);
}
#[test]
fn test_e2e_close_epic_refuses_open_stories() {
let td = tempfile::tempdir().unwrap();
seed_lint_repo(td.path());
let err = planr_err(td.path(), &["close", "epic", "e1"]);
assert!(
err.contains("unfinished"),
"expected unfinished stories: {err}"
);
}
#[test]
fn test_e2e_new_ticket_informational_lint() {
let td = tempfile::tempdir().unwrap();
seed_lint_repo(td.path());
let (stdout, _stderr) =
planr_ok_both(td.path(), &["new", "task", "new-lint-test", "Test", "s1"]);
assert!(stdout.contains(".plan/tasks/"), "stdout: {stdout}");
assert_eq!(
stdout.lines().count(),
1,
"stdout must be one line: {stdout}"
);
assert!(stdout.contains(".plan/tasks/"));
}
fn t1_path_of(dir: &Path) -> String {
format!(".plan/tasks/{}", find_task_slug(dir, "t1"))
}
fn t2_path_of(dir: &Path) -> String {
format!(".plan/tasks/{}", find_task_slug(dir, "t2"))
}
fn t3_path_of(dir: &Path) -> String {
format!(".plan/tasks/{}", find_task_slug(dir, "t3"))
}
#[test]
fn test_e2e_lint_cycle_real() {
let td = tempfile::tempdir().unwrap();
seed_lint_repo(td.path());
let _t2_path = planr_ok(td.path(), &["new", "task", "t2", "Task Two", "s1"]);
let _t3_path = planr_ok(td.path(), &["new", "task", "t3", "Task Three", "s1"]);
let inject = |path: &Path, deps: &str| {
let c = std::fs::read_to_string(path).unwrap();
std::fs::write(
path,
c.replace("depends_on: []", &format!("depends_on: [{deps}]")),
)
.unwrap();
};
inject(&td.path().join(t1_path_of(td.path())), "t2");
inject(&td.path().join(t2_path_of(td.path())), "t3");
inject(&td.path().join(t3_path_of(td.path())), "t1");
let c = std::fs::read_to_string(td.path().join(t1_path_of(td.path()))).unwrap();
std::fs::write(
td.path().join(t1_path_of(td.path())),
c.replace("depends_on: [t2]", "depends_on: [t2, t1]"),
)
.unwrap();
let out = planr(td.path(), &["lint"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("cycle"), "expected cycle: {stdout}");
assert!(stdout.contains("t1"), "cycle involving t1: {stdout}");
}