1mod context;
10pub mod json;
11pub mod text;
12
13use crate::brain::{Brain, NewEntry};
14use crate::model::{Kind, RepoLink, Status, Task, parse_tags};
15use anyhow::Result;
16use std::fmt;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ApiError {
21 NotFound(u32),
23 NotATask(u32, Kind),
25 NotADecision(u32, Kind),
27 Empty(&'static str),
29 SelfLink(u32),
31 NoComment(u32, usize),
33 NoRepo(String),
35 NotFoundIn(RepoLink),
37}
38
39impl fmt::Display for ApiError {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 match self {
42 ApiError::NotFound(id) => write!(f, "no entry #{id}"),
43 ApiError::NotATask(id, kind) => write!(f, "#{id} is a {}: only tasks have a status", kind.as_str()),
44 ApiError::NotADecision(id, kind) => {
45 write!(f, "#{id} is a {}: only decisions are accepted or rejected", kind.as_str())
46 }
47 ApiError::Empty(what) => write!(f, "the {what} is empty"),
48 ApiError::SelfLink(id) => write!(f, "#{id} can't link to itself"),
49 ApiError::NoComment(id, index) => write!(f, "#{id} has no comment {}", index + 1),
50 ApiError::NoRepo(repo) => write!(f, "`{repo}` isn't another repository of this project"),
51 ApiError::NotFoundIn(link) => write!(f, "no entry {link}"),
52 }
53 }
54}
55
56impl std::error::Error for ApiError {}
57
58impl Brain {
59 pub fn entry(&self, id: u32) -> Result<&Task, ApiError> {
61 self.task(id).ok_or(ApiError::NotFound(id))
62 }
63
64 pub fn add(&mut self, new: NewEntry) -> Result<u32> {
66 if new.title.trim().is_empty() {
67 return Err(ApiError::Empty("title").into());
68 }
69 for &link in &new.links {
70 self.entry(link)?;
71 }
72 self.check_repo_links(&new.repo_links)?;
73 let new = NewEntry { title: new.title.trim().to_string(), ..new };
74 self.create(new)
75 }
76
77 pub fn change_status(&mut self, id: u32, status: Status) -> Result<Option<u32>> {
80 let running = self.running().map(|t| t.id).filter(|&r| r != id && status == Status::Doing);
81 self.set_status(id, status)?;
82 Ok(running)
83 }
84
85 pub fn comment(&mut self, id: u32, text: &str) -> Result<()> {
87 self.add_comment(id, text).map(drop)
88 }
89
90 pub fn describe(&mut self, id: u32, text: &str) -> Result<()> {
92 self.set_description(id, text).map(drop)
93 }
94
95 pub fn link(&mut self, id: u32, others: &[u32], repo_links: &[RepoLink]) -> Result<()> {
98 for &other in others {
99 if other == id {
100 return Err(ApiError::SelfLink(id).into());
101 }
102 self.entry(other)?;
103 }
104 self.check_repo_links(repo_links)?;
105 let index = self.index_of(id)?;
106 let task = &mut self.tasks[index];
107 for &other in others {
108 if !task.links.contains(&other) {
109 task.links.push(other);
110 }
111 }
112 for link in repo_links {
113 if !task.repo_links.contains(link) {
114 task.repo_links.push(link.clone());
115 }
116 }
117 self.store.save(task)
118 }
119
120 fn check_repo_links(&self, links: &[RepoLink]) -> Result<(), ApiError> {
122 for link in links {
123 let other = self.sibling(&link.repo).ok_or_else(|| ApiError::NoRepo(link.repo.clone()))?;
124 other.task(link.id).ok_or_else(|| ApiError::NotFoundIn(link.clone()))?;
125 }
126 Ok(())
127 }
128
129 pub fn search(&self, query: &str) -> Vec<&Task> {
132 let terms: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
133 let mut found: Vec<&Task> = self.tasks.iter().filter(|t| terms.iter().all(|term| mentions(t, term))).collect();
134 found.sort_by_key(|t| (t.is_task() && t.status == Status::Done, std::cmp::Reverse(t.id)));
135 found
136 }
137}
138
139fn mentions(task: &Task, term: &str) -> bool {
142 match term.strip_prefix('#') {
143 Some("") => true,
144 Some(tag) => parse_tags(tag).first().is_some_and(|tag| task.tags.iter().any(|t| t.starts_with(tag))),
145 None => {
146 task.title.to_lowercase().contains(term)
147 || task.tags.iter().any(|t| t.contains(term))
148 || task.description.to_lowercase().contains(term)
149 || task.comments.iter().any(|c| c.body.to_lowercase().contains(term))
150 }
151 }
152}
153
154#[cfg(test)]
155#[path = "../../tests/api.rs"]
156mod tests;