use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::Path;
use crate::enforce;
use crate::id;
use crate::lifecycle::BaseChange;
use crate::message::Message;
use crate::task::{Blocker, On, Task};
use crate::taskfile::{add_blocker, invalid, read_task, task_ids, task_path, write_task};
use crate::verb::Verb;
pub struct Create {
pub id: String,
pub actor: String,
pub now: i64,
pub title: String,
pub parent: Option<String>,
pub priority: Option<i64>,
pub tags: Vec<String>,
pub blockers: Vec<Blocker>,
pub blocks: Vec<(String, On)>,
pub body: Option<String>,
pub message: Option<String>,
pub existing: Vec<String>,
}
impl BaseChange for Create {
fn stage(&self, dir: &Path) -> io::Result<()> {
let task = Task {
title: self.title.clone(),
created: self.now,
updated: self.now,
parent: self.parent.clone(),
priority: self.priority,
blockers: self.blockers.clone(),
tags: self.tags.clone(),
body: self.body.clone().unwrap_or_default(),
..Task::default()
};
write_task(dir, &self.id, &task)?;
for (target, on) in &self.blocks {
add_blocker(dir, target, Blocker { id: self.id.clone(), on: *on }, self.now)?;
}
Ok(())
}
fn finalize(&self, dir: &Path) -> io::Result<String> {
let existing: BTreeSet<&str> = self.existing.iter().map(String::as_str).collect();
let mut new: Vec<String> = task_ids(dir)?
.into_iter()
.filter(|id| !existing.contains(id.as_str()))
.collect();
let Some(id) = new.pop() else {
return Err(invalid(self.vanished(dir)));
};
if !new.is_empty() {
return Err(invalid(format!(
"create: expected exactly one new task file, found {}",
new.len() + 1
)));
}
if !id::is_valid(&id) {
return Err(invalid(format!("create: invalid task id '{id}'")));
}
let title = read_task(dir, &id)?.title;
commit_message(Verb::Create, &self.actor, &id, &title, self.message.as_deref())
}
}
impl Create {
fn vanished(&self, dir: &Path) -> String {
let collided = self.existing.iter().find(|id| {
read_task(dir, id).is_ok_and(|t| t.created == self.now && t.title == self.title)
});
match collided {
Some(id) => format!(
"create: a create.pre plugin reassigned the new task to `{id}`, which already exists — id collision, nothing sealed"
),
None => "create: expected exactly one new task file, found 0".to_string(),
}
}
}
pub struct Occupancy {
pub verb: Verb,
pub id: String,
pub claimant: Option<String>,
pub actor: String,
pub now: i64,
pub message: Option<String>,
}
impl Occupancy {
pub fn claim(id: String, actor: String, now: i64) -> Self {
Self { verb: Verb::Claim, id, claimant: Some(actor.clone()), actor, now, message: None }
}
pub fn unclaim(id: String, actor: String, now: i64) -> Self {
Self { verb: Verb::Unclaim, id, claimant: None, actor, now, message: None }
}
}
impl BaseChange for Occupancy {
fn stage(&self, dir: &Path) -> io::Result<()> {
let mut task = read_task(dir, &self.id)?;
if self.verb == Verb::Claim {
if let Some(who) = &task.claimant {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("claim: {} is already claimed by {who}", self.id),
));
}
enforce::claim(&task, &self.id, dir)?;
} else {
enforce::gate(&task, Verb::Unclaim, &self.id, dir)?;
}
task.claimant.clone_from(&self.claimant);
task.updated = self.now;
write_task(dir, &self.id, &task)
}
fn finalize(&self, dir: &Path) -> io::Result<String> {
let title = read_task(dir, &self.id)?.title;
commit_message(self.verb, &self.actor, &self.id, &title, self.message.as_deref())
}
}
pub struct Update {
pub id: String,
pub actor: String,
pub now: i64,
pub edits: Vec<FieldEdit>,
pub message: Option<String>,
}
impl BaseChange for Update {
fn stage(&self, dir: &Path) -> io::Result<()> {
let mut task = read_task(dir, &self.id)?;
enforce::gate(&task, Verb::Update, &self.id, dir)?;
for edit in &self.edits {
edit.apply(&mut task);
}
task.updated = self.now;
write_task(dir, &self.id, &task)
}
fn finalize(&self, dir: &Path) -> io::Result<String> {
let title = read_task(dir, &self.id)?.title;
commit_message(Verb::Update, &self.actor, &self.id, &title, self.message.as_deref())
}
fn narrated(&self) -> bool {
self.message.is_some()
}
}
#[path = "change_field.rs"]
mod field;
pub use field::FieldEdit;
pub struct Retire {
pub id: String,
pub title: String,
pub actor: String,
pub message: Option<String>,
}
impl Retire {
pub fn close(id: String, title: String, actor: String) -> Self {
Self { id, title, actor, message: None }
}
}
impl BaseChange for Retire {
fn stage(&self, dir: &Path) -> io::Result<()> {
let task = read_task(dir, &self.id)?;
enforce::close(&task, &self.id, dir)?;
fs::remove_file(task_path(dir, &self.id))
}
fn finalize(&self, _dir: &Path) -> io::Result<String> {
commit_message(Verb::Close, &self.actor, &self.id, &self.title, self.message.as_deref())
}
}
fn commit_message(verb: Verb, actor: &str, id: &str, title: &str, message: Option<&str>) -> io::Result<String> {
Message {
verb,
actor: actor.to_string(),
id: Some(id.to_string()),
subject: title.to_string(),
body: message.map(str::to_string),
}
.render()
}
#[cfg(test)]
#[path = "change_tests.rs"]
mod tests;