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, 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    /// Where a task stands. Declared in the order the task list shows them, so
25    /// `position()` is the sort key; the names are what task files contain.
26    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
27    pub enum Status {
28        /// Being worked on.
29        Doing => "doing",
30        /// Not started yet.
31        Todo => "todo",
32        /// Started, then set aside for now.
33        Parked => "parked",
34        /// Finished; hidden from the list by default.
35        Done => "done",
36    }
37}
38
39named_enum! {
40    /// Who made a change: the user, or a coding agent working through `kb`. Comments and status changes
41    /// record it, so the board tells them apart, and so a question an agent asked waits for a person.
42    #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
43    pub enum Author {
44        /// The user: in the kanban app, or `kb` typed at a terminal.
45        #[default]
46        Human => "human",
47        /// A coding agent, as `kb` finds from its environment.
48        Agent => "agent",
49    }
50}
51
52named_enum! {
53    /// Where a decision stands. An agent records the decisions within its task as accepted, and proposes
54    /// those it shouldn't take alone (architecture, dependencies, storage) for a person to accept or reject.
55    #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
56    pub enum DecisionState {
57        /// In force.
58        #[default]
59        Accepted => "accepted",
60        /// Waiting for a person to accept or reject it.
61        Proposed => "proposed",
62        /// Turned down: kept so the same approach isn't proposed again.
63        Rejected => "rejected",
64    }
65}
66
67named_enum! {
68    /// What an entry is. Only tasks have a status, history and sessions; notes and decisions are
69    /// documents that tasks and each other link to. Declared in the order `tab` cycles the list.
70    #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
71    pub enum Kind {
72        /// Something to do.
73        #[default]
74        Task => "task",
75        /// Something to remember: a finding, how something works, a snippet.
76        Note => "note",
77        /// Something decided, with its context and consequences (an ADR).
78        Decision => "decision",
79    }
80}
81
82/// One status change, as kept in the task's history.
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84#[non_exhaustive]
85pub struct StatusChange {
86    /// When the status changed.
87    pub at: DateTime<Local>,
88    /// The status before.
89    pub from: Status,
90    /// The status after.
91    pub to: Status,
92    /// Who moved it.
93    pub by: Author,
94}
95
96/// One continuous stretch of tracked work on a task.
97#[derive(Clone, Debug, PartialEq, Eq)]
98#[non_exhaustive]
99pub struct Session {
100    /// When the session started.
101    pub start: DateTime<Local>,
102    /// `None` while the session is still running.
103    pub end: Option<DateTime<Local>>,
104}
105
106impl Session {
107    /// How long the session lasted, or has lasted so far if it's running.
108    pub fn duration(&self, now: DateTime<Local>) -> Duration {
109        (self.end.unwrap_or(now) - self.start).max(Duration::zero())
110    }
111
112    /// Part of this session that falls inside `[from, to)`.
113    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    /// Where this session starts and ends within `[from, to)`, a running one ending `now`; the end is
119    /// before the start when it falls outside.
120    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/// A timestamped comment on a task, like on a GitHub/GitLab issue.
131#[derive(Clone, Debug, PartialEq, Eq)]
132#[non_exhaustive]
133pub struct Comment {
134    /// When the comment was written.
135    pub created: DateTime<Local>,
136    /// Set when the body was changed after posting.
137    pub edited: Option<DateTime<Local>>,
138    /// The comment text (markdown).
139    pub body: String,
140    /// Who wrote it.
141    pub author: Author,
142    /// A question to the user: the task waits for a person's answer (see [`Task::asked`]).
143    pub question: bool,
144    /// The answer to the question before it, whoever wrote it: a verdict on the decision it asked about, which an
145    /// agent may give on the user's word.
146    pub answer: bool,
147    /// Words of the header's parentheses this version doesn't know, written back as they were.
148    pub unknown: Vec<String>,
149}
150
151impl Comment {
152    /// Its first line that isn't blank, trimmed: what a list, a card or an excerpt shows of it.
153    pub fn first_line(&self) -> &str {
154        self.body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or_default()
155    }
156
157    /// A plain comment by `author`.
158    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/// Where a question an agent asked on a task stands, while the task is parked waiting on it.
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165pub enum Asked<'a> {
166    /// Nobody has answered the question yet.
167    Waiting(&'a Comment),
168    /// A person answered (the latest answer): the task can be picked up again.
169    Answered(&'a Comment),
170}
171
172impl Asked<'_> {
173    /// Where it stands, as the command line's text and JSON say it.
174    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/// An entry in another repository of the same project, written `shop-api#3`: ids repeat across
183/// repositories, so the repository's name goes with it.
184#[derive(Clone, Debug, PartialEq, Eq)]
185pub struct RepoLink {
186    /// The repository's name in the project, as `kb init` registered it.
187    pub repo: String,
188    /// The entry's id there.
189    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/// An entry: a task, note or decision. What it is, how it's tagged and linked, and for tasks,
199/// where it stands and how it got there.
200#[derive(Clone, Debug)]
201#[non_exhaustive]
202pub struct Task {
203    /// Unique number across all kinds, also the start of the file name.
204    pub id: u32,
205    /// Task, note or decision.
206    pub kind: Kind,
207    /// One-line title.
208    pub title: String,
209    /// Lowercase, without the leading `#`.
210    pub tags: Vec<String>,
211    /// Ids of related entries, in the order they were added. Deleting an entry removes the links to it.
212    pub links: Vec<u32>,
213    /// Related entries in the project's other repositories, in the order they were added. Nothing checks
214    /// them once written: the other repository may be elsewhere, or the entry deleted there since.
215    pub repo_links: Vec<RepoLink>,
216    /// Where the task stands.
217    pub status: Status,
218    /// Decisions only: whether it's in force, proposed or rejected.
219    pub decision: DecisionState,
220    /// When the task was created.
221    pub created: DateTime<Local>,
222    /// Free text describing the task.
223    pub description: String,
224    /// Oldest first.
225    pub comments: Vec<Comment>,
226    /// Every status change, oldest first.
227    pub history: Vec<StatusChange>,
228    /// Every change to the title, tags, related entries or description, oldest first.
229    pub activity: Vec<Activity>,
230    /// Tracked work, oldest first; only the last one can be open, and only while the task is `doing`.
231    pub sessions: Vec<Session>,
232    /// File this task was loaded from / saved to.
233    pub path: Option<PathBuf>,
234    /// What the file holds that this version doesn't understand, written back as it was.
235    pub unknown: Unknown,
236}
237
238/// Fields, link forms and sections a newer version wrote, kept as written: otherwise an older `kb` or app
239/// saving the entry, for a comment or a status change, would drop them from the file.
240#[derive(Clone, Debug, Default)]
241#[non_exhaustive]
242pub struct Unknown {
243    /// `- key: value` lines before the first section, in file order.
244    pub meta: Vec<String>,
245    /// Words of `- links:` that are neither an id nor `REPO#ID`.
246    pub links: Vec<String>,
247    /// Other `##` sections, each from its heading line, in file order.
248    pub sections: Vec<String>,
249    /// `## Activity` items this version can't read (a newer version's kind of change), each with its lines.
250    pub activity: Vec<String>,
251}
252
253/// One change to an entry's title, tags, related entries or description, as kept in its activity: the thread shows
254/// them among the comments, like an issue's on GitHub or GitLab.
255#[derive(Clone, Debug, PartialEq, Eq)]
256#[non_exhaustive]
257pub struct Activity {
258    /// When it was made.
259    pub at: DateTime<Local>,
260    /// Who made it.
261    pub by: Author,
262    /// What it changed.
263    pub change: Changed,
264}
265
266impl Activity {
267    /// A change `by` someone `at` a time.
268    pub fn new(at: DateTime<Local>, by: Author, change: Changed) -> Self {
269        Self { at, by, change }
270    }
271}
272
273/// What a change to an entry changed. Lists say what was added and removed rather than what was there before and
274/// after, so a change reads the same on each side of a relation, and a tag renamed in every entry says so in each.
275#[derive(Clone, Debug, PartialEq, Eq)]
276#[non_exhaustive]
277pub enum Changed {
278    /// The title.
279    Title {
280        /// What it was.
281        from: String,
282        /// What it became.
283        to: String,
284    },
285    /// The tags.
286    Tags {
287        /// Tags it got.
288        added: Vec<String>,
289        /// Tags it lost.
290        removed: Vec<String>,
291    },
292    /// The related entries, whichever side keeps the link: `5` names an entry of the same brain, `shop-api#3` one of
293    /// the project's other repositories, as `- links:` writes them.
294    Related {
295        /// Entries it was related to.
296        added: Vec<String>,
297        /// Entries it was unrelated from.
298        removed: Vec<String>,
299    },
300    /// The description: only that it changed, since one can be long.
301    Description,
302}
303
304impl Changed {
305    /// What it did, said after who did it: `changed the title from "A" to "B"`, `added the tag #ux and removed the
306    /// tag #bug`, `related it to #5 and unrelated it from shop-api#3`, `changed the description`.
307    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            // An entry of this brain is `#5`, one of another repository `shop-api#3`, as links are typed.
314            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    /// Whether it changes nothing: a list with nothing added or removed, or a title back to what it was.
334    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    /// Takes in `next`, a later change of the same kind, as one change; `next` back when it's another kind.
343    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/// 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
358/// change.
359#[derive(Clone, Copy, Debug, PartialEq, Eq)]
360pub enum ThreadItem<'a> {
361    /// A comment, and where it is in [`Task::comments`].
362    Comment(usize, &'a Comment),
363    /// A change to the title, tags, related entries or description.
364    Change(&'a Activity),
365}
366
367/// Adds `more` and `fewer` to what was `added` and `removed`: a word added and then removed, or the other way round,
368/// is neither.
369fn 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    /// A new `todo` task.
388    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    /// Records `change`, made `by` someone `at` a time, in the activity, in time order (another computer's clock
410    /// may be behind). The latest change, when the same author made it in the same second to the same thing, takes it
411    /// in (the app saves a form's related entries one at a time), and one that ends up changing nothing goes; any
412    /// other change between them keeps them apart, so the activity still says what came first.
413    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    /// Gives the entry `title`, made `by` someone `at` a time, and logs it; returns whether it changed.
433    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    /// Gives the entry `tags`, made `by` someone `at` a time, and logs which it got and lost; returns whether they
443    /// changed, the order included.
444    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    /// Gives the entry `description`, made `by` someone `at` a time, and logs that it changed; returns whether it did.
456    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    /// Logs that the entry was related to the entries `added` names and unrelated from those `removed` names (see
466    /// [`Changed::Related`]), by someone `at` a time. Whichever side keeps the link, both entries log it.
467    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    /// Moves the task to `status` and records the change, made `by` someone. Returns false if it already
472    /// was there. Leaving `doing` closes the open session, so a clock never keeps running on a task that
473    /// isn't being worked on.
474    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    /// Whether the last session is still open.
487    pub fn is_running(&self) -> bool {
488        self.sessions.last().is_some_and(|s| s.end.is_none())
489    }
490
491    /// How long the open session has been running, if any.
492    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    /// Opens a session now, unless one is already running. The caller moves the task to `doing`.
497    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    /// Closes the open session, if any.
504    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    /// Time tracked in all sessions.
511    pub fn total(&self, now: DateTime<Local>) -> Duration {
512        self.sessions.iter().map(|s| s.duration(now)).sum()
513    }
514
515    /// Time tracked inside `[from, to)`: sessions are clipped to the range. For several tasks, see [`worked`].
516    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    /// Who last moved the task to `doing`: a person or an agent. A task in `doing` with no such change (from
521    /// an older version, or edited by hand) counts as a person's.
522    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    /// Whether this is a task in `doing` that `by` started: a person has one at most, and an update waits for an
527    /// agent's.
528    pub fn doing_by(&self, by: Author) -> bool {
529        self.is_task() && self.status == Status::Doing && self.started_by() == by
530    }
531
532    /// When the status last changed (or the task was created, if never); used for sorting.
533    pub fn status_since(&self) -> DateTime<Local> {
534        self.history.last().map_or(self.created, |c| c.at)
535    }
536
537    /// The question this parked task waits on, or its answer. A question is the latest comment marked as
538    /// one; a person's comment after it answers it, as does a verdict on the decision it asked about. Starting the
539    /// task after either means it was picked up, so it's neither once the task is parked again for another reason.
540    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        // Strictly after: times are whole seconds, and an agent that starts a task and asks at once does
548        // both in the same second, which isn't picking it up again.
549        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    /// Whether this parked task waits for a person's answer to its question: [`Task::asked`] is
558    /// [`Asked::Waiting`].
559    pub fn waits_for_answer(&self) -> bool {
560        matches!(self.asked(), Some(Asked::Waiting(_)))
561    }
562
563    /// The comments and the activity together, oldest first, each in its own order. A change and a comment in the
564    /// same second go change first: the app's **Save and tell** writes the comment that tells of a change after it.
565    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    /// Whether this entry has a status, history and sessions (only tasks do).
579    pub fn is_task(&self) -> bool {
580        self.kind == Kind::Task
581    }
582
583    /// Search: every whitespace-separated term must match. `#term` matches a tag prefix;
584    /// anything else matches the title or a tag. Case-insensitive.
585    pub fn matches(&self, query: &str) -> bool {
586        query.split_whitespace().all(|term| self.has_term(&term.to_lowercase(), false))
587    }
588
589    /// Whether the entry has `term`, already lowercase: `#tag` (written as a tag is typed anywhere) a tag that
590    /// starts with it; anything else the title or a tag, and with `everywhere`, the description or a comment too.
591    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
606/// Time worked on any of `tasks` inside `[from, to)`: time two of them ran at once counts once (see
607/// [`covered`]), so agents' tasks running in parallel don't make a day longer than it was.
608pub 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;