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//! ## One emitter
8//!
9//! `blueprint()` does not format YAML itself. It builds a [`Document`] (the
10//! same typed model a parsed `.md` produces) and emits it through the
11//! canonical [`Document::to_markdown`]. The annotation grammar maps cleanly
12//! onto the document model:
13//!
14//! - **Leading `# …` prose** (`# <description>`, `# e.g. <value>`) becomes
15//!   own-line [`PayloadItem::Comment`]s before the field (top-level) or
16//!   [`NestedComment`]s at the leaf's slot (typed-container properties).
17//! - **Inline `# <type>[<format>]` annotation** becomes the field's *trailing
18//!   inline* comment: a one-space ` # …` trailer on the value line.
19//! - **`!must_fill`** becomes the field's `fill` flag (top-level) or a nested
20//!   fill path on the value tree (per-property leaves).
21//! - The `$quill` / `$kind` lines are the document's typed `$` metadata; the
22//!   `# keep verbatim` reminder rides `$quill` as an inline comment.
23//!
24//! Because emission is shared with the parse/round-trip path, the blueprint
25//! round-trips through `Document::parse` and back *by construction*:
26//! there is no second formatter to keep in sync.
27//!
28//! ## Rendering choices that follow from sharing `to_markdown`
29//!
30//! - **Richtext fields** carry no block scalar. An Unendorsed richtext field
31//!   is a bare `field: !must_fill # richtext<markdown>`; an Endorsed one renders
32//!   its default as an inline (double-quoted, `\n`-escaped) string. `to_markdown`
33//!   emits no `|`/`>` block forms, so neither does the blueprint.
34//! - **Arrays** render in block style at every level; including an
35//!   Unendorsed array's `example`, which rides the `!must_fill` marker as
36//!   block items rather than an inline flow sequence.
37//! - **Typed dictionaries** with `default: {}` expand to the field's
38//!   zero-filled shape (every key present, type-empty value, all unmarked),
39//!   so an empty endorsed object shows its structure instead of a bare `{}`.
40//!   A *non-empty* partial default is rendered verbatim (a deliberate
41//!   "already handled" signal); only `{}` expands.
42//!
43//! Declaration order controls field ordering. `ui.group` clusters fields
44//! together within the document but emits no banner.
45
46use indexmap::IndexMap;
47
48use super::{zero_value, CardSchema, FieldSchema, FieldType, QuillConfig};
49use crate::document::emit::{saphyr_emit_flow, saphyr_emit_scalar};
50use crate::document::prescan::NestedComment;
51use crate::document::{Card, Document, Payload, PayloadItem};
52use crate::value::{PathSegment, QuillValue};
53use serde_json::{Map as JsonMap, Value as JsonValue};
54
55impl QuillConfig {
56    /// Generate the canonical annotated Markdown blueprint for this quill:
57    /// the authoring surface handed to LLMs and humans, with Unendorsed cells
58    /// carrying the `!must_fill` marker. See module docs for the annotation
59    /// grammar; the function is total over any valid `QuillConfig`.
60    ///
61    /// The "filled-out" twin of the blueprint is **seeding**
62    /// ([`Quill::seed_document`](crate::Quill::seed_document)), a committed
63    /// [`Document`] rather than an annotated string. See
64    /// `prose/canon/BLUEPRINT.md`.
65    ///
66    /// The result is guaranteed schema-valid and parseable (every key
67    /// present, every value type-correct). It is *not* guaranteed to render:
68    /// that is the quill authoring contract on `plate.typ`; see
69    /// `prose/canon/BLUEPRINT.md` §Guarantees.
70    ///
71    /// [`Document`]: crate::Document
72    pub fn blueprint(&self) -> String {
73        let main_desc = self
74            .main
75            .description
76            .as_deref()
77            .filter(|s| !s.is_empty())
78            .or_else(|| Some(self.description.as_str()).filter(|s| !s.is_empty()));
79
80        let main = build_main_card(
81            &self.main,
82            &format!("{}@{}", self.name, self.version),
83            main_desc,
84        );
85        let cards = self.card_kinds.iter().map(build_card).collect();
86
87        Document::from_main_and_cards(main, cards).to_markdown()
88    }
89}
90
91/// Whitespace-collapse a description into a single line; `None` when it
92/// collapses to empty.
93fn collapse(text: &str) -> String {
94    text.split_whitespace().collect::<Vec<_>>().join(" ")
95}
96
97fn collapse_opt(text: &Option<String>) -> Option<String> {
98    text.as_deref()
99        .map(collapse)
100        .filter(|clean| !clean.is_empty())
101}
102
103/// The text after `# ` for a body region (`Write … here.` placeholder or the
104/// configured `body.example`), wrapped so `to_markdown` emits it verbatim
105/// after the closing fence. Empty when the card has no body.
106fn body_text(card: &CardSchema, fallback_kind: &str) -> String {
107    if !card.body_enabled() {
108        return String::new();
109    }
110    let example = card.body.as_ref().and_then(|b| b.example.as_deref());
111    let fallback = format!("Write {} body here.", fallback_kind);
112    let text = example.unwrap_or(fallback.as_str());
113    format!("\n{}\n", text)
114}
115
116/// Build the root card: `$quill` (with the `# keep verbatim` inline reminder),
117/// `$kind: main`, the optional description own-line comment, then the fields.
118fn build_main_card(card: &CardSchema, quill_ref: &str, description: Option<&str>) -> Card {
119    let reference = quill_ref
120        .parse()
121        .expect("quill name@version is always a valid QuillReference");
122    let mut items = vec![
123        PayloadItem::Quill { reference },
124        PayloadItem::comment_inline("keep verbatim"),
125        PayloadItem::Kind {
126            value: "main".into(),
127        },
128    ];
129    if let Some(desc) = description {
130        items.push(PayloadItem::comment(collapse(desc)));
131    }
132    append_fields(&mut items, card);
133    Card::from_parts(
134        Payload::from_items(items),
135        // The blueprint's output *is* the markdown surface, so it imports the
136        // body text (a trusted example or a generated placeholder) here and
137        // re-emits it via `to_markdown`. The empty-content fallback is defensive:
138        // a placeholder or a load-validated example never over-nests.
139        crate::document::import_body(&body_text(card, "main"))
140            .unwrap_or_else(|_| quillmark_content::Content::empty()),
141    )
142}
143
144/// Build a composable card: `$kind: <kind>`, the `composable (0..N)` role
145/// comment, the optional description, then the fields.
146fn build_card(card: &CardSchema) -> Card {
147    let mut items = vec![
148        PayloadItem::Kind {
149            value: card.name.clone(),
150        },
151        PayloadItem::comment("composable (0..N)"),
152    ];
153    if let Some(desc) = collapse_opt(&card.description) {
154        items.push(PayloadItem::comment(desc));
155    }
156    append_fields(&mut items, card);
157    Card::from_parts(
158        Payload::from_items(items),
159        crate::document::import_body(&body_text(card, &card.name))
160            .unwrap_or_else(|_| quillmark_content::Content::empty()),
161    )
162}
163
164/// Append every field of a card as payload items, clustered by `ui.group` and
165/// ordered by declaration order. Group order follows the card's `ui.groups`
166/// registry when present.
167fn append_fields(items: &mut Vec<PayloadItem>, card: &CardSchema) {
168    let registry: Option<Vec<&str>> = card
169        .ui
170        .as_ref()
171        .and_then(|u| u.groups.as_ref())
172        .map(|r| r.0.iter().map(|g| g.id.as_str()).collect());
173    for field in group_fields(card.fields.values(), registry.as_deref()) {
174        append_field(items, field);
175    }
176}
177
178/// Order fields by `ui.group` (ungrouped lead, then grouped clusters),
179/// preserving declaration order within each cluster (`fields` arrives in
180/// declaration order and the clustering is stable). Grouped clusters follow
181/// the `registry` declaration order when a registry is present, else first-
182/// appearance order (the deprecated implicit-group fallback); for a migrated
183/// quill the two are identical. Grouping is purely positional (no banner); the
184/// clusters are flattened into a single field stream.
185fn group_fields<'a, I: IntoIterator<Item = &'a FieldSchema>>(
186    fields: I,
187    registry: Option<&[&str]>,
188) -> Vec<&'a FieldSchema> {
189    let mut groups: Vec<(Option<&str>, Vec<&FieldSchema>)> = Vec::new();
190    for field in fields {
191        let group = field.ui.as_ref().and_then(|u| u.group.as_deref());
192        match groups.iter_mut().find(|(g, _)| *g == group) {
193            Some(slot) => slot.1.push(field),
194            None => groups.push((group, vec![field])),
195        }
196    }
197    // Ungrouped is the implicit leading pseudo-group (rank 0). Grouped clusters
198    // sort by registry position shifted past it (`pos + 1`); with no registry
199    // every group ties at `usize::MAX` and the stable sort preserves first
200    // appearance.
201    groups.sort_by_key(|(g, _)| match g {
202        None => 0,
203        Some(id) => registry
204            .and_then(|order| order.iter().position(|o| o == id))
205            .map(|pos| pos + 1)
206            .unwrap_or(usize::MAX),
207    });
208    groups.into_iter().flat_map(|(_, fields)| fields).collect()
209}
210
211/// Append one top-level field. Dispatches typed tables (`array<object>`) and
212/// typed dictionaries (`object` with `properties`) to their per-property
213/// builders; everything else is a scalar/array cell.
214fn append_field(items: &mut Vec<PayloadItem>, field: &FieldSchema) {
215    // Typed table: an array whose element is an object with properties.
216    if matches!(field.r#type, FieldType::Array) {
217        if let Some(elem) = &field.items {
218            if matches!(elem.r#type, FieldType::Object) {
219                if let Some(props) = &elem.properties {
220                    append_typed_table(items, field, props);
221                    return;
222                }
223            }
224        }
225    }
226
227    // Typed dictionary: standalone object with defined properties.
228    if matches!(field.r#type, FieldType::Object) {
229        if let Some(props) = &field.properties {
230            append_typed_dict(items, field, props);
231            return;
232        }
233    }
234
235    append_scalar(items, field);
236}
237
238/// Push the leading prose comments for a *top-level* field: the description,
239/// then the `# e.g.` hint. `eg_when` gates the hint: scalars surface it only
240/// when Endorsed (an Unendorsed example inlines as the marker value instead),
241/// while typed containers always surface it (their example never inlines).
242fn push_leading(items: &mut Vec<PayloadItem>, field: &FieldSchema, eg_when: bool) {
243    if let Some(desc) = collapse_opt(&field.description) {
244        items.push(PayloadItem::comment(desc));
245    }
246    if eg_when {
247        if let Some(eg) = field.example.as_ref() {
248            items.push(PayloadItem::comment(format!("e.g. {}", eg_hint(eg))));
249        }
250    }
251}
252
253/// The cell's `(value, fill)` for a scalar/array/richtext leaf, per the value
254/// cascade. Endorsed → the default, no marker. Unendorsed → the `example` (when
255/// present) carried by the marker, else a bare null marker. A richtext leaf never
256/// inlines an example: an Unendorsed richtext leaf is always a bare marker, but
257/// its `example:` still surfaces as a `# e.g.` hint (see `append_scalar`).
258fn scalar_cell(field: &FieldSchema) -> (JsonValue, bool) {
259    if let Some(default) = &field.default {
260        return (default.as_json().clone(), false);
261    }
262    if matches!(field.r#type, FieldType::RichText { .. }) {
263        return (JsonValue::Null, true);
264    }
265    match field.example.as_ref() {
266        Some(eg) => (eg.as_json().clone(), true),
267        None => (JsonValue::Null, true),
268    }
269}
270
271/// Append a scalar / scalar-array / richtext field as a single payload field
272/// plus its trailing inline type annotation.
273fn append_scalar(items: &mut Vec<PayloadItem>, field: &FieldSchema) {
274    // A richtext field never inlines its `example:` as the marker value, so
275    // (unlike other Unendorsed scalars) the example would vanish entirely.
276    // Surface it as a `# e.g.` hint instead (no-ops when no `example:` is set).
277    let eg_when = field.default.is_some() || matches!(field.r#type, FieldType::RichText { .. });
278    push_leading(items, field, eg_when);
279    let (json, fill) = scalar_cell(field);
280    items.push(PayloadItem::Field {
281        key: field.name.clone(),
282        value: QuillValue::from_json(json),
283        fill,
284        nested_comments: Vec::new(),
285    });
286    items.push(PayloadItem::comment_inline(type_expression(field)));
287}
288
289/// Build the per-property body of an Unendorsed typed container: the value
290/// mapping (each property at its own cell, in declaration order), the nested
291/// comments (description + `# e.g.` + inline type annotation, addressed by
292/// `container_path`/slot), and the nested fill paths. `prefix` is the container
293/// path of the mapping relative to the field value (`[]` for a typed dict,
294/// `[Index(0)]` for a typed table's synthetic row).
295fn build_property_mapping(
296    props: &IndexMap<String, Box<FieldSchema>>,
297    prefix: &[PathSegment],
298) -> (
299    JsonMap<String, JsonValue>,
300    Vec<NestedComment>,
301    Vec<Vec<PathSegment>>,
302) {
303    let mut map = JsonMap::new();
304    let mut nested = Vec::new();
305    let mut fills = Vec::new();
306    for (slot, prop) in props.values().map(|b| b.as_ref()).enumerate() {
307        if let Some(desc) = collapse_opt(&prop.description) {
308            nested.push(NestedComment {
309                container_path: prefix.to_vec(),
310                position: slot,
311                text: desc,
312                inline: false,
313            });
314        }
315        // `# e.g.` only when Endorsed (see `push_leading`).
316        if prop.default.is_some() {
317            if let Some(eg) = prop.example.as_ref() {
318                nested.push(NestedComment {
319                    container_path: prefix.to_vec(),
320                    position: slot,
321                    text: format!("e.g. {}", eg_hint(eg)),
322                    inline: false,
323                });
324            }
325        }
326        let (json, fill) = scalar_cell(prop);
327        map.insert(prop.name.clone(), json);
328        if fill {
329            let mut path = prefix.to_vec();
330            path.push(PathSegment::Key(prop.name.clone()));
331            fills.push(path);
332        }
333        nested.push(NestedComment {
334            container_path: prefix.to_vec(),
335            position: slot,
336            text: type_expression(prop),
337            inline: true,
338        });
339    }
340    (map, nested, fills)
341}
342
343/// Append a typed-dictionary field (`object` with `properties`). Endorsed:
344/// render the default mapping (`{}` expands to the zero-filled shape; a
345/// non-empty partial default is rendered verbatim, all unmarked). Unendorsed:
346/// recurse per property with leaf-level markers and annotations; the container
347/// key itself is untagged.
348fn append_typed_dict(
349    items: &mut Vec<PayloadItem>,
350    field: &FieldSchema,
351    props: &IndexMap<String, Box<FieldSchema>>,
352) {
353    push_leading(items, field, true);
354
355    let (value, nested, fills) = match field.default.as_ref().map(|d| d.as_json()) {
356        // `default: {}` → expand to the field's zero-filled shape so every key
357        // is shown; all leaves are Endorsed-by-the-container, hence unmarked
358        // and unannotated (uniform with a concrete default).
359        Some(JsonValue::Object(map)) if map.is_empty() => {
360            (zero_value(field).into_json(), Vec::new(), Vec::new())
361        }
362        // Concrete default (object or otherwise) → rendered verbatim, unmarked.
363        Some(default) => (default.clone(), Vec::new(), Vec::new()),
364        // Unendorsed → per-property recursion at the mapping root.
365        None => {
366            let (map, nested, fills) = build_property_mapping(props, &[]);
367            (JsonValue::Object(map), nested, fills)
368        }
369    };
370
371    push_container_field(items, &field.name, value, nested, fills, field);
372}
373
374/// Append a typed-table field (`array<object>`). Endorsed: render the default
375/// rows verbatim (including `default: []`, which stays inline `[]`: arrays do
376/// not expand). Unendorsed: emit one synthetic row carrying each property's
377/// leaf-level marker and annotation; the container key itself is untagged.
378fn append_typed_table(
379    items: &mut Vec<PayloadItem>,
380    field: &FieldSchema,
381    item_props: &IndexMap<String, Box<FieldSchema>>,
382) {
383    push_leading(items, field, true);
384
385    let (value, nested, fills) = match field.default.as_ref().map(|d| d.as_json()) {
386        // Any default (including `[]`) is shippable as-is, rendered verbatim.
387        Some(default) => (default.clone(), Vec::new(), Vec::new()),
388        // Row type declares no properties (schema-invalid in practice): emit a
389        // type-valid empty array rather than a null synthetic row.
390        None if item_props.is_empty() => (JsonValue::Array(Vec::new()), Vec::new(), Vec::new()),
391        // Unendorsed → one synthetic row, per-property markers at `[Index(0)]`.
392        None => {
393            let (row, nested, fills) = build_property_mapping(item_props, &[PathSegment::Index(0)]);
394            (
395                JsonValue::Array(vec![JsonValue::Object(row)]),
396                nested,
397                fills,
398            )
399        }
400    };
401
402    push_container_field(items, &field.name, value, nested, fills, field);
403}
404
405/// Push a typed-container field (value + nested comments + nested fills) and
406/// its trailing inline type annotation. The top-level `fill` flag is always
407/// `false`: typed containers are tagged on their leaves, never the container.
408fn push_container_field(
409    items: &mut Vec<PayloadItem>,
410    key: &str,
411    value: JsonValue,
412    nested_comments: Vec<NestedComment>,
413    fills: Vec<Vec<PathSegment>>,
414    field: &FieldSchema,
415) {
416    let mut quill_value = QuillValue::from_json(value);
417    for path in &fills {
418        quill_value.set_fill_at(path);
419    }
420    items.push(PayloadItem::Field {
421        key: key.to_string(),
422        value: quill_value,
423        fill: false,
424        nested_comments,
425    });
426    items.push(PayloadItem::comment_inline(type_expression(field)));
427}
428
429/// Build the inline annotation body (without the leading `# `): purely the
430/// structural type expression `<type>[<format>]`. Shippability is carried by
431/// the value cell alone (a concrete value is shippable as-is, a `!must_fill`
432/// marker asks to be filled) so the annotation needs no cell-state tag.
433fn type_expression(field: &FieldSchema) -> String {
434    if let Some(values) = &field.enum_values {
435        return format!("enum<{}>", values.join(" | "));
436    }
437    match field.r#type {
438        FieldType::String => "string".into(),
439        FieldType::Number => "number".into(),
440        FieldType::Integer => "integer".into(),
441        FieldType::Boolean => "boolean".into(),
442        FieldType::Object => "object".into(),
443        // The type names the role; the `<markdown>` format slot names the
444        // surface encoding an author writes (and `to_markdown` re-emits).
445        FieldType::RichText { inline: false } => "richtext<markdown>".into(),
446        FieldType::RichText { inline: true } => "richtext(inline)<markdown>".into(),
447        // The `<plain>` format slot names the literal codec (`from_plaintext`/
448        // `to_plaintext`): content the author navigates but which takes no
449        // markup, distinct from richtext's `<markdown>` surface.
450        FieldType::PlainText { inline: false } => "plaintext<plain>".into(),
451        FieldType::PlainText { inline: true } => "plaintext(inline)<plain>".into(),
452        // `enum` fields always carry `enum_values`, so the early return above
453        // handles them; this arm is the defensive fallback for a valueless enum.
454        FieldType::Enum => "enum".into(),
455        FieldType::Date => "date<YYYY-MM-DD>".into(),
456        FieldType::DateTime => "datetime<YYYY-MM-DDThh:mm[:ss]>".into(),
457        // The element type comes from `items`; a scalar element gives
458        // `array<string>`/`array<integer>`/`array<markdown>`, an object
459        // element gives `array<object>`.
460        FieldType::Array => {
461            let item = field
462                .items
463                .as_ref()
464                .map(|it| type_expression(it))
465                .unwrap_or_else(|| "string".into());
466            format!("array<{}>", item)
467        }
468    }
469}
470
471/// Format an example value as a compact one-line hint. Arrays and objects
472/// render as YAML flow collections (`[a, b, c]`, `{k: v}`) so multi-element
473/// shape information is preserved without expanding into multiple comment
474/// lines.
475fn eg_hint(example: &QuillValue) -> String {
476    match example.as_json() {
477        v @ (serde_json::Value::Array(_) | serde_json::Value::Object(_)) => saphyr_emit_flow(v),
478        val => saphyr_emit_scalar(val),
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use crate::quill::QuillConfig;
485    use crate::Document;
486
487    fn cfg(yaml: &str) -> QuillConfig {
488        QuillConfig::from_yaml(yaml).expect("valid yaml")
489    }
490
491    #[test]
492    fn must_fill_markdown_example_surfaces_as_eg_hint_not_inline_value() {
493        // Markdown never inlines its example as the marker value, but the
494        // `example:` must still surface as a `# e.g.` hint.
495        let t = cfg(r#"
496quill: { name: x, version: 1.0.0, backend: typst, description: x }
497main:
498  fields:
499    bio: { type: richtext, example: "Hello world" }
500"#)
501        .blueprint();
502        assert!(t.contains("# e.g. Hello world\nbio: !must_fill # richtext<markdown>\n"));
503    }
504
505    #[test]
506    fn endorsed_field_with_example_does_not_use_example_as_value() {
507        // Examples never render as values: they always surface in `# e.g.`.
508        let t = cfg(r#"
509quill: { name: x, version: 1.0.0, backend: typst, description: x }
510main:
511  fields:
512    status: { type: string, default: draft, example: final }
513"#)
514        .blueprint();
515        assert!(t.contains("# e.g. final\nstatus: draft # string\n"));
516    }
517
518    #[test]
519    fn endorsed_empty_default_renders_value_and_eg_line() {
520        let t = cfg(r#"
521quill: { name: x, version: 1.0.0, backend: typst, description: x }
522main:
523  fields:
524    classification: { type: string, default: "", example: CONFIDENTIAL }
525"#)
526        .blueprint();
527        assert!(t.contains("# e.g. CONFIDENTIAL\nclassification: \"\" # string\n"));
528    }
529
530    #[test]
531    fn must_fill_array_example_renders_as_block_sequence_with_context_quoting() {
532        let t = cfg(r#"
533quill: { name: x, version: 1.0.0, backend: typst, description: x }
534main:
535  fields:
536    recipient:
537      type: array
538      items: { type: string }
539      example:
540        - Mr. John Doe
541        - 123 Main St
542        - "Anytown, USA"
543"#)
544        .blueprint();
545        // Unendorsed field with an example: the example rides the `!must_fill`
546        // marker as block-style items (no inline flow), so no separate `# e.g.`.
547        assert!(t.contains(
548            "recipient: !must_fill # array<string>\n  - Mr. John Doe\n  - 123 Main St\n  - Anytown, USA\n"
549        ));
550        assert!(!t.contains("# e.g."));
551    }
552
553    #[test]
554    fn enum_endorsed_uses_enum_format_slot_and_no_eg() {
555        let t = cfg(r#"
556quill: { name: x, version: 1.0.0, backend: typst, description: x }
557main:
558  fields:
559    format: { type: string, enum: [standard, informal], default: standard }
560"#)
561        .blueprint();
562        assert!(t.contains("format: standard # enum<standard | informal>\n"));
563        assert!(!t.contains("e.g."));
564    }
565
566    #[test]
567    fn enum_must_fill_renders_bare_marker() {
568        // An enum field with no `default:` renders `!must_fill` rather than
569        // the first enum value: the cell is Unendorsed regardless.
570        let t = cfg(r#"
571quill: { name: x, version: 1.0.0, backend: typst, description: x }
572main:
573  fields:
574    severity: { type: string, enum: [low, medium, high] }
575"#)
576        .blueprint();
577        assert!(t.contains("severity: !must_fill # enum<low | medium | high>\n"));
578    }
579
580    #[test]
581    fn description_emitted_as_single_line() {
582        let t = cfg(r#"
583quill: { name: x, version: 1.0.0, backend: typst, description: x }
584main:
585  fields:
586    subject:
587      type: string
588      description: Be brief and clear.
589"#)
590        .blueprint();
591        assert!(t.contains("# Be brief and clear.\nsubject: !must_fill # string\n"));
592    }
593
594    #[test]
595    fn every_field_carries_inline_type_and_cell_signal() {
596        // Endorsed cells render a concrete value; Unendorsed cells carry the
597        // `!must_fill` marker on the value line.
598        let t = cfg(r#"
599quill: { name: x, version: 1.0.0, backend: typst, description: x }
600main:
601  fields:
602    title: { type: string }
603    size: { type: number, default: 11 }
604    flag: { type: boolean, default: false }
605    issued: { type: date }
606    published: { type: datetime }
607    refs: { type: array, default: [], items: { type: string } }
608"#)
609        .blueprint();
610        assert!(t.contains("title: !must_fill # string\n"));
611        assert!(t.contains("size: 11 # number\n"));
612        assert!(t.contains("flag: false # boolean\n"));
613        assert!(t.contains("issued: !must_fill # date<YYYY-MM-DD>\n"));
614        assert!(t.contains("published: !must_fill # datetime<YYYY-MM-DDThh:mm[:ss]>\n"));
615        assert!(t.contains("refs: [] # array<string>\n"));
616    }
617
618    #[test]
619    fn scalar_array_annotation_reflects_element_type() {
620        // The element type drives the format slot: `array<integer>`,
621        // `array<markdown>`, … rather than a hardcoded `array<string>`.
622        let t = cfg(r#"
623quill: { name: x, version: 1.0.0, backend: typst, description: x }
624main:
625  fields:
626    counts:   { type: array, items: { type: integer } }
627    sections: { type: array, items: { type: richtext } }
628    tags:     { type: array, items: { type: string } }
629"#)
630        .blueprint();
631        assert!(t.contains("counts: !must_fill # array<integer>\n"), "{t}");
632        assert!(
633            t.contains("sections: !must_fill # array<richtext<markdown>>\n"),
634            "{t}"
635        );
636        assert!(t.contains("tags: !must_fill # array<string>\n"), "{t}");
637    }
638
639    #[test]
640    fn must_fill_markdown_renders_bare_marker() {
641        // Unendorsed markdown → bare `!must_fill` on the value line (no block
642        // scalar). Null ≡ absent, so it zero-fills to an empty body at render.
643        let t = cfg(r#"
644quill: { name: x, version: 1.0.0, backend: typst, description: x }
645main:
646  fields:
647    bio: { type: richtext }
648"#)
649        .blueprint();
650        assert!(t.contains("bio: !must_fill # richtext<markdown>\n"));
651        assert!(!t.contains("|-"));
652    }
653
654    #[test]
655    fn endorsed_empty_markdown_renders_empty_string() {
656        // Endorsed empty markdown default → an inline empty-string cell (no
657        // block scalar): the "skippable" markdown cell, shippable as-is.
658        let t = cfg(r#"
659quill: { name: x, version: 1.0.0, backend: typst, description: x }
660main:
661  fields:
662    bio: { type: richtext, default: "" }
663"#)
664        .blueprint();
665        assert!(t.contains("bio: \"\" # richtext<markdown>\n"));
666        assert!(!t.contains("|-"));
667        assert!(!t.contains("!must_fill"));
668    }
669
670    #[test]
671    fn endorsed_markdown_default_inlines_quoted() {
672        // Endorsed multi-line markdown default → an inline double-quoted scalar
673        // with `\n` escapes (no block scalar): the canonical `to_markdown` form.
674        let t = cfg(r###"
675quill: { name: x, version: 1.0.0, backend: typst, description: x }
676main:
677  fields:
678    bio:
679      type: richtext
680      default: "## About me\n\nHello."
681"###)
682        .blueprint();
683        assert!(t.contains("bio: \"## About me\\n\\nHello.\" # richtext<markdown>\n"));
684        assert!(!t.contains("|-"));
685    }
686
687    #[test]
688    fn root_header_carries_quill_reminder_and_no_role_comment() {
689        let t = cfg(r#"
690quill: { name: taro, version: 0.1.0, backend: typst, description: x }
691main:
692  fields:
693    flavor: { type: string, default: taro }
694"#)
695        .blueprint();
696        // The root `$quill` line carries the inline "keep verbatim" reminder;
697        // `$kind: main` then goes straight to the description with no own-line
698        // role comment (the root has no `composable` cardinality).
699        assert!(t.starts_with("~~~\n$quill: taro@0.1.0 # keep verbatim\n$kind: main\n# x\n"));
700        assert!(t.contains("\nWrite main body here.\n"));
701    }
702
703    #[test]
704    fn card_fence_carries_composable_annotation() {
705        let t = cfg(r#"
706quill: { name: x, version: 1.0.0, backend: typst, description: x }
707main:
708  fields:
709    title: { type: string }
710card_kinds:
711  note:
712    description: A short note appended to the document.
713    fields:
714      author: { type: string }
715"#)
716        .blueprint();
717        assert!(t.contains(
718            "~~~\n$kind: note\n# composable (0..N)\n# A short note appended to the document.\n"
719        ));
720    }
721
722    #[test]
723    fn body_disabled_card_omits_body_placeholder() {
724        let t = cfg(r#"
725quill: { name: x, version: 1.0.0, backend: typst, description: x }
726main:
727  fields:
728    title: { type: string }
729card_kinds:
730  skills:
731    body: { enabled: false }
732    fields:
733      items: { type: array, items: { type: string } }
734"#)
735        .blueprint();
736        let after = &t[t.find("$kind: skills").unwrap()..];
737        assert!(!after.contains("skills body"));
738    }
739
740    #[test]
741    fn body_example_appears_verbatim() {
742        let t = cfg(r#"
743quill: { name: x, version: 1.0.0, backend: typst, description: x }
744main:
745  fields:
746    title: { type: string }
747card_kinds:
748  note:
749    body:
750      example: "This is an example note."
751    fields:
752      author: { type: string }
753"#)
754        .blueprint();
755        let after = &t[t.find("$kind: note").unwrap()..];
756        assert!(after.contains("\nThis is an example note.\n"));
757        assert!(!after.contains("Write note body here."));
758    }
759
760    #[test]
761    fn main_body_example_appears_verbatim() {
762        let t = cfg(r#"
763quill: { name: x, version: 1.0.0, backend: typst, description: x }
764main:
765  body:
766    example: "Dear Sir or Madam,\n\nI am writing to..."
767  fields:
768    to: { type: string }
769"#)
770        .blueprint();
771        assert!(t.contains("\nDear Sir or Madam,\n\nI am writing to...\n"));
772        assert!(!t.contains("Write main body here."));
773    }
774
775    #[test]
776    fn card_body_placeholder_uses_card_name() {
777        let t = cfg(r#"
778quill: { name: x, version: 1.0.0, backend: typst, description: x }
779main:
780  fields:
781    title: { type: string }
782card_kinds:
783  indorsement:
784    fields:
785      from: { type: string }
786"#)
787        .blueprint();
788        assert!(t.contains("\nWrite indorsement body here.\n"));
789    }
790
791    #[test]
792    fn ui_groups_cluster_fields_without_emitting_banner() {
793        let t = cfg(r#"
794quill: { name: x, version: 1.0.0, backend: typst, description: x }
795main:
796  fields:
797    memo_for: { type: array, items: { type: string }, ui: { group: Addressing } }
798    subject: { type: string, ui: { group: Addressing } }
799    letterhead_title: { type: string, default: HQ, ui: { group: Letterhead } }
800    notes: { type: string }
801"#)
802        .blueprint();
803        let after_quill = &t[t.find("$quill:").unwrap()..];
804        // No banners emitted at all.
805        assert!(!after_quill.contains("===="));
806        // Order: ungrouped first, then groups in first-appearance order.
807        let notes = after_quill.find("notes:").unwrap();
808        let memo_for = after_quill.find("memo_for:").unwrap();
809        let letterhead = after_quill.find("letterhead_title:").unwrap();
810        assert!(notes < memo_for);
811        assert!(memo_for < letterhead);
812    }
813
814    #[test]
815    fn typed_table_must_fill_emits_synthetic_row_with_leaf_markers() {
816        // Unendorsed container → outer key untagged (markers live on the leaves).
817        // Property leaves carry their own cell signals.
818        let t = cfg(r#"
819quill: { name: x, version: 1.0.0, backend: typst, description: x }
820main:
821  fields:
822    references:
823      type: array
824      description: Cited works.
825      items:
826        type: object
827        properties:
828          org: { type: string, description: Citing organization. }
829          year: { type: integer, default: 0, description: Publication year. }
830"#)
831        .blueprint();
832        // The first property rides the dash line (matching `to_markdown`), with
833        // its description lifted to the dash indent above it.
834        assert!(t.contains(
835            "# Cited works.\nreferences: # array<object>\n  # Citing organization.\n  - org: !must_fill # string\n"
836        ));
837        assert!(t.contains("    # Publication year.\n    year: 0 # integer\n"));
838    }
839
840    #[test]
841    fn typed_table_with_example_keeps_eg_line_and_synthetic_row() {
842        // Examples never render as rows: they surface only in `# e.g.`,
843        // consistent with every other field type.
844        let t = cfg(r#"
845quill: { name: x, version: 1.0.0, backend: typst, description: x }
846main:
847  fields:
848    refs:
849      type: array
850      example:
851        - { org: ACME, year: 2020 }
852      items:
853        type: object
854        properties:
855          org: { type: string }
856          year: { type: integer, default: 0 }
857"#)
858        .blueprint();
859        assert!(t.contains("# e.g. [{org: ACME, year: 2020}]\n"));
860        assert!(t.contains("refs: # array<object>\n  - org: !must_fill # string\n"));
861        assert!(t.contains("    year: 0 # integer\n"));
862    }
863
864    #[test]
865    fn typed_table_endorsed_renders_default_rows() {
866        let t = cfg(r#"
867quill: { name: x, version: 1.0.0, backend: typst, description: x }
868main:
869  fields:
870    refs:
871      type: array
872      default:
873        - { org: ACME }
874      items:
875        type: object
876        properties:
877          org: { type: string }
878"#)
879        .blueprint();
880        assert!(t.contains("refs: # array<object>\n  - org: ACME\n"));
881        assert!(!t.contains("refs: # array<object>\n  -\n"));
882    }
883
884    #[test]
885    fn typed_table_with_empty_default_renders_inline() {
886        // `default: []` means shippable as-is: the value renders inline as `[]`
887        // (no marker). Inline row shape under an empty default belongs in
888        // `example:`.
889        let t = cfg(r#"
890quill: { name: x, version: 1.0.0, backend: typst, description: x }
891main:
892  fields:
893    refs:
894      type: array
895      default: []
896      items:
897        type: object
898        properties:
899          org: { type: string }
900"#)
901        .blueprint();
902        assert!(
903            t.contains("refs: [] # array<object>\n"),
904            "wrong rendering: {t}"
905        );
906        assert!(!t.contains("!must_fill"), "no markers expected: {t}");
907    }
908
909    #[test]
910    fn typed_dict_with_empty_default_expands_to_zero_filled() {
911        // `default: {}` is Endorsed (the whole object ships as-is) and expands
912        // to the field's zero-filled shape: every key shown with its type-empty
913        // value, all unmarked and unannotated, so the structure is visible
914        // instead of a bare `{}`. (Arrays do not expand; only `{}` does.)
915        let t = cfg(r#"
916quill: { name: x, version: 1.0.0, backend: typst, description: x }
917main:
918  fields:
919    address:
920      type: object
921      default: {}
922      properties:
923        street: { type: string }
924        zip:    { type: integer }
925"#)
926        .blueprint();
927        assert!(
928            t.contains("address: # object\n  street: \"\"\n  zip: 0\n"),
929            "wrong rendering: {t}"
930        );
931        assert!(!t.contains("{}"), "no bare empty object expected: {t}");
932        assert!(!t.contains("!must_fill"), "no markers expected: {t}");
933        // No per-property annotations on the endorsed (expanded) form.
934        assert!(!t.contains("# string"), "no leaf annotations expected: {t}");
935    }
936
937    #[test]
938    fn typed_dict_must_fill_emits_per_property_annotations() {
939        // Unendorsed container → outer key untagged; per-property recursion
940        // with leaf markers.
941        let t = cfg(r#"
942quill: { name: x, version: 1.0.0, backend: typst, description: x }
943main:
944  fields:
945    address:
946      type: object
947      description: Mailing address.
948      properties:
949        street: { type: string, description: Street line. }
950        city:   { type: string }
951        zip:    { type: string, default: "" }
952"#)
953        .blueprint();
954        assert!(t.contains("# Mailing address.\naddress: # object\n"));
955        assert!(t.contains("  # Street line.\n  street: !must_fill # string\n"));
956        assert!(t.contains("  city: !must_fill # string\n"));
957        assert!(t.contains("  zip: \"\" # string\n"));
958    }
959
960    #[test]
961    fn typed_dict_endorsed_renders_block_mapping() {
962        let t = cfg(r#"
963quill: { name: x, version: 1.0.0, backend: typst, description: x }
964main:
965  fields:
966    address:
967      type: object
968      default: { street: "5000 Forbes Ave", city: Pittsburgh }
969      properties:
970        street: { type: string }
971        city:   { type: string }
972"#)
973        .blueprint();
974        assert!(t.contains("address: # object\n"));
975        assert!(
976            t.contains("  street: 5000 Forbes Ave\n")
977                || t.contains("  street: \"5000 Forbes Ave\"\n")
978        );
979        assert!(t.contains("  city: Pittsburgh\n"));
980        // No per-property annotations when concrete values are present.
981        assert!(!t.contains("# string"));
982    }
983
984    #[test]
985    fn typed_dict_with_example_keeps_eg_line_and_per_property() {
986        // Examples never render as a concrete mapping: they surface only in
987        // `# e.g.`, consistent with every other field type.
988        let t = cfg(r#"
989quill: { name: x, version: 1.0.0, backend: typst, description: x }
990main:
991  fields:
992    address:
993      type: object
994      example: { street: "1 Infinite Loop", city: Cupertino }
995      properties:
996        street: { type: string }
997        city:   { type: string, default: "" }
998"#)
999        .blueprint();
1000        assert!(t.contains("address: # object\n"));
1001        assert!(
1002            t.contains("# e.g. {street: 1 Infinite Loop, city: Cupertino}\n")
1003                || t.contains("# e.g. {city: Cupertino, street: 1 Infinite Loop}\n")
1004        );
1005        assert!(t.contains("  street: !must_fill # string\n"));
1006        assert!(t.contains("  city: \"\" # string\n"));
1007    }
1008
1009    const LETTER_QUILL: &str = r#"
1010quill: { name: letter, version: 1.0.0, backend: typst, description: A formal letter. }
1011main:
1012  fields:
1013    to:
1014      type: string
1015      description: Recipient name.
1016    subject:
1017      type: string
1018    date:
1019      type: datetime
1020    priority:
1021      type: string
1022      enum: [normal, urgent]
1023      default: normal
1024    attachments:
1025      type: array
1026      items: { type: string }
1027      default: []
1028      example:
1029        - report.pdf
1030card_kinds:
1031  enclosure:
1032    description: An enclosure attached to the letter.
1033    fields:
1034      label: { type: string }
1035      pages: { type: integer, default: 1 }
1036"#;
1037
1038    #[test]
1039    fn typed_table_synthetic_row_blueprint_round_trips() {
1040        // Regression: an Unendorsed typed-table synthetic row emits the first
1041        // property on the dash line (canonical `to_markdown` shape), so the
1042        // generated blueprint round-trips idempotently.
1043        let bp = cfg(r#"
1044quill: { name: x, version: 1.0.0, backend: typst, description: x }
1045main:
1046  fields:
1047    refs:
1048      type: array
1049      items:
1050        type: object
1051        properties:
1052          org: { type: string, description: Citing organization. }
1053          year: { type: integer, default: 0, description: Publication year. }
1054"#)
1055        .blueprint();
1056        let doc1 = Document::parse(&bp).expect("blueprint must parse").document;
1057        let doc2 = Document::parse(&doc1.to_markdown())
1058            .expect("re-emit must parse")
1059            .document;
1060        assert_eq!(doc1, doc2, "typed-table blueprint must round-trip");
1061    }
1062
1063    #[test]
1064    fn must_fill_markers_round_trip_and_survive_as_fill() {
1065        // An Unendorsed array with an example (carried as block items under the
1066        // `!must_fill` marker), a bare-marker scalar, and a bare-marker datetime
1067        // must all round-trip through parse → emit → parse, and the `fill` flag
1068        // must survive on each.
1069        let bp = cfg(r#"
1070quill: { name: letter, version: 1.0.0, backend: typst, description: A letter. }
1071main:
1072  fields:
1073    recipient:
1074      type: array
1075      items: { type: string }
1076      example: [Mr. John Doe, "Anytown, USA"]
1077    subject: { type: string }
1078    date: { type: datetime }
1079"#)
1080        .blueprint();
1081
1082        let doc1 = Document::parse(&bp).expect("blueprint must parse").document;
1083        // Every Unendorsed field parsed back as a `!must_fill` marker.
1084        let payload = doc1.main().payload();
1085        for key in ["recipient", "subject", "date"] {
1086            assert!(
1087                payload.is_fill(key),
1088                "`{key}` must carry the fill marker:\n{bp}"
1089            );
1090        }
1091        // The example rode along as the suggested value, fill-free in JSON.
1092        assert_eq!(
1093            payload
1094                .get("recipient")
1095                .and_then(|v| v.as_json().as_array().map(|a| a.len())),
1096            Some(2),
1097            "recipient suggested value should survive: {bp}"
1098        );
1099
1100        // Idempotent round-trip.
1101        let md2 = doc1.to_markdown();
1102        let doc2 = Document::parse(&md2).expect("re-emitted markdown must parse").document;
1103        assert_eq!(doc1, doc2, "blueprint must round-trip idempotently");
1104    }
1105
1106    #[test]
1107    fn blueprint_round_trips_idempotently() {
1108        let bp = cfg(LETTER_QUILL).blueprint();
1109        let doc1 = Document::parse(&bp).expect("blueprint must parse").document;
1110        let md2 = doc1.to_markdown();
1111        let doc2 = Document::parse(&md2).expect("round-tripped markdown must parse").document;
1112        assert_eq!(
1113            doc1, doc2,
1114            "Document must be equal after blueprint → parse → emit → parse"
1115        );
1116    }
1117
1118    /// String defaults that look numeric/boolean/null must be quoted so
1119    /// the schema-validated payload still types as `string` after
1120    /// round-trip: defaults like `1.0`, `on`, `01234`, or `null` must
1121    /// not be emitted bare and re-parsed as the wrong YAML type.
1122    #[test]
1123    fn type_ambiguous_string_defaults_round_trip_as_strings() {
1124        let bp = cfg(r#"
1125quill: { name: x, version: 1.0.0, backend: typst, description: x }
1126main:
1127  fields:
1128    version:     { type: string, default: "1.0" }
1129    activation:  { type: string, default: "on" }
1130    code:        { type: string, default: "01234" }
1131    placeholder: { type: string, default: "null" }
1132    yes_flag:    { type: string, default: "yes" }
1133"#)
1134        .blueprint();
1135
1136        let doc = Document::parse(&bp).expect("blueprint must parse").document;
1137        let payload = doc.main().payload();
1138        for (key, expected) in [
1139            ("version", "1.0"),
1140            ("activation", "on"),
1141            ("code", "01234"),
1142            ("placeholder", "null"),
1143            ("yes_flag", "yes"),
1144        ] {
1145            let v = payload.get(key).unwrap_or_else(|| panic!("missing {key}"));
1146            assert!(
1147                v.as_str().is_some(),
1148                "field {key} must round-trip as a string, got {:?}\nBlueprint:\n{}",
1149                v,
1150                bp
1151            );
1152            assert_eq!(v.as_str().unwrap(), expected, "field {key}: value mismatch");
1153        }
1154    }
1155}