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