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