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
187pub(crate) const MISSING_ID: &str = "missing `- id:` line";
190
191#[derive(Clone, Copy)]
193enum Section {
194 Meta,
196 Description,
198 Comments,
200 History,
202 Sessions,
204 Other,
206}
207
208impl Section {
209 fn from_heading(heading: &str) -> Self {
211 match heading.trim() {
212 "Description" => Section::Description,
213 "Comments" => Section::Comments,
214 "History" => Section::History,
215 "Sessions" => Section::Sessions,
216 _ => Section::Other,
217 }
218 }
219}
220
221#[derive(Default)]
223struct Parsed<'a> {
224 title: Option<String>,
226 id: Option<u32>,
228 kind: Option<Kind>,
230 links: Vec<u32>,
232 repo_links: Vec<RepoLink>,
234 status: Option<Status>,
236 decision: DecisionState,
238 tags: Vec<String>,
240 created: Option<DateTime<Local>>,
242 description: Vec<&'a str>,
244 comments: Vec<(Comment, Vec<&'a str>)>,
246 history: Vec<StatusChange>,
248 sessions: Vec<Session>,
250 unknown: Unknown,
252}
253
254impl<'a> Parsed<'a> {
255 fn meta_line(&mut self, line: &str) {
257 if self.title.is_none()
258 && let Some(title) = line.strip_prefix("# ")
259 {
260 self.title = Some(title.trim().to_string());
261 return;
262 }
263 let Some((key, value)) = line.trim_start().strip_prefix("- ").and_then(|rest| rest.split_once(':')) else {
264 if self.title.is_some() && !line.trim().is_empty() {
266 self.unknown.meta.push(line.trim().to_string());
267 }
268 return;
269 };
270 match key.trim() {
271 "id" => self.id = value.trim().parse().ok(),
272 "status" => self.status = Status::parse(value),
273 "kind" => self.kind = Kind::parse(value),
274 "decision" => match DecisionState::parse(value) {
275 Some(decision) => self.decision = decision,
276 None => self.unknown.meta.push(line.trim().to_string()),
277 },
278 "links" => {
280 let (links, repo_links) = parse_links(value);
281 for link in links {
282 if !self.links.contains(&link) {
283 self.links.push(link);
284 }
285 }
286 for link in repo_links {
287 if !self.repo_links.contains(&link) {
288 self.repo_links.push(link);
289 }
290 }
291 self.unknown.links.extend(link_words(value).filter(|w| parse_link(w).is_none()).map(String::from));
292 }
293 "tags" => self.tags = parse_tags(value),
294 "created" => self.created = parse_ts(value),
295 _ => self.unknown.meta.push(line.trim().to_string()),
296 }
297 }
298
299 fn other_line(&mut self, line: &str) {
302 if let Some(section) = self.unknown.sections.last_mut() {
303 section.push('\n');
304 section.push_str(line);
305 }
306 }
307
308 fn comment_line(&mut self, line: &'a str) {
310 match comment_header(line.trim_end()) {
311 Some(comment) => self.comments.push((comment, Vec::new())),
312 None => {
313 if let Some((_, body)) = self.comments.last_mut() {
315 body.push(line);
316 }
317 }
318 }
319 }
320
321 fn history_line(&mut self, line: &str) -> Result<(), String> {
324 let Some(rest) = line.trim_start().strip_prefix("- ") else { return Ok(()) };
325 let bad = || format!("bad history line: {rest}");
326 let (left, right) = rest.split_once("->").ok_or_else(bad)?;
327 let (at, from) = left.trim().rsplit_once(' ').ok_or_else(bad)?;
328 let at = parse_ts(at).ok_or_else(|| format!("bad timestamp: {}", at.trim()))?;
329 let from = Status::parse(from).ok_or_else(bad)?;
330 let (to, by) = right.split_once('(').unwrap_or((right, ""));
331 let to = Status::parse(to).ok_or_else(bad)?;
332 let by = Author::parse(by.trim().trim_end_matches(')')).unwrap_or_default();
333 self.history.push(StatusChange { at, from, to, by });
334 Ok(())
335 }
336
337 fn session_line(&mut self, line: &str) -> Result<(), String> {
339 let Some(rest) = line.trim_start().strip_prefix("- ") else { return Ok(()) };
340 let (start, end) = rest.split_once("->").ok_or_else(|| format!("bad session line: {rest}"))?;
341 let start = parse_ts(start).ok_or_else(|| format!("bad timestamp: {}", start.trim()))?;
342 let end = end.split('(').next().unwrap_or_default().trim();
343 let end =
344 if end == "running" { None } else { Some(parse_ts(end).ok_or_else(|| format!("bad timestamp: {end}"))?) };
345 self.sessions.push(Session { start, end });
346 Ok(())
347 }
348
349 fn into_task(mut self) -> Result<Task, String> {
353 let id = self.id.ok_or(MISSING_ID)?;
354 self.history.sort_by_key(|c| c.at);
355 self.sessions.sort_by_key(|s| s.start);
356 let next_starts: Vec<_> = self.sessions.iter().skip(1).map(|s| s.start).collect();
357 for (session, next_start) in self.sessions.iter_mut().zip(next_starts) {
358 session.end.get_or_insert(next_start);
359 }
360 let status = self.status.unwrap_or(Status::Todo);
361 if status != Status::Doing
362 && let Some(last) = self.sessions.last_mut()
363 && last.end.is_none()
364 {
365 let changed = self.history.last().map_or(last.start, |c| c.at);
366 last.end = Some(changed.max(last.start));
367 }
368 Ok(Task {
369 id,
370 kind: self.kind.unwrap_or_default(),
371 links: self.links,
372 repo_links: self.repo_links,
373 title: self.title.unwrap_or_else(|| "untitled".into()),
374 tags: self.tags,
375 status,
376 decision: self.decision,
377 created: self.created.or_else(|| self.history.first().map(|c| c.at)).unwrap_or_else(now),
378 description: without_blank_ends(&self.description),
379 comments: self
380 .comments
381 .into_iter()
382 .map(|(comment, body)| Comment { body: without_blank_ends(&body), ..comment })
383 .collect(),
384 history: self.history,
385 sessions: self.sessions,
386 path: None,
387 unknown: Unknown {
388 sections: self.unknown.sections.into_iter().map(|s| s.trim_end().to_string()).collect(),
389 ..self.unknown
390 },
391 })
392 }
393}
394
395pub fn parse_links(value: &str) -> (Vec<u32>, Vec<RepoLink>) {
398 let (mut links, mut repo_links) = (Vec::new(), Vec::new());
399 for link in link_words(value).filter_map(parse_link) {
400 match link {
401 Link::Here(id) if !links.contains(&id) => links.push(id),
402 Link::Repo(link) if !repo_links.contains(&link) => repo_links.push(link),
403 Link::Here(_) | Link::Repo(_) => {}
404 }
405 }
406 (links, repo_links)
407}
408
409pub fn not_a_link(value: &str) -> Option<&str> {
412 link_words(value).find(|word| parse_link(word).is_none())
413}
414
415fn link_words(value: &str) -> impl Iterator<Item = &str> {
417 value.split([',', ' ']).map(str::trim).filter(|w| !w.is_empty())
418}
419
420enum Link {
422 Here(u32),
424 Repo(RepoLink),
426}
427
428fn parse_link(word: &str) -> Option<Link> {
430 match word.rsplit_once('#') {
431 Some((repo, id)) if !repo.is_empty() => {
432 Some(Link::Repo(RepoLink { repo: repo.to_string(), id: id.parse().ok()? }))
433 }
434 _ => word.trim_start_matches('#').parse().ok().map(Link::Here),
435 }
436}
437
438fn without_blank_ends(lines: &[&str]) -> String {
440 let blank = |line: &&&str| line.trim().is_empty();
441 let start = lines.iter().position(|line| !blank(&line)).unwrap_or(lines.len());
442 let end = lines.iter().rposition(|line| !blank(&line)).map_or(start, |at| at + 1);
443 lines[start..end].join("\n").trim_end().to_string()
444}
445
446fn by_mark(by: Author) -> &'static str {
448 match by {
449 Author::Human => "",
450 Author::Agent => " (agent)",
451 }
452}
453
454fn comment_attributes(comment: &Comment) -> Vec<String> {
457 let author = (comment.author != Author::Human).then(|| comment.author.as_str().to_string());
458 let question = comment.question.then(|| "question".to_string());
459 let answer = comment.answer.then(|| "answer".to_string());
460 let edited = comment.edited.map(|at| format!("edited {}", fmt_ts(at)));
461 author.into_iter().chain(question).chain(answer).chain(comment.unknown.iter().cloned()).chain(edited).collect()
462}
463
464fn comment_header(line: &str) -> Option<Comment> {
467 let header = line.strip_prefix("### ")?;
468 let (created, attributes) = match header.split_once('(') {
469 Some((created, attributes)) => (created, attributes.trim_end().trim_end_matches(')')),
470 None => (header, ""),
471 };
472 let mut comment = Comment::new(parse_ts(created)?, String::new(), Author::Human);
473 for word in attributes.split(',').map(str::trim).filter(|w| !w.is_empty()) {
474 if word == "question" {
475 comment.question = true;
476 } else if word == "answer" {
477 comment.answer = true;
478 } else if let Some(author) = Author::parse(word) {
479 comment.author = author;
480 } else if let Some(at) = word.strip_prefix("edited ") {
481 comment.edited = parse_ts(at);
482 } else {
483 comment.unknown.push(word.to_string());
484 }
485 }
486 Some(comment)
487}
488
489#[cfg(test)]
490#[path = "../../tests/model/markdown.rs"]
491mod tests;