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