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, SprintSpillover, 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    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        // An empty title violates the constructor invariant even when the file contains it.
225        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
254/// Format the sprint into a Markdown string with a structured title and a Markdown goal body.
255pub(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
262/// Parse the Markdown of a sprint with a structured title and a Markdown goal body.
263pub(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
272/// Split a document whose first line starts with `+++` into (frontmatter, body).
273///
274/// `None` if there is no closing `+++` line. In the main text, remove one blank line and one trailing newline immediately after the delimiter.
275pub(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    // Extract the text from the closing line (`+++\n...` or `+++`).
280    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); // Separator blank line.
283    let body = body.strip_suffix('\n').unwrap_or(body); // Trailing newline.
284    Some((front, body))
285}
286
287/// Returns the remainder after removing the first `+++` line (`None` if the first line is not a separator line).
288///
289/// Allows `\r\n` line breaks as well as closing lines ([`find_delimiter_line`]).
290fn 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        // Only `+++` and no line break (no body/invalid) → returns empty.
296        None if rest.is_empty() => Some(rest),
297        None => None,
298    }
299}
300
301/// Returns the starting byte position of a line consisting only of `+++`.
302fn 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    /// PBI with optional fields filled in (multiple lines of text).
500    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        // PBIs that have not been started or completed do not have start_at / done_at (they are omitted when exporting).
550        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}