Skip to main content

quillmark_core/quill/
config.rs

1//! Quill configuration parsing and normalization.
2use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
3
4use indexmap::IndexMap;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Diagnostic, Severity, diag_args};
9use crate::value::QuillValue;
10
11use super::types::{RICHTEXT_INLINE_TOKEN_MSG, UI_ORDER_REMOVED_MSG};
12use super::{BodyCardSchema, CardSchema, FieldSchema, FieldType, GroupRegistry, UiCardSchema};
13
14/// Canonical string text for a bare scalar unambiguously representable as a
15/// string: a boolean (`true`/`false`) or number (`47`, `1.0`). `None` for
16/// `null` (≡ absent), strings (already strings), and collections.
17///
18/// Shared by `QuillConfig::conform_value` (to adopt the value) and
19/// `validation::validate_value` (to accept it), so coercion and validation
20/// never disagree about which bare scalars a `string` field accepts.
21pub(crate) fn scalar_as_string(value: &serde_json::Value) -> Option<String> {
22    match value {
23        serde_json::Value::Bool(b) => Some(b.to_string()),
24        serde_json::Value::Number(n) => Some(n.to_string()),
25        _ => None,
26    }
27}
28
29/// Reduce a lenient value to its authored-string form: a bare string, the
30/// sole element of a length-1 array when that element is a string (the
31/// array-unwrap leniency), or a bare scalar's canonical text (via
32/// [`scalar_as_string`]). `None` for anything else (a multi-element array, an
33/// object, null), leaving the caller's own fallback to apply.
34///
35/// Shared by the `String` and `Content` coercion branches, which both reduce
36/// a lenient value to a string before adopting it (as the field value itself,
37/// or as markdown to import).
38fn lenient_string(value: &serde_json::Value) -> Option<String> {
39    if let Some(s) = value.as_str() {
40        return Some(s.to_string());
41    }
42    if let Some(s) = value
43        .as_array()
44        .filter(|a| a.len() == 1)
45        .and_then(|a| a[0].as_str())
46    {
47        return Some(s.to_string());
48    }
49    scalar_as_string(value)
50}
51
52/// Top-level configuration for a Quillmark project
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[non_exhaustive]
55pub struct QuillConfig {
56    /// Quill package name
57    pub name: String,
58    /// Human-readable description of the quill itself (parsed from
59    /// `quill.description`). Distinct from `main.description`, which describes
60    /// the main card's schema.
61    pub description: String,
62    /// The entry-point card schema (parsed from the Quill.yaml `main:` section).
63    pub main: CardSchema,
64    /// Named, composable card-kind schemas (parsed from the Quill.yaml
65    /// `card_kinds:` section). Does not include `main`.
66    pub card_kinds: Vec<CardSchema>,
67    /// Backend to use for rendering (e.g., "typst", "html")
68    pub backend: String,
69    /// Version of the Quillmark spec
70    pub version: String,
71    /// Author of the project
72    pub author: String,
73    /// Backend-specific configuration parsed from the top-level YAML section
74    /// whose key matches `backend` (e.g. `[typst]`, `[html]`).
75    #[serde(default)]
76    pub backend_config: HashMap<String, QuillValue>,
77}
78
79impl QuillConfig {
80    /// The four fields `Quill.yaml` requires. `description`, `author`,
81    /// `card_kinds`, and `backend_config` start empty.
82    ///
83    /// This bypasses [`Self::from_yaml_with_warnings`] and its validation, so a
84    /// config built here can hold shapes the parser refuses. Loading a quill
85    /// goes through that path; this one is for a caller assembling a schema in
86    /// memory.
87    pub fn new(name: String, backend: String, version: String, main: CardSchema) -> Self {
88        Self {
89            name,
90            description: String::new(),
91            main,
92            card_kinds: Vec::new(),
93            backend,
94            version,
95            author: String::new(),
96            backend_config: HashMap::new(),
97        }
98    }
99}
100
101#[derive(Debug, Deserialize)]
102#[serde(deny_unknown_fields)]
103struct CardSchemaDef {
104    pub description: Option<String>,
105    // Declared so `deny_unknown_fields` accepts a `fields:` block on a card.
106    // Fields are parsed separately via `parse_fields` (per-field diagnostics).
107    #[allow(dead_code)]
108    pub fields: Option<serde_json::Map<String, serde_json::Value>>,
109    pub ui: Option<UiCardSchema>,
110    pub body: Option<BodyCardSchema>,
111}
112
113/// Depth context for [`QuillConfig::validate_field_schema_shape`]. Encodes
114/// which shapes are legal at the current nesting level, so the one-level
115/// nesting contract is enforced by a single recursive walk.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117enum ShapePosition {
118    /// A field declared directly on a card: scalar, object, or array.
119    Top,
120    /// An array's `items`: scalar or object (typed-table row), not an array.
121    ArrayItem,
122    /// An object's property: scalar only.
123    Leaf,
124}
125
126#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
127#[non_exhaustive]
128pub enum CoercionError {
129    #[error("cannot coerce `{value}` to type `{target}` at `{path}`: {reason}")]
130    Uncoercible {
131        path: String,
132        value: String,
133        target: String,
134        reason: String,
135    },
136}
137
138impl CoercionError {
139    /// The facts this error's message interpolates. See
140    /// [`Diagnostic::args`](crate::error::Diagnostic::args).
141    ///
142    /// Two of the four fields stay behind. `path` is a schema-space anchor
143    /// (`card_kinds.<kind>.<field>`) that `ERROR.md` § "Three grammars, one
144    /// that crosses" keeps engine-internal, and an args key would re-open that
145    /// door under a new name. `reason` is English minted at ~20 coercion arms,
146    /// sometimes wrapping a decode error's own prose; under a key it would be
147    /// interpolated into a translated sentence, so it stays in `message` where
148    /// a consumer takes it whole or not at all.
149    ///
150    /// What remains states the failure at lower resolution than the English
151    /// does ("`{value}` is not a `{target}`"), which is the contract.
152    pub fn args(&self) -> BTreeMap<String, serde_json::Value> {
153        match self {
154            CoercionError::Uncoercible {
155                path: _,
156                value,
157                target,
158                reason: _,
159            } => diag_args! {
160                "value" => value,
161                "target" => target,
162            },
163        }
164    }
165}
166
167/// Write-side leniency mode for [`QuillConfig::conform_value`]: the one axis
168/// that separates the render floor's forgiving coercion from a strict typed
169/// write.
170///
171/// The dispatch is shared; only the arms that *defer to the validation layer*
172/// or *cross type boundaries* branch on this. See `conform_value`.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub(crate) enum Leniency {
175    /// The render floor's forgiving cascade: cross-type scalar coercions apply
176    /// and a shape a type cannot adopt falls through unchanged for the
177    /// validation layer to report.
178    Render,
179    /// A strict typed write ([`Card::commit_field`](crate::document::Card::commit_field)):
180    /// value-parsing normalizations still apply (`"3"` → `3`, a bare scalar
181    /// wraps into a singleton array, richtext markdown imports to content), but
182    /// cross-type `Boolean`↔`Number` coercions are dropped and every
183    /// defer-to-validation fall-through becomes a `CoercionError`, so a
184    /// mismatched value fails at the write, not silently at a later render.
185    ///
186    /// This mode is also the **resting form** a content field converges to
187    /// ([`Quill::conform`](crate::Quill::conform)): `richtext` rests as the
188    /// canonical content object, `plaintext` as its literal string. Only the
189    /// `PlainText` arm's output shape differs between the two modes; the plate
190    /// keeps the content object under `Render`.
191    ///
192    /// "Strict" is asymmetric by target, not absolute: `string` and `array` are
193    /// universal sinks, so a scalar→`string` (`true` → `"true"`) and a
194    /// scalar→singleton-`array` wrap stay lenient even here (both are lossless,
195    /// unambiguous, and author-intended); only the lossy/ambiguous crossings
196    /// (scalar→`object`, `String`→`number`/`bool`, `Boolean`↔`Number`) are
197    /// rejected. A strict write thus still reshapes toward `string`/`array`
198    /// while refusing to invent structure or reinterpret a scalar's type.
199    Write,
200}
201
202impl QuillConfig {
203    /// Returns a named card-kind schema by name.
204    pub fn card_kind(&self, name: &str) -> Option<&CardSchema> {
205        self.card_kinds.iter().find(|card| card.name == name)
206    }
207
208    /// Full schema including `ui` hints.
209    ///
210    /// Describes the user-fillable fields of the main card and each named
211    /// card kind. The quill reference (constructed as `name@version` from
212    /// quill metadata) and card-kind discriminators are document-level
213    /// metadata, not fields, so they do not appear here.
214    ///
215    /// Key order is the ordering contract: fields, nested properties, and card
216    /// kinds all emit in declaration order (`preserve_order` end-to-end), so a
217    /// consumer walking the maps in key order renders the authored layout.
218    pub fn schema(&self) -> serde_json::Value {
219        let mut obj = serde_json::Map::new();
220
221        let main_value =
222            serde_json::to_value(&self.main).expect("CardSchema is always serializable");
223        obj.insert("main".to_string(), main_value);
224
225        if !self.card_kinds.is_empty() {
226            let mut card_kinds = serde_json::Map::new();
227            for card in &self.card_kinds {
228                let card_value =
229                    serde_json::to_value(card).expect("CardSchema is always serializable");
230                card_kinds.insert(card.name.clone(), card_value);
231            }
232            obj.insert(
233                "card_kinds".to_string(),
234                serde_json::Value::Object(card_kinds),
235            );
236        }
237
238        serde_json::Value::Object(obj)
239    }
240
241    /// Coerce typed payload fields (IndexMap of user fields only).
242    pub fn coerce_payload(
243        &self,
244        payload: &IndexMap<String, QuillValue>,
245    ) -> Result<IndexMap<String, QuillValue>, CoercionError> {
246        let mut coerced: IndexMap<String, QuillValue> = IndexMap::new();
247        for (field_name, field_value) in payload {
248            if let Some(field_schema) = self.main.fields.get(field_name) {
249                let path = field_name.as_str();
250                coerced.insert(
251                    field_name.clone(),
252                    Self::conform_value(field_value, field_schema, path, Leniency::Render)?,
253                );
254            } else {
255                coerced.insert(field_name.clone(), field_value.clone());
256            }
257        }
258        Ok(coerced)
259    }
260
261    /// Coerce typed fields for a single card (IndexMap of user fields only).
262    ///
263    /// Returns the input unchanged when the card kind is unknown.
264    pub fn coerce_card(
265        &self,
266        card_kind: &str,
267        fields: &IndexMap<String, QuillValue>,
268    ) -> Result<IndexMap<String, QuillValue>, CoercionError> {
269        let Some(card_schema) = self.card_kind(card_kind) else {
270            return Ok(fields.clone());
271        };
272        let mut coerced: IndexMap<String, QuillValue> = IndexMap::new();
273        for (field_name, field_value) in fields {
274            if let Some(field_schema) = card_schema.fields.get(field_name) {
275                let path = format!("card_kinds.{card_kind}.{field_name}");
276                coerced.insert(
277                    field_name.clone(),
278                    Self::conform_value(field_value, field_schema, &path, Leniency::Render)?,
279                );
280            } else {
281                coerced.insert(field_name.clone(), field_value.clone());
282            }
283        }
284        Ok(coerced)
285    }
286
287    /// Validate a typed [`crate::document::Document`] against this configuration.
288    pub fn validate_document(
289        &self,
290        doc: &crate::document::Document,
291    ) -> Result<(), Vec<super::validation::ValidationError>> {
292        super::validation::validate_typed_document(self, doc)
293    }
294
295    /// The one write-side per-type dispatch: given a value, a field's schema,
296    /// and a [`Leniency`] mode, validate/normalize the value to the canonical
297    /// form the type stores. `Render` is the render floor's forgiving coercion;
298    /// `Write` is the strict typed-write commit driving
299    /// [`Card::commit_field`](crate::document::Card::commit_field).
300    ///
301    /// Validation keeps its own read-only dispatch (`validation::validate_value`),
302    /// synced with this via the shared helpers `scalar_as_string` /
303    /// `decode_richtext_value`.
304    pub(crate) fn conform_value(
305        value: &QuillValue,
306        field_schema: &super::FieldSchema,
307        path: &str,
308        mode: Leniency,
309    ) -> Result<QuillValue, CoercionError> {
310        use super::FieldType;
311
312        let json_value = value.as_json();
313
314        // Null ≡ absent: a present-null value (`field:`, `field: null`,
315        // `field: ~`) carries no data, so it passes through coercion unchanged
316        // for every type rather than failing as a mismatch. The render floor
317        // and the validation layer treat it the same as an omitted field. This
318        // also preserves a `!must_fill` marker riding on `value` (the fill flag
319        // is never part of the JSON projection).
320        if json_value.is_null() {
321            return Ok(value.clone());
322        }
323
324        match field_schema.r#type {
325            FieldType::Array => {
326                let arr = if let Some(a) = json_value.as_array() {
327                    a.clone()
328                } else {
329                    vec![json_value.clone()]
330                };
331
332                // Every array carries an element schema (`items`). Coerce each
333                // element against it: scalar items (`string[]`, `integer[]`,
334                // `richtext[]`) coerce element-wise; object items recurse into
335                // the element's `properties` via the Object branch.
336                if let Some(items) = &field_schema.items {
337                    let mut out = Vec::with_capacity(arr.len());
338                    for (idx, elem) in arr.iter().enumerate() {
339                        let coerced = Self::conform_value(
340                            &QuillValue::from_json(elem.clone()),
341                            items,
342                            &format!("{path}[{idx}]"),
343                            mode,
344                        )?;
345                        out.push(coerced.into_json());
346                    }
347                    Ok(QuillValue::from_json(serde_json::Value::Array(out)))
348                } else {
349                    // Defensive fallback: schema-load rejects any array without
350                    // `items` (quill::array_missing_items), so a validated
351                    // config never reaches here, pass the array through as-is.
352                    Ok(QuillValue::from_json(serde_json::Value::Array(arr)))
353                }
354            }
355            FieldType::Boolean => {
356                if let Some(b) = json_value.as_bool() {
357                    return Ok(QuillValue::from_json(serde_json::Value::Bool(b)));
358                }
359                if let Some(s) = json_value.as_str() {
360                    let lower = s.to_lowercase();
361                    if lower == "true" {
362                        return Ok(QuillValue::from_json(serde_json::Value::Bool(true)));
363                    } else if lower == "false" {
364                        return Ok(QuillValue::from_json(serde_json::Value::Bool(false)));
365                    }
366                }
367                // Cross-type number→boolean is a render-floor leniency; a strict
368                // write requires an actual boolean or its `"true"`/`"false"` text.
369                if mode == Leniency::Render {
370                    if let Some(n) = json_value.as_i64() {
371                        return Ok(QuillValue::from_json(serde_json::Value::Bool(n != 0)));
372                    }
373                    if let Some(n) = json_value.as_f64() {
374                        if n.is_nan() {
375                            return Ok(QuillValue::from_json(serde_json::Value::Bool(false)));
376                        }
377                        return Ok(QuillValue::from_json(serde_json::Value::Bool(
378                            n.abs() > f64::EPSILON,
379                        )));
380                    }
381                }
382
383                Err(CoercionError::Uncoercible {
384                    path: path.to_string(),
385                    value: json_value.to_string(),
386                    target: "boolean".to_string(),
387                    reason: "value is not coercible to boolean".to_string(),
388                })
389            }
390            FieldType::Number => {
391                if json_value.is_number() {
392                    return Ok(value.clone());
393                }
394                if let Some(s) = json_value.as_str() {
395                    if let Ok(i) = s.parse::<i64>() {
396                        return Ok(QuillValue::from_json(serde_json::Number::from(i).into()));
397                    }
398                    if let Ok(f) = s.parse::<f64>() {
399                        if let Some(num) = serde_json::Number::from_f64(f) {
400                            return Ok(QuillValue::from_json(num.into()));
401                        }
402                    }
403                    return Err(CoercionError::Uncoercible {
404                        path: path.to_string(),
405                        value: s.to_string(),
406                        target: "number".to_string(),
407                        reason: "string is not a valid number".to_string(),
408                    });
409                }
410                // Cross-type boolean→number is a render-floor leniency only.
411                if mode == Leniency::Render {
412                    if let Some(b) = json_value.as_bool() {
413                        let n = if b { 1 } else { 0 };
414                        return Ok(QuillValue::from_json(serde_json::Value::Number(
415                            serde_json::Number::from(n),
416                        )));
417                    }
418                }
419
420                Err(CoercionError::Uncoercible {
421                    path: path.to_string(),
422                    value: json_value.to_string(),
423                    target: "number".to_string(),
424                    reason: "value is not coercible to number".to_string(),
425                })
426            }
427            FieldType::Integer => {
428                if let Some(i) = json_value.as_i64() {
429                    return Ok(QuillValue::from_json(serde_json::Number::from(i).into()));
430                }
431                if let Some(u) = json_value.as_u64() {
432                    if let Ok(i) = i64::try_from(u) {
433                        return Ok(QuillValue::from_json(serde_json::Number::from(i).into()));
434                    }
435                    return Err(CoercionError::Uncoercible {
436                        path: path.to_string(),
437                        value: json_value.to_string(),
438                        target: "integer".to_string(),
439                        reason: "integer value exceeds i64 range".to_string(),
440                    });
441                }
442                if let Some(s) = json_value.as_str() {
443                    if let Ok(i) = s.parse::<i64>() {
444                        return Ok(QuillValue::from_json(serde_json::Number::from(i).into()));
445                    }
446                    return Err(CoercionError::Uncoercible {
447                        path: path.to_string(),
448                        value: s.to_string(),
449                        target: "integer".to_string(),
450                        reason: "string is not a valid integer".to_string(),
451                    });
452                }
453                // Cross-type boolean→integer is a render-floor leniency only.
454                if mode == Leniency::Render {
455                    if let Some(b) = json_value.as_bool() {
456                        let n = if b { 1 } else { 0 };
457                        return Ok(QuillValue::from_json(serde_json::Value::Number(
458                            serde_json::Number::from(n),
459                        )));
460                    }
461                }
462
463                Err(CoercionError::Uncoercible {
464                    path: path.to_string(),
465                    value: json_value.to_string(),
466                    target: "integer".to_string(),
467                    reason: "value is not coercible to integer".to_string(),
468                })
469            }
470            // Enum is open scalar data drawn from a closed domain: coerced as a
471            // string here; domain membership is checked at the validation layer
472            // (an out-of-domain string is a value error, not a type error).
473            FieldType::String | FieldType::Enum => {
474                if json_value.is_string() {
475                    return Ok(value.clone());
476                }
477                // Gracious leniency: unwrap a length-1 array's sole string
478                // element, or adopt a bare bool/number's canonical text (an
479                // author writing `verified: true` for a `string` field), rather
480                // than reject it. Null is handled above; other collections fall
481                // through.
482                if let Some(text) = lenient_string(json_value) {
483                    return Ok(QuillValue::from_json(serde_json::Value::String(text)));
484                }
485                // A non-stringifiable shape (object, multi-element array): the
486                // render floor defers to validation, a strict write fails now.
487                match mode {
488                    Leniency::Render => Ok(value.clone()),
489                    Leniency::Write => Err(CoercionError::Uncoercible {
490                        path: path.to_string(),
491                        value: json_value.to_string(),
492                        target: field_schema.r#type.as_str().to_string(),
493                        reason: "value is not a string".to_string(),
494                    }),
495                }
496            }
497            FieldType::PlainText { inline } => {
498                // Plaintext rides the same content as richtext but through the
499                // *literal* codec: a string is imported verbatim via
500                // `from_plaintext` (no markdown parsing, no escaping), an
501                // already-structured content is validated plain. A wire content
502                // carrying marks or islands is rejected, not silently stripped:
503                // matching the `inline` precedent and keeping coercion lossless.
504                //
505                // `Write` commits the literal string because the codec is
506                // lossless on plain content (`to_plaintext ∘ from_plaintext` is
507                // identity), so the string loses nothing, while object rest
508                // would: emit is schema-free and markdown-escapes any content
509                // object it projects (`a *literal* line` → `a \*literal\* line`).
510                let plain_check =
511                    |rt: &quillmark_content::Content| -> Result<(), CoercionError> {
512                        if !rt.is_plain() {
513                            return Err(CoercionError::Uncoercible {
514                                path: path.to_string(),
515                                value: "<plaintext>".to_string(),
516                                target: "plaintext".to_string(),
517                                reason: "plaintext carries no marks, islands, or block \
518                                     formatting (lists, quotes, headings)"
519                                    .to_string(),
520                            });
521                        }
522                        if inline && !rt.is_inline() {
523                            return Err(CoercionError::Uncoercible {
524                                path: path.to_string(),
525                                value: "<plaintext>".to_string(),
526                                target: "plaintext(inline)".to_string(),
527                                reason: "plaintext(inline) requires a single line".to_string(),
528                            });
529                        }
530                        Ok(())
531                    };
532                let commit = |rt: &quillmark_content::Content| -> QuillValue {
533                    match mode {
534                        Leniency::Render => QuillValue::from_json(
535                            quillmark_content::serial::to_canonical_value(rt),
536                        ),
537                        Leniency::Write => QuillValue::from_json(serde_json::Value::String(
538                            quillmark_content::export::to_plaintext(rt),
539                        )),
540                    }
541                };
542                if json_value.is_object() {
543                    let rt = quillmark_content::serial::from_canonical_value(json_value).map_err(
544                        |e| CoercionError::Uncoercible {
545                            path: path.to_string(),
546                            value: "<object>".to_string(),
547                            target: "plaintext".to_string(),
548                            reason: format!("not a valid content object: {e}"),
549                        },
550                    )?;
551                    plain_check(&rt)?;
552                    return Ok(commit(&rt));
553                }
554                // Reduce to the authored literal string via the shared leniency
555                // cascade, then import verbatim.
556                let Some(text) = lenient_string(json_value) else {
557                    return match mode {
558                        Leniency::Render => Ok(value.clone()),
559                        Leniency::Write => Err(CoercionError::Uncoercible {
560                            path: path.to_string(),
561                            value: json_value.to_string(),
562                            target: "plaintext".to_string(),
563                            reason: "value is not a plaintext string or content".to_string(),
564                        }),
565                    };
566                };
567                let rt = quillmark_content::from_plaintext(&text);
568                plain_check(&rt)?;
569                Ok(commit(&rt))
570            }
571            FieldType::RichText { inline } => {
572                // The seam carries the content, so coercion commits the content
573                // form: an already-structured value (editor / re-render) is
574                // validated and re-canonicalized; an authored markdown string is
575                // imported. Determinism is inherited from `import` being pure.
576                // An `inline` field additionally requires the resulting content to
577                // be single-`Para` (`richtext(inline)`): editors mount a one-line
578                // surface, so multi-block content is a coercion error here, in
579                // lockstep with the validation-layer `validation::not_inline` check.
580                //
581                // This is the deliberately-lenient sibling of
582                // `document::decode_richtext_value` (used by the strict wire /
583                // literal / validation sites): the string branch below reduces a
584                // bare scalar or length-1 array to text before importing, which
585                // the strict decoder must not do, so it stays open-coded here.
586                let inline_check =
587                    |rt: &quillmark_content::Content| -> Result<(), CoercionError> {
588                        if inline && !rt.is_inline() {
589                            return Err(CoercionError::Uncoercible {
590                                path: path.to_string(),
591                                value: "<richtext>".to_string(),
592                                target: "richtext(inline)".to_string(),
593                                reason: "richtext(inline) requires a single paragraph line \
594                                     with no list/quote container and no islands"
595                                    .to_string(),
596                            });
597                        }
598                        Ok(())
599                    };
600                // A strict write uses `decode_richtext_value` semantics: a
601                // canonical content object or a markdown string, nothing else. No
602                // scalar→string reduction (the render floor's lenient cascade
603                // below): a bare scalar for a richtext field fails the write. The
604                // messages mirror `Card::commit_field`'s richtext error variants,
605                // which the bindings key on.
606                if mode == Leniency::Write {
607                    let content = match crate::document::decode_richtext_value(json_value) {
608                        Some(result) => result.map_err(|e| CoercionError::Uncoercible {
609                            path: path.to_string(),
610                            value: "<richtext>".to_string(),
611                            target: "richtext".to_string(),
612                            reason: e.into_message(),
613                        })?,
614                        None => {
615                            return Err(CoercionError::Uncoercible {
616                                path: path.to_string(),
617                                value: json_value.to_string(),
618                                target: "richtext".to_string(),
619                                reason: format!(
620                                    "expected a richtext content object or a markdown string, got {}",
621                                    match json_value {
622                                        serde_json::Value::Bool(_) => "a boolean",
623                                        serde_json::Value::Number(_) => "a number",
624                                        serde_json::Value::Array(_) => "an array",
625                                        _ => "an unsupported value",
626                                    }
627                                ),
628                            })
629                        }
630                    };
631                    inline_check(&content)?;
632                    return Ok(QuillValue::from_json(
633                        quillmark_content::serial::to_canonical_value(&content),
634                    ));
635                }
636                if json_value.is_object() {
637                    let rt = quillmark_content::serial::from_canonical_value(json_value).map_err(
638                        |e| CoercionError::Uncoercible {
639                            path: path.to_string(),
640                            value: "<object>".to_string(),
641                            target: "richtext".to_string(),
642                            reason: format!("not a valid richtext content: {e}"),
643                        },
644                    )?;
645                    inline_check(&rt)?;
646                    return Ok(QuillValue::from_json(
647                        quillmark_content::serial::to_canonical_value(&rt),
648                    ));
649                }
650                // Reduce to the authored markdown string via the shared
651                // leniency cascade (bare string, length-1 array unwrap, or bare
652                // scalar), then import.
653                let Some(markdown) = lenient_string(json_value) else {
654                    // A shape that is neither content nor stringifiable (e.g. a
655                    // multi-element array): leave it for the validation layer to
656                    // report, matching the String branch's fall-through.
657                    return Ok(value.clone());
658                };
659                let rt = quillmark_content::import::from_markdown(&markdown).map_err(|e| {
660                    CoercionError::Uncoercible {
661                        path: path.to_string(),
662                        value: markdown.clone(),
663                        target: "richtext".to_string(),
664                        reason: format!("markdown import failed: {e}"),
665                    }
666                })?;
667                inline_check(&rt)?;
668                Ok(QuillValue::from_json(
669                    quillmark_content::serial::to_canonical_value(&rt),
670                ))
671            }
672            FieldType::Date | FieldType::DateTime => {
673                if json_value.is_null() {
674                    return Ok(QuillValue::from_json(serde_json::Value::Null));
675                }
676                let text = if let Some(s) = json_value.as_str() {
677                    if s.is_empty() {
678                        return Ok(QuillValue::from_json(serde_json::Value::Null));
679                    }
680                    s.to_string()
681                } else if let Some(arr) = json_value.as_array() {
682                    if arr.len() == 1 {
683                        if let Some(s) = arr[0].as_str() {
684                            s.to_string()
685                        } else {
686                            return Err(CoercionError::Uncoercible {
687                                path: path.to_string(),
688                                value: json_value.to_string(),
689                                target: field_schema.r#type.as_str().to_string(),
690                                reason: "value must be a string".to_string(),
691                            });
692                        }
693                    } else {
694                        return Err(CoercionError::Uncoercible {
695                            path: path.to_string(),
696                            value: json_value.to_string(),
697                            target: field_schema.r#type.as_str().to_string(),
698                            reason: "value must be a single string".to_string(),
699                        });
700                    }
701                } else {
702                    return Err(CoercionError::Uncoercible {
703                        path: path.to_string(),
704                        value: json_value.to_string(),
705                        target: field_schema.r#type.as_str().to_string(),
706                        reason: "value must be a string".to_string(),
707                    });
708                };
709
710                // The two date types share extraction and verbatim storage;
711                // only the grammar differs. A `date` rejects any time component,
712                // a `datetime` rejects offsets/space/fraction/bare-date: neither
713                // truncates, so the stored string is exactly the authored one.
714                let (valid, reason) = match field_schema.r#type {
715                    FieldType::Date => {
716                        (super::formats::is_valid_date(&text), "invalid date format")
717                    }
718                    _ => (
719                        super::formats::is_valid_datetime(&text),
720                        "invalid datetime format",
721                    ),
722                };
723                if valid {
724                    Ok(QuillValue::from_json(serde_json::Value::String(text)))
725                } else {
726                    Err(CoercionError::Uncoercible {
727                        path: path.to_string(),
728                        value: text,
729                        target: field_schema.r#type.as_str().to_string(),
730                        reason: reason.to_string(),
731                    })
732                }
733            }
734            FieldType::Object => {
735                if let Some(obj) = json_value.as_object() {
736                    if let Some(props) = &field_schema.properties {
737                        let coerced_obj = Self::coerce_object_props(obj, props, path, mode)?;
738                        Ok(QuillValue::from_json(serde_json::Value::Object(
739                            coerced_obj,
740                        )))
741                    } else {
742                        Ok(value.clone())
743                    }
744                } else {
745                    // A non-object value: the render floor defers to validation,
746                    // a strict write fails now.
747                    match mode {
748                        Leniency::Render => Ok(value.clone()),
749                        Leniency::Write => Err(CoercionError::Uncoercible {
750                            path: path.to_string(),
751                            value: json_value.to_string(),
752                            target: "object".to_string(),
753                            reason: "value is not an object".to_string(),
754                        }),
755                    }
756                }
757            }
758        }
759    }
760
761    /// Walk `obj`'s keys, coercing any that match `props` against the matching
762    /// schema and copying any others through verbatim. `parent_path` is the
763    /// breadcrumb for the enclosing scope (e.g. `"foo[3]"` or `"foo"`); each
764    /// child's path is `"{parent_path}.{k}"`.
765    fn coerce_object_props(
766        obj: &serde_json::Map<String, serde_json::Value>,
767        props: &IndexMap<String, Box<super::FieldSchema>>,
768        parent_path: &str,
769        mode: Leniency,
770    ) -> Result<serde_json::Map<String, serde_json::Value>, CoercionError> {
771        let mut out = serde_json::Map::new();
772        for (k, v) in obj {
773            if let Some(prop_schema) = props.get(k) {
774                let child_path = format!("{parent_path}.{k}");
775                out.insert(
776                    k.clone(),
777                    Self::conform_value(
778                        &QuillValue::from_json(v.clone()),
779                        prop_schema,
780                        &child_path,
781                        mode,
782                    )?
783                    .into_json(),
784                );
785            } else {
786                out.insert(k.clone(), v.clone());
787            }
788        }
789        Ok(out)
790    }
791
792    /// Recursively validate a field's structural shape, enforcing the
793    /// one-level nesting contract in a single pass. The `position` records
794    /// what shapes are legal at the current depth:
795    ///
796    /// - [`ShapePosition::Top`], a field declared directly on a card: scalar,
797    ///   `object` (typed dictionary), or `array` (primitive list or typed
798    ///   table).
799    /// - [`ShapePosition::ArrayItem`], an array's `items`: a scalar or an
800    ///   `object` (the typed-table row), but **not** another array.
801    /// - [`ShapePosition::Leaf`], an object's property (whether a top-level
802    ///   typed dictionary or a typed-table row): scalar only. No deeper
803    ///   containers, so `array<object<array>>` and `object<array>` are
804    ///   rejected here.
805    ///
806    /// Returns the first violation as a ready-to-push [`Diagnostic`] whose
807    /// message names `owner` (the field-name path, e.g. `rows[].tags`), or
808    /// `None` when the shape is valid.
809    fn validate_field_schema_shape(
810        schema: &FieldSchema,
811        owner: &str,
812        position: ShapePosition,
813    ) -> Option<Diagnostic> {
814        let err = |code: &str, message: String| {
815            Some(Diagnostic::new(Severity::Error, message).with_code(code.to_string()))
816        };
817
818        // `items` is only meaningful on arrays; `properties` only on objects.
819        if schema.r#type != FieldType::Array && schema.items.is_some() {
820            return err(
821                "quill::items_not_supported",
822                format!(
823                    "Field '{owner}' declares 'items' but is not type: array. \
824                     'items' (the element schema) is only valid on array fields."
825                ),
826            );
827        }
828        // `inline` on a non-prose field is rejected earlier and once, when
829        // `from_quill_value` folds the wire key into the `FieldType` enum
830        // (`resolve_prose_inline`); no second check belongs here.
831
832        // `ui.group` clusters card-level fields only: the blueprint's grouping
833        // pass never descends into object properties or array items, so a nested
834        // `group` is an inert knob. Reject it rather than let it silently do
835        // nothing, the same dead-knob class this walk exists to catch.
836        if position != ShapePosition::Top
837            && schema.ui.as_ref().and_then(|u| u.group.as_ref()).is_some()
838        {
839            return err(
840                "quill::nested_group_not_supported",
841                format!(
842                    "Field '{owner}' sets ui.group in a nested position. Grouping applies \
843                     only to card-level fields; an object property or array item cannot \
844                     join a group."
845                ),
846            );
847        }
848
849        match schema.r#type {
850            FieldType::Object => {
851                // An object nested inside another object (a Leaf position) is
852                // the classic "nested type: object" rejection.
853                if position == ShapePosition::Leaf {
854                    return err(
855                        "quill::nested_object_not_supported",
856                        format!(
857                            "Field '{owner}' uses a nested type: object, which is not supported. \
858                             An object's properties may only be scalars."
859                        ),
860                    );
861                }
862                let Some(props) = &schema.properties else {
863                    return err(
864                        "quill::object_missing_properties",
865                        format!(
866                            "Field '{owner}' has type: object but no properties defined. \
867                             Declare a properties map, or use type: array with \
868                             items: {{ type: object, properties: … }} for a list of objects."
869                        ),
870                    );
871                };
872                if props.is_empty() {
873                    return err(
874                        "quill::object_empty_properties",
875                        format!(
876                            "Field '{owner}' has type: object with an empty properties map. \
877                             Declare at least one property, or remove the field entirely."
878                        ),
879                    );
880                }
881                // Object properties are leaves: scalars only.
882                props.iter().find_map(|(name, prop)| {
883                    Self::validate_field_schema_shape(
884                        prop,
885                        &format!("{owner}.{name}"),
886                        ShapePosition::Leaf,
887                    )
888                })
889            }
890            FieldType::Array => {
891                // An array may sit at the top level only; an array element may
892                // not itself be an array, and neither may an object property.
893                if position != ShapePosition::Top {
894                    return err(
895                        "quill::nested_array_not_supported",
896                        format!(
897                            "Field '{owner}' declares a nested array, which is not supported. \
898                             Array elements must be scalars or objects, and object properties \
899                             may only be scalars."
900                        ),
901                    );
902                }
903                if schema.properties.is_some() {
904                    return err(
905                        "quill::array_properties_not_supported",
906                        format!(
907                            "Field '{owner}' is type: array with a bare 'properties' map. \
908                             Declare the element type under 'items' instead: for a list \
909                             of objects use items: {{ type: object, properties: … }}."
910                        ),
911                    );
912                }
913                let Some(items) = &schema.items else {
914                    return err(
915                        "quill::array_missing_items",
916                        format!(
917                            "Field '{owner}' has type: array but no 'items' element schema. \
918                             Declare the element type, e.g. items: {{ type: string }} \
919                             for a list of strings or items: {{ type: object, \
920                             properties: … }} for a list of objects."
921                        ),
922                    );
923                };
924                Self::validate_field_schema_shape(
925                    items,
926                    &format!("{owner}[]"),
927                    ShapePosition::ArrayItem,
928                )
929            }
930            // Scalars are leaves; nothing further to validate.
931            _ => None,
932        }
933    }
934
935    /// Reject multi-line descriptions. Single-line is required so the leading
936    /// `# <description>` blueprint slot stays one line and the field-comment
937    /// stack remains parseable for LLM consumers.
938    fn validate_description_singleline(
939        desc: Option<&str>,
940        owner_label: &str,
941        errors: &mut Vec<Diagnostic>,
942    ) {
943        if let Some(d) = desc {
944            if d.contains('\n') {
945                errors.push(
946                    Diagnostic::new(
947                        Severity::Error,
948                        format!(
949                            "{} description must be a single line; multi-line \
950                             descriptions are not allowed.",
951                            owner_label
952                        ),
953                    )
954                    .with_code("quill::description_multiline".to_string()),
955                );
956            }
957        }
958    }
959
960    /// Reject `>`, `;`, `|` in enum literals. These characters are reserved by
961    /// the blueprint inline annotation grammar (`<format>` close, role
962    /// separator, enum value separator) and have no escape syntax.
963    fn validate_enum_literals(
964        field: &FieldSchema,
965        owner_label: &str,
966        errors: &mut Vec<Diagnostic>,
967    ) {
968        if let Some(values) = &field.enum_values {
969            for v in values {
970                if v.contains('>') || v.contains(';') || v.contains('|') {
971                    errors.push(
972                        Diagnostic::new(
973                            Severity::Error,
974                            format!(
975                                "{} enum value '{}' contains a reserved character \
976                                 ('>', ';', or '|') that conflicts with the \
977                                 blueprint inline annotation grammar.",
978                                owner_label, v
979                            ),
980                        )
981                        .with_code("quill::format_literal_reserved_char".to_string()),
982                    );
983                }
984            }
985        }
986    }
987
988    /// Recursively validate field-level blueprint constraints across the field,
989    /// any nested object properties, and an array's element schema (`items`).
990    fn validate_field_blueprint_constraints(
991        schema: &FieldSchema,
992        owner_label: &str,
993        errors: &mut Vec<Diagnostic>,
994    ) {
995        Self::validate_description_singleline(schema.description.as_deref(), owner_label, errors);
996        Self::validate_enum_literals(schema, owner_label, errors);
997        if let Some(v) = &schema.example {
998            Self::validate_schema_slot("example", v, schema, owner_label, errors);
999        }
1000        if let Some(v) = &schema.default {
1001            Self::validate_schema_slot("default", v, schema, owner_label, errors);
1002        }
1003        if let Some(props) = &schema.properties {
1004            for (name, prop) in props {
1005                let nested = format!("{}.{}", owner_label, name);
1006                Self::validate_field_blueprint_constraints(prop, &nested, errors);
1007            }
1008        }
1009        if let Some(items) = &schema.items {
1010            let nested = format!("{}[]", owner_label);
1011            Self::validate_field_blueprint_constraints(items, &nested, errors);
1012        }
1013    }
1014
1015    /// Validate a card's group registry and every card-level field's `ui.group`
1016    /// reference against it. Nested `ui.group` is already rejected upstream by
1017    /// [`validate_field_schema_shape`](Self::validate_field_schema_shape), so
1018    /// only card-level fields are considered here.
1019    ///
1020    /// With a registry present, `ui.group` is a *reference*: registry ids carry
1021    /// the same snake_case discipline as field keys and must be unique, and a
1022    /// reference to an id the registry does not declare is `quill::unknown_group`
1023    /// (the "no mixing implicit and declared" rule falls out of this, with a
1024    /// registry there is no implicit fallback). With no registry, each `ui.group`
1025    /// is a deprecated implicit group (label-as-identity) and the card earns one
1026    /// `quill::implicit_group` warning.
1027    fn validate_card_groups(
1028        label: &str,
1029        card: &CardSchema,
1030        errors: &mut Vec<Diagnostic>,
1031        warnings: &mut Vec<Diagnostic>,
1032    ) {
1033        let referenced: Vec<&str> = card
1034            .fields
1035            .values()
1036            .filter_map(|f| f.ui.as_ref().and_then(|u| u.group.as_deref()))
1037            .collect();
1038
1039        match card.ui.as_ref().and_then(|u| u.groups.as_ref()) {
1040            Some(GroupRegistry(groups)) => {
1041                let mut ids: HashSet<&str> = HashSet::new();
1042                for g in groups {
1043                    if !Self::is_snake_case_identifier(&g.id) {
1044                        errors.push(
1045                            Diagnostic::new(
1046                                Severity::Error,
1047                                format!(
1048                                    "{label} group id '{}' must be snake_case (lowercase letters, \
1049                                     digits, and underscores only); the display label goes in \
1050                                     'title:'.",
1051                                    g.id
1052                                ),
1053                            )
1054                            .with_code("quill::invalid_group_id".to_string()),
1055                        );
1056                    }
1057                    // Insert regardless of snake_case validity so a reference to
1058                    // an ill-named id resolves: one diagnostic, not a cascade.
1059                    if !ids.insert(g.id.as_str()) {
1060                        errors.push(
1061                            Diagnostic::new(
1062                                Severity::Error,
1063                                format!("{label} declares group '{}' more than once.", g.id),
1064                            )
1065                            .with_code("quill::duplicate_group".to_string()),
1066                        );
1067                    }
1068                }
1069                // One diagnostic per distinct unresolved reference.
1070                let unresolved: BTreeSet<&str> =
1071                    referenced.iter().copied().filter(|g| !ids.contains(g)).collect();
1072                for group in unresolved {
1073                    errors.push(
1074                        Diagnostic::new(
1075                            Severity::Error,
1076                            format!(
1077                                "{label} field references group '{group}', which is not declared \
1078                                 in ui.groups. Add it to the registry, or fix the reference."
1079                            ),
1080                        )
1081                        .with_code("quill::unknown_group".to_string()),
1082                    );
1083                }
1084            }
1085            None => {
1086                if !referenced.is_empty() {
1087                    warnings.push(
1088                        Diagnostic::new(
1089                            Severity::Warning,
1090                            format!(
1091                                "{label} uses ui.group without a ui.groups registry (implicit \
1092                                 groups). Declare the groups under the card's ui.groups; implicit \
1093                                 groups are deprecated and become an error in a future release."
1094                            ),
1095                        )
1096                        .with_code("quill::implicit_group".to_string())
1097                        .with_hint(
1098                            "Add a ui.groups registry listing each group id, and reference the id \
1099                             from each field's ui.group."
1100                                .to_string(),
1101                        ),
1102                    );
1103                }
1104            }
1105        }
1106    }
1107
1108    /// Validate a single `example:` or `default:` literal against the declared
1109    /// schema, pushing `quill::*`-namespaced [`Diagnostic`]s for any violations.
1110    ///
1111    /// Delegates type/enum/format/recursion checking to
1112    /// [`super::validation::validate_schema_literal`] (the shared conformance
1113    /// primitive) then converts each [`ValidationError`] into a Quill.yaml
1114    /// load-time diagnostic with the appropriate `quill::{slot}_*` error code
1115    /// and author-friendly hint.
1116    fn validate_schema_slot(
1117        slot: &str,
1118        value: &QuillValue,
1119        schema: &FieldSchema,
1120        owner_label: &str,
1121        errors: &mut Vec<Diagnostic>,
1122    ) {
1123        use super::validation::{validate_schema_literal, ValidationError};
1124
1125        // A Quill.yaml schema-literal anchor (`$seed.<kind>`, a field label) is
1126        // config-space, not a document path; it rides the one serializer with
1127        // its prefix as an opaque head field.
1128        let owner_path = crate::path::DocPath::new().field(owner_label);
1129        for violation in validate_schema_literal(schema, value, &owner_path) {
1130            let diag = match &violation {
1131                ValidationError::TypeMismatch {
1132                    path,
1133                    actual,
1134                    source_token,
1135                    ..
1136                } => {
1137                    // Use the field's declared `type:` verbatim (`datetime`,
1138                    // `markdown`, …); the validator's `expected` collapses those
1139                    // to `string`, which would misreport the author's intent.
1140                    let declared = schema.r#type.as_str();
1141                    // validation.rs uses "number" for all non-integer JSON numbers;
1142                    // display as "float" so messages match the YAML author's mental model.
1143                    let display_actual = if actual == "number" {
1144                        "float"
1145                    } else {
1146                        actual.as_str()
1147                    };
1148                    // Show the offending value's content. A top-level mismatch
1149                    // renders the original literal (so arrays/objects show their
1150                    // contents); a nested mismatch is always a scalar, whose
1151                    // verbatim token is already the full value.
1152                    let preview = if path.as_str() == owner_label {
1153                        Self::literal_preview(value.as_json())
1154                    } else {
1155                        Self::truncate_preview(source_token)
1156                    };
1157                    let hint = if actual == "number" || actual == "integer" {
1158                        let schema_type = if actual == "integer" {
1159                            "integer"
1160                        } else {
1161                            "number"
1162                        };
1163                        format!(
1164                            "Quote the {slot} as \"{raw}\" if the value is intentionally a \
1165                             string, or change the field type to '{schema_type}'.",
1166                            raw = source_token.trim_matches('"'),
1167                        )
1168                    } else if actual == "string" {
1169                        format!(
1170                            "Remove the quotes around the {slot} value to keep it a {declared}."
1171                        )
1172                    } else {
1173                        format!(
1174                            "Make the {slot} value a {declared}, or change the field type to match."
1175                        )
1176                    };
1177                    Diagnostic::new(
1178                        Severity::Error,
1179                        format!(
1180                            "{owner_label} declares type '{declared}' but {slot} is {display_actual} ({preview})."
1181                        ),
1182                    )
1183                    .with_code(format!("quill::{slot}_type_mismatch"))
1184                    .with_hint(hint)
1185                }
1186                ValidationError::EnumViolation {
1187                    path,
1188                    value: val,
1189                    allowed,
1190                } => {
1191                    let values_str = allowed
1192                        .iter()
1193                        .map(|v| format!("\"{}\"", v))
1194                        .collect::<Vec<_>>()
1195                        .join(", ");
1196                    Diagnostic::new(
1197                        Severity::Error,
1198                        format!(
1199                            "{path} {slot} \"{val}\" is not one of the declared enum values [{values_str}]."
1200                        ),
1201                    )
1202                    .with_code(format!("quill::{slot}_not_in_enum"))
1203                    .with_hint(format!("Set the {slot} to one of: {values_str}."))
1204                }
1205                ValidationError::FormatViolation { path, format } => Diagnostic::new(
1206                    Severity::Error,
1207                    format!("{path} {slot} has an invalid {format} format."),
1208                )
1209                .with_code(format!("quill::{slot}_format_violation"))
1210                .with_hint(format!("Provide a valid {format} value for the {slot}.")),
1211                // UnknownCard, BodyDisabled do not apply to schema literals.
1212                _ => continue,
1213            };
1214            errors.push(diag);
1215        }
1216    }
1217
1218    /// Render a short, quoted preview of a value for an error message. Strings
1219    /// are quoted; everything else uses its JSON form. Long renderings are
1220    /// truncated (see [`Self::truncate_preview`]).
1221    fn literal_preview(value: &serde_json::Value) -> String {
1222        let raw = match value {
1223            serde_json::Value::String(s) => format!("\"{}\"", s),
1224            other => other.to_string(),
1225        };
1226        Self::truncate_preview(&raw)
1227    }
1228
1229    /// Truncate an already-rendered preview token to at most 60 characters,
1230    /// appending an ellipsis when it overflows.
1231    fn truncate_preview(raw: &str) -> String {
1232        const MAX: usize = 60;
1233        if raw.chars().count() > MAX {
1234            let truncated: String = raw.chars().take(MAX).collect();
1235            format!("{}…", truncated)
1236        } else {
1237            raw.to_string()
1238        }
1239    }
1240
1241    /// Parse fields from a JSON map into `FieldSchema`s (both `main.fields` and
1242    /// a card kind's `fields`). Declaration order rides the map itself: the
1243    /// source map preserves key order (serde_json's `preserve_order`) and the
1244    /// returned `IndexMap` keeps insertion order, so no ordering pass runs.
1245    /// `context` labels error messages (e.g. `"field schema"`,
1246    /// `"card_kind 'note' field"`).
1247    fn parse_fields(
1248        fields_map: &serde_json::Map<String, serde_json::Value>,
1249        context: &str,
1250        errors: &mut Vec<Diagnostic>,
1251    ) -> IndexMap<String, FieldSchema> {
1252        let mut fields = IndexMap::new();
1253
1254        for (field_name, field_value) in fields_map {
1255            if !Self::is_snake_case_identifier(field_name) {
1256                errors.push(
1257                    Diagnostic::new(
1258                        Severity::Error,
1259                        format!(
1260                            "Invalid {} '{}': field keys must be snake_case \
1261                             (lowercase letters, digits, and underscores only), \
1262                             and capitalized field keys are reserved.",
1263                            context, field_name
1264                        ),
1265                    )
1266                    .with_code("quill::invalid_field_name".to_string()),
1267                );
1268                continue;
1269            }
1270
1271            let quill_value = QuillValue::from_json(field_value.clone());
1272            match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
1273                Ok(schema) => {
1274                    // One recursive pass enforces the whole shape contract:
1275                    // containers carry the right child schema (`object` →
1276                    // `properties`, `array` → `items`), and nesting stops after
1277                    // one structural level (a typed table is the deepest shape).
1278                    if let Some(diag) =
1279                        Self::validate_field_schema_shape(&schema, field_name, ShapePosition::Top)
1280                    {
1281                        errors.push(diag);
1282                        continue;
1283                    }
1284
1285                    let owner = format!("{} '{}'", context, field_name);
1286                    Self::validate_field_blueprint_constraints(&schema, &owner, errors);
1287
1288                    fields.insert(field_name.clone(), schema);
1289                }
1290                Err(e) => {
1291                    let hint = Self::field_parse_hint(field_value);
1292                    let mut diag = Diagnostic::new(
1293                        Severity::Error,
1294                        format!("Failed to parse {} '{}': {}", context, field_name, e),
1295                    )
1296                    .with_code("quill::field_parse_error".to_string());
1297                    if let Some(h) = hint {
1298                        diag = diag.with_hint(h);
1299                    }
1300                    errors.push(diag);
1301                }
1302            }
1303        }
1304
1305        fields
1306    }
1307
1308    /// Produce an actionable hint for common field schema mistakes based on the raw value.
1309    fn field_parse_hint(field_value: &serde_json::Value) -> Option<String> {
1310        if let Some(obj) = field_value.as_object() {
1311            if obj.contains_key("title") {
1312                return Some(
1313                    "'title' is not a valid field key; use 'description' instead.".to_string(),
1314                );
1315            }
1316            if obj
1317                .get("ui")
1318                .and_then(|u| u.as_object())
1319                .is_some_and(|u| u.contains_key("order"))
1320            {
1321                return Some(format!("{UI_ORDER_REMOVED_MSG}."));
1322            }
1323            if obj.get("type").and_then(|v| v.as_str()) == Some("richtext(inline)") {
1324                return Some(format!("{RICHTEXT_INLINE_TOKEN_MSG}."));
1325            }
1326            if obj.get("type").and_then(|v| v.as_str()) == Some("markdown") {
1327                return Some(
1328                    "'markdown' is no longer a field type; use type: richtext (block) \
1329                     or type: richtext with inline: true."
1330                        .to_string(),
1331                );
1332            }
1333        }
1334        None
1335    }
1336
1337    fn is_snake_case_identifier(name: &str) -> bool {
1338        let mut chars = name.chars();
1339        match chars.next() {
1340            Some(c) if c.is_ascii_lowercase() => {}
1341            _ => return false,
1342        }
1343
1344        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
1345    }
1346
1347    fn is_valid_quill_name(name: &str) -> bool {
1348        name == "__default__" || Self::is_snake_case_identifier(name)
1349    }
1350
1351    /// Parse QuillConfig from YAML content while collecting non-fatal warnings.
1352    ///
1353    /// Returns `Ok((config, warnings))` on success, or `Err(errors)` containing all
1354    /// parse/validation errors when the config is invalid. Errors are always collected
1355    /// exhaustively: callers see every problem, not just the first.
1356    pub fn from_yaml_with_warnings(
1357        yaml_content: &str,
1358    ) -> Result<(Self, Vec<Diagnostic>), Vec<Diagnostic>> {
1359        let mut warnings: Vec<Diagnostic> = Vec::new();
1360        let mut errors: Vec<Diagnostic> = Vec::new();
1361
1362        // Parse YAML into serde_json::Value via serde_saphyr. The depth budget
1363        // bounds nesting so an untrusted Quill.yaml cannot overflow the stack.
1364        // Note: serde_json with "preserve_order" feature is required for this to work as expected
1365        let quill_yaml_val: serde_json::Value = match serde_saphyr::from_str_with_options(
1366            yaml_content,
1367            crate::document::limits::yaml_parse_options(),
1368        ) {
1369            Ok(v) => v,
1370            Err(e) => {
1371                // Through `YamlError` so this shares the one saphyr adapter:
1372                // the engine's Rust API names stripped, the hint derived, and
1373                // the position carried as a `Location`.
1374                return Err(vec![crate::error::YamlError::from_de(e, yaml_content)
1375                    .to_diagnostic("quill::yaml_parse_error", "Quill.yaml")]);
1376            }
1377        };
1378
1379        // Extract [quill] section (required): fail immediately if absent since all
1380        // subsequent validation depends on it.
1381        let quill_section = match quill_yaml_val.get("quill") {
1382            Some(v) => v,
1383            None => {
1384                return Err(vec![Diagnostic::new(
1385                    Severity::Error,
1386                    "Missing required 'quill' section in Quill.yaml".to_string(),
1387                )
1388                .with_code("quill::missing_section".to_string())
1389                .with_hint(
1390                    "Add a 'quill:' section with name, backend, version, and description."
1391                        .to_string(),
1392                )]);
1393            }
1394        };
1395
1396        // Validate that no unknown keys appear in the [quill] section.
1397        const KNOWN_QUILL_KEYS: &[&str] =
1398            &["name", "backend", "description", "version", "author", "ui"];
1399        if let Some(quill_obj) = quill_section.as_object() {
1400            for key in quill_obj.keys() {
1401                if !KNOWN_QUILL_KEYS.contains(&key.as_str()) {
1402                    errors.push(
1403                        Diagnostic::new(
1404                            Severity::Error,
1405                            format!("Unknown key '{}' in 'quill:' section", key),
1406                        )
1407                        .with_code("quill::unknown_key".to_string())
1408                        .with_hint(format!("Valid keys are: {}", KNOWN_QUILL_KEYS.join(", "))),
1409                    );
1410                }
1411            }
1412        }
1413
1414        // Extract required fields: collect all missing-field errors before returning.
1415        let name = match quill_section.get("name").and_then(|v| v.as_str()) {
1416            Some(n) => {
1417                if !Self::is_valid_quill_name(n) {
1418                    errors.push(
1419                        Diagnostic::new(
1420                            Severity::Error,
1421                            format!(
1422                                "Invalid Quill name '{}': quill.name must be snake_case \
1423                                 (lowercase letters, digits, and underscores only).",
1424                                n
1425                            ),
1426                        )
1427                        .with_code("quill::invalid_name".to_string())
1428                        .with_hint(format!(
1429                            "Rename '{}' to '{}'",
1430                            n,
1431                            n.to_lowercase().replace('-', "_")
1432                        )),
1433                    );
1434                }
1435                n.to_string()
1436            }
1437            None => {
1438                errors.push(
1439                    Diagnostic::new(
1440                        Severity::Error,
1441                        "Missing required 'name' field in 'quill' section".to_string(),
1442                    )
1443                    .with_code("quill::missing_name".to_string())
1444                    .with_hint(
1445                        "Add 'name: your_quill_name' under the 'quill:' section.".to_string(),
1446                    ),
1447                );
1448                String::new()
1449            }
1450        };
1451
1452        let backend = match quill_section.get("backend").and_then(|v| v.as_str()) {
1453            Some(b) => b.to_string(),
1454            None => {
1455                errors.push(
1456                    Diagnostic::new(
1457                        Severity::Error,
1458                        "Missing required 'backend' field in 'quill' section".to_string(),
1459                    )
1460                    .with_code("quill::missing_backend".to_string())
1461                    .with_hint("Add 'backend: typst' (or another supported backend).".to_string()),
1462                );
1463                String::new()
1464            }
1465        };
1466
1467        let description = match quill_section.get("description").and_then(|v| v.as_str()) {
1468            Some(d) if !d.trim().is_empty() => {
1469                Self::validate_description_singleline(Some(d), "quill", &mut errors);
1470                d.to_string()
1471            }
1472            Some(_) => {
1473                errors.push(
1474                    Diagnostic::new(
1475                        Severity::Error,
1476                        "'description' field in 'quill' section cannot be empty".to_string(),
1477                    )
1478                    .with_code("quill::empty_description".to_string()),
1479                );
1480                String::new()
1481            }
1482            None => {
1483                errors.push(
1484                    Diagnostic::new(
1485                        Severity::Error,
1486                        "Missing required 'description' field in 'quill' section".to_string(),
1487                    )
1488                    .with_code("quill::missing_description".to_string())
1489                    .with_hint("Add a brief 'description:' of what this quill is for.".to_string()),
1490                );
1491                String::new()
1492            }
1493        };
1494
1495        // Extract the required `version` field.
1496        let version = match quill_section.get("version") {
1497            Some(version_val) => {
1498                // Handle version as string or number (YAML might parse 1.0 as number)
1499                let raw = if let Some(s) = version_val.as_str() {
1500                    s.to_string()
1501                } else if let Some(n) = version_val.as_f64() {
1502                    n.to_string()
1503                } else {
1504                    errors.push(
1505                        Diagnostic::new(
1506                            Severity::Error,
1507                            "Invalid 'version' field format".to_string(),
1508                        )
1509                        .with_code("quill::invalid_version".to_string())
1510                        .with_hint("Use semver format: '1.0' or '1.0.0'.".to_string()),
1511                    );
1512                    String::new()
1513                };
1514                if !raw.is_empty() {
1515                    use std::str::FromStr;
1516                    if let Err(e) = crate::version::Version::from_str(&raw) {
1517                        errors.push(
1518                            Diagnostic::new(
1519                                Severity::Error,
1520                                format!("Invalid version '{}': {}", raw, e),
1521                            )
1522                            .with_code("quill::invalid_version".to_string())
1523                            .with_hint("Use semver format: '1.0' or '1.0.0'.".to_string()),
1524                        );
1525                    }
1526                }
1527                raw
1528            }
1529            None => {
1530                errors.push(
1531                    Diagnostic::new(
1532                        Severity::Error,
1533                        "Missing required 'version' field in 'quill' section".to_string(),
1534                    )
1535                    .with_code("quill::missing_version".to_string())
1536                    .with_hint("Add 'version: 1.0' under the 'quill:' section.".to_string()),
1537                );
1538                String::new()
1539            }
1540        };
1541
1542        let author = quill_section
1543            .get("author")
1544            .and_then(|v| v.as_str())
1545            .map(|s| s.to_string())
1546            .unwrap_or_else(|| "Unknown".to_string());
1547
1548        let ui_section: Option<UiCardSchema> = match quill_section.get("ui").cloned() {
1549            None => None,
1550            Some(v) => match serde_json::from_value::<UiCardSchema>(v) {
1551                Ok(parsed) => Some(parsed),
1552                Err(e) => {
1553                    errors.push(
1554                        Diagnostic::new(
1555                            Severity::Error,
1556                            format!("Invalid 'quill.ui' block: {}", e),
1557                        )
1558                        .with_code("quill::invalid_ui".to_string())
1559                        .with_hint("Valid keys under 'ui' are: title, groups.".to_string()),
1560                    );
1561                    None
1562                }
1563            },
1564        };
1565
1566        // Extract optional backend-specific section (keyed by `quill.backend`).
1567        let mut backend_config = HashMap::new();
1568        if !backend.is_empty() {
1569            if let Some(section_val) = quill_yaml_val.get(&backend) {
1570                if let Some(table) = section_val.as_object() {
1571                    for (key, value) in table {
1572                        backend_config.insert(key.clone(), QuillValue::from_json(value.clone()));
1573                    }
1574                }
1575            }
1576        }
1577
1578        // Reject unknown top-level sections. Known sections are: quill, main, card_kinds,
1579        // and the backend name (e.g. typst). Everything else is a mistake. `fields` gets
1580        // a targeted hint since it's the most common shape mistake.
1581        if let Some(top_obj) = quill_yaml_val.as_object() {
1582            for key in top_obj.keys() {
1583                let is_known = key == "quill"
1584                    || key == "main"
1585                    || key == "card_kinds"
1586                    || (!backend.is_empty() && key == &backend);
1587                if is_known {
1588                    continue;
1589                }
1590
1591                let mut diag = Diagnostic::new(
1592                    Severity::Error,
1593                    format!("Unknown top-level section '{}'", key),
1594                )
1595                .with_code("quill::unknown_section".to_string());
1596
1597                diag = if key == "fields" {
1598                    diag.with_hint(
1599                        "Root-level `fields` is not supported; use `main.fields` instead."
1600                            .to_string(),
1601                    )
1602                } else {
1603                    diag.with_hint(format!(
1604                        "Valid top-level sections are: quill, main, card_kinds{}",
1605                        if backend.is_empty() {
1606                            String::new()
1607                        } else {
1608                            format!(", {}", backend)
1609                        }
1610                    ))
1611                };
1612
1613                errors.push(diag);
1614            }
1615        }
1616
1617        let main_obj_opt = quill_yaml_val.get("main").and_then(|v| v.as_object());
1618
1619        // Extract main.fields (optional)
1620        let fields = if let Some(fields_map) = main_obj_opt
1621            .and_then(|main_obj| main_obj.get("fields"))
1622            .and_then(|v| v.as_object())
1623        {
1624            Self::parse_fields(fields_map, "field schema", &mut errors)
1625        } else {
1626            IndexMap::new()
1627        };
1628
1629        // Extract main.ui (optional). Fail loudly on malformed UI metadata rather
1630        // than silently dropping it; see `quill.ui` handling above.
1631        let main_ui: Option<UiCardSchema> = match main_obj_opt
1632            .and_then(|main_obj| main_obj.get("ui"))
1633            .cloned()
1634        {
1635            None => None,
1636            Some(v) => match serde_json::from_value::<UiCardSchema>(v) {
1637                Ok(parsed) => Some(parsed),
1638                Err(e) => {
1639                    errors.push(
1640                        Diagnostic::new(Severity::Error, format!("Invalid 'main.ui' block: {}", e))
1641                            .with_code("quill::invalid_ui".to_string())
1642                            .with_hint("Valid keys under 'ui' are: title, groups.".to_string()),
1643                    );
1644                    None
1645                }
1646            },
1647        };
1648
1649        // Extract main.body (optional). Fail loudly on malformed body metadata.
1650        let main_body: Option<BodyCardSchema> = match main_obj_opt
1651            .and_then(|main_obj| main_obj.get("body"))
1652            .cloned()
1653        {
1654            None => None,
1655            Some(v) => match serde_json::from_value::<BodyCardSchema>(v) {
1656                Ok(parsed) => Some(parsed),
1657                Err(e) => {
1658                    errors.push(
1659                        Diagnostic::new(
1660                            Severity::Error,
1661                            format!("Invalid 'main.body' block: {}", e),
1662                        )
1663                        .with_code("quill::invalid_body".to_string())
1664                        .with_hint("Valid keys under 'body' are: enabled, example.".to_string()),
1665                    );
1666                    None
1667                }
1668            },
1669        };
1670
1671        // Extract main.description (optional, authored under `main:` like any
1672        // other card kind). This is independent of `quill.description`.
1673        let main_description = main_obj_opt
1674            .and_then(|main_obj| main_obj.get("description"))
1675            .and_then(|v| v.as_str())
1676            .map(|s| s.to_string());
1677        Self::validate_description_singleline(main_description.as_deref(), "main", &mut errors);
1678
1679        // The main entry-point card.
1680        let mut main = CardSchema {
1681            name: "main".to_string(),
1682            description: main_description,
1683            fields,
1684            ui: main_ui.or(ui_section),
1685            body: main_body,
1686        };
1687
1688        // Extract [card_kinds] section (optional)
1689        let mut card_kinds: Vec<CardSchema> = Vec::new();
1690        if let Some(card_kinds_val) = quill_yaml_val.get("card_kinds") {
1691            match card_kinds_val.as_object() {
1692                None => {
1693                    errors.push(
1694                        Diagnostic::new(
1695                            Severity::Error,
1696                            "'card_kinds' section must be an object (mapping of kind names to schemas)".to_string(),
1697                        )
1698                        .with_code("quill::invalid_card_kinds".to_string()),
1699                    );
1700                }
1701                Some(card_kinds_table) => {
1702                    for (card_name, card_value) in card_kinds_table {
1703                        if !crate::document::is_valid_kind_name(card_name) {
1704                            errors.push(
1705                                Diagnostic::new(
1706                                    Severity::Error,
1707                                    format!(
1708                                        "Invalid card-kind name '{}': names must match \
1709                                         [a-z_][a-z0-9_]* (lowercase letters, digits, and underscores only).",
1710                                        card_name
1711                                    ),
1712                                )
1713                                .with_code("quill::invalid_card_name".to_string()),
1714                            );
1715                            continue;
1716                        }
1717
1718                        // Parse card basic info using serde
1719                        let card_def: CardSchemaDef =
1720                            match serde_json::from_value(card_value.clone()) {
1721                                Ok(d) => d,
1722                                Err(e) => {
1723                                    errors.push(
1724                                        Diagnostic::new(
1725                                            Severity::Error,
1726                                            format!(
1727                                                "Failed to parse card_kind '{}': {}",
1728                                                card_name, e
1729                                            ),
1730                                        )
1731                                        .with_code("quill::invalid_card_schema".to_string()),
1732                                    );
1733                                    continue;
1734                                }
1735                            };
1736
1737                        // Parse card fields
1738                        let card_fields = if let Some(card_fields_table) =
1739                            card_value.get("fields").and_then(|v| v.as_object())
1740                        {
1741                            Self::parse_fields(
1742                                card_fields_table,
1743                                &format!("card_kind '{}' field", card_name),
1744                                &mut errors,
1745                            )
1746                        } else {
1747                            IndexMap::new()
1748                        };
1749
1750                        Self::validate_description_singleline(
1751                            card_def.description.as_deref(),
1752                            &format!("card_kind '{}'", card_name),
1753                            &mut errors,
1754                        );
1755                        card_kinds.push(CardSchema {
1756                            name: card_name.clone(),
1757                            description: card_def.description,
1758                            fields: card_fields,
1759                            ui: card_def.ui,
1760                            body: card_def.body,
1761                        });
1762                    }
1763                }
1764            }
1765        }
1766
1767        // Warn when `body.example` is set together with `body.enabled: false`:
1768        // the example has no effect since the body editor is disabled.
1769        let warn_example_unused = |label: &str,
1770                                   body: &Option<BodyCardSchema>|
1771         -> Option<Diagnostic> {
1772            let body = body.as_ref()?;
1773            if body.enabled == Some(false) && body.example.is_some() {
1774                Some(
1775                    Diagnostic::new(
1776                        Severity::Warning,
1777                        format!(
1778                            "`{label}.body.example` is set but `{label}.body.enabled` is false; the example will have no effect"
1779                        ),
1780                    )
1781                    .with_code("quill::body_example_unused".to_string())
1782                    .with_hint(
1783                        "Set `body.enabled: true` to surface the example, or remove `body.example`."
1784                            .to_string(),
1785                    ),
1786                )
1787            } else {
1788                None
1789            }
1790        };
1791        if let Some(d) = warn_example_unused("main", &main.body) {
1792            warnings.push(d);
1793        }
1794        for card in &card_kinds {
1795            if let Some(d) = warn_example_unused(&format!("card_kinds.{}", card.name), &card.body) {
1796                warnings.push(d);
1797            }
1798        }
1799
1800        // Validate each card's group registry and its fields' group references.
1801        Self::validate_card_groups("main", &main, &mut errors, &mut warnings);
1802        for card in &card_kinds {
1803            Self::validate_card_groups(
1804                &format!("card_kinds.{}", card.name),
1805                card,
1806                &mut errors,
1807                &mut warnings,
1808            );
1809        }
1810
1811        // Error when `body.example` contains a line that the document parser
1812        // would interpret as a `~~~` card-yaml block opener. Such a line would
1813        // start a new metadata block, corrupting document structure.
1814        let err_example_contains_fence = |label: &str,
1815                                          body: &Option<BodyCardSchema>|
1816         -> Option<Diagnostic> {
1817            let example = body.as_ref()?.example.as_deref()?;
1818            if example_contains_fence_line(example) {
1819                Some(
1820                    Diagnostic::new(
1821                        Severity::Error,
1822                        format!(
1823                            "`{label}.body.example` contains a line that would be parsed as a `~~~` card-yaml block opener; this would corrupt the blueprint"
1824                        ),
1825                    )
1826                    .with_code("quill::body_example_contains_fence".to_string())
1827                    .with_hint(
1828                        "Remove or reword any column-zero line that opens a card-yaml block (`~~~`, a longer tilde run, or `~~~card-yaml`). For a literal fenced code block, use a backtick fence (```).".to_string(),
1829                    ),
1830                )
1831            } else {
1832                None
1833            }
1834        };
1835        if let Some(d) = err_example_contains_fence("main", &main.body) {
1836            errors.push(d);
1837        }
1838        for card in &card_kinds {
1839            if let Some(d) =
1840                err_example_contains_fence(&format!("card_kinds.{}", card.name), &card.body)
1841            {
1842                errors.push(d);
1843            }
1844        }
1845
1846        // Import every richtext `default` / `example` / `body.example` literal
1847        // once into its canonical-content companion cache: a pure function of the
1848        // Quill.yaml bytes, never serialized. This is where `richtext(inline)`
1849        // violations and malformed richtext literals surface as load errors, and
1850        // where seeding and the render floor later read a pre-validated content
1851        // instead of re-importing the markdown per document.
1852        populate_card_content(&mut main, "main", &mut errors);
1853        for card in &mut card_kinds {
1854            let label = format!("card_kinds.{}", card.name);
1855            populate_card_content(card, &label, &mut errors);
1856        }
1857
1858        if !errors.is_empty() {
1859            return Err(errors);
1860        }
1861
1862        Ok((
1863            QuillConfig {
1864                name,
1865                description,
1866                main,
1867                card_kinds,
1868                backend,
1869                version,
1870                author,
1871                backend_config,
1872            },
1873            warnings,
1874        ))
1875    }
1876}
1877
1878/// Returns true if any line in `text` would be parsed as a card-yaml block
1879/// opener by the document parser, which would corrupt the blueprint's document
1880/// structure when the example is embedded verbatim as body content.
1881///
1882/// Delegates to the parser's own opener predicate
1883/// ([`crate::document::fences::is_card_yaml_opener_line`]) so the guard stays
1884/// in lock-step with fence detection: a column-zero tilde fence (three or more
1885/// tildes) whose info string is empty or `card-yaml`. Backtick fences,
1886/// language-tagged `~~~` fences, and indented fences are ordinary code blocks
1887/// and are not flagged.
1888fn example_contains_fence_line(text: &str) -> bool {
1889    text.lines().any(|line| {
1890        let line = line.strip_suffix('\r').unwrap_or(line);
1891        crate::document::fences::is_card_yaml_opener_line(line)
1892    })
1893}
1894
1895/// Whether a field's type tree contains any content leaf: the gate for caching
1896/// a content companion. Both `richtext` and its literal-codec sibling `plaintext`
1897/// are content leaves; a scalar (`string`, `integer`, `enum`, …) never carries
1898/// one; an `array<richtext>` or an `object` with a content property does.
1899pub(crate) fn field_contains_content(field: &FieldSchema) -> bool {
1900    match &field.r#type {
1901        FieldType::RichText { .. } | FieldType::PlainText { .. } => true,
1902        FieldType::Array => field.items.as_deref().is_some_and(field_contains_content),
1903        FieldType::Object => field
1904            .properties
1905            .as_ref()
1906            .is_some_and(|p| p.values().any(|f| field_contains_content(f))),
1907        _ => false,
1908    }
1909}
1910
1911/// Populate a field's `default_content` / `example_content` companion caches from
1912/// its markdown literals. No-op for a non-richtext field; a failed import or a
1913/// `richtext(inline)` violation is appended to `errors` as a load diagnostic.
1914fn populate_field_content(field: &mut FieldSchema, owner: &str, errors: &mut Vec<Diagnostic>) {
1915    if !field_contains_content(field) {
1916        return;
1917    }
1918    if let Some(default) = field.default.clone() {
1919        match literal_content(&default, field, &format!("{owner} `default`")) {
1920            Ok(content) => field.default_content = content,
1921            Err(d) => errors.push(d),
1922        }
1923    }
1924    if let Some(example) = field.example.clone() {
1925        match literal_content(&example, field, &format!("{owner} `example`")) {
1926            Ok(content) => field.example_content = content,
1927            Err(d) => errors.push(d),
1928        }
1929    }
1930}
1931
1932/// Populate every content companion on a card: each field's
1933/// `default`/`example`, and the card's `body.example` (block richtext, no
1934/// inline constraint; skipped when the body is disabled, since its example is
1935/// inert).
1936fn populate_card_content(card: &mut CardSchema, label: &str, errors: &mut Vec<Diagnostic>) {
1937    for (name, field) in card.fields.iter_mut() {
1938        populate_field_content(field, &format!("{label} field `{name}`"), errors);
1939    }
1940    let body_enabled = card.body.as_ref().is_none_or(|b| b.enabled != Some(false));
1941    if body_enabled {
1942        if let Some(body) = card.body.as_mut() {
1943            if let Some(example) = body.example.clone() {
1944                match crate::document::import_body(&example) {
1945                    Ok(rt) => {
1946                        body.example_content = Some(QuillValue::from_json(
1947                            quillmark_content::serial::to_canonical_value(&rt),
1948                        ));
1949                    }
1950                    Err(e) => errors.push(
1951                        Diagnostic::new(
1952                            Severity::Error,
1953                            format!("Failed to import {label} `body.example`: {e}"),
1954                        )
1955                        .with_code("quill::richtext_example_import".to_string()),
1956                    ),
1957                }
1958            }
1959        }
1960    }
1961}
1962
1963/// Compute the canonical-content form of a richtext-bearing schema literal
1964/// (`default` / `example`), importing every markdown leaf once and enforcing
1965/// `richtext(inline)`. Recurses through `array` / `object` shapes, converting
1966/// only their richtext leaves and passing other elements through unchanged.
1967/// `Ok(None)` when the literal carries no importable richtext (a null value, or
1968/// a field the gate already cleared as non-richtext); `Err` is a load error.
1969fn literal_content(
1970    value: &QuillValue,
1971    field: &FieldSchema,
1972    label: &str,
1973) -> Result<Option<QuillValue>, Diagnostic> {
1974    let json = value.as_json();
1975    // Null ≡ absent: no data to import, so no companion is cached.
1976    if json.is_null() {
1977        return Ok(None);
1978    }
1979    match &field.r#type {
1980        FieldType::RichText { inline } => {
1981            let rt = match crate::document::decode_richtext_value(json) {
1982                Some(Ok(rt)) => rt,
1983                Some(Err(e)) => {
1984                    let reason = match e {
1985                        crate::document::RichtextDecodeError::BadMarkdown(m) => {
1986                            format!("markdown import failed: {m}")
1987                        }
1988                        crate::document::RichtextDecodeError::NotContent(m) => {
1989                            format!("not a valid richtext content: {m}")
1990                        }
1991                    };
1992                    return Err(richtext_literal_error(label, &reason));
1993                }
1994                None => {
1995                    return Err(richtext_literal_error(
1996                        label,
1997                        "expected a markdown string (richtext literals are authored as markdown)",
1998                    ));
1999                }
2000            };
2001            if *inline && !rt.is_inline() {
2002                return Err(richtext_inline_error(label));
2003            }
2004            Ok(Some(QuillValue::from_json(
2005                quillmark_content::serial::to_canonical_value(&rt),
2006            )))
2007        }
2008        FieldType::PlainText { inline } => {
2009            // Plaintext literals are authored as literal strings and imported
2010            // verbatim (never markdown), so the cached content is plain by
2011            // construction; a content-object literal is revalidated. Shares the
2012            // one object-vs-string dispatch with the validation shape check.
2013            let rt = match crate::document::decode_plaintext_value(json) {
2014                Some(Ok(rt)) => rt,
2015                Some(Err(e)) => {
2016                    return Err(richtext_literal_error(
2017                        label,
2018                        &format!("not a valid richtext content: {e}"),
2019                    ))
2020                }
2021                None => {
2022                    return Err(richtext_literal_error(
2023                        label,
2024                        "expected a plaintext string (plaintext literals are authored as literal text)",
2025                    ))
2026                }
2027            };
2028            if !rt.is_plain() {
2029                return Err(richtext_literal_error(
2030                    label,
2031                    "plaintext carries no marks, islands, or block formatting",
2032                ));
2033            }
2034            if *inline && !rt.is_inline() {
2035                return Err(richtext_inline_error(label));
2036            }
2037            Ok(Some(QuillValue::from_json(
2038                quillmark_content::serial::to_canonical_value(&rt),
2039            )))
2040        }
2041        FieldType::Array => {
2042            let Some(items) = field.items.as_deref() else {
2043                return Ok(None);
2044            };
2045            if !field_contains_content(items) {
2046                return Ok(None);
2047            }
2048            let arr = json.as_array().cloned().unwrap_or_default();
2049            let mut out = Vec::with_capacity(arr.len());
2050            for (idx, elem) in arr.iter().enumerate() {
2051                let elem_v = QuillValue::from_json(elem.clone());
2052                let content =
2053                    literal_content(&elem_v, items, &format!("{label}[{idx}]"))?.unwrap_or(elem_v);
2054                out.push(content.into_json());
2055            }
2056            Ok(Some(QuillValue::from_json(serde_json::Value::Array(out))))
2057        }
2058        FieldType::Object => {
2059            let Some(props) = field.properties.as_ref() else {
2060                return Ok(None);
2061            };
2062            if !props.values().any(|f| field_contains_content(f)) {
2063                return Ok(None);
2064            }
2065            let obj = json.as_object().cloned().unwrap_or_default();
2066            let mut out = serde_json::Map::new();
2067            for (k, v) in &obj {
2068                let converted = match props.get(k) {
2069                    Some(pschema) => {
2070                        let pv = QuillValue::from_json(v.clone());
2071                        literal_content(&pv, pschema, &format!("{label}.{k}"))?
2072                            .map(QuillValue::into_json)
2073                            .unwrap_or_else(|| v.clone())
2074                    }
2075                    None => v.clone(),
2076                };
2077                out.insert(k.clone(), converted);
2078            }
2079            Ok(Some(QuillValue::from_json(serde_json::Value::Object(out))))
2080        }
2081        _ => Ok(None),
2082    }
2083}
2084
2085/// A load diagnostic for a richtext schema literal that failed to import.
2086fn richtext_literal_error(label: &str, reason: &str) -> Diagnostic {
2087    Diagnostic::new(
2088        Severity::Error,
2089        format!("Failed to import richtext {label}: {reason}"),
2090    )
2091    .with_code("quill::richtext_example_import".to_string())
2092}
2093
2094/// A load diagnostic for a `richtext(inline)` schema literal whose content spans
2095/// more than a single paragraph.
2096fn richtext_inline_error(label: &str) -> Diagnostic {
2097    Diagnostic::new(
2098        Severity::Error,
2099        format!(
2100            "richtext(inline) {label} must be a single paragraph (no blank lines, \
2101             headings, lists, quotes, or tables)"
2102        ),
2103    )
2104    .with_code("validation::not_inline".to_string())
2105    .with_hint(
2106        "Reduce the value to one paragraph, or change the field `type:` to `richtext`.".to_string(),
2107    )
2108}
2109
2110#[cfg(test)]
2111impl QuillConfig {
2112    /// The config, or every load diagnostic joined into one pretty string.
2113    /// Flattening `Vec<Diagnostic>` drops code, hint, and location, so the
2114    /// shape stays off the published surface; a test asserting on message text
2115    /// is the one caller that loss costs nothing.
2116    /// [`from_yaml_with_warnings`](Self::from_yaml_with_warnings) is the real
2117    /// load path.
2118    pub(crate) fn from_yaml(yaml_content: &str) -> Result<Self, String> {
2119        Self::from_yaml_with_warnings(yaml_content)
2120            .map(|(config, _warnings)| config)
2121            .map_err(|diags| {
2122                diags
2123                    .iter()
2124                    .map(|d| d.fmt_pretty())
2125                    .collect::<Vec<_>>()
2126                    .join("\n")
2127            })
2128    }
2129}