1mod markdown;
8mod text;
9mod time;
10
11pub(crate) use markdown::{MISSING_ID, ParseError};
12pub use markdown::{not_a_link, parse_links};
13pub(crate) use text::infallible;
14pub use text::{counted, one_line, parse_tags, sanitize_comment, sanitize_description, slugify};
15#[cfg(test)]
16pub use time::parse_ts;
17pub use time::{covered, fmt_age, fmt_clock, fmt_dur, fmt_ts, now};
18
19use crate::enums::named_enum;
20use chrono::{DateTime, Duration, Local};
21use std::path::PathBuf;
22
23named_enum! {
24 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
27 pub enum Status {
28 Doing => "doing",
30 Todo => "todo",
32 Parked => "parked",
34 Done => "done",
36 }
37}
38
39named_enum! {
40 #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
43 pub enum Author {
44 #[default]
46 Human => "human",
47 Agent => "agent",
49 }
50}
51
52named_enum! {
53 #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
56 pub enum DecisionState {
57 #[default]
59 Accepted => "accepted",
60 Proposed => "proposed",
62 Rejected => "rejected",
64 }
65}
66
67named_enum! {
68 #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
71 pub enum Kind {
72 #[default]
74 Task => "task",
75 Note => "note",
77 Decision => "decision",
79 }
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84#[non_exhaustive]
85pub struct StatusChange {
86 pub at: DateTime<Local>,
88 pub from: Status,
90 pub to: Status,
92 pub by: Author,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
98#[non_exhaustive]
99pub struct Session {
100 pub start: DateTime<Local>,
102 pub end: Option<DateTime<Local>>,
104}
105
106impl Session {
107 pub fn duration(&self, now: DateTime<Local>) -> Duration {
109 (self.end.unwrap_or(now) - self.start).max(Duration::zero())
110 }
111
112 pub fn overlap(&self, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>) -> Duration {
114 let (start, end) = self.span(from, to, now);
115 (end - start).max(Duration::zero())
116 }
117
118 pub fn span(
121 &self,
122 from: DateTime<Local>,
123 to: DateTime<Local>,
124 now: DateTime<Local>,
125 ) -> (DateTime<Local>, DateTime<Local>) {
126 (self.start.max(from), self.end.unwrap_or(now).min(to))
127 }
128}
129
130#[derive(Clone, Debug, PartialEq, Eq)]
132#[non_exhaustive]
133pub struct Comment {
134 pub created: DateTime<Local>,
136 pub edited: Option<DateTime<Local>>,
138 pub body: String,
140 pub author: Author,
142 pub question: bool,
144 pub answer: bool,
147 pub unknown: Vec<String>,
149}
150
151impl Comment {
152 pub fn first_line(&self) -> &str {
154 self.body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or_default()
155 }
156
157 pub fn new(created: DateTime<Local>, body: String, author: Author) -> Self {
159 Self { created, edited: None, body, author, question: false, answer: false, unknown: Vec::new() }
160 }
161}
162
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165pub enum Asked<'a> {
166 Waiting(&'a Comment),
168 Answered(&'a Comment),
170}
171
172impl Asked<'_> {
173 pub fn label(self) -> &'static str {
175 match self {
176 Self::Waiting(_) => "waiting for an answer",
177 Self::Answered(_) => "answered",
178 }
179 }
180}
181
182#[derive(Clone, Debug, PartialEq, Eq)]
185pub struct RepoLink {
186 pub repo: String,
188 pub id: u32,
190}
191
192impl std::fmt::Display for RepoLink {
193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 write!(f, "{}#{}", self.repo, self.id)
195 }
196}
197
198#[derive(Clone, Debug)]
201#[non_exhaustive]
202pub struct Task {
203 pub id: u32,
205 pub kind: Kind,
207 pub title: String,
209 pub tags: Vec<String>,
211 pub links: Vec<u32>,
213 pub repo_links: Vec<RepoLink>,
216 pub status: Status,
218 pub decision: DecisionState,
220 pub created: DateTime<Local>,
222 pub description: String,
224 pub comments: Vec<Comment>,
226 pub history: Vec<StatusChange>,
228 pub activity: Vec<Activity>,
230 pub sessions: Vec<Session>,
232 pub path: Option<PathBuf>,
234 pub unknown: Unknown,
236}
237
238#[derive(Clone, Debug, Default)]
241#[non_exhaustive]
242pub struct Unknown {
243 pub meta: Vec<String>,
245 pub links: Vec<String>,
247 pub sections: Vec<String>,
249 pub activity: Vec<String>,
251}
252
253#[derive(Clone, Debug, PartialEq, Eq)]
256#[non_exhaustive]
257pub struct Activity {
258 pub at: DateTime<Local>,
260 pub by: Author,
262 pub change: Changed,
264}
265
266impl Activity {
267 pub fn new(at: DateTime<Local>, by: Author, change: Changed) -> Self {
269 Self { at, by, change }
270 }
271}
272
273#[derive(Clone, Debug, PartialEq, Eq)]
276#[non_exhaustive]
277pub enum Changed {
278 Title {
280 from: String,
282 to: String,
284 },
285 Tags {
287 added: Vec<String>,
289 removed: Vec<String>,
291 },
292 Related {
295 added: Vec<String>,
297 removed: Vec<String>,
299 },
300 Description,
302}
303
304impl Changed {
305 pub fn said(&self) -> String {
308 type Name = fn(&String) -> String;
309 let (added, removed, verbs, name): (&[String], &[String], _, Name) = match self {
310 Self::Title { from, to } => return format!("changed the title from \"{from}\" to \"{to}\""),
311 Self::Description => return "changed the description".to_string(),
312 Self::Tags { added, removed } => (added, removed, ["added", "removed"], |tag| format!("#{tag}")),
313 Self::Related { added, removed } => (added, removed, ["related it to", "unrelated it from"], |word| {
315 if word.contains('#') { word.clone() } else { format!("#{word}") }
316 }),
317 };
318 let noun = |words: &[String]| match self {
319 Self::Tags { .. } if words.len() == 1 => " the tag",
320 Self::Tags { .. } => " the tags",
321 _ => "",
322 };
323 [(verbs[0], added), (verbs[1], removed)]
324 .into_iter()
325 .filter(|(_, words)| !words.is_empty())
326 .map(|(verb, words)| {
327 format!("{verb}{} {}", noun(words), words.iter().map(name).collect::<Vec<_>>().join(", "))
328 })
329 .collect::<Vec<_>>()
330 .join(" and ")
331 }
332
333 fn is_empty(&self) -> bool {
335 match self {
336 Self::Title { from, to } => from == to,
337 Self::Tags { added, removed } | Self::Related { added, removed } => added.is_empty() && removed.is_empty(),
338 Self::Description => false,
339 }
340 }
341
342 fn absorb(&mut self, next: Changed) -> Result<(), Changed> {
344 match (self, next) {
345 (Self::Title { to, .. }, Self::Title { to: next, .. }) => *to = next,
346 (Self::Tags { added, removed }, Self::Tags { added: more, removed: fewer })
347 | (Self::Related { added, removed }, Self::Related { added: more, removed: fewer }) => {
348 combine(added, removed, more, fewer);
349 }
350 (Self::Description, Self::Description) => {}
351 (_, next) => return Err(next),
352 }
353 Ok(())
354 }
355}
356
357#[derive(Clone, Copy, Debug, PartialEq, Eq)]
360pub enum ThreadItem<'a> {
361 Comment(usize, &'a Comment),
363 Change(&'a Activity),
365}
366
367fn combine(added: &mut Vec<String>, removed: &mut Vec<String>, more: Vec<String>, fewer: Vec<String>) {
370 for word in more {
371 match removed.iter().position(|w| *w == word) {
372 Some(at) => drop(removed.remove(at)),
373 None if !added.contains(&word) => added.push(word),
374 None => {}
375 }
376 }
377 for word in fewer {
378 match added.iter().position(|w| *w == word) {
379 Some(at) => drop(added.remove(at)),
380 None if !removed.contains(&word) => removed.push(word),
381 None => {}
382 }
383 }
384}
385
386impl Task {
387 pub fn new(id: u32, title: String, now: DateTime<Local>) -> Self {
389 Self {
390 id,
391 kind: Kind::Task,
392 title,
393 tags: Vec::new(),
394 links: Vec::new(),
395 repo_links: Vec::new(),
396 unknown: Unknown::default(),
397 status: Status::Todo,
398 decision: DecisionState::Accepted,
399 created: now,
400 description: String::new(),
401 comments: Vec::new(),
402 history: Vec::new(),
403 activity: Vec::new(),
404 sessions: Vec::new(),
405 path: None,
406 }
407 }
408
409 pub fn log(&mut self, at: DateTime<Local>, by: Author, change: Changed) {
414 let place = self.activity.partition_point(|a| a.at <= at);
415 let mut change = change;
416 if let Some(latest) = place.checked_sub(1).filter(|&i| self.activity[i].at == at && self.activity[i].by == by) {
417 match self.activity[latest].change.absorb(change) {
418 Ok(()) => {
419 if self.activity[latest].change.is_empty() {
420 self.activity.remove(latest);
421 }
422 return;
423 }
424 Err(other) => change = other,
425 }
426 }
427 if !change.is_empty() {
428 self.activity.insert(place, Activity::new(at, by, change));
429 }
430 }
431
432 pub fn set_title(&mut self, title: String, at: DateTime<Local>, by: Author) -> bool {
434 if title == self.title {
435 return false;
436 }
437 let from = std::mem::replace(&mut self.title, title);
438 self.log(at, by, Changed::Title { from, to: self.title.clone() });
439 true
440 }
441
442 pub fn set_tags(&mut self, tags: Vec<String>, at: DateTime<Local>, by: Author) -> bool {
445 if tags == self.tags {
446 return false;
447 }
448 let added = tags.iter().filter(|t| !self.tags.contains(t)).cloned().collect();
449 let removed = self.tags.iter().filter(|t| !tags.contains(t)).cloned().collect();
450 self.tags = tags;
451 self.log(at, by, Changed::Tags { added, removed });
452 true
453 }
454
455 pub fn set_description(&mut self, description: String, at: DateTime<Local>, by: Author) -> bool {
457 if description == self.description {
458 return false;
459 }
460 self.description = description;
461 self.log(at, by, Changed::Description);
462 true
463 }
464
465 pub fn relate(&mut self, at: DateTime<Local>, by: Author, added: &[String], removed: &[String]) {
468 self.log(at, by, Changed::Related { added: added.to_vec(), removed: removed.to_vec() });
469 }
470
471 pub fn set_status(&mut self, status: Status, now: DateTime<Local>, by: Author) -> bool {
475 if self.status == status {
476 return false;
477 }
478 if status != Status::Doing {
479 self.stop_session(now);
480 }
481 self.history.push(StatusChange { at: now, from: self.status, to: status, by });
482 self.status = status;
483 true
484 }
485
486 pub fn is_running(&self) -> bool {
488 self.sessions.last().is_some_and(|s| s.end.is_none())
489 }
490
491 pub fn running_for(&self, now: DateTime<Local>) -> Option<Duration> {
493 self.sessions.last().filter(|s| s.end.is_none()).map(|s| s.duration(now))
494 }
495
496 pub fn start_session(&mut self, now: DateTime<Local>) {
498 if !self.is_running() {
499 self.sessions.push(Session { start: now, end: None });
500 }
501 }
502
503 pub fn stop_session(&mut self, now: DateTime<Local>) {
505 if let Some(session) = self.sessions.last_mut().filter(|s| s.end.is_none()) {
506 session.end = Some(now.max(session.start));
507 }
508 }
509
510 pub fn total(&self, now: DateTime<Local>) -> Duration {
512 self.sessions.iter().map(|s| s.duration(now)).sum()
513 }
514
515 pub fn time_between(&self, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>) -> Duration {
517 self.sessions.iter().map(|s| s.overlap(from, to, now)).sum()
518 }
519
520 pub fn started_by(&self) -> Author {
523 self.history.iter().rev().find(|c| c.to == Status::Doing).map_or(Author::Human, |c| c.by)
524 }
525
526 pub fn doing_by(&self, by: Author) -> bool {
529 self.is_task() && self.status == Status::Doing && self.started_by() == by
530 }
531
532 pub fn status_since(&self) -> DateTime<Local> {
534 self.history.last().map_or(self.created, |c| c.at)
535 }
536
537 pub fn asked(&self) -> Option<Asked<'_>> {
541 if !self.is_task() || self.status != Status::Parked {
542 return None;
543 }
544 let at = self.comments.iter().rposition(|c| c.question)?;
545 let question = &self.comments[at];
546 let answer = self.comments[at + 1..].iter().rev().find(|c| c.author == Author::Human || c.answer);
547 let picked_up = self.history.iter().any(|c| c.to == Status::Doing && c.at > question.created);
550 match answer {
551 _ if picked_up => None,
552 Some(answer) => Some(Asked::Answered(answer)),
553 None => Some(Asked::Waiting(question)),
554 }
555 }
556
557 pub fn waits_for_answer(&self) -> bool {
560 matches!(self.asked(), Some(Asked::Waiting(_)))
561 }
562
563 pub fn thread(&self) -> Vec<ThreadItem<'_>> {
566 let mut thread = Vec::with_capacity(self.comments.len() + self.activity.len());
567 let mut changes = self.activity.iter().peekable();
568 for (index, comment) in self.comments.iter().enumerate() {
569 while let Some(change) = changes.next_if(|change| change.at <= comment.created) {
570 thread.push(ThreadItem::Change(change));
571 }
572 thread.push(ThreadItem::Comment(index, comment));
573 }
574 thread.extend(changes.map(ThreadItem::Change));
575 thread
576 }
577
578 pub fn is_task(&self) -> bool {
580 self.kind == Kind::Task
581 }
582
583 pub fn matches(&self, query: &str) -> bool {
586 query.split_whitespace().all(|term| self.has_term(&term.to_lowercase(), false))
587 }
588
589 pub(crate) fn has_term(&self, term: &str, everywhere: bool) -> bool {
592 match term.strip_prefix('#') {
593 Some("") => true,
594 Some(tag) => text::parse_tags(tag).first().is_some_and(|tag| self.tags.iter().any(|t| t.starts_with(tag))),
595 None => {
596 self.title.to_lowercase().contains(term)
597 || self.tags.iter().any(|t| t.contains(term))
598 || everywhere
599 && (self.description.to_lowercase().contains(term)
600 || self.comments.iter().any(|c| c.body.to_lowercase().contains(term)))
601 }
602 }
603 }
604}
605
606pub fn worked<'a>(
609 tasks: impl IntoIterator<Item = &'a Task>,
610 from: DateTime<Local>,
611 to: DateTime<Local>,
612 now: DateTime<Local>,
613) -> Duration {
614 covered(tasks.into_iter().flat_map(|t| &t.sessions).map(|s| s.span(from, to, now)))
615}
616
617#[cfg(test)]
618#[path = "../../tests/model.rs"]
619mod tests;