Skip to main content

kanbrain_core/brain/
actions.rs

1//! Changes to entries, and writing reports. Every change is written to disk immediately.
2
3use super::{Brain, ReportView};
4use crate::api::ApiError;
5use crate::export;
6use crate::model::{
7    Asked, Author, Comment, DecisionState, Kind, RepoLink, Status, Task, now, one_line, sanitize_comment,
8    sanitize_description,
9};
10use crate::projects;
11use anyhow::Result;
12use std::path::PathBuf;
13
14/// The body a new decision starts with: the usual sections of an architecture decision record.
15const DECISION_TEMPLATE: &str = "### Context\n\nWhat forces us to decide, and what we know.\n\n\
16                                 ### Decision\n\nWhat we chose.\n\n\
17                                 ### Consequences\n\nWhat becomes easier or harder, and what we gave up.";
18
19/// What a new entry starts with, from the desktop app, the command line or an agent.
20#[derive(Debug, Default)]
21pub struct NewEntry {
22    /// Task, note or decision.
23    pub kind: Kind,
24    /// Must not be empty.
25    pub title: String,
26    /// Lowercase, without `#`.
27    pub tags: Vec<String>,
28    /// Ids of related entries.
29    pub links: Vec<u32>,
30    /// Related entries in the project's other repositories.
31    pub repo_links: Vec<RepoLink>,
32    /// `None` leaves it empty, or for a decision, the ADR sections to fill in.
33    pub description: Option<String>,
34    /// Start a task right away (moving it to `doing` and starting its clock).
35    pub start: bool,
36}
37
38impl Brain {
39    /// The index of the task with this id; notes and decisions are refused, since only tasks have a status.
40    fn task_index(&self, id: u32) -> Result<usize, ApiError> {
41        let index = self.index_of(id)?;
42        match self.tasks[index].kind {
43            Kind::Task => Ok(index),
44            kind => Err(ApiError::NotATask(id, kind)),
45        }
46    }
47
48    /// Moves a task to `status`, recording when. `doing` starts its clock; for a person, it's refused while
49    /// `doing` holds a task a person started: making room is their call, never a side effect. Agents start
50    /// as many as they work on. Returns whether anything changed.
51    pub fn set_status(&mut self, id: u32, status: Status) -> Result<bool> {
52        let index = self.task_index(id)?;
53        if status == Status::Doing {
54            return self.start_task(index);
55        }
56        if !self.tasks[index].set_status(status, now(), self.author) {
57            return Ok(false);
58        }
59        self.store.save(&mut self.tasks[index])?;
60        Ok(true)
61    }
62
63    /// Moves the task at `index` to `doing` and opens a session, if `doing` has room. A task already in
64    /// `doing` without a session (from an older version) starts its clock.
65    fn start_task(&mut self, index: usize) -> Result<bool> {
66        if self.tasks[index].is_running() {
67            return Ok(false);
68        }
69        if self.tasks[index].status != Status::Doing {
70            self.check_doing_room(self.author)?;
71        }
72        let now = now();
73        self.tasks[index].set_status(Status::Doing, now, self.author);
74        self.tasks[index].start_session(now);
75        self.store.save(&mut self.tasks[index])?;
76        Ok(true)
77    }
78
79    /// Replaces the project's description, on the card all its repositories share.
80    pub fn set_project_description(&mut self, text: &str) -> Result<()> {
81        let (Some(project), Some(central)) = (&mut self.project, &self.central) else {
82            anyhow::bail!("only a project's repository has a project description");
83        };
84        project.description = text.trim().to_string();
85        projects::write_description(central, &project.name, &project.description)
86    }
87
88    /// Replaces this repository's own notes about itself, on its `.brain/project.md`.
89    pub fn set_repo_notes(&mut self, text: &str) -> Result<()> {
90        let Some(project) = &mut self.project else { anyhow::bail!("the central brain isn't a project's repository") };
91        project.notes = text.trim().to_string();
92        let repo = projects::read_repo_card(self.store.dir()).and_then(|card| card.repo);
93        let card = projects::RepoCard { project: project.name.clone(), repo, notes: project.notes.clone() };
94        projects::write_repo_card(self.store.dir(), &card)
95    }
96
97    /// Refuses with [`ApiError::DoingFull`] a person starting a task while `doing` holds one a person
98    /// started; an agent always has room.
99    fn check_doing_room(&self, by: Author) -> Result<(), ApiError> {
100        match self.person_doing() {
101            Some(doing) if by == Author::Human => Err(ApiError::DoingFull(doing)),
102            _ => Ok(()),
103        }
104    }
105
106    /// Parks every running task a person started, when they stop for the day. Agents' keep running: they may
107    /// still be at work, and each agent closes its own.
108    pub fn park_people_running(&mut self) -> Result<()> {
109        let now = now();
110        for task in self.tasks.iter_mut().filter(|t| t.is_running() && t.started_by() == Author::Human) {
111            task.set_status(Status::Parked, now, self.author);
112            self.store.save(task)?;
113        }
114        Ok(())
115    }
116
117    /// Creates an entry with a new id and returns the id: a `todo` task, a note, or a decision
118    /// that starts from the ADR sections unless it comes with a description. The caller has checked
119    /// the title and links (see `Brain::add`).
120    pub(crate) fn create(&mut self, new: NewEntry) -> Result<u32> {
121        // Before anything is written: a task that can't start isn't created half-way.
122        if new.start && new.kind == Kind::Task {
123            self.check_doing_room(self.author)?;
124        }
125        let mut task = Task::new(self.take_id()?, new.title, now());
126        task.kind = new.kind;
127        task.description = match new.description {
128            Some(text) => sanitize_description(&text).0,
129            None if task.kind == Kind::Decision => DECISION_TEMPLATE.to_string(),
130            None => String::new(),
131        };
132        task.tags = new.tags;
133        task.links = new.links;
134        task.repo_links = new.repo_links;
135        self.store.save(&mut task)?;
136        let id = task.id;
137        self.tasks.push(task);
138        self.sync_tag_colors()?;
139        if new.start && new.kind == Kind::Task {
140            self.set_status(id, Status::Doing)?;
141        }
142        Ok(id)
143    }
144
145    /// Saves a new title and tags (a new title also renames the file), logging what changed.
146    pub fn update(&mut self, id: u32, title: &str, tags: Vec<String>) -> Result<()> {
147        let title = one_line(title);
148        if title.is_empty() {
149            return Err(ApiError::Empty("title").into());
150        }
151        let index = self.index_of(id)?;
152        let (now, task) = (now(), &mut self.tasks[index]);
153        task.set_title(title, now, self.author);
154        task.set_tags(tags, now, self.author);
155        self.store.save(task)?;
156        self.sync_tag_colors()
157    }
158
159    /// Renames tag `old` to `new` in every entry that has it, logging it in each; an entry that has both just loses
160    /// `old`. `new` takes `old`'s color unless it has one. Returns the ids of the entries changed, lowest first.
161    pub fn rename_tag(&mut self, old: &str, new: &str) -> Result<Vec<u32>> {
162        let has = |task: &Task| task.tags.iter().any(|t| t == old);
163        if !self.tasks.iter().any(has) {
164            return Err(ApiError::NoTag(old.to_string()).into());
165        }
166        let (now, mut renamed) = (now(), Vec::new());
167        for task in self.tasks.iter_mut().filter(|t| has(t)) {
168            let mut tags: Vec<String> = Vec::with_capacity(task.tags.len());
169            for tag in task.tags.iter().map(|t| if t == old { new } else { t }) {
170                if !tags.iter().any(|t| t == tag) {
171                    tags.push(tag.to_string());
172                }
173            }
174            task.set_tags(tags, now, self.author);
175            self.store.save(task)?;
176            renamed.push(task.id);
177        }
178        if self.tag_colors.rename(old, new) {
179            self.store.save_tag_colors(&self.tag_colors)?;
180        }
181        self.sync_tag_colors()?;
182        renamed.sort_unstable();
183        Ok(renamed)
184    }
185
186    /// Deletes the entry's file, after removing the links other entries have to it. The links go first:
187    /// if a save fails, the entry is still there and nothing points nowhere.
188    pub fn delete(&mut self, id: u32) -> Result<()> {
189        let index = self.index_of(id)?;
190        for task in self.tasks.iter_mut().filter(|t| t.links.contains(&id)) {
191            task.links.retain(|&link| link != id);
192            self.store.save(task)?;
193        }
194        self.store.delete(&self.tasks[index])?;
195        self.tasks.remove(index);
196        Ok(())
197    }
198
199    /// Moves the entry to `to`, another brain (another project or repository, or the central one), and
200    /// returns its id there: a new one, since each brain numbers its own. Everything else comes
201    /// along (status, history, sessions, description, comments, tags) except its links, which name it by
202    /// its id here, so they're dropped on both sides (links to other repositories too). A running task keeps
203    /// its clock; one a person started needs room in `to`'s `doing`, as starting it there would.
204    ///
205    /// The entry is saved in `to` before it's deleted here: if something fails in between, it's in both
206    /// brains rather than in neither.
207    pub fn move_to(&mut self, id: u32, to: &mut Brain) -> Result<u32> {
208        let index = self.index_of(id)?;
209        if projects::same_dir(self.store.dir(), to.store.dir()) {
210            anyhow::bail!("#{id} is already there");
211        }
212        if self.tasks[index].status == Status::Doing {
213            to.check_doing_room(self.tasks[index].started_by())?;
214        }
215        let mut entry = Task {
216            id: to.take_id()?,
217            links: Vec::new(),
218            repo_links: Vec::new(),
219            path: None,
220            ..self.tasks[index].clone()
221        };
222        entry.unknown.links.clear();
223        to.store.save(&mut entry)?;
224        let new_id = entry.id;
225        to.tasks.push(entry);
226        to.sync_tag_colors()?;
227        self.delete(id)?;
228        Ok(new_id)
229    }
230
231    /// Removes every link to `link`, an entry of another repository of the project that's gone.
232    pub fn drop_repo_links_to(&mut self, link: &RepoLink) -> Result<()> {
233        for task in self.tasks.iter_mut().filter(|t| t.repo_links.contains(link)) {
234            task.repo_links.retain(|l| l != link);
235            self.store.save(task)?;
236        }
237        Ok(())
238    }
239
240    /// Relates entry `id` to entry `other_id` of `other`, another repository of this brain's project, or
241    /// unrelates them if they already are: the link is added here, and removed from whichever side stores
242    /// it. Returns whether they are related now.
243    pub fn toggle_repo_link(&mut self, id: u32, other: &mut Brain, other_id: u32) -> Result<bool> {
244        let (Some(here), Some(there)) = (&self.project, &other.project) else {
245            return Err(ApiError::NoRepo(other.store.dir().display().to_string()).into());
246        };
247        if here.name != there.name || here.repo == there.repo {
248            return Err(ApiError::NoRepo(there.repo.clone()).into());
249        }
250        let to = RepoLink { repo: there.repo.clone(), id: other_id };
251        let back = RepoLink { repo: here.repo.clone(), id };
252        let index = self.index_of(id)?;
253        let other_index = other.find(other_id).ok_or_else(|| ApiError::NotFoundIn(to.clone()))?;
254        let related = self.tasks[index].repo_links.contains(&to) || other.tasks[other_index].repo_links.contains(&back);
255        let (now, by) = (now(), self.author);
256        // Each side logs what its own `- links:` line gained or lost: like the link, a log of it in the other
257        // repository's files would be one more file of another repository written.
258        if !related {
259            let task = &mut self.tasks[index];
260            task.relate(now, by, &[to.to_string()], &[]);
261            task.repo_links.push(to);
262            self.store.save(task)?;
263            return Ok(true);
264        }
265        for (brain, index, link) in [(&mut *self, index, &to), (&mut *other, other_index, &back)] {
266            let task = &mut brain.tasks[index];
267            if task.repo_links.contains(link) {
268                task.repo_links.retain(|l| l != link);
269                task.relate(now, by, &[], &[link.to_string()]);
270                brain.store.save(task)?;
271            }
272        }
273        Ok(false)
274    }
275
276    /// Relates two entries, or unrelates them if they already are: the link is added to `id`, and
277    /// removed from whichever side stores it. Both log it. Returns whether they are related now.
278    pub fn toggle_link(&mut self, id: u32, other: u32) -> Result<bool> {
279        if id == other {
280            return Err(ApiError::SelfLink(id).into());
281        }
282        let sides = [(self.index_of(id)?, other), (self.index_of(other)?, id)];
283        let related = self.are_related(id, other);
284        let now = now();
285        for (index, to) in sides {
286            let task = &mut self.tasks[index];
287            let word = [to.to_string()];
288            if related {
289                task.links.retain(|&l| l != to);
290                task.relate(now, self.author, &[], &word);
291            } else {
292                if task.id == id {
293                    task.links.push(to);
294                }
295                task.relate(now, self.author, &word, &[]);
296            }
297            self.store.save(task)?;
298        }
299        Ok(!related)
300    }
301
302    /// Replaces the description, logging that it changed. Returns whether `##` headings had to become `###`.
303    pub fn set_description(&mut self, id: u32, text: &str) -> Result<bool> {
304        let index = self.index_of(id)?;
305        let (text, demoted) = sanitize_description(text);
306        self.tasks[index].set_description(text, now(), self.author);
307        self.store.save(&mut self.tasks[index])?;
308        Ok(demoted)
309    }
310
311    /// Adds a timestamped comment. Returns whether headings had to become `####`.
312    pub fn add_comment(&mut self, id: u32, text: &str) -> Result<bool> {
313        self.push_comment(id, text, Said::Comment)
314    }
315
316    /// Asks the user a question on a task and parks it, until a person answers with a comment (see
317    /// [`Task::asked`]). Returns whether headings had to become `####`.
318    pub fn ask(&mut self, id: u32, question: &str) -> Result<bool> {
319        let index = self.task_index(id)?;
320        let demoted = self.push_comment(id, question, Said::Question)?;
321        if self.tasks[index].set_status(Status::Parked, now(), self.author) {
322            self.store.save(&mut self.tasks[index])?;
323        }
324        Ok(demoted)
325    }
326
327    /// Proposes a decision for task `id`, which waits for a person to accept or reject it: the decision
328    /// is created as proposed, linked to the task, and the task asks about it and is parked. Returns
329    /// the decision's id.
330    pub fn propose(&mut self, id: u32, decision: NewEntry) -> Result<u32> {
331        self.task_index(id)?;
332        let mut links = decision.links;
333        if !links.contains(&id) {
334            links.insert(0, id);
335        }
336        let title = one_line(&decision.title);
337        let new = NewEntry { kind: Kind::Decision, title: title.clone(), links, start: false, ..decision };
338        let decision = self.add(new)?;
339        let index = self.index_of(decision)?;
340        self.tasks[index].decision = DecisionState::Proposed;
341        self.store.save(&mut self.tasks[index])?;
342        self.ask(id, &format!("Proposed decision #{decision}, {title}: accept or reject it."))?;
343        Ok(decision)
344    }
345
346    /// Accepts or rejects decision `id`, with `reason` (needed to reject), and answers the tasks that wait
347    /// on it. A decision can be reconsidered: an accepted one rejected later, and back. Returns false if it
348    /// already was in `state`.
349    pub fn resolve(&mut self, id: u32, state: DecisionState, reason: &str) -> Result<bool> {
350        let index = self.index_of(id)?;
351        let decision = &self.tasks[index];
352        if decision.kind != Kind::Decision {
353            return Err(ApiError::NotADecision(id, decision.kind).into());
354        }
355        if decision.decision == state {
356            return Ok(false);
357        }
358        if state == DecisionState::Rejected && reason.trim().is_empty() {
359            return Err(ApiError::Empty("reason").into());
360        }
361        let title = decision.title.clone();
362        let verdict = match state {
363            DecisionState::Accepted => "Accepted",
364            DecisionState::Rejected => "Rejected",
365            DecisionState::Proposed => "Proposed again",
366        };
367        let said = |what: &str| match reason.trim() {
368            "" => format!("{what}."),
369            reason => format!("{what}: {reason}"),
370        };
371        self.tasks[index].decision = state;
372        self.store.save(&mut self.tasks[index])?;
373        self.push_comment(id, &said(verdict), Said::Comment)?;
374        // The tasks whose question is about this decision, as `propose` asks it ("Proposed decision #5, …"): a task
375        // that only links it waits on something else.
376        let about = format!("#{id}");
377        let waiting: Vec<u32> = self
378            .tasks
379            .iter()
380            .filter(|t| match t.asked() {
381                Some(Asked::Waiting(question)) => {
382                    self.are_related(t.id, id)
383                        // Words as a link is written, so `web#6`, another repository's entry, isn't `#6`.
384                        && question
385                            .body
386                            .split(|c: char| !(c.is_alphanumeric() || matches!(c, '#' | '-' | '_')))
387                            .any(|w| w == about)
388                }
389                _ => false,
390            })
391            .map(|t| t.id)
392            .collect();
393        for task in waiting {
394            self.push_comment(task, &said(&format!("{verdict} decision #{id}, {title}")), Said::Answer)?;
395        }
396        Ok(true)
397    }
398
399    /// Adds a comment by this brain's author, saying what `said` says it is. Returns whether headings had to become
400    /// `####`.
401    fn push_comment(&mut self, id: u32, text: &str, said: Said) -> Result<bool> {
402        let index = self.index_of(id)?;
403        let (body, demoted) = sanitize_comment(text);
404        if body.is_empty() {
405            return Err(ApiError::Empty(if said == Said::Question { "question" } else { "comment" }).into());
406        }
407        let (question, answer) = (said == Said::Question, said == Said::Answer);
408        let comment = Comment { question, answer, ..Comment::new(now(), body, self.author) };
409        self.tasks[index].comments.push(comment);
410        self.store.save(&mut self.tasks[index])?;
411        Ok(demoted)
412    }
413
414    /// Changes a comment's text and marks it edited, unless the text is unchanged.
415    /// Returns whether headings had to become `####`.
416    pub fn update_comment(&mut self, id: u32, comment: usize, text: &str) -> Result<bool> {
417        let index = self.index_of(id)?;
418        let (body, demoted) = sanitize_comment(text);
419        if body.is_empty() {
420            return Err(ApiError::Empty("comment").into());
421        }
422        let task = &mut self.tasks[index];
423        let Some(existing) = task.comments.get_mut(comment) else {
424            return Err(ApiError::NoComment(id, comment).into());
425        };
426        if existing.body != body {
427            existing.body = body;
428            existing.edited = Some(now());
429            self.store.save(task)?;
430        }
431        Ok(demoted)
432    }
433
434    /// Deletes a comment.
435    pub fn delete_comment(&mut self, id: u32, comment: usize) -> Result<()> {
436        let index = self.index_of(id)?;
437        let task = &mut self.tasks[index];
438        if comment >= task.comments.len() {
439            return Err(ApiError::NoComment(id, comment).into());
440        }
441        task.comments.remove(comment);
442        self.store.save(task)
443    }
444
445    /// Where the markdown export of `view` goes: `<data dir>/reports/<name>.md`.
446    pub fn report_path(&self, view: &ReportView) -> PathBuf {
447        self.store.reports_dir().join(export::file_name(view))
448    }
449
450    /// Writes the report as markdown and returns the path.
451    pub fn export_report(&self, view: &ReportView) -> Result<PathBuf> {
452        self.write_report(&self.report(view), view)
453    }
454
455    /// Writes `report`, made for `view`, to this brain's `reports/` as markdown and returns the path;
456    /// for a report computed elsewhere, such as several brains' merged.
457    pub fn write_report(&self, report: &super::Report, view: &ReportView) -> Result<PathBuf> {
458        let markdown = export::to_markdown(report, view, now());
459        let path = self.report_path(view);
460        crate::store::write_file(&path, &markdown)?;
461        Ok(path)
462    }
463}
464
465/// What a comment is to the task it's on.
466#[derive(Clone, Copy, PartialEq, Eq)]
467enum Said {
468    /// Anything said.
469    Comment,
470    /// A question the task then waits on.
471    Question,
472    /// The answer to the question it waits on.
473    Answer,
474}