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