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