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 `!must_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::Meta`] 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`, `seed`, `set_quill`, `set_kind`, `set_id`,
36//! `set_ext`, `set_seed`).
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/// Which out-of-band system-metadata map a [`PayloadItem::Meta`] carries.
50///
51/// `$ext` and `$seed` are the same shape: an opaque `Map<String, Value>` that
52/// never reaches the plate JSON and round-trips through Markdown and the storage
53/// DTO, so the live model represents them as one variant discriminated by this
54/// key. They differ only in their canonical sort rank, whether they are
55/// root-only, and (downstream of storage) whether the seeding layer interprets
56/// them: `$ext` is opaque; `$seed` is read by [`crate::SeedOverlay::from_json`].
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum MetaKey {
60    /// `$ext`: opaque out-of-band consumer state (editor renames, agent
61    /// annotations). Allowed on any card.
62    Ext,
63    /// `$seed`: per-card-kind seed overlays. **Root-only** (like `$quill`).
64    Seed,
65}
66
67impl MetaKey {
68    /// The literal source key (`"$ext"` / `"$seed"`).
69    pub fn as_str(self) -> &'static str {
70        match self {
71            MetaKey::Ext => "$ext",
72            MetaKey::Seed => "$seed",
73        }
74    }
75
76    /// Parse the source key (`"$ext"` / `"$seed"`), or `None` for any other key.
77    pub fn from_key_str(key: &str) -> Option<Self> {
78        match key {
79            "$ext" => Some(MetaKey::Ext),
80            "$seed" => Some(MetaKey::Seed),
81            _ => None,
82        }
83    }
84
85    /// Canonical sort rank among typed `$` entries (after `$id`).
86    fn rank(self) -> u8 {
87        match self {
88            MetaKey::Ext => 3,
89            MetaKey::Seed => 4,
90        }
91    }
92
93    /// `true` when the key may appear on the root card only (rejected on
94    /// composable cards), like `$quill`.
95    pub fn is_root_only(self) -> bool {
96        matches!(self, MetaKey::Seed)
97    }
98}
99
100/// One entry in a [`Payload`]: a typed `$` system metadata entry, a user
101/// field, or a comment line.
102///
103/// `PayloadItem` is the live in-memory model; it is intentionally **not**
104/// `Serialize`/`Deserialize`. Storage uses the versioned DTOs in
105/// `document::dto`, and bindings translate to their own wire types.
106#[derive(Debug, Clone, PartialEq)]
107#[non_exhaustive]
108pub enum PayloadItem {
109    /// `$quill` system metadata, holding the parsed quill reference.
110    Quill { reference: QuillReference },
111    /// `$kind` system metadata: the card's kind name.
112    Kind { value: String },
113    /// `$id` system metadata, the durable card handle: opaque,
114    /// caller-supplied, unique per document across composable cards
115    /// (`DOCUMENT_STORAGE.md` §Card-id identity).
116    Id { value: String },
117    /// `$ext` / `$seed` system metadata: an opaque mapping (discriminated by
118    /// [`MetaKey`]) reserved for out-of-band data. Never emitted into the plate
119    /// JSON, always round-trips through Markdown and the storage DTO.
120    /// `nested_comments` carries YAML comments inside the mapping; paths are
121    /// **relative** to the value tree (the `$ext` / `$seed` key itself is not
122    /// part of the path). `$seed` is additionally interpreted by the seeding
123    /// layer; see [`crate::SeedOverlay::from_json`] and [`crate::Quill::seed_card`].
124    Meta {
125        key: MetaKey,
126        value: JsonMap<String, JsonValue>,
127        nested_comments: Vec<NestedComment>,
128    },
129    /// A user-defined YAML field, optionally tagged `!must_fill`.
130    ///
131    /// `nested_comments` carries YAML comments inside the field's value
132    /// (only meaningful when the value is a mapping or sequence); paths
133    /// are **relative** to the field's value tree (the field's key is
134    /// not part of the path).
135    Field {
136        key: String,
137        value: QuillValue,
138        /// `true` when the field was written as `key: !must_fill <value>` or
139        /// `key: !must_fill` in source.
140        fill: bool,
141        nested_comments: Vec<NestedComment>,
142    },
143    /// A YAML comment. Text excludes the leading `#` and one optional space.
144    ///
145    /// `inline` distinguishes own-line comments (`# text` on a line by
146    /// itself) from trailing inline comments (`field: value # text`). An
147    /// inline comment attaches to the item that immediately precedes it
148    /// in the items vector; if no such item exists at emit time (orphan)
149    /// it degrades to an own-line comment.
150    Comment { text: String, inline: bool },
151}
152
153impl PayloadItem {
154    /// Build a plain (non-fill) field entry with no nested comments.
155    pub fn field(key: impl Into<String>, value: QuillValue) -> Self {
156        PayloadItem::Field {
157            key: key.into(),
158            value,
159            fill: false,
160            nested_comments: Vec::new(),
161        }
162    }
163
164    /// Borrow the field/meta nested-comments slice. Returns `&[]` for
165    /// variants that don't carry nested comments.
166    pub fn nested_comments(&self) -> &[NestedComment] {
167        match self {
168            PayloadItem::Field {
169                nested_comments, ..
170            }
171            | PayloadItem::Meta {
172                nested_comments, ..
173            } => nested_comments,
174            _ => &[],
175        }
176    }
177
178    pub fn comment(text: impl Into<String>) -> Self {
179        PayloadItem::Comment {
180            text: text.into(),
181            inline: false,
182        }
183    }
184
185    pub fn comment_inline(text: impl Into<String>) -> Self {
186        PayloadItem::Comment {
187            text: text.into(),
188            inline: true,
189        }
190    }
191
192    /// Canonical sort rank for typed `$` entries: `$quill` < `$kind` <
193    /// `$id` < `$ext` < `$seed`. Returns `None` for user fields and comments,
194    /// which are positioned by source order and never reshuffled.
195    fn meta_rank(&self) -> Option<u8> {
196        match self {
197            PayloadItem::Quill { .. } => Some(0),
198            PayloadItem::Kind { .. } => Some(1),
199            PayloadItem::Id { .. } => Some(2),
200            PayloadItem::Meta { key, .. } => Some(key.rank()),
201            _ => None,
202        }
203    }
204}
205
206/// Ordered, comment-preserving payload of a card-yaml block.
207///
208/// Contains the block's `$` entries, user fields, and comments interleaved
209/// in source order. See the module docs for the full design.
210#[derive(Debug, Clone, PartialEq, Default)]
211pub struct Payload {
212    items: Vec<PayloadItem>,
213}
214
215impl Payload {
216    /// Create an empty `Payload`.
217    pub fn new() -> Self {
218        Self::default()
219    }
220
221    /// Build from an `IndexMap` of user fields. No `$` entries, no
222    /// comments, no fill markers.
223    pub fn from_index_map(map: IndexMap<String, QuillValue>) -> Self {
224        let items = map
225            .into_iter()
226            .map(|(key, value)| PayloadItem::Field {
227                key,
228                value,
229                fill: false,
230                nested_comments: Vec::new(),
231            })
232            .collect();
233        Self { items }
234    }
235
236    /// Build from a pre-computed item list (parser and DTO entry point).
237    pub fn from_items(items: Vec<PayloadItem>) -> Self {
238        Self { items }
239    }
240
241    /// Build from a pre-computed item list plus a flat absolute-path
242    /// `nested_comments` Vec, partitioning the latter onto the matching
243    /// [`PayloadItem::Field`] / [`PayloadItem::Meta`] items.
244    ///
245    /// The first segment of each comment's `container_path` must be a
246    /// `Key(field)` matching a Field or Meta (`$ext` / `$seed`) entry in `items`;
247    /// that first segment is stripped and the remainder attached to the
248    /// owning item. Comments whose first segment matches nothing in
249    /// `items` are dropped silently: this can only arise from a
250    /// hand-crafted storage DTO that references a non-existent field.
251    pub(crate) fn from_items_with_flat_nested(
252        mut items: Vec<PayloadItem>,
253        nested_comments: Vec<NestedComment>,
254    ) -> Self {
255        for nc in nested_comments {
256            let Some((first, rest)) = nc.container_path.split_first() else {
257                // Empty path can't address any user field; drop.
258                continue;
259            };
260            let target_key = match first {
261                CommentPathSegment::Key(k) => k.clone(),
262                CommentPathSegment::Index(_) => continue,
263            };
264
265            let relative = NestedComment {
266                container_path: rest.to_vec(),
267                position: nc.position,
268                text: nc.text,
269                inline: nc.inline,
270            };
271
272            // `$ext` / `$seed` are encoded with their literal key at the head
273            // of the path; everything else is a user field.
274            let slot = if let Some(meta_key) = MetaKey::from_key_str(&target_key) {
275                items.iter_mut().find_map(|i| match i {
276                    PayloadItem::Meta {
277                        key,
278                        nested_comments,
279                        ..
280                    } if *key == meta_key => Some(nested_comments),
281                    _ => None,
282                })
283            } else {
284                items.iter_mut().find_map(|i| match i {
285                    PayloadItem::Field {
286                        key,
287                        nested_comments,
288                        ..
289                    } if key == &target_key => Some(nested_comments),
290                    _ => None,
291                })
292            };
293            if let Some(slot) = slot {
294                slot.push(relative);
295            }
296        }
297        Self { items }
298    }
299
300    /// Walk every Field/Meta item and yield each nested comment with its
301    /// path re-prefixed by the owning item's key (`$ext` / `$seed` for Meta,
302    /// the field key for Field). Used by the storage DTO conversion to
303    /// flatten the per-item storage back to the wire format's
304    /// payload-level sidecar.
305    pub(crate) fn flat_nested_comments(&self) -> Vec<NestedComment> {
306        let mut out = Vec::new();
307        for item in &self.items {
308            let (prefix, comments) = match item {
309                PayloadItem::Field {
310                    key,
311                    nested_comments,
312                    ..
313                } => (key.clone(), nested_comments),
314                PayloadItem::Meta {
315                    key,
316                    nested_comments,
317                    ..
318                } => (key.as_str().to_string(), nested_comments),
319                _ => continue,
320            };
321            for nc in comments {
322                let mut path = Vec::with_capacity(nc.container_path.len() + 1);
323                path.push(CommentPathSegment::Key(prefix.clone()));
324                path.extend(nc.container_path.iter().cloned());
325                out.push(NestedComment {
326                    container_path: path,
327                    position: nc.position,
328                    text: nc.text.clone(),
329                    inline: nc.inline,
330                });
331            }
332        }
333        out
334    }
335
336    // ── Item-level access ───────────────────────────────────────────────────
337
338    /// Ordered iterator over raw items (`$` entries, fields, comments).
339    pub fn items(&self) -> &[PayloadItem] {
340        &self.items
341    }
342
343    /// Mutable access to the raw item list. Callers must preserve the
344    /// invariants (at most one `Quill`/`Kind`/`Id`/`Ext`, no duplicate
345    /// field keys, every field name matches `[A-Za-z_][A-Za-z0-9_]*`): use
346    /// the typed mutators when in doubt.
347    pub fn items_mut(&mut self) -> &mut [PayloadItem] {
348        &mut self.items
349    }
350
351    /// Remove the first item matching `pred` and return it. The typed
352    /// removers (`take_id`, `take_meta`, `remove`) wrap this and destructure
353    /// the returned variant, which `pred` guarantees.
354    fn take_item(&mut self, pred: impl Fn(&PayloadItem) -> bool) -> Option<PayloadItem> {
355        let pos = self.items.iter().position(pred)?;
356        Some(self.items.remove(pos))
357    }
358
359    // ── Typed `$` access ────────────────────────────────────────────────────
360
361    /// The `$quill` reference, if declared.
362    pub fn quill(&self) -> Option<&QuillReference> {
363        self.items.iter().find_map(|i| match i {
364            PayloadItem::Quill { reference } => Some(reference),
365            _ => None,
366        })
367    }
368
369    /// The `$kind` value, if declared.
370    pub fn kind(&self) -> Option<&str> {
371        self.items.iter().find_map(|i| match i {
372            PayloadItem::Kind { value } => Some(value.as_str()),
373            _ => None,
374        })
375    }
376
377    /// The `$id` value, if declared.
378    pub fn id(&self) -> Option<&str> {
379        self.items.iter().find_map(|i| match i {
380            PayloadItem::Id { value } => Some(value.as_str()),
381            _ => None,
382        })
383    }
384
385    /// The map for the given out-of-band meta key, if declared.
386    pub(crate) fn meta(&self, want: MetaKey) -> Option<&JsonMap<String, JsonValue>> {
387        self.items.iter().find_map(|i| match i {
388            PayloadItem::Meta { key, value, .. } if *key == want => Some(value),
389            _ => None,
390        })
391    }
392
393    /// The `$ext` map, if declared. The map is opaque: Quillmark does not
394    /// interpret its contents and never emits them into the plate JSON.
395    pub fn ext(&self) -> Option<&JsonMap<String, JsonValue>> {
396        self.meta(MetaKey::Ext)
397    }
398
399    /// The raw `$seed` map (keyed by card-kind), if declared. The seeding
400    /// layer interprets it; it never reaches the plate JSON. For a parsed,
401    /// per-kind overlay, index this map by kind and pass the entry to
402    /// [`crate::SeedOverlay::from_json`].
403    pub fn seed(&self) -> Option<&JsonMap<String, JsonValue>> {
404        self.meta(MetaKey::Seed)
405    }
406
407    /// Set or replace the `$quill` entry. Inserts at canonical position
408    /// (before any `$kind` / `$id` / `$ext`) when adding. Comments are
409    /// untouched.
410    pub fn set_quill(&mut self, reference: QuillReference) {
411        self.upsert_meta(PayloadItem::Quill { reference });
412    }
413
414    /// Set or replace the `$kind` entry. Same insertion rules as
415    /// [`set_quill`](Self::set_quill).
416    pub fn set_kind(&mut self, kind: impl Into<String>) {
417        self.upsert_meta(PayloadItem::Kind { value: kind.into() });
418    }
419
420    /// Set or replace the `$id` entry. Same insertion rules as
421    /// [`set_quill`](Self::set_quill).
422    ///
423    /// This is the stamping door for a card **not yet placed** in a document
424    /// (mint → stamp → insert); uniqueness is checked at insertion. For a
425    /// placed card, write through the guarded
426    /// [`Document::set_card_id`](crate::Document::set_card_id) so the
427    /// per-document uniqueness of `$id` holds.
428    pub fn set_id(&mut self, id: impl Into<String>) {
429        self.upsert_meta(PayloadItem::Id { value: id.into() });
430    }
431
432    /// Remove the `$id` entry, returning the previous value if any. Removal
433    /// cannot collide, so no document-level guard exists or is needed.
434    pub fn take_id(&mut self) -> Option<String> {
435        match self.take_item(|i| matches!(i, PayloadItem::Id { .. }))? {
436            PayloadItem::Id { value } => Some(value),
437            _ => unreachable!(),
438        }
439    }
440
441    /// Set or replace an out-of-band meta entry at its canonical position.
442    /// Nested comments on a replaced entry are dropped (the new value tree
443    /// may not contain matching positions).
444    pub(crate) fn set_meta(&mut self, key: MetaKey, value: JsonMap<String, JsonValue>) {
445        self.upsert_meta(PayloadItem::Meta {
446            key,
447            value,
448            nested_comments: Vec::new(),
449        });
450    }
451
452    /// Set or replace the `$ext` entry. Same insertion rules as
453    /// [`set_quill`](Self::set_quill); the canonical position is after
454    /// `$quill` / `$kind` / `$id` and before any user field.
455    ///
456    /// Nested comments on a replaced `$ext` entry are dropped (the new value
457    /// tree may not contain matching positions).
458    pub fn set_ext(&mut self, value: JsonMap<String, JsonValue>) {
459        self.set_meta(MetaKey::Ext, value);
460    }
461
462    /// Set or replace the `$seed` entry. Inserted at the canonical position
463    /// (after `$quill` / `$kind` / `$id` / `$ext`, before any user field).
464    /// Nested comments on a replaced `$seed` are dropped, like
465    /// [`set_ext`](Self::set_ext).
466    pub fn set_seed(&mut self, value: JsonMap<String, JsonValue>) {
467        self.set_meta(MetaKey::Seed, value);
468    }
469
470    /// Remove an out-of-band meta entry, returning the previous map if any.
471    /// Any nested comments attached to the entry are dropped.
472    pub(crate) fn take_meta(&mut self, want: MetaKey) -> Option<JsonMap<String, JsonValue>> {
473        match self.take_item(|i| matches!(i, PayloadItem::Meta { key, .. } if *key == want))? {
474            PayloadItem::Meta { value, .. } => Some(value),
475            _ => unreachable!(),
476        }
477    }
478
479    /// Remove the `$ext` entry, returning the previous map if any. Any
480    /// nested comments attached to the entry are dropped.
481    pub fn take_ext(&mut self) -> Option<JsonMap<String, JsonValue>> {
482        self.take_meta(MetaKey::Ext)
483    }
484
485    /// Remove the `$seed` entry, returning the previous map if any. Any
486    /// nested comments attached to the entry are dropped.
487    pub fn take_seed(&mut self) -> Option<JsonMap<String, JsonValue>> {
488        self.take_meta(MetaKey::Seed)
489    }
490
491    fn upsert_meta(&mut self, new: PayloadItem) {
492        let new_rank = new
493            .meta_rank()
494            .expect("upsert_meta only accepts $-typed items");
495        for slot in self.items.iter_mut() {
496            if slot.meta_rank() == Some(new_rank) {
497                *slot = new;
498                return;
499            }
500        }
501        let insert_at = self
502            .items
503            .iter()
504            .position(|i| matches!(i.meta_rank(), Some(r) if r > new_rank))
505            .unwrap_or_else(|| {
506                // No higher-ranked `$` item; insert after the last lower
507                // (or equal-rank-impossible) `$` item, before any non-`$`
508                // entry. This keeps the `$quill < $kind < $id` ordering
509                // while not displacing user fields.
510                self.items
511                    .iter()
512                    .rposition(|i| matches!(i.meta_rank(), Some(r) if r < new_rank))
513                    .map(|p| p + 1)
514                    .unwrap_or(0)
515            });
516        self.items.insert(insert_at, new);
517    }
518
519    // ── User-field access (map-style, `$` entries filtered out) ─────────────
520
521    /// Iterator over user `(key, &value)` pairs. Excludes `$` entries and
522    /// comments; preserves source order.
523    pub fn iter(&self) -> impl Iterator<Item = (&String, &QuillValue)> + '_ {
524        self.items.iter().filter_map(|item| match item {
525            PayloadItem::Field { key, value, .. } => Some((key, value)),
526            _ => None,
527        })
528    }
529
530    /// Iterator over user field keys.
531    pub fn keys(&self) -> impl Iterator<Item = &String> + '_ {
532        self.items.iter().filter_map(|item| match item {
533            PayloadItem::Field { key, .. } => Some(key),
534            _ => None,
535        })
536    }
537
538    /// Number of *user-field* items (`$` entries and comments excluded).
539    pub fn len(&self) -> usize {
540        self.items
541            .iter()
542            .filter(|item| matches!(item, PayloadItem::Field { .. }))
543            .count()
544    }
545
546    /// `true` when there are no user-field items.
547    pub fn is_empty(&self) -> bool {
548        self.len() == 0
549    }
550
551    /// Look up a user-field value by key. `$` entries are not visible via
552    /// this accessor: use [`quill`](Self::quill) / [`kind`](Self::kind) /
553    /// [`id`](Self::id).
554    pub fn get(&self, key: &str) -> Option<&QuillValue> {
555        self.items.iter().find_map(|item| match item {
556            PayloadItem::Field { key: k, value, .. } if k == key => Some(value),
557            _ => None,
558        })
559    }
560
561    /// `true` if a user field with this key is present.
562    pub fn contains_key(&self, key: &str) -> bool {
563        self.get(key).is_some()
564    }
565
566    /// `true` if a user field with this key is marked `!must_fill`.
567    pub fn is_fill(&self, key: &str) -> bool {
568        self.items.iter().any(|item| match item {
569            PayloadItem::Field { key: k, fill, .. } => k == key && *fill,
570            _ => false,
571        })
572    }
573
574    /// Insert or update a user field, clearing any `!must_fill` marker.
575    /// Preserves position for an existing key; appends a new one. `$` entries
576    /// and comments are untouched; replacing a field discards its
577    /// `nested_comments` (the new value tree may not carry matching positions).
578    ///
579    /// Validates the field name and value depth
580    /// ([`validate_field`](super::edit::validate_field)) at this boundary, so
581    /// the "a constructed document cannot be invalid" invariant holds even for
582    /// the direct `Payload` path reachable through
583    /// [`Card::payload_mut`](super::Card::payload_mut). Pre-validated callers
584    /// (typed commit, all-or-nothing batches) use `insert_unchecked` to skip the
585    /// redundant check.
586    pub fn insert(
587        &mut self,
588        key: impl Into<String>,
589        value: QuillValue,
590    ) -> Result<Option<QuillValue>, super::edit::FieldViolation> {
591        let key = key.into();
592        super::edit::validate_field(&key, value.as_json())?;
593        Ok(self.insert_item(key, value, false))
594    }
595
596    /// Insert or update a user field and mark it a `!must_fill` placeholder;
597    /// same rules and boundary validation as [`insert`](Self::insert).
598    pub fn insert_fill(
599        &mut self,
600        key: impl Into<String>,
601        value: QuillValue,
602    ) -> Result<Option<QuillValue>, super::edit::FieldViolation> {
603        let key = key.into();
604        super::edit::validate_field(&key, value.as_json())?;
605        Ok(self.insert_item(key, value, true))
606    }
607
608    /// [`insert`](Self::insert) without the field-invariant check. `pub(crate)`
609    /// for callers that have already validated the exact stored `(name, value)`:
610    /// `resolve_field_write` and the batch setters that validate the whole
611    /// batch before applying any of it.
612    pub(crate) fn insert_unchecked(
613        &mut self,
614        key: impl Into<String>,
615        value: QuillValue,
616    ) -> Option<QuillValue> {
617        self.insert_item(key.into(), value, false)
618    }
619
620    /// Insert or replace field `key` with `value`, setting its fill marker.
621    /// Position-preserving for an existing key, append otherwise.
622    fn insert_item(&mut self, key: String, value: QuillValue, fill: bool) -> Option<QuillValue> {
623        for item in self.items.iter_mut() {
624            if let PayloadItem::Field {
625                key: k,
626                value: v,
627                fill: item_fill,
628                nested_comments,
629            } = item
630            {
631                if k == &key {
632                    let old = std::mem::replace(v, value);
633                    *item_fill = fill;
634                    nested_comments.clear();
635                    return Some(old);
636                }
637            }
638        }
639        self.items.push(PayloadItem::Field {
640            key,
641            value,
642            fill,
643            nested_comments: Vec::new(),
644        });
645        None
646    }
647
648    /// Remove a user field by key, returning its value. Comments and `$`
649    /// entries are untouched.
650    pub fn remove(&mut self, key: &str) -> Option<QuillValue> {
651        match self.take_item(|item| matches!(item, PayloadItem::Field { key: k, .. } if k == key))? {
652            PayloadItem::Field { value, .. } => Some(value),
653            _ => unreachable!(),
654        }
655    }
656
657    /// Project the user-field portion into an `IndexMap<String, QuillValue>`.
658    /// Comments, fill markers, and `$` entries are dropped. Preserves order.
659    pub fn to_index_map(&self) -> IndexMap<String, QuillValue> {
660        let mut map = IndexMap::new();
661        for item in &self.items {
662            if let PayloadItem::Field { key, value, .. } = item {
663                map.insert(key.clone(), value.clone());
664            }
665        }
666        map
667    }
668}
669
670impl<'a> IntoIterator for &'a Payload {
671    type Item = (&'a String, &'a QuillValue);
672    type IntoIter = std::iter::FilterMap<
673        std::slice::Iter<'a, PayloadItem>,
674        fn(&'a PayloadItem) -> Option<(&'a String, &'a QuillValue)>,
675    >;
676
677    fn into_iter(self) -> Self::IntoIter {
678        fn filter(item: &PayloadItem) -> Option<(&String, &QuillValue)> {
679            match item {
680                PayloadItem::Field { key, value, .. } => Some((key, value)),
681                _ => None,
682            }
683        }
684        self.items.iter().filter_map(filter)
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    fn qv(s: &str) -> QuillValue {
693        QuillValue::from_json(serde_json::json!(s))
694    }
695
696    #[test]
697    fn insert_new_appends_after_meta() {
698        let mut fm = Payload::new();
699        fm.set_quill("foo@0.1".parse().unwrap());
700        fm.set_kind("main");
701        fm.insert("title", qv("Hello")).unwrap();
702        let last = fm.items().last().unwrap();
703        assert!(matches!(last, PayloadItem::Field { key, .. } if key == "title"));
704    }
705
706    #[test]
707    fn insert_existing_preserves_position() {
708        let mut fm = Payload::new();
709        fm.insert("a", qv("1")).unwrap();
710        fm.insert("b", qv("2")).unwrap();
711        fm.insert("a", qv("updated")).unwrap();
712        let keys: Vec<&String> = fm.keys().collect();
713        assert_eq!(keys, vec!["a", "b"]);
714        assert_eq!(fm.get("a").unwrap().as_str(), Some("updated"));
715    }
716
717    #[test]
718    fn insert_clears_fill() {
719        let mut fm = Payload::new();
720        fm.insert_fill("k", qv("placeholder")).unwrap();
721        assert!(fm.is_fill("k"));
722        fm.insert("k", qv("user value")).unwrap();
723        assert!(!fm.is_fill("k"));
724    }
725
726    #[test]
727    fn insert_enforces_the_field_invariant() {
728        use super::super::edit::FieldViolation;
729
730        // A malformed name is refused: `payload_mut().insert(...)` cannot seat
731        // an invalid field in a "constructed" document.
732        let mut fm = Payload::new();
733        assert_eq!(fm.insert("bad name", qv("v")), Err(FieldViolation::InvalidName));
734        assert_eq!(fm.insert("$id", qv("v")), Err(FieldViolation::InvalidName));
735        assert_eq!(
736            fm.insert_fill("bad name", qv("v")),
737            Err(FieldViolation::InvalidName)
738        );
739
740        // Over-deep value.
741        let mut deep = serde_json::json!(0);
742        for _ in 0..(crate::document::limits::MAX_YAML_DEPTH + 5) {
743            deep = serde_json::json!([deep]);
744        }
745        assert_eq!(
746            fm.insert("field", QuillValue::from_json(deep)),
747            Err(FieldViolation::TooDeep)
748        );
749
750        // Nothing was applied on any rejection.
751        assert!(fm.items().is_empty());
752
753        // The unchecked path is the deliberate escape hatch: no validation.
754        fm.insert_unchecked("bad name", qv("v"));
755        assert_eq!(fm.items().len(), 1);
756    }
757
758    #[test]
759    fn map_style_iter_skips_meta_and_comments() {
760        let mut fm = Payload::new();
761        fm.set_quill("foo@0.1".parse().unwrap());
762        fm.set_kind("main");
763        let _ = fm.insert("title", qv("Hello"));
764        let items = std::mem::take(&mut fm).items().to_vec();
765        // Reconstruct with an interleaved comment.
766        let mut items_with_comment = items;
767        items_with_comment.insert(2, PayloadItem::comment("c"));
768        let fm = Payload::from_items(items_with_comment);
769        let pairs: Vec<(String, String)> = fm
770            .iter()
771            .map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string()))
772            .collect();
773        assert_eq!(pairs, vec![("title".to_string(), "Hello".to_string())]);
774        // But the typed access still works:
775        assert_eq!(fm.kind(), Some("main"));
776    }
777
778    #[test]
779    fn set_quill_inserts_at_position_zero() {
780        let mut fm = Payload::new();
781        fm.set_kind("main");
782        fm.set_quill("foo@0.1".parse().unwrap());
783        assert!(matches!(fm.items()[0], PayloadItem::Quill { .. }));
784        assert!(matches!(fm.items()[1], PayloadItem::Kind { .. }));
785    }
786
787    #[test]
788    fn set_id_inserts_after_quill_and_kind() {
789        let mut fm = Payload::new();
790        fm.set_quill("foo@0.1".parse().unwrap());
791        fm.set_kind("main");
792        fm.set_id("rev-1");
793        assert_eq!(fm.items().len(), 3);
794        assert!(matches!(fm.items()[2], PayloadItem::Id { .. }));
795    }
796
797    #[test]
798    fn set_replaces_in_place_preserving_comments() {
799        let mut fm = Payload::from_items(vec![
800            PayloadItem::Quill {
801                reference: "foo@0.1".parse().unwrap(),
802            },
803            PayloadItem::comment_inline("trailing"),
804            PayloadItem::Kind {
805                value: "main".into(),
806            },
807        ]);
808        fm.set_quill("bar@0.2".parse().unwrap());
809        assert_eq!(fm.quill().unwrap().to_string(), "bar@0.2");
810        assert_eq!(fm.items().len(), 3);
811        assert!(matches!(fm.items()[1], PayloadItem::Comment { .. }));
812    }
813
814    #[test]
815    fn remove_leaves_comments_and_meta_alone() {
816        let mut fm = Payload::from_items(vec![
817            PayloadItem::Quill {
818                reference: "q".parse().unwrap(),
819            },
820            PayloadItem::Kind {
821                value: "main".into(),
822            },
823            PayloadItem::comment("header"),
824            PayloadItem::field("a", qv("1")),
825            PayloadItem::comment("mid"),
826            PayloadItem::field("b", qv("2")),
827        ]);
828        let removed = fm.remove("a").unwrap();
829        assert_eq!(removed.as_str(), Some("1"));
830        assert!(matches!(fm.items()[0], PayloadItem::Quill { .. }));
831        assert!(matches!(fm.items()[1], PayloadItem::Kind { .. }));
832        let comments: Vec<&str> = fm
833            .items()
834            .iter()
835            .filter_map(|item| match item {
836                PayloadItem::Comment { text, .. } => Some(text.as_str()),
837                _ => None,
838            })
839            .collect();
840        assert_eq!(comments, vec!["header", "mid"]);
841    }
842}