1use 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 pub fn to_markdown(&self) -> String {
63 let mut out = String::new();
64 infallible(self.write_markdown(&mut out));
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 Self::parse(text).map_err(|e| e.to_string())
175 }
176
177 pub(crate) fn parse(text: &str) -> Result<Task, ParseError> {
179 let mut parsed = Parsed::default();
180 let mut section = Section::Meta;
181 let lines: Vec<&str> = text.lines().collect();
185 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 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 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
229pub(crate) const MISSING_ID: &str = "missing `- id:` line";
232
233#[derive(Debug, PartialEq, Eq)]
235pub(crate) enum ParseError {
236 MissingId,
238 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#[derive(Clone, Copy)]
253enum Section {
254 Meta,
256 Description,
258 Comments,
260 Activity,
262 History,
264 Sessions,
266 Other,
268}
269
270impl Section {
271 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#[derive(Default)]
286struct Parsed<'a> {
287 title: Option<String>,
289 id: Option<u32>,
291 kind: Option<Kind>,
293 links: Vec<u32>,
295 repo_links: Vec<RepoLink>,
297 status: Option<Status>,
299 decision: DecisionState,
301 tags: Vec<String>,
303 created: Option<DateTime<Local>>,
305 description: Vec<&'a str>,
307 comments: Vec<(Comment, Vec<&'a str>)>,
309 history: Vec<StatusChange>,
311 activity: Vec<String>,
313 sessions: Vec<Session>,
315 unknown: Unknown,
317}
318
319impl<'a> Parsed<'a> {
320 fn meta_line(&mut self, line: &str) {
322 if self.title.is_none()
323 && let Some(title) = line.strip_prefix("# ")
324 {
325 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 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 "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 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 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 if let Some((_, body)) = self.comments.last_mut() {
381 body.push(line);
382 }
383 }
384 }
385 }
386
387 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 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 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 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
485pub 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
499pub fn not_a_link(value: &str) -> Option<&str> {
502 link_words(value).find(|word| parse_link(word).is_none())
503}
504
505fn link_words(value: &str) -> impl Iterator<Item = &str> {
507 value.split([',', ' ']).map(str::trim).filter(|w| !w.is_empty())
508}
509
510enum Link {
512 Here(u32),
514 Repo(RepoLink),
516}
517
518fn 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
528fn 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
536fn 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
542fn 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
573fn 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
590fn by_mark(by: Author) -> &'static str {
592 match by {
593 Author::Human => "",
594 Author::Agent => " (agent)",
595 }
596}
597
598fn 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
608fn 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;