use std::collections::BTreeMap;
use super::validation::MUST_FILL_SENTINEL;
use super::{CardSchema, FieldSchema, FieldType, QuillConfig};
use crate::document::emit::{saphyr_emit_flow, saphyr_emit_scalar};
use crate::value::QuillValue;
use serde_json::Value as JsonValue;
impl QuillConfig {
pub fn blueprint(&self) -> String {
let mut out = String::new();
let main_desc = self
.main
.description
.as_deref()
.filter(|s| !s.is_empty())
.or_else(|| Some(self.description.as_str()).filter(|s| !s.is_empty()));
write_main_fence(
&mut out,
&self.main,
&format!("{}@{}", self.name, self.version),
main_desc,
);
if self.main.body_enabled() {
let example = self.main.body.as_ref().and_then(|b| b.example.as_deref());
let text = example.unwrap_or("Write main body here.");
out.push_str(&format!("\n{}\n", text));
}
for card in &self.card_kinds {
out.push('\n');
write_card_fence(&mut out, card);
if card.body_enabled() {
let example = card.body.as_ref().and_then(|b| b.example.as_deref());
let fallback = format!("Write {} body here.", card.name);
let text = example.unwrap_or(fallback.as_str());
out.push_str(&format!("\n{}\n", text));
}
}
out
}
}
fn write_comment(out: &mut String, text: &str) {
let clean = text.split_whitespace().collect::<Vec<_>>().join(" ");
if !clean.is_empty() {
out.push_str(&format!("# {}\n", clean));
}
}
fn write_main_fence(
out: &mut String,
card: &CardSchema,
quill_ref: &str,
description: Option<&str>,
) {
out.push_str("~~~\n");
out.push_str("$quill: ");
out.push_str(&saphyr_emit_scalar(&JsonValue::String(
quill_ref.to_string(),
)));
out.push('\n');
out.push_str("$kind: main\n");
if let Some(desc) = description {
write_comment(out, desc);
}
write_card_fields(out, card);
out.push_str("~~~\n");
}
fn write_card_fence(out: &mut String, card: &CardSchema) {
out.push_str("~~~\n");
out.push_str("$kind: ");
out.push_str(&saphyr_emit_scalar(&JsonValue::String(card.name.clone())));
out.push('\n');
out.push_str("# composable (0..N)\n");
if let Some(desc) = &card.description {
write_comment(out, desc);
}
write_card_fields(out, card);
out.push_str("~~~\n");
}
fn write_card_fields(out: &mut String, card: &CardSchema) {
for field in group_fields(card.fields.values()) {
write_field(out, field, 0);
}
}
fn group_fields<'a, I: IntoIterator<Item = &'a FieldSchema>>(fields: I) -> Vec<&'a FieldSchema> {
let mut sorted: Vec<&FieldSchema> = fields.into_iter().collect();
sorted.sort_by_key(|f| f.ui_order());
let mut groups: Vec<(Option<&str>, Vec<&FieldSchema>)> = Vec::new();
for field in sorted {
let group = field.ui.as_ref().and_then(|u| u.group.as_deref());
match groups.iter_mut().find(|(g, _)| *g == group) {
Some(slot) => slot.1.push(field),
None => groups.push((group, vec![field])),
}
}
groups.sort_by_key(|(g, _)| g.is_some());
groups.into_iter().flat_map(|(_, fields)| fields).collect()
}
fn write_field(out: &mut String, field: &FieldSchema, indent: usize) {
let pad = " ".repeat(indent);
if matches!(field.r#type, FieldType::Array) {
if let Some(items) = &field.items {
if matches!(items.r#type, FieldType::Object) {
if let Some(props) = &items.properties {
write_typed_table_field(out, field, props, indent);
return;
}
}
}
}
if matches!(field.r#type, FieldType::Object) {
if let Some(props) = &field.properties {
write_typed_object_field(out, field, props, indent);
return;
}
}
write_description(out, field, &pad);
write_eg_comment(out, field, &pad);
if matches!(field.r#type, FieldType::Markdown) {
let inline = inline_annotation(field, field.default.is_some());
write_markdown_block(out, field, &pad, &inline);
return;
}
let inline = format!(" # {}", inline_annotation(field, field.default.is_some()));
let value = field_value(field);
write_value(out, &field.name, &value, &inline, &pad);
}
fn write_description(out: &mut String, field: &FieldSchema, pad: &str) {
if let Some(desc) = &field.description {
let clean = desc.split_whitespace().collect::<Vec<_>>().join(" ");
if !clean.is_empty() {
out.push_str(&format!("{}# {}\n", pad, clean));
}
}
}
fn write_eg_comment(out: &mut String, field: &FieldSchema, pad: &str) {
if let Some(eg) = field.example.as_ref().map(eg_hint) {
out.push_str(&format!("{}# e.g. {}\n", pad, eg));
}
}
fn write_markdown_block(out: &mut String, field: &FieldSchema, pad: &str, inline: &str) {
out.push_str(&format!("{}{}: |- # {}\n", pad, field.name, inline));
let body_pad = format!("{} ", pad);
let content = field.default.as_ref().and_then(|v| match v.as_json() {
serde_json::Value::String(s) => Some(s),
_ => None,
});
match content {
Some(text) if !text.is_empty() => {
for line in text.lines() {
out.push_str(&format!("{}{}\n", body_pad, line));
}
}
Some(_) => {
out.push_str(&format!("{}\n", body_pad));
}
None => {
out.push_str(&format!("{}{}\n", body_pad, MUST_FILL_SENTINEL));
}
}
}
fn sort_props(props: &BTreeMap<String, Box<FieldSchema>>) -> Vec<&FieldSchema> {
let mut v: Vec<&FieldSchema> = props.values().map(|b| b.as_ref()).collect();
v.sort_by_key(|f| f.ui_order());
v
}
fn write_typed_table_field(
out: &mut String,
field: &FieldSchema,
item_props: &BTreeMap<String, Box<FieldSchema>>,
indent: usize,
) {
let pad = " ".repeat(indent);
write_description(out, field, &pad);
write_eg_comment(out, field, &pad);
let inline = inline_annotation(field, field.default.is_some());
let rows = field.default.as_ref().and_then(|v| match v.as_json() {
serde_json::Value::Array(items) => Some(items.clone()),
_ => None,
});
match rows {
Some(items) if items.is_empty() => {
out.push_str(&format!("{}{}: [] # {}\n", pad, field.name, inline));
}
Some(items) => {
out.push_str(&format!("{}{}: # {}\n", pad, field.name, inline));
write_array_items(out, &items, &pad);
}
None if item_props.is_empty() => {
out.push_str(&format!("{}{}: [] # {}\n", pad, field.name, inline));
}
None => {
out.push_str(&format!("{}{}: # {}\n", pad, field.name, inline));
let dash_pad = " ".repeat(indent + 1);
out.push_str(&format!("{}-\n", dash_pad));
for prop in sort_props(item_props) {
write_field(out, prop, indent + 2);
}
}
}
}
fn write_typed_object_field(
out: &mut String,
field: &FieldSchema,
props: &BTreeMap<String, Box<FieldSchema>>,
indent: usize,
) {
let pad = " ".repeat(indent);
write_description(out, field, &pad);
write_eg_comment(out, field, &pad);
let inline = inline_annotation(field, field.default.is_some());
let mapping = field.default.as_ref().and_then(|v| match v.as_json() {
serde_json::Value::Object(map) => Some(map.clone()),
_ => None,
});
match mapping {
Some(map) if map.is_empty() => {
out.push_str(&format!("{}{}: {{}} # {}\n", pad, field.name, inline));
}
Some(map) => {
out.push_str(&format!("{}{}: # {}\n", pad, field.name, inline));
let inner_pad = format!("{} ", pad);
for (k, v) in &map {
out.push_str(&format!("{}{}: {}\n", inner_pad, k, render_scalar(v)));
}
}
None if props.is_empty() => {
out.push_str(&format!("{}{}: {{}} # {}\n", pad, field.name, inline));
}
None => {
out.push_str(&format!("{}{}: # {}\n", pad, field.name, inline));
for prop in sort_props(props) {
write_field(out, prop, indent + 1);
}
}
}
}
fn inline_annotation(field: &FieldSchema, endorsed: bool) -> String {
let type_expr = type_expression(field);
if endorsed {
format!("{}; delete-ok", type_expr)
} else {
type_expr
}
}
fn type_expression(field: &FieldSchema) -> String {
if let Some(values) = &field.enum_values {
return format!("enum<{}>", values.join(" | "));
}
match field.r#type {
FieldType::String => "string".into(),
FieldType::Number => "number".into(),
FieldType::Integer => "integer".into(),
FieldType::Boolean => "boolean".into(),
FieldType::Object => "object".into(),
FieldType::Markdown => "markdown".into(),
FieldType::DateTime => "datetime<YYYY-MM-DD[Thh:mm:ss]>".into(),
FieldType::Array => {
let item = field
.items
.as_ref()
.map(|it| type_expression(it))
.unwrap_or_else(|| "string".into());
format!("array<{}>", item)
}
}
}
enum FieldValue {
Inline(String),
Block(Vec<serde_json::Value>),
}
fn field_value(field: &FieldSchema) -> FieldValue {
if let Some(v) = &field.default {
return json_to_value(v.as_json());
}
FieldValue::Inline(MUST_FILL_SENTINEL.to_string())
}
fn json_to_value(val: &serde_json::Value) -> FieldValue {
match val {
serde_json::Value::Array(items) if items.is_empty() => FieldValue::Inline("[]".into()),
serde_json::Value::Array(items) => FieldValue::Block(items.clone()),
serde_json::Value::String(s) if s.is_empty() => FieldValue::Inline("\"\"".into()),
other => FieldValue::Inline(render_scalar(other)),
}
}
fn write_value(out: &mut String, key: &str, val: &FieldValue, comment: &str, pad: &str) {
match val {
FieldValue::Inline(s) => {
out.push_str(&format!("{}{}: {}{}\n", pad, key, s, comment));
}
FieldValue::Block(items) => {
out.push_str(&format!("{}{}:{}\n", pad, key, comment));
write_array_items(out, items, pad);
}
}
}
fn write_array_items(out: &mut String, items: &[serde_json::Value], pad: &str) {
let item_pad = format!("{} ", pad);
for item in items {
match item {
serde_json::Value::Object(map) => {
let mut entries = map.iter();
if let Some((first_key, first_val)) = entries.next() {
out.push_str(&format!(
"{}- {}: {}\n",
item_pad,
first_key,
render_scalar(first_val)
));
let inner = format!("{} ", item_pad);
for (k, v) in entries {
out.push_str(&format!("{}{}: {}\n", inner, k, render_scalar(v)));
}
}
}
_ => out.push_str(&format!("{}- {}\n", item_pad, render_scalar(item))),
}
}
}
fn eg_hint(example: &QuillValue) -> String {
match example.as_json() {
v @ (serde_json::Value::Array(_) | serde_json::Value::Object(_)) => saphyr_emit_flow(v),
val => render_scalar(val),
}
}
fn render_scalar(val: &serde_json::Value) -> String {
saphyr_emit_scalar(val)
}
#[cfg(test)]
mod tests {
use crate::quill::QuillConfig;
use crate::Document;
fn cfg(yaml: &str) -> QuillConfig {
QuillConfig::from_yaml(yaml).expect("valid yaml")
}
#[test]
fn must_fill_string_renders_sentinel_with_no_delete_ok() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
author: { type: string }
"#)
.blueprint();
assert!(t.contains("author: <must-fill> # string\n"));
}
#[test]
fn endorsed_field_with_example_does_not_use_example_as_value() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
status: { type: string, default: draft, example: final }
"#)
.blueprint();
assert!(t.contains("# e.g. final\nstatus: draft # string; delete-ok\n"));
}
#[test]
fn endorsed_empty_default_renders_value_with_delete_ok_and_eg_line() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
classification: { type: string, default: "", example: CONFIDENTIAL }
"#)
.blueprint();
assert!(t.contains("# e.g. CONFIDENTIAL\nclassification: \"\" # string; delete-ok\n"));
}
#[test]
fn must_fill_array_example_renders_as_flow_sequence_with_context_quoting() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
recipient:
type: array
items: { type: string }
example:
- Mr. John Doe
- 123 Main St
- "Anytown, USA"
"#)
.blueprint();
assert!(t.contains(
"# e.g. [Mr. John Doe, 123 Main St, \"Anytown, USA\"]\nrecipient: <must-fill> # array<string>\n"
));
}
#[test]
fn enum_endorsed_uses_enum_format_slot_and_no_eg() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
format: { type: string, enum: [standard, informal], default: standard }
"#)
.blueprint();
assert!(t.contains("format: standard # enum<standard | informal>; delete-ok\n"));
assert!(!t.contains("e.g."));
}
#[test]
fn enum_must_fill_renders_sentinel_in_value_cell() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
severity: { type: string, enum: [low, medium, high] }
"#)
.blueprint();
assert!(t.contains("severity: <must-fill> # enum<low | medium | high>\n"));
}
#[test]
fn must_fill_array_with_example_renders_eg_only_not_value() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
memo_from:
type: array
items: { type: string }
example:
- ORG/SYMBOL
- City ST 12345
"#)
.blueprint();
assert!(t.contains(
"# e.g. [ORG/SYMBOL, City ST 12345]\nmemo_from: <must-fill> # array<string>\n"
));
}
#[test]
fn description_emitted_as_single_line() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
subject:
type: string
description: Be brief and clear.
"#)
.blueprint();
assert!(t.contains("# Be brief and clear.\nsubject: <must-fill> # string\n"));
}
#[test]
fn every_field_carries_inline_type_and_cell_signal() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
title: { type: string }
size: { type: number, default: 11 }
flag: { type: boolean, default: false }
issued: { type: datetime }
published: { type: datetime }
refs: { type: array, default: [], items: { type: string } }
"#)
.blueprint();
assert!(t.contains("title: <must-fill> # string\n"));
assert!(t.contains("size: 11 # number; delete-ok\n"));
assert!(t.contains("flag: false # boolean; delete-ok\n"));
assert!(t.contains("issued: <must-fill> # datetime<YYYY-MM-DD[Thh:mm:ss]>\n"));
assert!(t.contains("published: <must-fill> # datetime<YYYY-MM-DD[Thh:mm:ss]>\n"));
assert!(t.contains("refs: [] # array<string>; delete-ok\n"));
}
#[test]
fn scalar_array_annotation_reflects_element_type() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
counts: { type: array, items: { type: integer } }
sections: { type: array, items: { type: markdown } }
tags: { type: array, items: { type: string } }
"#)
.blueprint();
assert!(t.contains("counts: <must-fill> # array<integer>\n"), "{t}");
assert!(
t.contains("sections: <must-fill> # array<markdown>\n"),
"{t}"
);
assert!(t.contains("tags: <must-fill> # array<string>\n"), "{t}");
}
#[test]
fn must_fill_markdown_renders_sentinel_inside_block_scalar() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
bio: { type: markdown }
"#)
.blueprint();
assert!(t.contains("bio: |- # markdown\n <must-fill>\n"));
}
#[test]
fn endorsed_empty_markdown_renders_blank_line_with_delete_ok() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
bio: { type: markdown, default: "" }
"#)
.blueprint();
assert!(t.contains("bio: |- # markdown; delete-ok\n \n"));
assert!(!t.contains("<must-fill>"));
}
#[test]
fn endorsed_markdown_default_fills_block() {
let t = cfg(r###"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
bio:
type: markdown
default: "## About me\n\nHello."
"###)
.blueprint();
assert!(t.contains("bio: |- # markdown; delete-ok\n ## About me\n \n Hello.\n"));
}
#[test]
fn root_header_emits_no_role_comment() {
let t = cfg(r#"
quill: { name: taro, version: 0.1.0, backend: typst, description: x }
main:
fields:
flavor: { type: string, default: taro }
"#)
.blueprint();
assert!(t.starts_with("~~~\n$quill: taro@0.1.0\n$kind: main\n# x\n"));
assert!(t.contains("\nWrite main body here.\n"));
}
#[test]
fn card_fence_carries_composable_annotation() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
title: { type: string }
card_kinds:
note:
description: A short note appended to the document.
fields:
author: { type: string }
"#)
.blueprint();
assert!(t.contains(
"~~~\n$kind: note\n# composable (0..N)\n# A short note appended to the document.\n"
));
}
#[test]
fn body_disabled_card_omits_body_placeholder() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
title: { type: string }
card_kinds:
skills:
body: { enabled: false }
fields:
items: { type: array, items: { type: string } }
"#)
.blueprint();
let after = &t[t.find("$kind: skills").unwrap()..];
assert!(!after.contains("skills body"));
}
#[test]
fn body_example_appears_verbatim() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
title: { type: string }
card_kinds:
note:
body:
example: "This is an example note."
fields:
author: { type: string }
"#)
.blueprint();
let after = &t[t.find("$kind: note").unwrap()..];
assert!(after.contains("\nThis is an example note.\n"));
assert!(!after.contains("Write note body here."));
}
#[test]
fn main_body_example_appears_verbatim() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
body:
example: "Dear Sir or Madam,\n\nI am writing to..."
fields:
to: { type: string }
"#)
.blueprint();
assert!(t.contains("\nDear Sir or Madam,\n\nI am writing to...\n"));
assert!(!t.contains("Write main body here."));
}
#[test]
fn card_body_placeholder_uses_card_name() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
title: { type: string }
card_kinds:
indorsement:
fields:
from: { type: string }
"#)
.blueprint();
assert!(t.contains("\nWrite indorsement body here.\n"));
}
#[test]
fn ui_groups_cluster_fields_without_emitting_banner() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
memo_for: { type: array, items: { type: string }, ui: { group: Addressing } }
subject: { type: string, ui: { group: Addressing } }
letterhead_title: { type: string, default: HQ, ui: { group: Letterhead } }
notes: { type: string }
"#)
.blueprint();
let after_quill = &t[t.find("$quill:").unwrap()..];
assert!(!after_quill.contains("===="));
let notes = after_quill.find("notes:").unwrap();
let memo_for = after_quill.find("memo_for:").unwrap();
let letterhead = after_quill.find("letterhead_title:").unwrap();
assert!(notes < memo_for);
assert!(memo_for < letterhead);
}
#[test]
fn typed_table_must_fill_emits_synthetic_row_with_leaf_sentinels() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
references:
type: array
description: Cited works.
items:
type: object
properties:
org: { type: string, description: Citing organization. }
year: { type: integer, default: 0, description: Publication year. }
"#)
.blueprint();
assert!(t.contains("# Cited works.\nreferences: # array<object>\n -\n"));
assert!(t.contains(" # Citing organization.\n org: <must-fill> # string\n"));
assert!(t.contains(" # Publication year.\n year: 0 # integer; delete-ok\n"));
}
#[test]
fn typed_table_with_example_keeps_eg_line_and_synthetic_row() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
refs:
type: array
example:
- { org: ACME, year: 2020 }
items:
type: object
properties:
org: { type: string }
year: { type: integer, default: 0 }
"#)
.blueprint();
assert!(t.contains("# e.g. [{org: ACME, year: 2020}]\n"));
assert!(t.contains("refs: # array<object>\n -\n"));
assert!(t.contains(" org: <must-fill> # string\n"));
assert!(t.contains(" year: 0 # integer; delete-ok\n"));
}
#[test]
fn typed_table_endorsed_renders_default_rows_with_delete_ok() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
refs:
type: array
default:
- { org: ACME }
items:
type: object
properties:
org: { type: string }
"#)
.blueprint();
assert!(t.contains("refs: # array<object>; delete-ok\n - org: ACME\n"));
assert!(!t.contains("refs: # array<object>; delete-ok\n -\n"));
}
#[test]
fn typed_table_with_empty_default_renders_inline_and_delete_ok() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
refs:
type: array
default: []
items:
type: object
properties:
org: { type: string }
"#)
.blueprint();
assert!(
t.contains("refs: [] # array<object>; delete-ok\n"),
"wrong rendering: {t}"
);
assert!(!t.contains("<must-fill>"), "no sentinels expected: {t}");
}
#[test]
fn typed_dict_with_empty_default_renders_inline_and_delete_ok() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
address:
type: object
default: {}
properties:
street: { type: string }
"#)
.blueprint();
assert!(
t.contains("address: {} # object; delete-ok\n"),
"wrong rendering: {t}"
);
assert!(!t.contains("<must-fill>"), "no sentinels expected: {t}");
}
#[test]
fn typed_dict_must_fill_emits_per_property_annotations() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
address:
type: object
description: Mailing address.
properties:
street: { type: string, description: Street line. }
city: { type: string }
zip: { type: string, default: "" }
"#)
.blueprint();
assert!(t.contains("# Mailing address.\naddress: # object\n"));
assert!(t.contains(" # Street line.\n street: <must-fill> # string\n"));
assert!(t.contains(" city: <must-fill> # string\n"));
assert!(t.contains(" zip: \"\" # string; delete-ok\n"));
}
#[test]
fn typed_dict_endorsed_renders_block_mapping_with_delete_ok() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
address:
type: object
default: { street: "5000 Forbes Ave", city: Pittsburgh }
properties:
street: { type: string }
city: { type: string }
"#)
.blueprint();
assert!(t.contains("address: # object; delete-ok\n"));
assert!(
t.contains(" street: 5000 Forbes Ave\n")
|| t.contains(" street: \"5000 Forbes Ave\"\n")
);
assert!(t.contains(" city: Pittsburgh\n"));
assert!(!t.contains("# string"));
}
#[test]
fn typed_dict_with_example_keeps_eg_line_and_per_property() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
address:
type: object
example: { street: "1 Infinite Loop", city: Cupertino }
properties:
street: { type: string }
city: { type: string, default: "" }
"#)
.blueprint();
assert!(t.contains("address: # object\n"));
assert!(
t.contains("# e.g. {street: 1 Infinite Loop, city: Cupertino}\n")
|| t.contains("# e.g. {city: Cupertino, street: 1 Infinite Loop}\n")
);
assert!(t.contains(" street: <must-fill> # string\n"));
assert!(t.contains(" city: \"\" # string; delete-ok\n"));
}
const LETTER_QUILL: &str = r#"
quill: { name: letter, version: 1.0.0, backend: typst, description: A formal letter. }
main:
fields:
to:
type: string
description: Recipient name.
subject:
type: string
date:
type: datetime
priority:
type: string
enum: [normal, urgent]
default: normal
attachments:
type: array
items: { type: string }
default: []
example:
- report.pdf
card_kinds:
enclosure:
description: An enclosure attached to the letter.
fields:
label: { type: string }
pages: { type: integer, default: 1 }
"#;
#[test]
fn blueprint_round_trips_idempotently() {
let bp = cfg(LETTER_QUILL).blueprint();
let doc1 = Document::from_markdown(&bp).expect("blueprint must parse");
let md2 = doc1.to_markdown();
let doc2 = Document::from_markdown(&md2).expect("round-tripped markdown must parse");
assert_eq!(
doc1, doc2,
"Document must be equal after blueprint → parse → emit → parse"
);
}
#[test]
fn blueprint_renders_default_and_surfaces_example_as_hint() {
let blueprint = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
status: { type: string, default: draft, example: final }
"#)
.blueprint();
assert!(
blueprint.contains("status: draft # string; delete-ok\n"),
"blueprint should render the default value: {blueprint}"
);
assert!(
blueprint.contains("# e.g. final\n"),
"blueprint should surface the example as a hint: {blueprint}"
);
}
#[test]
fn empty_typed_object_and_table_rejected() {
for yaml in [
r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
addr: { type: object, properties: {} }
"#,
r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
rows: { type: array, items: { type: object, properties: {} } }
"#,
] {
let errs = QuillConfig::from_yaml_with_warnings(yaml)
.expect_err("expected error for empty properties");
assert!(
errs.iter()
.any(|e| e.code.as_deref() == Some("quill::object_empty_properties")),
"expected quill::object_empty_properties, got: {errs:?}"
);
}
}
#[test]
fn type_ambiguous_string_defaults_round_trip_as_strings() {
let bp = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
version: { type: string, default: "1.0" }
activation: { type: string, default: "on" }
code: { type: string, default: "01234" }
placeholder: { type: string, default: "null" }
yes_flag: { type: string, default: "yes" }
"#)
.blueprint();
let doc = Document::from_markdown(&bp).expect("blueprint must parse");
let payload = doc.main().payload();
for (key, expected) in [
("version", "1.0"),
("activation", "on"),
("code", "01234"),
("placeholder", "null"),
("yes_flag", "yes"),
] {
let v = payload.get(key).unwrap_or_else(|| panic!("missing {key}"));
assert!(
v.as_str().is_some(),
"field {key} must round-trip as a string, got {:?}\nBlueprint:\n{}",
v,
bp
);
assert_eq!(v.as_str().unwrap(), expected, "field {key}: value mismatch");
}
}
}