use std::collections::BTreeMap;
use super::{CardSchema, FieldSchema, FieldType, QuillConfig};
use crate::document::emit::emit_double_quoted;
use crate::value::QuillValue;
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_card_frontmatter(
&mut out,
&self.main,
&format!("QUILL: {}@{} # sentinel", self.name, self.version),
main_desc,
);
if self.main.body_enabled() {
let desc = self
.main
.body
.as_ref()
.and_then(|b| b.description.as_deref());
out.push_str(&format!("\n{}\n", body_marker("main body", desc)));
}
for card in &self.card_types {
let sentinel = format!("CARD: {} # sentinel, composable (0..N)", card.name);
out.push('\n');
write_card_frontmatter(&mut out, card, &sentinel, card.description.as_deref());
if card.body_enabled() {
let label = format!("{} body", card.name);
let desc = card.body.as_ref().and_then(|b| b.description.as_deref());
out.push_str(&format!("\n{}\n", body_marker(&label, desc)));
}
}
out
}
}
fn body_marker(label: &str, description: Option<&str>) -> String {
match description {
Some(desc) => format!("{} \u{2014} {}", label, desc),
None => label.to_string(),
}
}
fn write_card_frontmatter(
out: &mut String,
card: &CardSchema,
sentinel_line: &str,
description: Option<&str>,
) {
out.push_str("---\n");
if let Some(desc) = description {
for line in desc.lines() {
out.push_str(&format!("# {}\n", line));
}
}
out.push_str(sentinel_line);
out.push('\n');
for (group, fields) in group_fields(card.fields.values()) {
if let Some(name) = group {
out.push_str(&format!("\n# === {} ===\n", name));
}
for field in fields {
write_field(out, field, 0);
}
}
out.push_str("---\n");
}
fn group_fields<'a, I: IntoIterator<Item = &'a FieldSchema>>(
fields: I,
) -> Vec<(Option<String>, Vec<&'a FieldSchema>)> {
let mut sorted: Vec<&FieldSchema> = fields.into_iter().collect();
sorted.sort_by_key(|f| ui_order(f));
let mut groups: Vec<(Option<String>, Vec<&FieldSchema>)> = Vec::new();
for field in sorted {
let group = field
.ui
.as_ref()
.and_then(|u| u.group.as_ref())
.map(|s| s.to_string());
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
}
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;
}
}
}
}
write_field_comments(out, field, &pad);
write_example_comment(out, field, &pad);
let comment = match type_annotation(&field.r#type) {
Some(hint) => format!(" # {}", hint),
None => String::new(),
};
let value = field_value(field);
write_value(out, &field.name, &value, &comment, &pad);
}
fn write_field_comments(out: &mut String, field: &FieldSchema, pad: &str) {
if let Some(desc) = &field.description {
let clean = desc.split_whitespace().collect::<Vec<_>>().join(" ");
out.push_str(&format!("{}# {}\n", pad, clean));
}
if field.required {
out.push_str(&format!("{}# required\n", pad));
}
if let Some(vals) = &field.enum_values {
out.push_str(&format!("{}# enum: {}\n", pad, vals.join(" | ")));
}
}
fn write_example_comment(out: &mut String, field: &FieldSchema, pad: &str) {
if !field.required && field.enum_values.is_none() {
if let Some(eg) = field.example.as_ref().map(eg_hint) {
out.push_str(&format!("{}# example: {}\n", pad, eg));
}
}
}
fn ui_order(f: &FieldSchema) -> i32 {
f.ui.as_ref().and_then(|u| u.order).unwrap_or(i32::MAX)
}
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| ui_order(f));
v
}
fn write_typed_table_field(
out: &mut String,
field: &FieldSchema,
item_props: &BTreeMap<String, Box<FieldSchema>>,
indent: usize,
) {
let pad = " ".repeat(indent);
write_field_comments(out, field, &pad);
out.push_str(&format!("{}{}:\n", pad, field.name));
let concrete_rows = field
.example
.as_ref()
.or(field.default.as_ref())
.and_then(|v| match v.as_json() {
serde_json::Value::Array(items) if !items.is_empty() => Some(items.clone()),
_ => None,
});
match concrete_rows {
Some(items) => write_array_items(out, &items, &pad),
None => {
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);
}
}
}
}
enum FieldValue {
Inline(String), Block(Vec<serde_json::Value>), }
fn field_value(field: &FieldSchema) -> FieldValue {
if field.required {
if let Some(v) = field.example.as_ref().or(field.default.as_ref()) {
return json_to_value(v.as_json());
}
placeholder(&field.r#type, Some(&field.name))
} else {
if let Some(v) = field.default.as_ref() {
return json_to_value(v.as_json());
}
if let Some(first) = field.enum_values.as_ref().and_then(|v| v.first()) {
return FieldValue::Inline(first.clone());
}
placeholder(&field.r#type, None)
}
}
fn placeholder(t: &FieldType, label: Option<&str>) -> FieldValue {
match t {
FieldType::Array => FieldValue::Inline("[]".into()),
FieldType::Boolean => FieldValue::Inline("false".into()),
FieldType::Number | FieldType::Integer => FieldValue::Inline("0".into()),
FieldType::Date | FieldType::DateTime => FieldValue::Inline("\"\"".into()),
_ => FieldValue::Inline(match label {
Some(name) => format!("\"<{}>\"", name),
None => "\"\"".into(),
}),
}
}
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 type_annotation(t: &FieldType) -> Option<&'static str> {
match t {
FieldType::Number => Some("number"),
FieldType::Integer => Some("integer"),
FieldType::Boolean => Some("boolean"),
FieldType::Markdown => Some("markdown"),
FieldType::Object => Some("object"),
FieldType::Date => Some("YYYY-MM-DD"),
FieldType::DateTime => Some("ISO 8601"),
FieldType::String | FieldType::Array => None,
}
}
fn eg_hint(example: &QuillValue) -> String {
match example.as_json() {
serde_json::Value::Array(items) => {
let parts: Vec<String> = items.iter().map(render_scalar_flow).collect();
format!("[{}]", parts.join(", "))
}
val => render_scalar(val),
}
}
fn render_scalar(val: &serde_json::Value) -> String {
match val {
serde_json::Value::String(s) => yaml_string(s),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Null => "null".to_string(),
other => yaml_string(&other.to_string()),
}
}
fn render_scalar_flow(val: &serde_json::Value) -> String {
match val {
serde_json::Value::String(s) => yaml_string_flow(s),
other => render_scalar(other),
}
}
fn yaml_string(s: &str) -> String {
let needs_quotes = s.is_empty()
|| matches!(s, "true" | "false" | "null" | "yes" | "no" | "on" | "off")
|| s.starts_with(|c: char| {
matches!(
c,
'{' | '[' | '&' | '*' | '!' | '|' | '>' | '\'' | '"' | '%' | '@' | '`'
)
})
|| s.contains(": ")
|| s.contains(" #")
|| s.starts_with("- ")
|| s.starts_with('#');
if needs_quotes {
quote(s)
} else {
s.to_string()
}
}
fn yaml_string_flow(s: &str) -> String {
if s.contains([',', '[', ']', '{', '}']) {
quote(s)
} else {
yaml_string(s)
}
}
fn quote(s: &str) -> String {
let mut out = String::new();
emit_double_quoted(&mut out, s);
out
}
#[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 required_string_without_example_uses_angle_bracket_placeholder() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
author: { type: string, required: true }
"#)
.blueprint();
assert!(t.contains("# required\nauthor: \"<author>\"\n"));
}
#[test]
fn required_field_uses_example_over_default() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
status: { type: string, required: true, default: draft, example: final }
"#)
.blueprint();
assert!(t.contains("# required\nstatus: final\n"));
}
#[test]
fn optional_field_uses_default_example_becomes_eg() {
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("# example: CONFIDENTIAL\nclassification: \"\"\n"));
}
#[test]
fn optional_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
example:
- Mr. John Doe
- 123 Main St
- "Anytown, USA"
"#)
.blueprint();
assert!(
t.contains("# example: [Mr. John Doe, 123 Main St, \"Anytown, USA\"]\nrecipient: []\n")
);
}
#[test]
fn enum_field_shows_values_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("# enum: standard | informal\nformat: standard\n"));
assert!(!t.contains("example:"));
}
#[test]
fn required_array_uses_example_as_items() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
memo_from:
type: array
required: true
example:
- ORG/SYMBOL
- City ST 12345
"#)
.blueprint();
assert!(t.contains("# required\nmemo_from:\n - ORG/SYMBOL\n - City ST 12345\n"));
}
#[test]
fn description_emitted_as_preceding_comment() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
subject:
type: string
required: true
description: Be brief and clear.
"#)
.blueprint();
assert!(t.contains("# Be brief and clear.\n# required\nsubject: \"<subject>\"\n"));
}
#[test]
fn non_obvious_types_get_annotation() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
size: { type: number, default: 11 }
flag: { type: boolean, default: false }
body: { type: markdown }
issued: { type: date }
"#)
.blueprint();
assert!(t.contains("size: 11 # number"));
assert!(t.contains("flag: false # boolean"));
assert!(t.contains("body: \"\" # markdown"));
assert!(t.contains("issued: \"\" # YYYY-MM-DD"));
}
#[test]
fn card_description_emitted_after_sentinel() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
title: { type: string }
card_types:
note:
description: A short note appended to the document.
fields:
author: { type: string }
"#)
.blueprint();
assert!(t.contains(
"# A short note appended to the document.\nCARD: note # sentinel, composable (0..N)\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_types:
skills:
body: { enabled: false }
fields:
items: { type: array, required: true }
"#)
.blueprint();
let after = &t[t.find("CARD: skills").unwrap()..];
assert!(!after.contains("skills body"));
}
#[test]
fn body_description_appears_in_placeholder() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
title: { type: string }
card_types:
note:
body:
description: Write your note here
fields:
author: { type: string }
"#)
.blueprint();
let after = &t[t.find("CARD: note").unwrap()..];
assert!(after.contains("\nnote body \u{2014} Write your note here\n"));
assert!(!after.contains("\nnote body\n"));
}
#[test]
fn main_body_description_appears_in_placeholder() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
body:
description: Write the letter body here
fields:
to: { type: string }
"#)
.blueprint();
assert!(t.contains("\nmain body \u{2014} Write the letter body here\n"));
assert!(!t.contains("\nmain body\n"));
}
#[test]
fn sentinel_and_body_present() {
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# x\nQUILL: taro@0.1.0 # sentinel\n"));
assert!(t.contains("\nmain body\n"));
}
#[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_types:
indorsement:
fields:
from: { type: string }
"#)
.blueprint();
assert!(t.contains("\nindorsement body\n"));
}
#[test]
fn ui_groups_emit_section_banners_in_first_appearance_order() {
let t = cfg(r#"
quill: { name: x, version: 1.0.0, backend: typst, description: x }
main:
fields:
memo_for: { type: array, required: true, ui: { group: Addressing } }
subject: { type: string, required: true, ui: { group: Addressing } }
letterhead_title: { type: string, default: HQ, ui: { group: Letterhead } }
notes: { type: string }
"#)
.blueprint();
let after_quill = &t[t.find("QUILL:").unwrap()..];
let addressing = after_quill.find("# === Addressing ===").unwrap();
let letterhead = after_quill.find("# === Letterhead ===").unwrap();
let notes = after_quill.find("notes:").unwrap();
assert!(notes < addressing);
assert!(addressing < letterhead);
assert!(!after_quill[..notes].contains("# ==="));
}
#[test]
fn typed_table_emits_synthetic_row_when_no_example() {
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, required: true, description: Citing organization. }
year: { type: integer, description: Publication year. }
"#)
.blueprint();
assert!(t.contains("# Cited works.\nreferences:\n -\n"));
assert!(t.contains(" # Citing organization.\n # required\n org: \"<org>\"\n"));
assert!(t.contains(" # Publication year.\n year: 0 # integer\n"));
}
#[test]
fn typed_table_with_example_renders_example_rows() {
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, required: true }
year: { type: integer }
"#)
.blueprint();
assert!(t.contains("refs:\n - org: ACME\n"));
assert!(!t.contains("refs:\n -\n"));
assert!(!t.contains("# example:"));
}
#[test]
fn typed_table_with_default_renders_default_rows() {
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, required: true }
"#)
.blueprint();
assert!(t.contains("refs:\n - org: ACME\n"));
assert!(!t.contains("refs:\n -\n"));
}
#[test]
fn typed_table_with_empty_default_falls_through_to_synthetic_row() {
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, required: true }
"#)
.blueprint();
assert!(t.contains("refs:\n -\n # required\n org: \"<org>\"\n"));
}
const LETTER_QUILL: &str = r#"
quill: { name: letter, version: 1.0.0, backend: typst, description: A formal letter. }
main:
fields:
to:
type: string
required: true
description: Recipient name.
subject:
type: string
required: true
date:
type: date
priority:
type: string
enum: [normal, urgent]
default: normal
attachments:
type: array
example:
- report.pdf
card_types:
enclosure:
description: An enclosure attached to the letter.
fields:
label: { type: string, required: true }
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"
);
}
}