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