Skip to main content

quillmark_core/document/
payload.rs

1//! Unified payload representation.
2//!
3//! A [`Payload`] is the typed representation of a card-yaml block's full
4//! YAML content. It carries — in source order, as variants of a single
5//! [`PayloadItem`] enum:
6//!
7//! - **System metadata** — typed `$quill` / `$kind` / `$id` / `$ext`
8//!   entries.
9//! - **User fields** — `key: value` pairs with an optional `!fill` flag.
10//! - **Comments** — own-line or trailing inline, attached to whichever
11//!   item they immediately follow at emit time.
12//!
13//! The unified item list is the canonical storage of the block; treating
14//! `$` entries as just another variant means a comment adjacent to a `$`
15//! line round-trips through the same mechanism as a comment adjacent to a
16//! user field. No "metadata region" vs "payload region" routing decision is
17//! ever made — there is only the source-ordered list.
18//!
19//! ## Comments at every level
20//!
21//! Top-level YAML comments (own-line and trailing inline) live as
22//! `PayloadItem::Comment` entries interleaved with fields and `$` items.
23//! Comments **inside** a structured value (mapping or sequence) live on
24//! the [`PayloadItem::Field`] / [`PayloadItem::Ext`] that owns that
25//! value, as a `nested_comments` slice with paths relative to the
26//! field's value tree. One storage surface, scoped to the item that
27//! "owns" each comment — no sidecar Vec hanging off `Payload`.
28//!
29//! ## Two faces
30//!
31//! [`Payload`] exposes both ordered iteration (over the raw items vec) and
32//! map-keyed access (`get`, `iter`, `insert`, `remove`). The map-style
33//! accessors filter to [`PayloadItem::Field`] only — they intentionally
34//! don't expose `$` entries because typed `$` access has dedicated methods
35//! (`quill`, `kind`, `id`, `ext`, `set_quill`, `set_kind`, `set_id`,
36//! `set_ext`).
37//!
38//! The map-style accessors present the payload as a key/value map of user
39//! data, while comment preservation and `$` access ride on the same
40//! underlying storage.
41
42use indexmap::IndexMap;
43use serde_json::{Map as JsonMap, Value as JsonValue};
44
45use super::prescan::{CommentPathSegment, NestedComment};
46use crate::value::QuillValue;
47use crate::version::QuillReference;
48
49/// One entry in a [`Payload`]: a typed `$` system metadata entry, a user
50/// field, or a comment line.
51///
52/// `PayloadItem` is the live in-memory model; it is intentionally **not**
53/// `Serialize`/`Deserialize`. Storage uses the versioned DTOs in
54/// `document::dto`, and bindings translate to their own wire types.
55#[derive(Debug, Clone, PartialEq)]
56pub enum PayloadItem {
57    /// `$quill` system metadata, holding the parsed quill reference.
58    Quill { reference: QuillReference },
59    /// `$kind` system metadata — the card's kind name.
60    Kind { value: String },
61    /// `$id` system metadata — opaque identifier.
62    Id { value: String },
63    /// `$ext` system metadata — an opaque mapping reserved for out-of-band
64    /// extension data (UI editor state, agent annotations, …). Never
65    /// emitted into the plate JSON, always round-trips through Markdown
66    /// and the storage DTO. `nested_comments` carries YAML comments
67    /// inside the `$ext` mapping; paths are **relative** to the `$ext`
68    /// value tree (the `$ext` key itself is not part of the path).
69    Ext {
70        value: JsonMap<String, JsonValue>,
71        nested_comments: Vec<NestedComment>,
72    },
73    /// A user-defined YAML field, optionally tagged `!fill`.
74    ///
75    /// `nested_comments` carries YAML comments inside the field's value
76    /// (only meaningful when the value is a mapping or sequence); paths
77    /// are **relative** to the field's value tree (the field's key is
78    /// not part of the path).
79    Field {
80        key: String,
81        value: QuillValue,
82        /// `true` when the field was written as `key: !fill <value>` or
83        /// `key: !fill` in source.
84        fill: bool,
85        nested_comments: Vec<NestedComment>,
86    },
87    /// A YAML comment. Text excludes the leading `#` and one optional space.
88    ///
89    /// `inline` distinguishes own-line comments (`# text` on a line by
90    /// itself) from trailing inline comments (`field: value # text`). An
91    /// inline comment attaches to the item that immediately precedes it
92    /// in the items vector; if no such item exists at emit time (orphan)
93    /// it degrades to an own-line comment.
94    Comment { text: String, inline: bool },
95}
96
97impl PayloadItem {
98    /// Build a plain (non-fill) field entry with no nested comments.
99    pub fn field(key: impl Into<String>, value: QuillValue) -> Self {
100        PayloadItem::Field {
101            key: key.into(),
102            value,
103            fill: false,
104            nested_comments: Vec::new(),
105        }
106    }
107
108    /// Borrow the field/ext nested-comments slice. Returns `&[]` for
109    /// variants that don't carry nested comments.
110    pub fn nested_comments(&self) -> &[NestedComment] {
111        match self {
112            PayloadItem::Field {
113                nested_comments, ..
114            }
115            | PayloadItem::Ext {
116                nested_comments, ..
117            } => nested_comments,
118            _ => &[],
119        }
120    }
121
122    pub fn comment(text: impl Into<String>) -> Self {
123        PayloadItem::Comment {
124            text: text.into(),
125            inline: false,
126        }
127    }
128
129    pub fn comment_inline(text: impl Into<String>) -> Self {
130        PayloadItem::Comment {
131            text: text.into(),
132            inline: true,
133        }
134    }
135
136    /// Canonical sort rank for typed `$` entries: `$quill` < `$kind` <
137    /// `$id` < `$ext`. Returns `None` for user fields and comments, which
138    /// are positioned by source order and never reshuffled.
139    fn meta_rank(&self) -> Option<u8> {
140        match self {
141            PayloadItem::Quill { .. } => Some(0),
142            PayloadItem::Kind { .. } => Some(1),
143            PayloadItem::Id { .. } => Some(2),
144            PayloadItem::Ext { .. } => Some(3),
145            _ => None,
146        }
147    }
148}
149
150/// Ordered, comment-preserving payload of a card-yaml block.
151///
152/// Contains the block's `$` entries, user fields, and comments interleaved
153/// in source order. See the module docs for the full design.
154#[derive(Debug, Clone, PartialEq, Default)]
155pub struct Payload {
156    items: Vec<PayloadItem>,
157}
158
159impl Payload {
160    /// Create an empty `Payload`.
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    /// Build from an `IndexMap` of user fields. No `$` entries, no
166    /// comments, no fill markers.
167    pub fn from_index_map(map: IndexMap<String, QuillValue>) -> Self {
168        let items = map
169            .into_iter()
170            .map(|(key, value)| PayloadItem::Field {
171                key,
172                value,
173                fill: false,
174                nested_comments: Vec::new(),
175            })
176            .collect();
177        Self { items }
178    }
179
180    /// Build from a pre-computed item list (parser and DTO entry point).
181    pub fn from_items(items: Vec<PayloadItem>) -> Self {
182        Self { items }
183    }
184
185    /// Build from a pre-computed item list plus a flat absolute-path
186    /// `nested_comments` Vec, partitioning the latter onto the matching
187    /// [`PayloadItem::Field`] / [`PayloadItem::Ext`] items.
188    ///
189    /// The first segment of each comment's `container_path` must be a
190    /// `Key(field)` matching a Field or Ext (`$ext`) entry in `items`;
191    /// that first segment is stripped and the remainder attached to the
192    /// owning item. Comments whose first segment matches nothing in
193    /// `items` are dropped silently — this can only arise from a
194    /// hand-crafted storage DTO that references a non-existent field.
195    pub(crate) fn from_items_with_flat_nested(
196        mut items: Vec<PayloadItem>,
197        nested_comments: Vec<NestedComment>,
198    ) -> Self {
199        for nc in nested_comments {
200            let Some((first, rest)) = nc.container_path.split_first() else {
201                // Empty path can't address any user field; drop.
202                continue;
203            };
204            let target_key = match first {
205                CommentPathSegment::Key(k) => k.clone(),
206                CommentPathSegment::Index(_) => continue,
207            };
208
209            let relative = NestedComment {
210                container_path: rest.to_vec(),
211                position: nc.position,
212                text: nc.text,
213                inline: nc.inline,
214            };
215
216            // `$ext` is encoded with the literal key "$ext" at the head of
217            // the path; everything else is a user field.
218            if target_key == "$ext" {
219                if let Some(slot) = items.iter_mut().find_map(|i| match i {
220                    PayloadItem::Ext {
221                        nested_comments, ..
222                    } => Some(nested_comments),
223                    _ => None,
224                }) {
225                    slot.push(relative);
226                }
227            } else if let Some(slot) = items.iter_mut().find_map(|i| match i {
228                PayloadItem::Field {
229                    key,
230                    nested_comments,
231                    ..
232                } if key == &target_key => Some(nested_comments),
233                _ => None,
234            }) {
235                slot.push(relative);
236            }
237        }
238        Self { items }
239    }
240
241    /// Walk every Field/Ext item and yield each nested comment with its
242    /// path re-prefixed by the owning item's key (`$ext` for Ext, the
243    /// field key for Field). Used by the storage DTO conversion to
244    /// flatten the per-item storage back to the wire format's
245    /// payload-level sidecar.
246    pub(crate) fn flat_nested_comments(&self) -> Vec<NestedComment> {
247        let mut out = Vec::new();
248        for item in &self.items {
249            let (prefix, comments) = match item {
250                PayloadItem::Field {
251                    key,
252                    nested_comments,
253                    ..
254                } => (key.clone(), nested_comments),
255                PayloadItem::Ext {
256                    nested_comments, ..
257                } => ("$ext".to_string(), nested_comments),
258                _ => continue,
259            };
260            for nc in comments {
261                let mut path = Vec::with_capacity(nc.container_path.len() + 1);
262                path.push(CommentPathSegment::Key(prefix.clone()));
263                path.extend(nc.container_path.iter().cloned());
264                out.push(NestedComment {
265                    container_path: path,
266                    position: nc.position,
267                    text: nc.text.clone(),
268                    inline: nc.inline,
269                });
270            }
271        }
272        out
273    }
274
275    // ── Item-level access ───────────────────────────────────────────────────
276
277    /// Ordered iterator over raw items (`$` entries, fields, comments).
278    pub fn items(&self) -> &[PayloadItem] {
279        &self.items
280    }
281
282    /// Mutable access to the raw item list. Callers must preserve the
283    /// invariants (at most one `Quill`/`Kind`/`Id`/`Ext`, no duplicate
284    /// field keys, every field name matches `[a-z_][a-z0-9_]*`) — use
285    /// the typed mutators when in doubt.
286    pub fn items_mut(&mut self) -> &mut [PayloadItem] {
287        &mut self.items
288    }
289
290    // ── Typed `$` access ────────────────────────────────────────────────────
291
292    /// The `$quill` reference, if declared.
293    pub fn quill(&self) -> Option<&QuillReference> {
294        self.items.iter().find_map(|i| match i {
295            PayloadItem::Quill { reference } => Some(reference),
296            _ => None,
297        })
298    }
299
300    /// The `$kind` value, if declared.
301    pub fn kind(&self) -> Option<&str> {
302        self.items.iter().find_map(|i| match i {
303            PayloadItem::Kind { value } => Some(value.as_str()),
304            _ => None,
305        })
306    }
307
308    /// The `$id` value, if declared.
309    pub fn id(&self) -> Option<&str> {
310        self.items.iter().find_map(|i| match i {
311            PayloadItem::Id { value } => Some(value.as_str()),
312            _ => None,
313        })
314    }
315
316    /// The `$ext` map, if declared. The map is opaque — Quillmark does not
317    /// interpret its contents and never emits them into the plate JSON.
318    pub fn ext(&self) -> Option<&JsonMap<String, JsonValue>> {
319        self.items.iter().find_map(|i| match i {
320            PayloadItem::Ext { value, .. } => Some(value),
321            _ => None,
322        })
323    }
324
325    /// Set or replace the `$quill` entry. Inserts at canonical position
326    /// (before any `$kind` / `$id` / `$ext`) when adding. Comments are
327    /// untouched.
328    pub fn set_quill(&mut self, reference: QuillReference) {
329        self.upsert_meta(PayloadItem::Quill { reference });
330    }
331
332    /// Set or replace the `$kind` entry. Same insertion rules as
333    /// [`set_quill`](Self::set_quill).
334    pub fn set_kind(&mut self, kind: impl Into<String>) {
335        self.upsert_meta(PayloadItem::Kind { value: kind.into() });
336    }
337
338    /// Set or replace the `$id` entry. Same insertion rules as
339    /// [`set_quill`](Self::set_quill).
340    pub fn set_id(&mut self, id: impl Into<String>) {
341        self.upsert_meta(PayloadItem::Id { value: id.into() });
342    }
343
344    /// Set or replace the `$ext` entry. Same insertion rules as
345    /// [`set_quill`](Self::set_quill); the canonical position is after
346    /// `$quill` / `$kind` / `$id` and before any user field.
347    ///
348    /// Any nested comments previously attached to a replaced `$ext`
349    /// entry are dropped (the new value tree may not contain matching
350    /// positions).
351    pub fn set_ext(&mut self, value: JsonMap<String, JsonValue>) {
352        self.upsert_meta(PayloadItem::Ext {
353            value,
354            nested_comments: Vec::new(),
355        });
356    }
357
358    /// Remove the `$quill` entry, returning the previous value if any.
359    pub fn take_quill(&mut self) -> Option<QuillReference> {
360        let pos = self
361            .items
362            .iter()
363            .position(|i| matches!(i, PayloadItem::Quill { .. }))?;
364        match self.items.remove(pos) {
365            PayloadItem::Quill { reference } => Some(reference),
366            _ => unreachable!(),
367        }
368    }
369
370    /// Remove the `$ext` entry, returning the previous map if any. Any
371    /// nested comments attached to the entry are dropped.
372    pub fn take_ext(&mut self) -> Option<JsonMap<String, JsonValue>> {
373        let pos = self
374            .items
375            .iter()
376            .position(|i| matches!(i, PayloadItem::Ext { .. }))?;
377        match self.items.remove(pos) {
378            PayloadItem::Ext { value, .. } => Some(value),
379            _ => unreachable!(),
380        }
381    }
382
383    fn upsert_meta(&mut self, new: PayloadItem) {
384        let new_rank = new
385            .meta_rank()
386            .expect("upsert_meta only accepts $-typed items");
387        for slot in self.items.iter_mut() {
388            if slot.meta_rank() == Some(new_rank) {
389                *slot = new;
390                return;
391            }
392        }
393        let insert_at = self
394            .items
395            .iter()
396            .position(|i| matches!(i.meta_rank(), Some(r) if r > new_rank))
397            .unwrap_or_else(|| {
398                // No higher-ranked `$` item; insert after the last lower
399                // (or equal-rank-impossible) `$` item, before any non-`$`
400                // entry. This keeps the `$quill < $kind < $id` ordering
401                // while not displacing user fields.
402                self.items
403                    .iter()
404                    .rposition(|i| matches!(i.meta_rank(), Some(r) if r < new_rank))
405                    .map(|p| p + 1)
406                    .unwrap_or(0)
407            });
408        self.items.insert(insert_at, new);
409    }
410
411    // ── User-field access (map-style, `$` entries filtered out) ─────────────
412
413    /// Iterator over user `(key, &value)` pairs. Excludes `$` entries and
414    /// comments; preserves source order.
415    pub fn iter(&self) -> impl Iterator<Item = (&String, &QuillValue)> + '_ {
416        self.items.iter().filter_map(|item| match item {
417            PayloadItem::Field { key, value, .. } => Some((key, value)),
418            _ => None,
419        })
420    }
421
422    /// Iterator over user field keys.
423    pub fn keys(&self) -> impl Iterator<Item = &String> + '_ {
424        self.items.iter().filter_map(|item| match item {
425            PayloadItem::Field { key, .. } => Some(key),
426            _ => None,
427        })
428    }
429
430    /// Number of *user-field* items (`$` entries and comments excluded).
431    pub fn len(&self) -> usize {
432        self.items
433            .iter()
434            .filter(|item| matches!(item, PayloadItem::Field { .. }))
435            .count()
436    }
437
438    /// `true` when there are no user-field items.
439    pub fn is_empty(&self) -> bool {
440        self.len() == 0
441    }
442
443    /// Look up a user-field value by key. `$` entries are not visible via
444    /// this accessor — use [`quill`](Self::quill) / [`kind`](Self::kind) /
445    /// [`id`](Self::id).
446    pub fn get(&self, key: &str) -> Option<&QuillValue> {
447        self.items.iter().find_map(|item| match item {
448            PayloadItem::Field { key: k, value, .. } if k == key => Some(value),
449            _ => None,
450        })
451    }
452
453    /// `true` if a user field with this key is present.
454    pub fn contains_key(&self, key: &str) -> bool {
455        self.get(key).is_some()
456    }
457
458    /// `true` if a user field with this key is marked `!fill`.
459    pub fn is_fill(&self, key: &str) -> bool {
460        self.items.iter().any(|item| match item {
461            PayloadItem::Field { key: k, fill, .. } => k == key && *fill,
462            _ => false,
463        })
464    }
465
466    /// Insert or update a user field. Always clears the `fill` marker
467    /// (field is no longer a placeholder). Preserves position for existing
468    /// keys; appends new keys at the end. `$` entries and comments are
469    /// untouched. Replacing an existing field discards its
470    /// `nested_comments` because the new value tree may not contain
471    /// matching positions.
472    pub fn insert(&mut self, key: impl Into<String>, value: QuillValue) -> Option<QuillValue> {
473        let key = key.into();
474        for item in self.items.iter_mut() {
475            if let PayloadItem::Field {
476                key: k,
477                value: v,
478                fill,
479                nested_comments,
480            } = item
481            {
482                if k == &key {
483                    let old = std::mem::replace(v, value);
484                    *fill = false;
485                    nested_comments.clear();
486                    return Some(old);
487                }
488            }
489        }
490        self.items.push(PayloadItem::Field {
491            key,
492            value,
493            fill: false,
494            nested_comments: Vec::new(),
495        });
496        None
497    }
498
499    /// Insert or update a user field and mark it as a `!fill` placeholder.
500    /// Preserves position for existing keys; appends new keys at the end.
501    /// Replacing an existing field discards its `nested_comments`.
502    pub fn insert_fill(&mut self, key: impl Into<String>, value: QuillValue) -> Option<QuillValue> {
503        let key = key.into();
504        for item in self.items.iter_mut() {
505            if let PayloadItem::Field {
506                key: k,
507                value: v,
508                fill,
509                nested_comments,
510            } = item
511            {
512                if k == &key {
513                    let old = std::mem::replace(v, value);
514                    *fill = true;
515                    nested_comments.clear();
516                    return Some(old);
517                }
518            }
519        }
520        self.items.push(PayloadItem::Field {
521            key,
522            value,
523            fill: true,
524            nested_comments: Vec::new(),
525        });
526        None
527    }
528
529    /// Remove a user field by key, returning its value. Comments and `$`
530    /// entries are untouched.
531    pub fn remove(&mut self, key: &str) -> Option<QuillValue> {
532        let pos = self
533            .items
534            .iter()
535            .position(|item| matches!(item, PayloadItem::Field { key: k, .. } if k == key))?;
536        match self.items.remove(pos) {
537            PayloadItem::Field { value, .. } => Some(value),
538            _ => unreachable!(),
539        }
540    }
541
542    /// Project the user-field portion into an `IndexMap<String, QuillValue>`.
543    /// Comments, fill markers, and `$` entries are dropped. Preserves order.
544    pub fn to_index_map(&self) -> IndexMap<String, QuillValue> {
545        let mut map = IndexMap::new();
546        for item in &self.items {
547            if let PayloadItem::Field { key, value, .. } = item {
548                map.insert(key.clone(), value.clone());
549            }
550        }
551        map
552    }
553}
554
555impl<'a> IntoIterator for &'a Payload {
556    type Item = (&'a String, &'a QuillValue);
557    type IntoIter = std::iter::FilterMap<
558        std::slice::Iter<'a, PayloadItem>,
559        fn(&'a PayloadItem) -> Option<(&'a String, &'a QuillValue)>,
560    >;
561
562    fn into_iter(self) -> Self::IntoIter {
563        fn filter(item: &PayloadItem) -> Option<(&String, &QuillValue)> {
564            match item {
565                PayloadItem::Field { key, value, .. } => Some((key, value)),
566                _ => None,
567            }
568        }
569        self.items.iter().filter_map(filter)
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    fn qv(s: &str) -> QuillValue {
578        QuillValue::from_json(serde_json::json!(s))
579    }
580
581    #[test]
582    fn insert_new_appends_after_meta() {
583        let mut fm = Payload::new();
584        fm.set_quill("foo@0.1".parse().unwrap());
585        fm.set_kind("main");
586        fm.insert("title", qv("Hello"));
587        let last = fm.items().last().unwrap();
588        assert!(matches!(last, PayloadItem::Field { key, .. } if key == "title"));
589    }
590
591    #[test]
592    fn insert_existing_preserves_position() {
593        let mut fm = Payload::new();
594        fm.insert("a", qv("1"));
595        fm.insert("b", qv("2"));
596        fm.insert("a", qv("updated"));
597        let keys: Vec<&String> = fm.keys().collect();
598        assert_eq!(keys, vec!["a", "b"]);
599        assert_eq!(fm.get("a").unwrap().as_str(), Some("updated"));
600    }
601
602    #[test]
603    fn insert_clears_fill() {
604        let mut fm = Payload::new();
605        fm.insert_fill("k", qv("placeholder"));
606        assert!(fm.is_fill("k"));
607        fm.insert("k", qv("user value"));
608        assert!(!fm.is_fill("k"));
609    }
610
611    #[test]
612    fn map_style_iter_skips_meta_and_comments() {
613        let mut fm = Payload::new();
614        fm.set_quill("foo@0.1".parse().unwrap());
615        fm.set_kind("main");
616        fm.insert("title", qv("Hello"));
617        let items = std::mem::take(&mut fm).items().to_vec();
618        // Reconstruct with an interleaved comment.
619        let mut items_with_comment = items;
620        items_with_comment.insert(2, PayloadItem::comment("c"));
621        let fm = Payload::from_items(items_with_comment);
622        let pairs: Vec<(String, String)> = fm
623            .iter()
624            .map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string()))
625            .collect();
626        assert_eq!(pairs, vec![("title".to_string(), "Hello".to_string())]);
627        // But the typed access still works:
628        assert_eq!(fm.kind(), Some("main"));
629    }
630
631    #[test]
632    fn set_quill_inserts_at_position_zero() {
633        let mut fm = Payload::new();
634        fm.set_kind("main");
635        fm.set_quill("foo@0.1".parse().unwrap());
636        assert!(matches!(fm.items()[0], PayloadItem::Quill { .. }));
637        assert!(matches!(fm.items()[1], PayloadItem::Kind { .. }));
638    }
639
640    #[test]
641    fn set_id_inserts_after_quill_and_kind() {
642        let mut fm = Payload::new();
643        fm.set_quill("foo@0.1".parse().unwrap());
644        fm.set_kind("main");
645        fm.set_id("rev-1");
646        assert_eq!(fm.items().len(), 3);
647        assert!(matches!(fm.items()[2], PayloadItem::Id { .. }));
648    }
649
650    #[test]
651    fn set_replaces_in_place_preserving_comments() {
652        let mut fm = Payload::from_items(vec![
653            PayloadItem::Quill {
654                reference: "foo@0.1".parse().unwrap(),
655            },
656            PayloadItem::comment_inline("trailing"),
657            PayloadItem::Kind {
658                value: "main".into(),
659            },
660        ]);
661        fm.set_quill("bar@0.2".parse().unwrap());
662        assert_eq!(fm.quill().unwrap().to_string(), "bar@0.2");
663        assert_eq!(fm.items().len(), 3);
664        assert!(matches!(fm.items()[1], PayloadItem::Comment { .. }));
665    }
666
667    #[test]
668    fn remove_leaves_comments_and_meta_alone() {
669        let mut fm = Payload::from_items(vec![
670            PayloadItem::Quill {
671                reference: "q".parse().unwrap(),
672            },
673            PayloadItem::Kind {
674                value: "main".into(),
675            },
676            PayloadItem::comment("header"),
677            PayloadItem::field("a", qv("1")),
678            PayloadItem::comment("mid"),
679            PayloadItem::field("b", qv("2")),
680        ]);
681        let removed = fm.remove("a").unwrap();
682        assert_eq!(removed.as_str(), Some("1"));
683        assert!(matches!(fm.items()[0], PayloadItem::Quill { .. }));
684        assert!(matches!(fm.items()[1], PayloadItem::Kind { .. }));
685        let comments: Vec<&str> = fm
686            .items()
687            .iter()
688            .filter_map(|item| match item {
689                PayloadItem::Comment { text, .. } => Some(text.as_str()),
690                _ => None,
691            })
692            .collect();
693        assert_eq!(comments, vec!["header", "mid"]);
694    }
695}