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    /// A field violates the payload invariant: a name failing
94    /// `[a-z_][a-z0-9_]*`, or a value (including `$ext`) nesting past the
95    /// §8 depth limit.
96    InvalidField { key: String, reason: String },
97}
98
99impl std::fmt::Display for WireError {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        match self {
102            WireError::InvalidQuillReference { value, reason } => {
103                write!(f, "invalid `quill` reference {value:?}: {reason}")
104            }
105            WireError::InvalidField { key, reason } => {
106                write!(f, "invalid field {key:?}: {reason}")
107            }
108        }
109    }
110}
111
112impl std::error::Error for WireError {}
113
114impl From<&Card> for CardWire {
115    fn from(card: &Card) -> Self {
116        let mut wire = CardWire {
117            kind: String::new(),
118            quill: None,
119            id: None,
120            ext: None,
121            payload_items: Vec::new(),
122            body: card.body().to_string(),
123        };
124        for item in card.payload().items() {
125            match item {
126                PayloadItem::Quill { reference } => wire.quill = Some(reference.to_string()),
127                PayloadItem::Kind { value } => wire.kind = value.clone(),
128                PayloadItem::Id { value } => wire.id = Some(value.clone()),
129                PayloadItem::Ext { value, .. } => wire.ext = Some(value.clone()),
130                PayloadItem::Field {
131                    key, value, fill, ..
132                } => wire.payload_items.push(PayloadItemWire::Field {
133                    key: key.clone(),
134                    value: value.as_json().clone(),
135                    fill: *fill,
136                }),
137                PayloadItem::Comment { text, inline } => {
138                    wire.payload_items.push(PayloadItemWire::Comment {
139                        text: text.clone(),
140                        inline: *inline,
141                    })
142                }
143            }
144        }
145        wire
146    }
147}
148
149impl TryFrom<CardWire> for Card {
150    type Error = WireError;
151
152    fn try_from(wire: CardWire) -> Result<Self, Self::Error> {
153        let items = wire
154            .payload_items
155            .into_iter()
156            .map(|item| match item {
157                PayloadItemWire::Field { key, value, fill } => {
158                    validate_wire_field(&key, &value)?;
159                    Ok(PayloadItem::Field {
160                        key,
161                        value: QuillValue::from_json(value),
162                        fill,
163                        nested_comments: Vec::new(),
164                    })
165                }
166                PayloadItemWire::Comment { text, inline } => {
167                    Ok(PayloadItem::Comment { text, inline })
168                }
169            })
170            .collect::<Result<Vec<_>, WireError>>()?;
171
172        // Build the user fields/comments, then apply each `$` entry through its
173        // setter so the canonical `$quill < $kind < $id < $ext` ordering holds
174        // regardless of input order.
175        let mut payload = Payload::from_items(items);
176        if let Some(value) = wire.quill {
177            let reference = QuillReference::from_str(&value)
178                .map_err(|reason| WireError::InvalidQuillReference { value, reason })?;
179            payload.set_quill(reference);
180        }
181        if !wire.kind.is_empty() {
182            payload.set_kind(wire.kind);
183        }
184        if let Some(id) = wire.id {
185            payload.set_id(id);
186        }
187        if let Some(ext) = wire.ext {
188            let as_value = JsonValue::Object(ext);
189            if crate::value::json_depth_exceeds(&as_value, crate::document::limits::MAX_YAML_DEPTH)
190            {
191                return Err(WireError::InvalidField {
192                    key: "$ext".to_string(),
193                    reason: format!(
194                        "nests deeper than the maximum of {} levels",
195                        crate::document::limits::MAX_YAML_DEPTH
196                    ),
197                });
198            }
199            let JsonValue::Object(ext) = as_value else {
200                unreachable!("constructed as Object above")
201            };
202            payload.set_ext(ext);
203        }
204        Ok(Card::from_parts(payload, wire.body))
205    }
206}
207
208/// Validate a wire field against the payload invariant (see
209/// `edit::validate_field`), mapping a violation to [`WireError::InvalidField`].
210fn validate_wire_field(key: &str, value: &JsonValue) -> Result<(), WireError> {
211    use super::edit::{validate_field, FieldViolation};
212    validate_field(key, value).map_err(|v| WireError::InvalidField {
213        key: key.to_string(),
214        reason: match v {
215            FieldViolation::InvalidName => "field names must match [a-z_][a-z0-9_]*".to_string(),
216            FieldViolation::TooDeep => format!(
217                "nests deeper than the maximum of {} levels",
218                crate::document::limits::MAX_YAML_DEPTH
219            ),
220        },
221    })
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use serde_json::json;
228
229    /// A field-and-comment card with `$kind` round-trips Card → wire → Card.
230    #[test]
231    fn card_wire_round_trips_fields_and_comment() {
232        let mut payload = Payload::from_items(vec![
233            PayloadItem::comment("a note"),
234            PayloadItem::field("title", QuillValue::from_json(json!("Hi"))),
235            PayloadItem::Field {
236                key: "count".to_string(),
237                value: QuillValue::from_json(json!(3)),
238                fill: true,
239                nested_comments: Vec::new(),
240            },
241        ]);
242        payload.set_kind("note");
243        let card = Card::from_parts(payload, "body text".to_string());
244
245        let wire = CardWire::from(&card);
246        assert_eq!(wire.kind, "note");
247        assert_eq!(wire.payload_items.len(), 3);
248
249        let back = Card::try_from(wire).expect("wire → card");
250        assert_eq!(back, card, "Card → wire → Card must be identity");
251    }
252
253    /// `$quill` (main card) survives the round-trip and parses back.
254    #[test]
255    fn card_wire_round_trips_quill() {
256        let mut payload = Payload::from_index_map(Default::default());
257        payload.set_quill("memo@1.2.3".parse().unwrap());
258        payload.set_kind("main");
259        let card = Card::from_parts(payload, String::new());
260
261        let wire = CardWire::from(&card);
262        assert_eq!(wire.quill.as_deref(), Some("memo@1.2.3"));
263
264        let back = Card::try_from(wire).expect("wire → card");
265        assert_eq!(back, card);
266    }
267
268    /// The wire JSON uses camelCase `payloadItems` and the `type`-tagged items.
269    #[test]
270    fn card_wire_json_shape() {
271        let card = Card::try_from(CardWire {
272            kind: "note".to_string(),
273            quill: None,
274            id: None,
275            ext: None,
276            payload_items: vec![PayloadItemWire::Field {
277                key: "x".to_string(),
278                value: json!(1),
279                fill: false,
280            }],
281            body: String::new(),
282        })
283        .unwrap();
284        let json = serde_json::to_value(CardWire::from(&card)).unwrap();
285        assert_eq!(json["kind"], json!("note"));
286        assert_eq!(json["payloadItems"][0]["type"], json!("field"));
287        assert_eq!(json["payloadItems"][0]["key"], json!("x"));
288        assert!(json.get("quill").is_none(), "absent quill is omitted");
289    }
290
291    /// A malformed `quill` string is a typed error, not a panic.
292    #[test]
293    fn card_wire_rejects_bad_quill() {
294        let err = Card::try_from(CardWire {
295            kind: String::new(),
296            quill: Some("@nope".to_string()),
297            id: None,
298            ext: None,
299            payload_items: Vec::new(),
300            body: String::new(),
301        })
302        .unwrap_err();
303        assert!(matches!(err, WireError::InvalidQuillReference { .. }));
304    }
305}