Skip to main content

kanbrain_core/model/
mod.rs

1//! Entries (tasks, notes and decisions), their links, status history, time-tracking sessions and comments.
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 use markdown::parse_links;
12pub use text::{parse_tags, sanitize_comment, sanitize_description, slugify};
13#[cfg(test)]
14pub use time::parse_ts;
15pub use time::{fmt_age, fmt_clock, fmt_dur, fmt_ts, now};
16
17use crate::enums::named_enum;
18use chrono::{DateTime, Duration, Local};
19use std::path::PathBuf;
20
21named_enum! {
22    /// Where a task stands. Declared in the order the task list shows them, so
23    /// `position()` is the sort key; the names are what task files contain.
24    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
25    pub enum Status {
26        /// Being worked on.
27        Doing => "doing",
28        /// Not started yet.
29        Todo => "todo",
30        /// Started, then set aside for now.
31        Parked => "parked",
32        /// Finished; hidden from the list by default.
33        Done => "done",
34    }
35}
36
37named_enum! {
38    /// Who made a change: the user, or a coding agent working through `kb`. Comments and status changes
39    /// record it, so the board tells them apart, and so a question an agent asked waits for a person.
40    #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
41    pub enum Author {
42        /// The user: in the kanban app, or `kb` typed at a terminal.
43        #[default]
44        Human => "human",
45        /// A coding agent, as `kb` finds from its environment.
46        Agent => "agent",
47    }
48}
49
50named_enum! {
51    /// Where a decision stands. An agent records the decisions within its task as accepted, and proposes
52    /// those it shouldn't take alone (architecture, dependencies, storage) for a person to accept or reject.
53    #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
54    pub enum DecisionState {
55        /// In force.
56        #[default]
57        Accepted => "accepted",
58        /// Waiting for a person to accept or reject it.
59        Proposed => "proposed",
60        /// Turned down: kept so the same approach isn't proposed again.
61        Rejected => "rejected",
62    }
63}
64
65named_enum! {
66    /// What an entry is. Only tasks have a status, history and sessions; notes and decisions are
67    /// documents that tasks and each other link to. Declared in the order `tab` cycles the list.
68    #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
69    pub enum Kind {
70        /// Something to do.
71        #[default]
72        Task => "task",
73        /// Something to remember: a finding, how something works, a snippet.
74        Note => "note",
75        /// Something decided, with its context and consequences (an ADR).
76        Decision => "decision",
77    }
78}
79
80/// One status change, as kept in the task's history.
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub struct StatusChange {
83    /// When the status changed.
84    pub at: DateTime<Local>,
85    /// The status before.
86    pub from: Status,
87    /// The status after.
88    pub to: Status,
89    /// Who moved it.
90    pub by: Author,
91}
92
93/// One continuous stretch of tracked work on a task.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct Session {
96    /// When the session started.
97    pub start: DateTime<Local>,
98    /// `None` while the session is still running.
99    pub end: Option<DateTime<Local>>,
100}
101
102impl Session {
103    /// How long the session lasted, or has lasted so far if it's running.
104    pub fn duration(&self, now: DateTime<Local>) -> Duration {
105        (self.end.unwrap_or(now) - self.start).max(Duration::zero())
106    }
107
108    /// Part of this session that falls inside `[from, to)`.
109    pub fn overlap(&self, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>) -> Duration {
110        let start = self.start.max(from);
111        let end = self.end.unwrap_or(now).min(to);
112        (end - start).max(Duration::zero())
113    }
114}
115
116/// A timestamped comment on a task, like on a GitHub/GitLab issue.
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct Comment {
119    /// When the comment was written.
120    pub created: DateTime<Local>,
121    /// Set when the body was changed after posting.
122    pub edited: Option<DateTime<Local>>,
123    /// The comment text (markdown).
124    pub body: String,
125    /// Who wrote it.
126    pub author: Author,
127    /// A question to the user: the task waits for a person's answer (see [`Task::asked`]).
128    pub question: bool,
129    /// Words of the header's parentheses this version doesn't know, written back as they were.
130    pub unknown: Vec<String>,
131}
132
133impl Comment {
134    /// A plain comment by `author`.
135    pub fn new(created: DateTime<Local>, body: String, author: Author) -> Self {
136        Self { created, edited: None, body, author, question: false, unknown: Vec::new() }
137    }
138}
139
140/// Where a question an agent asked on a task stands, while the task is parked waiting on it.
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub enum Asked<'a> {
143    /// Nobody has answered the question yet.
144    Waiting(&'a Comment),
145    /// A person answered (the latest answer): the task can be picked up again.
146    Answered(&'a Comment),
147}
148
149/// An entry in another repository of the same project, written `shop-api#3`: ids repeat across
150/// repositories, so the repository's name goes with it.
151#[derive(Clone, Debug, PartialEq, Eq)]
152pub struct RepoLink {
153    /// The repository's name in the project, as `kb init` registered it.
154    pub repo: String,
155    /// The entry's id there.
156    pub id: u32,
157}
158
159impl std::fmt::Display for RepoLink {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        write!(f, "{}#{}", self.repo, self.id)
162    }
163}
164
165/// An entry: a task, note or decision. What it is, how it's tagged and linked, and for tasks,
166/// where it stands and how it got there.
167#[derive(Clone, Debug)]
168pub struct Task {
169    /// Unique number across all kinds, also the start of the file name.
170    pub id: u32,
171    /// Task, note or decision.
172    pub kind: Kind,
173    /// One-line title.
174    pub title: String,
175    /// Lowercase, without the leading `#`.
176    pub tags: Vec<String>,
177    /// Ids of related entries, in the order they were added. Deleting an entry removes the links to it.
178    pub links: Vec<u32>,
179    /// Related entries in the project's other repositories, in the order they were added. Nothing checks
180    /// them once written: the other repository may be elsewhere, or the entry deleted there since.
181    pub repo_links: Vec<RepoLink>,
182    /// Where the task stands.
183    pub status: Status,
184    /// Decisions only: whether it's in force, proposed or rejected.
185    pub decision: DecisionState,
186    /// When the task was created.
187    pub created: DateTime<Local>,
188    /// Free text describing the task.
189    pub description: String,
190    /// Oldest first.
191    pub comments: Vec<Comment>,
192    /// Every status change, oldest first.
193    pub history: Vec<StatusChange>,
194    /// Tracked work, oldest first; only the last one can be open, and only while the task is `doing`.
195    pub sessions: Vec<Session>,
196    /// File this task was loaded from / saved to.
197    pub path: Option<PathBuf>,
198    /// What the file holds that this version doesn't understand, written back as it was.
199    pub unknown: Unknown,
200}
201
202/// Fields, link forms and sections a newer version wrote, kept as written: otherwise an older `kb` or app
203/// saving the entry, for a comment or a status change, would drop them from the file.
204#[derive(Clone, Debug, Default)]
205pub struct Unknown {
206    /// `- key: value` lines before the first section, in file order.
207    pub meta: Vec<String>,
208    /// Words of `- links:` that are neither an id nor `REPO#ID`.
209    pub links: Vec<String>,
210    /// Other `##` sections, each from its heading line, in file order.
211    pub sections: Vec<String>,
212}
213
214impl Task {
215    /// A new `todo` task.
216    pub fn new(id: u32, title: String, now: DateTime<Local>) -> Self {
217        Self {
218            id,
219            kind: Kind::Task,
220            title,
221            tags: Vec::new(),
222            links: Vec::new(),
223            repo_links: Vec::new(),
224            unknown: Unknown::default(),
225            status: Status::Todo,
226            decision: DecisionState::Accepted,
227            created: now,
228            description: String::new(),
229            comments: Vec::new(),
230            history: Vec::new(),
231            sessions: Vec::new(),
232            path: None,
233        }
234    }
235
236    /// Moves the task to `status` and records the change, made `by` someone. Returns false if it already
237    /// was there. Leaving `doing` closes the open session, so a clock never keeps running on a task that
238    /// isn't being worked on.
239    pub fn set_status(&mut self, status: Status, now: DateTime<Local>, by: Author) -> bool {
240        if self.status == status {
241            return false;
242        }
243        if status != Status::Doing {
244            self.stop_session(now);
245        }
246        self.history.push(StatusChange { at: now, from: self.status, to: status, by });
247        self.status = status;
248        true
249    }
250
251    /// Whether the last session is still open.
252    pub fn is_running(&self) -> bool {
253        self.sessions.last().is_some_and(|s| s.end.is_none())
254    }
255
256    /// How long the open session has been running, if any.
257    pub fn running_for(&self, now: DateTime<Local>) -> Option<Duration> {
258        self.sessions.last().filter(|s| s.end.is_none()).map(|s| s.duration(now))
259    }
260
261    /// Opens a session now, unless one is already running. The caller moves the task to `doing`.
262    pub fn start_session(&mut self, now: DateTime<Local>) {
263        if !self.is_running() {
264            self.sessions.push(Session { start: now, end: None });
265        }
266    }
267
268    /// Closes the open session, if any.
269    pub fn stop_session(&mut self, now: DateTime<Local>) {
270        if let Some(session) = self.sessions.last_mut().filter(|s| s.end.is_none()) {
271            session.end = Some(now.max(session.start));
272        }
273    }
274
275    /// Time tracked in all sessions.
276    pub fn total(&self, now: DateTime<Local>) -> Duration {
277        self.sessions.iter().map(|s| s.duration(now)).sum()
278    }
279
280    /// Time tracked inside `[from, to)`: sessions are clipped to the range.
281    pub fn time_between(&self, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>) -> Duration {
282        self.sessions.iter().map(|s| s.overlap(from, to, now)).sum()
283    }
284
285    /// When the status last changed (or the task was created, if never); used for sorting.
286    pub fn status_since(&self) -> DateTime<Local> {
287        self.history.last().map_or(self.created, |c| c.at)
288    }
289
290    /// The question this parked task waits on, or its answer. A question is the latest comment marked as
291    /// one; a person's comment after it answers it. Starting the task after either means it was picked up,
292    /// so it's neither once the task is parked again for another reason.
293    pub fn asked(&self) -> Option<Asked<'_>> {
294        if !self.is_task() || self.status != Status::Parked {
295            return None;
296        }
297        let at = self.comments.iter().rposition(|c| c.question)?;
298        let question = &self.comments[at];
299        let answer = self.comments[at + 1..].iter().rev().find(|c| c.author == Author::Human);
300        // Strictly after: times are whole seconds, and an agent that starts a task and asks at once does
301        // both in the same second, which isn't picking it up again.
302        let picked_up = self.history.iter().any(|c| c.to == Status::Doing && c.at > question.created);
303        match answer {
304            _ if picked_up => None,
305            Some(answer) => Some(Asked::Answered(answer)),
306            None => Some(Asked::Waiting(question)),
307        }
308    }
309
310    /// Whether this entry has a status, history and sessions (only tasks do).
311    pub fn is_task(&self) -> bool {
312        self.kind == Kind::Task
313    }
314
315    /// Search: every whitespace-separated term must match. `#term` matches a tag prefix;
316    /// anything else matches the title or a tag. Case-insensitive.
317    pub fn matches(&self, query: &str) -> bool {
318        query.split_whitespace().all(|term| {
319            let term = term.to_lowercase();
320            match term.strip_prefix('#') {
321                Some("") => true,
322                Some(tag) => self.tags.iter().any(|t| t.starts_with(tag)),
323                None => self.title.to_lowercase().contains(&term) || self.tags.iter().any(|t| t.contains(&term)),
324            }
325        })
326    }
327}
328
329#[cfg(test)]
330#[path = "../../tests/model.rs"]
331mod tests;