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