Skip to main content

kanbrain_core/api/
mod.rs

1//! Operations by entry id, for the command line: what an agent (or a script)
2//! does without a screen. They go through the same `Brain` actions as the desktop app, so tracking
3//! rules, tag colors and file names behave exactly as there.
4//!
5//! - [`json`]: entries as JSON, for scripts.
6//! - [`text`]: entries as compact text, for agents.
7//! - [`context`]: the markdown summary an agent reads at the start of a session.
8
9mod context;
10pub mod json;
11pub mod text;
12
13use crate::brain::{Brain, NewEntry};
14use crate::model::{Kind, RepoLink, Status, Task, now, one_line};
15use anyhow::Result;
16use std::fmt;
17
18/// Why an operation by id was refused, as the CLI prints it.
19#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum ApiError {
22    /// No entry has this id.
23    NotFound(u32),
24    /// Only tasks have a status; this entry is a note or decision.
25    NotATask(u32, Kind),
26    /// Only decisions are accepted or rejected; this entry is a task or note.
27    NotADecision(u32, Kind),
28    /// A title, comment or description that is empty once trimmed.
29    Empty(&'static str),
30    /// A link from an entry to itself.
31    SelfLink(u32),
32    /// The entry has no comment at this index (counting from 0).
33    NoComment(u32, usize),
34    /// A link to a repository that isn't another one of this brain's project, or can't be read.
35    NoRepo(String),
36    /// A link to an entry that the other repository doesn't have.
37    NotFoundIn(RepoLink),
38    /// A person starting a task while `doing` holds the one a person started: its id.
39    DoingFull(u32),
40    /// No entry has this tag.
41    NoTag(String),
42}
43
44impl fmt::Display for ApiError {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            ApiError::NotFound(id) => write!(f, "no entry #{id}"),
48            ApiError::NotATask(id, kind) => write!(f, "#{id} is a {}: only tasks have a status", kind.as_str()),
49            ApiError::NotADecision(id, kind) => {
50                write!(f, "#{id} is a {}: only decisions are accepted or rejected", kind.as_str())
51            }
52            ApiError::Empty(what) => write!(f, "the {what} is empty"),
53            ApiError::SelfLink(id) => write!(f, "#{id} can't link to itself"),
54            ApiError::NoComment(id, index) => write!(f, "#{id} has no comment {}", index + 1),
55            ApiError::NoRepo(repo) => write!(f, "`{repo}` isn't another repository of this project"),
56            ApiError::NotFoundIn(link) => write!(f, "no entry {link}"),
57            ApiError::DoingFull(id) => {
58                write!(f, "#{id} is in doing, and a person works on one task at a time: park or finish it first")
59            }
60            ApiError::NoTag(tag) => write!(f, "no entry is tagged #{tag}"),
61        }
62    }
63}
64
65impl std::error::Error for ApiError {}
66
67impl Brain {
68    /// The entry with this id, or `NotFound`.
69    pub fn entry(&self, id: u32) -> Result<&Task, ApiError> {
70        self.task(id).ok_or(ApiError::NotFound(id))
71    }
72
73    /// Creates an entry and returns its id. Links must point at existing entries.
74    pub fn add(&mut self, new: NewEntry) -> Result<u32> {
75        if new.title.trim().is_empty() {
76            return Err(ApiError::Empty("title").into());
77        }
78        for &link in &new.links {
79            self.entry(link)?;
80        }
81        self.check_repo_links(&new.repo_links)?;
82        let new = NewEntry { title: one_line(&new.title), ..new };
83        self.create(new)
84    }
85
86    /// Moves a task to `status`; `doing` starts its clock (refused for a person who already runs a task).
87    pub fn change_status(&mut self, id: u32, status: Status) -> Result<()> {
88        self.set_status(id, status).map(drop)
89    }
90
91    /// Adds a timestamped comment, e.g. progress notes from an agent.
92    pub fn comment(&mut self, id: u32, text: &str) -> Result<()> {
93        self.add_comment(id, text).map(drop)
94    }
95
96    /// Replaces the description.
97    pub fn describe(&mut self, id: u32, text: &str) -> Result<()> {
98        self.set_description(id, text).map(drop)
99    }
100
101    /// Adds links from `id` to each of `others` and of `repo_links` (entries of the project's other
102    /// repositories), skipping ones it already has. Each entry of this brain it wasn't related to logs it on both
103    /// sides; each link to another repository's entry that it didn't have, here only, as that file isn't written.
104    pub fn link(&mut self, id: u32, others: &[u32], repo_links: &[RepoLink]) -> Result<()> {
105        for &other in others {
106            if other == id {
107                return Err(ApiError::SelfLink(id).into());
108            }
109            self.entry(other)?;
110        }
111        self.check_repo_links(repo_links)?;
112        let index = self.index_of(id)?;
113        let mut new: Vec<u32> = Vec::new();
114        for &other in others {
115            if !self.are_related(id, other) && !new.contains(&other) {
116                new.push(other);
117            }
118        }
119        let now = now();
120        let task = &mut self.tasks[index];
121        let mut added: Vec<String> = new.iter().map(u32::to_string).collect();
122        for &other in others {
123            if !task.links.contains(&other) {
124                task.links.push(other);
125            }
126        }
127        for link in repo_links {
128            if !task.repo_links.contains(link) {
129                task.repo_links.push(link.clone());
130                added.push(link.to_string());
131            }
132        }
133        task.relate(now, self.author, &added, &[]);
134        self.store.save(task)?;
135        // After the entry that keeps the links: a save that fails before leaves no side saying they're related.
136        for &other in &new {
137            let back = self.index_of(other)?;
138            self.tasks[back].relate(now, self.author, &[id.to_string()], &[]);
139            self.store.save(&mut self.tasks[back])?;
140        }
141        Ok(())
142    }
143
144    /// Checks that each link names another repository of this project, and an entry it has.
145    fn check_repo_links(&self, links: &[RepoLink]) -> Result<(), ApiError> {
146        for link in links {
147            let other = self.sibling(&link.repo).ok_or_else(|| ApiError::NoRepo(link.repo.clone()))?;
148            other.task(link.id).ok_or_else(|| ApiError::NotFoundIn(link.clone()))?;
149        }
150        Ok(())
151    }
152
153    /// Entries matching every whitespace-separated term in their title, tags, description or comments
154    /// (`#term` matches a tag prefix only), case-insensitive; tasks not done first, then newest first.
155    pub fn search(&self, query: &str) -> Vec<&Task> {
156        let terms: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
157        let mut found: Vec<&Task> =
158            self.tasks.iter().filter(|t| terms.iter().all(|term| t.has_term(term, true))).collect();
159        found.sort_by_key(|t| (t.is_task() && t.status == Status::Done, std::cmp::Reverse(t.id)));
160        found
161    }
162}
163
164#[cfg(test)]
165#[path = "../../tests/api.rs"]
166mod tests;