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