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 still hand a string here.
131    ///
132    /// No `body_markdown` projection rides this wire: the eager `export ∘ body`
133    /// precompute was dropped (the delimiter-safety fix makes `to_markdown`
134    /// re-parse every rendered line, so it is no longer cheap) in favor of the
135    /// on-demand `exportMarkdown(body)` codec at the binding boundary.
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        if !wire.kind.is_empty() {
261            payload.set_kind(wire.kind);
262        }
263        if let Some(id) = wire.id {
264            payload.set_id(id);
265        }
266        let too_deep = |key: &str| {
267            let key = key.to_string();
268            move |max| WireError::InvalidField {
269                key,
270                reason: format!("nests deeper than the maximum of {} levels", max),
271            }
272        };
273        if let Some(ext) = wire.ext {
274            payload.set_ext(crate::value::depth_check_meta_map(ext, too_deep("$ext"))?);
275        }
276        if let Some(seed) = wire.seed {
277            payload.set_seed(crate::value::depth_check_meta_map(seed, too_deep("$seed"))?);
278        }
279        let body = body_from_wire(&wire.body)?;
280        Ok(Card::from_parts(payload, body))
281    }
282}
283
284/// Read a [`CardWire::body`] into a [`Content`] content. The body is the source
285/// of truth in two accepted encodings: a **content object** (an editor / a
286/// re-serialized card) is deserialized and validated; a **markdown string** (an
287/// LLM / markdown writer) is imported. `null`/absent is the empty content; any
288/// other shape is an invalid `$body`.
289fn body_from_wire(body: &JsonValue) -> Result<Content, WireError> {
290    let invalid = |reason: String| WireError::InvalidField {
291        key: "$body".to_string(),
292        reason,
293    };
294    match super::decode_richtext_value(body) {
295        Some(result) => result.map_err(|e| invalid(e.into_message())),
296        // `null`/absent is the empty content; every other non-decodable shape is
297        // an invalid `$body`.
298        None => match body {
299            JsonValue::Null => Ok(Content::empty()),
300            other => Err(invalid(format!(
301                "expected a richtext content object or a markdown string, got {}",
302                match other {
303                    JsonValue::Bool(_) => "a boolean",
304                    JsonValue::Number(_) => "a number",
305                    JsonValue::Array(_) => "an array",
306                    _ => "an unsupported value",
307                }
308            ))),
309        },
310    }
311}
312
313/// Validate a wire field against the payload invariant (see
314/// `edit::validate_field`), mapping a violation to [`WireError::InvalidField`].
315fn validate_wire_field(key: &str, value: &JsonValue) -> Result<(), WireError> {
316    use super::edit::{validate_field, FieldViolation};
317    validate_field(key, value).map_err(|v| WireError::InvalidField {
318        key: key.to_string(),
319        reason: match v {
320            FieldViolation::InvalidName => {
321                "field names must match [A-Za-z_][A-Za-z0-9_]*".to_string()
322            }
323            FieldViolation::TooDeep => format!(
324                "nests deeper than the maximum of {} levels",
325                crate::document::limits::MAX_YAML_DEPTH
326            ),
327        },
328    })
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use serde_json::json;
335
336    /// Nested `!must_fill` markers inside a field value survive Card → wire →
337    /// Card via the `nestedFills` path list (the JSON projection is fill-free).
338    #[test]
339    fn card_wire_round_trips_nested_fill() {
340        let mut addr = QuillValue::from_json(json!({"street": null, "city": "Anytown"}));
341        assert!(addr.set_fill_at(&[PathSegment::Key("street".to_string())]));
342        let payload = Payload::from_items(vec![PayloadItem::Field {
343            key: "addr".to_string(),
344            value: addr,
345            fill: false,
346            nested_comments: Vec::new(),
347        }]);
348        let card = Card::from_parts(payload, quillmark_content::Content::empty());
349
350        let wire = CardWire::from(&card);
351        let as_json = serde_json::to_value(&wire).unwrap();
352        assert_eq!(
353            as_json["payloadItems"][0]["nestedFills"],
354            json!([["street"]]),
355            "nested fill path rides the wire as a JS array; JSON value stays fill-free"
356        );
357        assert_eq!(
358            as_json["payloadItems"][0]["value"],
359            json!({"street": null, "city": "Anytown"})
360        );
361
362        let back = Card::try_from(wire).expect("wire → card");
363        assert_eq!(back, card, "nested fill must survive Card → wire → Card");
364    }
365
366    /// A richtext field stored as a canonical content object rides the wire
367    /// **structurally and losslessly** — the same opaque-JSON `Field` carrier as
368    /// any object value, so identity marks (an `underline` with no markdown
369    /// projection) survive Card → wire → Card. This is the lossless carrier the
370    /// card-yaml markdown projection (emit) deliberately is not.
371    #[test]
372    fn card_wire_round_trips_content_field_losslessly() {
373        use quillmark_content::model::{Mark, MarkKind};
374
375        let mut card = Card::new("note").unwrap();
376        let mut content = quillmark_content::import::from_markdown("underlined intro").unwrap();
377        content.marks.push(Mark {
378            start: 0,
379            end: 10,
380            kind: MarkKind::Underline,
381        });
382        content.normalize();
383        let json = quillmark_content::serial::to_canonical_value(&content);
384        let schema = crate::quill::FieldSchema::new(
385            "intro".to_string(),
386            crate::quill::FieldType::RichText { inline: false },
387            None,
388        );
389        card.commit_field("intro", crate::QuillValue::from_json(json), &schema)
390            .unwrap();
391
392        let wire = CardWire::from(&card);
393        // Carried as the content object, verbatim — not a markdown projection.
394        let as_json = serde_json::to_value(&wire).unwrap();
395        assert!(as_json["payloadItems"][0]["value"].is_object());
396
397        let back = Card::try_from(wire).expect("wire → card");
398        assert_eq!(back, card, "content field must survive Card → wire → Card");
399        // Underline (content-only, no markdown form) is intact after the round-trip.
400        let read = back.field_richtext("intro").unwrap().unwrap();
401        assert!(read.marks.iter().any(|m| matches!(m.kind, MarkKind::Underline)));
402    }
403
404    /// A field-and-comment card with `$kind` round-trips Card → wire → Card.
405    #[test]
406    fn card_wire_round_trips_fields_and_comment() {
407        let mut payload = Payload::from_items(vec![
408            PayloadItem::comment("a note"),
409            PayloadItem::field("title", QuillValue::from_json(json!("Hi"))),
410            PayloadItem::Field {
411                key: "count".to_string(),
412                value: QuillValue::from_json(json!(3)),
413                fill: true,
414                nested_comments: Vec::new(),
415            },
416        ]);
417        payload.set_kind("note");
418        let card = Card::from_parts(payload, crate::document::import_body("body text").unwrap());
419
420        let wire = CardWire::from(&card);
421        assert_eq!(wire.kind, "note");
422        assert_eq!(wire.payload_items.len(), 3);
423
424        let back = Card::try_from(wire).expect("wire → card");
425        assert_eq!(back, card, "Card → wire → Card must be identity");
426    }
427
428    /// `$quill` (main card) survives the round-trip and parses back.
429    #[test]
430    fn card_wire_round_trips_quill() {
431        let mut payload = Payload::from_index_map(Default::default());
432        payload.set_quill("memo@1.2.3".parse().unwrap());
433        payload.set_kind("main");
434        let card = Card::from_parts(payload, quillmark_content::Content::empty());
435
436        let wire = CardWire::from(&card);
437        assert_eq!(wire.quill.as_deref(), Some("memo@1.2.3"));
438
439        let back = Card::try_from(wire).expect("wire → card");
440        assert_eq!(back, card);
441    }
442
443    /// The wire JSON uses camelCase `payloadItems` and the `type`-tagged items.
444    #[test]
445    fn card_wire_json_shape() {
446        let card = Card::try_from(CardWire {
447            kind: "note".to_string(),
448            quill: None,
449            id: None,
450            ext: None,
451            seed: None,
452            payload_items: vec![PayloadItemWire::Field {
453                key: "x".to_string(),
454                value: json!(1),
455                fill: false,
456                nested_fills: Vec::new(),
457            }],
458            body: JsonValue::Null,
459        })
460        .unwrap();
461        let json = serde_json::to_value(CardWire::from(&card)).unwrap();
462        assert_eq!(json["kind"], json!("note"));
463        assert_eq!(json["payloadItems"][0]["type"], json!("field"));
464        assert_eq!(json["payloadItems"][0]["key"], json!("x"));
465        assert!(json.get("quill").is_none(), "absent quill is omitted");
466    }
467
468    /// A malformed `quill` string is a typed error, not a panic.
469    #[test]
470    fn card_wire_rejects_bad_quill() {
471        let err = Card::try_from(CardWire {
472            kind: String::new(),
473            quill: Some("@nope".to_string()),
474            id: None,
475            ext: None,
476            seed: None,
477            payload_items: Vec::new(),
478            body: JsonValue::Null,
479        })
480        .unwrap_err();
481        assert!(matches!(err, WireError::InvalidQuillReference { .. }));
482    }
483}