1use 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 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 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 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 pub fn from_markdown(text: &str) -> Result<Task, String> {
139 let mut parsed = Parsed::default();
140 let mut section = Section::Meta;
141 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 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#[derive(Clone, Copy)]
167enum Section {
168 Meta,
170 Description,
172 Comments,
174 History,
176 Sessions,
178 Other,
180}
181
182impl Section {
183 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#[derive(Default)]
197struct Parsed<'a> {
198 title: Option<String>,
200 id: Option<u32>,
202 kind: Option<Kind>,
204 links: Vec<u32>,
206 repo_links: Vec<RepoLink>,
208 status: Option<Status>,
210 decision: DecisionState,
212 tags: Vec<String>,
214 created: Option<DateTime<Local>>,
216 description: Vec<&'a str>,
218 comments: Vec<(Comment, Vec<&'a str>)>,
220 history: Vec<StatusChange>,
222 sessions: Vec<Session>,
224 unknown: Unknown,
226}
227
228impl<'a> Parsed<'a> {
229 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 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 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 if let Some((_, body)) = self.comments.last_mut() {
274 body.push(line);
275 }
276 }
277 }
278 }
279
280 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 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 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
354pub 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
368fn link_words(value: &str) -> impl Iterator<Item = &str> {
370 value.split([',', ' ']).map(str::trim).filter(|w| !w.is_empty())
371}
372
373enum Link {
375 Here(u32),
377 Repo(RepoLink),
379}
380
381fn 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
391fn by_mark(by: Author) -> &'static str {
393 match by {
394 Author::Human => "",
395 Author::Agent => " (agent)",
396 }
397}
398
399fn 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
408fn 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;