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