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_82_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`); `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;
34
35/// One entry in a [`CardWire`]'s `payload_items`: a user field or a comment.
36/// The `$` system entries are hoisted onto [`CardWire`] itself, never here.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(tag = "type", rename_all = "lowercase")]
39pub enum PayloadItemWire {
40    /// A user-defined field.
41    Field {
42        key: String,
43        value: JsonValue,
44        /// `true` when the field itself is `key: !must_fill <value>` in source.
45        #[serde(default)]
46        fill: bool,
47        /// Paths to `!must_fill` markers nested *inside* `value` (e.g. a leaf
48        /// property of an object, or a key within an array element). The JSON
49        /// `value` projection is fill-free, so these carry the nested markers
50        /// across the wire. Empty for a top-level-only or no-fill field.
51        #[serde(
52            default,
53            rename = "nestedFills",
54            alias = "nested_fills",
55            skip_serializing_if = "Vec::is_empty"
56        )]
57        nested_fills: Vec<Vec<PathStepWire>>,
58    },
59    /// A YAML comment line (text excludes the leading `#`).
60    Comment {
61        text: String,
62        /// `true` for a trailing inline comment (`field: value # text`).
63        #[serde(default)]
64        inline: bool,
65    },
66}
67
68/// One step in a nested fill path: an object key or an array index. Serializes
69/// **untagged** — a key as a JSON string, an index as a JSON number — so a path
70/// is a plain JS array like `["addr", "street"]` or `["recipients", 0, "name"]`.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[serde(untagged)]
73pub enum PathStepWire {
74    Index(usize),
75    Key(String),
76}
77
78impl From<&PathSegment> for PathStepWire {
79    fn from(seg: &PathSegment) -> Self {
80        match seg {
81            PathSegment::Key(k) => PathStepWire::Key(k.clone()),
82            PathSegment::Index(i) => PathStepWire::Index(*i),
83        }
84    }
85}
86
87impl From<&PathStepWire> for PathSegment {
88    fn from(seg: &PathStepWire) -> Self {
89        match seg {
90            PathStepWire::Key(k) => PathSegment::Key(k.clone()),
91            PathStepWire::Index(i) => PathSegment::Index(*i),
92        }
93    }
94}
95
96/// Canonical live wire form of a [`Card`]. See the module docs.
97///
98/// Serializes to JS-facing camelCase (`payloadItems`); the snake_case
99/// `payload_items` is also accepted on input for the Python binding.
100/// `deny_unknown_fields` makes a stale flat `{ kind, fields }` shape fail
101/// loudly rather than deserialize into an empty card.
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase", deny_unknown_fields)]
104pub struct CardWire {
105    /// The block's `$kind` (e.g. `"endorsement"`); empty string when the block
106    /// declares no `$kind`. Kept non-optional to match the binding read shape.
107    #[serde(default)]
108    pub kind: String,
109    /// The block's `$quill` reference string (`name@version`), present on the
110    /// main card only. Omitted when absent.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub quill: Option<String>,
113    /// The block's `$id`, if any. Omitted when absent.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub id: Option<String>,
116    /// The block's opaque `$ext` map, if declared. Omitted when absent.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub ext: Option<JsonMap<String, JsonValue>>,
119    /// The block's `$seed` map (keyed by card-kind), if declared. Present on
120    /// the main card only. Omitted when absent.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub seed: Option<JsonMap<String, JsonValue>>,
123    /// User fields and comments, in source order.
124    #[serde(default, alias = "payload_items")]
125    pub payload_items: Vec<PayloadItemWire>,
126    /// Markdown body after the card's closing fence. Empty when absent.
127    #[serde(default)]
128    pub body: String,
129}
130
131/// Failure converting a [`CardWire`] back into a [`Card`].
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum WireError {
134    /// The `quill` string is not a valid `name@version` reference.
135    InvalidQuillReference { value: String, reason: String },
136    /// A field violates the payload invariant: a name failing
137    /// `[A-Za-z_][A-Za-z0-9_]*`, or a value (including `$ext`) nesting past the
138    /// §8 depth limit.
139    InvalidField { key: String, reason: String },
140}
141
142impl std::fmt::Display for WireError {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        match self {
145            WireError::InvalidQuillReference { value, reason } => {
146                write!(f, "invalid `quill` reference {value:?}: {reason}")
147            }
148            WireError::InvalidField { key, reason } => {
149                write!(f, "invalid field {key:?}: {reason}")
150            }
151        }
152    }
153}
154
155impl std::error::Error for WireError {}
156
157impl From<&Card> for CardWire {
158    fn from(card: &Card) -> Self {
159        let mut wire = CardWire {
160            kind: String::new(),
161            quill: None,
162            id: None,
163            ext: None,
164            seed: None,
165            payload_items: Vec::new(),
166            body: card.body().to_string(),
167        };
168        for item in card.payload().items() {
169            match item {
170                PayloadItem::Quill { reference } => wire.quill = Some(reference.to_string()),
171                PayloadItem::Kind { value } => wire.kind = value.clone(),
172                PayloadItem::Id { value } => wire.id = Some(value.clone()),
173                PayloadItem::Meta {
174                    key: MetaKey::Ext,
175                    value,
176                    ..
177                } => wire.ext = Some(value.clone()),
178                PayloadItem::Meta {
179                    key: MetaKey::Seed,
180                    value,
181                    ..
182                } => wire.seed = Some(value.clone()),
183                PayloadItem::Field {
184                    key, value, fill, ..
185                } => {
186                    let nested_fills = value
187                        .nonroot_fill_paths()
188                        .map(|p| p.iter().map(PathStepWire::from).collect())
189                        .collect();
190                    wire.payload_items.push(PayloadItemWire::Field {
191                        key: key.clone(),
192                        value: value.as_json().clone(),
193                        fill: *fill,
194                        nested_fills,
195                    })
196                }
197                PayloadItem::Comment { text, inline } => {
198                    wire.payload_items.push(PayloadItemWire::Comment {
199                        text: text.clone(),
200                        inline: *inline,
201                    })
202                }
203            }
204        }
205        wire
206    }
207}
208
209impl TryFrom<CardWire> for Card {
210    type Error = WireError;
211
212    fn try_from(wire: CardWire) -> Result<Self, Self::Error> {
213        let items = wire
214            .payload_items
215            .into_iter()
216            .map(|item| match item {
217                PayloadItemWire::Field {
218                    key,
219                    value,
220                    fill,
221                    nested_fills,
222                } => {
223                    validate_wire_field(&key, &value)?;
224                    let mut qv = QuillValue::from_json(value);
225                    for path in &nested_fills {
226                        let segs: Vec<PathSegment> = path.iter().map(PathSegment::from).collect();
227                        qv.set_fill_at(&segs);
228                    }
229                    Ok(PayloadItem::Field {
230                        key,
231                        value: qv,
232                        fill,
233                        nested_comments: Vec::new(),
234                    })
235                }
236                PayloadItemWire::Comment { text, inline } => {
237                    Ok(PayloadItem::Comment { text, inline })
238                }
239            })
240            .collect::<Result<Vec<_>, WireError>>()?;
241
242        // Build the user fields/comments, then apply each `$` entry through its
243        // setter so the canonical `$quill < $kind < $id < $ext < $seed` ordering
244        // holds regardless of input order.
245        let mut payload = Payload::from_items(items);
246        if let Some(value) = wire.quill {
247            let reference = QuillReference::from_str(&value)
248                .map_err(|reason| WireError::InvalidQuillReference { value, reason })?;
249            payload.set_quill(reference);
250        }
251        if !wire.kind.is_empty() {
252            payload.set_kind(wire.kind);
253        }
254        if let Some(id) = wire.id {
255            payload.set_id(id);
256        }
257        if let Some(ext) = wire.ext {
258            let as_value = JsonValue::Object(ext);
259            if crate::value::json_depth_exceeds(&as_value, crate::document::limits::MAX_YAML_DEPTH)
260            {
261                return Err(WireError::InvalidField {
262                    key: "$ext".to_string(),
263                    reason: format!(
264                        "nests deeper than the maximum of {} levels",
265                        crate::document::limits::MAX_YAML_DEPTH
266                    ),
267                });
268            }
269            let JsonValue::Object(ext) = as_value else {
270                unreachable!("constructed as Object above")
271            };
272            payload.set_ext(ext);
273        }
274        if let Some(seed) = wire.seed {
275            let as_value = JsonValue::Object(seed);
276            if crate::value::json_depth_exceeds(&as_value, crate::document::limits::MAX_YAML_DEPTH)
277            {
278                return Err(WireError::InvalidField {
279                    key: "$seed".to_string(),
280                    reason: format!(
281                        "nests deeper than the maximum of {} levels",
282                        crate::document::limits::MAX_YAML_DEPTH
283                    ),
284                });
285            }
286            let JsonValue::Object(seed) = as_value else {
287                unreachable!("constructed as Object above")
288            };
289            payload.set_seed(seed);
290        }
291        Ok(Card::from_parts(payload, wire.body))
292    }
293}
294
295/// Validate a wire field against the payload invariant (see
296/// `edit::validate_field`), mapping a violation to [`WireError::InvalidField`].
297fn validate_wire_field(key: &str, value: &JsonValue) -> Result<(), WireError> {
298    use super::edit::{validate_field, FieldViolation};
299    validate_field(key, value).map_err(|v| WireError::InvalidField {
300        key: key.to_string(),
301        reason: match v {
302            FieldViolation::InvalidName => {
303                "field names must match [A-Za-z_][A-Za-z0-9_]*".to_string()
304            }
305            FieldViolation::TooDeep => format!(
306                "nests deeper than the maximum of {} levels",
307                crate::document::limits::MAX_YAML_DEPTH
308            ),
309        },
310    })
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use serde_json::json;
317
318    /// Nested `!must_fill` markers inside a field value survive Card → wire →
319    /// Card via the `nestedFills` path list (the JSON projection is fill-free).
320    #[test]
321    fn card_wire_round_trips_nested_fill() {
322        let mut addr = QuillValue::from_json(json!({"street": null, "city": "Anytown"}));
323        assert!(addr.set_fill_at(&[PathSegment::Key("street".to_string())]));
324        let payload = Payload::from_items(vec![PayloadItem::Field {
325            key: "addr".to_string(),
326            value: addr,
327            fill: false,
328            nested_comments: Vec::new(),
329        }]);
330        let card = Card::from_parts(payload, String::new());
331
332        let wire = CardWire::from(&card);
333        let as_json = serde_json::to_value(&wire).unwrap();
334        assert_eq!(
335            as_json["payloadItems"][0]["nestedFills"],
336            json!([["street"]]),
337            "nested fill path rides the wire as a JS array; JSON value stays fill-free"
338        );
339        assert_eq!(
340            as_json["payloadItems"][0]["value"],
341            json!({"street": null, "city": "Anytown"})
342        );
343
344        let back = Card::try_from(wire).expect("wire → card");
345        assert_eq!(back, card, "nested fill must survive Card → wire → Card");
346    }
347
348    /// A field-and-comment card with `$kind` round-trips Card → wire → Card.
349    #[test]
350    fn card_wire_round_trips_fields_and_comment() {
351        let mut payload = Payload::from_items(vec![
352            PayloadItem::comment("a note"),
353            PayloadItem::field("title", QuillValue::from_json(json!("Hi"))),
354            PayloadItem::Field {
355                key: "count".to_string(),
356                value: QuillValue::from_json(json!(3)),
357                fill: true,
358                nested_comments: Vec::new(),
359            },
360        ]);
361        payload.set_kind("note");
362        let card = Card::from_parts(payload, "body text".to_string());
363
364        let wire = CardWire::from(&card);
365        assert_eq!(wire.kind, "note");
366        assert_eq!(wire.payload_items.len(), 3);
367
368        let back = Card::try_from(wire).expect("wire → card");
369        assert_eq!(back, card, "Card → wire → Card must be identity");
370    }
371
372    /// `$quill` (main card) survives the round-trip and parses back.
373    #[test]
374    fn card_wire_round_trips_quill() {
375        let mut payload = Payload::from_index_map(Default::default());
376        payload.set_quill("memo@1.2.3".parse().unwrap());
377        payload.set_kind("main");
378        let card = Card::from_parts(payload, String::new());
379
380        let wire = CardWire::from(&card);
381        assert_eq!(wire.quill.as_deref(), Some("memo@1.2.3"));
382
383        let back = Card::try_from(wire).expect("wire → card");
384        assert_eq!(back, card);
385    }
386
387    /// The wire JSON uses camelCase `payloadItems` and the `type`-tagged items.
388    #[test]
389    fn card_wire_json_shape() {
390        let card = Card::try_from(CardWire {
391            kind: "note".to_string(),
392            quill: None,
393            id: None,
394            ext: None,
395            seed: None,
396            payload_items: vec![PayloadItemWire::Field {
397                key: "x".to_string(),
398                value: json!(1),
399                fill: false,
400                nested_fills: Vec::new(),
401            }],
402            body: String::new(),
403        })
404        .unwrap();
405        let json = serde_json::to_value(CardWire::from(&card)).unwrap();
406        assert_eq!(json["kind"], json!("note"));
407        assert_eq!(json["payloadItems"][0]["type"], json!("field"));
408        assert_eq!(json["payloadItems"][0]["key"], json!("x"));
409        assert!(json.get("quill").is_none(), "absent quill is omitted");
410    }
411
412    /// A malformed `quill` string is a typed error, not a panic.
413    #[test]
414    fn card_wire_rejects_bad_quill() {
415        let err = Card::try_from(CardWire {
416            kind: String::new(),
417            quill: Some("@nope".to_string()),
418            id: None,
419            ext: None,
420            seed: None,
421            payload_items: Vec::new(),
422            body: String::new(),
423        })
424        .unwrap_err();
425        assert!(matches!(err, WireError::InvalidQuillReference { .. }));
426    }
427}