1use indexmap::IndexMap;
19use serde::Serialize;
20
21use super::compose::resolve_card_sourced;
22use super::{CardSchema, Quill, QuillConfig};
23use crate::{Card, Document, QuillValue};
24
25#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
28#[serde(rename_all = "lowercase")]
29pub enum FieldSource {
30 Authored,
32 Default,
34 Zero,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize)]
45pub struct ResolvedField {
46 pub name: String,
47 pub value: QuillValue,
48 pub source: FieldSource,
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize)]
54pub struct ResolvedMain {
55 pub fields: Vec<ResolvedField>,
56 pub body: Option<ResolvedField>,
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize)]
63pub struct ResolvedCard {
64 pub kind: Option<String>,
65 pub index: usize,
66 pub fields: Vec<ResolvedField>,
67 pub body: Option<ResolvedField>,
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize)]
72pub struct Resolved {
73 pub main: ResolvedMain,
74 pub cards: Vec<ResolvedCard>,
75}
76
77impl Quill {
78 pub fn resolve(&self, doc: &Document) -> Resolved {
88 let config = self.config();
89 let (fields, body) = resolve_card_fields(&config.main, doc.main());
90 let main = ResolvedMain { fields, body };
91 let cards = doc
92 .cards()
93 .iter()
94 .enumerate()
95 .map(|(index, card)| card_states(config, card, index))
96 .collect();
97 Resolved { main, cards }
98 }
99}
100
101fn resolve_card_fields(schema: &CardSchema, card: &Card) -> (Vec<ResolvedField>, Option<ResolvedField>) {
114 let sourced: IndexMap<String, (QuillValue, FieldSource)> = resolve_card_sourced(schema, card);
115 let mut fields = Vec::new();
116
117 for (name, _field_schema) in &schema.fields {
120 let (value, source) = sourced
121 .get(name)
122 .cloned()
123 .expect("resolve_card_sourced emits every declared field");
124 fields.push(ResolvedField {
125 name: name.clone(),
126 value,
127 source,
128 });
129 }
130
131 for (name, (value, source)) in &sourced {
135 if !schema.fields.contains_key(name) {
136 fields.push(ResolvedField {
137 name: name.clone(),
138 value: value.clone(),
139 source: *source,
140 });
141 }
142 }
143
144 let body = schema.body_enabled().then(|| body_state(card));
145 (fields, body)
146}
147
148fn card_states(config: &QuillConfig, card: &Card, index: usize) -> ResolvedCard {
153 let kind = card.kind().map(String::from);
156 match card.kind().and_then(|k| config.card_kind(k)) {
157 Some(schema) => {
158 let (fields, body) = resolve_card_fields(schema, card);
159 ResolvedCard {
160 kind,
161 index,
162 fields,
163 body,
164 }
165 }
166 None => {
167 let fields = card
170 .payload()
171 .to_index_map()
172 .into_iter()
173 .map(|(name, value)| ResolvedField {
174 name,
175 value,
176 source: FieldSource::Authored,
177 })
178 .collect();
179 ResolvedCard {
180 kind,
181 index,
182 fields,
183 body: None,
184 }
185 }
186 }
187}
188
189fn body_state(card: &Card) -> ResolvedField {
194 let value = QuillValue::from_json(quillmark_content::serial::to_canonical_value(card.body()));
195 let source = if card.body().is_blank() {
196 FieldSource::Zero
197 } else {
198 FieldSource::Authored
199 };
200 ResolvedField {
201 name: "body".to_string(),
202 value,
203 source,
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use crate::quill::FileTreeNode;
211 use crate::{Card, Document, Payload, Quill};
212 use std::collections::HashMap as StdHashMap;
213
214 fn quill_from_yaml(yaml: &str) -> Quill {
216 let mut files = StdHashMap::new();
217 files.insert(
218 "Quill.yaml".to_string(),
219 FileTreeNode::File {
220 contents: yaml.as_bytes().to_vec(),
221 },
222 );
223 let root = FileTreeNode::Directory { files };
224 Quill::from_tree(root).expect("quill_from_yaml: from_tree failed")
225 }
226
227 fn parse(md: &str) -> Document {
228 Document::parse(md).expect("document should parse").document
229 }
230
231 fn row<'a>(fields: &'a [ResolvedField], name: &str) -> &'a ResolvedField {
233 fields
234 .iter()
235 .find(|f| f.name == name)
236 .unwrap_or_else(|| panic!("no row `{name}`"))
237 }
238
239 fn has_row(fields: &[ResolvedField], name: &str) -> bool {
240 fields.iter().any(|f| f.name == name)
241 }
242
243 const QUILL: &str = r#"
244quill:
245 name: fs_test
246 version: "1.0"
247 backend: typst
248 description: Field-state tests
249main:
250 body:
251 example: "Example body prose."
252 fields:
253 title:
254 type: string
255 status:
256 type: string
257 default: draft
258 notes:
259 type: string
260 intro:
261 type: richtext
262 default: "**hi**"
263 recipients:
264 type: array
265 items:
266 type: object
267 properties:
268 name: { type: string }
269card_kinds:
270 note:
271 fields:
272 author:
273 type: string
274 example: A. Author
275 tag:
276 type: string
277"#;
278
279 #[test]
282 fn scalar_sources_authored_default_zero() {
283 let quill = quill_from_yaml(QUILL);
284 let doc = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: Hello\n~~~\n");
286 let states = quill.resolve(&doc);
287 let f = &states.main.fields;
288
289 assert_eq!(row(f, "title").source, FieldSource::Authored);
290 assert_eq!(row(f, "title").value.as_json(), &serde_json::json!("Hello"));
291
292 assert_eq!(row(f, "status").source, FieldSource::Default);
293 assert_eq!(row(f, "status").value.as_json(), &serde_json::json!("draft"));
294
295 assert_eq!(row(f, "notes").source, FieldSource::Zero);
296 assert_eq!(row(f, "notes").value.as_json(), &serde_json::json!(""));
297 }
298
299 #[test]
300 fn richtext_default_reports_default_and_matches_plate() {
301 let quill = quill_from_yaml(QUILL);
302 let doc = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n");
304 let states = quill.resolve(&doc);
305 let intro = row(&states.main.fields, "intro");
306
307 assert_eq!(intro.source, FieldSource::Default);
308 let plate = quill.compile_data(&doc).expect("compile");
310 assert_eq!(intro.value.as_json(), &plate["intro"]);
311 assert!(intro.value.as_json().is_object());
313 }
314
315 #[test]
316 fn present_null_is_absent_takes_default_rung() {
317 let quill = quill_from_yaml(QUILL);
318 let doc = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\nstatus:\n~~~\n");
320 let states = quill.resolve(&doc);
321 let status = row(&states.main.fields, "status");
322 assert_eq!(status.source, FieldSource::Default);
323 assert_eq!(status.value.as_json(), &serde_json::json!("draft"));
324 }
325
326 #[test]
334 fn every_row_is_byte_for_byte_with_compile_data() {
335 let quill = quill_from_yaml(QUILL);
336 let md = "~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\n\
337 title: Hello\nintro: \"**bold**\"\nrecipients:\n - name: Alice\n~~~\n\n\
338 Body prose here.\n\n\
339 ~~~card-yaml\n$kind: note\nauthor: Zed\n~~~\nNote body.\n";
340 let doc = parse(md);
341 let states = quill.resolve(&doc);
342 let plate = quill.compile_data(&doc).expect("compile");
343
344 for name in ["title", "status", "notes", "intro", "recipients"] {
346 assert_eq!(
347 row(&states.main.fields, name).value.as_json(),
348 &plate[name],
349 "main row `{name}` must be byte-for-byte with the plate"
350 );
351 }
352 assert_eq!(
353 states.main.body.as_ref().unwrap().value.as_json(),
354 &plate["$body"]
355 );
356
357 let plate_card = &plate["$cards"][0];
359 let card = &states.cards[0];
360 for name in ["author", "tag"] {
361 assert_eq!(
362 row(&card.fields, name).value.as_json(),
363 &plate_card[name],
364 "card row `{name}` must be byte-for-byte with the plate"
365 );
366 }
367 assert_eq!(
368 card.body.as_ref().unwrap().value.as_json(),
369 &plate_card["$body"]
370 );
371 }
372
373 #[test]
374 fn non_nfc_key_on_a_constructed_payload_rows_under_its_nfc_spelling() {
375 let quill = quill_from_yaml(QUILL);
381 let mut map = IndexMap::new();
382 map.insert(
384 "cafe\u{301}".to_string(),
385 QuillValue::from_json(serde_json::json!("hot")),
386 );
387 let mut payload = Payload::from_index_map(map);
388 payload.set_quill("fs_test@1.0".parse().unwrap());
389 payload.set_kind("main");
390 let main = Card::from_parts(payload, quillmark_content::Content::empty());
391 let doc = Document::from_main_and_cards(main, Vec::new());
392 let states = quill.resolve(&doc);
393
394 assert!(!has_row(&states.main.fields, "cafe\u{301}"));
395 let r = row(&states.main.fields, "caf\u{e9}");
396 assert_eq!(r.source, FieldSource::Authored);
397 assert_eq!(r.value.as_json(), &serde_json::json!("hot"));
398 }
399
400 #[test]
403 fn body_row_authored_vs_blank_source() {
404 let quill = quill_from_yaml(QUILL);
405
406 let authored =
407 parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n\nHello body.\n");
408 assert_eq!(
409 quill.resolve(&authored).main.body.unwrap().source,
410 FieldSource::Authored
411 );
412
413 let blank = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n");
414 let states = quill.resolve(&blank);
415 let body = states.main.body.as_ref().unwrap();
416 assert_eq!(body.source, FieldSource::Zero);
417 assert!(body.value.as_json().is_object(), "blank body is empty content");
418 }
419
420 const BODY_DISABLED_QUILL: &str = r#"
421quill:
422 name: bd_test
423 version: "1.0"
424 backend: typst
425 description: Body-disabled test
426main:
427 fields:
428 title:
429 type: string
430card_kinds:
431 stamp:
432 body:
433 enabled: false
434 fields:
435 label:
436 type: string
437"#;
438
439 #[test]
440 fn body_disabled_kind_omits_body_row() {
441 let quill = quill_from_yaml(BODY_DISABLED_QUILL);
442 let doc = parse(
443 "~~~card-yaml\n$quill: bd_test@1.0\n$kind: main\ntitle: T\n~~~\n\n\
444 ~~~card-yaml\n$kind: stamp\nlabel: L\n~~~\nStray prose.\n",
445 );
446 let states = quill.resolve(&doc);
447 let card = &states.cards[0];
448 assert!(card.body.is_none(), "a body-disabled kind has no body row");
449 assert!(has_row(&card.fields, "label"), "declared rows still present");
450 }
451
452 #[test]
455 fn unknown_kind_card_shape() {
456 let quill = quill_from_yaml(QUILL);
457 let doc = parse(
458 "~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n\n\
459 ~~~card-yaml\n$kind: mystery\nfoo: bar\n~~~\nUnread body.\n",
460 );
461 let states = quill.resolve(&doc);
462 let card = &states.cards[0];
463
464 assert_eq!(card.kind.as_deref(), Some("mystery"));
465 assert_eq!(card.index, 0);
466 assert_eq!(row(&card.fields, "foo").source, FieldSource::Authored);
468 assert_eq!(row(&card.fields, "foo").value.as_json(), &serde_json::json!("bar"));
469 assert!(card.body.is_none());
470 }
471
472 #[test]
475 fn undeclared_authored_field_row_is_authored() {
476 let quill = quill_from_yaml(QUILL);
477 let doc = parse(
478 "~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\nextra: whatever\n~~~\n",
479 );
480 let states = quill.resolve(&doc);
481 let r = row(&states.main.fields, "extra");
482 assert_eq!(r.source, FieldSource::Authored);
483 assert_eq!(r.value.as_json(), &serde_json::json!("whatever"));
484 }
485
486 #[test]
489 fn field_source_serializes_lowercase() {
490 assert_eq!(
491 serde_json::to_string(&FieldSource::Authored).unwrap(),
492 "\"authored\""
493 );
494 assert_eq!(
495 serde_json::to_string(&FieldSource::Default).unwrap(),
496 "\"default\""
497 );
498 assert_eq!(serde_json::to_string(&FieldSource::Zero).unwrap(), "\"zero\"");
499 }
500
501 #[test]
502 fn field_state_is_name_value_and_source_only() {
503 let state = ResolvedField {
504 name: "x".to_string(),
505 value: QuillValue::from_json(serde_json::json!("v")),
506 source: FieldSource::Authored,
507 };
508 let json = serde_json::to_value(&state).unwrap();
509 let obj = json.as_object().unwrap();
510 assert_eq!(obj.len(), 3, "only name + value + source on the wire: {json}");
511 assert!(
512 obj.contains_key("name") && obj.contains_key("value") && obj.contains_key("source")
513 );
514 }
515}