use indexmap::IndexMap;
use serde::Serialize;
use super::compose::resolve_card_sourced;
use super::{CardSchema, Quill, QuillConfig};
use crate::{Card, Document, QuillValue};
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FieldSource {
Authored,
Default,
Zero,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ResolvedField {
pub name: String,
pub value: QuillValue,
pub source: FieldSource,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ResolvedMain {
pub fields: Vec<ResolvedField>,
pub body: Option<ResolvedField>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ResolvedCard {
pub kind: Option<String>,
pub index: usize,
pub fields: Vec<ResolvedField>,
pub body: Option<ResolvedField>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Resolved {
pub main: ResolvedMain,
pub cards: Vec<ResolvedCard>,
}
impl Quill {
pub fn resolve(&self, doc: &Document) -> Resolved {
let config = self.config();
let (fields, body) = resolve_card_fields(&config.main, doc.main());
let main = ResolvedMain { fields, body };
let cards = doc
.cards()
.iter()
.enumerate()
.map(|(index, card)| card_states(config, card, index))
.collect();
Resolved { main, cards }
}
}
fn resolve_card_fields(schema: &CardSchema, card: &Card) -> (Vec<ResolvedField>, Option<ResolvedField>) {
let sourced: IndexMap<String, (QuillValue, FieldSource)> = resolve_card_sourced(schema, card);
let mut fields = Vec::new();
for (name, _field_schema) in &schema.fields {
let (value, source) = sourced
.get(name)
.cloned()
.expect("resolve_card_sourced emits every declared field");
fields.push(ResolvedField {
name: name.clone(),
value,
source,
});
}
for (name, (value, source)) in &sourced {
if !schema.fields.contains_key(name) {
fields.push(ResolvedField {
name: name.clone(),
value: value.clone(),
source: *source,
});
}
}
let body = schema.body_enabled().then(|| body_state(card));
(fields, body)
}
fn card_states(config: &QuillConfig, card: &Card, index: usize) -> ResolvedCard {
let kind = card.kind().map(String::from);
match card.kind().and_then(|k| config.card_kind(k)) {
Some(schema) => {
let (fields, body) = resolve_card_fields(schema, card);
ResolvedCard {
kind,
index,
fields,
body,
}
}
None => {
let fields = card
.payload()
.to_index_map()
.into_iter()
.map(|(name, value)| ResolvedField {
name,
value,
source: FieldSource::Authored,
})
.collect();
ResolvedCard {
kind,
index,
fields,
body: None,
}
}
}
}
fn body_state(card: &Card) -> ResolvedField {
let value = QuillValue::from_json(quillmark_content::serial::to_canonical_value(card.body()));
let source = if card.body().is_blank() {
FieldSource::Zero
} else {
FieldSource::Authored
};
ResolvedField {
name: "body".to_string(),
value,
source,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::quill::FileTreeNode;
use crate::{Card, Document, Payload, Quill};
use std::collections::HashMap as StdHashMap;
fn quill_from_yaml(yaml: &str) -> Quill {
let mut files = StdHashMap::new();
files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File {
contents: yaml.as_bytes().to_vec(),
},
);
let root = FileTreeNode::Directory { files };
Quill::from_tree(root).expect("quill_from_yaml: from_tree failed")
}
fn parse(md: &str) -> Document {
Document::parse(md).expect("document should parse").document
}
fn row<'a>(fields: &'a [ResolvedField], name: &str) -> &'a ResolvedField {
fields
.iter()
.find(|f| f.name == name)
.unwrap_or_else(|| panic!("no row `{name}`"))
}
fn has_row(fields: &[ResolvedField], name: &str) -> bool {
fields.iter().any(|f| f.name == name)
}
const QUILL: &str = r#"
quill:
name: fs_test
version: "1.0"
backend: typst
description: Field-state tests
main:
body:
example: "Example body prose."
fields:
title:
type: string
status:
type: string
default: draft
notes:
type: string
intro:
type: richtext
default: "**hi**"
recipients:
type: array
items:
type: object
properties:
name: { type: string }
card_kinds:
note:
fields:
author:
type: string
example: A. Author
tag:
type: string
"#;
#[test]
fn scalar_sources_authored_default_zero() {
let quill = quill_from_yaml(QUILL);
let doc = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: Hello\n~~~\n");
let states = quill.resolve(&doc);
let f = &states.main.fields;
assert_eq!(row(f, "title").source, FieldSource::Authored);
assert_eq!(row(f, "title").value.as_json(), &serde_json::json!("Hello"));
assert_eq!(row(f, "status").source, FieldSource::Default);
assert_eq!(row(f, "status").value.as_json(), &serde_json::json!("draft"));
assert_eq!(row(f, "notes").source, FieldSource::Zero);
assert_eq!(row(f, "notes").value.as_json(), &serde_json::json!(""));
}
#[test]
fn richtext_default_reports_default_and_matches_plate() {
let quill = quill_from_yaml(QUILL);
let doc = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n");
let states = quill.resolve(&doc);
let intro = row(&states.main.fields, "intro");
assert_eq!(intro.source, FieldSource::Default);
let plate = quill.compile_data(&doc).expect("compile");
assert_eq!(intro.value.as_json(), &plate["intro"]);
assert!(intro.value.as_json().is_object());
}
#[test]
fn present_null_is_absent_takes_default_rung() {
let quill = quill_from_yaml(QUILL);
let doc = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\nstatus:\n~~~\n");
let states = quill.resolve(&doc);
let status = row(&states.main.fields, "status");
assert_eq!(status.source, FieldSource::Default);
assert_eq!(status.value.as_json(), &serde_json::json!("draft"));
}
#[test]
fn every_row_is_byte_for_byte_with_compile_data() {
let quill = quill_from_yaml(QUILL);
let md = "~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\n\
title: Hello\nintro: \"**bold**\"\nrecipients:\n - name: Alice\n~~~\n\n\
Body prose here.\n\n\
~~~card-yaml\n$kind: note\nauthor: Zed\n~~~\nNote body.\n";
let doc = parse(md);
let states = quill.resolve(&doc);
let plate = quill.compile_data(&doc).expect("compile");
for name in ["title", "status", "notes", "intro", "recipients"] {
assert_eq!(
row(&states.main.fields, name).value.as_json(),
&plate[name],
"main row `{name}` must be byte-for-byte with the plate"
);
}
assert_eq!(
states.main.body.as_ref().unwrap().value.as_json(),
&plate["$body"]
);
let plate_card = &plate["$cards"][0];
let card = &states.cards[0];
for name in ["author", "tag"] {
assert_eq!(
row(&card.fields, name).value.as_json(),
&plate_card[name],
"card row `{name}` must be byte-for-byte with the plate"
);
}
assert_eq!(
card.body.as_ref().unwrap().value.as_json(),
&plate_card["$body"]
);
}
#[test]
fn non_nfc_key_on_a_constructed_payload_rows_under_its_nfc_spelling() {
let quill = quill_from_yaml(QUILL);
let mut map = IndexMap::new();
map.insert(
"cafe\u{301}".to_string(),
QuillValue::from_json(serde_json::json!("hot")),
);
let mut payload = Payload::from_index_map(map);
payload.set_quill("fs_test@1.0".parse().unwrap());
payload.set_kind("main");
let main = Card::from_parts(payload, quillmark_content::Content::empty());
let doc = Document::from_main_and_cards(main, Vec::new());
let states = quill.resolve(&doc);
assert!(!has_row(&states.main.fields, "cafe\u{301}"));
let r = row(&states.main.fields, "caf\u{e9}");
assert_eq!(r.source, FieldSource::Authored);
assert_eq!(r.value.as_json(), &serde_json::json!("hot"));
}
#[test]
fn body_row_authored_vs_blank_source() {
let quill = quill_from_yaml(QUILL);
let authored =
parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n\nHello body.\n");
assert_eq!(
quill.resolve(&authored).main.body.unwrap().source,
FieldSource::Authored
);
let blank = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n");
let states = quill.resolve(&blank);
let body = states.main.body.as_ref().unwrap();
assert_eq!(body.source, FieldSource::Zero);
assert!(body.value.as_json().is_object(), "blank body is empty content");
}
const BODY_DISABLED_QUILL: &str = r#"
quill:
name: bd_test
version: "1.0"
backend: typst
description: Body-disabled test
main:
fields:
title:
type: string
card_kinds:
stamp:
body:
enabled: false
fields:
label:
type: string
"#;
#[test]
fn body_disabled_kind_omits_body_row() {
let quill = quill_from_yaml(BODY_DISABLED_QUILL);
let doc = parse(
"~~~card-yaml\n$quill: bd_test@1.0\n$kind: main\ntitle: T\n~~~\n\n\
~~~card-yaml\n$kind: stamp\nlabel: L\n~~~\nStray prose.\n",
);
let states = quill.resolve(&doc);
let card = &states.cards[0];
assert!(card.body.is_none(), "a body-disabled kind has no body row");
assert!(has_row(&card.fields, "label"), "declared rows still present");
}
#[test]
fn unknown_kind_card_shape() {
let quill = quill_from_yaml(QUILL);
let doc = parse(
"~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n\n\
~~~card-yaml\n$kind: mystery\nfoo: bar\n~~~\nUnread body.\n",
);
let states = quill.resolve(&doc);
let card = &states.cards[0];
assert_eq!(card.kind.as_deref(), Some("mystery"));
assert_eq!(card.index, 0);
assert_eq!(row(&card.fields, "foo").source, FieldSource::Authored);
assert_eq!(row(&card.fields, "foo").value.as_json(), &serde_json::json!("bar"));
assert!(card.body.is_none());
}
#[test]
fn undeclared_authored_field_row_is_authored() {
let quill = quill_from_yaml(QUILL);
let doc = parse(
"~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\nextra: whatever\n~~~\n",
);
let states = quill.resolve(&doc);
let r = row(&states.main.fields, "extra");
assert_eq!(r.source, FieldSource::Authored);
assert_eq!(r.value.as_json(), &serde_json::json!("whatever"));
}
#[test]
fn field_source_serializes_lowercase() {
assert_eq!(
serde_json::to_string(&FieldSource::Authored).unwrap(),
"\"authored\""
);
assert_eq!(
serde_json::to_string(&FieldSource::Default).unwrap(),
"\"default\""
);
assert_eq!(serde_json::to_string(&FieldSource::Zero).unwrap(), "\"zero\"");
}
#[test]
fn field_state_is_name_value_and_source_only() {
let state = ResolvedField {
name: "x".to_string(),
value: QuillValue::from_json(serde_json::json!("v")),
source: FieldSource::Authored,
};
let json = serde_json::to_value(&state).unwrap();
let obj = json.as_object().unwrap();
assert_eq!(obj.len(), 3, "only name + value + source on the wire: {json}");
assert!(
obj.contains_key("name") && obj.contains_key("value") && obj.contains_key("source")
);
}
}