mod markdown;
mod text;
mod time;
pub(crate) use markdown::MISSING_ID;
pub use markdown::{not_a_link, parse_links};
pub use text::{counted, one_line, parse_tags, sanitize_comment, sanitize_description, slugify};
#[cfg(test)]
pub use time::parse_ts;
pub use time::{covered, fmt_age, fmt_clock, fmt_dur, fmt_ts, now};
use crate::enums::named_enum;
use chrono::{DateTime, Duration, Local};
use std::path::PathBuf;
named_enum! {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Doing => "doing",
Todo => "todo",
Parked => "parked",
Done => "done",
}
}
named_enum! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Author {
#[default]
Human => "human",
Agent => "agent",
}
}
named_enum! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum DecisionState {
#[default]
Accepted => "accepted",
Proposed => "proposed",
Rejected => "rejected",
}
}
named_enum! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Kind {
#[default]
Task => "task",
Note => "note",
Decision => "decision",
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct StatusChange {
pub at: DateTime<Local>,
pub from: Status,
pub to: Status,
pub by: Author,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Session {
pub start: DateTime<Local>,
pub end: Option<DateTime<Local>>,
}
impl Session {
pub fn duration(&self, now: DateTime<Local>) -> Duration {
(self.end.unwrap_or(now) - self.start).max(Duration::zero())
}
pub fn overlap(&self, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>) -> Duration {
let (start, end) = self.span(from, to, now);
(end - start).max(Duration::zero())
}
pub fn span(
&self,
from: DateTime<Local>,
to: DateTime<Local>,
now: DateTime<Local>,
) -> (DateTime<Local>, DateTime<Local>) {
(self.start.max(from), self.end.unwrap_or(now).min(to))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Comment {
pub created: DateTime<Local>,
pub edited: Option<DateTime<Local>>,
pub body: String,
pub author: Author,
pub question: bool,
pub answer: bool,
pub unknown: Vec<String>,
}
impl Comment {
pub fn new(created: DateTime<Local>, body: String, author: Author) -> Self {
Self { created, edited: None, body, author, question: false, answer: false, unknown: Vec::new() }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Asked<'a> {
Waiting(&'a Comment),
Answered(&'a Comment),
}
impl Asked<'_> {
pub fn label(self) -> &'static str {
match self {
Self::Waiting(_) => "waiting for an answer",
Self::Answered(_) => "answered",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RepoLink {
pub repo: String,
pub id: u32,
}
impl std::fmt::Display for RepoLink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}#{}", self.repo, self.id)
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Task {
pub id: u32,
pub kind: Kind,
pub title: String,
pub tags: Vec<String>,
pub links: Vec<u32>,
pub repo_links: Vec<RepoLink>,
pub status: Status,
pub decision: DecisionState,
pub created: DateTime<Local>,
pub description: String,
pub comments: Vec<Comment>,
pub history: Vec<StatusChange>,
pub activity: Vec<Activity>,
pub sessions: Vec<Session>,
pub path: Option<PathBuf>,
pub unknown: Unknown,
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct Unknown {
pub meta: Vec<String>,
pub links: Vec<String>,
pub sections: Vec<String>,
pub activity: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Activity {
pub at: DateTime<Local>,
pub by: Author,
pub change: Changed,
}
impl Activity {
pub fn new(at: DateTime<Local>, by: Author, change: Changed) -> Self {
Self { at, by, change }
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Changed {
Title {
from: String,
to: String,
},
Tags {
added: Vec<String>,
removed: Vec<String>,
},
Related {
added: Vec<String>,
removed: Vec<String>,
},
Description,
}
impl Changed {
pub fn said(&self) -> String {
type Name = fn(&String) -> String;
let (added, removed, verbs, name): (&[String], &[String], _, Name) = match self {
Self::Title { from, to } => return format!("changed the title from \"{from}\" to \"{to}\""),
Self::Description => return "changed the description".to_string(),
Self::Tags { added, removed } => (added, removed, ["added", "removed"], |tag| format!("#{tag}")),
Self::Related { added, removed } => (added, removed, ["related it to", "unrelated it from"], |word| {
if word.contains('#') { word.clone() } else { format!("#{word}") }
}),
};
let noun = |words: &[String]| match self {
Self::Tags { .. } if words.len() == 1 => " the tag",
Self::Tags { .. } => " the tags",
_ => "",
};
[(verbs[0], added), (verbs[1], removed)]
.into_iter()
.filter(|(_, words)| !words.is_empty())
.map(|(verb, words)| {
format!("{verb}{} {}", noun(words), words.iter().map(name).collect::<Vec<_>>().join(", "))
})
.collect::<Vec<_>>()
.join(" and ")
}
fn is_empty(&self) -> bool {
match self {
Self::Title { from, to } => from == to,
Self::Tags { added, removed } | Self::Related { added, removed } => added.is_empty() && removed.is_empty(),
Self::Description => false,
}
}
fn absorb(&mut self, next: Changed) -> Result<(), Changed> {
match (self, next) {
(Self::Title { to, .. }, Self::Title { to: next, .. }) => *to = next,
(Self::Tags { added, removed }, Self::Tags { added: more, removed: fewer })
| (Self::Related { added, removed }, Self::Related { added: more, removed: fewer }) => {
combine(added, removed, more, fewer);
}
(Self::Description, Self::Description) => {}
(_, next) => return Err(next),
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ThreadItem<'a> {
Comment(usize, &'a Comment),
Change(&'a Activity),
}
fn combine(added: &mut Vec<String>, removed: &mut Vec<String>, more: Vec<String>, fewer: Vec<String>) {
for word in more {
match removed.iter().position(|w| *w == word) {
Some(at) => drop(removed.remove(at)),
None if !added.contains(&word) => added.push(word),
None => {}
}
}
for word in fewer {
match added.iter().position(|w| *w == word) {
Some(at) => drop(added.remove(at)),
None if !removed.contains(&word) => removed.push(word),
None => {}
}
}
}
impl Task {
pub fn new(id: u32, title: String, now: DateTime<Local>) -> Self {
Self {
id,
kind: Kind::Task,
title,
tags: Vec::new(),
links: Vec::new(),
repo_links: Vec::new(),
unknown: Unknown::default(),
status: Status::Todo,
decision: DecisionState::Accepted,
created: now,
description: String::new(),
comments: Vec::new(),
history: Vec::new(),
activity: Vec::new(),
sessions: Vec::new(),
path: None,
}
}
pub fn log(&mut self, at: DateTime<Local>, by: Author, change: Changed) {
let place = self.activity.partition_point(|a| a.at <= at);
let mut change = change;
if let Some(latest) = place.checked_sub(1).filter(|&i| self.activity[i].at == at && self.activity[i].by == by) {
match self.activity[latest].change.absorb(change) {
Ok(()) => {
if self.activity[latest].change.is_empty() {
self.activity.remove(latest);
}
return;
}
Err(other) => change = other,
}
}
if !change.is_empty() {
self.activity.insert(place, Activity::new(at, by, change));
}
}
pub fn set_title(&mut self, title: String, at: DateTime<Local>, by: Author) -> bool {
if title == self.title {
return false;
}
let from = std::mem::replace(&mut self.title, title);
self.log(at, by, Changed::Title { from, to: self.title.clone() });
true
}
pub fn set_tags(&mut self, tags: Vec<String>, at: DateTime<Local>, by: Author) -> bool {
if tags == self.tags {
return false;
}
let added = tags.iter().filter(|t| !self.tags.contains(t)).cloned().collect();
let removed = self.tags.iter().filter(|t| !tags.contains(t)).cloned().collect();
self.tags = tags;
self.log(at, by, Changed::Tags { added, removed });
true
}
pub fn set_description(&mut self, description: String, at: DateTime<Local>, by: Author) -> bool {
if description == self.description {
return false;
}
self.description = description;
self.log(at, by, Changed::Description);
true
}
pub fn relate(&mut self, at: DateTime<Local>, by: Author, added: &[String], removed: &[String]) {
self.log(at, by, Changed::Related { added: added.to_vec(), removed: removed.to_vec() });
}
pub fn set_status(&mut self, status: Status, now: DateTime<Local>, by: Author) -> bool {
if self.status == status {
return false;
}
if status != Status::Doing {
self.stop_session(now);
}
self.history.push(StatusChange { at: now, from: self.status, to: status, by });
self.status = status;
true
}
pub fn is_running(&self) -> bool {
self.sessions.last().is_some_and(|s| s.end.is_none())
}
pub fn running_for(&self, now: DateTime<Local>) -> Option<Duration> {
self.sessions.last().filter(|s| s.end.is_none()).map(|s| s.duration(now))
}
pub fn start_session(&mut self, now: DateTime<Local>) {
if !self.is_running() {
self.sessions.push(Session { start: now, end: None });
}
}
pub fn stop_session(&mut self, now: DateTime<Local>) {
if let Some(session) = self.sessions.last_mut().filter(|s| s.end.is_none()) {
session.end = Some(now.max(session.start));
}
}
pub fn total(&self, now: DateTime<Local>) -> Duration {
self.sessions.iter().map(|s| s.duration(now)).sum()
}
pub fn time_between(&self, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>) -> Duration {
self.sessions.iter().map(|s| s.overlap(from, to, now)).sum()
}
pub fn started_by(&self) -> Author {
self.history.iter().rev().find(|c| c.to == Status::Doing).map_or(Author::Human, |c| c.by)
}
pub fn status_since(&self) -> DateTime<Local> {
self.history.last().map_or(self.created, |c| c.at)
}
pub fn asked(&self) -> Option<Asked<'_>> {
if !self.is_task() || self.status != Status::Parked {
return None;
}
let at = self.comments.iter().rposition(|c| c.question)?;
let question = &self.comments[at];
let answer = self.comments[at + 1..].iter().rev().find(|c| c.author == Author::Human || c.answer);
let picked_up = self.history.iter().any(|c| c.to == Status::Doing && c.at > question.created);
match answer {
_ if picked_up => None,
Some(answer) => Some(Asked::Answered(answer)),
None => Some(Asked::Waiting(question)),
}
}
pub fn thread(&self) -> Vec<ThreadItem<'_>> {
let mut thread = Vec::with_capacity(self.comments.len() + self.activity.len());
let mut changes = self.activity.iter().peekable();
for (index, comment) in self.comments.iter().enumerate() {
while let Some(change) = changes.next_if(|change| change.at <= comment.created) {
thread.push(ThreadItem::Change(change));
}
thread.push(ThreadItem::Comment(index, comment));
}
thread.extend(changes.map(ThreadItem::Change));
thread
}
pub fn is_task(&self) -> bool {
self.kind == Kind::Task
}
pub fn matches(&self, query: &str) -> bool {
query.split_whitespace().all(|term| self.has_term(&term.to_lowercase(), false))
}
pub(crate) fn has_term(&self, term: &str, everywhere: bool) -> bool {
match term.strip_prefix('#') {
Some("") => true,
Some(tag) => text::parse_tags(tag).first().is_some_and(|tag| self.tags.iter().any(|t| t.starts_with(tag))),
None => {
self.title.to_lowercase().contains(term)
|| self.tags.iter().any(|t| t.contains(term))
|| everywhere
&& (self.description.to_lowercase().contains(term)
|| self.comments.iter().any(|c| c.body.to_lowercase().contains(term)))
}
}
}
}
pub fn worked<'a>(
tasks: impl IntoIterator<Item = &'a Task>,
from: DateTime<Local>,
to: DateTime<Local>,
now: DateTime<Local>,
) -> Duration {
covered(tasks.into_iter().flat_map(|t| &t.sessions).map(|s| s.span(from, to, now)))
}
#[cfg(test)]
#[path = "../../tests/model.rs"]
mod tests;