Skip to main content

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;