Skip to main content

kanbrain_core/brain/
mod.rs

1//! The brain: every entry in the data folder, and the operations on them. Time is always tracked: each task in `doing`
2//! has its clock running. `doing` holds one task started by a person (starting another is refused, not made room for);
3//! agents', which may run one per subagent, aren't limited. Each brain (the central one, each project repository) has
4//! its own, so work in different projects can run side by side. The command line and the desktop app both go through
5//! it, so tracking rules, tag colors and file names behave the same everywhere. It holds no screen state; the desktop
6//! app keeps its own.
7//!
8//! - [`actions`]: changes to entries and tags, each written to disk immediately.
9//! - [`report`]: time per task or tag over a period, from the tracked sessions.
10//! - [`calendar`]: each day's interactions over a period: sessions, moves, comments, new entries.
11
12mod actions;
13mod calendar;
14mod report;
15#[cfg(test)]
16#[path = "../../tests/brain.rs"]
17mod tests;
18
19pub use actions::NewEntry;
20pub use calendar::{Calendar, CalendarView, Day, Event, Show, What};
21pub use report::{Group, Report, ReportRow, ReportView, Slot};
22
23use crate::api::ApiError;
24use crate::model::{Author, Kind, RepoLink, Status, Task, now};
25use crate::projects::{self, ProjectInfo};
26use crate::store::{self, Skipped, Store};
27use crate::tags::TagColors;
28use anyhow::{Context, Result};
29use std::path::{Path, PathBuf};
30
31/// Every entry in the data folder, with the tag colors.
32#[derive(Debug)]
33pub struct Brain {
34    /// The data folder entries are read from and written to.
35    pub store: Store,
36    /// Every entry, in no particular order.
37    pub tasks: Vec<Task>,
38    /// Tag → color assignments, kept in `tags.md`.
39    pub tag_colors: TagColors,
40    /// Files skipped by the last load, with why; the desktop app shows them.
41    pub warnings: Vec<String>,
42    /// What a project's repository knows of its project; `None` for the central brain.
43    pub project: Option<ProjectInfo>,
44    /// The central brain, which lists the projects and keeps their shared cards. `None` (as from
45    /// [`Brain::open`]) leaves the project's description and sibling repositories out.
46    pub central: Option<PathBuf>,
47    /// Who the comments and status changes made through this brain are recorded as: a person unless
48    /// the command line found it runs for an agent.
49    pub author: Author,
50}
51
52impl Brain {
53    /// Loads every entry and the tag colors.
54    pub fn open(store: Store) -> Result<Self> {
55        Self::open_with(store, None)
56    }
57
58    /// Loads every entry and the tag colors, knowing the central brain if `central` is given. A brain in an older
59    /// data format is migrated first, as it's opened to be changed (see [`crate::migrate`]).
60    fn open_with(store: Store, central: Option<PathBuf>) -> Result<Self> {
61        Self::open_skipping(store, central).map(|(brain, _)| brain)
62    }
63
64    /// [`Brain::open_with`], with the files its load skipped, which [`Brain::warnings`] says only as text.
65    fn open_skipping(store: Store, central: Option<PathBuf>) -> Result<(Self, Vec<Skipped>)> {
66        crate::migrate::migrate(&store)?;
67        let mut brain = Self {
68            store,
69            tasks: Vec::new(),
70            tag_colors: TagColors::default(),
71            warnings: Vec::new(),
72            project: None,
73            central,
74            author: Author::Human,
75        };
76        let skipped = brain.reload_skipping()?;
77        Ok((brain, skipped))
78    }
79
80    /// Loads every entry and the tag colors as the files have them, writing nothing: for reading a brain
81    /// nobody is working in, such as another repository's to show links to it, or to complete a command
82    /// line. [`Brain::open`] also repairs what it finds, in files that may be in another git repository.
83    pub fn read_only(store: Store) -> Result<Self> {
84        let (tasks, skipped) = store.load_entries()?;
85        let warnings = skipped.iter().map(ToString::to_string).collect();
86        let tag_colors = store.load_tag_colors()?;
87        Ok(Self { store, tasks, tag_colors, warnings, project: None, central: None, author: Author::Human })
88    }
89
90    /// Loads every entry, knowing the central brain at `central` (see [`Brain::central`]), and so the
91    /// project's other repositories, whose deleted entries' links it drops.
92    pub fn open_in(store: Store, central: &Path) -> Result<Self> {
93        Self::open_with(store, Some(central.to_path_buf()))
94    }
95
96    /// [`Brain::open_in`], with the files its load skipped, typed: for the command line, which says only those an
97    /// entry's.
98    pub(crate) fn open_in_skipping(store: Store, central: &Path) -> Result<(Self, Vec<Skipped>)> {
99        Self::open_skipping(store, Some(central.to_path_buf()))
100    }
101
102    /// Re-reads every file from disk.
103    pub fn reload(&mut self) -> Result<()> {
104        self.reload_skipping().map(drop)
105    }
106
107    /// [`Brain::reload`], returning the files it skipped, which [`Brain::warnings`] says as text.
108    fn reload_skipping(&mut self) -> Result<Vec<Skipped>> {
109        let (tasks, skipped) = self.store.load_entries()?;
110        self.tasks = tasks;
111        self.warnings = skipped.iter().map(ToString::to_string).collect();
112        self.renumber_shared_ids()?;
113        self.keep_one_person_doing()?;
114        self.drop_dangling_links()?;
115        self.remember_highest_id()?;
116        self.tag_colors = self.store.load_tag_colors()?;
117        self.project = projects::info(self.store.dir(), self.central.as_deref());
118        self.drop_dangling_repo_links()?;
119        self.sync_tag_colors()?;
120        Ok(skipped)
121    }
122
123    /// The entry with this id.
124    pub fn task(&self, id: u32) -> Option<&Task> {
125        self.tasks.iter().find(|t| t.id == id)
126    }
127
128    /// Index into `tasks` of the entry with this id.
129    pub(crate) fn find(&self, id: u32) -> Option<usize> {
130        self.tasks.iter().position(|t| t.id == id)
131    }
132
133    /// Index into `tasks` of the entry with this id, or `NotFound`: for actions on an entry by its id.
134    pub(crate) fn index_of(&self, id: u32) -> Result<usize, ApiError> {
135        self.find(id).ok_or(ApiError::NotFound(id))
136    }
137
138    /// The tasks whose clock is running: those in `doing`.
139    pub fn running(&self) -> impl Iterator<Item = &Task> {
140        self.tasks.iter().filter(|t| t.is_running())
141    }
142
143    /// The task in `doing` that a person started, if any: there's one at most.
144    pub fn person_doing(&self) -> Option<u32> {
145        self.tasks.iter().find(|t| t.doing_by(Author::Human)).map(|t| t.id)
146    }
147
148    /// Tasks with `status`, most recently moved there first.
149    pub fn column(&self, status: Status) -> Vec<&Task> {
150        column(&self.tasks, status)
151    }
152
153    /// Entries that link to the one with this id, oldest first.
154    pub fn backlinks(&self, id: u32) -> Vec<&Task> {
155        let mut from: Vec<&Task> = self.tasks.iter().filter(|t| t.links.contains(&id)).collect();
156        from.sort_by_key(|t| t.id);
157        from
158    }
159
160    /// Entries related to the one with this id, whichever side stores the link: notes, then
161    /// decisions, then tasks, newest first within each.
162    pub fn related(&self, id: u32) -> Vec<&Task> {
163        let links = self.task(id).map_or(&[][..], |t| t.links.as_slice());
164        let mut related: Vec<&Task> =
165            self.tasks.iter().filter(|t| t.id != id && (links.contains(&t.id) || t.links.contains(&id))).collect();
166        related.sort_by_key(|t| (related_rank(t.kind), std::cmp::Reverse(t.id)));
167        related
168    }
169
170    /// Whether the two entries are related, whichever side stores the link.
171    pub fn are_related(&self, a: u32, b: u32) -> bool {
172        let links = |x: u32, y: u32| self.task(x).is_some_and(|t| t.links.contains(&y));
173        links(a, b) || links(b, a)
174    }
175
176    /// The brain of the project's repository named `repo`, read from disk without changing it, to check or
177    /// show links to its entries. `None` when this brain isn't in a project, the project has no such other
178    /// repository, or its folder is gone: it's never created here.
179    pub fn sibling(&self, repo: &str) -> Option<Brain> {
180        let (_, dir) = self.project.as_ref()?.siblings.iter().find(|(name, _)| name == repo)?;
181        if !dir.is_dir() {
182            return None;
183        }
184        Brain::read_only(Store::at(dir).ok()?).ok()
185    }
186
187    /// Entries of the project's other repositories related to the one with this id, whichever side
188    /// stores the link, as `(repository, entry)`: by repository, then as [`Brain::related`] orders them.
189    /// A repository that can't be read, or an entry deleted there since, is left out.
190    pub fn repo_related(&self, id: u32) -> Vec<(String, Task)> {
191        let Some(project) = &self.project else { return Vec::new() };
192        let links = self.task(id).map_or(&[][..], |t| t.repo_links.as_slice());
193        let here = RepoLink { repo: project.repo.clone(), id };
194        let mut related = Vec::new();
195        for (name, _) in &project.siblings {
196            let Some(other) = self.sibling(name) else { continue };
197            let mut found: Vec<Task> = other
198                .tasks
199                .into_iter()
200                .filter(|t| links.contains(&RepoLink { repo: name.clone(), id: t.id }) || t.repo_links.contains(&here))
201                .collect();
202            found.sort_by_key(|t| (related_rank(t.kind), std::cmp::Reverse(t.id)));
203            related.extend(found.into_iter().map(|t| (name.clone(), t)));
204        }
205        related
206    }
207
208    /// All tags in use, sorted.
209    pub fn all_tags(&self) -> Vec<String> {
210        let mut tags: Vec<String> = self.tasks.iter().flat_map(|t| t.tags.iter().cloned()).collect();
211        tags.sort();
212        tags.dedup();
213        tags
214    }
215
216    /// Takes the id for a new entry: one more than the highest ever given, by `last-id`, the entries in memory or
217    /// the entry files there are (one that doesn't parse holds its id too), whichever is higher, since another
218    /// program may have added one since this brain loaded.
219    /// It's recorded before the entry is written, so a failure in between leaves a gap, never a reused id.
220    fn take_id(&self) -> Result<u32> {
221        let highest = self.tasks.iter().map(|t| t.id).chain(self.store.file_ids()).max().unwrap_or(0);
222        // A hand-edited `- id:` or `last-id` can be the largest id there is; wrapping to 0 would hand it out again
223        // and again, each new entry over the last's file.
224        let id = highest.max(self.store.last_id()).checked_add(1).context("no ids left: the highest id is taken")?;
225        self.store.save_last_id(id)?;
226        Ok(id)
227    }
228
229    /// Links to entries of the project's other repositories that were deleted there (by hand, or by an
230    /// older version) are removed, as deleting in the app does. Only where that repository can be read:
231    /// one whose folder is missing for now, an unmounted disk say, keeps the links to it. So does an entry whose
232    /// file is there but doesn't parse, and one newer than the repository's `last-id`, as on a branch checked out
233    /// from before it was added: neither was deleted.
234    fn drop_dangling_repo_links(&mut self) -> Result<()> {
235        let mut repos: Vec<&str> = self.tasks.iter().flat_map(|t| &t.repo_links).map(|l| l.repo.as_str()).collect();
236        repos.sort_unstable();
237        repos.dedup();
238        let mut gone: Vec<RepoLink> = Vec::new();
239        for repo in repos {
240            let Some(other) = self.sibling(repo) else { continue };
241            // An older brain may have no `last-id`: the highest id of its files stands in.
242            let files = other.store.file_ids();
243            let last = other.store.last_id().max(files.iter().copied().max().unwrap_or(0));
244            for link in self.tasks.iter().flat_map(|t| &t.repo_links).filter(|l| l.repo == repo) {
245                let deleted = other.task(link.id).is_none() && !files.contains(&link.id) && link.id <= last;
246                if deleted && !gone.contains(link) {
247                    gone.push(link.clone());
248                }
249            }
250        }
251        for link in &gone {
252            self.drop_repo_links_to(link)?;
253        }
254        Ok(())
255    }
256
257    /// Two entries with one id, a sync tool's conflict copy (`0005-title (conflicted copy).md`) or one added on
258    /// two machines at once, would each take commands meant for the other, and saving one could overwrite the
259    /// other's file: all but one get new ids. The one kept is under its own name, else the oldest.
260    fn renumber_shared_ids(&mut self) -> Result<()> {
261        let own_name =
262            |t: &Task| t.path.as_ref().and_then(|p| p.file_name()).is_some_and(|n| *n == *store::file_name(t));
263        let mut order: Vec<usize> = (0..self.tasks.len()).collect();
264        let key = |i: usize| {
265            let t = &self.tasks[i];
266            (t.id, !own_name(t), t.created, t.path.as_ref())
267        };
268        order.sort_by(|&a, &b| key(a).cmp(&key(b)));
269        let mut seen = std::collections::HashSet::new();
270        for i in order {
271            if !seen.insert(self.tasks[i].id) {
272                let id = self.take_id()?;
273                let task = &mut self.tasks[i];
274                task.id = id;
275                self.store.save(task)?;
276            }
277        }
278        Ok(())
279    }
280
281    /// Records the highest id among the entries when `last-id` is behind it (a folder from an older
282    /// version, or entries copied in), so deleting the newest entry can't free its id.
283    fn remember_highest_id(&self) -> Result<()> {
284        let highest = self.tasks.iter().map(|t| t.id).max().unwrap_or(0);
285        if highest > self.store.last_id() {
286            self.store.save_last_id(highest)?;
287        }
288        Ok(())
289    }
290
291    /// `doing` holds one task started by a person: data from older versions, hand edits or a sync conflict
292    /// can leave several there, and all but the most recently started are parked. Agents' tasks are left
293    /// alone.
294    fn keep_one_person_doing(&mut self) -> Result<()> {
295        let doing = |t: &&mut Task| t.doing_by(Author::Human);
296        let Some(latest) = self.tasks.iter_mut().filter(doing).map(|t| (t.status_since(), t.id)).max() else {
297            return Ok(());
298        };
299        let now = now();
300        for task in self.tasks.iter_mut().filter(doing) {
301            if (task.status_since(), task.id) != latest {
302                task.set_status(Status::Parked, now, self.author);
303                self.store.save(task)?;
304            }
305        }
306        Ok(())
307    }
308
309    /// Entries deleted outside kanbrain (by hand, `rm`, a sync) leave links to them behind; they're
310    /// removed so nothing points at an entry that's gone. One whose file is there but doesn't parse isn't gone.
311    fn drop_dangling_links(&mut self) -> Result<()> {
312        let mut ids = self.store.file_ids();
313        ids.extend(self.tasks.iter().map(|t| t.id));
314        for task in &mut self.tasks {
315            let before = task.links.len();
316            task.links.retain(|link| ids.contains(link));
317            if task.links.len() != before {
318                self.store.save(task)?;
319            }
320        }
321        Ok(())
322    }
323
324    /// Colors any new tags, oldest entries first so earlier tags claim colors first.
325    fn sync_tag_colors(&mut self) -> Result<()> {
326        let mut by_age: Vec<&Task> = self.tasks.iter().collect();
327        by_age.sort_by_key(|t| t.id);
328        if self.tag_colors.assign(by_age.iter().flat_map(|t| &t.tags)) {
329            self.store.save_tag_colors(&self.tag_colors)?;
330        }
331        Ok(())
332    }
333}
334
335/// [`Brain::column`] of `tasks`, for `list`, which reads them without a `Brain`'s other parts.
336pub(crate) fn column(tasks: &[Task], status: Status) -> Vec<&Task> {
337    let mut column: Vec<&Task> = tasks.iter().filter(|t| t.is_task() && t.status == status).collect();
338    column.sort_by(|a, b| b.status_since().cmp(&a.status_since()).then(b.id.cmp(&a.id)));
339    column
340}
341
342/// Where a kind's related entries are listed: notes and decisions (what a task needs to know) before tasks.
343fn related_rank(kind: Kind) -> u8 {
344    match kind {
345        Kind::Note => 0,
346        Kind::Decision => 1,
347        Kind::Task => 2,
348    }
349}