mod markdown;
mod text;
mod time;
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)]
pub struct StatusChange {
pub at: DateTime<Local>,
pub from: Status,
pub to: Status,
pub by: Author,
}
#[derive(Clone, Debug, PartialEq, Eq)]
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)]
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)]
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 sessions: Vec<Session>,
pub path: Option<PathBuf>,
pub unknown: Unknown,
}
#[derive(Clone, Debug, Default)]
pub struct Unknown {
pub meta: Vec<String>,
pub links: Vec<String>,
pub sections: Vec<String>,
}
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(),
sessions: Vec::new(),
path: None,
}
}
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 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;