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, parse_tags};
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)]
20pub enum ApiError {
21    /// No entry has this id.
22    NotFound(u32),
23    /// Only tasks have a status; this entry is a note or decision.
24    NotATask(u32, Kind),
25    /// Only decisions are accepted or rejected; this entry is a task or note.
26    NotADecision(u32, Kind),
27    /// A title, comment or description that is empty once trimmed.
28    Empty(&'static str),
29    /// A link from an entry to itself.
30    SelfLink(u32),
31    /// The entry has no comment at this index (counting from 0).
32    NoComment(u32, usize),
33    /// A link to a repository that isn't another one of this brain's project, or can't be read.
34    NoRepo(String),
35    /// A link to an entry that the other repository doesn't have.
36    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    /// The entry with this id, or `NotFound`.
60    pub fn entry(&self, id: u32) -> Result<&Task, ApiError> {
61        self.task(id).ok_or(ApiError::NotFound(id))
62    }
63
64    /// Creates an entry and returns its id. Links must point at existing entries.
65    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    /// Moves a task to `status`. `doing` starts its clock and parks the running task, whose id it
78    /// returns, so the agent learns its move set another task aside.
79    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    /// Adds a timestamped comment, e.g. progress notes from an agent.
86    pub fn comment(&mut self, id: u32, text: &str) -> Result<()> {
87        self.add_comment(id, text).map(drop)
88    }
89
90    /// Replaces the description.
91    pub fn describe(&mut self, id: u32, text: &str) -> Result<()> {
92        self.set_description(id, text).map(drop)
93    }
94
95    /// Adds links from `id` to each of `others` and of `repo_links` (entries of the project's other
96    /// repositories), skipping ones it already has.
97    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    /// Checks that each link names another repository of this project, and an entry it has.
121    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    /// Entries matching every whitespace-separated term in their title, tags, description or comments
130    /// (`#term` matches a tag prefix only), case-insensitive; tasks not done first, then newest first.
131    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
139/// Whether the entry mentions `term` (already lowercase): `#tag` matches a tag prefix only,
140/// anything else the title, a tag, the description or a comment.
141fn 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;