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::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(trimmed.trim());
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/// The `##` section a line belongs to.
188#[derive(Clone, Copy)]
189enum Section {
190    /// Title and `- key: value` lines, before the first `##`.
191    Meta,
192    /// `## Description`: free text.
193    Description,
194    /// `## Comments`: `### <timestamp>` headings, each followed by the comment.
195    Comments,
196    /// `## History`: one `- <time> <from> -> <to>` line per status change.
197    History,
198    /// `## Sessions`: one `- <start> -> <end> (duration)` or `- <start> -> running` line each.
199    Sessions,
200    /// Any other `##` section, kept as written.
201    Other,
202}
203
204impl Section {
205    /// The section a `## <heading>` starts.
206    fn from_heading(heading: &str) -> Self {
207        match heading.trim() {
208            "Description" => Section::Description,
209            "Comments" => Section::Comments,
210            "History" => Section::History,
211            "Sessions" => Section::Sessions,
212            _ => Section::Other,
213        }
214    }
215}
216
217/// Everything read from a file so far, before it becomes a `Task`.
218#[derive(Default)]
219struct Parsed<'a> {
220    /// From the `# Title` line.
221    title: Option<String>,
222    /// From `- id:`; required.
223    id: Option<u32>,
224    /// From `- kind:`; a task if missing.
225    kind: Option<Kind>,
226    /// From `- links:`, the ids in this brain.
227    links: Vec<u32>,
228    /// From `- links:`, the entries of other repositories.
229    repo_links: Vec<RepoLink>,
230    /// From `- status:`; `todo` if missing.
231    status: Option<Status>,
232    /// From `- decision:`; accepted if missing.
233    decision: DecisionState,
234    /// From `- tags:`.
235    tags: Vec<String>,
236    /// From `- created:`.
237    created: Option<DateTime<Local>>,
238    /// Lines of the description, joined at the end.
239    description: Vec<&'a str>,
240    /// Each comment with its body lines, joined at the end.
241    comments: Vec<(Comment, Vec<&'a str>)>,
242    /// In file order; sorted at the end.
243    history: Vec<StatusChange>,
244    /// In file order; sorted at the end.
245    sessions: Vec<Session>,
246    /// Whatever the lines above don't take.
247    unknown: Unknown,
248}
249
250impl<'a> Parsed<'a> {
251    /// The title line or a `- key: value` line before the first section.
252    fn meta_line(&mut self, line: &str) {
253        if self.title.is_none()
254            && let Some(title) = line.strip_prefix("# ")
255        {
256            self.title = Some(title.trim().to_string());
257            return;
258        }
259        let Some((key, value)) = line.trim_start().strip_prefix("- ").and_then(|rest| rest.split_once(':')) else {
260            // Text of a hand edit among the keys: kept, written back after them.
261            if self.title.is_some() && !line.trim().is_empty() {
262                self.unknown.meta.push(line.trim().to_string());
263            }
264            return;
265        };
266        match key.trim() {
267            "id" => self.id = value.trim().parse().ok(),
268            "status" => self.status = Status::parse(value),
269            "kind" => self.kind = Kind::parse(value),
270            "decision" => match DecisionState::parse(value) {
271                Some(decision) => self.decision = decision,
272                None => self.unknown.meta.push(line.trim().to_string()),
273            },
274            // A second line, from a hand edit or a merge, adds to the first rather than replace it.
275            "links" => {
276                let (links, repo_links) = parse_links(value);
277                for link in links {
278                    if !self.links.contains(&link) {
279                        self.links.push(link);
280                    }
281                }
282                for link in repo_links {
283                    if !self.repo_links.contains(&link) {
284                        self.repo_links.push(link);
285                    }
286                }
287                self.unknown.links.extend(link_words(value).filter(|w| parse_link(w).is_none()).map(String::from));
288            }
289            "tags" => self.tags = parse_tags(value),
290            "created" => self.created = parse_ts(value),
291            _ => self.unknown.meta.push(line.trim().to_string()),
292        }
293    }
294
295    /// A line of a section this version doesn't know, added to it with blank lines at its end dropped
296    /// when the file is written back.
297    fn other_line(&mut self, line: &str) {
298        if let Some(section) = self.unknown.sections.last_mut() {
299            section.push('\n');
300            section.push_str(line);
301        }
302    }
303
304    /// `### <created> [(<attributes>)]` starts a comment; other lines belong to the current one.
305    fn comment_line(&mut self, line: &'a str) {
306        match comment_header(line.trim_end()) {
307            Some(comment) => self.comments.push((comment, Vec::new())),
308            None => {
309                // Text before the first comment header has nowhere to go.
310                if let Some((_, body)) = self.comments.last_mut() {
311                    body.push(line);
312                }
313            }
314        }
315    }
316
317    /// `- <time> <from> -> <to> [(agent)]`. The time may contain a space when typed by hand. Who moved it
318    /// is only a record, so a name this version doesn't know reads as a person rather than losing the file.
319    fn history_line(&mut self, line: &str) -> Result<(), String> {
320        let Some(rest) = line.trim_start().strip_prefix("- ") else { return Ok(()) };
321        let bad = || format!("bad history line: {rest}");
322        let (left, right) = rest.split_once("->").ok_or_else(bad)?;
323        let (at, from) = left.trim().rsplit_once(' ').ok_or_else(bad)?;
324        let at = parse_ts(at).ok_or_else(|| format!("bad timestamp: {}", at.trim()))?;
325        let from = Status::parse(from).ok_or_else(bad)?;
326        let (to, by) = right.split_once('(').unwrap_or((right, ""));
327        let to = Status::parse(to).ok_or_else(bad)?;
328        let by = Author::parse(by.trim().trim_end_matches(')')).unwrap_or_default();
329        self.history.push(StatusChange { at, from, to, by });
330        Ok(())
331    }
332
333    /// `- <start> -> <end> (duration)` or `- <start> -> running`; the duration is informational.
334    fn session_line(&mut self, line: &str) -> Result<(), String> {
335        let Some(rest) = line.trim_start().strip_prefix("- ") else { return Ok(()) };
336        let (start, end) = rest.split_once("->").ok_or_else(|| format!("bad session line: {rest}"))?;
337        let start = parse_ts(start).ok_or_else(|| format!("bad timestamp: {}", start.trim()))?;
338        let end = end.split('(').next().unwrap_or_default().trim();
339        let end =
340            if end == "running" { None } else { Some(parse_ts(end).ok_or_else(|| format!("bad timestamp: {end}"))?) };
341        self.sessions.push(Session { start, end });
342        Ok(())
343    }
344
345    /// Builds the task. The `- status:` line decides where the task stands, so open sessions
346    /// are closed where they can't be running: any but the last at the next one's start, and the
347    /// last at the latest status change if the task isn't `doing`.
348    fn into_task(mut self) -> Result<Task, String> {
349        let id = self.id.ok_or("missing `- id:` line")?;
350        self.history.sort_by_key(|c| c.at);
351        self.sessions.sort_by_key(|s| s.start);
352        let next_starts: Vec<_> = self.sessions.iter().skip(1).map(|s| s.start).collect();
353        for (session, next_start) in self.sessions.iter_mut().zip(next_starts) {
354            session.end.get_or_insert(next_start);
355        }
356        let status = self.status.unwrap_or(Status::Todo);
357        if status != Status::Doing
358            && let Some(last) = self.sessions.last_mut()
359            && last.end.is_none()
360        {
361            let changed = self.history.last().map_or(last.start, |c| c.at);
362            last.end = Some(changed.max(last.start));
363        }
364        Ok(Task {
365            id,
366            kind: self.kind.unwrap_or_default(),
367            links: self.links,
368            repo_links: self.repo_links,
369            title: self.title.unwrap_or_else(|| "untitled".into()),
370            tags: self.tags,
371            status,
372            decision: self.decision,
373            created: self.created.or_else(|| self.history.first().map(|c| c.at)).unwrap_or_else(now),
374            description: without_blank_ends(&self.description),
375            comments: self
376                .comments
377                .into_iter()
378                .map(|(comment, body)| Comment { body: without_blank_ends(&body), ..comment })
379                .collect(),
380            history: self.history,
381            sessions: self.sessions,
382            path: None,
383            unknown: Unknown {
384                sections: self.unknown.sections.into_iter().map(|s| s.trim_end().to_string()).collect(),
385                ..self.unknown
386            },
387        })
388    }
389}
390
391/// `5, 9` or `#5 #9`: ids of linked entries in this brain; `shop-api#3`: an entry of another repository.
392/// Without repeats; anything else is skipped.
393pub fn parse_links(value: &str) -> (Vec<u32>, Vec<RepoLink>) {
394    let (mut links, mut repo_links) = (Vec::new(), Vec::new());
395    for link in link_words(value).filter_map(parse_link) {
396        match link {
397            Link::Here(id) if !links.contains(&id) => links.push(id),
398            Link::Repo(link) if !repo_links.contains(&link) => repo_links.push(link),
399            Link::Here(_) | Link::Repo(_) => {}
400        }
401    }
402    (links, repo_links)
403}
404
405/// The first word of `value` that isn't a link (an id, `#12`, or `REPO#ID`): typed on the command line, it's a
406/// mistake to say, where a file's `- links:` line keeps such words as they are.
407pub fn not_a_link(value: &str) -> Option<&str> {
408    link_words(value).find(|word| parse_link(word).is_none())
409}
410
411/// The words of a `- links:` value, split at commas and spaces.
412fn link_words(value: &str) -> impl Iterator<Item = &str> {
413    value.split([',', ' ']).map(str::trim).filter(|w| !w.is_empty())
414}
415
416/// One link word, read.
417enum Link {
418    /// `5` or `#5`: an entry of this brain.
419    Here(u32),
420    /// `shop-api#3`: an entry of another repository of the project.
421    Repo(RepoLink),
422}
423
424/// The link a word names; `None` for a form this version doesn't know.
425fn parse_link(word: &str) -> Option<Link> {
426    match word.rsplit_once('#') {
427        Some((repo, id)) if !repo.is_empty() => {
428            Some(Link::Repo(RepoLink { repo: repo.to_string(), id: id.parse().ok()? }))
429        }
430        _ => word.trim_start_matches('#').parse().ok().map(Link::Here),
431    }
432}
433
434/// Free text without the blank lines around it, its first line keeping its indentation (an indented code block).
435fn without_blank_ends(lines: &[&str]) -> String {
436    let blank = |line: &&&str| line.trim().is_empty();
437    let start = lines.iter().position(|line| !blank(&line)).unwrap_or(lines.len());
438    let end = lines.iter().rposition(|line| !blank(&line)).map_or(start, |at| at + 1);
439    lines[start..end].join("\n").trim_end().to_string()
440}
441
442/// ` (agent)` after what an agent did; nothing after what a person did, so their files read as before.
443fn by_mark(by: Author) -> &'static str {
444    match by {
445        Author::Human => "",
446        Author::Agent => " (agent)",
447    }
448}
449
450/// What a comment's header says in parentheses after its time: `agent`, `question` or `answer`, words this version
451/// doesn't know, then `edited <time>`.
452fn comment_attributes(comment: &Comment) -> Vec<String> {
453    let author = (comment.author != Author::Human).then(|| comment.author.as_str().to_string());
454    let question = comment.question.then(|| "question".to_string());
455    let answer = comment.answer.then(|| "answer".to_string());
456    let edited = comment.edited.map(|at| format!("edited {}", fmt_ts(at)));
457    author.into_iter().chain(question).chain(answer).chain(comment.unknown.iter().cloned()).chain(edited).collect()
458}
459
460/// Parses `### <created>` or `### <created> (<attributes>)`, the attributes as [`comment_attributes`]
461/// writes them, in any order. A `###` line whose time doesn't parse isn't a header, but part of a comment.
462fn comment_header(line: &str) -> Option<Comment> {
463    let header = line.strip_prefix("### ")?;
464    let (created, attributes) = match header.split_once('(') {
465        Some((created, attributes)) => (created, attributes.trim_end().trim_end_matches(')')),
466        None => (header, ""),
467    };
468    let mut comment = Comment::new(parse_ts(created)?, String::new(), Author::Human);
469    for word in attributes.split(',').map(str::trim).filter(|w| !w.is_empty()) {
470        if word == "question" {
471            comment.question = true;
472        } else if word == "answer" {
473            comment.answer = true;
474        } else if let Some(author) = Author::parse(word) {
475            comment.author = author;
476        } else if let Some(at) = word.strip_prefix("edited ") {
477            comment.edited = parse_ts(at);
478        } else {
479            comment.unknown.push(word.to_string());
480        }
481    }
482    Some(comment)
483}
484
485#[cfg(test)]
486#[path = "../../tests/model/markdown.rs"]
487mod tests;