use super::*;
use crate::message::parse;
use crate::task::{Blocker, On, Task};
use tempfile::tempdir;
const TASK: &str = "+++\ntitle = \"A task\"\ncreated = 0\nupdated = 0\n+++\nbody\n";
const CLAIMED: &str = "+++\ntitle = \"A task\"\ncreated = 0\nupdated = 0\nclaimant = \"bob\"\n+++\n";
const RICH: &str = "+++\ntitle = \"A task\"\ncreated = 0\nupdated = 0\nparent = \"bl-old\"\n\
priority = 1\ntags = [\"a\"]\n\n[[blockers]]\nid = \"bl-z\"\non = \"claim\"\n+++\nbody\n";
fn write(dir: &Path, id: &str, md: &str) {
let tasks = dir.join("tasks");
fs::create_dir_all(&tasks).unwrap();
fs::write(tasks.join(format!("{id}.md")), md).unwrap();
}
#[test]
fn claim_sets_the_claimant_and_bumps_updated() {
let d = tempdir().unwrap();
let dir = d.path();
write(dir, "bl-1", TASK);
let o = Occupancy::claim("bl-1".into(), "alice".into(), 1_749_085_200);
o.stage(dir).unwrap();
let t = read_task(dir, "bl-1").unwrap();
assert_eq!(t.claimant.as_deref(), Some("alice"));
assert_eq!(t.updated, 1_749_085_200);
let md = parse(&o.finalize(dir).unwrap()).unwrap();
assert_eq!(md["bl-op"], ["claim"]);
assert_eq!(md["bl-id"], ["bl-1"]);
}
#[test]
fn claim_refuses_an_already_claimed_ball() {
let d = tempdir().unwrap();
let dir = d.path();
write(dir, "bl-1", CLAIMED);
let o = Occupancy::claim("bl-1".into(), "alice".into(), 0);
let err = o.stage(dir).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert!(err.to_string().contains("already claimed by bob"));
}
#[test]
fn unclaim_clears_the_claimant() {
let d = tempdir().unwrap();
let dir = d.path();
write(dir, "bl-1", CLAIMED);
let o = Occupancy::unclaim("bl-1".into(), "alice".into(), 22);
o.stage(dir).unwrap();
let t = read_task(dir, "bl-1").unwrap();
assert!(t.claimant.is_none());
assert_eq!(t.updated, 22);
let md = parse(&o.finalize(dir).unwrap()).unwrap();
assert_eq!(md["bl-op"], ["unclaim"]);
}
#[test]
fn guard_repo_rejects_only_a_definite_cross_repo_mismatch() {
let with = |r: Option<&str>| Task { root_commit: r.map(str::to_string), ..Task::default() };
let set = |rs: &[&str]| rs.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
super::guard_repo(&with(Some("aaa")), &set(&["aaa"]), "bl-1").unwrap();
let err = super::guard_repo(&with(Some("aaa")), &set(&["bbb"]), "bl-1").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
let msg = err.to_string();
assert!(msg.contains("rooted at aaa") && msg.contains("rooted at bbb"), "{msg}");
super::guard_repo(&with(None), &set(&["bbb"]), "bl-1").unwrap();
super::guard_repo(&with(Some("aaa")), &[], "bl-1").unwrap();
}
#[test]
fn a_multi_root_checkout_admits_a_ball_recorded_against_any_of_its_roots() {
use crate::delivery_repo::Project;
let tmp = tempdir().unwrap();
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap();
let g = |args: &[&str]| Project::run(&repo, args).unwrap();
g(&["init", "-q", "-b", "main"]);
g(&["config", "user.name", "t"]);
g(&["config", "user.email", "t@e.test"]);
fs::write(repo.join("a.txt"), "A").unwrap();
g(&["add", "-A"]);
g(&["commit", "-q", "-m", "root A"]);
let root_a = Project::run(&repo, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
g(&["checkout", "-q", "--orphan", "vendor"]);
g(&["rm", "-q", "-f", "a.txt"]);
fs::write(repo.join("b.txt"), "B").unwrap();
g(&["add", "-A"]);
g(&["commit", "-q", "-m", "root B"]);
let root_b = Project::run(&repo, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
g(&["checkout", "-q", "main"]);
g(&["merge", "-q", "--allow-unrelated-histories", "--no-edit", "vendor"]);
let roots = Project::at(&repo).root_commits();
assert_eq!(roots.len(), 2, "a merged unrelated history has two roots: {roots:?}");
assert!(roots.contains(&root_a) && roots.contains(&root_b), "{roots:?}");
let ball = |r: &str| Task { root_commit: Some(r.to_string()), ..Task::default() };
let mut flipped = roots.clone();
flipped.reverse();
super::guard_repo(&ball(&root_a), &roots, "bl-1").unwrap();
super::guard_repo(&ball(&root_a), &flipped, "bl-1").unwrap();
super::guard_repo(&ball(&root_b), &roots, "bl-1").unwrap();
assert!(super::guard_repo(&ball("deadbeef"), &roots, "bl-1").is_err());
}
#[test]
fn claim_stage_rejects_a_wrong_repo_ball() {
let d = tempdir().unwrap();
let dir = d.path();
write(dir, "bl-1", "+++\ntitle = \"A\"\ncreated = 0\nupdated = 0\nroot_commit = \"aaa\"\n+++\n");
let mut o = Occupancy::claim("bl-1".into(), "me".into(), 0);
o.current_roots = vec!["bbb".into()];
let err = o.stage(dir).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
o.current_roots = vec!["ccc".into(), "aaa".into()];
o.stage(dir).unwrap();
assert_eq!(read_task(dir, "bl-1").unwrap().claimant.as_deref(), Some("me"));
}
#[test]
fn close_removes_the_file_and_emits_a_close_message() {
let d = tempdir().unwrap();
let dir = d.path();
write(dir, "bl-1", TASK);
let r = Retire::close("bl-1".into(), "A task".into(), "me".into());
r.stage(dir).unwrap();
assert!(!task_path(dir, "bl-1").exists());
let msg = r.finalize(dir).unwrap();
assert!(msg.starts_with("A task"));
let md = parse(&msg).unwrap();
assert_eq!(md["bl-op"], ["close"]);
assert_eq!(md["bl-id"], ["bl-1"]);
}
#[test]
fn retire_errors_when_the_ball_is_absent() {
let d = tempdir().unwrap();
let r = Retire::close("bl-gone".into(), "t".into(), "me".into());
assert!(r.stage(d.path()).is_err());
}
#[test]
fn the_m_message_flows_into_the_commit_body_under_the_title_subject() {
let d = tempdir().unwrap();
let dir = d.path();
write(dir, "bl-1", TASK);
let mut o = Occupancy::claim("bl-1".into(), "me".into(), 0);
o.message = Some("Extra paragraph.".into());
o.stage(dir).unwrap();
let msg = o.finalize(dir).unwrap();
assert!(msg.starts_with("A task"));
assert!(msg.contains("Extra paragraph."));
}
#[path = "change_create_tests.rs"]
mod create;
#[path = "change_field_tests.rs"]
mod field;