1use crate::backlog::{BacklogItem, ItemId, Status};
4use crate::error::{Error, Result};
5use crate::rank::Rank;
6use crate::sprint::{Sprint, SprintId, SprintSpillover, 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 closed_at: Option<DateTime<Utc>>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 start: Option<DateTime<Utc>>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 end: Option<DateTime<Utc>>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 daily_work_hours: Option<f64>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 holiday_days: Option<u32>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 deduction_factor: Option<f64>,
190 #[serde(default, skip_serializing_if = "is_zero")]
191 spillover_points: u32,
192 #[serde(default, skip_serializing_if = "is_zero")]
193 spillover_items: u32,
194 #[serde(default, skip_serializing_if = "is_zero")]
195 unestimated_spillover_items: u32,
196 created: DateTime<Utc>,
197 updated: DateTime<Utc>,
198}
199
200impl SprintFrontmatter {
201 fn from_sprint(sprint: &Sprint) -> Self {
202 Self {
203 id: sprint.id.to_string(),
204 title: sprint.title.clone(),
205 state: sprint.state.to_string(),
206 closed_at: sprint.closed_at,
207 start: sprint.start,
208 end: sprint.end,
209 daily_work_hours: sprint.daily_work_hours,
210 holiday_days: sprint.holiday_days,
211 deduction_factor: sprint.deduction_factor,
212 spillover_points: sprint.spillover.points,
213 spillover_items: sprint.spillover.items,
214 unestimated_spillover_items: sprint.spillover.unestimated_items,
215 created: sprint.created,
216 updated: sprint.updated,
217 }
218 }
219
220 fn into_sprint(self, goal: String, path: &Path) -> Result<Sprint> {
221 let to_parse = |e: Error| Error::parse(path, e.to_string());
222 let id: SprintId = self.id.parse().map_err(to_parse)?;
223 let state: SprintState = self.state.parse().map_err(to_parse)?;
224 if self.title.trim().is_empty() {
226 return Err(Error::parse(path, Error::EmptySprintTitle.to_string()));
227 }
228 Ok(Sprint {
229 id,
230 title: self.title,
231 goal,
232 start: self.start,
233 end: self.end,
234 daily_work_hours: self.daily_work_hours,
235 holiday_days: self.holiday_days,
236 deduction_factor: self.deduction_factor,
237 spillover: SprintSpillover {
238 points: self.spillover_points,
239 items: self.spillover_items,
240 unestimated_items: self.unestimated_spillover_items,
241 },
242 state,
243 closed_at: self.closed_at,
244 created: self.created,
245 updated: self.updated,
246 })
247 }
248}
249
250fn is_zero(value: &u32) -> bool {
251 *value == 0
252}
253
254pub(super) fn sprint_to_markdown(sprint: &Sprint) -> Result<String> {
256 let fm = SprintFrontmatter::from_sprint(sprint);
257 let toml = toml::to_string(&fm)
258 .map_err(|e| Error::parse(&PathBuf::from(format!("{}.md", sprint.id)), e.to_string()))?;
259 Ok(assemble_markdown(&toml, &sprint.goal))
260}
261
262pub(super) fn sprint_from_markdown(text: &str, path: &Path) -> Result<Sprint> {
264 let (front, goal) = split_frontmatter(text).ok_or_else(|| Error::MissingFrontmatter {
265 path: path.to_path_buf(),
266 })?;
267 let fm: SprintFrontmatter =
268 toml::from_str(front).map_err(|e| Error::parse(path, e.to_string()))?;
269 fm.into_sprint(goal.to_string(), path)
270}
271
272pub(crate) fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
276 let rest = strip_delimiter_line(text)?;
277 let closing = find_delimiter_line(rest)?;
278 let front = &rest[..closing];
279 let after = &rest[closing..];
281 let body = after.split_once('\n').map(|(_, b)| b).unwrap_or("");
282 let body = body.strip_prefix('\n').unwrap_or(body); let body = body.strip_suffix('\n').unwrap_or(body); Some((front, body))
285}
286
287fn strip_delimiter_line(text: &str) -> Option<&str> {
291 let rest = text.strip_prefix(DELIMITER)?;
292 let rest = rest.strip_prefix('\r').unwrap_or(rest);
293 match rest.strip_prefix('\n') {
294 Some(rest) => Some(rest),
295 None if rest.is_empty() => Some(rest),
297 None => None,
298 }
299}
300
301fn find_delimiter_line(s: &str) -> Option<usize> {
303 let mut offset = 0;
304 for line in s.split_inclusive('\n') {
305 let content = line.strip_suffix('\n').unwrap_or(line);
306 let content = content.strip_suffix('\r').unwrap_or(content);
307 if content == DELIMITER {
308 return Some(offset);
309 }
310 offset += line.len();
311 }
312 None
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use chrono::{Duration, TimeZone};
319 use proptest::prelude::*;
320
321 fn epoch() -> DateTime<Utc> {
322 DateTime::from_timestamp(0, 0).expect("valid epoch")
323 }
324
325 fn unicode_text() -> impl Strategy<Value = String> {
326 let character = prop_oneof![
327 Just('日'),
328 Just('本'),
329 Just('語'),
330 Just('🙂'),
331 Just('é'),
332 Just(' '),
333 Just('"'),
334 Just('\\'),
335 Just('='),
336 any::<char>().prop_filter("control characters are not needed here", |character| {
337 !character.is_control()
338 }),
339 ];
340 prop::collection::vec(character, 0..32)
341 .prop_map(|characters| characters.into_iter().collect())
342 }
343
344 fn non_blank_unicode_text() -> impl Strategy<Value = String> {
345 unicode_text().prop_map(|text| {
346 if text.trim().is_empty() {
347 format!("{text}値")
348 } else {
349 text
350 }
351 })
352 }
353
354 fn markdown_body() -> impl Strategy<Value = String> {
355 prop::collection::vec(
356 unicode_text().prop_filter("a delimiter line would be ambiguous", |line| line != "+++"),
357 0..8,
358 )
359 .prop_map(|lines| lines.join("\n"))
360 }
361
362 fn timestamp_strategy() -> impl Strategy<Value = DateTime<Utc>> {
363 (-4_000_000_000i64..=4_000_000_000i64).prop_map(|seconds| {
364 Utc.timestamp_opt(seconds, 0)
365 .single()
366 .expect("generated timestamp is valid")
367 })
368 }
369
370 fn item_strategy() -> impl Strategy<Value = BacklogItem> {
371 (
372 (
373 non_blank_unicode_text(),
374 non_blank_unicode_text(),
375 prop::option::of(0u32..=100),
376 prop::collection::vec(unicode_text(), 0..4),
377 ),
378 (
379 prop::option::of(non_blank_unicode_text()),
380 prop::option::of(non_blank_unicode_text()),
381 prop::option::of(1u32..=9),
382 prop::collection::vec(1u32..=9, 0..4),
383 ),
384 (
385 prop::option::of(timestamp_strategy()),
386 prop::option::of(timestamp_strategy()),
387 markdown_body(),
388 prop::collection::vec(non_blank_unicode_text(), 0..4),
389 ),
390 (timestamp_strategy(), timestamp_strategy()),
391 )
392 .prop_map(
393 |(
394 (title, status, points, labels),
395 (assignee, sprint, parent, depends_on),
396 (start_at, done_at, body, commits),
397 (created, updated),
398 )| {
399 let mut item = BacklogItem::new(
400 ItemId::new("T", 7),
401 title,
402 Status::new(status),
403 Rank::parse("m").expect("fixed rank is valid"),
404 created,
405 )
406 .expect("generated item is valid");
407 item.points = points;
408 item.labels = labels;
409 item.assignee = assignee;
410 item.sprint = sprint;
411 item.parent = parent.map(|number| ItemId::new("T", number));
412 item.depends_on = depends_on
413 .into_iter()
414 .map(|number| ItemId::new("T", number))
415 .collect();
416 item.start_at = start_at;
417 item.done_at = done_at;
418 item.body = body;
419 item.commits = commits;
420 item.updated = updated;
421 item
422 },
423 )
424 }
425
426 fn sprint_strategy() -> impl Strategy<Value = Sprint> {
427 (
428 (non_blank_unicode_text(), markdown_body()),
429 prop_oneof![
430 Just(SprintState::Planned),
431 Just(SprintState::Active),
432 Just(SprintState::Closed),
433 ],
434 (
435 prop::option::of(timestamp_strategy()),
436 prop::option::of(timestamp_strategy()),
437 prop::option::of(0.0f64..=24.0),
438 prop::option::of(0u32..=30),
439 prop::option::of(0.0f64..=1.0),
440 ),
441 (0u32..=100, 0u32..=20, 0u32..=20),
442 (
443 timestamp_strategy(),
444 timestamp_strategy(),
445 prop::option::of(timestamp_strategy()),
446 ),
447 )
448 .prop_map(
449 |(
450 (title, goal),
451 state,
452 (start, end, daily_work_hours, holiday_days, deduction_factor),
453 (points, items, unestimated_items),
454 (created, updated, closed_at),
455 )| {
456 let mut sprint = Sprint::new(
457 SprintId::new("sprint-7").expect("fixed sprint id is valid"),
458 title,
459 created,
460 )
461 .expect("generated sprint is valid");
462 sprint.goal = goal;
463 sprint.start = start;
464 sprint.end = end;
465 sprint.daily_work_hours = daily_work_hours;
466 sprint.holiday_days = holiday_days;
467 sprint.deduction_factor = deduction_factor;
468 sprint.spillover = SprintSpillover {
469 points,
470 items,
471 unestimated_items,
472 };
473 sprint.state = state;
474 sprint.closed_at = closed_at;
475 sprint.updated = updated;
476 sprint
477 },
478 )
479 }
480
481 proptest! {
482 #[test]
483 fn item_markdown_round_trip_preserves_generated_value(item in item_strategy()) {
484 let text = to_markdown(&item).expect("serialize generated item");
485 let parsed = from_markdown(&text, Path::new("generated-item.md"))
486 .expect("parse generated item");
487 prop_assert_eq!(parsed, item);
488 }
489
490 #[test]
491 fn sprint_markdown_round_trip_preserves_generated_value(sprint in sprint_strategy()) {
492 let text = sprint_to_markdown(&sprint).expect("serialize generated sprint");
493 let parsed = sprint_from_markdown(&text, Path::new("generated-sprint.md"))
494 .expect("parse generated sprint");
495 prop_assert_eq!(parsed, sprint);
496 }
497 }
498
499 fn full_item() -> BacklogItem {
501 let mut item = BacklogItem::new(
502 ItemId::new("T", 7),
503 "Full item",
504 Status::new("in-progress"),
505 Rank::between(None, None).expect("open bounds produce a rank"),
506 epoch(),
507 )
508 .expect("valid item");
509 item.points = Some(5);
510 item.labels = vec!["backend".to_string(), "urgent".to_string()];
511 item.assignee = Some("alice".to_string());
512 item.sprint = Some("S-1".to_string());
513 item.parent = Some(ItemId::new("T", 1));
514 item.depends_on = vec![ItemId::new("T", 2), ItemId::new("T", 3)];
515 item.start_at = Some(epoch() + Duration::seconds(30));
516 item.done_at = Some(epoch() + Duration::seconds(90));
517 item.commits = vec!["abc1234".to_string(), "def5678".to_string()];
518 item.body = "Acceptance criteria\n- one\n- two".to_string();
519 item.updated = epoch() + Duration::seconds(60);
520 item
521 }
522
523 #[test]
524 fn item_markdown_roundtrips_all_fields() {
525 let item = full_item();
526 let text = to_markdown(&item).expect("serialize");
527 let parsed = from_markdown(&text, Path::new("T-7.md")).expect("parse");
528 assert_eq!(parsed, item);
529 }
530
531 #[test]
532 fn item_markdown_roundtrips_minimal_item_without_body() {
533 let item = BacklogItem::new(
534 ItemId::new("T", 1),
535 "Minimal",
536 Status::new("todo"),
537 Rank::between(None, None).expect("open bounds produce a rank"),
538 epoch(),
539 )
540 .expect("valid item");
541 let text = to_markdown(&item).expect("serialize");
542 let parsed = from_markdown(&text, Path::new("T-1.md")).expect("parse");
543 assert_eq!(parsed, item);
544 assert!(parsed.body.is_empty(), "empty body preserved");
545 }
546
547 #[test]
548 fn from_markdown_without_work_timestamps_defaults_to_none() {
549 let text = "\
551+++
552id = \"T-1\"
553title = \"Unstarted\"
554status = \"todo\"
555rank = \"n\"
556created = \"1970-01-01T00:00:00Z\"
557updated = \"1970-01-01T00:00:00Z\"
558+++
559";
560 let parsed = from_markdown(text, Path::new("T-1.md")).expect("parse todo item");
561 assert_eq!(parsed.start_at, None);
562 assert_eq!(parsed.done_at, None);
563 }
564
565 #[test]
566 fn sprint_markdown_roundtrips_all_fields() {
567 let mut sprint = Sprint::new(
568 SprintId::new("sprint-1").expect("valid id"),
569 "Sprint One",
570 epoch(),
571 )
572 .expect("valid sprint");
573 sprint.goal = "Ship the MVP\nwith tests".to_string();
574 sprint.state = SprintState::Closed;
575 sprint.closed_at = Some(epoch() + Duration::seconds(20));
576 sprint.start = Some(epoch());
577 sprint.end = Some(epoch() + Duration::days(14));
578 sprint.spillover = crate::sprint::SprintSpillover {
579 points: 8,
580 items: 2,
581 unestimated_items: 1,
582 };
583 sprint.updated = epoch() + Duration::seconds(30);
584
585 let text = sprint_to_markdown(&sprint).expect("serialize");
586 let parsed = sprint_from_markdown(&text, Path::new("sprint-1.md")).expect("parse");
587 assert_eq!(parsed, sprint);
588 }
589
590 #[test]
591 fn sprint_markdown_without_spillover_fields_defaults_to_zero() {
592 let text = "\
593+++
594id = \"S-1\"
595title = \"Sprint One\"
596state = \"closed\"
597created = \"1970-01-01T00:00:00Z\"
598updated = \"1970-01-01T00:00:00Z\"
599+++
600";
601
602 let sprint = sprint_from_markdown(text, Path::new("S-1.md")).expect("parse sprint");
603
604 assert_eq!(sprint.closed_at, None);
605 assert_eq!(sprint.spillover, crate::sprint::SprintSpillover::default());
606 }
607
608 #[test]
609 fn sprint_markdown_writes_title_to_frontmatter_and_goal_to_body() {
610 let mut sprint = Sprint::new(
611 SprintId::new("sprint-1").expect("valid id"),
612 "Sprint One",
613 epoch(),
614 )
615 .expect("valid sprint");
616 sprint.goal = "Ship the parser".to_string();
617
618 let text = sprint_to_markdown(&sprint).expect("serialize");
619
620 assert!(text.contains("title = \"Sprint One\""));
621 assert!(text.contains("\n\nShip the parser\n"));
622 }
623
624 #[test]
625 fn item_markdown_accepts_title_without_sprint_goal_field() {
626 let text = "\
627+++
628id = \"T-1\"
629title = \"Item\"
630status = \"todo\"
631rank = \"n\"
632created = \"1970-01-01T00:00:00Z\"
633updated = \"1970-01-01T00:00:00Z\"
634+++
635";
636
637 assert!(from_markdown(text, Path::new("T-1.md")).is_ok());
638 }
639
640 #[test]
641 fn split_frontmatter_separates_front_and_body() {
642 let text = "+++\nfoo = 1\n+++\n\nbody line\n";
643 let (front, body) = split_frontmatter(text).expect("has frontmatter");
644 assert_eq!(front, "foo = 1\n");
645 assert_eq!(body, "body line");
646 }
647
648 #[test]
649 fn split_frontmatter_handles_empty_body() {
650 let text = "+++\nfoo = 1\n+++\n";
651 let (front, body) = split_frontmatter(text).expect("has frontmatter");
652 assert_eq!(front, "foo = 1\n");
653 assert_eq!(body, "");
654 }
655
656 #[test]
657 fn split_frontmatter_without_delimiters_is_none() {
658 assert!(split_frontmatter("just some text\nno frontmatter\n").is_none());
659 }
660
661 #[test]
662 fn split_frontmatter_without_closing_delimiter_is_none() {
663 assert!(split_frontmatter("+++\nfoo = 1\nnever closed\n").is_none());
664 }
665
666 #[test]
667 fn from_markdown_without_frontmatter_errors_without_panic() {
668 let err = from_markdown("plain body, no frontmatter", Path::new("bad.md")).unwrap_err();
669 assert!(
670 matches!(err, Error::MissingFrontmatter { .. }),
671 "expected MissingFrontmatter, got {err:?}"
672 );
673 }
674}