1use std::collections::BTreeMap;
26
27use super::{LeafSchema, FieldSchema, FieldType, QuillConfig};
28use crate::document::emit::emit_double_quoted;
29use crate::value::QuillValue;
30
31impl QuillConfig {
32 pub fn blueprint(&self) -> String {
36 let mut out = String::new();
37 let main_desc = self
38 .main
39 .description
40 .as_deref()
41 .filter(|s| !s.is_empty())
42 .or_else(|| Some(self.description.as_str()).filter(|s| !s.is_empty()));
43 write_fence_block(
44 &mut out,
45 &self.main,
46 &format!(
47 "QUILL: {}@{} # sentinel; required, verbatim",
48 self.name, self.version
49 ),
50 main_desc,
51 "---\n",
52 "---\n",
53 );
54 if self.main.body_enabled() {
55 let example = self.main.body.as_ref().and_then(|b| b.example.as_deref());
56 let text = example.unwrap_or("Write main body here.");
57 out.push_str(&format!("\n{}\n", text));
58 }
59 for leaf in &self.leaf_kinds {
60 let sentinel = format!("KIND: {} # sentinel; composable (0..N)", leaf.name);
61 out.push('\n');
62 write_fence_block(
63 &mut out,
64 leaf,
65 &sentinel,
66 leaf.description.as_deref(),
67 "```leaf\n",
68 "```\n",
69 );
70 if leaf.body_enabled() {
71 let example = leaf.body.as_ref().and_then(|b| b.example.as_deref());
72 let fallback = format!("Write {} body here.", leaf.name);
73 let text = example.unwrap_or(fallback.as_str());
74 out.push_str(&format!("\n{}\n", text));
75 }
76 }
77 out
78 }
79}
80
81fn write_fence_block(
82 out: &mut String,
83 leaf: &LeafSchema,
84 sentinel_line: &str,
85 description: Option<&str>,
86 open_fence: &str,
87 close_fence: &str,
88) {
89 out.push_str(open_fence);
90 if let Some(desc) = description {
91 let clean = desc.split_whitespace().collect::<Vec<_>>().join(" ");
92 out.push_str(&format!("# {}\n", clean));
93 }
94 out.push_str(sentinel_line);
95 out.push('\n');
96 for (_, fields) in group_fields(leaf.fields.values()) {
97 for field in fields {
98 write_field(out, field, 0);
99 }
100 }
101 out.push_str(close_fence);
102}
103
104fn group_fields<'a, I: IntoIterator<Item = &'a FieldSchema>>(
108 fields: I,
109) -> Vec<(Option<String>, Vec<&'a FieldSchema>)> {
110 let mut sorted: Vec<&FieldSchema> = fields.into_iter().collect();
111 sorted.sort_by_key(|f| ui_order(f));
112 let mut groups: Vec<(Option<String>, Vec<&FieldSchema>)> = Vec::new();
113 for field in sorted {
114 let group = field
115 .ui
116 .as_ref()
117 .and_then(|u| u.group.as_ref())
118 .map(|s| s.to_string());
119 match groups.iter_mut().find(|(g, _)| g == &group) {
120 Some(slot) => slot.1.push(field),
121 None => groups.push((group, vec![field])),
122 }
123 }
124 groups.sort_by_key(|(g, _)| g.is_some());
125 groups
126}
127
128fn write_field(out: &mut String, field: &FieldSchema, indent: usize) {
129 let pad = " ".repeat(indent);
130
131 if matches!(field.r#type, FieldType::Array) {
133 if let Some(props) = &field.properties {
134 write_typed_table_field(out, field, props, indent);
135 return;
136 }
137 }
138
139 if matches!(field.r#type, FieldType::Object) {
141 if let Some(props) = &field.properties {
142 write_typed_object_field(out, field, props, indent);
143 return;
144 }
145 }
146
147 write_description(out, field, &pad);
148 write_eg_comment(out, field, &pad);
149
150 if matches!(field.r#type, FieldType::Markdown) {
153 let inline = inline_annotation(field, false);
154 write_markdown_block(out, field, &pad, &inline);
155 return;
156 }
157
158 let inline = format!(" # {}", inline_annotation(field, false));
159 let value = field_value(field);
160 write_value(out, &field.name, &value, &inline, &pad);
161}
162
163fn write_description(out: &mut String, field: &FieldSchema, pad: &str) {
164 if let Some(desc) = &field.description {
165 let clean = desc.split_whitespace().collect::<Vec<_>>().join(" ");
166 if !clean.is_empty() {
167 out.push_str(&format!("{}# {}\n", pad, clean));
168 }
169 }
170}
171
172fn write_eg_comment(out: &mut String, field: &FieldSchema, pad: &str) {
176 if let Some(eg) = field.example.as_ref().map(eg_hint) {
177 out.push_str(&format!("{}# e.g. {}\n", pad, eg));
178 }
179}
180
181fn write_markdown_block(out: &mut String, field: &FieldSchema, pad: &str, inline: &str) {
182 out.push_str(&format!("{}{}: |- # {}\n", pad, field.name, inline));
183 let body_pad = format!("{} ", pad);
184 let content = field.default.as_ref().and_then(|v| match v.as_json() {
187 serde_json::Value::String(s) => Some(s),
188 _ => None,
189 });
190 match content {
191 Some(text) if !text.is_empty() => {
192 for line in text.lines() {
193 out.push_str(&format!("{}{}\n", body_pad, line));
194 }
195 }
196 _ => {
197 out.push_str(&format!("{}\n", body_pad));
198 }
199 }
200}
201
202fn ui_order(f: &FieldSchema) -> i32 {
203 f.ui.as_ref().and_then(|u| u.order).unwrap_or(i32::MAX)
204}
205
206fn sort_props(props: &BTreeMap<String, Box<FieldSchema>>) -> Vec<&FieldSchema> {
207 let mut v: Vec<&FieldSchema> = props.values().map(|b| b.as_ref()).collect();
208 v.sort_by_key(|f| ui_order(f));
209 v
210}
211
212fn write_typed_table_field(
218 out: &mut String,
219 field: &FieldSchema,
220 item_props: &BTreeMap<String, Box<FieldSchema>>,
221 indent: usize,
222) {
223 let pad = " ".repeat(indent);
224
225 let concrete_rows = field
226 .example
227 .as_ref()
228 .or(field.default.as_ref())
229 .and_then(|v| match v.as_json() {
230 serde_json::Value::Array(items) if !items.is_empty() => Some(items.clone()),
231 _ => None,
232 });
233
234 write_description(out, field, &pad);
235 if concrete_rows.is_none() {
236 write_eg_comment(out, field, &pad);
237 }
238
239 let inline = inline_annotation(field, true);
240 out.push_str(&format!("{}{}: # {}\n", pad, field.name, inline));
241
242 match concrete_rows {
243 Some(items) => write_array_items(out, &items, &pad),
244 None => {
245 let dash_pad = " ".repeat(indent + 1);
246 out.push_str(&format!("{}-\n", dash_pad));
247 for prop in sort_props(item_props) {
248 write_field(out, prop, indent + 2);
249 }
250 }
251 }
252}
253
254fn write_typed_object_field(
260 out: &mut String,
261 field: &FieldSchema,
262 props: &BTreeMap<String, Box<FieldSchema>>,
263 indent: usize,
264) {
265 let pad = " ".repeat(indent);
266
267 let concrete = field
268 .example
269 .as_ref()
270 .or(field.default.as_ref())
271 .and_then(|v| match v.as_json() {
272 serde_json::Value::Object(map) if !map.is_empty() => Some(map.clone()),
273 _ => None,
274 });
275
276 write_description(out, field, &pad);
277 if concrete.is_none() {
278 write_eg_comment(out, field, &pad);
279 }
280
281 let inline = inline_annotation(field, false);
282 out.push_str(&format!("{}{}: # {}\n", pad, field.name, inline));
283
284 match concrete {
285 Some(map) => {
286 let inner_pad = format!("{} ", pad);
287 for (k, v) in &map {
288 out.push_str(&format!("{}{}: {}\n", inner_pad, k, render_scalar(v)));
289 }
290 }
291 None => {
292 for prop in sort_props(props) {
293 write_field(out, prop, indent + 1);
294 }
295 }
296 }
297}
298
299fn inline_annotation(field: &FieldSchema, force_array_object: bool) -> String {
303 let role = if field.required {
304 "required"
305 } else {
306 "optional"
307 };
308 let type_expr = type_expression(field, force_array_object);
309 format!("{}; {}", type_expr, role)
310}
311
312fn type_expression(field: &FieldSchema, force_array_object: bool) -> String {
313 if let Some(values) = &field.enum_values {
314 return format!("enum<{}>", values.join(" | "));
315 }
316 match field.r#type {
317 FieldType::String => "string".into(),
318 FieldType::Number => "number".into(),
319 FieldType::Integer => "integer".into(),
320 FieldType::Boolean => "boolean".into(),
321 FieldType::Object => "object".into(),
322 FieldType::Markdown => "markdown".into(),
323 FieldType::Date => "date<YYYY-MM-DD>".into(),
324 FieldType::DateTime => "datetime<ISO 8601>".into(),
325 FieldType::Array => {
326 let item = if force_array_object {
327 "object"
328 } else {
329 "string"
330 };
331 format!("array<{}>", item)
332 }
333 }
334}
335
336enum FieldValue {
339 Inline(String),
340 Block(Vec<serde_json::Value>),
341}
342
343fn field_value(field: &FieldSchema) -> FieldValue {
344 if let Some(v) = field.default.as_ref() {
345 return json_to_value(v.as_json());
346 }
347 if let Some(first) = field.enum_values.as_ref().and_then(|v| v.first()) {
348 return FieldValue::Inline(first.clone());
349 }
350 type_empty(&field.r#type)
351}
352
353fn type_empty(t: &FieldType) -> FieldValue {
354 match t {
355 FieldType::Array => FieldValue::Inline("[]".into()),
356 FieldType::Boolean => FieldValue::Inline("false".into()),
357 FieldType::Number | FieldType::Integer => FieldValue::Inline("0".into()),
358 FieldType::Date | FieldType::DateTime => FieldValue::Inline("\"\"".into()),
359 _ => FieldValue::Inline("\"\"".into()),
362 }
363}
364
365fn json_to_value(val: &serde_json::Value) -> FieldValue {
366 match val {
367 serde_json::Value::Array(items) if items.is_empty() => FieldValue::Inline("[]".into()),
368 serde_json::Value::Array(items) => FieldValue::Block(items.clone()),
369 serde_json::Value::String(s) if s.is_empty() => FieldValue::Inline("\"\"".into()),
370 other => FieldValue::Inline(render_scalar(other)),
371 }
372}
373
374fn write_value(out: &mut String, key: &str, val: &FieldValue, comment: &str, pad: &str) {
375 match val {
376 FieldValue::Inline(s) => {
377 out.push_str(&format!("{}{}: {}{}\n", pad, key, s, comment));
378 }
379 FieldValue::Block(items) => {
380 out.push_str(&format!("{}{}:{}\n", pad, key, comment));
381 write_array_items(out, items, pad);
382 }
383 }
384}
385
386fn write_array_items(out: &mut String, items: &[serde_json::Value], pad: &str) {
387 let item_pad = format!("{} ", pad);
388 for item in items {
389 match item {
390 serde_json::Value::Object(map) => {
391 let mut entries = map.iter();
392 if let Some((first_key, first_val)) = entries.next() {
393 out.push_str(&format!(
394 "{}- {}: {}\n",
395 item_pad,
396 first_key,
397 render_scalar(first_val)
398 ));
399 let inner = format!("{} ", item_pad);
400 for (k, v) in entries {
401 out.push_str(&format!("{}{}: {}\n", inner, k, render_scalar(v)));
402 }
403 }
404 }
405 _ => out.push_str(&format!("{}- {}\n", item_pad, render_scalar(item))),
406 }
407 }
408}
409
410fn eg_hint(example: &QuillValue) -> String {
414 match example.as_json() {
415 serde_json::Value::Array(items) => {
416 let parts: Vec<String> = items.iter().map(render_scalar_flow).collect();
417 format!("[{}]", parts.join(", "))
418 }
419 val => render_scalar(val),
420 }
421}
422
423fn render_scalar(val: &serde_json::Value) -> String {
424 match val {
425 serde_json::Value::String(s) => yaml_string(s),
426 serde_json::Value::Number(n) => n.to_string(),
427 serde_json::Value::Bool(b) => b.to_string(),
428 serde_json::Value::Null => "null".to_string(),
429 other => yaml_string(&other.to_string()),
430 }
431}
432
433fn render_scalar_flow(val: &serde_json::Value) -> String {
437 match val {
438 serde_json::Value::String(s) => yaml_string_flow(s),
439 other => render_scalar(other),
440 }
441}
442
443fn yaml_string(s: &str) -> String {
445 let needs_quotes = s.is_empty()
446 || matches!(s, "true" | "false" | "null" | "yes" | "no" | "on" | "off")
447 || s.starts_with(|c: char| {
448 matches!(
449 c,
450 '{' | '[' | '&' | '*' | '!' | '|' | '>' | '\'' | '"' | '%' | '@' | '`'
451 )
452 })
453 || s.contains(": ")
454 || s.contains(" #")
455 || s.starts_with("- ")
456 || s.starts_with('#');
457 if needs_quotes {
458 quote(s)
459 } else {
460 s.to_string()
461 }
462}
463
464fn yaml_string_flow(s: &str) -> String {
468 if s.contains([',', '[', ']', '{', '}']) {
469 quote(s)
470 } else {
471 yaml_string(s)
472 }
473}
474
475fn quote(s: &str) -> String {
476 let mut out = String::new();
477 emit_double_quoted(&mut out, s);
478 out
479}
480
481#[cfg(test)]
482mod tests {
483 use crate::quill::QuillConfig;
484 use crate::Document;
485
486 fn cfg(yaml: &str) -> QuillConfig {
487 QuillConfig::from_yaml(yaml).expect("valid yaml")
488 }
489
490 #[test]
491 fn required_string_renders_empty_with_required_role() {
492 let t = cfg(r#"
493quill: { name: x, version: 1.0.0, backend: typst, description: x }
494main:
495 fields:
496 author: { type: string, required: true }
497"#)
498 .blueprint();
499 assert!(t.contains("author: \"\" # string; required\n"));
500 }
501
502 #[test]
503 fn required_field_with_example_does_not_use_example_as_value() {
504 let t = cfg(r#"
506quill: { name: x, version: 1.0.0, backend: typst, description: x }
507main:
508 fields:
509 status: { type: string, required: true, default: draft, example: final }
510"#)
511 .blueprint();
512 assert!(t.contains("# e.g. final\nstatus: draft # string; required\n"));
513 }
514
515 #[test]
516 fn optional_field_default_renders_as_value_with_eg_line() {
517 let t = cfg(r#"
518quill: { name: x, version: 1.0.0, backend: typst, description: x }
519main:
520 fields:
521 classification: { type: string, default: "", example: CONFIDENTIAL }
522"#)
523 .blueprint();
524 assert!(t.contains("# e.g. CONFIDENTIAL\nclassification: \"\" # string; optional\n"));
525 }
526
527 #[test]
528 fn optional_array_example_renders_as_flow_sequence_with_context_quoting() {
529 let t = cfg(r#"
530quill: { name: x, version: 1.0.0, backend: typst, description: x }
531main:
532 fields:
533 recipient:
534 type: array
535 example:
536 - Mr. John Doe
537 - 123 Main St
538 - "Anytown, USA"
539"#)
540 .blueprint();
541 assert!(t.contains(
542 "# e.g. [Mr. John Doe, 123 Main St, \"Anytown, USA\"]\nrecipient: [] # array<string>; optional\n"
543 ));
544 }
545
546 #[test]
547 fn enum_field_uses_enum_format_slot_and_no_eg() {
548 let t = cfg(r#"
549quill: { name: x, version: 1.0.0, backend: typst, description: x }
550main:
551 fields:
552 format: { type: string, enum: [standard, informal], default: standard }
553"#)
554 .blueprint();
555 assert!(t.contains("format: standard # enum<standard | informal>; optional\n"));
556 assert!(!t.contains("e.g."));
557 }
558
559 #[test]
560 fn required_array_with_example_renders_eg_only_not_value() {
561 let t = cfg(r#"
564quill: { name: x, version: 1.0.0, backend: typst, description: x }
565main:
566 fields:
567 memo_from:
568 type: array
569 required: true
570 example:
571 - ORG/SYMBOL
572 - City ST 12345
573"#)
574 .blueprint();
575 assert!(t.contains(
576 "# e.g. [ORG/SYMBOL, City ST 12345]\nmemo_from: [] # array<string>; required\n"
577 ));
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 required: true
589 description: Be brief and clear.
590"#)
591 .blueprint();
592 assert!(t.contains("# Be brief and clear.\nsubject: \"\" # string; required\n"));
593 }
594
595 #[test]
596 fn every_field_carries_inline_type_and_role() {
597 let t = cfg(r#"
598quill: { name: x, version: 1.0.0, backend: typst, description: x }
599main:
600 fields:
601 title: { type: string }
602 size: { type: number, default: 11 }
603 flag: { type: boolean, default: false }
604 issued: { type: date }
605 published: { type: datetime }
606 refs: { type: array }
607"#)
608 .blueprint();
609 assert!(t.contains("title: \"\" # string; optional\n"));
610 assert!(t.contains("size: 11 # number; optional\n"));
611 assert!(t.contains("flag: false # boolean; optional\n"));
612 assert!(t.contains("issued: \"\" # date<YYYY-MM-DD>; optional\n"));
613 assert!(t.contains("published: \"\" # datetime<ISO 8601>; optional\n"));
614 assert!(t.contains("refs: [] # array<string>; optional\n"));
615 }
616
617 #[test]
618 fn markdown_field_renders_as_block_scalar() {
619 let t = cfg(r#"
620quill: { name: x, version: 1.0.0, backend: typst, description: x }
621main:
622 fields:
623 bio: { type: markdown }
624"#)
625 .blueprint();
626 assert!(t.contains("bio: |- # markdown; optional\n \n"));
627 }
628
629 #[test]
630 fn markdown_field_with_default_fills_block() {
631 let t = cfg(r###"
632quill: { name: x, version: 1.0.0, backend: typst, description: x }
633main:
634 fields:
635 bio:
636 type: markdown
637 default: "## About me\n\nHello."
638"###)
639 .blueprint();
640 assert!(t.contains("bio: |- # markdown; optional\n ## About me\n \n Hello.\n"));
641 }
642
643 #[test]
644 fn quill_sentinel_line_is_required_verbatim() {
645 let t = cfg(r#"
646quill: { name: taro, version: 0.1.0, backend: typst, description: x }
647main:
648 fields:
649 flavor: { type: string, default: taro }
650"#)
651 .blueprint();
652 assert!(t.starts_with("---\n# x\nQUILL: taro@0.1.0 # sentinel; required, verbatim\n"));
653 assert!(t.contains("\nWrite main body here.\n"));
654 }
655
656 #[test]
657 fn leaf_sentinel_line_is_composable() {
658 let t = cfg(r#"
659quill: { name: x, version: 1.0.0, backend: typst, description: x }
660main:
661 fields:
662 title: { type: string }
663leaf_kinds:
664 note:
665 description: A short note appended to the document.
666 fields:
667 author: { type: string }
668"#)
669 .blueprint();
670 assert!(t.contains(
671 "# A short note appended to the document.\nKIND: note # sentinel; composable (0..N)\n"
672 ));
673 }
674
675 #[test]
676 fn body_disabled_leaf_omits_body_placeholder() {
677 let t = cfg(r#"
678quill: { name: x, version: 1.0.0, backend: typst, description: x }
679main:
680 fields:
681 title: { type: string }
682leaf_kinds:
683 skills:
684 body: { enabled: false }
685 fields:
686 items: { type: array, required: true }
687"#)
688 .blueprint();
689 let after = &t[t.find("KIND: skills").unwrap()..];
690 assert!(!after.contains("skills body"));
691 }
692
693 #[test]
694 fn body_example_appears_verbatim() {
695 let t = cfg(r#"
696quill: { name: x, version: 1.0.0, backend: typst, description: x }
697main:
698 fields:
699 title: { type: string }
700leaf_kinds:
701 note:
702 body:
703 example: "This is an example note."
704 fields:
705 author: { type: string }
706"#)
707 .blueprint();
708 let after = &t[t.find("KIND: note").unwrap()..];
709 assert!(after.contains("\nThis is an example note.\n"));
710 assert!(!after.contains("Write note body here."));
711 }
712
713 #[test]
714 fn main_body_example_appears_verbatim() {
715 let t = cfg(r#"
716quill: { name: x, version: 1.0.0, backend: typst, description: x }
717main:
718 body:
719 example: "Dear Sir or Madam,\n\nI am writing to..."
720 fields:
721 to: { type: string }
722"#)
723 .blueprint();
724 assert!(t.contains("\nDear Sir or Madam,\n\nI am writing to...\n"));
725 assert!(!t.contains("Write main body here."));
726 }
727
728 #[test]
729 fn leaf_body_placeholder_uses_leaf_name() {
730 let t = cfg(r#"
731quill: { name: x, version: 1.0.0, backend: typst, description: x }
732main:
733 fields:
734 title: { type: string }
735leaf_kinds:
736 indorsement:
737 fields:
738 from: { type: string }
739"#)
740 .blueprint();
741 assert!(t.contains("\nWrite indorsement body here.\n"));
742 }
743
744 #[test]
745 fn ui_groups_cluster_fields_without_emitting_banner() {
746 let t = cfg(r#"
747quill: { name: x, version: 1.0.0, backend: typst, description: x }
748main:
749 fields:
750 memo_for: { type: array, required: true, ui: { group: Addressing } }
751 subject: { type: string, required: true, ui: { group: Addressing } }
752 letterhead_title: { type: string, default: HQ, ui: { group: Letterhead } }
753 notes: { type: string }
754"#)
755 .blueprint();
756 let after_quill = &t[t.find("QUILL:").unwrap()..];
757 assert!(!after_quill.contains("===="));
759 let notes = after_quill.find("notes:").unwrap();
761 let memo_for = after_quill.find("memo_for:").unwrap();
762 let letterhead = after_quill.find("letterhead_title:").unwrap();
763 assert!(notes < memo_for);
764 assert!(memo_for < letterhead);
765 }
766
767 #[test]
768 fn typed_table_emits_synthetic_row_when_no_example() {
769 let t = cfg(r#"
770quill: { name: x, version: 1.0.0, backend: typst, description: x }
771main:
772 fields:
773 references:
774 type: array
775 description: Cited works.
776 properties:
777 org: { type: string, required: true, description: Citing organization. }
778 year: { type: integer, description: Publication year. }
779"#)
780 .blueprint();
781 assert!(t.contains("# Cited works.\nreferences: # array<object>; optional\n -\n"));
782 assert!(t.contains(" # Citing organization.\n org: \"\" # string; required\n"));
783 assert!(t.contains(" # Publication year.\n year: 0 # integer; optional\n"));
784 }
785
786 #[test]
787 fn typed_table_with_example_renders_example_rows_no_eg_line() {
788 let t = cfg(r#"
789quill: { name: x, version: 1.0.0, backend: typst, description: x }
790main:
791 fields:
792 refs:
793 type: array
794 example:
795 - { org: ACME, year: 2020 }
796 properties:
797 org: { type: string, required: true }
798 year: { type: integer }
799"#)
800 .blueprint();
801 assert!(t.contains("refs: # array<object>; optional\n - org: ACME\n"));
802 assert!(!t.contains("refs: # array<object>; optional\n -\n"));
803 assert!(!t.contains("# e.g."));
804 }
805
806 #[test]
807 fn typed_table_with_default_renders_default_rows() {
808 let t = cfg(r#"
809quill: { name: x, version: 1.0.0, backend: typst, description: x }
810main:
811 fields:
812 refs:
813 type: array
814 default:
815 - { org: ACME }
816 properties:
817 org: { type: string, required: true }
818"#)
819 .blueprint();
820 assert!(t.contains("refs: # array<object>; optional\n - org: ACME\n"));
821 assert!(!t.contains("refs: # array<object>; optional\n -\n"));
822 }
823
824 #[test]
825 fn typed_table_with_empty_default_falls_through_to_synthetic_row() {
826 let t = cfg(r#"
827quill: { name: x, version: 1.0.0, backend: typst, description: x }
828main:
829 fields:
830 refs:
831 type: array
832 default: []
833 properties:
834 org: { type: string, required: true }
835"#)
836 .blueprint();
837 assert!(t.contains(
838 "refs: # array<object>; optional\n -\n org: \"\" # string; required\n"
839 ));
840 }
841
842 #[test]
843 fn typed_dict_emits_per_property_annotations() {
844 let t = cfg(r#"
845quill: { name: x, version: 1.0.0, backend: typst, description: x }
846main:
847 fields:
848 address:
849 type: object
850 description: Mailing address.
851 properties:
852 street: { type: string, required: true, description: Street line. }
853 city: { type: string, required: true }
854 zip: { type: string }
855"#)
856 .blueprint();
857 assert!(t.contains("# Mailing address.\naddress: # object; optional\n"));
858 assert!(t.contains(" # Street line.\n street: \"\" # string; required\n"));
859 assert!(t.contains(" city: \"\" # string; required\n"));
860 assert!(t.contains(" zip: \"\" # string; optional\n"));
861 }
862
863 #[test]
864 fn typed_dict_required_carries_role() {
865 let t = cfg(r#"
866quill: { name: x, version: 1.0.0, backend: typst, description: x }
867main:
868 fields:
869 address:
870 type: object
871 required: true
872 properties:
873 street: { type: string, required: true }
874"#)
875 .blueprint();
876 assert!(t.contains("address: # object; required\n"));
877 }
878
879 #[test]
880 fn typed_dict_with_default_renders_block_mapping_no_annotations() {
881 let t = cfg(r#"
882quill: { name: x, version: 1.0.0, backend: typst, description: x }
883main:
884 fields:
885 address:
886 type: object
887 default: { street: "5000 Forbes Ave", city: Pittsburgh }
888 properties:
889 street: { type: string, required: true }
890 city: { type: string, required: true }
891"#)
892 .blueprint();
893 assert!(t.contains("address: # object; optional\n"));
894 assert!(
895 t.contains(" street: 5000 Forbes Ave\n")
896 || t.contains(" street: \"5000 Forbes Ave\"\n")
897 );
898 assert!(t.contains(" city: Pittsburgh\n"));
899 assert!(!t.contains("# string; required"));
901 }
902
903 #[test]
904 fn typed_dict_with_example_suppresses_eg_line() {
905 let t = cfg(r#"
906quill: { name: x, version: 1.0.0, backend: typst, description: x }
907main:
908 fields:
909 address:
910 type: object
911 example: { street: "1 Infinite Loop", city: Cupertino }
912 properties:
913 street: { type: string, required: true }
914 city: { type: string }
915"#)
916 .blueprint();
917 assert!(t.contains("address: # object; optional\n"));
918 assert!(!t.contains("# e.g."));
920 assert!(t.contains(" city: Cupertino\n"));
921 }
922
923 const LETTER_QUILL: &str = r#"
924quill: { name: letter, version: 1.0.0, backend: typst, description: A formal letter. }
925main:
926 fields:
927 to:
928 type: string
929 required: true
930 description: Recipient name.
931 subject:
932 type: string
933 required: true
934 date:
935 type: date
936 priority:
937 type: string
938 enum: [normal, urgent]
939 default: normal
940 attachments:
941 type: array
942 example:
943 - report.pdf
944leaf_kinds:
945 enclosure:
946 description: An enclosure attached to the letter.
947 fields:
948 label: { type: string, required: true }
949 pages: { type: integer, default: 1 }
950"#;
951
952 #[test]
953 fn blueprint_round_trips_idempotently() {
954 let bp = cfg(LETTER_QUILL).blueprint();
955 let doc1 = Document::from_markdown(&bp).expect("blueprint must parse");
956 assert_eq!(
960 doc1.leaves().len(),
961 1,
962 "blueprint emits one leaf; parser must recognise it"
963 );
964 assert_eq!(doc1.leaves()[0].tag(), "enclosure");
965 let md2 = doc1.to_markdown();
966 let doc2 = Document::from_markdown(&md2).expect("round-tripped markdown must parse");
967 assert_eq!(
968 doc1, doc2,
969 "Document must be equal after blueprint → parse → emit → parse"
970 );
971 }
972}