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::{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        self.write_markdown(&mut out).expect("writing to a String cannot fail");
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        let mut parsed = Parsed::default();
175        let mut section = Section::Meta;
176        // A title with line breaks, which versions before 0.4.0 wrote as typed: the lines right under it, up to the
177        // blank line kanbrain writes before the keys, continue it rather than read as a section that hides the id
178        // and every key after it. Never a `- key:` line, which a hand edit may put first.
179        let lines: Vec<&str> = text.lines().collect();
180        // Only a title above the id: a `# ` line further down is another section's text.
181        let id_at = lines.iter().position(|l| l.trim_start().starts_with("- id:")).unwrap_or_default();
182        let continued = match lines[..id_at].iter().position(|l| l.starts_with("# ")) {
183            Some(title) => {
184                let is_key = |l: &&&str| l.trim_start().strip_prefix("- ").is_some_and(|rest| rest.contains(':'));
185                let run = lines[title + 1..id_at].iter().take_while(|l| !l.trim().is_empty() && !is_key(l)).count();
186                title + 1..title + 1 + run
187            }
188            None => 0..0,
189        };
190        // Read line by line; each `## ` heading switches the section the next lines belong to.
191        for (at, line) in text.lines().enumerate() {
192            let trimmed = line.trim_end();
193            if continued.contains(&at) {
194                if let Some(title) = &mut parsed.title
195                    && !trimmed.trim().is_empty()
196                {
197                    title.push(' ');
198                    title.push_str(&one_line(trimmed));
199                }
200                continue;
201            }
202            if let Some(heading) = trimmed.strip_prefix("## ") {
203                section = Section::from_heading(heading);
204                if matches!(section, Section::Other) {
205                    parsed.unknown.sections.push(trimmed.to_string());
206                }
207                continue;
208            }
209            match section {
210                Section::Meta => parsed.meta_line(trimmed),
211                // Free text keeps its original indentation, so pass the untrimmed line.
212                Section::Description => parsed.description.push(line),
213                Section::Comments => parsed.comment_line(line),
214                Section::Activity => parsed.activity_line(trimmed),
215                Section::History => parsed.history_line(trimmed)?,
216                Section::Sessions => parsed.session_line(trimmed)?,
217                Section::Other => parsed.other_line(line),
218            }
219        }
220        parsed.into_task()
221    }
222}
223
224/// [`Task::from_markdown`]'s error for a file with no id: what tells markdown of other kinds, which a central brain
225/// kept in a notes vault holds, from a broken entry.
226pub(crate) const MISSING_ID: &str = "missing `- id:` line";
227
228/// The `##` section a line belongs to.
229#[derive(Clone, Copy)]
230enum Section {
231    /// Title and `- key: value` lines, before the first `##`.
232    Meta,
233    /// `## Description`: free text.
234    Description,
235    /// `## Comments`: `### <timestamp>` headings, each followed by the comment.
236    Comments,
237    /// `## Activity`: one `- <time> <what>` item per change, a title's with its lines.
238    Activity,
239    /// `## History`: one `- <time> <from> -> <to>` line per status change.
240    History,
241    /// `## Sessions`: one `- <start> -> <end> (duration)` or `- <start> -> running` line each.
242    Sessions,
243    /// Any other `##` section, kept as written.
244    Other,
245}
246
247impl Section {
248    /// The section a `## <heading>` starts.
249    fn from_heading(heading: &str) -> Self {
250        match heading.trim() {
251            "Description" => Section::Description,
252            "Comments" => Section::Comments,
253            "Activity" => Section::Activity,
254            "History" => Section::History,
255            "Sessions" => Section::Sessions,
256            _ => Section::Other,
257        }
258    }
259}
260
261/// Everything read from a file so far, before it becomes a `Task`.
262#[derive(Default)]
263struct Parsed<'a> {
264    /// From the `# Title` line.
265    title: Option<String>,
266    /// From `- id:`; required.
267    id: Option<u32>,
268    /// From `- kind:`; a task if missing.
269    kind: Option<Kind>,
270    /// From `- links:`, the ids in this brain.
271    links: Vec<u32>,
272    /// From `- links:`, the entries of other repositories.
273    repo_links: Vec<RepoLink>,
274    /// From `- status:`; `todo` if missing.
275    status: Option<Status>,
276    /// From `- decision:`; accepted if missing.
277    decision: DecisionState,
278    /// From `- tags:`.
279    tags: Vec<String>,
280    /// From `- created:`.
281    created: Option<DateTime<Local>>,
282    /// Lines of the description, joined at the end.
283    description: Vec<&'a str>,
284    /// Each comment with its body lines, joined at the end.
285    comments: Vec<(Comment, Vec<&'a str>)>,
286    /// In file order; sorted at the end.
287    history: Vec<StatusChange>,
288    /// The `## Activity` items, each with its lines, read at the end.
289    activity: Vec<String>,
290    /// In file order; sorted at the end.
291    sessions: Vec<Session>,
292    /// Whatever the lines above don't take.
293    unknown: Unknown,
294}
295
296impl<'a> Parsed<'a> {
297    /// The title line or a `- key: value` line before the first section.
298    fn meta_line(&mut self, line: &str) {
299        if self.title.is_none()
300            && let Some(title) = line.strip_prefix("# ")
301        {
302            // A lone `\r` older versions kept in it is a line break too, as markdown reads it.
303            self.title = Some(one_line(title));
304            return;
305        }
306        let Some((key, value)) = line.trim_start().strip_prefix("- ").and_then(|rest| rest.split_once(':')) else {
307            // Text of a hand edit among the keys: kept, written back after them.
308            if self.title.is_some() && !line.trim().is_empty() {
309                self.unknown.meta.push(line.trim().to_string());
310            }
311            return;
312        };
313        match key.trim() {
314            "id" => self.id = value.trim().parse().ok(),
315            "status" => self.status = Status::parse(value),
316            "kind" => self.kind = Kind::parse(value),
317            "decision" => match DecisionState::parse(value) {
318                Some(decision) => self.decision = decision,
319                None => self.unknown.meta.push(line.trim().to_string()),
320            },
321            // A second line, from a hand edit or a merge, adds to the first rather than replace it.
322            "links" => {
323                let (links, repo_links) = parse_links(value);
324                for link in links {
325                    if !self.links.contains(&link) {
326                        self.links.push(link);
327                    }
328                }
329                for link in repo_links {
330                    if !self.repo_links.contains(&link) {
331                        self.repo_links.push(link);
332                    }
333                }
334                self.unknown.links.extend(link_words(value).filter(|w| parse_link(w).is_none()).map(String::from));
335            }
336            "tags" => self.tags = parse_tags(value),
337            "created" => self.created = parse_ts(value),
338            _ => self.unknown.meta.push(line.trim().to_string()),
339        }
340    }
341
342    /// A line of a section this version doesn't know, added to it with blank lines at its end dropped
343    /// when the file is written back.
344    fn other_line(&mut self, line: &str) {
345        if let Some(section) = self.unknown.sections.last_mut() {
346            section.push('\n');
347            section.push_str(line);
348        }
349    }
350
351    /// `### <created> [(<attributes>)]` starts a comment; other lines belong to the current one.
352    fn comment_line(&mut self, line: &'a str) {
353        match comment_header(line.trim_end()) {
354            Some(comment) => self.comments.push((comment, Vec::new())),
355            None => {
356                // Text before the first comment header has nowhere to go.
357                if let Some((_, body)) = self.comments.last_mut() {
358                    body.push(line);
359                }
360            }
361        }
362    }
363
364    /// A `- ` line at the start starts an `## Activity` item; any other line is part of the last one, blank ones
365    /// too, so text someone wrote there reads back as it was. Text before the first item is an item of its own, which
366    /// this version can't read and keeps.
367    fn activity_line(&mut self, line: &str) {
368        match self.activity.last_mut() {
369            Some(item) if !line.starts_with("- ") => {
370                item.push('\n');
371                item.push_str(line);
372            }
373            _ if line.trim().is_empty() => {}
374            _ => self.activity.push(line.to_string()),
375        }
376    }
377
378    /// `- <time> <from> -> <to> [(agent)]`. The time may contain a space when typed by hand. Who moved it
379    /// is only a record, so a name this version doesn't know reads as a person rather than losing the file.
380    fn history_line(&mut self, line: &str) -> Result<(), String> {
381        let Some(rest) = line.trim_start().strip_prefix("- ") else { return Ok(()) };
382        let bad = || format!("bad history line: {rest}");
383        let (left, right) = rest.split_once("->").ok_or_else(bad)?;
384        let (at, from) = left.trim().rsplit_once(' ').ok_or_else(bad)?;
385        let at = parse_ts(at).ok_or_else(|| format!("bad timestamp: {}", at.trim()))?;
386        let from = Status::parse(from).ok_or_else(bad)?;
387        let (to, by) = right.split_once('(').unwrap_or((right, ""));
388        let to = Status::parse(to).ok_or_else(bad)?;
389        let by = Author::parse(by.trim().trim_end_matches(')')).unwrap_or_default();
390        self.history.push(StatusChange { at, from, to, by });
391        Ok(())
392    }
393
394    /// `- <start> -> <end> (duration)` or `- <start> -> running`; the duration is informational.
395    fn session_line(&mut self, line: &str) -> Result<(), String> {
396        let Some(rest) = line.trim_start().strip_prefix("- ") else { return Ok(()) };
397        let (start, end) = rest.split_once("->").ok_or_else(|| format!("bad session line: {rest}"))?;
398        let start = parse_ts(start).ok_or_else(|| format!("bad timestamp: {}", start.trim()))?;
399        let end = end.split('(').next().unwrap_or_default().trim();
400        let end =
401            if end == "running" { None } else { Some(parse_ts(end).ok_or_else(|| format!("bad timestamp: {end}"))?) };
402        self.sessions.push(Session { start, end });
403        Ok(())
404    }
405
406    /// Builds the task. The `- status:` line decides where the task stands, so open sessions
407    /// are closed where they can't be running: any but the last at the next one's start, and the
408    /// last at the latest status change if the task isn't `doing`.
409    fn into_task(mut self) -> Result<Task, String> {
410        let id = self.id.ok_or(MISSING_ID)?;
411        self.history.sort_by_key(|c| c.at);
412        let (mut activity, mut unread) = (Vec::new(), Vec::new());
413        for item in self.activity {
414            match activity_item(&item) {
415                Some(read) => activity.push(read),
416                None => unread.push(item.trim_end().to_string()),
417            }
418        }
419        activity.sort_by_key(|a: &Activity| a.at);
420        self.sessions.sort_by_key(|s| s.start);
421        let next_starts: Vec<_> = self.sessions.iter().skip(1).map(|s| s.start).collect();
422        for (session, next_start) in self.sessions.iter_mut().zip(next_starts) {
423            session.end.get_or_insert(next_start);
424        }
425        let status = self.status.unwrap_or(Status::Todo);
426        if status != Status::Doing
427            && let Some(last) = self.sessions.last_mut()
428            && last.end.is_none()
429        {
430            let changed = self.history.last().map_or(last.start, |c| c.at);
431            last.end = Some(changed.max(last.start));
432        }
433        Ok(Task {
434            id,
435            kind: self.kind.unwrap_or_default(),
436            links: self.links,
437            repo_links: self.repo_links,
438            title: self.title.unwrap_or_else(|| "untitled".into()),
439            tags: self.tags,
440            status,
441            decision: self.decision,
442            created: self.created.or_else(|| self.history.first().map(|c| c.at)).unwrap_or_else(now),
443            description: without_blank_ends(&self.description),
444            comments: self
445                .comments
446                .into_iter()
447                .map(|(comment, body)| Comment { body: without_blank_ends(&body), ..comment })
448                .collect(),
449            history: self.history,
450            activity,
451            sessions: self.sessions,
452            path: None,
453            unknown: Unknown {
454                sections: self.unknown.sections.into_iter().map(|s| s.trim_end().to_string()).collect(),
455                activity: unread,
456                ..self.unknown
457            },
458        })
459    }
460}
461
462/// `5, 9` or `#5 #9`: ids of linked entries in this brain; `shop-api#3`: an entry of another repository.
463/// Without repeats; anything else is skipped.
464pub fn parse_links(value: &str) -> (Vec<u32>, Vec<RepoLink>) {
465    let (mut links, mut repo_links) = (Vec::new(), Vec::new());
466    for link in link_words(value).filter_map(parse_link) {
467        match link {
468            Link::Here(id) if !links.contains(&id) => links.push(id),
469            Link::Repo(link) if !repo_links.contains(&link) => repo_links.push(link),
470            Link::Here(_) | Link::Repo(_) => {}
471        }
472    }
473    (links, repo_links)
474}
475
476/// The first word of `value` that isn't a link (an id, `#12`, or `REPO#ID`): typed on the command line, it's a
477/// mistake to say, where a file's `- links:` line keeps such words as they are.
478pub fn not_a_link(value: &str) -> Option<&str> {
479    link_words(value).find(|word| parse_link(word).is_none())
480}
481
482/// The words of a `- links:` value, split at commas and spaces.
483fn link_words(value: &str) -> impl Iterator<Item = &str> {
484    value.split([',', ' ']).map(str::trim).filter(|w| !w.is_empty())
485}
486
487/// One link word, read.
488enum Link {
489    /// `5` or `#5`: an entry of this brain.
490    Here(u32),
491    /// `shop-api#3`: an entry of another repository of the project.
492    Repo(RepoLink),
493}
494
495/// The link a word names; `None` for a form this version doesn't know.
496fn parse_link(word: &str) -> Option<Link> {
497    match word.rsplit_once('#') {
498        Some((repo, id)) if !repo.is_empty() => {
499            Some(Link::Repo(RepoLink { repo: repo.to_string(), id: id.parse().ok()? }))
500        }
501        _ => word.trim_start_matches('#').parse().ok().map(Link::Here),
502    }
503}
504
505/// Free text without the blank lines around it, its first line keeping its indentation (an indented code block).
506fn without_blank_ends(lines: &[&str]) -> String {
507    let blank = |line: &&&str| line.trim().is_empty();
508    let start = lines.iter().position(|line| !blank(&line)).unwrap_or(lines.len());
509    let end = lines.iter().rposition(|line| !blank(&line)).map_or(start, |at| at + 1);
510    lines[start..end].join("\n").trim_end().to_string()
511}
512
513/// `+added -removed`, the words of an `## Activity` list's change.
514fn signed(added: &[String], removed: &[String]) -> String {
515    let added = added.iter().map(|word| format!("+{word}"));
516    added.chain(removed.iter().map(|word| format!("-{word}"))).collect::<Vec<_>>().join(" ")
517}
518
519/// An `## Activity` item, as [`Task::write_activity`] writes it; `None` for one this version can't read, which is
520/// kept as written rather than lose the file: what this section says is only a record.
521fn activity_item(item: &str) -> Option<Activity> {
522    let mut lines = item.lines().filter(|line| !line.trim().is_empty());
523    let (at, rest) = lines.next()?.strip_prefix("- ")?.split_once(' ')?;
524    let at = parse_ts(at)?;
525    let (head, words) = match rest.split_once(':') {
526        Some((head, words)) => (head, Some(words)),
527        None => (rest, None),
528    };
529    let (what, by) = match head.trim_end().strip_suffix(')').and_then(|head| head.rsplit_once(" (")) {
530        Some((what, by)) => (what, Author::parse(by)?),
531        None => (head.trim_end(), Author::Human),
532    };
533    let mut value = |key: &str| lines.next()?.trim_start().strip_prefix("- ")?.strip_prefix(key).map(str::trim);
534    let change = match (what, words) {
535        ("title", None) => Changed::Title { from: value("from:")?.to_string(), to: value("to:")?.to_string() },
536        ("tags", Some(words)) => {
537            let (added, removed) = signed_words(words)?;
538            Changed::Tags { added, removed }
539        }
540        ("related", Some(words)) => {
541            let (added, removed) = signed_words(words)?;
542            Changed::Related { added, removed }
543        }
544        ("description", None) => Changed::Description,
545        _ => return None,
546    };
547    lines.next().is_none().then(|| Activity::new(at, by, change))
548}
549
550/// The `+word` and `-word` words of an `## Activity` list's change; `None` when one is neither, or there's none.
551fn signed_words(words: &str) -> Option<(Vec<String>, Vec<String>)> {
552    let (mut added, mut removed) = (Vec::new(), Vec::new());
553    for word in words.split_whitespace() {
554        let (list, word) = match word.split_at_checked(1)? {
555            ("+", word) => (&mut added, word),
556            ("-", word) => (&mut removed, word),
557            _ => return None,
558        };
559        if word.is_empty() {
560            return None;
561        }
562        list.push(word.to_string());
563    }
564    (!added.is_empty() || !removed.is_empty()).then_some((added, removed))
565}
566
567/// ` (agent)` after what an agent did; nothing after what a person did, so their files read as before.
568fn by_mark(by: Author) -> &'static str {
569    match by {
570        Author::Human => "",
571        Author::Agent => " (agent)",
572    }
573}
574
575/// What a comment's header says in parentheses after its time: `agent`, `question` or `answer`, words this version
576/// doesn't know, then `edited <time>`.
577fn comment_attributes(comment: &Comment) -> Vec<String> {
578    let author = (comment.author != Author::Human).then(|| comment.author.as_str().to_string());
579    let question = comment.question.then(|| "question".to_string());
580    let answer = comment.answer.then(|| "answer".to_string());
581    let edited = comment.edited.map(|at| format!("edited {}", fmt_ts(at)));
582    author.into_iter().chain(question).chain(answer).chain(comment.unknown.iter().cloned()).chain(edited).collect()
583}
584
585/// Parses `### <created>` or `### <created> (<attributes>)`, the attributes as [`comment_attributes`]
586/// writes them, in any order. A `###` line whose time doesn't parse isn't a header, but part of a comment.
587fn comment_header(line: &str) -> Option<Comment> {
588    let header = line.strip_prefix("### ")?;
589    let (created, attributes) = match header.split_once('(') {
590        Some((created, attributes)) => (created, attributes.trim_end().trim_end_matches(')')),
591        None => (header, ""),
592    };
593    let mut comment = Comment::new(parse_ts(created)?, String::new(), Author::Human);
594    for word in attributes.split(',').map(str::trim).filter(|w| !w.is_empty()) {
595        if word == "question" {
596            comment.question = true;
597        } else if word == "answer" {
598            comment.answer = true;
599        } else if let Some(author) = Author::parse(word) {
600            comment.author = author;
601        } else if let Some(at) = word.strip_prefix("edited ") {
602            comment.edited = parse_ts(at);
603        } else {
604            comment.unknown.push(word.to_string());
605        }
606    }
607    Some(comment)
608}
609
610#[cfg(test)]
611#[path = "../../tests/model/markdown.rs"]
612mod tests;