1use 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 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 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 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 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 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 pub fn from_markdown(text: &str) -> Result<Task, String> {
174 let mut parsed = Parsed::default();
175 let mut section = Section::Meta;
176 let lines: Vec<&str> = text.lines().collect();
180 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 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 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
224pub(crate) const MISSING_ID: &str = "missing `- id:` line";
227
228#[derive(Clone, Copy)]
230enum Section {
231 Meta,
233 Description,
235 Comments,
237 Activity,
239 History,
241 Sessions,
243 Other,
245}
246
247impl Section {
248 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#[derive(Default)]
263struct Parsed<'a> {
264 title: Option<String>,
266 id: Option<u32>,
268 kind: Option<Kind>,
270 links: Vec<u32>,
272 repo_links: Vec<RepoLink>,
274 status: Option<Status>,
276 decision: DecisionState,
278 tags: Vec<String>,
280 created: Option<DateTime<Local>>,
282 description: Vec<&'a str>,
284 comments: Vec<(Comment, Vec<&'a str>)>,
286 history: Vec<StatusChange>,
288 activity: Vec<String>,
290 sessions: Vec<Session>,
292 unknown: Unknown,
294}
295
296impl<'a> Parsed<'a> {
297 fn meta_line(&mut self, line: &str) {
299 if self.title.is_none()
300 && let Some(title) = line.strip_prefix("# ")
301 {
302 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 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 "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 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 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 if let Some((_, body)) = self.comments.last_mut() {
358 body.push(line);
359 }
360 }
361 }
362 }
363
364 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 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 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 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
462pub 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
476pub fn not_a_link(value: &str) -> Option<&str> {
479 link_words(value).find(|word| parse_link(word).is_none())
480}
481
482fn link_words(value: &str) -> impl Iterator<Item = &str> {
484 value.split([',', ' ']).map(str::trim).filter(|w| !w.is_empty())
485}
486
487enum Link {
489 Here(u32),
491 Repo(RepoLink),
493}
494
495fn 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
505fn 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
513fn 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
519fn 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
550fn 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
567fn by_mark(by: Author) -> &'static str {
569 match by {
570 Author::Human => "",
571 Author::Agent => " (agent)",
572 }
573}
574
575fn 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
585fn 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;