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() };
super::guard_repo(&with(Some("aaa")), Some("aaa"), "bl-1").unwrap();
let err = super::guard_repo(&with(Some("aaa")), Some("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), Some("bbb"), "bl-1").unwrap();
super::guard_repo(&with(Some("aaa")), None, "bl-1").unwrap();
}
#[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_root = Some("bbb".into());
let err = o.stage(dir).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
o.current_root = Some("aaa".into());
o.stage(dir).unwrap();
assert_eq!(read_task(dir, "bl-1").unwrap().claimant.as_deref(), Some("me"));
}
#[test]
fn update_applies_every_field_edit_and_bumps_updated() {
let d = tempdir().unwrap();
let dir = d.path();
write(dir, "bl-1", RICH);
let u = Update {
id: "bl-1".into(),
actor: "me".into(),
now: 99,
message: None,
edits: vec![
FieldEdit::Title("Renamed".into()),
FieldEdit::Body("new body\n".into()),
FieldEdit::Parent(Some("bl-p".into())),
FieldEdit::Priority(Some(3)),
FieldEdit::AddTag("a".into()),
FieldEdit::AddTag("b".into()),
FieldEdit::RemoveTag("a".into()),
FieldEdit::AddBlocker(Blocker { id: "bl-x".into(), on: On::Close }),
FieldEdit::AddBlocker(Blocker { id: "bl-x".into(), on: On::Close }),
FieldEdit::RemoveBlocker("bl-z".into()),
FieldEdit::SetExtra("state".into(), "doing".into()),
FieldEdit::SetExtra("foo".into(), "bar".into()),
FieldEdit::RemoveExtra("foo".into()),
],
};
u.stage(dir).unwrap();
let t = read_task(dir, "bl-1").unwrap();
assert_eq!(t.title, "Renamed");
assert_eq!(t.body, "new body\n");
assert_eq!(t.parent.as_deref(), Some("bl-p"));
assert_eq!(t.priority, Some(3));
assert_eq!(t.tags, ["b"]);
assert_eq!(t.blockers, vec![Blocker { id: "bl-x".into(), on: On::Close }]);
assert_eq!(t.updated, 99);
assert_eq!(
t.extra.get("state").and_then(toml::Value::as_str),
Some("doing")
);
assert!(!t.extra.contains_key("foo"));
}
#[test]
fn update_replace_overwrites_the_whole_ball_but_preserves_created() {
let d = tempdir().unwrap();
let dir = d.path();
write(dir, "bl-1", RICH);
let original_created = read_task(dir, "bl-1").unwrap().created;
let buffer = Task { title: "Hand-edited".into(), created: 999, updated: 999, body: "rewritten\n".into(), ..Task::default() };
let u = Update {
id: "bl-1".into(),
actor: "me".into(),
now: 7,
message: None,
edits: vec![FieldEdit::Replace(Box::new(buffer))],
};
u.stage(dir).unwrap();
let t = read_task(dir, "bl-1").unwrap();
assert_eq!(t.title, "Hand-edited");
assert_eq!(t.body, "rewritten\n");
assert_eq!(t.created, original_created, "created is preserved");
assert_eq!(t.updated, 7, "updated is seal-restamped, never the hand-typed 999");
assert!(t.parent.is_none() && t.priority.is_none() && t.tags.is_empty() && t.blockers.is_empty());
}
#[test]
fn update_is_refused_while_an_on_update_blocker_is_open() {
let d = tempdir().unwrap();
let dir = d.path();
write(dir, "bl-dep", TASK); write(
dir,
"bl-1",
"+++\ntitle = \"A\"\ncreated = 0\nupdated = 0\n\n[[blockers]]\nid = \"bl-dep\"\non = \"update\"\n+++\n",
);
let u = Update {
id: "bl-1".into(),
actor: "me".into(),
now: 9,
message: None,
edits: vec![FieldEdit::Title("Renamed".into())],
};
let err = u.stage(dir).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(err.to_string(), "update: bl-1 blocked by unresolved bl-dep");
}
#[test]
fn update_clears_optional_fields_with_none() {
let d = tempdir().unwrap();
let dir = d.path();
write(dir, "bl-1", RICH);
let u = Update {
id: "bl-1".into(),
actor: "me".into(),
now: 99,
message: None,
edits: vec![FieldEdit::Parent(None), FieldEdit::Priority(None)],
};
u.stage(dir).unwrap();
let t = read_task(dir, "bl-1").unwrap();
assert!(t.parent.is_none());
assert!(t.priority.is_none());
}
#[test]
fn update_finalizes_with_the_retitled_subject() {
let d = tempdir().unwrap();
let dir = d.path();
write(dir, "bl-1", TASK);
let u = Update {
id: "bl-1".into(),
actor: "me".into(),
now: 99,
message: None,
edits: vec![FieldEdit::Title("Renamed".into())],
};
u.stage(dir).unwrap();
let msg = u.finalize(dir).unwrap();
assert!(msg.starts_with("Renamed"));
assert_eq!(parse(&msg).unwrap()["bl-op"], ["update"]);
}
#[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."));
}
#[test]
fn update_is_narrated_iff_it_carries_m() {
let noted =
Update { id: "bl-1".into(), actor: "me".into(), now: 1, edits: vec![], message: Some("n".into()) };
assert!(noted.narrated());
let plain = Update { id: "bl-1".into(), actor: "me".into(), now: 1, edits: vec![], message: None };
assert!(!plain.narrated());
}
#[path = "change_create_tests.rs"]
mod create;