1use indexmap::IndexMap;
47
48use super::{zero_value, CardSchema, FieldSchema, FieldType, QuillConfig};
49use crate::document::emit::{saphyr_emit_flow, saphyr_emit_scalar};
50use crate::document::prescan::NestedComment;
51use crate::document::{Card, Document, Payload, PayloadItem};
52use crate::value::{PathSegment, QuillValue};
53use serde_json::{Map as JsonMap, Value as JsonValue};
54
55impl QuillConfig {
56 pub fn blueprint(&self) -> String {
73 let main_desc = self
74 .main
75 .description
76 .as_deref()
77 .filter(|s| !s.is_empty())
78 .or_else(|| Some(self.description.as_str()).filter(|s| !s.is_empty()));
79
80 let main = build_main_card(
81 &self.main,
82 &format!("{}@{}", self.name, self.version),
83 main_desc,
84 );
85 let cards = self.card_kinds.iter().map(build_card).collect();
86
87 Document::from_main_and_cards(main, cards).to_markdown()
88 }
89}
90
91fn collapse(text: &str) -> String {
94 text.split_whitespace().collect::<Vec<_>>().join(" ")
95}
96
97fn collapse_opt(text: &Option<String>) -> Option<String> {
98 text.as_deref()
99 .map(collapse)
100 .filter(|clean| !clean.is_empty())
101}
102
103fn body_text(card: &CardSchema, fallback_kind: &str) -> String {
107 if !card.body_enabled() {
108 return String::new();
109 }
110 let example = card.body.as_ref().and_then(|b| b.example.as_deref());
111 let fallback = format!("Write {} body here.", fallback_kind);
112 let text = example.unwrap_or(fallback.as_str());
113 format!("\n{}\n", text)
114}
115
116fn build_main_card(card: &CardSchema, quill_ref: &str, description: Option<&str>) -> Card {
119 let reference = quill_ref
120 .parse()
121 .expect("quill name@version is always a valid QuillReference");
122 let mut items = vec![
123 PayloadItem::Quill { reference },
124 PayloadItem::comment_inline("keep verbatim"),
125 PayloadItem::Kind {
126 value: "main".into(),
127 },
128 ];
129 if let Some(desc) = description {
130 items.push(PayloadItem::comment(collapse(desc)));
131 }
132 append_fields(&mut items, card);
133 Card::from_parts(
134 Payload::from_items(items),
135 crate::document::import_body(&body_text(card, "main"))
140 .unwrap_or_else(|_| quillmark_content::Content::empty()),
141 )
142}
143
144fn build_card(card: &CardSchema) -> Card {
147 let mut items = vec![
148 PayloadItem::Kind {
149 value: card.name.clone(),
150 },
151 PayloadItem::comment("composable (0..N)"),
152 ];
153 if let Some(desc) = collapse_opt(&card.description) {
154 items.push(PayloadItem::comment(desc));
155 }
156 append_fields(&mut items, card);
157 Card::from_parts(
158 Payload::from_items(items),
159 crate::document::import_body(&body_text(card, &card.name))
160 .unwrap_or_else(|_| quillmark_content::Content::empty()),
161 )
162}
163
164fn append_fields(items: &mut Vec<PayloadItem>, card: &CardSchema) {
168 let registry: Option<Vec<&str>> = card
169 .ui
170 .as_ref()
171 .and_then(|u| u.groups.as_ref())
172 .map(|r| r.0.iter().map(|g| g.id.as_str()).collect());
173 for field in group_fields(card.fields.values(), registry.as_deref()) {
174 append_field(items, field);
175 }
176}
177
178fn group_fields<'a, I: IntoIterator<Item = &'a FieldSchema>>(
186 fields: I,
187 registry: Option<&[&str]>,
188) -> Vec<&'a FieldSchema> {
189 let mut groups: Vec<(Option<&str>, Vec<&FieldSchema>)> = Vec::new();
190 for field in fields {
191 let group = field.ui.as_ref().and_then(|u| u.group.as_deref());
192 match groups.iter_mut().find(|(g, _)| *g == group) {
193 Some(slot) => slot.1.push(field),
194 None => groups.push((group, vec![field])),
195 }
196 }
197 groups.sort_by_key(|(g, _)| match g {
202 None => 0,
203 Some(id) => registry
204 .and_then(|order| order.iter().position(|o| o == id))
205 .map(|pos| pos + 1)
206 .unwrap_or(usize::MAX),
207 });
208 groups.into_iter().flat_map(|(_, fields)| fields).collect()
209}
210
211fn append_field(items: &mut Vec<PayloadItem>, field: &FieldSchema) {
215 if matches!(field.r#type, FieldType::Array) {
217 if let Some(elem) = &field.items {
218 if matches!(elem.r#type, FieldType::Object) {
219 if let Some(props) = &elem.properties {
220 append_typed_table(items, field, props);
221 return;
222 }
223 }
224 }
225 }
226
227 if matches!(field.r#type, FieldType::Object) {
229 if let Some(props) = &field.properties {
230 append_typed_dict(items, field, props);
231 return;
232 }
233 }
234
235 append_scalar(items, field);
236}
237
238fn push_leading(items: &mut Vec<PayloadItem>, field: &FieldSchema, eg_when: bool) {
243 if let Some(desc) = collapse_opt(&field.description) {
244 items.push(PayloadItem::comment(desc));
245 }
246 if eg_when {
247 if let Some(eg) = field.example.as_ref() {
248 items.push(PayloadItem::comment(format!("e.g. {}", eg_hint(eg))));
249 }
250 }
251}
252
253fn scalar_cell(field: &FieldSchema) -> (JsonValue, bool) {
259 if let Some(default) = &field.default {
260 return (default.as_json().clone(), false);
261 }
262 if matches!(field.r#type, FieldType::RichText { .. }) {
263 return (JsonValue::Null, true);
264 }
265 match field.example.as_ref() {
266 Some(eg) => (eg.as_json().clone(), true),
267 None => (JsonValue::Null, true),
268 }
269}
270
271fn append_scalar(items: &mut Vec<PayloadItem>, field: &FieldSchema) {
274 let eg_when = field.default.is_some() || matches!(field.r#type, FieldType::RichText { .. });
278 push_leading(items, field, eg_when);
279 let (json, fill) = scalar_cell(field);
280 items.push(PayloadItem::Field {
281 key: field.name.clone(),
282 value: QuillValue::from_json(json),
283 fill,
284 nested_comments: Vec::new(),
285 });
286 items.push(PayloadItem::comment_inline(type_expression(field)));
287}
288
289fn build_property_mapping(
296 props: &IndexMap<String, Box<FieldSchema>>,
297 prefix: &[PathSegment],
298) -> (
299 JsonMap<String, JsonValue>,
300 Vec<NestedComment>,
301 Vec<Vec<PathSegment>>,
302) {
303 let mut map = JsonMap::new();
304 let mut nested = Vec::new();
305 let mut fills = Vec::new();
306 for (slot, prop) in props.values().map(|b| b.as_ref()).enumerate() {
307 if let Some(desc) = collapse_opt(&prop.description) {
308 nested.push(NestedComment {
309 container_path: prefix.to_vec(),
310 position: slot,
311 text: desc,
312 inline: false,
313 });
314 }
315 if prop.default.is_some() {
317 if let Some(eg) = prop.example.as_ref() {
318 nested.push(NestedComment {
319 container_path: prefix.to_vec(),
320 position: slot,
321 text: format!("e.g. {}", eg_hint(eg)),
322 inline: false,
323 });
324 }
325 }
326 let (json, fill) = scalar_cell(prop);
327 map.insert(prop.name.clone(), json);
328 if fill {
329 let mut path = prefix.to_vec();
330 path.push(PathSegment::Key(prop.name.clone()));
331 fills.push(path);
332 }
333 nested.push(NestedComment {
334 container_path: prefix.to_vec(),
335 position: slot,
336 text: type_expression(prop),
337 inline: true,
338 });
339 }
340 (map, nested, fills)
341}
342
343fn append_typed_dict(
349 items: &mut Vec<PayloadItem>,
350 field: &FieldSchema,
351 props: &IndexMap<String, Box<FieldSchema>>,
352) {
353 push_leading(items, field, true);
354
355 let (value, nested, fills) = match field.default.as_ref().map(|d| d.as_json()) {
356 Some(JsonValue::Object(map)) if map.is_empty() => {
360 (zero_value(field).into_json(), Vec::new(), Vec::new())
361 }
362 Some(default) => (default.clone(), Vec::new(), Vec::new()),
364 None => {
366 let (map, nested, fills) = build_property_mapping(props, &[]);
367 (JsonValue::Object(map), nested, fills)
368 }
369 };
370
371 push_container_field(items, &field.name, value, nested, fills, field);
372}
373
374fn append_typed_table(
379 items: &mut Vec<PayloadItem>,
380 field: &FieldSchema,
381 item_props: &IndexMap<String, Box<FieldSchema>>,
382) {
383 push_leading(items, field, true);
384
385 let (value, nested, fills) = match field.default.as_ref().map(|d| d.as_json()) {
386 Some(default) => (default.clone(), Vec::new(), Vec::new()),
388 None if item_props.is_empty() => (JsonValue::Array(Vec::new()), Vec::new(), Vec::new()),
391 None => {
393 let (row, nested, fills) = build_property_mapping(item_props, &[PathSegment::Index(0)]);
394 (
395 JsonValue::Array(vec![JsonValue::Object(row)]),
396 nested,
397 fills,
398 )
399 }
400 };
401
402 push_container_field(items, &field.name, value, nested, fills, field);
403}
404
405fn push_container_field(
409 items: &mut Vec<PayloadItem>,
410 key: &str,
411 value: JsonValue,
412 nested_comments: Vec<NestedComment>,
413 fills: Vec<Vec<PathSegment>>,
414 field: &FieldSchema,
415) {
416 let mut quill_value = QuillValue::from_json(value);
417 for path in &fills {
418 quill_value.set_fill_at(path);
419 }
420 items.push(PayloadItem::Field {
421 key: key.to_string(),
422 value: quill_value,
423 fill: false,
424 nested_comments,
425 });
426 items.push(PayloadItem::comment_inline(type_expression(field)));
427}
428
429fn type_expression(field: &FieldSchema) -> String {
434 if let Some(values) = &field.enum_values {
435 return format!("enum<{}>", values.join(" | "));
436 }
437 match field.r#type {
438 FieldType::String => "string".into(),
439 FieldType::Number => "number".into(),
440 FieldType::Integer => "integer".into(),
441 FieldType::Boolean => "boolean".into(),
442 FieldType::Object => "object".into(),
443 FieldType::RichText { inline: false } => "richtext<markdown>".into(),
446 FieldType::RichText { inline: true } => "richtext(inline)<markdown>".into(),
447 FieldType::PlainText { inline: false } => "plaintext<plain>".into(),
451 FieldType::PlainText { inline: true } => "plaintext(inline)<plain>".into(),
452 FieldType::Enum => "enum".into(),
455 FieldType::Date => "date<YYYY-MM-DD>".into(),
456 FieldType::DateTime => "datetime<YYYY-MM-DDThh:mm[:ss]>".into(),
457 FieldType::Array => {
461 let item = field
462 .items
463 .as_ref()
464 .map(|it| type_expression(it))
465 .unwrap_or_else(|| "string".into());
466 format!("array<{}>", item)
467 }
468 }
469}
470
471fn eg_hint(example: &QuillValue) -> String {
476 match example.as_json() {
477 v @ (serde_json::Value::Array(_) | serde_json::Value::Object(_)) => saphyr_emit_flow(v),
478 val => saphyr_emit_scalar(val),
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use crate::quill::QuillConfig;
485 use crate::Document;
486
487 fn cfg(yaml: &str) -> QuillConfig {
488 QuillConfig::from_yaml(yaml).expect("valid yaml")
489 }
490
491 #[test]
492 fn must_fill_markdown_example_surfaces_as_eg_hint_not_inline_value() {
493 let t = cfg(r#"
496quill: { name: x, version: 1.0.0, backend: typst, description: x }
497main:
498 fields:
499 bio: { type: richtext, example: "Hello world" }
500"#)
501 .blueprint();
502 assert!(t.contains("# e.g. Hello world\nbio: !must_fill # richtext<markdown>\n"));
503 }
504
505 #[test]
506 fn endorsed_field_with_example_does_not_use_example_as_value() {
507 let t = cfg(r#"
509quill: { name: x, version: 1.0.0, backend: typst, description: x }
510main:
511 fields:
512 status: { type: string, default: draft, example: final }
513"#)
514 .blueprint();
515 assert!(t.contains("# e.g. final\nstatus: draft # string\n"));
516 }
517
518 #[test]
519 fn endorsed_empty_default_renders_value_and_eg_line() {
520 let t = cfg(r#"
521quill: { name: x, version: 1.0.0, backend: typst, description: x }
522main:
523 fields:
524 classification: { type: string, default: "", example: CONFIDENTIAL }
525"#)
526 .blueprint();
527 assert!(t.contains("# e.g. CONFIDENTIAL\nclassification: \"\" # string\n"));
528 }
529
530 #[test]
531 fn must_fill_array_example_renders_as_block_sequence_with_context_quoting() {
532 let t = cfg(r#"
533quill: { name: x, version: 1.0.0, backend: typst, description: x }
534main:
535 fields:
536 recipient:
537 type: array
538 items: { type: string }
539 example:
540 - Mr. John Doe
541 - 123 Main St
542 - "Anytown, USA"
543"#)
544 .blueprint();
545 assert!(t.contains(
548 "recipient: !must_fill # array<string>\n - Mr. John Doe\n - 123 Main St\n - Anytown, USA\n"
549 ));
550 assert!(!t.contains("# e.g."));
551 }
552
553 #[test]
554 fn enum_endorsed_uses_enum_format_slot_and_no_eg() {
555 let t = cfg(r#"
556quill: { name: x, version: 1.0.0, backend: typst, description: x }
557main:
558 fields:
559 format: { type: string, enum: [standard, informal], default: standard }
560"#)
561 .blueprint();
562 assert!(t.contains("format: standard # enum<standard | informal>\n"));
563 assert!(!t.contains("e.g."));
564 }
565
566 #[test]
567 fn enum_must_fill_renders_bare_marker() {
568 let t = cfg(r#"
571quill: { name: x, version: 1.0.0, backend: typst, description: x }
572main:
573 fields:
574 severity: { type: string, enum: [low, medium, high] }
575"#)
576 .blueprint();
577 assert!(t.contains("severity: !must_fill # enum<low | medium | high>\n"));
578 }
579
580 #[test]
581 fn description_emitted_as_single_line() {
582 let t = cfg(r#"
583quill: { name: x, version: 1.0.0, backend: typst, description: x }
584main:
585 fields:
586 subject:
587 type: string
588 description: Be brief and clear.
589"#)
590 .blueprint();
591 assert!(t.contains("# Be brief and clear.\nsubject: !must_fill # string\n"));
592 }
593
594 #[test]
595 fn every_field_carries_inline_type_and_cell_signal() {
596 let t = cfg(r#"
599quill: { name: x, version: 1.0.0, backend: typst, description: x }
600main:
601 fields:
602 title: { type: string }
603 size: { type: number, default: 11 }
604 flag: { type: boolean, default: false }
605 issued: { type: date }
606 published: { type: datetime }
607 refs: { type: array, default: [], items: { type: string } }
608"#)
609 .blueprint();
610 assert!(t.contains("title: !must_fill # string\n"));
611 assert!(t.contains("size: 11 # number\n"));
612 assert!(t.contains("flag: false # boolean\n"));
613 assert!(t.contains("issued: !must_fill # date<YYYY-MM-DD>\n"));
614 assert!(t.contains("published: !must_fill # datetime<YYYY-MM-DDThh:mm[:ss]>\n"));
615 assert!(t.contains("refs: [] # array<string>\n"));
616 }
617
618 #[test]
619 fn scalar_array_annotation_reflects_element_type() {
620 let t = cfg(r#"
623quill: { name: x, version: 1.0.0, backend: typst, description: x }
624main:
625 fields:
626 counts: { type: array, items: { type: integer } }
627 sections: { type: array, items: { type: richtext } }
628 tags: { type: array, items: { type: string } }
629"#)
630 .blueprint();
631 assert!(t.contains("counts: !must_fill # array<integer>\n"), "{t}");
632 assert!(
633 t.contains("sections: !must_fill # array<richtext<markdown>>\n"),
634 "{t}"
635 );
636 assert!(t.contains("tags: !must_fill # array<string>\n"), "{t}");
637 }
638
639 #[test]
640 fn must_fill_markdown_renders_bare_marker() {
641 let t = cfg(r#"
644quill: { name: x, version: 1.0.0, backend: typst, description: x }
645main:
646 fields:
647 bio: { type: richtext }
648"#)
649 .blueprint();
650 assert!(t.contains("bio: !must_fill # richtext<markdown>\n"));
651 assert!(!t.contains("|-"));
652 }
653
654 #[test]
655 fn endorsed_empty_markdown_renders_empty_string() {
656 let t = cfg(r#"
659quill: { name: x, version: 1.0.0, backend: typst, description: x }
660main:
661 fields:
662 bio: { type: richtext, default: "" }
663"#)
664 .blueprint();
665 assert!(t.contains("bio: \"\" # richtext<markdown>\n"));
666 assert!(!t.contains("|-"));
667 assert!(!t.contains("!must_fill"));
668 }
669
670 #[test]
671 fn endorsed_markdown_default_inlines_quoted() {
672 let t = cfg(r###"
675quill: { name: x, version: 1.0.0, backend: typst, description: x }
676main:
677 fields:
678 bio:
679 type: richtext
680 default: "## About me\n\nHello."
681"###)
682 .blueprint();
683 assert!(t.contains("bio: \"## About me\\n\\nHello.\" # richtext<markdown>\n"));
684 assert!(!t.contains("|-"));
685 }
686
687 #[test]
688 fn root_header_carries_quill_reminder_and_no_role_comment() {
689 let t = cfg(r#"
690quill: { name: taro, version: 0.1.0, backend: typst, description: x }
691main:
692 fields:
693 flavor: { type: string, default: taro }
694"#)
695 .blueprint();
696 assert!(t.starts_with("~~~\n$quill: taro@0.1.0 # keep verbatim\n$kind: main\n# x\n"));
700 assert!(t.contains("\nWrite main body here.\n"));
701 }
702
703 #[test]
704 fn card_fence_carries_composable_annotation() {
705 let t = cfg(r#"
706quill: { name: x, version: 1.0.0, backend: typst, description: x }
707main:
708 fields:
709 title: { type: string }
710card_kinds:
711 note:
712 description: A short note appended to the document.
713 fields:
714 author: { type: string }
715"#)
716 .blueprint();
717 assert!(t.contains(
718 "~~~\n$kind: note\n# composable (0..N)\n# A short note appended to the document.\n"
719 ));
720 }
721
722 #[test]
723 fn body_disabled_card_omits_body_placeholder() {
724 let t = cfg(r#"
725quill: { name: x, version: 1.0.0, backend: typst, description: x }
726main:
727 fields:
728 title: { type: string }
729card_kinds:
730 skills:
731 body: { enabled: false }
732 fields:
733 items: { type: array, items: { type: string } }
734"#)
735 .blueprint();
736 let after = &t[t.find("$kind: skills").unwrap()..];
737 assert!(!after.contains("skills body"));
738 }
739
740 #[test]
741 fn body_example_appears_verbatim() {
742 let t = cfg(r#"
743quill: { name: x, version: 1.0.0, backend: typst, description: x }
744main:
745 fields:
746 title: { type: string }
747card_kinds:
748 note:
749 body:
750 example: "This is an example note."
751 fields:
752 author: { type: string }
753"#)
754 .blueprint();
755 let after = &t[t.find("$kind: note").unwrap()..];
756 assert!(after.contains("\nThis is an example note.\n"));
757 assert!(!after.contains("Write note body here."));
758 }
759
760 #[test]
761 fn main_body_example_appears_verbatim() {
762 let t = cfg(r#"
763quill: { name: x, version: 1.0.0, backend: typst, description: x }
764main:
765 body:
766 example: "Dear Sir or Madam,\n\nI am writing to..."
767 fields:
768 to: { type: string }
769"#)
770 .blueprint();
771 assert!(t.contains("\nDear Sir or Madam,\n\nI am writing to...\n"));
772 assert!(!t.contains("Write main body here."));
773 }
774
775 #[test]
776 fn card_body_placeholder_uses_card_name() {
777 let t = cfg(r#"
778quill: { name: x, version: 1.0.0, backend: typst, description: x }
779main:
780 fields:
781 title: { type: string }
782card_kinds:
783 indorsement:
784 fields:
785 from: { type: string }
786"#)
787 .blueprint();
788 assert!(t.contains("\nWrite indorsement body here.\n"));
789 }
790
791 #[test]
792 fn ui_groups_cluster_fields_without_emitting_banner() {
793 let t = cfg(r#"
794quill: { name: x, version: 1.0.0, backend: typst, description: x }
795main:
796 fields:
797 memo_for: { type: array, items: { type: string }, ui: { group: Addressing } }
798 subject: { type: string, ui: { group: Addressing } }
799 letterhead_title: { type: string, default: HQ, ui: { group: Letterhead } }
800 notes: { type: string }
801"#)
802 .blueprint();
803 let after_quill = &t[t.find("$quill:").unwrap()..];
804 assert!(!after_quill.contains("===="));
806 let notes = after_quill.find("notes:").unwrap();
808 let memo_for = after_quill.find("memo_for:").unwrap();
809 let letterhead = after_quill.find("letterhead_title:").unwrap();
810 assert!(notes < memo_for);
811 assert!(memo_for < letterhead);
812 }
813
814 #[test]
815 fn typed_table_must_fill_emits_synthetic_row_with_leaf_markers() {
816 let t = cfg(r#"
819quill: { name: x, version: 1.0.0, backend: typst, description: x }
820main:
821 fields:
822 references:
823 type: array
824 description: Cited works.
825 items:
826 type: object
827 properties:
828 org: { type: string, description: Citing organization. }
829 year: { type: integer, default: 0, description: Publication year. }
830"#)
831 .blueprint();
832 assert!(t.contains(
835 "# Cited works.\nreferences: # array<object>\n # Citing organization.\n - org: !must_fill # string\n"
836 ));
837 assert!(t.contains(" # Publication year.\n year: 0 # integer\n"));
838 }
839
840 #[test]
841 fn typed_table_with_example_keeps_eg_line_and_synthetic_row() {
842 let t = cfg(r#"
845quill: { name: x, version: 1.0.0, backend: typst, description: x }
846main:
847 fields:
848 refs:
849 type: array
850 example:
851 - { org: ACME, year: 2020 }
852 items:
853 type: object
854 properties:
855 org: { type: string }
856 year: { type: integer, default: 0 }
857"#)
858 .blueprint();
859 assert!(t.contains("# e.g. [{org: ACME, year: 2020}]\n"));
860 assert!(t.contains("refs: # array<object>\n - org: !must_fill # string\n"));
861 assert!(t.contains(" year: 0 # integer\n"));
862 }
863
864 #[test]
865 fn typed_table_endorsed_renders_default_rows() {
866 let t = cfg(r#"
867quill: { name: x, version: 1.0.0, backend: typst, description: x }
868main:
869 fields:
870 refs:
871 type: array
872 default:
873 - { org: ACME }
874 items:
875 type: object
876 properties:
877 org: { type: string }
878"#)
879 .blueprint();
880 assert!(t.contains("refs: # array<object>\n - org: ACME\n"));
881 assert!(!t.contains("refs: # array<object>\n -\n"));
882 }
883
884 #[test]
885 fn typed_table_with_empty_default_renders_inline() {
886 let t = cfg(r#"
890quill: { name: x, version: 1.0.0, backend: typst, description: x }
891main:
892 fields:
893 refs:
894 type: array
895 default: []
896 items:
897 type: object
898 properties:
899 org: { type: string }
900"#)
901 .blueprint();
902 assert!(
903 t.contains("refs: [] # array<object>\n"),
904 "wrong rendering: {t}"
905 );
906 assert!(!t.contains("!must_fill"), "no markers expected: {t}");
907 }
908
909 #[test]
910 fn typed_dict_with_empty_default_expands_to_zero_filled() {
911 let t = cfg(r#"
916quill: { name: x, version: 1.0.0, backend: typst, description: x }
917main:
918 fields:
919 address:
920 type: object
921 default: {}
922 properties:
923 street: { type: string }
924 zip: { type: integer }
925"#)
926 .blueprint();
927 assert!(
928 t.contains("address: # object\n street: \"\"\n zip: 0\n"),
929 "wrong rendering: {t}"
930 );
931 assert!(!t.contains("{}"), "no bare empty object expected: {t}");
932 assert!(!t.contains("!must_fill"), "no markers expected: {t}");
933 assert!(!t.contains("# string"), "no leaf annotations expected: {t}");
935 }
936
937 #[test]
938 fn typed_dict_must_fill_emits_per_property_annotations() {
939 let t = cfg(r#"
942quill: { name: x, version: 1.0.0, backend: typst, description: x }
943main:
944 fields:
945 address:
946 type: object
947 description: Mailing address.
948 properties:
949 street: { type: string, description: Street line. }
950 city: { type: string }
951 zip: { type: string, default: "" }
952"#)
953 .blueprint();
954 assert!(t.contains("# Mailing address.\naddress: # object\n"));
955 assert!(t.contains(" # Street line.\n street: !must_fill # string\n"));
956 assert!(t.contains(" city: !must_fill # string\n"));
957 assert!(t.contains(" zip: \"\" # string\n"));
958 }
959
960 #[test]
961 fn typed_dict_endorsed_renders_block_mapping() {
962 let t = cfg(r#"
963quill: { name: x, version: 1.0.0, backend: typst, description: x }
964main:
965 fields:
966 address:
967 type: object
968 default: { street: "5000 Forbes Ave", city: Pittsburgh }
969 properties:
970 street: { type: string }
971 city: { type: string }
972"#)
973 .blueprint();
974 assert!(t.contains("address: # object\n"));
975 assert!(
976 t.contains(" street: 5000 Forbes Ave\n")
977 || t.contains(" street: \"5000 Forbes Ave\"\n")
978 );
979 assert!(t.contains(" city: Pittsburgh\n"));
980 assert!(!t.contains("# string"));
982 }
983
984 #[test]
985 fn typed_dict_with_example_keeps_eg_line_and_per_property() {
986 let t = cfg(r#"
989quill: { name: x, version: 1.0.0, backend: typst, description: x }
990main:
991 fields:
992 address:
993 type: object
994 example: { street: "1 Infinite Loop", city: Cupertino }
995 properties:
996 street: { type: string }
997 city: { type: string, default: "" }
998"#)
999 .blueprint();
1000 assert!(t.contains("address: # object\n"));
1001 assert!(
1002 t.contains("# e.g. {street: 1 Infinite Loop, city: Cupertino}\n")
1003 || t.contains("# e.g. {city: Cupertino, street: 1 Infinite Loop}\n")
1004 );
1005 assert!(t.contains(" street: !must_fill # string\n"));
1006 assert!(t.contains(" city: \"\" # string\n"));
1007 }
1008
1009 const LETTER_QUILL: &str = r#"
1010quill: { name: letter, version: 1.0.0, backend: typst, description: A formal letter. }
1011main:
1012 fields:
1013 to:
1014 type: string
1015 description: Recipient name.
1016 subject:
1017 type: string
1018 date:
1019 type: datetime
1020 priority:
1021 type: string
1022 enum: [normal, urgent]
1023 default: normal
1024 attachments:
1025 type: array
1026 items: { type: string }
1027 default: []
1028 example:
1029 - report.pdf
1030card_kinds:
1031 enclosure:
1032 description: An enclosure attached to the letter.
1033 fields:
1034 label: { type: string }
1035 pages: { type: integer, default: 1 }
1036"#;
1037
1038 #[test]
1039 fn typed_table_synthetic_row_blueprint_round_trips() {
1040 let bp = cfg(r#"
1044quill: { name: x, version: 1.0.0, backend: typst, description: x }
1045main:
1046 fields:
1047 refs:
1048 type: array
1049 items:
1050 type: object
1051 properties:
1052 org: { type: string, description: Citing organization. }
1053 year: { type: integer, default: 0, description: Publication year. }
1054"#)
1055 .blueprint();
1056 let doc1 = Document::parse(&bp).expect("blueprint must parse").document;
1057 let doc2 = Document::parse(&doc1.to_markdown())
1058 .expect("re-emit must parse")
1059 .document;
1060 assert_eq!(doc1, doc2, "typed-table blueprint must round-trip");
1061 }
1062
1063 #[test]
1064 fn must_fill_markers_round_trip_and_survive_as_fill() {
1065 let bp = cfg(r#"
1070quill: { name: letter, version: 1.0.0, backend: typst, description: A letter. }
1071main:
1072 fields:
1073 recipient:
1074 type: array
1075 items: { type: string }
1076 example: [Mr. John Doe, "Anytown, USA"]
1077 subject: { type: string }
1078 date: { type: datetime }
1079"#)
1080 .blueprint();
1081
1082 let doc1 = Document::parse(&bp).expect("blueprint must parse").document;
1083 let payload = doc1.main().payload();
1085 for key in ["recipient", "subject", "date"] {
1086 assert!(
1087 payload.is_fill(key),
1088 "`{key}` must carry the fill marker:\n{bp}"
1089 );
1090 }
1091 assert_eq!(
1093 payload
1094 .get("recipient")
1095 .and_then(|v| v.as_json().as_array().map(|a| a.len())),
1096 Some(2),
1097 "recipient suggested value should survive: {bp}"
1098 );
1099
1100 let md2 = doc1.to_markdown();
1102 let doc2 = Document::parse(&md2).expect("re-emitted markdown must parse").document;
1103 assert_eq!(doc1, doc2, "blueprint must round-trip idempotently");
1104 }
1105
1106 #[test]
1107 fn blueprint_round_trips_idempotently() {
1108 let bp = cfg(LETTER_QUILL).blueprint();
1109 let doc1 = Document::parse(&bp).expect("blueprint must parse").document;
1110 let md2 = doc1.to_markdown();
1111 let doc2 = Document::parse(&md2).expect("round-tripped markdown must parse").document;
1112 assert_eq!(
1113 doc1, doc2,
1114 "Document must be equal after blueprint → parse → emit → parse"
1115 );
1116 }
1117
1118 #[test]
1123 fn type_ambiguous_string_defaults_round_trip_as_strings() {
1124 let bp = cfg(r#"
1125quill: { name: x, version: 1.0.0, backend: typst, description: x }
1126main:
1127 fields:
1128 version: { type: string, default: "1.0" }
1129 activation: { type: string, default: "on" }
1130 code: { type: string, default: "01234" }
1131 placeholder: { type: string, default: "null" }
1132 yes_flag: { type: string, default: "yes" }
1133"#)
1134 .blueprint();
1135
1136 let doc = Document::parse(&bp).expect("blueprint must parse").document;
1137 let payload = doc.main().payload();
1138 for (key, expected) in [
1139 ("version", "1.0"),
1140 ("activation", "on"),
1141 ("code", "01234"),
1142 ("placeholder", "null"),
1143 ("yes_flag", "yes"),
1144 ] {
1145 let v = payload.get(key).unwrap_or_else(|| panic!("missing {key}"));
1146 assert!(
1147 v.as_str().is_some(),
1148 "field {key} must round-trip as a string, got {:?}\nBlueprint:\n{}",
1149 v,
1150 bp
1151 );
1152 assert_eq!(v.as_str().unwrap(), expected, "field {key}: value mismatch");
1153 }
1154 }
1155}