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