1use 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
14const 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#[derive(Debug, Default)]
21pub struct NewEntry {
22 pub kind: Kind,
24 pub title: String,
26 pub tags: Vec<String>,
28 pub links: Vec<u32>,
30 pub repo_links: Vec<RepoLink>,
32 pub description: Option<String>,
34 pub start: bool,
36}
37
38impl Brain {
39 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 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 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 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 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 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 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 pub(crate) fn create(&mut self, new: NewEntry) -> Result<u32> {
121 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 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 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 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 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 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 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 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 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 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 pub fn add_comment(&mut self, id: u32, text: &str) -> Result<bool> {
313 self.push_comment(id, text, Said::Comment)
314 }
315
316 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 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 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 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 && 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 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 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 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 pub fn report_path(&self, view: &ReportView) -> PathBuf {
447 self.store.reports_dir().join(export::file_name(view))
448 }
449
450 pub fn export_report(&self, view: &ReportView) -> Result<PathBuf> {
452 self.write_report(&self.report(view), view)
453 }
454
455 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#[derive(Clone, Copy, PartialEq, Eq)]
467enum Said {
468 Comment,
470 Question,
472 Answer,
474}