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(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)]
83pub struct StatusChange {
84 /// When the status changed.
85 pub at: DateTime<Local>,
86 /// The status before.
87 pub from: Status,
88 /// The status after.
89 pub to: Status,
90 /// Who moved it.
91 pub by: Author,
92}
93
94/// One continuous stretch of tracked work on a task.
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct Session {
97 /// When the session started.
98 pub start: DateTime<Local>,
99 /// `None` while the session is still running.
100 pub end: Option<DateTime<Local>>,
101}
102
103impl Session {
104 /// How long the session lasted, or has lasted so far if it's running.
105 pub fn duration(&self, now: DateTime<Local>) -> Duration {
106 (self.end.unwrap_or(now) - self.start).max(Duration::zero())
107 }
108
109 /// Part of this session that falls inside `[from, to)`.
110 pub fn overlap(&self, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>) -> Duration {
111 let (start, end) = self.span(from, to, now);
112 (end - start).max(Duration::zero())
113 }
114
115 /// Where this session starts and ends within `[from, to)`, a running one ending `now`; the end is
116 /// before the start when it falls outside.
117 pub fn span(
118 &self,
119 from: DateTime<Local>,
120 to: DateTime<Local>,
121 now: DateTime<Local>,
122 ) -> (DateTime<Local>, DateTime<Local>) {
123 (self.start.max(from), self.end.unwrap_or(now).min(to))
124 }
125}
126
127/// A timestamped comment on a task, like on a GitHub/GitLab issue.
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub struct Comment {
130 /// When the comment was written.
131 pub created: DateTime<Local>,
132 /// Set when the body was changed after posting.
133 pub edited: Option<DateTime<Local>>,
134 /// The comment text (markdown).
135 pub body: String,
136 /// Who wrote it.
137 pub author: Author,
138 /// A question to the user: the task waits for a person's answer (see [`Task::asked`]).
139 pub question: bool,
140 /// The answer to the question before it, whoever wrote it: a verdict on the decision it asked about, which an
141 /// agent may give on the user's word.
142 pub answer: bool,
143 /// Words of the header's parentheses this version doesn't know, written back as they were.
144 pub unknown: Vec<String>,
145}
146
147impl Comment {
148 /// A plain comment by `author`.
149 pub fn new(created: DateTime<Local>, body: String, author: Author) -> Self {
150 Self { created, edited: None, body, author, question: false, answer: false, unknown: Vec::new() }
151 }
152}
153
154/// Where a question an agent asked on a task stands, while the task is parked waiting on it.
155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
156pub enum Asked<'a> {
157 /// Nobody has answered the question yet.
158 Waiting(&'a Comment),
159 /// A person answered (the latest answer): the task can be picked up again.
160 Answered(&'a Comment),
161}
162
163impl Asked<'_> {
164 /// Where it stands, as the command line's text and JSON say it.
165 pub fn label(self) -> &'static str {
166 match self {
167 Self::Waiting(_) => "waiting for an answer",
168 Self::Answered(_) => "answered",
169 }
170 }
171}
172
173/// An entry in another repository of the same project, written `shop-api#3`: ids repeat across
174/// repositories, so the repository's name goes with it.
175#[derive(Clone, Debug, PartialEq, Eq)]
176pub struct RepoLink {
177 /// The repository's name in the project, as `kb init` registered it.
178 pub repo: String,
179 /// The entry's id there.
180 pub id: u32,
181}
182
183impl std::fmt::Display for RepoLink {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 write!(f, "{}#{}", self.repo, self.id)
186 }
187}
188
189/// An entry: a task, note or decision. What it is, how it's tagged and linked, and for tasks,
190/// where it stands and how it got there.
191#[derive(Clone, Debug)]
192pub struct Task {
193 /// Unique number across all kinds, also the start of the file name.
194 pub id: u32,
195 /// Task, note or decision.
196 pub kind: Kind,
197 /// One-line title.
198 pub title: String,
199 /// Lowercase, without the leading `#`.
200 pub tags: Vec<String>,
201 /// Ids of related entries, in the order they were added. Deleting an entry removes the links to it.
202 pub links: Vec<u32>,
203 /// Related entries in the project's other repositories, in the order they were added. Nothing checks
204 /// them once written: the other repository may be elsewhere, or the entry deleted there since.
205 pub repo_links: Vec<RepoLink>,
206 /// Where the task stands.
207 pub status: Status,
208 /// Decisions only: whether it's in force, proposed or rejected.
209 pub decision: DecisionState,
210 /// When the task was created.
211 pub created: DateTime<Local>,
212 /// Free text describing the task.
213 pub description: String,
214 /// Oldest first.
215 pub comments: Vec<Comment>,
216 /// Every status change, oldest first.
217 pub history: Vec<StatusChange>,
218 /// Tracked work, oldest first; only the last one can be open, and only while the task is `doing`.
219 pub sessions: Vec<Session>,
220 /// File this task was loaded from / saved to.
221 pub path: Option<PathBuf>,
222 /// What the file holds that this version doesn't understand, written back as it was.
223 pub unknown: Unknown,
224}
225
226/// Fields, link forms and sections a newer version wrote, kept as written: otherwise an older `kb` or app
227/// saving the entry, for a comment or a status change, would drop them from the file.
228#[derive(Clone, Debug, Default)]
229pub struct Unknown {
230 /// `- key: value` lines before the first section, in file order.
231 pub meta: Vec<String>,
232 /// Words of `- links:` that are neither an id nor `REPO#ID`.
233 pub links: Vec<String>,
234 /// Other `##` sections, each from its heading line, in file order.
235 pub sections: Vec<String>,
236}
237
238impl Task {
239 /// A new `todo` task.
240 pub fn new(id: u32, title: String, now: DateTime<Local>) -> Self {
241 Self {
242 id,
243 kind: Kind::Task,
244 title,
245 tags: Vec::new(),
246 links: Vec::new(),
247 repo_links: Vec::new(),
248 unknown: Unknown::default(),
249 status: Status::Todo,
250 decision: DecisionState::Accepted,
251 created: now,
252 description: String::new(),
253 comments: Vec::new(),
254 history: Vec::new(),
255 sessions: Vec::new(),
256 path: None,
257 }
258 }
259
260 /// Moves the task to `status` and records the change, made `by` someone. Returns false if it already
261 /// was there. Leaving `doing` closes the open session, so a clock never keeps running on a task that
262 /// isn't being worked on.
263 pub fn set_status(&mut self, status: Status, now: DateTime<Local>, by: Author) -> bool {
264 if self.status == status {
265 return false;
266 }
267 if status != Status::Doing {
268 self.stop_session(now);
269 }
270 self.history.push(StatusChange { at: now, from: self.status, to: status, by });
271 self.status = status;
272 true
273 }
274
275 /// Whether the last session is still open.
276 pub fn is_running(&self) -> bool {
277 self.sessions.last().is_some_and(|s| s.end.is_none())
278 }
279
280 /// How long the open session has been running, if any.
281 pub fn running_for(&self, now: DateTime<Local>) -> Option<Duration> {
282 self.sessions.last().filter(|s| s.end.is_none()).map(|s| s.duration(now))
283 }
284
285 /// Opens a session now, unless one is already running. The caller moves the task to `doing`.
286 pub fn start_session(&mut self, now: DateTime<Local>) {
287 if !self.is_running() {
288 self.sessions.push(Session { start: now, end: None });
289 }
290 }
291
292 /// Closes the open session, if any.
293 pub fn stop_session(&mut self, now: DateTime<Local>) {
294 if let Some(session) = self.sessions.last_mut().filter(|s| s.end.is_none()) {
295 session.end = Some(now.max(session.start));
296 }
297 }
298
299 /// Time tracked in all sessions.
300 pub fn total(&self, now: DateTime<Local>) -> Duration {
301 self.sessions.iter().map(|s| s.duration(now)).sum()
302 }
303
304 /// Time tracked inside `[from, to)`: sessions are clipped to the range. For several tasks, see [`worked`].
305 pub fn time_between(&self, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>) -> Duration {
306 self.sessions.iter().map(|s| s.overlap(from, to, now)).sum()
307 }
308
309 /// Who last moved the task to `doing`: a person or an agent. A task in `doing` with no such change (from
310 /// an older version, or edited by hand) counts as a person's.
311 pub fn started_by(&self) -> Author {
312 self.history.iter().rev().find(|c| c.to == Status::Doing).map_or(Author::Human, |c| c.by)
313 }
314
315 /// When the status last changed (or the task was created, if never); used for sorting.
316 pub fn status_since(&self) -> DateTime<Local> {
317 self.history.last().map_or(self.created, |c| c.at)
318 }
319
320 /// The question this parked task waits on, or its answer. A question is the latest comment marked as
321 /// one; a person's comment after it answers it, as does a verdict on the decision it asked about. Starting the
322 /// task after either means it was picked up, so it's neither once the task is parked again for another reason.
323 pub fn asked(&self) -> Option<Asked<'_>> {
324 if !self.is_task() || self.status != Status::Parked {
325 return None;
326 }
327 let at = self.comments.iter().rposition(|c| c.question)?;
328 let question = &self.comments[at];
329 let answer = self.comments[at + 1..].iter().rev().find(|c| c.author == Author::Human || c.answer);
330 // Strictly after: times are whole seconds, and an agent that starts a task and asks at once does
331 // both in the same second, which isn't picking it up again.
332 let picked_up = self.history.iter().any(|c| c.to == Status::Doing && c.at > question.created);
333 match answer {
334 _ if picked_up => None,
335 Some(answer) => Some(Asked::Answered(answer)),
336 None => Some(Asked::Waiting(question)),
337 }
338 }
339
340 /// Whether this entry has a status, history and sessions (only tasks do).
341 pub fn is_task(&self) -> bool {
342 self.kind == Kind::Task
343 }
344
345 /// Search: every whitespace-separated term must match. `#term` matches a tag prefix;
346 /// anything else matches the title or a tag. Case-insensitive.
347 pub fn matches(&self, query: &str) -> bool {
348 query.split_whitespace().all(|term| self.has_term(&term.to_lowercase(), false))
349 }
350
351 /// Whether the entry has `term`, already lowercase: `#tag` (written as a tag is typed anywhere) a tag that
352 /// starts with it; anything else the title or a tag, and with `everywhere`, the description or a comment too.
353 pub(crate) fn has_term(&self, term: &str, everywhere: bool) -> bool {
354 match term.strip_prefix('#') {
355 Some("") => true,
356 Some(tag) => text::parse_tags(tag).first().is_some_and(|tag| self.tags.iter().any(|t| t.starts_with(tag))),
357 None => {
358 self.title.to_lowercase().contains(term)
359 || self.tags.iter().any(|t| t.contains(term))
360 || everywhere
361 && (self.description.to_lowercase().contains(term)
362 || self.comments.iter().any(|c| c.body.to_lowercase().contains(term)))
363 }
364 }
365 }
366}
367
368/// Time worked on any of `tasks` inside `[from, to)`: time two of them ran at once counts once (see
369/// [`covered`]), so agents' tasks running in parallel don't make a day longer than it was.
370pub fn worked<'a>(
371 tasks: impl IntoIterator<Item = &'a Task>,
372 from: DateTime<Local>,
373 to: DateTime<Local>,
374 now: DateTime<Local>,
375) -> Duration {
376 covered(tasks.into_iter().flat_map(|t| &t.sessions).map(|s| s.span(from, to, now)))
377}
378
379#[cfg(test)]
380#[path = "../../tests/model.rs"]
381mod tests;