1use crate::backlog::{BacklogItem, ItemId, Status};
4use crate::error::{Error, Result};
5use crate::rank::Rank;
6use crate::sprint::{Sprint, SprintId, SprintState};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10
11const DELIMITER: &str = "+++";
13
14#[derive(Debug, Serialize, Deserialize)]
19struct Frontmatter {
20 id: String,
21 title: String,
22 status: String,
23 rank: String,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 points: Option<u32>,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 labels: Vec<String>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 assignee: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 sprint: Option<String>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 parent: Option<String>,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 depends_on: Vec<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 start_at: Option<DateTime<Utc>>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 done_at: Option<DateTime<Utc>>,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 commits: Vec<String>,
42 created: DateTime<Utc>,
43 updated: DateTime<Utc>,
44}
45
46impl Frontmatter {
47 fn from_item(item: &BacklogItem) -> Self {
48 Self {
49 id: item.id.to_string(),
50 title: item.title.clone(),
51 status: item.status.to_string(),
52 rank: item.rank.to_string(),
53 points: item.points,
54 labels: item.labels.clone(),
55 assignee: item.assignee.clone(),
56 sprint: item.sprint.clone(),
57 parent: item.parent.as_ref().map(ItemId::to_string),
58 depends_on: item.depends_on.iter().map(ItemId::to_string).collect(),
59 start_at: item.start_at,
60 done_at: item.done_at,
61 commits: item.commits.clone(),
62 created: item.created,
63 updated: item.updated,
64 }
65 }
66
67 fn into_item(self, body: String, path: &Path) -> Result<BacklogItem> {
68 let to_parse = |e: Error| Error::parse(path, e.to_string());
69 let id: ItemId = self.id.parse().map_err(to_parse)?;
70 let rank = Rank::parse(&self.rank).map_err(to_parse)?;
71 let parent = match self.parent {
72 Some(p) => Some(p.parse::<ItemId>().map_err(to_parse)?),
73 None => None,
74 };
75 let depends_on = self
76 .depends_on
77 .iter()
78 .map(|d| d.parse::<ItemId>().map_err(to_parse))
79 .collect::<Result<Vec<_>>>()?;
80 if self.title.trim().is_empty() {
82 return Err(Error::parse(path, Error::EmptyTitle.to_string()));
83 }
84 Ok(BacklogItem {
85 id,
86 title: self.title,
87 status: Status::new(self.status),
88 rank,
89 points: self.points,
90 labels: self.labels,
91 assignee: self.assignee,
92 sprint: self.sprint,
93 parent,
94 depends_on,
95 start_at: self.start_at,
96 done_at: self.done_at,
97 created: self.created,
98 updated: self.updated,
99 body,
100 commits: self.commits,
101 })
102 }
103}
104
105fn assemble_markdown(front_toml: &str, body: &str) -> String {
109 let mut out = String::new();
110 out.push_str(DELIMITER);
111 out.push('\n');
112 out.push_str(front_toml); out.push_str(DELIMITER);
114 out.push('\n');
115 if !body.is_empty() {
116 out.push('\n');
117 out.push_str(body);
118 out.push('\n');
119 }
120 out
121}
122
123pub(crate) fn to_markdown(item: &BacklogItem) -> Result<String> {
125 let fm = Frontmatter::from_item(item);
126 let toml = toml::to_string(&fm)
127 .map_err(|e| Error::parse(&PathBuf::from(format!("{}.md", item.id)), e.to_string()))?;
128 Ok(assemble_markdown(&toml, &item.body))
129}
130
131pub(crate) fn from_markdown(text: &str, path: &Path) -> Result<BacklogItem> {
133 let (front, body) = split_frontmatter(text).ok_or_else(|| Error::MissingFrontmatter {
134 path: path.to_path_buf(),
135 })?;
136 let fm: Frontmatter = toml::from_str(front).map_err(|e| Error::parse(path, e.to_string()))?;
137 fm.into_item(body.to_string(), path)
138}
139
140pub fn parse_item_markdown(text: &str, path: &Path) -> Result<BacklogItem> {
166 from_markdown(text, path)
167}
168
169#[derive(Debug, Serialize, Deserialize)]
174struct SprintFrontmatter {
175 id: String,
176 title: String,
177 state: String,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 start: Option<DateTime<Utc>>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 end: Option<DateTime<Utc>>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 daily_work_hours: Option<f64>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 holiday_days: Option<u32>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 deduction_factor: Option<f64>,
188 created: DateTime<Utc>,
189 updated: DateTime<Utc>,
190}
191
192impl SprintFrontmatter {
193 fn from_sprint(sprint: &Sprint) -> Self {
194 Self {
195 id: sprint.id.to_string(),
196 title: sprint.title.clone(),
197 state: sprint.state.to_string(),
198 start: sprint.start,
199 end: sprint.end,
200 daily_work_hours: sprint.daily_work_hours,
201 holiday_days: sprint.holiday_days,
202 deduction_factor: sprint.deduction_factor,
203 created: sprint.created,
204 updated: sprint.updated,
205 }
206 }
207
208 fn into_sprint(self, goal: String, path: &Path) -> Result<Sprint> {
209 let to_parse = |e: Error| Error::parse(path, e.to_string());
210 let id: SprintId = self.id.parse().map_err(to_parse)?;
211 let state: SprintState = self.state.parse().map_err(to_parse)?;
212 if self.title.trim().is_empty() {
214 return Err(Error::parse(path, Error::EmptySprintTitle.to_string()));
215 }
216 Ok(Sprint {
217 id,
218 title: self.title,
219 goal,
220 start: self.start,
221 end: self.end,
222 daily_work_hours: self.daily_work_hours,
223 holiday_days: self.holiday_days,
224 deduction_factor: self.deduction_factor,
225 state,
226 created: self.created,
227 updated: self.updated,
228 })
229 }
230}
231
232pub(super) fn sprint_to_markdown(sprint: &Sprint) -> Result<String> {
234 let fm = SprintFrontmatter::from_sprint(sprint);
235 let toml = toml::to_string(&fm)
236 .map_err(|e| Error::parse(&PathBuf::from(format!("{}.md", sprint.id)), e.to_string()))?;
237 Ok(assemble_markdown(&toml, &sprint.goal))
238}
239
240pub(super) fn sprint_from_markdown(text: &str, path: &Path) -> Result<Sprint> {
242 let (front, goal) = split_frontmatter(text).ok_or_else(|| Error::MissingFrontmatter {
243 path: path.to_path_buf(),
244 })?;
245 let fm: SprintFrontmatter =
246 toml::from_str(front).map_err(|e| Error::parse(path, e.to_string()))?;
247 fm.into_sprint(goal.to_string(), path)
248}
249
250fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
254 let rest = strip_delimiter_line(text)?;
255 let closing = find_delimiter_line(rest)?;
256 let front = &rest[..closing];
257 let after = &rest[closing..];
259 let body = after.split_once('\n').map(|(_, b)| b).unwrap_or("");
260 let body = body.strip_prefix('\n').unwrap_or(body); let body = body.strip_suffix('\n').unwrap_or(body); Some((front, body))
263}
264
265fn strip_delimiter_line(text: &str) -> Option<&str> {
269 let rest = text.strip_prefix(DELIMITER)?;
270 let rest = rest.strip_prefix('\r').unwrap_or(rest);
271 match rest.strip_prefix('\n') {
272 Some(rest) => Some(rest),
273 None if rest.is_empty() => Some(rest),
275 None => None,
276 }
277}
278
279fn find_delimiter_line(s: &str) -> Option<usize> {
281 let mut offset = 0;
282 for line in s.split_inclusive('\n') {
283 let content = line.strip_suffix('\n').unwrap_or(line);
284 let content = content.strip_suffix('\r').unwrap_or(content);
285 if content == DELIMITER {
286 return Some(offset);
287 }
288 offset += line.len();
289 }
290 None
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use chrono::Duration;
297
298 fn epoch() -> DateTime<Utc> {
299 DateTime::from_timestamp(0, 0).expect("valid epoch")
300 }
301
302 fn full_item() -> BacklogItem {
304 let mut item = BacklogItem::new(
305 ItemId::new("T", 7),
306 "Full item",
307 Status::new("in-progress"),
308 Rank::between(None, None).expect("open bounds produce a rank"),
309 epoch(),
310 )
311 .expect("valid item");
312 item.points = Some(5);
313 item.labels = vec!["backend".to_string(), "urgent".to_string()];
314 item.assignee = Some("alice".to_string());
315 item.sprint = Some("S-1".to_string());
316 item.parent = Some(ItemId::new("T", 1));
317 item.depends_on = vec![ItemId::new("T", 2), ItemId::new("T", 3)];
318 item.start_at = Some(epoch() + Duration::seconds(30));
319 item.done_at = Some(epoch() + Duration::seconds(90));
320 item.commits = vec!["abc1234".to_string(), "def5678".to_string()];
321 item.body = "Acceptance criteria\n- one\n- two".to_string();
322 item.updated = epoch() + Duration::seconds(60);
323 item
324 }
325
326 #[test]
327 fn item_markdown_roundtrips_all_fields() {
328 let item = full_item();
329 let text = to_markdown(&item).expect("serialize");
330 let parsed = from_markdown(&text, Path::new("T-7.md")).expect("parse");
331 assert_eq!(parsed, item);
332 }
333
334 #[test]
335 fn item_markdown_roundtrips_minimal_item_without_body() {
336 let item = BacklogItem::new(
337 ItemId::new("T", 1),
338 "Minimal",
339 Status::new("todo"),
340 Rank::between(None, None).expect("open bounds produce a rank"),
341 epoch(),
342 )
343 .expect("valid item");
344 let text = to_markdown(&item).expect("serialize");
345 let parsed = from_markdown(&text, Path::new("T-1.md")).expect("parse");
346 assert_eq!(parsed, item);
347 assert!(parsed.body.is_empty(), "empty body preserved");
348 }
349
350 #[test]
351 fn from_markdown_without_work_timestamps_defaults_to_none() {
352 let text = "\
354+++
355id = \"T-1\"
356title = \"Unstarted\"
357status = \"todo\"
358rank = \"n\"
359created = \"1970-01-01T00:00:00Z\"
360updated = \"1970-01-01T00:00:00Z\"
361+++
362";
363 let parsed = from_markdown(text, Path::new("T-1.md")).expect("parse todo item");
364 assert_eq!(parsed.start_at, None);
365 assert_eq!(parsed.done_at, None);
366 }
367
368 #[test]
369 fn sprint_markdown_roundtrips_all_fields() {
370 let mut sprint = Sprint::new(
371 SprintId::new("sprint-1").expect("valid id"),
372 "Sprint One",
373 epoch(),
374 )
375 .expect("valid sprint");
376 sprint.goal = "Ship the MVP\nwith tests".to_string();
377 sprint.state = SprintState::Active;
378 sprint.start = Some(epoch());
379 sprint.end = Some(epoch() + Duration::days(14));
380 sprint.updated = epoch() + Duration::seconds(30);
381
382 let text = sprint_to_markdown(&sprint).expect("serialize");
383 let parsed = sprint_from_markdown(&text, Path::new("sprint-1.md")).expect("parse");
384 assert_eq!(parsed, sprint);
385 }
386
387 #[test]
388 fn sprint_markdown_writes_title_to_frontmatter_and_goal_to_body() {
389 let mut sprint = Sprint::new(
390 SprintId::new("sprint-1").expect("valid id"),
391 "Sprint One",
392 epoch(),
393 )
394 .expect("valid sprint");
395 sprint.goal = "Ship the parser".to_string();
396
397 let text = sprint_to_markdown(&sprint).expect("serialize");
398
399 assert!(text.contains("title = \"Sprint One\""));
400 assert!(text.contains("\n\nShip the parser\n"));
401 }
402
403 #[test]
404 fn item_markdown_accepts_title_without_sprint_goal_field() {
405 let text = "\
406+++
407id = \"T-1\"
408title = \"Item\"
409status = \"todo\"
410rank = \"n\"
411created = \"1970-01-01T00:00:00Z\"
412updated = \"1970-01-01T00:00:00Z\"
413+++
414";
415
416 assert!(from_markdown(text, Path::new("T-1.md")).is_ok());
417 }
418
419 #[test]
420 fn split_frontmatter_separates_front_and_body() {
421 let text = "+++\nfoo = 1\n+++\n\nbody line\n";
422 let (front, body) = split_frontmatter(text).expect("has frontmatter");
423 assert_eq!(front, "foo = 1\n");
424 assert_eq!(body, "body line");
425 }
426
427 #[test]
428 fn split_frontmatter_handles_empty_body() {
429 let text = "+++\nfoo = 1\n+++\n";
430 let (front, body) = split_frontmatter(text).expect("has frontmatter");
431 assert_eq!(front, "foo = 1\n");
432 assert_eq!(body, "");
433 }
434
435 #[test]
436 fn split_frontmatter_without_delimiters_is_none() {
437 assert!(split_frontmatter("just some text\nno frontmatter\n").is_none());
438 }
439
440 #[test]
441 fn split_frontmatter_without_closing_delimiter_is_none() {
442 assert!(split_frontmatter("+++\nfoo = 1\nnever closed\n").is_none());
443 }
444
445 #[test]
446 fn from_markdown_without_frontmatter_errors_without_panic() {
447 let err = from_markdown("plain body, no frontmatter", Path::new("bad.md")).unwrap_err();
448 assert!(
449 matches!(err, Error::MissingFrontmatter { .. }),
450 "expected MissingFrontmatter, got {err:?}"
451 );
452 }
453}