kanbrain_core/api/
text.rs1use super::ApiError;
6use crate::brain::Brain;
7use crate::model::{Author, Comment, DecisionState, Task, counted, fmt_age, fmt_dur, now};
8use chrono::{DateTime, Local};
9use std::fmt::Write;
10
11const HISTORY_LIMIT: usize = 5;
13pub const SEARCH_LIMIT: usize = 20;
15
16pub 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
31pub fn head(out: &mut String, task: &Task) -> std::fmt::Result {
33 write!(out, "#{} {}", task.id, task.title)?;
34 task.tags.iter().try_for_each(|tag| write!(out, " #{tag}"))
35}
36
37pub fn state_note(task: &Task) -> Option<&'static str> {
40 if let Some(asked) = task.asked() {
41 return Some(asked.label());
42 }
43 match task.decision {
44 DecisionState::Accepted => None,
45 state => Some(state.as_str()),
46 }
47}
48
49fn comment_heading(comment: &Comment) -> String {
51 let mut heading = when(comment.created);
52 if comment.author == Author::Agent {
53 heading.push_str(" · agent");
54 }
55 if comment.question {
56 heading.push_str(" · question");
57 }
58 if let Some(edited) = comment.edited {
59 write!(heading, " (edited {})", when(edited)).expect("writing to a String cannot fail");
60 }
61 heading
62}
63
64fn when(at: DateTime<Local>) -> String {
66 at.format("%Y-%m-%d %H:%M").to_string()
67}
68
69impl Brain {
70 pub fn entry_text(&self, id: u32) -> Result<String, ApiError> {
73 let task = self.entry(id)?;
74 let mut out = String::new();
75 self.write_entry(&mut out, task).expect("writing to a String cannot fail");
76 Ok(out)
77 }
78
79 pub fn search_text(&self, query: &str) -> String {
81 let found = self.search(query);
82 if found.is_empty() {
83 return "nothing found\n".to_string();
84 }
85 let mut out = String::new();
86 for task in found.iter().take(SEARCH_LIMIT) {
87 writeln!(out, "- {}", line(task)).expect("writing to a String cannot fail");
88 }
89 if let Some(more) = found.len().checked_sub(SEARCH_LIMIT).filter(|&n| n > 0) {
90 writeln!(out, "- … {more} more: add words to narrow it down").expect("writing to a String cannot fail");
91 }
92 out
93 }
94
95 fn write_entry(&self, out: &mut String, task: &Task) -> std::fmt::Result {
97 let now = now();
98 writeln!(out, "# {}", line(task))?;
99 let mut facts = vec![format!("created {}", when(task.created))];
100 if task.is_task() && !task.history.is_empty() {
101 facts.push(format!("{} for {}", task.status.as_str(), fmt_age(now - task.status_since())));
102 }
103 if !task.sessions.is_empty() {
104 let running = task.running_for(now).map(|r| format!(", running for {}", fmt_dur(r))).unwrap_or_default();
105 let sessions = counted(task.sessions.len(), "session", "sessions");
106 facts.push(format!("tracked {} in {sessions}{running}", fmt_dur(task.total(now))));
107 }
108 writeln!(out, "{}", facts.join(" · "))?;
109 if let Some(path) = &task.path {
110 writeln!(out, "file: {}", path.display())?;
111 }
112 if !task.description.trim().is_empty() {
113 writeln!(out, "\n## Description\n\n{}", task.description.trim())?;
114 }
115 if !task.comments.is_empty() {
116 writeln!(out, "\n## Comments")?;
117 for comment in &task.comments {
118 writeln!(out, "\n### {}\n\n{}", comment_heading(comment), comment.body.trim())?;
119 }
120 }
121 let related = self.related(task.id);
122 let elsewhere = self.repo_related(task.id);
123 if !related.is_empty() || !elsewhere.is_empty() {
124 writeln!(out, "\n## Related\n")?;
125 for other in related {
126 writeln!(out, "- {}", line(other))?;
127 }
128 for (repo, other) in &elsewhere {
129 writeln!(out, "- {repo}{}", line(other))?;
130 }
131 }
132 if !task.history.is_empty() {
133 let skipped = task.history.len().saturating_sub(HISTORY_LIMIT);
134 let heading = if skipped > 0 {
135 format!("History (latest {HISTORY_LIMIT} of {})", task.history.len())
136 } else {
137 "History".to_string()
138 };
139 writeln!(out, "\n## {heading}\n")?;
140 for change in &task.history[skipped..] {
141 let by = if change.by == Author::Agent { " (agent)" } else { "" };
142 writeln!(out, "- {} {} -> {}{by}", when(change.at), change.from.as_str(), change.to.as_str())?;
143 }
144 }
145 Ok(())
146 }
147}