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`,
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 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
140impl CardWire {
141    /// A card block with no `$`-prefixed system metadata. `kind` is the empty
142    /// string for a block declaring none. `body` is canonical Content-JSON, or
143    /// a markdown string the reader imports.
144    pub fn new(kind: String, body: JsonValue) -> Self {
145        Self {
146            kind,
147            quill: None,
148            ext: None,
149            seed: None,
150            payload_items: Vec::new(),
151            body,
152        }
153    }
154}
155
156/// Failure converting a [`CardWire`] back into a [`Card`].
157#[derive(Debug, Clone, PartialEq, Eq)]
158#[non_exhaustive]
159pub enum WireError {
160    /// The `quill` string is not a valid `name@version` reference.
161    InvalidQuillReference { value: String, reason: String },
162    /// A field violates the payload invariant: a name failing
163    /// `[A-Za-z_][A-Za-z0-9_]*`, or a value (including `$ext`) nesting past the
164    /// §8 depth limit.
165    InvalidField { key: String, reason: String },
166}
167
168impl std::fmt::Display for WireError {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        match self {
171            WireError::InvalidQuillReference { value, reason } => {
172                write!(f, "invalid `quill` reference {value:?}: {reason}")
173            }
174            WireError::InvalidField { key, reason } => {
175                write!(f, "invalid field {key:?}: {reason}")
176            }
177        }
178    }
179}
180
181impl std::error::Error for WireError {}
182
183impl From<&Card> for CardWire {
184    fn from(card: &Card) -> Self {
185        let mut wire = CardWire {
186            kind: String::new(),
187            quill: None,
188            ext: None,
189            seed: None,
190            payload_items: Vec::new(),
191            body: quillmark_content::serial::to_canonical_value(card.body()),
192        };
193        for item in card.payload().items() {
194            match item {
195                PayloadItem::Quill { reference } => wire.quill = Some(reference.to_string()),
196                PayloadItem::Kind { value } => wire.kind = value.clone(),
197                PayloadItem::Meta {
198                    key: MetaKey::Ext,
199                    value,
200                    ..
201                } => wire.ext = Some(value.clone()),
202                PayloadItem::Meta {
203                    key: MetaKey::Seed,
204                    value,
205                    ..
206                } => wire.seed = Some(value.clone()),
207                PayloadItem::Field {
208                    key, value, fill, ..
209                } => {
210                    let nested_fills = value
211                        .nonroot_fill_paths()
212                        .map(|p| p.iter().map(PathStepWire::from).collect())
213                        .collect();
214                    wire.payload_items.push(PayloadItemWire::Field {
215                        key: key.clone(),
216                        value: value.as_json().clone(),
217                        fill: *fill,
218                        nested_fills,
219                    })
220                }
221                PayloadItem::Comment { text, inline } => {
222                    wire.payload_items.push(PayloadItemWire::Comment {
223                        text: text.clone(),
224                        inline: *inline,
225                    })
226                }
227            }
228        }
229        wire
230    }
231}
232
233impl TryFrom<CardWire> for Card {
234    type Error = WireError;
235
236    fn try_from(wire: CardWire) -> Result<Self, Self::Error> {
237        let items = wire
238            .payload_items
239            .into_iter()
240            .map(|item| match item {
241                PayloadItemWire::Field {
242                    key,
243                    value,
244                    fill,
245                    nested_fills,
246                } => {
247                    validate_wire_field(&key, &value)?;
248                    let mut qv = QuillValue::from_json(value);
249                    for path in &nested_fills {
250                        let segs: Vec<PathSegment> = path.iter().map(PathSegment::from).collect();
251                        qv.set_fill_at(&segs);
252                    }
253                    Ok(PayloadItem::Field {
254                        key,
255                        value: qv,
256                        fill,
257                        nested_comments: Vec::new(),
258                    })
259                }
260                PayloadItemWire::Comment { text, inline } => {
261                    Ok(PayloadItem::Comment { text, inline })
262                }
263            })
264            .collect::<Result<Vec<_>, WireError>>()?;
265
266        // Build the user fields/comments, then apply each `$` entry through its
267        // setter so the canonical `$quill < $kind < $ext < $seed` ordering
268        // holds regardless of input order.
269        let mut payload = Payload::from_items(items);
270        if let Some(value) = wire.quill {
271            let reference = QuillReference::from_str(&value)
272                .map_err(|reason| WireError::InvalidQuillReference { value, reason })?;
273            payload.set_quill(reference);
274        }
275        // No `$kind` check here. This decoder validates only what a detached
276        // card can decide alone: field-name grammar, value depth, the `$quill`
277        // reference above. `$kind` validity is positional (`main` is right for
278        // the root and reserved for a composable card) and a `CardWire` carries
279        // no signal of which it is. So it belongs to `push_card`/`insert_card`,
280        // which know the position, and which report `edit::invalid_kind_name` /
281        // `edit::reserved_kind`.
282        //
283        // Checking the context-free half (the `[a-z_][a-z0-9_]*` grammar) here
284        // would split one user-facing concept across two error types, and the
285        // earlier `WireError` would shadow the routable `EditError` code.
286        if !wire.kind.is_empty() {
287            payload.set_kind(wire.kind);
288        }
289        let too_deep = |key: &str| {
290            let key = key.to_string();
291            move |max| WireError::InvalidField {
292                key,
293                reason: format!("nests deeper than the maximum of {} levels", max),
294            }
295        };
296        if let Some(ext) = wire.ext {
297            payload.set_ext(crate::value::depth_check_meta_map(ext, too_deep("$ext"))?);
298        }
299        if let Some(seed) = wire.seed {
300            payload.set_seed(crate::value::depth_check_meta_map(seed, too_deep("$seed"))?);
301        }
302        let body = body_from_wire(&wire.body)?;
303        Ok(Card::from_parts(payload, body))
304    }
305}
306
307/// Read a [`CardWire::body`] into a [`Content`] content. The body is the source
308/// of truth in two accepted encodings: a **content object** (an editor / a
309/// re-serialized card) is deserialized and validated; a **markdown string** (an
310/// LLM / markdown writer) is imported. `null`/absent is the empty content; any
311/// other shape is an invalid `$body`.
312fn body_from_wire(body: &JsonValue) -> Result<Content, WireError> {
313    let invalid = |reason: String| WireError::InvalidField {
314        key: "$body".to_string(),
315        reason,
316    };
317    match super::decode_richtext_value(body) {
318        Some(result) => result.map_err(|e| invalid(e.into_message())),
319        // `null`/absent is the empty content; every other non-decodable shape is
320        // an invalid `$body`.
321        None => match body {
322            JsonValue::Null => Ok(Content::empty()),
323            other => Err(invalid(format!(
324                "expected a richtext content object or a markdown string, got {}",
325                match other {
326                    JsonValue::Bool(_) => "a boolean",
327                    JsonValue::Number(_) => "a number",
328                    JsonValue::Array(_) => "an array",
329                    _ => "an unsupported value",
330                }
331            ))),
332        },
333    }
334}
335
336/// Validate a wire field against the payload invariant (see
337/// `edit::validate_field`), mapping a violation to [`WireError::InvalidField`].
338fn validate_wire_field(key: &str, value: &JsonValue) -> Result<(), WireError> {
339    use super::edit::{validate_field, FieldViolation};
340    validate_field(key, value).map_err(|v| WireError::InvalidField {
341        key: key.to_string(),
342        reason: match v {
343            FieldViolation::InvalidName => {
344                "field names must match [A-Za-z_][A-Za-z0-9_]*".to_string()
345            }
346            FieldViolation::TooDeep => format!(
347                "nests deeper than the maximum of {} levels",
348                crate::document::limits::MAX_YAML_DEPTH
349            ),
350        },
351    })
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use serde_json::json;
358
359    /// Nested `!must_fill` markers inside a field value survive Card → wire →
360    /// Card via the `nestedFills` path list (the JSON projection is fill-free).
361    #[test]
362    fn card_wire_round_trips_nested_fill() {
363        let mut addr = QuillValue::from_json(json!({"street": null, "city": "Anytown"}));
364        assert!(addr.set_fill_at(&[PathSegment::Key("street".to_string())]));
365        let payload = Payload::from_items(vec![PayloadItem::Field {
366            key: "addr".to_string(),
367            value: addr,
368            fill: false,
369            nested_comments: Vec::new(),
370        }]);
371        let card = Card::from_parts(payload, quillmark_content::Content::empty());
372
373        let wire = CardWire::from(&card);
374        let as_json = serde_json::to_value(&wire).unwrap();
375        assert_eq!(
376            as_json["payloadItems"][0]["nestedFills"],
377            json!([["street"]]),
378            "nested fill path rides the wire as a JS array; JSON value stays fill-free"
379        );
380        assert_eq!(
381            as_json["payloadItems"][0]["value"],
382            json!({"street": null, "city": "Anytown"})
383        );
384
385        let back = Card::try_from(wire).expect("wire → card");
386        assert_eq!(back, card, "nested fill must survive Card → wire → Card");
387    }
388
389    /// A richtext field stored as a canonical content object rides the wire
390    /// **structurally and losslessly**: the same opaque-JSON `Field` carrier as
391    /// any object value, so identity marks (an `underline` with no markdown
392    /// projection) survive Card → wire → Card. This is the lossless carrier the
393    /// card-yaml markdown projection (emit) deliberately is not.
394    #[test]
395    fn card_wire_round_trips_content_field_losslessly() {
396        use quillmark_content::model::{Mark, MarkKind};
397
398        let mut card = Card::new("note").unwrap();
399        let mut content = quillmark_content::import::from_markdown("underlined intro").unwrap();
400        content.marks.push(Mark::new(0, 10, MarkKind::Underline));
401        content.normalize();
402        let json = quillmark_content::serial::to_canonical_value(&content);
403        let schema = crate::quill::FieldSchema::new(
404            "intro".to_string(),
405            crate::quill::FieldType::RichText { inline: false },
406            None,
407        );
408        card.commit_field("intro", crate::QuillValue::from_json(json), &schema)
409            .unwrap();
410
411        let wire = CardWire::from(&card);
412        // Carried as the content object, verbatim: not a markdown projection.
413        let as_json = serde_json::to_value(&wire).unwrap();
414        assert!(as_json["payloadItems"][0]["value"].is_object());
415
416        let back = Card::try_from(wire).expect("wire → card");
417        assert_eq!(back, card, "content field must survive Card → wire → Card");
418        // Underline (content-only, no markdown form) is intact after the round-trip.
419        let read = back.field_richtext("intro").unwrap().unwrap();
420        assert!(read.marks.iter().any(|m| matches!(m.kind, MarkKind::Underline)));
421    }
422
423    /// A field-and-comment card with `$kind` round-trips Card → wire → Card.
424    #[test]
425    fn card_wire_round_trips_fields_and_comment() {
426        let mut payload = Payload::from_items(vec![
427            PayloadItem::comment("a note"),
428            PayloadItem::field("title", QuillValue::from_json(json!("Hi"))),
429            PayloadItem::Field {
430                key: "count".to_string(),
431                value: QuillValue::from_json(json!(3)),
432                fill: true,
433                nested_comments: Vec::new(),
434            },
435        ]);
436        payload.set_kind("note");
437        let card = Card::from_parts(payload, crate::document::import_body("body text").unwrap());
438
439        let wire = CardWire::from(&card);
440        assert_eq!(wire.kind, "note");
441        assert_eq!(wire.payload_items.len(), 3);
442
443        let back = Card::try_from(wire).expect("wire → card");
444        assert_eq!(back, card, "Card → wire → Card must be identity");
445    }
446
447    /// `$quill` (main card) survives the round-trip and parses back.
448    #[test]
449    fn card_wire_round_trips_quill() {
450        let mut payload = Payload::from_index_map(Default::default());
451        payload.set_quill("memo@1.2.3".parse().unwrap());
452        payload.set_kind("main");
453        let card = Card::from_parts(payload, quillmark_content::Content::empty());
454
455        let wire = CardWire::from(&card);
456        assert_eq!(wire.quill.as_deref(), Some("memo@1.2.3"));
457
458        let back = Card::try_from(wire).expect("wire → card");
459        assert_eq!(back, card);
460    }
461
462    /// The wire JSON uses camelCase `payloadItems` and the `type`-tagged items.
463    #[test]
464    fn card_wire_json_shape() {
465        let card = Card::try_from(CardWire {
466            kind: "note".to_string(),
467            quill: None,
468            ext: None,
469            seed: None,
470            payload_items: vec![PayloadItemWire::Field {
471                key: "x".to_string(),
472                value: json!(1),
473                fill: false,
474                nested_fills: Vec::new(),
475            }],
476            body: JsonValue::Null,
477        })
478        .unwrap();
479        let json = serde_json::to_value(CardWire::from(&card)).unwrap();
480        assert_eq!(json["kind"], json!("note"));
481        assert_eq!(json["payloadItems"][0]["type"], json!("field"));
482        assert_eq!(json["payloadItems"][0]["key"], json!("x"));
483        assert!(json.get("quill").is_none(), "absent quill is omitted");
484    }
485
486    /// A malformed `quill` string is a typed error, not a panic.
487    #[test]
488    fn card_wire_rejects_bad_quill() {
489        let err = Card::try_from(CardWire {
490            kind: String::new(),
491            quill: Some("@nope".to_string()),
492            ext: None,
493            seed: None,
494            payload_items: Vec::new(),
495            body: JsonValue::Null,
496        })
497        .unwrap_err();
498        assert!(matches!(err, WireError::InvalidQuillReference { .. }));
499    }
500
501    /// Construction accepts a kind the mutators reject: `make_card` is
502    /// permissive data-shaping and `insert_card` is the gate, so the grammar
503    /// check belongs there, not here.
504    #[test]
505    fn card_wire_accepts_any_kind() {
506        let card = Card::try_from(CardWire {
507            kind: "BadKind".to_string(),
508            quill: None,
509            ext: None,
510            seed: None,
511            payload_items: Vec::new(),
512            body: JsonValue::Null,
513        })
514        .expect("construction does not police the kind grammar");
515        assert_eq!(card.kind(), Some("BadKind"));
516    }
517}