kanbrain_core/model/mod.rs
1//! Entries (tasks, notes and decisions), their links, status history, time-tracking sessions, comments and activity.
2//!
3//! - [`markdown`]: reading and writing the task file format.
4//! - [`text`]: tags, file-name slugs and heading rules for user text.
5//! - [`time`]: timestamps, ages and durations.
6
7mod markdown;
8mod text;
9mod time;
10
11pub(crate) use markdown::MISSING_ID;
12pub use markdown::{not_a_link, parse_links};
13pub use text::{counted, one_line, parse_tags, sanitize_comment, sanitize_description, slugify};
14#[cfg(test)]
15pub use time::parse_ts;
16pub use time::{covered, fmt_age, fmt_clock, fmt_dur, fmt_ts, now};
17
18use crate::enums::named_enum;
19use chrono::{DateTime, Duration, Local};
20use std::path::PathBuf;
21
22named_enum! {
23 /// Where a task stands. Declared in the order the task list shows them, so
24 /// `position()` is the sort key; the names are what task files contain.
25 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
26 pub enum Status {
27 /// Being worked on.
28 Doing => "doing",
29 /// Not started yet.
30 Todo => "todo",
31 /// Started, then set aside for now.
32 Parked => "parked",
33 /// Finished; hidden from the list by default.
34 Done => "done",
35 }
36}
37
38named_enum! {
39 /// Who made a change: the user, or a coding agent working through `kb`. Comments and status changes
40 /// record it, so the board tells them apart, and so a question an agent asked waits for a person.
41 #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
42 pub enum Author {
43 /// The user: in the kanban app, or `kb` typed at a terminal.
44 #[default]
45 Human => "human",
46 /// A coding agent, as `kb` finds from its environment.
47 Agent => "agent",
48 }
49}
50
51named_enum! {
52 /// Where a decision stands. An agent records the decisions within its task as accepted, and proposes
53 /// those it shouldn't take alone (architecture, dependencies, storage) for a person to accept or reject.
54 #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
55 pub enum DecisionState {
56 /// In force.
57 #[default]
58 Accepted => "accepted",
59 /// Waiting for a person to accept or reject it.
60 Proposed => "proposed",
61 /// Turned down: kept so the same approach isn't proposed again.
62 Rejected => "rejected",
63 }
64}
65
66named_enum! {
67 /// What an entry is. Only tasks have a status, history and sessions; notes and decisions are
68 /// documents that tasks and each other link to. Declared in the order `tab` cycles the list.
69 #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
70 pub enum Kind {
71 /// Something to do.
72 #[default]
73 Task => "task",
74 /// Something to remember: a finding, how something works, a snippet.
75 Note => "note",
76 /// Something decided, with its context and consequences (an ADR).
77 Decision => "decision",
78 }
79}
80
81/// One status change, as kept in the task's history.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83#[non_exhaustive]
84pub struct StatusChange {
85 /// When the status changed.
86 pub at: DateTime<Local>,
87 /// The status before.
88 pub from: Status,
89 /// The status after.
90 pub to: Status,
91 /// Who moved it.
92 pub by: Author,
93}
94
95/// One continuous stretch of tracked work on a task.
96#[derive(Clone, Debug, PartialEq, Eq)]
97#[non_exhaustive]
98pub struct Session {
99 /// When the session started.
100 pub start: DateTime<Local>,
101 /// `None` while the session is still running.
102 pub end: Option<DateTime<Local>>,
103}
104
105impl Session {
106 /// How long the session lasted, or has lasted so far if it's running.
107 pub fn duration(&self, now: DateTime<Local>) -> Duration {
108 (self.end.unwrap_or(now) - self.start).max(Duration::zero())
109 }
110
111 /// Part of this session that falls inside `[from, to)`.
112 pub fn overlap(&self, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>) -> Duration {
113 let (start, end) = self.span(from, to, now);
114 (end - start).max(Duration::zero())
115 }
116
117 /// Where this session starts and ends within `[from, to)`, a running one ending `now`; the end is
118 /// before the start when it falls outside.
119 pub fn span(
120 &self,
121 from: DateTime<Local>,
122 to: DateTime<Local>,
123 now: DateTime<Local>,
124 ) -> (DateTime<Local>, DateTime<Local>) {
125 (self.start.max(from), self.end.unwrap_or(now).min(to))
126 }
127}
128
129/// A timestamped comment on a task, like on a GitHub/GitLab issue.
130#[derive(Clone, Debug, PartialEq, Eq)]
131#[non_exhaustive]
132pub struct Comment {
133 /// When the comment was written.
134 pub created: DateTime<Local>,
135 /// Set when the body was changed after posting.
136 pub edited: Option<DateTime<Local>>,
137 /// The comment text (markdown).
138 pub body: String,
139 /// Who wrote it.
140 pub author: Author,
141 /// A question to the user: the task waits for a person's answer (see [`Task::asked`]).
142 pub question: bool,
143 /// The answer to the question before it, whoever wrote it: a verdict on the decision it asked about, which an
144 /// agent may give on the user's word.
145 pub answer: bool,
146 /// Words of the header's parentheses this version doesn't know, written back as they were.
147 pub unknown: Vec<String>,
148}
149
150impl Comment {
151 /// A plain comment by `author`.
152 pub fn new(created: DateTime<Local>, body: String, author: Author) -> Self {
153 Self { created, edited: None, body, author, question: false, answer: false, unknown: Vec::new() }
154 }
155}
156
157/// Where a question an agent asked on a task stands, while the task is parked waiting on it.
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159pub enum Asked<'a> {
160 /// Nobody has answered the question yet.
161 Waiting(&'a Comment),
162 /// A person answered (the latest answer): the task can be picked up again.
163 Answered(&'a Comment),
164}
165
166impl Asked<'_> {
167 /// Where it stands, as the command line's text and JSON say it.
168 pub fn label(self) -> &'static str {
169 match self {
170 Self::Waiting(_) => "waiting for an answer",
171 Self::Answered(_) => "answered",
172 }
173 }
174}
175
176/// An entry in another repository of the same project, written `shop-api#3`: ids repeat across
177/// repositories, so the repository's name goes with it.
178#[derive(Clone, Debug, PartialEq, Eq)]
179pub struct RepoLink {
180 /// The repository's name in the project, as `kb init` registered it.
181 pub repo: String,
182 /// The entry's id there.
183 pub id: u32,
184}
185
186impl std::fmt::Display for RepoLink {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 write!(f, "{}#{}", self.repo, self.id)
189 }
190}
191
192/// An entry: a task, note or decision. What it is, how it's tagged and linked, and for tasks,
193/// where it stands and how it got there.
194#[derive(Clone, Debug)]
195#[non_exhaustive]
196pub struct Task {
197 /// Unique number across all kinds, also the start of the file name.
198 pub id: u32,
199 /// Task, note or decision.
200 pub kind: Kind,
201 /// One-line title.
202 pub title: String,
203 /// Lowercase, without the leading `#`.
204 pub tags: Vec<String>,
205 /// Ids of related entries, in the order they were added. Deleting an entry removes the links to it.
206 pub links: Vec<u32>,
207 /// Related entries in the project's other repositories, in the order they were added. Nothing checks
208 /// them once written: the other repository may be elsewhere, or the entry deleted there since.
209 pub repo_links: Vec<RepoLink>,
210 /// Where the task stands.
211 pub status: Status,
212 /// Decisions only: whether it's in force, proposed or rejected.
213 pub decision: DecisionState,
214 /// When the task was created.
215 pub created: DateTime<Local>,
216 /// Free text describing the task.
217 pub description: String,
218 /// Oldest first.
219 pub comments: Vec<Comment>,
220 /// Every status change, oldest first.
221 pub history: Vec<StatusChange>,
222 /// Every change to the title, tags, related entries or description, oldest first.
223 pub activity: Vec<Activity>,
224 /// Tracked work, oldest first; only the last one can be open, and only while the task is `doing`.
225 pub sessions: Vec<Session>,
226 /// File this task was loaded from / saved to.
227 pub path: Option<PathBuf>,
228 /// What the file holds that this version doesn't understand, written back as it was.
229 pub unknown: Unknown,
230}
231
232/// Fields, link forms and sections a newer version wrote, kept as written: otherwise an older `kb` or app
233/// saving the entry, for a comment or a status change, would drop them from the file.
234#[derive(Clone, Debug, Default)]
235#[non_exhaustive]
236pub struct Unknown {
237 /// `- key: value` lines before the first section, in file order.
238 pub meta: Vec<String>,
239 /// Words of `- links:` that are neither an id nor `REPO#ID`.
240 pub links: Vec<String>,
241 /// Other `##` sections, each from its heading line, in file order.
242 pub sections: Vec<String>,
243 /// `## Activity` items this version can't read (a newer version's kind of change), each with its lines.
244 pub activity: Vec<String>,
245}
246
247/// One change to an entry's title, tags, related entries or description, as kept in its activity: the thread shows
248/// them among the comments, like an issue's on GitHub or GitLab.
249#[derive(Clone, Debug, PartialEq, Eq)]
250#[non_exhaustive]
251pub struct Activity {
252 /// When it was made.
253 pub at: DateTime<Local>,
254 /// Who made it.
255 pub by: Author,
256 /// What it changed.
257 pub change: Changed,
258}
259
260impl Activity {
261 /// A change `by` someone `at` a time.
262 pub fn new(at: DateTime<Local>, by: Author, change: Changed) -> Self {
263 Self { at, by, change }
264 }
265}
266
267/// What a change to an entry changed. Lists say what was added and removed rather than what was there before and
268/// after, so a change reads the same on each side of a relation, and a tag renamed in every entry says so in each.
269#[derive(Clone, Debug, PartialEq, Eq)]
270#[non_exhaustive]
271pub enum Changed {
272 /// The title.
273 Title {
274 /// What it was.
275 from: String,
276 /// What it became.
277 to: String,
278 },
279 /// The tags.
280 Tags {
281 /// Tags it got.
282 added: Vec<String>,
283 /// Tags it lost.
284 removed: Vec<String>,
285 },
286 /// The related entries, whichever side keeps the link: `5` names an entry of the same brain, `shop-api#3` one of
287 /// the project's other repositories, as `- links:` writes them.
288 Related {
289 /// Entries it was related to.
290 added: Vec<String>,
291 /// Entries it was unrelated from.
292 removed: Vec<String>,
293 },
294 /// The description: only that it changed, since one can be long.
295 Description,
296}
297
298impl Changed {
299 /// What it did, said after who did it: `changed the title from "A" to "B"`, `added the tag #ux and removed the
300 /// tag #bug`, `related it to #5 and unrelated it from shop-api#3`, `changed the description`.
301 pub fn said(&self) -> String {
302 type Name = fn(&String) -> String;
303 let (added, removed, verbs, name): (&[String], &[String], _, Name) = match self {
304 Self::Title { from, to } => return format!("changed the title from \"{from}\" to \"{to}\""),
305 Self::Description => return "changed the description".to_string(),
306 Self::Tags { added, removed } => (added, removed, ["added", "removed"], |tag| format!("#{tag}")),
307 // An entry of this brain is `#5`, one of another repository `shop-api#3`, as links are typed.
308 Self::Related { added, removed } => (added, removed, ["related it to", "unrelated it from"], |word| {
309 if word.contains('#') { word.clone() } else { format!("#{word}") }
310 }),
311 };
312 let noun = |words: &[String]| match self {
313 Self::Tags { .. } if words.len() == 1 => " the tag",
314 Self::Tags { .. } => " the tags",
315 _ => "",
316 };
317 [(verbs[0], added), (verbs[1], removed)]
318 .into_iter()
319 .filter(|(_, words)| !words.is_empty())
320 .map(|(verb, words)| {
321 format!("{verb}{} {}", noun(words), words.iter().map(name).collect::<Vec<_>>().join(", "))
322 })
323 .collect::<Vec<_>>()
324 .join(" and ")
325 }
326
327 /// Whether it changes nothing: a list with nothing added or removed, or a title back to what it was.
328 fn is_empty(&self) -> bool {
329 match self {
330 Self::Title { from, to } => from == to,
331 Self::Tags { added, removed } | Self::Related { added, removed } => added.is_empty() && removed.is_empty(),
332 Self::Description => false,
333 }
334 }
335
336 /// Takes in `next`, a later change of the same kind, as one change; `next` back when it's another kind.
337 fn absorb(&mut self, next: Changed) -> Result<(), Changed> {
338 match (self, next) {
339 (Self::Title { to, .. }, Self::Title { to: next, .. }) => *to = next,
340 (Self::Tags { added, removed }, Self::Tags { added: more, removed: fewer })
341 | (Self::Related { added, removed }, Self::Related { added: more, removed: fewer }) => {
342 combine(added, removed, more, fewer);
343 }
344 (Self::Description, Self::Description) => {}
345 (_, next) => return Err(next),
346 }
347 Ok(())
348 }
349}
350
351/// One item of an entry's thread, as the app and `kb show` list it: a comment, with its index among the comments, or a
352/// change.
353#[derive(Clone, Copy, Debug, PartialEq, Eq)]
354pub enum ThreadItem<'a> {
355 /// A comment, and where it is in [`Task::comments`].
356 Comment(usize, &'a Comment),
357 /// A change to the title, tags, related entries or description.
358 Change(&'a Activity),
359}
360
361/// Adds `more` and `fewer` to what was `added` and `removed`: a word added and then removed, or the other way round,
362/// is neither.
363fn combine(added: &mut Vec<String>, removed: &mut Vec<String>, more: Vec<String>, fewer: Vec<String>) {
364 for word in more {
365 match removed.iter().position(|w| *w == word) {
366 Some(at) => drop(removed.remove(at)),
367 None if !added.contains(&word) => added.push(word),
368 None => {}
369 }
370 }
371 for word in fewer {
372 match added.iter().position(|w| *w == word) {
373 Some(at) => drop(added.remove(at)),
374 None if !removed.contains(&word) => removed.push(word),
375 None => {}
376 }
377 }
378}
379
380impl Task {
381 /// A new `todo` task.
382 pub fn new(id: u32, title: String, now: DateTime<Local>) -> Self {
383 Self {
384 id,
385 kind: Kind::Task,
386 title,
387 tags: Vec::new(),
388 links: Vec::new(),
389 repo_links: Vec::new(),
390 unknown: Unknown::default(),
391 status: Status::Todo,
392 decision: DecisionState::Accepted,
393 created: now,
394 description: String::new(),
395 comments: Vec::new(),
396 history: Vec::new(),
397 activity: Vec::new(),
398 sessions: Vec::new(),
399 path: None,
400 }
401 }
402
403 /// Records `change`, made `by` someone `at` a time, in the activity, in time order (another computer's clock
404 /// may be behind). The latest change, when the same author made it in the same second to the same thing, takes it
405 /// in (the app saves a form's related entries one at a time), and one that ends up changing nothing goes; any
406 /// other change between them keeps them apart, so the activity still says what came first.
407 pub fn log(&mut self, at: DateTime<Local>, by: Author, change: Changed) {
408 let place = self.activity.partition_point(|a| a.at <= at);
409 let mut change = change;
410 if let Some(latest) = place.checked_sub(1).filter(|&i| self.activity[i].at == at && self.activity[i].by == by) {
411 match self.activity[latest].change.absorb(change) {
412 Ok(()) => {
413 if self.activity[latest].change.is_empty() {
414 self.activity.remove(latest);
415 }
416 return;
417 }
418 Err(other) => change = other,
419 }
420 }
421 if !change.is_empty() {
422 self.activity.insert(place, Activity::new(at, by, change));
423 }
424 }
425
426 /// Gives the entry `title`, made `by` someone `at` a time, and logs it; returns whether it changed.
427 pub fn set_title(&mut self, title: String, at: DateTime<Local>, by: Author) -> bool {
428 if title == self.title {
429 return false;
430 }
431 let from = std::mem::replace(&mut self.title, title);
432 self.log(at, by, Changed::Title { from, to: self.title.clone() });
433 true
434 }
435
436 /// Gives the entry `tags`, made `by` someone `at` a time, and logs which it got and lost; returns whether they
437 /// changed, the order included.
438 pub fn set_tags(&mut self, tags: Vec<String>, at: DateTime<Local>, by: Author) -> bool {
439 if tags == self.tags {
440 return false;
441 }
442 let added = tags.iter().filter(|t| !self.tags.contains(t)).cloned().collect();
443 let removed = self.tags.iter().filter(|t| !tags.contains(t)).cloned().collect();
444 self.tags = tags;
445 self.log(at, by, Changed::Tags { added, removed });
446 true
447 }
448
449 /// Gives the entry `description`, made `by` someone `at` a time, and logs that it changed; returns whether it did.
450 pub fn set_description(&mut self, description: String, at: DateTime<Local>, by: Author) -> bool {
451 if description == self.description {
452 return false;
453 }
454 self.description = description;
455 self.log(at, by, Changed::Description);
456 true
457 }
458
459 /// Logs that the entry was related to the entries `added` names and unrelated from those `removed` names (see
460 /// [`Changed::Related`]), by someone `at` a time. Whichever side keeps the link, both entries log it.
461 pub fn relate(&mut self, at: DateTime<Local>, by: Author, added: &[String], removed: &[String]) {
462 self.log(at, by, Changed::Related { added: added.to_vec(), removed: removed.to_vec() });
463 }
464
465 /// Moves the task to `status` and records the change, made `by` someone. Returns false if it already
466 /// was there. Leaving `doing` closes the open session, so a clock never keeps running on a task that
467 /// isn't being worked on.
468 pub fn set_status(&mut self, status: Status, now: DateTime<Local>, by: Author) -> bool {
469 if self.status == status {
470 return false;
471 }
472 if status != Status::Doing {
473 self.stop_session(now);
474 }
475 self.history.push(StatusChange { at: now, from: self.status, to: status, by });
476 self.status = status;
477 true
478 }
479
480 /// Whether the last session is still open.
481 pub fn is_running(&self) -> bool {
482 self.sessions.last().is_some_and(|s| s.end.is_none())
483 }
484
485 /// How long the open session has been running, if any.
486 pub fn running_for(&self, now: DateTime<Local>) -> Option<Duration> {
487 self.sessions.last().filter(|s| s.end.is_none()).map(|s| s.duration(now))
488 }
489
490 /// Opens a session now, unless one is already running. The caller moves the task to `doing`.
491 pub fn start_session(&mut self, now: DateTime<Local>) {
492 if !self.is_running() {
493 self.sessions.push(Session { start: now, end: None });
494 }
495 }
496
497 /// Closes the open session, if any.
498 pub fn stop_session(&mut self, now: DateTime<Local>) {
499 if let Some(session) = self.sessions.last_mut().filter(|s| s.end.is_none()) {
500 session.end = Some(now.max(session.start));
501 }
502 }
503
504 /// Time tracked in all sessions.
505 pub fn total(&self, now: DateTime<Local>) -> Duration {
506 self.sessions.iter().map(|s| s.duration(now)).sum()
507 }
508
509 /// Time tracked inside `[from, to)`: sessions are clipped to the range. For several tasks, see [`worked`].
510 pub fn time_between(&self, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>) -> Duration {
511 self.sessions.iter().map(|s| s.overlap(from, to, now)).sum()
512 }
513
514 /// Who last moved the task to `doing`: a person or an agent. A task in `doing` with no such change (from
515 /// an older version, or edited by hand) counts as a person's.
516 pub fn started_by(&self) -> Author {
517 self.history.iter().rev().find(|c| c.to == Status::Doing).map_or(Author::Human, |c| c.by)
518 }
519
520 /// When the status last changed (or the task was created, if never); used for sorting.
521 pub fn status_since(&self) -> DateTime<Local> {
522 self.history.last().map_or(self.created, |c| c.at)
523 }
524
525 /// The question this parked task waits on, or its answer. A question is the latest comment marked as
526 /// one; a person's comment after it answers it, as does a verdict on the decision it asked about. Starting the
527 /// task after either means it was picked up, so it's neither once the task is parked again for another reason.
528 pub fn asked(&self) -> Option<Asked<'_>> {
529 if !self.is_task() || self.status != Status::Parked {
530 return None;
531 }
532 let at = self.comments.iter().rposition(|c| c.question)?;
533 let question = &self.comments[at];
534 let answer = self.comments[at + 1..].iter().rev().find(|c| c.author == Author::Human || c.answer);
535 // Strictly after: times are whole seconds, and an agent that starts a task and asks at once does
536 // both in the same second, which isn't picking it up again.
537 let picked_up = self.history.iter().any(|c| c.to == Status::Doing && c.at > question.created);
538 match answer {
539 _ if picked_up => None,
540 Some(answer) => Some(Asked::Answered(answer)),
541 None => Some(Asked::Waiting(question)),
542 }
543 }
544
545 /// The comments and the activity together, oldest first, each in its own order. A change and a comment in the
546 /// same second go change first: the app's **Save and tell** writes the comment that tells of a change after it.
547 pub fn thread(&self) -> Vec<ThreadItem<'_>> {
548 let mut thread = Vec::with_capacity(self.comments.len() + self.activity.len());
549 let mut changes = self.activity.iter().peekable();
550 for (index, comment) in self.comments.iter().enumerate() {
551 while let Some(change) = changes.next_if(|change| change.at <= comment.created) {
552 thread.push(ThreadItem::Change(change));
553 }
554 thread.push(ThreadItem::Comment(index, comment));
555 }
556 thread.extend(changes.map(ThreadItem::Change));
557 thread
558 }
559
560 /// Whether this entry has a status, history and sessions (only tasks do).
561 pub fn is_task(&self) -> bool {
562 self.kind == Kind::Task
563 }
564
565 /// Search: every whitespace-separated term must match. `#term` matches a tag prefix;
566 /// anything else matches the title or a tag. Case-insensitive.
567 pub fn matches(&self, query: &str) -> bool {
568 query.split_whitespace().all(|term| self.has_term(&term.to_lowercase(), false))
569 }
570
571 /// Whether the entry has `term`, already lowercase: `#tag` (written as a tag is typed anywhere) a tag that
572 /// starts with it; anything else the title or a tag, and with `everywhere`, the description or a comment too.
573 pub(crate) fn has_term(&self, term: &str, everywhere: bool) -> bool {
574 match term.strip_prefix('#') {
575 Some("") => true,
576 Some(tag) => text::parse_tags(tag).first().is_some_and(|tag| self.tags.iter().any(|t| t.starts_with(tag))),
577 None => {
578 self.title.to_lowercase().contains(term)
579 || self.tags.iter().any(|t| t.contains(term))
580 || everywhere
581 && (self.description.to_lowercase().contains(term)
582 || self.comments.iter().any(|c| c.body.to_lowercase().contains(term)))
583 }
584 }
585 }
586}
587
588/// Time worked on any of `tasks` inside `[from, to)`: time two of them ran at once counts once (see
589/// [`covered`]), so agents' tasks running in parallel don't make a day longer than it was.
590pub fn worked<'a>(
591 tasks: impl IntoIterator<Item = &'a Task>,
592 from: DateTime<Local>,
593 to: DateTime<Local>,
594 now: DateTime<Local>,
595) -> Duration {
596 covered(tasks.into_iter().flat_map(|t| &t.sessions).map(|s| s.span(from, to, now)))
597}
598
599#[cfg(test)]
600#[path = "../../tests/model.rs"]
601mod tests;