use super::text::parse_tags;
use super::time::{fmt_dur, fmt_ts, now, parse_ts};
use super::{Author, Comment, DecisionState, Kind, RepoLink, Session, Status, StatusChange, Task, Unknown};
use chrono::{DateTime, Local};
use std::fmt::Write;
impl Task {
pub fn to_markdown(&self) -> String {
let mut out = String::new();
self.write_markdown(&mut out).expect("writing to a String cannot fail");
out
}
fn write_markdown(&self, out: &mut String) -> std::fmt::Result {
writeln!(out, "# {}\n", self.title)?;
writeln!(out, "- id: {}", self.id)?;
if self.is_task() {
writeln!(out, "- status: {}", self.status.as_str())?;
} else {
writeln!(out, "- kind: {}", self.kind.as_str())?;
}
if self.kind == Kind::Decision && self.decision != DecisionState::Accepted {
writeln!(out, "- decision: {}", self.decision.as_str())?;
}
writeln!(out, "- tags: {}", self.tags.join(", "))?;
if !self.links.is_empty() || !self.repo_links.is_empty() || !self.unknown.links.is_empty() {
let local = self.links.iter().map(u32::to_string);
let links: Vec<String> = local
.chain(self.repo_links.iter().map(RepoLink::to_string))
.chain(self.unknown.links.iter().cloned())
.collect();
writeln!(out, "- links: {}", links.join(", "))?;
}
for line in &self.unknown.meta {
writeln!(out, "{line}")?;
}
writeln!(out, "- created: {}\n", fmt_ts(self.created))?;
writeln!(out, "## Description\n")?;
if !self.description.is_empty() {
writeln!(out, "{}\n", self.description)?;
}
writeln!(out, "## Comments\n")?;
for comment in &self.comments {
write!(out, "### {}", fmt_ts(comment.created))?;
let attributes = comment_attributes(comment);
if !attributes.is_empty() {
write!(out, " ({})", attributes.join(", "))?;
}
writeln!(out, "\n")?;
if !comment.body.is_empty() {
writeln!(out, "{}\n", comment.body)?;
}
}
if self.is_task() {
self.write_tracking(out)?;
}
for section in &self.unknown.sections {
if !out.ends_with("\n\n") {
out.push('\n');
}
writeln!(out, "{section}\n")?;
}
Ok(())
}
fn write_tracking(&self, out: &mut String) -> std::fmt::Result {
writeln!(out, "## History\n")?;
for change in &self.history {
write!(out, "- {} {} -> {}", fmt_ts(change.at), change.from.as_str(), change.to.as_str())?;
writeln!(out, "{}", by_mark(change.by))?;
}
if !self.sessions.is_empty() {
writeln!(out, "\n## Sessions\n")?;
}
for session in &self.sessions {
match session.end {
Some(end) => {
let duration = fmt_dur(end - session.start);
writeln!(out, "- {} -> {} ({duration})", fmt_ts(session.start), fmt_ts(end))?;
}
None => writeln!(out, "- {} -> running", fmt_ts(session.start))?,
}
}
Ok(())
}
pub fn from_markdown(text: &str) -> Result<Task, String> {
let mut parsed = Parsed::default();
let mut section = Section::Meta;
let lines: Vec<&str> = text.lines().collect();
let id_at = lines.iter().position(|l| l.trim_start().starts_with("- id:")).unwrap_or_default();
let continued = match lines[..id_at].iter().position(|l| l.starts_with("# ")) {
Some(title) => {
let is_key = |l: &&&str| l.trim_start().strip_prefix("- ").is_some_and(|rest| rest.contains(':'));
let run = lines[title + 1..id_at].iter().take_while(|l| !l.trim().is_empty() && !is_key(l)).count();
title + 1..title + 1 + run
}
None => 0..0,
};
for (at, line) in text.lines().enumerate() {
let trimmed = line.trim_end();
if continued.contains(&at) {
if let Some(title) = &mut parsed.title
&& !trimmed.trim().is_empty()
{
title.push(' ');
title.push_str(trimmed.trim());
}
continue;
}
if let Some(heading) = trimmed.strip_prefix("## ") {
section = Section::from_heading(heading);
if matches!(section, Section::Other) {
parsed.unknown.sections.push(trimmed.to_string());
}
continue;
}
match section {
Section::Meta => parsed.meta_line(trimmed),
Section::Description => parsed.description.push(line),
Section::Comments => parsed.comment_line(line),
Section::History => parsed.history_line(trimmed)?,
Section::Sessions => parsed.session_line(trimmed)?,
Section::Other => parsed.other_line(line),
}
}
parsed.into_task()
}
}
pub(crate) const MISSING_ID: &str = "missing `- id:` line";
#[derive(Clone, Copy)]
enum Section {
Meta,
Description,
Comments,
History,
Sessions,
Other,
}
impl Section {
fn from_heading(heading: &str) -> Self {
match heading.trim() {
"Description" => Section::Description,
"Comments" => Section::Comments,
"History" => Section::History,
"Sessions" => Section::Sessions,
_ => Section::Other,
}
}
}
#[derive(Default)]
struct Parsed<'a> {
title: Option<String>,
id: Option<u32>,
kind: Option<Kind>,
links: Vec<u32>,
repo_links: Vec<RepoLink>,
status: Option<Status>,
decision: DecisionState,
tags: Vec<String>,
created: Option<DateTime<Local>>,
description: Vec<&'a str>,
comments: Vec<(Comment, Vec<&'a str>)>,
history: Vec<StatusChange>,
sessions: Vec<Session>,
unknown: Unknown,
}
impl<'a> Parsed<'a> {
fn meta_line(&mut self, line: &str) {
if self.title.is_none()
&& let Some(title) = line.strip_prefix("# ")
{
self.title = Some(title.trim().to_string());
return;
}
let Some((key, value)) = line.trim_start().strip_prefix("- ").and_then(|rest| rest.split_once(':')) else {
if self.title.is_some() && !line.trim().is_empty() {
self.unknown.meta.push(line.trim().to_string());
}
return;
};
match key.trim() {
"id" => self.id = value.trim().parse().ok(),
"status" => self.status = Status::parse(value),
"kind" => self.kind = Kind::parse(value),
"decision" => match DecisionState::parse(value) {
Some(decision) => self.decision = decision,
None => self.unknown.meta.push(line.trim().to_string()),
},
"links" => {
let (links, repo_links) = parse_links(value);
for link in links {
if !self.links.contains(&link) {
self.links.push(link);
}
}
for link in repo_links {
if !self.repo_links.contains(&link) {
self.repo_links.push(link);
}
}
self.unknown.links.extend(link_words(value).filter(|w| parse_link(w).is_none()).map(String::from));
}
"tags" => self.tags = parse_tags(value),
"created" => self.created = parse_ts(value),
_ => self.unknown.meta.push(line.trim().to_string()),
}
}
fn other_line(&mut self, line: &str) {
if let Some(section) = self.unknown.sections.last_mut() {
section.push('\n');
section.push_str(line);
}
}
fn comment_line(&mut self, line: &'a str) {
match comment_header(line.trim_end()) {
Some(comment) => self.comments.push((comment, Vec::new())),
None => {
if let Some((_, body)) = self.comments.last_mut() {
body.push(line);
}
}
}
}
fn history_line(&mut self, line: &str) -> Result<(), String> {
let Some(rest) = line.trim_start().strip_prefix("- ") else { return Ok(()) };
let bad = || format!("bad history line: {rest}");
let (left, right) = rest.split_once("->").ok_or_else(bad)?;
let (at, from) = left.trim().rsplit_once(' ').ok_or_else(bad)?;
let at = parse_ts(at).ok_or_else(|| format!("bad timestamp: {}", at.trim()))?;
let from = Status::parse(from).ok_or_else(bad)?;
let (to, by) = right.split_once('(').unwrap_or((right, ""));
let to = Status::parse(to).ok_or_else(bad)?;
let by = Author::parse(by.trim().trim_end_matches(')')).unwrap_or_default();
self.history.push(StatusChange { at, from, to, by });
Ok(())
}
fn session_line(&mut self, line: &str) -> Result<(), String> {
let Some(rest) = line.trim_start().strip_prefix("- ") else { return Ok(()) };
let (start, end) = rest.split_once("->").ok_or_else(|| format!("bad session line: {rest}"))?;
let start = parse_ts(start).ok_or_else(|| format!("bad timestamp: {}", start.trim()))?;
let end = end.split('(').next().unwrap_or_default().trim();
let end =
if end == "running" { None } else { Some(parse_ts(end).ok_or_else(|| format!("bad timestamp: {end}"))?) };
self.sessions.push(Session { start, end });
Ok(())
}
fn into_task(mut self) -> Result<Task, String> {
let id = self.id.ok_or(MISSING_ID)?;
self.history.sort_by_key(|c| c.at);
self.sessions.sort_by_key(|s| s.start);
let next_starts: Vec<_> = self.sessions.iter().skip(1).map(|s| s.start).collect();
for (session, next_start) in self.sessions.iter_mut().zip(next_starts) {
session.end.get_or_insert(next_start);
}
let status = self.status.unwrap_or(Status::Todo);
if status != Status::Doing
&& let Some(last) = self.sessions.last_mut()
&& last.end.is_none()
{
let changed = self.history.last().map_or(last.start, |c| c.at);
last.end = Some(changed.max(last.start));
}
Ok(Task {
id,
kind: self.kind.unwrap_or_default(),
links: self.links,
repo_links: self.repo_links,
title: self.title.unwrap_or_else(|| "untitled".into()),
tags: self.tags,
status,
decision: self.decision,
created: self.created.or_else(|| self.history.first().map(|c| c.at)).unwrap_or_else(now),
description: without_blank_ends(&self.description),
comments: self
.comments
.into_iter()
.map(|(comment, body)| Comment { body: without_blank_ends(&body), ..comment })
.collect(),
history: self.history,
sessions: self.sessions,
path: None,
unknown: Unknown {
sections: self.unknown.sections.into_iter().map(|s| s.trim_end().to_string()).collect(),
..self.unknown
},
})
}
}
pub fn parse_links(value: &str) -> (Vec<u32>, Vec<RepoLink>) {
let (mut links, mut repo_links) = (Vec::new(), Vec::new());
for link in link_words(value).filter_map(parse_link) {
match link {
Link::Here(id) if !links.contains(&id) => links.push(id),
Link::Repo(link) if !repo_links.contains(&link) => repo_links.push(link),
Link::Here(_) | Link::Repo(_) => {}
}
}
(links, repo_links)
}
pub fn not_a_link(value: &str) -> Option<&str> {
link_words(value).find(|word| parse_link(word).is_none())
}
fn link_words(value: &str) -> impl Iterator<Item = &str> {
value.split([',', ' ']).map(str::trim).filter(|w| !w.is_empty())
}
enum Link {
Here(u32),
Repo(RepoLink),
}
fn parse_link(word: &str) -> Option<Link> {
match word.rsplit_once('#') {
Some((repo, id)) if !repo.is_empty() => {
Some(Link::Repo(RepoLink { repo: repo.to_string(), id: id.parse().ok()? }))
}
_ => word.trim_start_matches('#').parse().ok().map(Link::Here),
}
}
fn without_blank_ends(lines: &[&str]) -> String {
let blank = |line: &&&str| line.trim().is_empty();
let start = lines.iter().position(|line| !blank(&line)).unwrap_or(lines.len());
let end = lines.iter().rposition(|line| !blank(&line)).map_or(start, |at| at + 1);
lines[start..end].join("\n").trim_end().to_string()
}
fn by_mark(by: Author) -> &'static str {
match by {
Author::Human => "",
Author::Agent => " (agent)",
}
}
fn comment_attributes(comment: &Comment) -> Vec<String> {
let author = (comment.author != Author::Human).then(|| comment.author.as_str().to_string());
let question = comment.question.then(|| "question".to_string());
let answer = comment.answer.then(|| "answer".to_string());
let edited = comment.edited.map(|at| format!("edited {}", fmt_ts(at)));
author.into_iter().chain(question).chain(answer).chain(comment.unknown.iter().cloned()).chain(edited).collect()
}
fn comment_header(line: &str) -> Option<Comment> {
let header = line.strip_prefix("### ")?;
let (created, attributes) = match header.split_once('(') {
Some((created, attributes)) => (created, attributes.trim_end().trim_end_matches(')')),
None => (header, ""),
};
let mut comment = Comment::new(parse_ts(created)?, String::new(), Author::Human);
for word in attributes.split(',').map(str::trim).filter(|w| !w.is_empty()) {
if word == "question" {
comment.question = true;
} else if word == "answer" {
comment.answer = true;
} else if let Some(author) = Author::parse(word) {
comment.author = author;
} else if let Some(at) = word.strip_prefix("edited ") {
comment.edited = parse_ts(at);
} else {
comment.unknown.push(word.to_string());
}
}
Some(comment)
}
#[cfg(test)]
#[path = "../../tests/model/markdown.rs"]
mod tests;