1use 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
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> {
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 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 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 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 let repo = projects::read_repo_card(self.store.dir()).and_then(|card| card.repo);
92 let card = projects::RepoCard { project: project.name.clone(), repo, notes: project.notes.clone() };
93 projects::write_repo_card(self.store.dir(), &card)
94 }
95
96 fn check_doing_room(&self, by: Author) -> Result<(), ApiError> {
99 match self.person_doing() {
100 Some(doing) if by == Author::Human => Err(ApiError::DoingFull(doing)),
101 _ => Ok(()),
102 }
103 }
104
105 pub fn park_people_running(&mut self) -> Result<()> {
108 let now = now();
109 for task in self.tasks.iter_mut().filter(|t| t.is_running() && t.started_by() == Author::Human) {
110 task.set_status(Status::Parked, now, self.author);
111 self.store.save(task)?;
112 }
113 Ok(())
114 }
115
116 pub(crate) fn create(&mut self, new: NewEntry) -> Result<u32> {
120 if new.start && new.kind == Kind::Task {
122 self.check_doing_room(self.author)?;
123 }
124 let mut task = Task::new(self.take_id()?, new.title, now());
125 task.kind = new.kind;
126 task.description = match new.description {
127 Some(text) => sanitize_description(&text).0,
128 None if task.kind == Kind::Decision => DECISION_TEMPLATE.to_string(),
129 None => String::new(),
130 };
131 task.tags = new.tags;
132 task.links = new.links;
133 task.repo_links = new.repo_links;
134 self.store.save(&mut task)?;
135 let id = task.id;
136 self.tasks.push(task);
137 self.sync_tag_colors()?;
138 if new.start && new.kind == Kind::Task {
139 self.set_status(id, Status::Doing)?;
140 }
141 Ok(id)
142 }
143
144 pub fn update(&mut self, id: u32, title: &str, tags: Vec<String>) -> Result<()> {
146 let title = title.trim();
147 if title.is_empty() {
148 return Err(ApiError::Empty("title").into());
149 }
150 let index = self.index_of(id)?;
151 title.clone_into(&mut self.tasks[index].title);
152 self.tasks[index].tags = tags;
153 self.store.save(&mut self.tasks[index])?;
154 self.sync_tag_colors()
155 }
156
157 pub fn delete(&mut self, id: u32) -> Result<()> {
160 let index = self.index_of(id)?;
161 for task in self.tasks.iter_mut().filter(|t| t.links.contains(&id)) {
162 task.links.retain(|&link| link != id);
163 self.store.save(task)?;
164 }
165 self.store.delete(&self.tasks[index])?;
166 self.tasks.remove(index);
167 Ok(())
168 }
169
170 pub fn move_to(&mut self, id: u32, to: &mut Brain) -> Result<u32> {
179 let index = self.index_of(id)?;
180 if projects::same_dir(self.store.dir(), to.store.dir()) {
181 anyhow::bail!("#{id} is already there");
182 }
183 if self.tasks[index].status == Status::Doing {
184 to.check_doing_room(self.tasks[index].started_by())?;
185 }
186 let mut entry = Task {
187 id: to.take_id()?,
188 links: Vec::new(),
189 repo_links: Vec::new(),
190 path: None,
191 ..self.tasks[index].clone()
192 };
193 entry.unknown.links.clear();
194 to.store.save(&mut entry)?;
195 let new_id = entry.id;
196 to.tasks.push(entry);
197 to.sync_tag_colors()?;
198 self.delete(id)?;
199 Ok(new_id)
200 }
201
202 pub fn drop_repo_links_to(&mut self, link: &RepoLink) -> Result<()> {
204 for task in self.tasks.iter_mut().filter(|t| t.repo_links.contains(link)) {
205 task.repo_links.retain(|l| l != link);
206 self.store.save(task)?;
207 }
208 Ok(())
209 }
210
211 pub fn toggle_repo_link(&mut self, id: u32, other: &mut Brain, other_id: u32) -> Result<bool> {
215 let (Some(here), Some(there)) = (&self.project, &other.project) else {
216 return Err(ApiError::NoRepo(other.store.dir().display().to_string()).into());
217 };
218 if here.name != there.name || here.repo == there.repo {
219 return Err(ApiError::NoRepo(there.repo.clone()).into());
220 }
221 let to = RepoLink { repo: there.repo.clone(), id: other_id };
222 let back = RepoLink { repo: here.repo.clone(), id };
223 let index = self.index_of(id)?;
224 let other_index = other.find(other_id).ok_or_else(|| ApiError::NotFoundIn(to.clone()))?;
225 let related = self.tasks[index].repo_links.contains(&to) || other.tasks[other_index].repo_links.contains(&back);
226 if !related {
227 self.tasks[index].repo_links.push(to);
228 self.store.save(&mut self.tasks[index])?;
229 return Ok(true);
230 }
231 for (brain, index, link) in [(&mut *self, index, &to), (&mut *other, other_index, &back)] {
232 let task = &mut brain.tasks[index];
233 if task.repo_links.contains(link) {
234 task.repo_links.retain(|l| l != link);
235 brain.store.save(task)?;
236 }
237 }
238 Ok(false)
239 }
240
241 pub fn toggle_link(&mut self, id: u32, other: u32) -> Result<bool> {
244 if id == other {
245 return Err(ApiError::SelfLink(id).into());
246 }
247 for side in [id, other] {
248 self.find(side).ok_or(ApiError::NotFound(side))?;
249 }
250 let related = self.are_related(id, other);
251 for (from, to) in [(id, other), (other, id)] {
252 let Some(index) = self.find(from) else { continue };
253 let task = &mut self.tasks[index];
254 let before = task.links.len();
255 if related {
256 task.links.retain(|&l| l != to);
257 } else if from == id {
258 task.links.push(to);
259 }
260 if task.links.len() != before {
261 self.store.save(task)?;
262 }
263 }
264 Ok(!related)
265 }
266
267 pub fn set_description(&mut self, id: u32, text: &str) -> Result<bool> {
269 let index = self.index_of(id)?;
270 let (text, demoted) = sanitize_description(text);
271 self.tasks[index].description = text;
272 self.store.save(&mut self.tasks[index])?;
273 Ok(demoted)
274 }
275
276 pub fn add_comment(&mut self, id: u32, text: &str) -> Result<bool> {
278 self.push_comment(id, text, false)
279 }
280
281 pub fn ask(&mut self, id: u32, question: &str) -> Result<bool> {
284 let index = self.task_index(id)?;
285 let demoted = self.push_comment(id, question, true)?;
286 if self.tasks[index].set_status(Status::Parked, now(), self.author) {
287 self.store.save(&mut self.tasks[index])?;
288 }
289 Ok(demoted)
290 }
291
292 pub fn propose(&mut self, id: u32, decision: NewEntry) -> Result<u32> {
296 self.task_index(id)?;
297 let mut links = decision.links;
298 if !links.contains(&id) {
299 links.insert(0, id);
300 }
301 let title = decision.title.trim().to_string();
302 let new = NewEntry { kind: Kind::Decision, title: title.clone(), links, start: false, ..decision };
303 let decision = self.add(new)?;
304 let index = self.index_of(decision)?;
305 self.tasks[index].decision = DecisionState::Proposed;
306 self.store.save(&mut self.tasks[index])?;
307 self.ask(id, &format!("Proposed decision #{decision}, {title}: accept or reject it."))?;
308 Ok(decision)
309 }
310
311 pub fn resolve(&mut self, id: u32, state: DecisionState, reason: &str) -> Result<bool> {
315 let index = self.index_of(id)?;
316 let decision = &self.tasks[index];
317 if decision.kind != Kind::Decision {
318 return Err(ApiError::NotADecision(id, decision.kind).into());
319 }
320 if decision.decision == state {
321 return Ok(false);
322 }
323 if state == DecisionState::Rejected && reason.trim().is_empty() {
324 return Err(ApiError::Empty("reason").into());
325 }
326 let title = decision.title.clone();
327 let verdict = match state {
328 DecisionState::Accepted => "Accepted",
329 DecisionState::Rejected => "Rejected",
330 DecisionState::Proposed => "Proposed again",
331 };
332 let said = |what: &str| match reason.trim() {
333 "" => format!("{what}."),
334 reason => format!("{what}: {reason}"),
335 };
336 self.tasks[index].decision = state;
337 self.store.save(&mut self.tasks[index])?;
338 self.push_comment(id, &said(verdict), false)?;
339 let waiting: Vec<u32> = self
340 .tasks
341 .iter()
342 .filter(|t| matches!(t.asked(), Some(Asked::Waiting(_))) && self.are_related(t.id, id))
343 .map(|t| t.id)
344 .collect();
345 for task in waiting {
346 self.push_comment(task, &said(&format!("{verdict} decision #{id}, {title}")), false)?;
347 }
348 Ok(true)
349 }
350
351 fn push_comment(&mut self, id: u32, text: &str, question: bool) -> Result<bool> {
353 let index = self.index_of(id)?;
354 let (body, demoted) = sanitize_comment(text);
355 if body.is_empty() {
356 return Err(ApiError::Empty(if question { "question" } else { "comment" }).into());
357 }
358 let comment = Comment { question, ..Comment::new(now(), body, self.author) };
359 self.tasks[index].comments.push(comment);
360 self.store.save(&mut self.tasks[index])?;
361 Ok(demoted)
362 }
363
364 pub fn update_comment(&mut self, id: u32, comment: usize, text: &str) -> Result<bool> {
367 let index = self.index_of(id)?;
368 let (body, demoted) = sanitize_comment(text);
369 if body.is_empty() {
370 return Err(ApiError::Empty("comment").into());
371 }
372 let task = &mut self.tasks[index];
373 let Some(existing) = task.comments.get_mut(comment) else {
374 return Err(ApiError::NoComment(id, comment).into());
375 };
376 if existing.body != body {
377 existing.body = body;
378 existing.edited = Some(now());
379 self.store.save(task)?;
380 }
381 Ok(demoted)
382 }
383
384 pub fn delete_comment(&mut self, id: u32, comment: usize) -> Result<()> {
386 let index = self.index_of(id)?;
387 let task = &mut self.tasks[index];
388 if comment >= task.comments.len() {
389 return Err(ApiError::NoComment(id, comment).into());
390 }
391 task.comments.remove(comment);
392 self.store.save(task)
393 }
394
395 pub fn report_path(&self, view: &ReportView) -> PathBuf {
397 self.store.reports_dir().join(export::file_name(view))
398 }
399
400 pub fn export_report(&self, view: &ReportView) -> Result<PathBuf> {
402 self.write_report(&self.report(view), view)
403 }
404
405 pub fn write_report(&self, report: &super::Report, view: &ReportView) -> Result<PathBuf> {
408 let markdown = export::to_markdown(report, view, now());
409 let path = self.report_path(view);
410 crate::store::write_file(&path, &markdown)?;
411 Ok(path)
412 }
413}