1use super::{Brain, ReportView};
4use crate::api::ApiError;
5use crate::export;
6use crate::model::{
7 Asked, Comment, DecisionState, Kind, RepoLink, Status, Task, now, sanitize_comment, sanitize_description,
8};
9use crate::projects;
10use anyhow::Result;
11use std::path::PathBuf;
12
13const 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#[derive(Debug, Default)]
20pub struct NewEntry {
21 pub kind: Kind,
23 pub title: String,
25 pub tags: Vec<String>,
27 pub links: Vec<u32>,
29 pub repo_links: Vec<RepoLink>,
31 pub description: Option<String>,
33 pub start: bool,
35}
36
37impl Brain {
38 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 pub fn set_status(&mut self, id: u32, status: Status) -> Result<bool> {
50 let index = self.task_index(id)?;
51 if status == Status::Doing {
52 return self.start_task(index);
53 }
54 if !self.tasks[index].set_status(status, now(), self.author) {
55 return Ok(false);
56 }
57 self.store.save(&mut self.tasks[index])?;
58 Ok(true)
59 }
60
61 fn start_task(&mut self, index: usize) -> Result<bool> {
64 if self.tasks[index].is_running() {
65 return Ok(false);
66 }
67 let now = now();
68 for (i, task) in self.tasks.iter_mut().enumerate() {
69 if i != index && task.status == Status::Doing {
70 task.set_status(Status::Parked, now, self.author);
71 self.store.save(task)?;
72 }
73 }
74 self.tasks[index].set_status(Status::Doing, now, self.author);
75 self.tasks[index].start_session(now);
76 self.store.save(&mut self.tasks[index])?;
77 Ok(true)
78 }
79
80 pub fn set_project_description(&mut self, text: &str) -> Result<()> {
82 let (Some(project), Some(central)) = (&mut self.project, &self.central) else {
83 anyhow::bail!("only a project's repository has a project description");
84 };
85 project.description = text.trim().to_string();
86 projects::write_description(central, &project.name, &project.description)
87 }
88
89 pub fn set_repo_notes(&mut self, text: &str) -> Result<()> {
91 let Some(project) = &mut self.project else { anyhow::bail!("the central brain isn't a project's repository") };
92 project.notes = text.trim().to_string();
93 projects::write_repo_card(self.store.dir(), &project.name, &project.notes)
94 }
95
96 pub fn park_running(&mut self) -> Result<()> {
98 if let Some(index) = self.tasks.iter().position(Task::is_running) {
99 self.tasks[index].set_status(Status::Parked, now(), self.author);
100 self.store.save(&mut self.tasks[index])?;
101 }
102 Ok(())
103 }
104
105 pub(crate) fn create(&mut self, new: NewEntry) -> Result<u32> {
109 let mut task = Task::new(self.take_id()?, new.title, now());
110 task.kind = new.kind;
111 task.description = match new.description {
112 Some(text) => sanitize_description(&text).0,
113 None if task.kind == Kind::Decision => DECISION_TEMPLATE.to_string(),
114 None => String::new(),
115 };
116 task.tags = new.tags;
117 task.links = new.links;
118 task.repo_links = new.repo_links;
119 self.store.save(&mut task)?;
120 let id = task.id;
121 self.tasks.push(task);
122 self.sync_tag_colors()?;
123 if new.start && new.kind == Kind::Task {
124 self.set_status(id, Status::Doing)?;
125 }
126 Ok(id)
127 }
128
129 pub fn update(&mut self, id: u32, title: &str, tags: Vec<String>) -> Result<()> {
131 let title = title.trim();
132 if title.is_empty() {
133 return Err(ApiError::Empty("title").into());
134 }
135 let index = self.index_of(id)?;
136 title.clone_into(&mut self.tasks[index].title);
137 self.tasks[index].tags = tags;
138 self.store.save(&mut self.tasks[index])?;
139 self.sync_tag_colors()
140 }
141
142 pub fn delete(&mut self, id: u32) -> Result<()> {
145 let index = self.index_of(id)?;
146 for task in self.tasks.iter_mut().filter(|t| t.links.contains(&id)) {
147 task.links.retain(|&link| link != id);
148 self.store.save(task)?;
149 }
150 self.store.delete(&self.tasks[index])?;
151 self.tasks.remove(index);
152 Ok(())
153 }
154
155 pub fn move_to(&mut self, id: u32, to: &mut Brain) -> Result<u32> {
164 let index = self.index_of(id)?;
165 if projects::same_dir(self.store.dir(), to.store.dir()) {
166 anyhow::bail!("#{id} is already there");
167 }
168 let mut entry = Task {
169 id: to.take_id()?,
170 links: Vec::new(),
171 repo_links: Vec::new(),
172 path: None,
173 ..self.tasks[index].clone()
174 };
175 entry.unknown.links.clear();
176 if entry.status == Status::Doing {
177 to.park_running()?;
178 }
179 to.store.save(&mut entry)?;
180 let new_id = entry.id;
181 to.tasks.push(entry);
182 to.sync_tag_colors()?;
183 self.delete(id)?;
184 Ok(new_id)
185 }
186
187 pub fn drop_repo_links_to(&mut self, link: &RepoLink) -> Result<()> {
189 for task in self.tasks.iter_mut().filter(|t| t.repo_links.contains(link)) {
190 task.repo_links.retain(|l| l != link);
191 self.store.save(task)?;
192 }
193 Ok(())
194 }
195
196 pub fn toggle_repo_link(&mut self, id: u32, other: &mut Brain, other_id: u32) -> Result<bool> {
200 let (Some(here), Some(there)) = (&self.project, &other.project) else {
201 return Err(ApiError::NoRepo(other.store.dir().display().to_string()).into());
202 };
203 if here.name != there.name || here.repo == there.repo {
204 return Err(ApiError::NoRepo(there.repo.clone()).into());
205 }
206 let to = RepoLink { repo: there.repo.clone(), id: other_id };
207 let back = RepoLink { repo: here.repo.clone(), id };
208 let index = self.index_of(id)?;
209 let other_index = other.find(other_id).ok_or_else(|| ApiError::NotFoundIn(to.clone()))?;
210 let related = self.tasks[index].repo_links.contains(&to) || other.tasks[other_index].repo_links.contains(&back);
211 if !related {
212 self.tasks[index].repo_links.push(to);
213 self.store.save(&mut self.tasks[index])?;
214 return Ok(true);
215 }
216 for (brain, index, link) in [(&mut *self, index, &to), (&mut *other, other_index, &back)] {
217 let task = &mut brain.tasks[index];
218 if task.repo_links.contains(link) {
219 task.repo_links.retain(|l| l != link);
220 brain.store.save(task)?;
221 }
222 }
223 Ok(false)
224 }
225
226 pub fn toggle_link(&mut self, id: u32, other: u32) -> Result<bool> {
229 if id == other {
230 return Err(ApiError::SelfLink(id).into());
231 }
232 for side in [id, other] {
233 self.find(side).ok_or(ApiError::NotFound(side))?;
234 }
235 let related = self.are_related(id, other);
236 for (from, to) in [(id, other), (other, id)] {
237 let Some(index) = self.find(from) else { continue };
238 let task = &mut self.tasks[index];
239 let before = task.links.len();
240 if related {
241 task.links.retain(|&l| l != to);
242 } else if from == id {
243 task.links.push(to);
244 }
245 if task.links.len() != before {
246 self.store.save(task)?;
247 }
248 }
249 Ok(!related)
250 }
251
252 pub fn set_description(&mut self, id: u32, text: &str) -> Result<bool> {
254 let index = self.index_of(id)?;
255 let (text, demoted) = sanitize_description(text);
256 self.tasks[index].description = text;
257 self.store.save(&mut self.tasks[index])?;
258 Ok(demoted)
259 }
260
261 pub fn add_comment(&mut self, id: u32, text: &str) -> Result<bool> {
263 self.push_comment(id, text, false)
264 }
265
266 pub fn ask(&mut self, id: u32, question: &str) -> Result<bool> {
269 let index = self.task_index(id)?;
270 let demoted = self.push_comment(id, question, true)?;
271 if self.tasks[index].set_status(Status::Parked, now(), self.author) {
272 self.store.save(&mut self.tasks[index])?;
273 }
274 Ok(demoted)
275 }
276
277 pub fn propose(&mut self, id: u32, decision: NewEntry) -> Result<u32> {
281 self.task_index(id)?;
282 let mut links = decision.links;
283 if !links.contains(&id) {
284 links.insert(0, id);
285 }
286 let title = decision.title.trim().to_string();
287 let new = NewEntry { kind: Kind::Decision, title: title.clone(), links, start: false, ..decision };
288 let decision = self.add(new)?;
289 let index = self.index_of(decision)?;
290 self.tasks[index].decision = DecisionState::Proposed;
291 self.store.save(&mut self.tasks[index])?;
292 self.ask(id, &format!("Proposed decision #{decision}, {title}: accept or reject it."))?;
293 Ok(decision)
294 }
295
296 pub fn resolve(&mut self, id: u32, state: DecisionState, reason: &str) -> Result<bool> {
300 let index = self.index_of(id)?;
301 let decision = &self.tasks[index];
302 if decision.kind != Kind::Decision {
303 return Err(ApiError::NotADecision(id, decision.kind).into());
304 }
305 if decision.decision == state {
306 return Ok(false);
307 }
308 if state == DecisionState::Rejected && reason.trim().is_empty() {
309 return Err(ApiError::Empty("reason").into());
310 }
311 let title = decision.title.clone();
312 let verdict = match state {
313 DecisionState::Accepted => "Accepted",
314 DecisionState::Rejected => "Rejected",
315 DecisionState::Proposed => "Proposed again",
316 };
317 let said = |what: &str| match reason.trim() {
318 "" => format!("{what}."),
319 reason => format!("{what}: {reason}"),
320 };
321 self.tasks[index].decision = state;
322 self.store.save(&mut self.tasks[index])?;
323 self.push_comment(id, &said(verdict), false)?;
324 let waiting: Vec<u32> = self
325 .tasks
326 .iter()
327 .filter(|t| matches!(t.asked(), Some(Asked::Waiting(_))) && self.are_related(t.id, id))
328 .map(|t| t.id)
329 .collect();
330 for task in waiting {
331 self.push_comment(task, &said(&format!("{verdict} decision #{id}, {title}")), false)?;
332 }
333 Ok(true)
334 }
335
336 fn push_comment(&mut self, id: u32, text: &str, question: bool) -> Result<bool> {
338 let index = self.index_of(id)?;
339 let (body, demoted) = sanitize_comment(text);
340 if body.is_empty() {
341 return Err(ApiError::Empty(if question { "question" } else { "comment" }).into());
342 }
343 let comment = Comment { question, ..Comment::new(now(), body, self.author) };
344 self.tasks[index].comments.push(comment);
345 self.store.save(&mut self.tasks[index])?;
346 Ok(demoted)
347 }
348
349 pub fn update_comment(&mut self, id: u32, comment: usize, text: &str) -> Result<bool> {
352 let index = self.index_of(id)?;
353 let (body, demoted) = sanitize_comment(text);
354 if body.is_empty() {
355 return Err(ApiError::Empty("comment").into());
356 }
357 let task = &mut self.tasks[index];
358 let Some(existing) = task.comments.get_mut(comment) else {
359 return Err(ApiError::NoComment(id, comment).into());
360 };
361 if existing.body != body {
362 existing.body = body;
363 existing.edited = Some(now());
364 self.store.save(task)?;
365 }
366 Ok(demoted)
367 }
368
369 pub fn delete_comment(&mut self, id: u32, comment: usize) -> Result<()> {
371 let index = self.index_of(id)?;
372 let task = &mut self.tasks[index];
373 if comment >= task.comments.len() {
374 return Err(ApiError::NoComment(id, comment).into());
375 }
376 task.comments.remove(comment);
377 self.store.save(task)
378 }
379
380 pub fn report_path(&self, view: &ReportView) -> PathBuf {
382 self.store.reports_dir().join(export::file_name(view))
383 }
384
385 pub fn export_report(&self, view: &ReportView) -> Result<PathBuf> {
387 self.write_report(&self.report(view), view)
388 }
389
390 pub fn write_report(&self, report: &super::Report, view: &ReportView) -> Result<PathBuf> {
393 let markdown = export::to_markdown(report, view, now());
394 let path = self.report_path(view);
395 crate::store::write_file(&path, &markdown)?;
396 Ok(path)
397 }
398}