1use super::text::parse_tags;
43use super::time::{fmt_dur, fmt_ts, now, parse_ts};
44use super::{Author, Comment, DecisionState, Kind, RepoLink, Session, Status, StatusChange, Task, Unknown};
45use chrono::{DateTime, Local};
46use std::fmt::Write;
47
48impl Task {
49 pub fn to_markdown(&self) -> String {
51 let mut out = String::new();
52 self.write_markdown(&mut out).expect("writing to a String cannot fail");
53 out
54 }
55
56 fn write_markdown(&self, out: &mut String) -> std::fmt::Result {
58 writeln!(out, "# {}\n", self.title)?;
59 writeln!(out, "- id: {}", self.id)?;
60 if self.is_task() {
61 writeln!(out, "- status: {}", self.status.as_str())?;
62 } else {
63 writeln!(out, "- kind: {}", self.kind.as_str())?;
64 }
65 if self.kind == Kind::Decision && self.decision != DecisionState::Accepted {
66 writeln!(out, "- decision: {}", self.decision.as_str())?;
67 }
68 writeln!(out, "- tags: {}", self.tags.join(", "))?;
69 if !self.links.is_empty() || !self.repo_links.is_empty() || !self.unknown.links.is_empty() {
70 let local = self.links.iter().map(u32::to_string);
71 let links: Vec<String> = local
72 .chain(self.repo_links.iter().map(RepoLink::to_string))
73 .chain(self.unknown.links.iter().cloned())
74 .collect();
75 writeln!(out, "- links: {}", links.join(", "))?;
76 }
77 for line in &self.unknown.meta {
78 writeln!(out, "{line}")?;
79 }
80 writeln!(out, "- created: {}\n", fmt_ts(self.created))?;
81
82 writeln!(out, "## Description\n")?;
83 if !self.description.is_empty() {
84 writeln!(out, "{}\n", self.description)?;
85 }
86
87 writeln!(out, "## Comments\n")?;
88 for comment in &self.comments {
89 write!(out, "### {}", fmt_ts(comment.created))?;
90 let attributes = comment_attributes(comment);
91 if !attributes.is_empty() {
92 write!(out, " ({})", attributes.join(", "))?;
93 }
94 writeln!(out, "\n")?;
95 if !comment.body.is_empty() {
96 writeln!(out, "{}\n", comment.body)?;
97 }
98 }
99
100 if self.is_task() {
101 self.write_tracking(out)?;
102 }
103 for section in &self.unknown.sections {
104 if !out.ends_with("\n\n") {
105 out.push('\n');
106 }
107 writeln!(out, "{section}\n")?;
108 }
109 Ok(())
110 }
111
112 fn write_tracking(&self, out: &mut String) -> std::fmt::Result {
114 writeln!(out, "## History\n")?;
115 for change in &self.history {
116 write!(out, "- {} {} -> {}", fmt_ts(change.at), change.from.as_str(), change.to.as_str())?;
117 writeln!(out, "{}", by_mark(change.by))?;
118 }
119
120 if !self.sessions.is_empty() {
121 writeln!(out, "\n## Sessions\n")?;
122 }
123 for session in &self.sessions {
124 match session.end {
125 Some(end) => {
126 let duration = fmt_dur(end - session.start);
127 writeln!(out, "- {} -> {} ({duration})", fmt_ts(session.start), fmt_ts(end))?;
128 }
129 None => writeln!(out, "- {} -> running", fmt_ts(session.start))?,
130 }
131 }
132 Ok(())
133 }
134
135 pub fn from_markdown(text: &str) -> Result<Task, String> {
138 let mut parsed = Parsed::default();
139 let mut section = Section::Meta;
140 let lines: Vec<&str> = text.lines().collect();
144 let id_at = lines.iter().position(|l| l.trim_start().starts_with("- id:")).unwrap_or_default();
146 let continued = match lines[..id_at].iter().position(|l| l.starts_with("# ")) {
147 Some(title) => {
148 let is_key = |l: &&&str| l.trim_start().strip_prefix("- ").is_some_and(|rest| rest.contains(':'));
149 let run = lines[title + 1..id_at].iter().take_while(|l| !l.trim().is_empty() && !is_key(l)).count();
150 title + 1..title + 1 + run
151 }
152 None => 0..0,
153 };
154 for (at, line) in text.lines().enumerate() {
156 let trimmed = line.trim_end();
157 if continued.contains(&at) {
158 if let Some(title) = &mut parsed.title
159 && !trimmed.trim().is_empty()
160 {
161 title.push(' ');
162 title.push_str(trimmed.trim());
163 }
164 continue;
165 }
166 if let Some(heading) = trimmed.strip_prefix("## ") {
167 section = Section::from_heading(heading);
168 if matches!(section, Section::Other) {
169 parsed.unknown.sections.push(trimmed.to_string());
170 }
171 continue;
172 }
173 match section {
174 Section::Meta => parsed.meta_line(trimmed),
175 Section::Description => parsed.description.push(line),
177 Section::Comments => parsed.comment_line(line),
178 Section::History => parsed.history_line(trimmed)?,
179 Section::Sessions => parsed.session_line(trimmed)?,
180 Section::Other => parsed.other_line(line),
181 }
182 }
183 parsed.into_task()
184 }
185}
186
187#[derive(Clone, Copy)]
189enum Section {
190 Meta,
192 Description,
194 Comments,
196 History,
198 Sessions,
200 Other,
202}
203
204impl Section {
205 fn from_heading(heading: &str) -> Self {
207 match heading.trim() {
208 "Description" => Section::Description,
209 "Comments" => Section::Comments,
210 "History" => Section::History,
211 "Sessions" => Section::Sessions,
212 _ => Section::Other,
213 }
214 }
215}
216
217#[derive(Default)]
219struct Parsed<'a> {
220 title: Option<String>,
222 id: Option<u32>,
224 kind: Option<Kind>,
226 links: Vec<u32>,
228 repo_links: Vec<RepoLink>,
230 status: Option<Status>,
232 decision: DecisionState,
234 tags: Vec<String>,
236 created: Option<DateTime<Local>>,
238 description: Vec<&'a str>,
240 comments: Vec<(Comment, Vec<&'a str>)>,
242 history: Vec<StatusChange>,
244 sessions: Vec<Session>,
246 unknown: Unknown,
248}
249
250impl<'a> Parsed<'a> {
251 fn meta_line(&mut self, line: &str) {
253 if self.title.is_none()
254 && let Some(title) = line.strip_prefix("# ")
255 {
256 self.title = Some(title.trim().to_string());
257 return;
258 }
259 let Some((key, value)) = line.trim_start().strip_prefix("- ").and_then(|rest| rest.split_once(':')) else {
260 if self.title.is_some() && !line.trim().is_empty() {
262 self.unknown.meta.push(line.trim().to_string());
263 }
264 return;
265 };
266 match key.trim() {
267 "id" => self.id = value.trim().parse().ok(),
268 "status" => self.status = Status::parse(value),
269 "kind" => self.kind = Kind::parse(value),
270 "decision" => match DecisionState::parse(value) {
271 Some(decision) => self.decision = decision,
272 None => self.unknown.meta.push(line.trim().to_string()),
273 },
274 "links" => {
276 let (links, repo_links) = parse_links(value);
277 for link in links {
278 if !self.links.contains(&link) {
279 self.links.push(link);
280 }
281 }
282 for link in repo_links {
283 if !self.repo_links.contains(&link) {
284 self.repo_links.push(link);
285 }
286 }
287 self.unknown.links.extend(link_words(value).filter(|w| parse_link(w).is_none()).map(String::from));
288 }
289 "tags" => self.tags = parse_tags(value),
290 "created" => self.created = parse_ts(value),
291 _ => self.unknown.meta.push(line.trim().to_string()),
292 }
293 }
294
295 fn other_line(&mut self, line: &str) {
298 if let Some(section) = self.unknown.sections.last_mut() {
299 section.push('\n');
300 section.push_str(line);
301 }
302 }
303
304 fn comment_line(&mut self, line: &'a str) {
306 match comment_header(line.trim_end()) {
307 Some(comment) => self.comments.push((comment, Vec::new())),
308 None => {
309 if let Some((_, body)) = self.comments.last_mut() {
311 body.push(line);
312 }
313 }
314 }
315 }
316
317 fn history_line(&mut self, line: &str) -> Result<(), String> {
320 let Some(rest) = line.trim_start().strip_prefix("- ") else { return Ok(()) };
321 let bad = || format!("bad history line: {rest}");
322 let (left, right) = rest.split_once("->").ok_or_else(bad)?;
323 let (at, from) = left.trim().rsplit_once(' ').ok_or_else(bad)?;
324 let at = parse_ts(at).ok_or_else(|| format!("bad timestamp: {}", at.trim()))?;
325 let from = Status::parse(from).ok_or_else(bad)?;
326 let (to, by) = right.split_once('(').unwrap_or((right, ""));
327 let to = Status::parse(to).ok_or_else(bad)?;
328 let by = Author::parse(by.trim().trim_end_matches(')')).unwrap_or_default();
329 self.history.push(StatusChange { at, from, to, by });
330 Ok(())
331 }
332
333 fn session_line(&mut self, line: &str) -> Result<(), String> {
335 let Some(rest) = line.trim_start().strip_prefix("- ") else { return Ok(()) };
336 let (start, end) = rest.split_once("->").ok_or_else(|| format!("bad session line: {rest}"))?;
337 let start = parse_ts(start).ok_or_else(|| format!("bad timestamp: {}", start.trim()))?;
338 let end = end.split('(').next().unwrap_or_default().trim();
339 let end =
340 if end == "running" { None } else { Some(parse_ts(end).ok_or_else(|| format!("bad timestamp: {end}"))?) };
341 self.sessions.push(Session { start, end });
342 Ok(())
343 }
344
345 fn into_task(mut self) -> Result<Task, String> {
349 let id = self.id.ok_or("missing `- id:` line")?;
350 self.history.sort_by_key(|c| c.at);
351 self.sessions.sort_by_key(|s| s.start);
352 let next_starts: Vec<_> = self.sessions.iter().skip(1).map(|s| s.start).collect();
353 for (session, next_start) in self.sessions.iter_mut().zip(next_starts) {
354 session.end.get_or_insert(next_start);
355 }
356 let status = self.status.unwrap_or(Status::Todo);
357 if status != Status::Doing
358 && let Some(last) = self.sessions.last_mut()
359 && last.end.is_none()
360 {
361 let changed = self.history.last().map_or(last.start, |c| c.at);
362 last.end = Some(changed.max(last.start));
363 }
364 Ok(Task {
365 id,
366 kind: self.kind.unwrap_or_default(),
367 links: self.links,
368 repo_links: self.repo_links,
369 title: self.title.unwrap_or_else(|| "untitled".into()),
370 tags: self.tags,
371 status,
372 decision: self.decision,
373 created: self.created.or_else(|| self.history.first().map(|c| c.at)).unwrap_or_else(now),
374 description: without_blank_ends(&self.description),
375 comments: self
376 .comments
377 .into_iter()
378 .map(|(comment, body)| Comment { body: without_blank_ends(&body), ..comment })
379 .collect(),
380 history: self.history,
381 sessions: self.sessions,
382 path: None,
383 unknown: Unknown {
384 sections: self.unknown.sections.into_iter().map(|s| s.trim_end().to_string()).collect(),
385 ..self.unknown
386 },
387 })
388 }
389}
390
391pub fn parse_links(value: &str) -> (Vec<u32>, Vec<RepoLink>) {
394 let (mut links, mut repo_links) = (Vec::new(), Vec::new());
395 for link in link_words(value).filter_map(parse_link) {
396 match link {
397 Link::Here(id) if !links.contains(&id) => links.push(id),
398 Link::Repo(link) if !repo_links.contains(&link) => repo_links.push(link),
399 Link::Here(_) | Link::Repo(_) => {}
400 }
401 }
402 (links, repo_links)
403}
404
405pub fn not_a_link(value: &str) -> Option<&str> {
408 link_words(value).find(|word| parse_link(word).is_none())
409}
410
411fn link_words(value: &str) -> impl Iterator<Item = &str> {
413 value.split([',', ' ']).map(str::trim).filter(|w| !w.is_empty())
414}
415
416enum Link {
418 Here(u32),
420 Repo(RepoLink),
422}
423
424fn parse_link(word: &str) -> Option<Link> {
426 match word.rsplit_once('#') {
427 Some((repo, id)) if !repo.is_empty() => {
428 Some(Link::Repo(RepoLink { repo: repo.to_string(), id: id.parse().ok()? }))
429 }
430 _ => word.trim_start_matches('#').parse().ok().map(Link::Here),
431 }
432}
433
434fn without_blank_ends(lines: &[&str]) -> String {
436 let blank = |line: &&&str| line.trim().is_empty();
437 let start = lines.iter().position(|line| !blank(&line)).unwrap_or(lines.len());
438 let end = lines.iter().rposition(|line| !blank(&line)).map_or(start, |at| at + 1);
439 lines[start..end].join("\n").trim_end().to_string()
440}
441
442fn by_mark(by: Author) -> &'static str {
444 match by {
445 Author::Human => "",
446 Author::Agent => " (agent)",
447 }
448}
449
450fn comment_attributes(comment: &Comment) -> Vec<String> {
453 let author = (comment.author != Author::Human).then(|| comment.author.as_str().to_string());
454 let question = comment.question.then(|| "question".to_string());
455 let answer = comment.answer.then(|| "answer".to_string());
456 let edited = comment.edited.map(|at| format!("edited {}", fmt_ts(at)));
457 author.into_iter().chain(question).chain(answer).chain(comment.unknown.iter().cloned()).chain(edited).collect()
458}
459
460fn comment_header(line: &str) -> Option<Comment> {
463 let header = line.strip_prefix("### ")?;
464 let (created, attributes) = match header.split_once('(') {
465 Some((created, attributes)) => (created, attributes.trim_end().trim_end_matches(')')),
466 None => (header, ""),
467 };
468 let mut comment = Comment::new(parse_ts(created)?, String::new(), Author::Human);
469 for word in attributes.split(',').map(str::trim).filter(|w| !w.is_empty()) {
470 if word == "question" {
471 comment.question = true;
472 } else if word == "answer" {
473 comment.answer = true;
474 } else if let Some(author) = Author::parse(word) {
475 comment.author = author;
476 } else if let Some(at) = word.strip_prefix("edited ") {
477 comment.edited = parse_ts(at);
478 } else {
479 comment.unknown.push(word.to_string());
480 }
481 }
482 Some(comment)
483}
484
485#[cfg(test)]
486#[path = "../../tests/model/markdown.rs"]
487mod tests;