Skip to main content

pinto/storage/
markdown.rs

1//! Markdown + TOML frontmatter conversion.
2
3use 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
11/// Start/end delimiter for frontmatter.
12const DELIMITER: &str = "+++";
13
14/// Structured fields serialized to TOML frontmatter.
15///
16/// Domain types such as [`ItemId`], [`Status`], and [`Rank`] are stored as strings so the TOML
17/// remains simple and easy to edit by hand.
18#[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        // An empty title violates the constructor invariant even if it is present in the file.
81        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
105/// Assemble the frontmatter (TOML) and the body into a Markdown string.
106///
107/// If the body is empty, omit the blank body section. This is shared by PBI and sprint formatting.
108fn 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); // toml::to_string includes a trailing newline.
113    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
123/// Format PBI into `+++` frontmatter + body Markdown string.
124pub(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
131/// Parse the required PBI frontmatter and the body Markdown.
132pub(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
140/// Parse a backlog item's `+++` TOML frontmatter and Markdown body.
141///
142/// The parser validates the serialized [`ItemId`], [`Rank`], and required title before returning
143/// a domain item. `path` is included in parse errors so callers can point users to the invalid
144/// document.
145///
146/// # Examples
147///
148/// ```
149/// use std::path::Path;
150/// use pinto::storage::parse_item_markdown;
151///
152/// let markdown = "+++\n\
153/// id = \"T-1\"\n\
154/// title = \"Review parser input\"\n\
155/// status = \"todo\"\n\
156/// rank = \"i\"\n\
157/// created = \"1970-01-01T00:00:00Z\"\n\
158/// updated = \"1970-01-01T00:00:00Z\"\n\
159/// +++\n\
160/// body\n";
161/// let item = parse_item_markdown(markdown, Path::new("T-1.md")).expect("valid item");
162/// assert_eq!(item.id.to_string(), "T-1");
163/// assert_eq!(item.body, "body");
164/// ```
165pub fn parse_item_markdown(text: &str, path: &Path) -> Result<BacklogItem> {
166    from_markdown(text, path)
167}
168
169/// Sprint frontmatter fields. The title is structured; the goal remains the Markdown body.
170///
171/// As with item frontmatter, store domain types such as [`SprintId`] and [`SprintState`] as strings
172/// so the TOML remains easy to edit by hand.
173#[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        // An empty title violates the constructor invariant even when the file contains it.
213        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
232/// Format the sprint into a Markdown string with a structured title and a Markdown goal body.
233pub(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
240/// Parse the Markdown of a sprint with a structured title and a Markdown goal body.
241pub(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
250/// Split a document whose first line starts with `+++` into (frontmatter, body).
251///
252/// `None` if there is no closing `+++` line. In the main text, remove one blank line and one trailing newline immediately after the delimiter.
253fn 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    // Extract the text from the closing line (`+++\n...` or `+++`).
258    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); // Separator blank line.
261    let body = body.strip_suffix('\n').unwrap_or(body); // Trailing newline.
262    Some((front, body))
263}
264
265/// Returns the remainder after removing the first `+++` line (`None` if the first line is not a separator line).
266///
267/// Allows `\r\n` line breaks as well as closing lines ([`find_delimiter_line`]).
268fn 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        // Only `+++` and no line break (no body/invalid) → returns empty.
274        None if rest.is_empty() => Some(rest),
275        None => None,
276    }
277}
278
279/// Returns the starting byte position of a line consisting only of `+++`.
280fn 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    /// PBI with optional fields filled in (multiple lines of text).
303    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        // PBIs that have not been started or completed do not have start_at / done_at (they are omitted when exporting).
353        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}