Skip to main content

quillmark_core/document/
wire.rs

1//! Canonical **live** wire form of a [`Card`] for language-binding APIs.
2//!
3//! [`CardWire`] is the single, core-owned translation between a [`Card`] and
4//! the flat `{ kind, payloadItems, … }` shape that the WASM and Python bindings
5//! exchange with JS/Python. Bindings serialize/deserialize this type instead of
6//! hand-rolling their own per-card conversion, so the field/comment/`$`-entry
7//! mapping lives in exactly one place.
8//!
9//! ## Why this is separate from the storage DTO
10//!
11//! The versioned storage DTO (`document::dto`, e.g. `CardV0_92_0`) is **frozen**
12//! per schema version so persisted documents keep loading forever. `CardWire`
13//! is the **current** API shape and is free to evolve with the bindings. They
14//! are structurally similar today, but coupling the live API to a frozen
15//! storage schema would chain one to the other's change cadence — so they are
16//! deliberately distinct, both built on the live [`Card`]/[`Payload`] model.
17//!
18//! ## Shape
19//!
20//! The `$` system entries are hoisted to named fields (`kind`, `quill`, `id`,
21//! `ext`, `seed`); `payload_items` carries only user fields and comments, in order.
22//! Field/`$ext` *nested* comments are not represented here — they survive the
23//! Markdown and storage round-trips, not this editable projection.
24
25use std::str::FromStr;
26
27use serde::{Deserialize, Serialize};
28use serde_json::{Map as JsonMap, Value as JsonValue};
29
30use super::payload::{MetaKey, Payload, PayloadItem};
31use super::Card;
32use crate::value::{PathSegment, QuillValue};
33use crate::version::QuillReference;
34use quillmark_content::Content;
35
36/// One entry in a [`CardWire`]'s `payload_items`: a user field or a comment.
37/// The `$` system entries are hoisted onto [`CardWire`] itself, never here.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(tag = "type", rename_all = "lowercase")]
40pub enum PayloadItemWire {
41    /// A user-defined field.
42    Field {
43        key: String,
44        value: JsonValue,
45        /// `true` when the field itself is `key: !must_fill <value>` in source.
46        #[serde(default)]
47        fill: bool,
48        /// Paths to `!must_fill` markers nested *inside* `value` (e.g. a leaf
49        /// property of an object, or a key within an array element). The JSON
50        /// `value` projection is fill-free, so these carry the nested markers
51        /// across the wire. Empty for a top-level-only or no-fill field.
52        #[serde(
53            default,
54            rename = "nestedFills",
55            alias = "nested_fills",
56            skip_serializing_if = "Vec::is_empty"
57        )]
58        nested_fills: Vec<Vec<PathStepWire>>,
59    },
60    /// A YAML comment line (text excludes the leading `#`).
61    Comment {
62        text: String,
63        /// `true` for a trailing inline comment (`field: value # text`).
64        #[serde(default)]
65        inline: bool,
66    },
67}
68
69/// One step in a nested fill path: an object key or an array index. Serializes
70/// **untagged** — a key as a JSON string, an index as a JSON number — so a path
71/// is a plain JS array like `["addr", "street"]` or `["recipients", 0, "name"]`.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(untagged)]
74pub enum PathStepWire {
75    Index(usize),
76    Key(String),
77}
78
79impl From<&PathSegment> for PathStepWire {
80    fn from(seg: &PathSegment) -> Self {
81        match seg {
82            PathSegment::Key(k) => PathStepWire::Key(k.clone()),
83            PathSegment::Index(i) => PathStepWire::Index(*i),
84        }
85    }
86}
87
88impl From<&PathStepWire> for PathSegment {
89    fn from(seg: &PathStepWire) -> Self {
90        match seg {
91            PathStepWire::Key(k) => PathSegment::Key(k.clone()),
92            PathStepWire::Index(i) => PathSegment::Index(*i),
93        }
94    }
95}
96
97/// Canonical live wire form of a [`Card`]. See the module docs.
98///
99/// Serializes to JS-facing camelCase (`payloadItems`); the snake_case
100/// `payload_items` is also accepted on input for the Python binding.
101/// `deny_unknown_fields` makes a stale flat `{ kind, fields }` shape fail
102/// loudly rather than deserialize into an empty card.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase", deny_unknown_fields)]
105pub struct CardWire {
106    /// The block's `$kind` (e.g. `"endorsement"`); empty string when the block
107    /// declares no `$kind`. Kept non-optional to match the binding read shape.
108    #[serde(default)]
109    pub kind: String,
110    /// The block's `$quill` reference string (`name@version`), present on the
111    /// main card only. Omitted when absent.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub quill: Option<String>,
114    /// The block's `$id`, if any. Omitted when absent.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub id: Option<String>,
117    /// The block's opaque `$ext` map, if declared. Omitted when absent.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub ext: Option<JsonMap<String, JsonValue>>,
120    /// The block's `$seed` map (keyed by card-kind), if declared. Present on
121    /// the main card only. Omitted when absent.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub seed: Option<JsonMap<String, JsonValue>>,
124    /// User fields and comments, in source order.
125    #[serde(default, alias = "payload_items")]
126    pub payload_items: Vec<PayloadItemWire>,
127    /// The card body as canonical Content-JSON — the source-of-truth content
128    /// model (a content object, `{text, lines, marks, islands}`). The empty content
129    /// when absent. A markdown string is also accepted on input (imported), so an
130    /// LLM/markdown writer can hand a string here.
131    ///
132    /// No `body_markdown` projection rides this wire. Delimiter safety makes
133    /// `to_markdown` re-parse every rendered line, so an eager `export ∘ body`
134    /// precompute is not cheap; the `exportMarkdown(body)` codec at the binding
135    /// boundary does it on demand instead.
136    #[serde(default)]
137    pub body: JsonValue,
138}
139
140/// Failure converting a [`CardWire`] back into a [`Card`].
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum WireError {
143    /// The `quill` string is not a valid `name@version` reference.
144    InvalidQuillReference { value: String, reason: String },
145    /// A field violates the payload invariant: a name failing
146    /// `[A-Za-z_][A-Za-z0-9_]*`, or a value (including `$ext`) nesting past the
147    /// §8 depth limit.
148    InvalidField { key: String, reason: String },
149}
150
151impl std::fmt::Display for WireError {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        match self {
154            WireError::InvalidQuillReference { value, reason } => {
155                write!(f, "invalid `quill` reference {value:?}: {reason}")
156            }
157            WireError::InvalidField { key, reason } => {
158                write!(f, "invalid field {key:?}: {reason}")
159            }
160        }
161    }
162}
163
164impl std::error::Error for WireError {}
165
166impl From<&Card> for CardWire {
167    fn from(card: &Card) -> Self {
168        let mut wire = CardWire {
169            kind: String::new(),
170            quill: None,
171            id: None,
172            ext: None,
173            seed: None,
174            payload_items: Vec::new(),
175            body: quillmark_content::serial::to_canonical_value(card.body()),
176        };
177        for item in card.payload().items() {
178            match item {
179                PayloadItem::Quill { reference } => wire.quill = Some(reference.to_string()),
180                PayloadItem::Kind { value } => wire.kind = value.clone(),
181                PayloadItem::Id { value } => wire.id = Some(value.clone()),
182                PayloadItem::Meta {
183                    key: MetaKey::Ext,
184                    value,
185                    ..
186                } => wire.ext = Some(value.clone()),
187                PayloadItem::Meta {
188                    key: MetaKey::Seed,
189                    value,
190                    ..
191                } => wire.seed = Some(value.clone()),
192                PayloadItem::Field {
193                    key, value, fill, ..
194                } => {
195                    let nested_fills = value
196                        .nonroot_fill_paths()
197                        .map(|p| p.iter().map(PathStepWire::from).collect())
198                        .collect();
199                    wire.payload_items.push(PayloadItemWire::Field {
200                        key: key.clone(),
201                        value: value.as_json().clone(),
202                        fill: *fill,
203                        nested_fills,
204                    })
205                }
206                PayloadItem::Comment { text, inline } => {
207                    wire.payload_items.push(PayloadItemWire::Comment {
208                        text: text.clone(),
209                        inline: *inline,
210                    })
211                }
212            }
213        }
214        wire
215    }
216}
217
218impl TryFrom<CardWire> for Card {
219    type Error = WireError;
220
221    fn try_from(wire: CardWire) -> Result<Self, Self::Error> {
222        let items = wire
223            .payload_items
224            .into_iter()
225            .map(|item| match item {
226                PayloadItemWire::Field {
227                    key,
228                    value,
229                    fill,
230                    nested_fills,
231                } => {
232                    validate_wire_field(&key, &value)?;
233                    let mut qv = QuillValue::from_json(value);
234                    for path in &nested_fills {
235                        let segs: Vec<PathSegment> = path.iter().map(PathSegment::from).collect();
236                        qv.set_fill_at(&segs);
237                    }
238                    Ok(PayloadItem::Field {
239                        key,
240                        value: qv,
241                        fill,
242                        nested_comments: Vec::new(),
243                    })
244                }
245                PayloadItemWire::Comment { text, inline } => {
246                    Ok(PayloadItem::Comment { text, inline })
247                }
248            })
249            .collect::<Result<Vec<_>, WireError>>()?;
250
251        // Build the user fields/comments, then apply each `$` entry through its
252        // setter so the canonical `$quill < $kind < $id < $ext < $seed` ordering
253        // holds regardless of input order.
254        let mut payload = Payload::from_items(items);
255        if let Some(value) = wire.quill {
256            let reference = QuillReference::from_str(&value)
257                .map_err(|reason| WireError::InvalidQuillReference { value, reason })?;
258            payload.set_quill(reference);
259        }
260        // No `$kind` check here. This decoder validates only what a detached
261        // card can decide alone: field-name grammar, value depth, the `$quill`
262        // reference above. `$kind` validity is positional — `main` is right for
263        // the root and reserved for a composable card — and a `CardWire` carries
264        // no signal of which it is. So it belongs to `push_card`/`insert_card`,
265        // which know the position and the sibling `$id`s, and which report
266        // `edit::invalid_kind_name` / `edit::reserved_kind`.
267        //
268        // Checking the context-free half (the `[a-z_][a-z0-9_]*` grammar) here
269        // would split one user-facing concept across two error types, and the
270        // earlier `WireError` would shadow the routable `EditError` code.
271        if !wire.kind.is_empty() {
272            payload.set_kind(wire.kind);
273        }
274        if let Some(id) = wire.id {
275            payload.set_id(id);
276        }
277        let too_deep = |key: &str| {
278            let key = key.to_string();
279            move |max| WireError::InvalidField {
280                key,
281                reason: format!("nests deeper than the maximum of {} levels", max),
282            }
283        };
284        if let Some(ext) = wire.ext {
285            payload.set_ext(crate::value::depth_check_meta_map(ext, too_deep("$ext"))?);
286        }
287        if let Some(seed) = wire.seed {
288            payload.set_seed(crate::value::depth_check_meta_map(seed, too_deep("$seed"))?);
289        }
290        let body = body_from_wire(&wire.body)?;
291        Ok(Card::from_parts(payload, body))
292    }
293}
294
295/// Read a [`CardWire::body`] into a [`Content`] content. The body is the source
296/// of truth in two accepted encodings: a **content object** (an editor / a
297/// re-serialized card) is deserialized and validated; a **markdown string** (an
298/// LLM / markdown writer) is imported. `null`/absent is the empty content; any
299/// other shape is an invalid `$body`.
300fn body_from_wire(body: &JsonValue) -> Result<Content, WireError> {
301    let invalid = |reason: String| WireError::InvalidField {
302        key: "$body".to_string(),
303        reason,
304    };
305    match super::decode_richtext_value(body) {
306        Some(result) => result.map_err(|e| invalid(e.into_message())),
307        // `null`/absent is the empty content; every other non-decodable shape is
308        // an invalid `$body`.
309        None => match body {
310            JsonValue::Null => Ok(Content::empty()),
311            other => Err(invalid(format!(
312                "expected a richtext content object or a markdown string, got {}",
313                match other {
314                    JsonValue::Bool(_) => "a boolean",
315                    JsonValue::Number(_) => "a number",
316                    JsonValue::Array(_) => "an array",
317                    _ => "an unsupported value",
318                }
319            ))),
320        },
321    }
322}
323
324/// Validate a wire field against the payload invariant (see
325/// `edit::validate_field`), mapping a violation to [`WireError::InvalidField`].
326fn validate_wire_field(key: &str, value: &JsonValue) -> Result<(), WireError> {
327    use super::edit::{validate_field, FieldViolation};
328    validate_field(key, value).map_err(|v| WireError::InvalidField {
329        key: key.to_string(),
330        reason: match v {
331            FieldViolation::InvalidName => {
332                "field names must match [A-Za-z_][A-Za-z0-9_]*".to_string()
333            }
334            FieldViolation::TooDeep => format!(
335                "nests deeper than the maximum of {} levels",
336                crate::document::limits::MAX_YAML_DEPTH
337            ),
338        },
339    })
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use serde_json::json;
346
347    /// Nested `!must_fill` markers inside a field value survive Card → wire →
348    /// Card via the `nestedFills` path list (the JSON projection is fill-free).
349    #[test]
350    fn card_wire_round_trips_nested_fill() {
351        let mut addr = QuillValue::from_json(json!({"street": null, "city": "Anytown"}));
352        assert!(addr.set_fill_at(&[PathSegment::Key("street".to_string())]));
353        let payload = Payload::from_items(vec![PayloadItem::Field {
354            key: "addr".to_string(),
355            value: addr,
356            fill: false,
357            nested_comments: Vec::new(),
358        }]);
359        let card = Card::from_parts(payload, quillmark_content::Content::empty());
360
361        let wire = CardWire::from(&card);
362        let as_json = serde_json::to_value(&wire).unwrap();
363        assert_eq!(
364            as_json["payloadItems"][0]["nestedFills"],
365            json!([["street"]]),
366            "nested fill path rides the wire as a JS array; JSON value stays fill-free"
367        );
368        assert_eq!(
369            as_json["payloadItems"][0]["value"],
370            json!({"street": null, "city": "Anytown"})
371        );
372
373        let back = Card::try_from(wire).expect("wire → card");
374        assert_eq!(back, card, "nested fill must survive Card → wire → Card");
375    }
376
377    /// A richtext field stored as a canonical content object rides the wire
378    /// **structurally and losslessly** — the same opaque-JSON `Field` carrier as
379    /// any object value, so identity marks (an `underline` with no markdown
380    /// projection) survive Card → wire → Card. This is the lossless carrier the
381    /// card-yaml markdown projection (emit) deliberately is not.
382    #[test]
383    fn card_wire_round_trips_content_field_losslessly() {
384        use quillmark_content::model::{Mark, MarkKind};
385
386        let mut card = Card::new("note").unwrap();
387        let mut content = quillmark_content::import::from_markdown("underlined intro").unwrap();
388        content.marks.push(Mark {
389            start: 0,
390            end: 10,
391            kind: MarkKind::Underline,
392        });
393        content.normalize();
394        let json = quillmark_content::serial::to_canonical_value(&content);
395        let schema = crate::quill::FieldSchema::new(
396            "intro".to_string(),
397            crate::quill::FieldType::RichText { inline: false },
398            None,
399        );
400        card.commit_field("intro", crate::QuillValue::from_json(json), &schema)
401            .unwrap();
402
403        let wire = CardWire::from(&card);
404        // Carried as the content object, verbatim — not a markdown projection.
405        let as_json = serde_json::to_value(&wire).unwrap();
406        assert!(as_json["payloadItems"][0]["value"].is_object());
407
408        let back = Card::try_from(wire).expect("wire → card");
409        assert_eq!(back, card, "content field must survive Card → wire → Card");
410        // Underline (content-only, no markdown form) is intact after the round-trip.
411        let read = back.field_richtext("intro").unwrap().unwrap();
412        assert!(read.marks.iter().any(|m| matches!(m.kind, MarkKind::Underline)));
413    }
414
415    /// A field-and-comment card with `$kind` round-trips Card → wire → Card.
416    #[test]
417    fn card_wire_round_trips_fields_and_comment() {
418        let mut payload = Payload::from_items(vec![
419            PayloadItem::comment("a note"),
420            PayloadItem::field("title", QuillValue::from_json(json!("Hi"))),
421            PayloadItem::Field {
422                key: "count".to_string(),
423                value: QuillValue::from_json(json!(3)),
424                fill: true,
425                nested_comments: Vec::new(),
426            },
427        ]);
428        payload.set_kind("note");
429        let card = Card::from_parts(payload, crate::document::import_body("body text").unwrap());
430
431        let wire = CardWire::from(&card);
432        assert_eq!(wire.kind, "note");
433        assert_eq!(wire.payload_items.len(), 3);
434
435        let back = Card::try_from(wire).expect("wire → card");
436        assert_eq!(back, card, "Card → wire → Card must be identity");
437    }
438
439    /// `$quill` (main card) survives the round-trip and parses back.
440    #[test]
441    fn card_wire_round_trips_quill() {
442        let mut payload = Payload::from_index_map(Default::default());
443        payload.set_quill("memo@1.2.3".parse().unwrap());
444        payload.set_kind("main");
445        let card = Card::from_parts(payload, quillmark_content::Content::empty());
446
447        let wire = CardWire::from(&card);
448        assert_eq!(wire.quill.as_deref(), Some("memo@1.2.3"));
449
450        let back = Card::try_from(wire).expect("wire → card");
451        assert_eq!(back, card);
452    }
453
454    /// The wire JSON uses camelCase `payloadItems` and the `type`-tagged items.
455    #[test]
456    fn card_wire_json_shape() {
457        let card = Card::try_from(CardWire {
458            kind: "note".to_string(),
459            quill: None,
460            id: None,
461            ext: None,
462            seed: None,
463            payload_items: vec![PayloadItemWire::Field {
464                key: "x".to_string(),
465                value: json!(1),
466                fill: false,
467                nested_fills: Vec::new(),
468            }],
469            body: JsonValue::Null,
470        })
471        .unwrap();
472        let json = serde_json::to_value(CardWire::from(&card)).unwrap();
473        assert_eq!(json["kind"], json!("note"));
474        assert_eq!(json["payloadItems"][0]["type"], json!("field"));
475        assert_eq!(json["payloadItems"][0]["key"], json!("x"));
476        assert!(json.get("quill").is_none(), "absent quill is omitted");
477    }
478
479    /// A malformed `quill` string is a typed error, not a panic.
480    #[test]
481    fn card_wire_rejects_bad_quill() {
482        let err = Card::try_from(CardWire {
483            kind: String::new(),
484            quill: Some("@nope".to_string()),
485            id: None,
486            ext: None,
487            seed: None,
488            payload_items: Vec::new(),
489            body: JsonValue::Null,
490        })
491        .unwrap_err();
492        assert!(matches!(err, WireError::InvalidQuillReference { .. }));
493    }
494
495    /// Construction accepts a kind the mutators reject: `make_card` is
496    /// permissive data-shaping and `insert_card` is the gate, so the grammar
497    /// check belongs there, not here.
498    #[test]
499    fn card_wire_accepts_any_kind() {
500        let card = Card::try_from(CardWire {
501            kind: "BadKind".to_string(),
502            quill: None,
503            id: None,
504            ext: None,
505            seed: None,
506            payload_items: Vec::new(),
507            body: JsonValue::Null,
508        })
509        .expect("construction does not police the kind grammar");
510        assert_eq!(card.kind(), Some("BadKind"));
511    }
512}