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::{Payload, PayloadItem};
31use super::Card;
32use crate::value::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 written as `key: !fill <value>` in source / marked fill.
45        #[serde(default)]
46        fill: bool,
47    },
48    /// A YAML comment line (text excludes the leading `#`).
49    Comment {
50        text: String,
51        /// `true` for a trailing inline comment (`field: value # text`).
52        #[serde(default)]
53        inline: bool,
54    },
55}
56
57/// Canonical live wire form of a [`Card`]. See the module docs.
58///
59/// Serializes to JS-facing camelCase (`payloadItems`); the snake_case
60/// `payload_items` is also accepted on input for the Python binding.
61/// `deny_unknown_fields` makes a stale flat `{ kind, fields }` shape fail
62/// loudly rather than deserialize into an empty card.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase", deny_unknown_fields)]
65pub struct CardWire {
66    /// The block's `$kind` (e.g. `"endorsement"`); empty string when the block
67    /// declares no `$kind`. Kept non-optional to match the binding read shape.
68    #[serde(default)]
69    pub kind: String,
70    /// The block's `$quill` reference string (`name@version`), present on the
71    /// main card only. Omitted when absent.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub quill: Option<String>,
74    /// The block's `$id`, if any. Omitted when absent.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub id: Option<String>,
77    /// The block's opaque `$ext` map, if declared. Omitted when absent.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub ext: Option<JsonMap<String, JsonValue>>,
80    /// User fields and comments, in source order.
81    #[serde(default, alias = "payload_items")]
82    pub payload_items: Vec<PayloadItemWire>,
83    /// Markdown body after the card's closing fence. Empty when absent.
84    #[serde(default)]
85    pub body: String,
86}
87
88/// Failure converting a [`CardWire`] back into a [`Card`].
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum WireError {
91    /// The `quill` string is not a valid `name@version` reference.
92    InvalidQuillReference { value: String, reason: String },
93}
94
95impl std::fmt::Display for WireError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            WireError::InvalidQuillReference { value, reason } => {
99                write!(f, "invalid `quill` reference {value:?}: {reason}")
100            }
101        }
102    }
103}
104
105impl std::error::Error for WireError {}
106
107impl From<&Card> for CardWire {
108    fn from(card: &Card) -> Self {
109        let mut wire = CardWire {
110            kind: String::new(),
111            quill: None,
112            id: None,
113            ext: None,
114            payload_items: Vec::new(),
115            body: card.body().to_string(),
116        };
117        for item in card.payload().items() {
118            match item {
119                PayloadItem::Quill { reference } => wire.quill = Some(reference.to_string()),
120                PayloadItem::Kind { value } => wire.kind = value.clone(),
121                PayloadItem::Id { value } => wire.id = Some(value.clone()),
122                PayloadItem::Ext { value, .. } => wire.ext = Some(value.clone()),
123                PayloadItem::Field {
124                    key, value, fill, ..
125                } => wire.payload_items.push(PayloadItemWire::Field {
126                    key: key.clone(),
127                    value: value.as_json().clone(),
128                    fill: *fill,
129                }),
130                PayloadItem::Comment { text, inline } => {
131                    wire.payload_items.push(PayloadItemWire::Comment {
132                        text: text.clone(),
133                        inline: *inline,
134                    })
135                }
136            }
137        }
138        wire
139    }
140}
141
142impl TryFrom<CardWire> for Card {
143    type Error = WireError;
144
145    fn try_from(wire: CardWire) -> Result<Self, Self::Error> {
146        let items = wire
147            .payload_items
148            .into_iter()
149            .map(|item| match item {
150                PayloadItemWire::Field { key, value, fill } => PayloadItem::Field {
151                    key,
152                    value: QuillValue::from_json(value),
153                    fill,
154                    nested_comments: Vec::new(),
155                },
156                PayloadItemWire::Comment { text, inline } => PayloadItem::Comment { text, inline },
157            })
158            .collect::<Vec<_>>();
159
160        // Build the user fields/comments, then apply each `$` entry through its
161        // setter so the canonical `$quill < $kind < $id < $ext` ordering holds
162        // regardless of input order.
163        let mut payload = Payload::from_items(items);
164        if let Some(value) = wire.quill {
165            let reference = QuillReference::from_str(&value)
166                .map_err(|reason| WireError::InvalidQuillReference { value, reason })?;
167            payload.set_quill(reference);
168        }
169        if !wire.kind.is_empty() {
170            payload.set_kind(wire.kind);
171        }
172        if let Some(id) = wire.id {
173            payload.set_id(id);
174        }
175        if let Some(ext) = wire.ext {
176            payload.set_ext(ext);
177        }
178        Ok(Card::from_parts(payload, wire.body))
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use serde_json::json;
186
187    /// A field-and-comment card with `$kind` round-trips Card → wire → Card.
188    #[test]
189    fn card_wire_round_trips_fields_and_comment() {
190        let mut payload = Payload::from_items(vec![
191            PayloadItem::comment("a note"),
192            PayloadItem::field("title", QuillValue::from_json(json!("Hi"))),
193            PayloadItem::Field {
194                key: "count".to_string(),
195                value: QuillValue::from_json(json!(3)),
196                fill: true,
197                nested_comments: Vec::new(),
198            },
199        ]);
200        payload.set_kind("note");
201        let card = Card::from_parts(payload, "body text".to_string());
202
203        let wire = CardWire::from(&card);
204        assert_eq!(wire.kind, "note");
205        assert_eq!(wire.payload_items.len(), 3);
206
207        let back = Card::try_from(wire).expect("wire → card");
208        assert_eq!(back, card, "Card → wire → Card must be identity");
209    }
210
211    /// `$quill` (main card) survives the round-trip and parses back.
212    #[test]
213    fn card_wire_round_trips_quill() {
214        let mut payload = Payload::from_index_map(Default::default());
215        payload.set_quill("memo@1.2.3".parse().unwrap());
216        payload.set_kind("main");
217        let card = Card::from_parts(payload, String::new());
218
219        let wire = CardWire::from(&card);
220        assert_eq!(wire.quill.as_deref(), Some("memo@1.2.3"));
221
222        let back = Card::try_from(wire).expect("wire → card");
223        assert_eq!(back, card);
224    }
225
226    /// The wire JSON uses camelCase `payloadItems` and the `type`-tagged items.
227    #[test]
228    fn card_wire_json_shape() {
229        let card = Card::try_from(CardWire {
230            kind: "note".to_string(),
231            quill: None,
232            id: None,
233            ext: None,
234            payload_items: vec![PayloadItemWire::Field {
235                key: "x".to_string(),
236                value: json!(1),
237                fill: false,
238            }],
239            body: String::new(),
240        })
241        .unwrap();
242        let json = serde_json::to_value(CardWire::from(&card)).unwrap();
243        assert_eq!(json["kind"], json!("note"));
244        assert_eq!(json["payloadItems"][0]["type"], json!("field"));
245        assert_eq!(json["payloadItems"][0]["key"], json!("x"));
246        assert!(json.get("quill").is_none(), "absent quill is omitted");
247    }
248
249    /// A malformed `quill` string is a typed error, not a panic.
250    #[test]
251    fn card_wire_rejects_bad_quill() {
252        let err = Card::try_from(CardWire {
253            kind: String::new(),
254            quill: Some("@nope".to_string()),
255            id: None,
256            ext: None,
257            payload_items: Vec::new(),
258            body: String::new(),
259        })
260        .unwrap_err();
261        assert!(matches!(err, WireError::InvalidQuillReference { .. }));
262    }
263}