Skip to main content

quillmark_core/quill/
blueprint.rs

1//! Auto-generated Markdown blueprint for a Quill.
2//!
3//! Produces an annotated reference document dense enough to replace the schema
4//! for LLM consumers. The blueprint shows the document's shape — fields,
5//! constraints, examples — so a consumer can write a fresh document from it.
6//!
7//! Annotation grammar:
8//! - **Leading `# …` lines** carry prose: `# <description>` (single line,
9//!   whitespace-collapsed) and `# e.g. <value>` (whenever an `example:` is
10//!   configured, regardless of role or type).
11//! - **Inline `# …` annotation** on the value line is structural:
12//!   `# <type>[<format>]; <role>[, <extras>...]`. Type is mandatory on every
13//!   field. Format slot uses angle brackets (`array<string>`,
14//!   `date<YYYY-MM-DD>`, `enum<a | b | c>`). Role is `required`, `optional`,
15//!   or `composable (0..N)` for leaves. The QUILL sentinel adds a `verbatim`
16//!   extra signaling that the value must not be modified.
17//! - **Body regions** are signalled by `Write main body here.` after the main
18//!   fence and `Write <leaf name> body here.` after each leaf fence. When
19//!   `body.example` is set, the example text is embedded verbatim instead.
20//!   Absent when `body.enabled` is false.
21//!
22//! `ui.order` controls field ordering. `ui.group` clusters fields together
23//! within the document but emits no banner.
24
25use 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    /// Generate an annotated Markdown blueprint for this quill. See module
33    /// docs for the annotation grammar; the function is total over any valid
34    /// `QuillConfig`.
35    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
104/// Cluster fields by `ui.group` (preserving first-appearance order; ungrouped
105/// fields lead) and sort within each group by `ui.order`. The grouping is
106/// purely positional now — no banner is emitted.
107fn 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    // Typed table: array with a properties map directly on the field.
132    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    // Typed dictionary: standalone object with defined properties.
140    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    // Markdown fields render as a YAML block scalar so multi-line content has
151    // a consistent shape regardless of whether a default is configured.
152    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
172/// `# e.g. <value>` — emitted whenever `example:` is configured on the field.
173/// Independent of role, type, or enum-ness; examples never become rendered
174/// values.
175fn 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    // Render default content if present; otherwise leave one indented blank
185    // line so the block scalar has a body for the author to fill in.
186    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
212/// Emit a typed-table field: description + optional `# e.g.` line, then the
213/// field key with its `array<object>; <role>` inline annotation, then either
214/// example/default rows or a synthetic template row. When concrete rows are
215/// rendered, the `# e.g.` comment is suppressed (the rows themselves carry
216/// the example shape).
217fn 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
254/// Emit a typed-dictionary field: description + optional `# e.g.` line, then the
255/// field key with its `object; <role>` inline annotation, then either a concrete
256/// mapping from example/default or per-property annotations. When concrete values
257/// are rendered, the `# e.g.` comment is suppressed (the mapping itself carries
258/// the example shape).
259fn 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
299/// Build the inline annotation body (without the leading `# `).
300/// `force_array_object` is `true` for typed-table outer fields, which always
301/// renders as `array<object>`; plain arrays render as `array<string>`.
302fn 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
336/// The value to render for a field. Single cascade independent of role:
337/// default → first enum value → type-empty.
338enum 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        // String, markdown, object: empty string. Markdown is special-cased
360        // earlier in `write_field` and never reaches this code path.
361        _ => 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
410/// Format an example value as a compact one-line hint. Arrays render as a YAML
411/// flow sequence (`[a, b, c]`) so multi-element shape information is preserved
412/// without expanding into multiple comment lines.
413fn 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
433/// Render a scalar in YAML flow context — strings containing flow indicators
434/// (`,`, `[`, `]`, `{`, `}`) must be quoted so the surrounding `[…]` parses
435/// as a single item, not a comma-split list.
436fn 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
443/// Quote a YAML string only when necessary in block context.
444fn 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
464/// Quote a YAML string for flow context — adds flow indicators (`,`, `[`, `]`,
465/// `{`, `}`) to the trigger set so flow-sequence items round-trip as single
466/// values.
467fn 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        // Examples never render as values — they always surface in `# e.g.`.
505        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        // Plain (non-typed-table) required arrays render type-empty; the
562        // example surfaces in the leading `# e.g.` line.
563        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        // No banners emitted at all.
758        assert!(!after_quill.contains("===="));
759        // Order: ungrouped first, then groups in first-appearance order.
760        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        // No per-property annotations when concrete values are present.
900        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        // eg comment suppressed when concrete values are rendered.
919        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        // The blueprint declares one leaf kind (`enclosure`). It must survive
957        // parsing — earlier the leaf was emitted as `---/KIND/---`, which the
958        // parser silently dropped into body prose. See LEAF_REWORK.md §3.3.
959        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}