use std::str::FromStr;
use serde::{Deserialize, Serialize};
use serde_json::{Map as JsonMap, Value as JsonValue};
use super::payload::{MetaKey, Payload, PayloadItem};
use super::Card;
use crate::value::{PathSegment, QuillValue};
use crate::version::QuillReference;
use quillmark_content::Content;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PayloadItemWire {
Field {
key: String,
value: JsonValue,
#[serde(default)]
fill: bool,
#[serde(
default,
rename = "nestedFills",
alias = "nested_fills",
skip_serializing_if = "Vec::is_empty"
)]
nested_fills: Vec<Vec<PathStepWire>>,
},
Comment {
text: String,
#[serde(default)]
inline: bool,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PathStepWire {
Index(usize),
Key(String),
}
impl From<&PathSegment> for PathStepWire {
fn from(seg: &PathSegment) -> Self {
match seg {
PathSegment::Key(k) => PathStepWire::Key(k.clone()),
PathSegment::Index(i) => PathStepWire::Index(*i),
}
}
}
impl From<&PathStepWire> for PathSegment {
fn from(seg: &PathStepWire) -> Self {
match seg {
PathStepWire::Key(k) => PathSegment::Key(k.clone()),
PathStepWire::Index(i) => PathSegment::Index(*i),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CardWire {
#[serde(default)]
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quill: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ext: Option<JsonMap<String, JsonValue>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<JsonMap<String, JsonValue>>,
#[serde(default, alias = "payload_items")]
pub payload_items: Vec<PayloadItemWire>,
#[serde(default)]
pub body: JsonValue,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WireError {
InvalidQuillReference { value: String, reason: String },
InvalidField { key: String, reason: String },
}
impl std::fmt::Display for WireError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WireError::InvalidQuillReference { value, reason } => {
write!(f, "invalid `quill` reference {value:?}: {reason}")
}
WireError::InvalidField { key, reason } => {
write!(f, "invalid field {key:?}: {reason}")
}
}
}
}
impl std::error::Error for WireError {}
impl From<&Card> for CardWire {
fn from(card: &Card) -> Self {
let mut wire = CardWire {
kind: String::new(),
quill: None,
id: None,
ext: None,
seed: None,
payload_items: Vec::new(),
body: quillmark_content::serial::to_canonical_value(card.body()),
};
for item in card.payload().items() {
match item {
PayloadItem::Quill { reference } => wire.quill = Some(reference.to_string()),
PayloadItem::Kind { value } => wire.kind = value.clone(),
PayloadItem::Id { value } => wire.id = Some(value.clone()),
PayloadItem::Meta {
key: MetaKey::Ext,
value,
..
} => wire.ext = Some(value.clone()),
PayloadItem::Meta {
key: MetaKey::Seed,
value,
..
} => wire.seed = Some(value.clone()),
PayloadItem::Field {
key, value, fill, ..
} => {
let nested_fills = value
.nonroot_fill_paths()
.map(|p| p.iter().map(PathStepWire::from).collect())
.collect();
wire.payload_items.push(PayloadItemWire::Field {
key: key.clone(),
value: value.as_json().clone(),
fill: *fill,
nested_fills,
})
}
PayloadItem::Comment { text, inline } => {
wire.payload_items.push(PayloadItemWire::Comment {
text: text.clone(),
inline: *inline,
})
}
}
}
wire
}
}
impl TryFrom<CardWire> for Card {
type Error = WireError;
fn try_from(wire: CardWire) -> Result<Self, Self::Error> {
let items = wire
.payload_items
.into_iter()
.map(|item| match item {
PayloadItemWire::Field {
key,
value,
fill,
nested_fills,
} => {
validate_wire_field(&key, &value)?;
let mut qv = QuillValue::from_json(value);
for path in &nested_fills {
let segs: Vec<PathSegment> = path.iter().map(PathSegment::from).collect();
qv.set_fill_at(&segs);
}
Ok(PayloadItem::Field {
key,
value: qv,
fill,
nested_comments: Vec::new(),
})
}
PayloadItemWire::Comment { text, inline } => {
Ok(PayloadItem::Comment { text, inline })
}
})
.collect::<Result<Vec<_>, WireError>>()?;
let mut payload = Payload::from_items(items);
if let Some(value) = wire.quill {
let reference = QuillReference::from_str(&value)
.map_err(|reason| WireError::InvalidQuillReference { value, reason })?;
payload.set_quill(reference);
}
if !wire.kind.is_empty() {
payload.set_kind(wire.kind);
}
if let Some(id) = wire.id {
payload.set_id(id);
}
let too_deep = |key: &str| {
let key = key.to_string();
move |max| WireError::InvalidField {
key,
reason: format!("nests deeper than the maximum of {} levels", max),
}
};
if let Some(ext) = wire.ext {
payload.set_ext(crate::value::depth_check_meta_map(ext, too_deep("$ext"))?);
}
if let Some(seed) = wire.seed {
payload.set_seed(crate::value::depth_check_meta_map(seed, too_deep("$seed"))?);
}
let body = body_from_wire(&wire.body)?;
Ok(Card::from_parts(payload, body))
}
}
fn body_from_wire(body: &JsonValue) -> Result<Content, WireError> {
let invalid = |reason: String| WireError::InvalidField {
key: "$body".to_string(),
reason,
};
match super::decode_richtext_value(body) {
Some(result) => result.map_err(|e| invalid(e.into_message())),
None => match body {
JsonValue::Null => Ok(Content::empty()),
other => Err(invalid(format!(
"expected a richtext content object or a markdown string, got {}",
match other {
JsonValue::Bool(_) => "a boolean",
JsonValue::Number(_) => "a number",
JsonValue::Array(_) => "an array",
_ => "an unsupported value",
}
))),
},
}
}
fn validate_wire_field(key: &str, value: &JsonValue) -> Result<(), WireError> {
use super::edit::{validate_field, FieldViolation};
validate_field(key, value).map_err(|v| WireError::InvalidField {
key: key.to_string(),
reason: match v {
FieldViolation::InvalidName => {
"field names must match [A-Za-z_][A-Za-z0-9_]*".to_string()
}
FieldViolation::TooDeep => format!(
"nests deeper than the maximum of {} levels",
crate::document::limits::MAX_YAML_DEPTH
),
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn card_wire_round_trips_nested_fill() {
let mut addr = QuillValue::from_json(json!({"street": null, "city": "Anytown"}));
assert!(addr.set_fill_at(&[PathSegment::Key("street".to_string())]));
let payload = Payload::from_items(vec![PayloadItem::Field {
key: "addr".to_string(),
value: addr,
fill: false,
nested_comments: Vec::new(),
}]);
let card = Card::from_parts(payload, quillmark_content::Content::empty());
let wire = CardWire::from(&card);
let as_json = serde_json::to_value(&wire).unwrap();
assert_eq!(
as_json["payloadItems"][0]["nestedFills"],
json!([["street"]]),
"nested fill path rides the wire as a JS array; JSON value stays fill-free"
);
assert_eq!(
as_json["payloadItems"][0]["value"],
json!({"street": null, "city": "Anytown"})
);
let back = Card::try_from(wire).expect("wire → card");
assert_eq!(back, card, "nested fill must survive Card → wire → Card");
}
#[test]
fn card_wire_round_trips_content_field_losslessly() {
use quillmark_content::model::{Mark, MarkKind};
let mut card = Card::new("note").unwrap();
let mut content = quillmark_content::import::from_markdown("underlined intro").unwrap();
content.marks.push(Mark {
start: 0,
end: 10,
kind: MarkKind::Underline,
});
content.normalize();
let json = quillmark_content::serial::to_canonical_value(&content);
let schema = crate::quill::FieldSchema::new(
"intro".to_string(),
crate::quill::FieldType::RichText { inline: false },
None,
);
card.commit_field("intro", crate::QuillValue::from_json(json), &schema)
.unwrap();
let wire = CardWire::from(&card);
let as_json = serde_json::to_value(&wire).unwrap();
assert!(as_json["payloadItems"][0]["value"].is_object());
let back = Card::try_from(wire).expect("wire → card");
assert_eq!(back, card, "content field must survive Card → wire → Card");
let read = back.field_richtext("intro").unwrap().unwrap();
assert!(read.marks.iter().any(|m| matches!(m.kind, MarkKind::Underline)));
}
#[test]
fn card_wire_round_trips_fields_and_comment() {
let mut payload = Payload::from_items(vec![
PayloadItem::comment("a note"),
PayloadItem::field("title", QuillValue::from_json(json!("Hi"))),
PayloadItem::Field {
key: "count".to_string(),
value: QuillValue::from_json(json!(3)),
fill: true,
nested_comments: Vec::new(),
},
]);
payload.set_kind("note");
let card = Card::from_parts(payload, crate::document::import_body("body text").unwrap());
let wire = CardWire::from(&card);
assert_eq!(wire.kind, "note");
assert_eq!(wire.payload_items.len(), 3);
let back = Card::try_from(wire).expect("wire → card");
assert_eq!(back, card, "Card → wire → Card must be identity");
}
#[test]
fn card_wire_round_trips_quill() {
let mut payload = Payload::from_index_map(Default::default());
payload.set_quill("memo@1.2.3".parse().unwrap());
payload.set_kind("main");
let card = Card::from_parts(payload, quillmark_content::Content::empty());
let wire = CardWire::from(&card);
assert_eq!(wire.quill.as_deref(), Some("memo@1.2.3"));
let back = Card::try_from(wire).expect("wire → card");
assert_eq!(back, card);
}
#[test]
fn card_wire_json_shape() {
let card = Card::try_from(CardWire {
kind: "note".to_string(),
quill: None,
id: None,
ext: None,
seed: None,
payload_items: vec![PayloadItemWire::Field {
key: "x".to_string(),
value: json!(1),
fill: false,
nested_fills: Vec::new(),
}],
body: JsonValue::Null,
})
.unwrap();
let json = serde_json::to_value(CardWire::from(&card)).unwrap();
assert_eq!(json["kind"], json!("note"));
assert_eq!(json["payloadItems"][0]["type"], json!("field"));
assert_eq!(json["payloadItems"][0]["key"], json!("x"));
assert!(json.get("quill").is_none(), "absent quill is omitted");
}
#[test]
fn card_wire_rejects_bad_quill() {
let err = Card::try_from(CardWire {
kind: String::new(),
quill: Some("@nope".to_string()),
id: None,
ext: None,
seed: None,
payload_items: Vec::new(),
body: JsonValue::Null,
})
.unwrap_err();
assert!(matches!(err, WireError::InvalidQuillReference { .. }));
}
}