Skip to main content

kanbrain_core/api/
text.rs

1//! Entries as compact text for agents: what `show`, `search` and the write commands print. Short on purpose, since agents pay for every token they read: dates without
2//! seconds or offsets, empty sections left out, sessions as one total, and only the latest history.
3//! The full record stays in the entry's file, whose path `show` gives.
4
5use super::ApiError;
6use crate::brain::Brain;
7use crate::model::{Asked, Author, Comment, DecisionState, Task, fmt_age, fmt_dur, now};
8use chrono::{DateTime, Local};
9use std::fmt::Write;
10
11/// Status changes `show` lists, latest last; the rest are counted.
12const HISTORY_LIMIT: usize = 5;
13/// Results `search` lists, best first; the rest are counted.
14pub const SEARCH_LIMIT: usize = 20;
15
16/// One line naming an entry: `#4 Fix login (doing) #auth`, with the kind instead of a status for
17/// notes and decisions. A question it waits on, and a decision not in force, are said too:
18/// `(parked, waiting for an answer)`, `(decision, proposed)`.
19pub fn line(task: &Task) -> String {
20    let mut state = if task.is_task() { task.status.as_str() } else { task.kind.as_str() }.to_string();
21    if let Some(also) = state_note(task) {
22        write!(state, ", {also}").expect("writing to a String cannot fail");
23    }
24    let mut line = format!("#{} {} ({state})", task.id, task.title);
25    for tag in &task.tags {
26        write!(line, " #{tag}").expect("writing to a String cannot fail");
27    }
28    line
29}
30
31/// What an entry's status alone doesn't say: that a parked task waits for an answer or got one, or that a
32/// decision is proposed or rejected.
33pub fn state_note(task: &Task) -> Option<&'static str> {
34    match task.asked() {
35        Some(Asked::Waiting(_)) => return Some("waiting for an answer"),
36        Some(Asked::Answered(_)) => return Some("answered"),
37        None => {}
38    }
39    match task.decision {
40        DecisionState::Accepted => None,
41        state => Some(state.as_str()),
42    }
43}
44
45/// A comment's heading in `show`: when, and who and what when it's an agent's or a question.
46fn comment_heading(comment: &Comment) -> String {
47    let mut heading = when(comment.created);
48    if comment.author == Author::Agent {
49        heading.push_str(" · agent");
50    }
51    if comment.question {
52        heading.push_str(" · question");
53    }
54    if let Some(edited) = comment.edited {
55        write!(heading, " (edited {})", when(edited)).expect("writing to a String cannot fail");
56    }
57    heading
58}
59
60/// A time as `2026-10-08 10:40`: minutes are precise enough to act on, and the files keep the rest.
61fn when(at: DateTime<Local>) -> String {
62    at.format("%Y-%m-%d %H:%M").to_string()
63}
64
65impl Brain {
66    /// The entry for an agent to read: state, description, comments, related entries, the latest
67    /// status changes and the time tracked.
68    pub fn entry_text(&self, id: u32) -> Result<String, ApiError> {
69        let task = self.entry(id)?;
70        let mut out = String::new();
71        self.write_entry(&mut out, task).expect("writing to a String cannot fail");
72        Ok(out)
73    }
74
75    /// Search results as lines, best first, at most `SEARCH_LIMIT`, then how many more matched.
76    pub fn search_text(&self, query: &str) -> String {
77        let found = self.search(query);
78        if found.is_empty() {
79            return "nothing found\n".to_string();
80        }
81        let mut out = String::new();
82        for task in found.iter().take(SEARCH_LIMIT) {
83            writeln!(out, "- {}", line(task)).expect("writing to a String cannot fail");
84        }
85        if let Some(more) = found.len().checked_sub(SEARCH_LIMIT).filter(|&n| n > 0) {
86            writeln!(out, "- … {more} more: add words to narrow it down").expect("writing to a String cannot fail");
87        }
88        out
89    }
90
91    /// Writes `task` for `entry_text`.
92    fn write_entry(&self, out: &mut String, task: &Task) -> std::fmt::Result {
93        let now = now();
94        writeln!(out, "# {}", line(task))?;
95        let mut facts = vec![format!("created {}", when(task.created))];
96        if task.is_task() && !task.history.is_empty() {
97            facts.push(format!("{} for {}", task.status.as_str(), fmt_age(now - task.status_since())));
98        }
99        if !task.sessions.is_empty() {
100            let running = task.running_for(now).map(|r| format!(", running for {}", fmt_dur(r))).unwrap_or_default();
101            facts.push(format!("tracked {} in {} session(s){running}", fmt_dur(task.total(now)), task.sessions.len()));
102        }
103        writeln!(out, "{}", facts.join(" · "))?;
104        if let Some(path) = &task.path {
105            writeln!(out, "file: {}", path.display())?;
106        }
107        if !task.description.trim().is_empty() {
108            writeln!(out, "\n## Description\n\n{}", task.description.trim())?;
109        }
110        if !task.comments.is_empty() {
111            writeln!(out, "\n## Comments")?;
112            for comment in &task.comments {
113                writeln!(out, "\n### {}\n\n{}", comment_heading(comment), comment.body.trim())?;
114            }
115        }
116        let related = self.related(task.id);
117        let elsewhere = self.repo_related(task.id);
118        if !related.is_empty() || !elsewhere.is_empty() {
119            writeln!(out, "\n## Related\n")?;
120            for other in related {
121                writeln!(out, "- {}", line(other))?;
122            }
123            for (repo, other) in &elsewhere {
124                writeln!(out, "- {repo}{}", line(other))?;
125            }
126        }
127        if !task.history.is_empty() {
128            let skipped = task.history.len().saturating_sub(HISTORY_LIMIT);
129            let heading = if skipped > 0 {
130                format!("History (latest {HISTORY_LIMIT} of {})", task.history.len())
131            } else {
132                "History".to_string()
133            };
134            writeln!(out, "\n## {heading}\n")?;
135            for change in &task.history[skipped..] {
136                let by = if change.by == Author::Agent { " (agent)" } else { "" };
137                writeln!(out, "- {} {} -> {}{by}", when(change.at), change.from.as_str(), change.to.as_str())?;
138            }
139        }
140        Ok(())
141    }
142}