Skip to main content

kanbrain_core/model/
markdown.rs

1//! The task file format:
2//!
3//! ```markdown
4//! # Title
5//!
6//! - id: 2
7//! - status: doing
8//! - tags: prod, support
9//! - links: 5, 9, shop-api#3
10//! - created: 2026-09-24T10:12:00+02:00
11//!
12//! ## Description
13//!
14//! free text
15//!
16//! ## Comments
17//!
18//! ### 2026-09-24T10:40:00+02:00 (edited 2026-09-24T10:52:00+02:00)
19//!
20//! comment text
21//!
22//! ### 2026-09-24T10:45:00+02:00 (agent, question)
23//!
24//! a question an agent asked
25//!
26//! ## History
27//!
28//! - 2026-09-24T10:57:00+02:00 todo -> doing (agent)
29//!
30//! ## Sessions
31//!
32//! - 2026-09-24T10:57:00+02:00 -> 2026-09-24T11:42:00+02:00 (45m)
33//! - 2026-09-25T09:30:00+02:00 -> running
34//! ```
35//!
36//! `## Sessions` is only written once the task has been tracked, so untracked files stay short. Notes and decisions
37//! have `- kind: note` / `- kind: decision` instead of `- status:`, and no History or Sessions, and a decision that
38//! isn't in force has `- decision: proposed|rejected`. What a person wrote or did carries no mark; what an agent did
39//! says `agent`. `- links:` is only written when there are links. `shop-api#3` is entry 3 of the project's `shop-api`
40//! repository. Other `- key:` lines, link words and `##` sections are kept and written back (see [`Unknown`]).
41
42use super::text::{one_line, parse_tags};
43use super::time::{fmt_dur, fmt_ts, now, parse_ts};
44use super::{Author, Comment, DecisionState, Kind, RepoLink, Session, Status, StatusChange, Task, Unknown};
45use chrono::{DateTime, Local};
46use std::fmt::Write;
47
48impl Task {
49    /// The task as a markdown file (see the format at the top of this module).
50    pub fn to_markdown(&self) -> String {
51        let mut out = String::new();
52        self.write_markdown(&mut out).expect("writing to a String cannot fail");
53        out
54    }
55
56    /// Writes the file contents into `out`.
57    fn write_markdown(&self, out: &mut String) -> std::fmt::Result {
58        writeln!(out, "# {}\n", self.title)?;
59        writeln!(out, "- id: {}", self.id)?;
60        if self.is_task() {
61            writeln!(out, "- status: {}", self.status.as_str())?;
62        } else {
63            writeln!(out, "- kind: {}", self.kind.as_str())?;
64        }
65        if self.kind == Kind::Decision && self.decision != DecisionState::Accepted {
66            writeln!(out, "- decision: {}", self.decision.as_str())?;
67        }
68        writeln!(out, "- tags: {}", self.tags.join(", "))?;
69        if !self.links.is_empty() || !self.repo_links.is_empty() || !self.unknown.links.is_empty() {
70            let local = self.links.iter().map(u32::to_string);
71            let links: Vec<String> = local
72                .chain(self.repo_links.iter().map(RepoLink::to_string))
73                .chain(self.unknown.links.iter().cloned())
74                .collect();
75            writeln!(out, "- links: {}", links.join(", "))?;
76        }
77        for line in &self.unknown.meta {
78            writeln!(out, "{line}")?;
79        }
80        writeln!(out, "- created: {}\n", fmt_ts(self.created))?;
81
82        writeln!(out, "## Description\n")?;
83        if !self.description.is_empty() {
84            writeln!(out, "{}\n", self.description)?;
85        }
86
87        writeln!(out, "## Comments\n")?;
88        for comment in &self.comments {
89            write!(out, "### {}", fmt_ts(comment.created))?;
90            let attributes = comment_attributes(comment);
91            if !attributes.is_empty() {
92                write!(out, " ({})", attributes.join(", "))?;
93            }
94            writeln!(out, "\n")?;
95            if !comment.body.is_empty() {
96                writeln!(out, "{}\n", comment.body)?;
97            }
98        }
99
100        if self.is_task() {
101            self.write_tracking(out)?;
102        }
103        for section in &self.unknown.sections {
104            if !out.ends_with("\n\n") {
105                out.push('\n');
106            }
107            writeln!(out, "{section}\n")?;
108        }
109        Ok(())
110    }
111
112    /// Writes the History and Sessions sections.
113    fn write_tracking(&self, out: &mut String) -> std::fmt::Result {
114        writeln!(out, "## History\n")?;
115        for change in &self.history {
116            write!(out, "- {} {} -> {}", fmt_ts(change.at), change.from.as_str(), change.to.as_str())?;
117            writeln!(out, "{}", by_mark(change.by))?;
118        }
119
120        if !self.sessions.is_empty() {
121            writeln!(out, "\n## Sessions\n")?;
122        }
123        for session in &self.sessions {
124            match session.end {
125                Some(end) => {
126                    let duration = fmt_dur(end - session.start);
127                    writeln!(out, "- {} -> {} ({duration})", fmt_ts(session.start), fmt_ts(end))?;
128                }
129                None => writeln!(out, "- {} -> running", fmt_ts(session.start))?,
130            }
131        }
132        Ok(())
133    }
134
135    /// Parses a task file. Unknown `- key:` lines, link words and `##` sections are kept as written;
136    /// open sessions that can't be running are closed (see `Parsed::into_task`).
137    pub fn from_markdown(text: &str) -> Result<Task, String> {
138        let mut parsed = Parsed::default();
139        let mut section = Section::Meta;
140        // A title with line breaks, which versions before 0.4.0 wrote as typed: the lines right under it, up to the
141        // blank line kanbrain writes before the keys, continue it rather than read as a section that hides the id
142        // and every key after it. Never a `- key:` line, which a hand edit may put first.
143        let lines: Vec<&str> = text.lines().collect();
144        // Only a title above the id: a `# ` line further down is another section's text.
145        let id_at = lines.iter().position(|l| l.trim_start().starts_with("- id:")).unwrap_or_default();
146        let continued = match lines[..id_at].iter().position(|l| l.starts_with("# ")) {
147            Some(title) => {
148                let is_key = |l: &&&str| l.trim_start().strip_prefix("- ").is_some_and(|rest| rest.contains(':'));
149                let run = lines[title + 1..id_at].iter().take_while(|l| !l.trim().is_empty() && !is_key(l)).count();
150                title + 1..title + 1 + run
151            }
152            None => 0..0,
153        };
154        // Read line by line; each `## ` heading switches the section the next lines belong to.
155        for (at, line) in text.lines().enumerate() {
156            let trimmed = line.trim_end();
157            if continued.contains(&at) {
158                if let Some(title) = &mut parsed.title
159                    && !trimmed.trim().is_empty()
160                {
161                    title.push(' ');
162                    title.push_str(&one_line(trimmed));
163                }
164                continue;
165            }
166            if let Some(heading) = trimmed.strip_prefix("## ") {
167                section = Section::from_heading(heading);
168                if matches!(section, Section::Other) {
169                    parsed.unknown.sections.push(trimmed.to_string());
170                }
171                continue;
172            }
173            match section {
174                Section::Meta => parsed.meta_line(trimmed),
175                // Free text keeps its original indentation, so pass the untrimmed line.
176                Section::Description => parsed.description.push(line),
177                Section::Comments => parsed.comment_line(line),
178                Section::History => parsed.history_line(trimmed)?,
179                Section::Sessions => parsed.session_line(trimmed)?,
180                Section::Other => parsed.other_line(line),
181            }
182        }
183        parsed.into_task()
184    }
185}
186
187/// [`Task::from_markdown`]'s error for a file with no id: what tells markdown of other kinds, which a central brain
188/// kept in a notes vault holds, from a broken entry.
189pub(crate) const MISSING_ID: &str = "missing `- id:` line";
190
191/// The `##` section a line belongs to.
192#[derive(Clone, Copy)]
193enum Section {
194    /// Title and `- key: value` lines, before the first `##`.
195    Meta,
196    /// `## Description`: free text.
197    Description,
198    /// `## Comments`: `### <timestamp>` headings, each followed by the comment.
199    Comments,
200    /// `## History`: one `- <time> <from> -> <to>` line per status change.
201    History,
202    /// `## Sessions`: one `- <start> -> <end> (duration)` or `- <start> -> running` line each.
203    Sessions,
204    /// Any other `##` section, kept as written.
205    Other,
206}
207
208impl Section {
209    /// The section a `## <heading>` starts.
210    fn from_heading(heading: &str) -> Self {
211        match heading.trim() {
212            "Description" => Section::Description,
213            "Comments" => Section::Comments,
214            "History" => Section::History,
215            "Sessions" => Section::Sessions,
216            _ => Section::Other,
217        }
218    }
219}
220
221/// Everything read from a file so far, before it becomes a `Task`.
222#[derive(Default)]
223struct Parsed<'a> {
224    /// From the `# Title` line.
225    title: Option<String>,
226    /// From `- id:`; required.
227    id: Option<u32>,
228    /// From `- kind:`; a task if missing.
229    kind: Option<Kind>,
230    /// From `- links:`, the ids in this brain.
231    links: Vec<u32>,
232    /// From `- links:`, the entries of other repositories.
233    repo_links: Vec<RepoLink>,
234    /// From `- status:`; `todo` if missing.
235    status: Option<Status>,
236    /// From `- decision:`; accepted if missing.
237    decision: DecisionState,
238    /// From `- tags:`.
239    tags: Vec<String>,
240    /// From `- created:`.
241    created: Option<DateTime<Local>>,
242    /// Lines of the description, joined at the end.
243    description: Vec<&'a str>,
244    /// Each comment with its body lines, joined at the end.
245    comments: Vec<(Comment, Vec<&'a str>)>,
246    /// In file order; sorted at the end.
247    history: Vec<StatusChange>,
248    /// In file order; sorted at the end.
249    sessions: Vec<Session>,
250    /// Whatever the lines above don't take.
251    unknown: Unknown,
252}
253
254impl<'a> Parsed<'a> {
255    /// The title line or a `- key: value` line before the first section.
256    fn meta_line(&mut self, line: &str) {
257        if self.title.is_none()
258            && let Some(title) = line.strip_prefix("# ")
259        {
260            // A lone `\r` older versions kept in it is a line break too, as markdown reads it.
261            self.title = Some(one_line(title));
262            return;
263        }
264        let Some((key, value)) = line.trim_start().strip_prefix("- ").and_then(|rest| rest.split_once(':')) else {
265            // Text of a hand edit among the keys: kept, written back after them.
266            if self.title.is_some() && !line.trim().is_empty() {
267                self.unknown.meta.push(line.trim().to_string());
268            }
269            return;
270        };
271        match key.trim() {
272            "id" => self.id = value.trim().parse().ok(),
273            "status" => self.status = Status::parse(value),
274            "kind" => self.kind = Kind::parse(value),
275            "decision" => match DecisionState::parse(value) {
276                Some(decision) => self.decision = decision,
277                None => self.unknown.meta.push(line.trim().to_string()),
278            },
279            // A second line, from a hand edit or a merge, adds to the first rather than replace it.
280            "links" => {
281                let (links, repo_links) = parse_links(value);
282                for link in links {
283                    if !self.links.contains(&link) {
284                        self.links.push(link);
285                    }
286                }
287                for link in repo_links {
288                    if !self.repo_links.contains(&link) {
289                        self.repo_links.push(link);
290                    }
291                }
292                self.unknown.links.extend(link_words(value).filter(|w| parse_link(w).is_none()).map(String::from));
293            }
294            "tags" => self.tags = parse_tags(value),
295            "created" => self.created = parse_ts(value),
296            _ => self.unknown.meta.push(line.trim().to_string()),
297        }
298    }
299
300    /// A line of a section this version doesn't know, added to it with blank lines at its end dropped
301    /// when the file is written back.
302    fn other_line(&mut self, line: &str) {
303        if let Some(section) = self.unknown.sections.last_mut() {
304            section.push('\n');
305            section.push_str(line);
306        }
307    }
308
309    /// `### <created> [(<attributes>)]` starts a comment; other lines belong to the current one.
310    fn comment_line(&mut self, line: &'a str) {
311        match comment_header(line.trim_end()) {
312            Some(comment) => self.comments.push((comment, Vec::new())),
313            None => {
314                // Text before the first comment header has nowhere to go.
315                if let Some((_, body)) = self.comments.last_mut() {
316                    body.push(line);
317                }
318            }
319        }
320    }
321
322    /// `- <time> <from> -> <to> [(agent)]`. The time may contain a space when typed by hand. Who moved it
323    /// is only a record, so a name this version doesn't know reads as a person rather than losing the file.
324    fn history_line(&mut self, line: &str) -> Result<(), String> {
325        let Some(rest) = line.trim_start().strip_prefix("- ") else { return Ok(()) };
326        let bad = || format!("bad history line: {rest}");
327        let (left, right) = rest.split_once("->").ok_or_else(bad)?;
328        let (at, from) = left.trim().rsplit_once(' ').ok_or_else(bad)?;
329        let at = parse_ts(at).ok_or_else(|| format!("bad timestamp: {}", at.trim()))?;
330        let from = Status::parse(from).ok_or_else(bad)?;
331        let (to, by) = right.split_once('(').unwrap_or((right, ""));
332        let to = Status::parse(to).ok_or_else(bad)?;
333        let by = Author::parse(by.trim().trim_end_matches(')')).unwrap_or_default();
334        self.history.push(StatusChange { at, from, to, by });
335        Ok(())
336    }
337
338    /// `- <start> -> <end> (duration)` or `- <start> -> running`; the duration is informational.
339    fn session_line(&mut self, line: &str) -> Result<(), String> {
340        let Some(rest) = line.trim_start().strip_prefix("- ") else { return Ok(()) };
341        let (start, end) = rest.split_once("->").ok_or_else(|| format!("bad session line: {rest}"))?;
342        let start = parse_ts(start).ok_or_else(|| format!("bad timestamp: {}", start.trim()))?;
343        let end = end.split('(').next().unwrap_or_default().trim();
344        let end =
345            if end == "running" { None } else { Some(parse_ts(end).ok_or_else(|| format!("bad timestamp: {end}"))?) };
346        self.sessions.push(Session { start, end });
347        Ok(())
348    }
349
350    /// Builds the task. The `- status:` line decides where the task stands, so open sessions
351    /// are closed where they can't be running: any but the last at the next one's start, and the
352    /// last at the latest status change if the task isn't `doing`.
353    fn into_task(mut self) -> Result<Task, String> {
354        let id = self.id.ok_or(MISSING_ID)?;
355        self.history.sort_by_key(|c| c.at);
356        self.sessions.sort_by_key(|s| s.start);
357        let next_starts: Vec<_> = self.sessions.iter().skip(1).map(|s| s.start).collect();
358        for (session, next_start) in self.sessions.iter_mut().zip(next_starts) {
359            session.end.get_or_insert(next_start);
360        }
361        let status = self.status.unwrap_or(Status::Todo);
362        if status != Status::Doing
363            && let Some(last) = self.sessions.last_mut()
364            && last.end.is_none()
365        {
366            let changed = self.history.last().map_or(last.start, |c| c.at);
367            last.end = Some(changed.max(last.start));
368        }
369        Ok(Task {
370            id,
371            kind: self.kind.unwrap_or_default(),
372            links: self.links,
373            repo_links: self.repo_links,
374            title: self.title.unwrap_or_else(|| "untitled".into()),
375            tags: self.tags,
376            status,
377            decision: self.decision,
378            created: self.created.or_else(|| self.history.first().map(|c| c.at)).unwrap_or_else(now),
379            description: without_blank_ends(&self.description),
380            comments: self
381                .comments
382                .into_iter()
383                .map(|(comment, body)| Comment { body: without_blank_ends(&body), ..comment })
384                .collect(),
385            history: self.history,
386            sessions: self.sessions,
387            path: None,
388            unknown: Unknown {
389                sections: self.unknown.sections.into_iter().map(|s| s.trim_end().to_string()).collect(),
390                ..self.unknown
391            },
392        })
393    }
394}
395
396/// `5, 9` or `#5 #9`: ids of linked entries in this brain; `shop-api#3`: an entry of another repository.
397/// Without repeats; anything else is skipped.
398pub fn parse_links(value: &str) -> (Vec<u32>, Vec<RepoLink>) {
399    let (mut links, mut repo_links) = (Vec::new(), Vec::new());
400    for link in link_words(value).filter_map(parse_link) {
401        match link {
402            Link::Here(id) if !links.contains(&id) => links.push(id),
403            Link::Repo(link) if !repo_links.contains(&link) => repo_links.push(link),
404            Link::Here(_) | Link::Repo(_) => {}
405        }
406    }
407    (links, repo_links)
408}
409
410/// The first word of `value` that isn't a link (an id, `#12`, or `REPO#ID`): typed on the command line, it's a
411/// mistake to say, where a file's `- links:` line keeps such words as they are.
412pub fn not_a_link(value: &str) -> Option<&str> {
413    link_words(value).find(|word| parse_link(word).is_none())
414}
415
416/// The words of a `- links:` value, split at commas and spaces.
417fn link_words(value: &str) -> impl Iterator<Item = &str> {
418    value.split([',', ' ']).map(str::trim).filter(|w| !w.is_empty())
419}
420
421/// One link word, read.
422enum Link {
423    /// `5` or `#5`: an entry of this brain.
424    Here(u32),
425    /// `shop-api#3`: an entry of another repository of the project.
426    Repo(RepoLink),
427}
428
429/// The link a word names; `None` for a form this version doesn't know.
430fn parse_link(word: &str) -> Option<Link> {
431    match word.rsplit_once('#') {
432        Some((repo, id)) if !repo.is_empty() => {
433            Some(Link::Repo(RepoLink { repo: repo.to_string(), id: id.parse().ok()? }))
434        }
435        _ => word.trim_start_matches('#').parse().ok().map(Link::Here),
436    }
437}
438
439/// Free text without the blank lines around it, its first line keeping its indentation (an indented code block).
440fn without_blank_ends(lines: &[&str]) -> String {
441    let blank = |line: &&&str| line.trim().is_empty();
442    let start = lines.iter().position(|line| !blank(&line)).unwrap_or(lines.len());
443    let end = lines.iter().rposition(|line| !blank(&line)).map_or(start, |at| at + 1);
444    lines[start..end].join("\n").trim_end().to_string()
445}
446
447/// ` (agent)` after what an agent did; nothing after what a person did, so their files read as before.
448fn by_mark(by: Author) -> &'static str {
449    match by {
450        Author::Human => "",
451        Author::Agent => " (agent)",
452    }
453}
454
455/// What a comment's header says in parentheses after its time: `agent`, `question` or `answer`, words this version
456/// doesn't know, then `edited <time>`.
457fn comment_attributes(comment: &Comment) -> Vec<String> {
458    let author = (comment.author != Author::Human).then(|| comment.author.as_str().to_string());
459    let question = comment.question.then(|| "question".to_string());
460    let answer = comment.answer.then(|| "answer".to_string());
461    let edited = comment.edited.map(|at| format!("edited {}", fmt_ts(at)));
462    author.into_iter().chain(question).chain(answer).chain(comment.unknown.iter().cloned()).chain(edited).collect()
463}
464
465/// Parses `### <created>` or `### <created> (<attributes>)`, the attributes as [`comment_attributes`]
466/// writes them, in any order. A `###` line whose time doesn't parse isn't a header, but part of a comment.
467fn comment_header(line: &str) -> Option<Comment> {
468    let header = line.strip_prefix("### ")?;
469    let (created, attributes) = match header.split_once('(') {
470        Some((created, attributes)) => (created, attributes.trim_end().trim_end_matches(')')),
471        None => (header, ""),
472    };
473    let mut comment = Comment::new(parse_ts(created)?, String::new(), Author::Human);
474    for word in attributes.split(',').map(str::trim).filter(|w| !w.is_empty()) {
475        if word == "question" {
476            comment.question = true;
477        } else if word == "answer" {
478            comment.answer = true;
479        } else if let Some(author) = Author::parse(word) {
480            comment.author = author;
481        } else if let Some(at) = word.strip_prefix("edited ") {
482            comment.edited = parse_ts(at);
483        } else {
484            comment.unknown.push(word.to_string());
485        }
486    }
487    Some(comment)
488}
489
490#[cfg(test)]
491#[path = "../../tests/model/markdown.rs"]
492mod tests;