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-z_][a-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 => "field names must match [a-z_][a-z0-9_]*".to_string(),
303            FieldViolation::TooDeep => format!(
304                "nests deeper than the maximum of {} levels",
305                crate::document::limits::MAX_YAML_DEPTH
306            ),
307        },
308    })
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use serde_json::json;
315
316    /// Nested `!must_fill` markers inside a field value survive Card → wire →
317    /// Card via the `nestedFills` path list (the JSON projection is fill-free).
318    #[test]
319    fn card_wire_round_trips_nested_fill() {
320        let mut addr = QuillValue::from_json(json!({"street": null, "city": "Anytown"}));
321        assert!(addr.set_fill_at(&[PathSegment::Key("street".to_string())]));
322        let payload = Payload::from_items(vec![PayloadItem::Field {
323            key: "addr".to_string(),
324            value: addr,
325            fill: false,
326            nested_comments: Vec::new(),
327        }]);
328        let card = Card::from_parts(payload, String::new());
329
330        let wire = CardWire::from(&card);
331        let as_json = serde_json::to_value(&wire).unwrap();
332        assert_eq!(
333            as_json["payloadItems"][0]["nestedFills"],
334            json!([["street"]]),
335            "nested fill path rides the wire as a JS array; JSON value stays fill-free"
336        );
337        assert_eq!(
338            as_json["payloadItems"][0]["value"],
339            json!({"street": null, "city": "Anytown"})
340        );
341
342        let back = Card::try_from(wire).expect("wire → card");
343        assert_eq!(back, card, "nested fill must survive Card → wire → Card");
344    }
345
346    /// A field-and-comment card with `$kind` round-trips Card → wire → Card.
347    #[test]
348    fn card_wire_round_trips_fields_and_comment() {
349        let mut payload = Payload::from_items(vec![
350            PayloadItem::comment("a note"),
351            PayloadItem::field("title", QuillValue::from_json(json!("Hi"))),
352            PayloadItem::Field {
353                key: "count".to_string(),
354                value: QuillValue::from_json(json!(3)),
355                fill: true,
356                nested_comments: Vec::new(),
357            },
358        ]);
359        payload.set_kind("note");
360        let card = Card::from_parts(payload, "body text".to_string());
361
362        let wire = CardWire::from(&card);
363        assert_eq!(wire.kind, "note");
364        assert_eq!(wire.payload_items.len(), 3);
365
366        let back = Card::try_from(wire).expect("wire → card");
367        assert_eq!(back, card, "Card → wire → Card must be identity");
368    }
369
370    /// `$quill` (main card) survives the round-trip and parses back.
371    #[test]
372    fn card_wire_round_trips_quill() {
373        let mut payload = Payload::from_index_map(Default::default());
374        payload.set_quill("memo@1.2.3".parse().unwrap());
375        payload.set_kind("main");
376        let card = Card::from_parts(payload, String::new());
377
378        let wire = CardWire::from(&card);
379        assert_eq!(wire.quill.as_deref(), Some("memo@1.2.3"));
380
381        let back = Card::try_from(wire).expect("wire → card");
382        assert_eq!(back, card);
383    }
384
385    /// The wire JSON uses camelCase `payloadItems` and the `type`-tagged items.
386    #[test]
387    fn card_wire_json_shape() {
388        let card = Card::try_from(CardWire {
389            kind: "note".to_string(),
390            quill: None,
391            id: None,
392            ext: None,
393            seed: None,
394            payload_items: vec![PayloadItemWire::Field {
395                key: "x".to_string(),
396                value: json!(1),
397                fill: false,
398                nested_fills: Vec::new(),
399            }],
400            body: String::new(),
401        })
402        .unwrap();
403        let json = serde_json::to_value(CardWire::from(&card)).unwrap();
404        assert_eq!(json["kind"], json!("note"));
405        assert_eq!(json["payloadItems"][0]["type"], json!("field"));
406        assert_eq!(json["payloadItems"][0]["key"], json!("x"));
407        assert!(json.get("quill").is_none(), "absent quill is omitted");
408    }
409
410    /// A malformed `quill` string is a typed error, not a panic.
411    #[test]
412    fn card_wire_rejects_bad_quill() {
413        let err = Card::try_from(CardWire {
414            kind: String::new(),
415            quill: Some("@nope".to_string()),
416            id: None,
417            ext: None,
418            seed: None,
419            payload_items: Vec::new(),
420            body: String::new(),
421        })
422        .unwrap_err();
423        assert!(matches!(err, WireError::InvalidQuillReference { .. }));
424    }
425}