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