Skip to main content

quillmark_core/quill/
config.rs

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