Skip to main content

quillmark_core/quill/
validation.rs

1use std::collections::BTreeMap;
2
3use indexmap::IndexMap;
4
5use crate::document::Document;
6use crate::error::{Diagnostic, Severity, diag_args};
7use crate::path::DocPath;
8use crate::quill::formats::{is_valid_date, is_valid_datetime};
9use crate::quill::{CardSchema, FieldSchema, FieldType, QuillConfig};
10use crate::value::QuillValue;
11
12/// Validation error with a structured field path.
13///
14/// Field-level type and presence errors carry the field path, the
15/// schema-declared type, and any verbatim YAML source token / default:
16/// enough for the `Display` impl to render the uniform diagnostic message
17/// described in `ERROR.md` ("Validation message contract").
18///
19/// Two concerns are deliberately *not* well-formedness errors and so have no
20/// variant here: the `!must_fill` marker (surfaced as a non-fatal warning by
21/// `Quill::validate`) and field absence (an absent or present-null field
22/// zero-fills at render). Both are handled outside the value-layer checks below.
23#[derive(Debug, Clone, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum ValidationError {
26    TypeMismatch {
27        path: String,
28        /// Schema-declared type (`string`, `integer`, …).
29        expected: String,
30        /// YAML-parsed type of the source token (`integer`, `number`,
31        /// `boolean`, `null`, `string`, `array`, `object`).
32        actual: String,
33        /// Verbatim YAML scalar that triggered the error, rendered in
34        /// its canonical YAML form (`42`, `null`, `"hello"`, `""`).
35        source_token: String,
36        /// Pre-rendered default token from the schema, when present.
37        /// Same canonical YAML form as `source_token`.
38        default: Option<String>,
39    },
40
41    EnumViolation {
42        path: String,
43        value: String,
44        allowed: Vec<String>,
45    },
46
47    FormatViolation {
48        path: String,
49        format: String,
50    },
51
52    UnknownCard {
53        path: String,
54        card: String,
55    },
56
57    BodyDisabled {
58        path: String,
59        card: String,
60    },
61
62    /// A `richtext(inline)` field whose content is not single-`Para` (a block, a
63    /// list/quote container, or an island). Same fatality class as
64    /// `TypeMismatch`: the value is well-typed richtext but the wrong *shape*
65    /// for an inline field.
66    NotInline {
67        path: String,
68    },
69
70    /// A `plaintext` field whose content carries marks, islands, or block
71    /// formatting. Same fatality class as `TypeMismatch`: the value is a
72    /// well-formed content but the wrong *shape* for a plaintext field, which
73    /// takes prose the author navigates but no formatting.
74    NotPlain {
75        path: String,
76    },
77}
78
79impl std::error::Error for ValidationError {}
80
81impl std::fmt::Display for ValidationError {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            ValidationError::TypeMismatch {
85                path,
86                expected,
87                actual,
88                source_token,
89                default,
90            } => {
91                // Line 1: what we got vs what the schema says.
92                write!(
93                    f,
94                    "Field `{path}` got {actual} `{source_token}`, schema declares `{expected}`"
95                )?;
96                if let Some(d) = default {
97                    write!(f, " with default `{d}`")?;
98                }
99                write!(
100                    f,
101                    ". {hint}",
102                    hint = type_mismatch_hint(expected, actual, default.as_deref())
103                )
104            }
105            ValidationError::EnumViolation {
106                path,
107                value,
108                allowed,
109            } => {
110                write!(
111                    f,
112                    "field `{path}` value `{value}` not in allowed set {allowed:?}"
113                )
114            }
115            ValidationError::FormatViolation { path, format } => {
116                write!(
117                    f,
118                    "field `{path}` does not match expected format `{format}`"
119                )
120            }
121            ValidationError::UnknownCard { path, card } => {
122                write!(f, "unknown card kind `{card}` at `{path}`")
123            }
124            ValidationError::BodyDisabled { path, card } => {
125                write!(
126                    f,
127                    "card `{card}` at `{path}` has body content but the card kind declares `body.enabled: false`: {hint}",
128                    hint = body_disabled_hint(),
129                )
130            }
131            ValidationError::NotInline { path } => {
132                write!(
133                    f,
134                    "field `{path}` is `richtext(inline)` but its content is not a single \
135                     paragraph: {hint}",
136                    hint = not_inline_hint(),
137                )
138            }
139            ValidationError::NotPlain { path } => {
140                write!(
141                    f,
142                    "field `{path}` is `plaintext` but its content carries formatting: {hint}",
143                    hint = not_plain_hint(),
144                )
145            }
146        }
147    }
148}
149
150/// Actionable exit clause for a TypeMismatch. Mirrors the (expected, actual,
151/// has_default) branching in `Display` so the structured hint and the prose
152/// message can never disagree.
153fn type_mismatch_hint(expected: &str, actual: &str, default: Option<&str>) -> String {
154    if default.is_some() {
155        format!(
156            "Either omit the line (the default will fill in) or provide a value of type `{expected}`."
157        )
158    } else {
159        format!(
160            "Either provide a value of type `{expected}` or change the schema's `type:` to `{actual}`."
161        )
162    }
163}
164
165/// Actionable exit clause for a `BodyDisabled` error. Same text in both the
166/// prose message and the structured hint.
167fn body_disabled_hint() -> &'static str {
168    "remove the body content or set `body.enabled: true` on the card kind"
169}
170
171/// Actionable exit clause for a `NotInline` error.
172fn not_inline_hint() -> &'static str {
173    "keep the value to a single paragraph (no blank lines, headings, lists, \
174     quotes, or tables), or change the schema's `type:` to `richtext`"
175}
176
177/// Actionable exit clause for a `NotPlain` error.
178fn not_plain_hint() -> &'static str {
179    "remove the formatting (marks, tables, images, headings, lists, quotes), or \
180     change the schema's `type:` to `richtext`"
181}
182
183impl ValidationError {
184    /// Document-model path anchor for this error.
185    ///
186    /// See [`crate::error`] module docs for the path grammar and conventions.
187    pub fn path(&self) -> &str {
188        match self {
189            ValidationError::TypeMismatch { path, .. }
190            | ValidationError::EnumViolation { path, .. }
191            | ValidationError::FormatViolation { path, .. }
192            | ValidationError::UnknownCard { path, .. }
193            | ValidationError::BodyDisabled { path, .. }
194            | ValidationError::NotInline { path, .. }
195            | ValidationError::NotPlain { path, .. } => path,
196        }
197    }
198
199    /// Stable diagnostic code for this error variant. Pattern-match on this
200    /// instead of the message text.
201    pub fn code(&self) -> &'static str {
202        match self {
203            ValidationError::TypeMismatch { .. } => "validation::type_mismatch",
204            ValidationError::EnumViolation { .. } => "validation::enum_violation",
205            ValidationError::FormatViolation { .. } => "validation::format_violation",
206            ValidationError::UnknownCard { .. } => "validation::unknown_card",
207            ValidationError::BodyDisabled { .. } => "validation::body_disabled",
208            ValidationError::NotInline { .. } => "richtext::not_inline",
209            ValidationError::NotPlain { .. } => "plaintext::not_plain",
210        }
211    }
212
213    /// The facts this error's message interpolates. See
214    /// [`Diagnostic::args`](crate::error::Diagnostic::args).
215    ///
216    /// `path` stays out: it is the diagnostic's anchor, and an anchor
217    /// reachable by two routes acquires two spellings. `NotInline` and
218    /// `NotPlain` carry nothing else, so their sentence follows from the code
219    /// and the anchor alone.
220    ///
221    /// `default` is present only when the schema declares one, the same
222    /// condition `type_mismatch_hint` branches on, so a consumer picks its
223    /// own exit clause from the key's presence instead of re-deriving the
224    /// branch. Emitting `null` instead would read as a default spelled `null`.
225    pub fn args(&self) -> BTreeMap<String, serde_json::Value> {
226        match self {
227            ValidationError::TypeMismatch {
228                path: _,
229                expected,
230                actual,
231                source_token,
232                default,
233            } => {
234                let mut args = diag_args! {
235                    "expected" => expected,
236                    "actual" => actual,
237                    "sourceToken" => source_token,
238                };
239                if let Some(default) = default {
240                    args.insert("default".to_string(), serde_json::json!(default));
241                }
242                args
243            }
244            ValidationError::EnumViolation {
245                path: _,
246                value,
247                allowed,
248            } => diag_args! {
249                "value" => value,
250                "allowed" => allowed,
251            },
252            ValidationError::FormatViolation { path: _, format } => diag_args! {
253                "format" => format,
254            },
255            ValidationError::UnknownCard { path: _, card } => diag_args! {
256                "card" => card,
257            },
258            ValidationError::BodyDisabled { path: _, card } => diag_args! {
259                "card" => card,
260            },
261            ValidationError::NotInline { path: _ } => diag_args! {},
262            ValidationError::NotPlain { path: _ } => diag_args! {},
263        }
264    }
265
266    /// Actionable hint for this error, when defined for the variant: the same
267    /// string the `Display` impl bakes in, exposed so consumers can surface it
268    /// without re-parsing prose.
269    pub fn hint(&self) -> Option<String> {
270        match self {
271            ValidationError::TypeMismatch {
272                expected,
273                actual,
274                default,
275                ..
276            } => Some(type_mismatch_hint(expected, actual, default.as_deref())),
277            ValidationError::BodyDisabled { .. } => Some(body_disabled_hint().to_string()),
278            ValidationError::NotInline { .. } => Some(not_inline_hint().to_string()),
279            ValidationError::NotPlain { .. } => Some(not_plain_hint().to_string()),
280            ValidationError::EnumViolation { .. }
281            | ValidationError::FormatViolation { .. }
282            | ValidationError::UnknownCard { .. } => None,
283        }
284    }
285
286    /// Convert this error into a structured [`Diagnostic`] carrying the
287    /// stable code, the document-model `path`, the canonical message, and
288    /// the actionable hint (when the variant defines one).
289    pub fn to_diagnostic(&self) -> Diagnostic {
290        let mut diag = Diagnostic::new(Severity::Error, self.to_string())
291            .with_code(self.code().to_string())
292            .with_path(self.path().to_string())
293            .with_args(self.args());
294        if let Some(hint) = self.hint() {
295            diag = diag.with_hint(hint);
296        }
297        diag
298    }
299}
300
301/// Render a JSON scalar as the verbatim YAML token it would parse from.
302/// Primitives appear bare (`42`, `true`, `null`); strings appear quoted
303/// (`"hello"`, `""`); compound values render as a short placeholder.
304fn verbatim_yaml_scalar(value: &serde_json::Value) -> String {
305    match value {
306        serde_json::Value::Null => "null".to_string(),
307        serde_json::Value::Bool(b) => b.to_string(),
308        serde_json::Value::Number(n) => n.to_string(),
309        serde_json::Value::String(s) => format!("\"{s}\""),
310        serde_json::Value::Array(_) => "[…]".to_string(),
311        serde_json::Value::Object(_) => "{…}".to_string(),
312    }
313}
314
315/// YAML-parsed type name for a JSON value. Distinguishes `integer` from
316/// `number` so diagnostic messages can report the two separately.
317fn yaml_scalar_type(value: &serde_json::Value) -> &'static str {
318    match value {
319        serde_json::Value::Null => "null",
320        serde_json::Value::Bool(_) => "boolean",
321        serde_json::Value::Number(n) => {
322            if n.is_i64() || n.is_u64() {
323                "integer"
324            } else {
325                "number"
326            }
327        }
328        serde_json::Value::String(_) => "string",
329        serde_json::Value::Array(_) => "array",
330        serde_json::Value::Object(_) => "object",
331    }
332}
333
334/// Validate a typed [`Document`] (with `IndexMap` payload + typed `Card` list).
335///
336/// This is the typed entry point used by `QuillConfig::validate_document`.
337pub fn validate_typed_document(
338    config: &QuillConfig,
339    doc: &Document,
340) -> Result<(), Vec<ValidationError>> {
341    let main_fields = doc.main().payload().to_index_map();
342    let mut errors = validate_fields_for_card_indexmap(&config.main, &main_fields, &DocPath::main());
343
344    // Enforce body.enabled on the main card. Whitespace-only bodies are
345    // treated as empty: only meaningful prose triggers the diagnostic.
346    if !config.main.body_enabled() && !doc.main().body().is_blank() {
347        errors.push(ValidationError::BodyDisabled {
348            path: DocPath::main_body().to_string(),
349            card: "main".to_string(),
350        });
351    }
352
353    for (index, card) in doc.cards().iter().enumerate() {
354        let card_name = card.kind().unwrap_or("").to_string();
355
356        let Some(card_schema) = config.card_kind(card_name.as_str()) else {
357            // An unknown-kind card has no kind to qualify with: `cards[<i>]`,
358            // the sole bare-index root. (A document's cards are always a
359            // `cards` list; the kind *definitions* live under `card_kinds:`.)
360            errors.push(ValidationError::UnknownCard {
361                path: DocPath::card(None, index).to_string(),
362                card: card_name,
363            });
364            continue;
365        };
366
367        let card_path = DocPath::card(Some(&card_name), index);
368        let card_fields = card.payload().to_index_map();
369        errors.extend(validate_fields_for_card_indexmap(
370            card_schema,
371            &card_fields,
372            &card_path,
373        ));
374
375        if !card_schema.body_enabled() && !card.body().is_blank() {
376            errors.push(ValidationError::BodyDisabled {
377                path: card_path.body().to_string(),
378                card: card_name,
379            });
380        }
381    }
382
383    if errors.is_empty() {
384        Ok(())
385    } else {
386        Err(errors)
387    }
388}
389
390fn validate_fields_for_card_indexmap(
391    card: &CardSchema,
392    fields: &IndexMap<String, QuillValue>,
393    base: &DocPath,
394) -> Vec<ValidationError> {
395    let mut errors = Vec::new();
396    let mut field_names: Vec<&String> = card.fields.keys().collect();
397    field_names.sort();
398
399    for field_name in field_names {
400        let schema = &card.fields[field_name];
401        let path = base.field(field_name);
402        // Absence is a completeness concern, not a well-formedness one: an
403        // absent field (like a present-null one) is zero-filled at render and
404        // raises nothing here.
405        if let Some(value) = fields.get(field_name) {
406            errors.extend(validate_field(schema, value, &path));
407        }
408    }
409
410    errors
411}
412
413/// Distinguishes the two value sources the conformance core
414/// [`validate_value`] serves. The type/enum/format/recursion checks are
415/// identical; only the document-authoring concerns differ.
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417enum ValueContext {
418    /// A value parsed from an authored document. Treats present-null as absent
419    /// and reports the field's `default:` token alongside a type mismatch.
420    Document,
421    /// An `example:` or `default:` literal declared in Quill.yaml. Partial
422    /// objects are allowed (absent properties are not errors) and the
423    /// document-only null/default semantics do not apply.
424    SchemaLiteral,
425}
426
427/// Shared conformance core: validate a single `value` against `field` at
428/// `path`, checking type compatibility, enum membership, datetime format, and
429/// recursing into array elements / object properties. `ctx` selects the few
430/// document-only behaviors (see [`ValueContext`]).
431fn validate_value(
432    field: &FieldSchema,
433    value: &QuillValue,
434    path: &DocPath,
435    ctx: ValueContext,
436) -> Vec<ValidationError> {
437    // Null ≡ absent: a present-null value in a document is treated as omitted
438    // (no type error). The `!must_fill` marker is surfaced separately as a
439    // warning by `Quill::validate`, not here.
440    if ctx == ValueContext::Document && value.as_json().is_null() {
441        return vec![];
442    }
443
444    let mut errors = Vec::new();
445
446    let type_valid = match field.r#type {
447        // In a document a bare bool/number is type-valid as a string (the
448        // coercion layer adopts it): in lockstep with `conform_value`
449        // via `scalar_as_string`. Schema literals stay strict so the blueprint
450        // keeps quoting ambiguous string literals.
451        // Enum is string-valued data (domain membership is checked separately
452        // below), so it is type-valid exactly where a string is.
453        FieldType::String | FieldType::Enum => {
454            value.as_str().is_some()
455                || (ctx == ValueContext::Document
456                    && super::config::scalar_as_string(value.as_json()).is_some())
457        }
458        // Post-coercion (Document) a richtext/plaintext value is a canonical
459        // content object; an authored `default`/`example` (Schema) is a string
460        // (markdown for richtext, literal for plaintext). Accept both shapes:
461        // the content's own invariants were enforced at coercion, and a bare
462        // scalar still stringifies. The plaintext-specific plain constraint is
463        // checked in the shape pass below, parallel to the inline check.
464        FieldType::RichText { .. } | FieldType::PlainText { .. } => {
465            value.as_json().is_object()
466                || value.as_str().is_some()
467                || (ctx == ValueContext::Document
468                    && super::config::scalar_as_string(value.as_json()).is_some())
469        }
470        FieldType::Integer => {
471            let json = value.as_json();
472            json.is_i64() || json.is_u64()
473        }
474        FieldType::Number => value.as_json().is_number(),
475        FieldType::Boolean => value.as_bool().is_some(),
476        FieldType::Date | FieldType::DateTime => {
477            if value.as_json().is_null() {
478                true
479            } else {
480                match value.as_str() {
481                    Some("") => true,
482                    Some(text) => {
483                        let (ok, format) = match field.r#type {
484                            FieldType::Date => (is_valid_date(text), "date"),
485                            _ => (is_valid_datetime(text), "datetime"),
486                        };
487                        if ok {
488                            true
489                        } else {
490                            errors.push(ValidationError::FormatViolation {
491                                path: path.to_string(),
492                                format: format.to_string(),
493                            });
494                            false
495                        }
496                    }
497                    None => false,
498                }
499            }
500        }
501        FieldType::Array => match value.as_array() {
502            Some(items) => {
503                // Validate each element against the array's `items` schema.
504                // Scalar elements (`string[]`, `integer[]`, `richtext[]`, …)
505                // are type-checked element-wise; object elements recurse into
506                // their properties via the Object branch.
507                if let Some(item_schema) = &field.items {
508                    for (idx, item) in items.iter().enumerate() {
509                        let row_path = path.index(idx);
510                        errors.extend(validate_value(
511                            item_schema,
512                            &QuillValue::from_json(item.clone()),
513                            &row_path,
514                            ctx,
515                        ));
516                    }
517                }
518                true
519            }
520            None => false,
521        },
522        FieldType::Object => match value.as_object() {
523            Some(object) => {
524                if let Some(properties) = &field.properties {
525                    let mut property_names: Vec<&String> = properties.keys().collect();
526                    property_names.sort();
527                    for property_name in property_names {
528                        let property_schema = &properties[property_name];
529                        let property_path = path.field(property_name);
530                        // Absent object property: completeness, not
531                        // well-formedness. Like a top-level absent field, it
532                        // zero-fills at render and raises nothing here.
533                        if let Some(property_value) = object.get(property_name) {
534                            errors.extend(validate_value(
535                                property_schema,
536                                &QuillValue::from_json(property_value.clone()),
537                                &property_path,
538                                ctx,
539                            ));
540                        }
541                    }
542                }
543                true
544            }
545            None => false,
546        },
547    };
548
549    // Content shape checks, run only on a type-valid value (a mistyped value
550    // already raises TypeMismatch below, and a null/absent field zero-fills to
551    // the empty content, which is both inline and plain). Mirror the
552    // coercion-layer checks so a content that bypassed coercion (e.g. a direct
553    // `validate_document`) is still caught. A decode failure is not this layer's
554    // error to report: swallow it and flag only a well-formed but mis-shaped
555    // content.
556    if type_valid {
557        match field.r#type {
558            FieldType::RichText { inline: true } => {
559                let parsed =
560                    crate::document::decode_richtext_value(value.as_json()).and_then(Result::ok);
561                if let Some(rt) = parsed {
562                    if !rt.is_inline() {
563                        errors.push(ValidationError::NotInline {
564                            path: path.to_string(),
565                        });
566                    }
567                }
568            }
569            FieldType::PlainText { inline } => {
570                // Plaintext strings are literal, not markdown, so a schema
571                // literal decodes through the literal codec; a Document value is
572                // a canonical content object. The plain constraint is primary;
573                // the single-line constraint applies only when `inline`. A decode
574                // error is another layer's to report (swallowed via `.ok()`).
575                if let Some(rt) = crate::document::decode_plaintext_value(value.as_json())
576                    .and_then(Result::ok)
577                {
578                    if !rt.is_plain() {
579                        errors.push(ValidationError::NotPlain {
580                            path: path.to_string(),
581                        });
582                    } else if inline && !rt.is_inline() {
583                        errors.push(ValidationError::NotInline {
584                            path: path.to_string(),
585                        });
586                    }
587                }
588            }
589            _ => {}
590        }
591    }
592
593    // A Date/DateTime with a string value already emitted a FormatViolation;
594    // skip the redundant TypeMismatch in that case.
595    let format_error_already_reported =
596        matches!(field.r#type, FieldType::Date | FieldType::DateTime) && value.as_str().is_some();
597
598    if !type_valid && !format_error_already_reported {
599        errors.push(ValidationError::TypeMismatch {
600            path: path.to_string(),
601            expected: expected_type_name(&field.r#type).to_string(),
602            actual: yaml_scalar_type(value.as_json()).to_string(),
603            source_token: verbatim_yaml_scalar(value.as_json()),
604            // The `default:` token is a document-authoring aid ("omit the line
605            // and the default fills in"): meaningless when validating the
606            // schema's own literals.
607            default: match ctx {
608                ValueContext::Document => field
609                    .default
610                    .as_ref()
611                    .map(|d| verbatim_yaml_scalar(d.as_json())),
612                ValueContext::SchemaLiteral => None,
613            },
614        });
615    }
616
617    if type_valid {
618        if let (Some(allowed), Some(actual)) = (&field.enum_values, value.as_str()) {
619            if !allowed.contains(&actual.to_string()) {
620                errors.push(ValidationError::EnumViolation {
621                    path: path.to_string(),
622                    value: actual.to_string(),
623                    allowed: allowed.clone(),
624                });
625            }
626        }
627    }
628
629    errors
630}
631
632/// Validate a single document value against a field schema at the given path.
633/// Used internally; exposed for testing.
634pub(crate) fn validate_field(
635    field: &FieldSchema,
636    value: &QuillValue,
637    path: &DocPath,
638) -> Vec<ValidationError> {
639    validate_value(field, value, path, ValueContext::Document)
640}
641
642/// Validate a schema literal value (an `example:` or `default:` declared in
643/// Quill.yaml) against a field schema.
644///
645/// Shares the type/enum/format/recursion core with [`validate_field`] (see
646/// [`validate_value`]) but omits the document-authoring concerns: it does not
647/// apply null≡absent leniency, and never attaches a `default:` token to a type
648/// mismatch (partial examples/defaults are intentional and valid).
649pub(crate) fn validate_schema_literal(
650    schema: &FieldSchema,
651    value: &QuillValue,
652    path: &DocPath,
653) -> Vec<ValidationError> {
654    validate_value(schema, value, path, ValueContext::SchemaLiteral)
655}
656
657fn expected_type_name(field_type: &FieldType) -> &'static str {
658    match field_type {
659        FieldType::String | FieldType::Date | FieldType::DateTime => "string",
660        // Enum is a closed string domain; a mistyped value reports the base type.
661        FieldType::Enum => "string",
662        FieldType::RichText { .. } => "richtext",
663        FieldType::PlainText { .. } => "plaintext",
664        FieldType::Integer => "integer",
665        FieldType::Number => "number",
666        FieldType::Boolean => "boolean",
667        FieldType::Array => "array",
668        FieldType::Object => "object",
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use crate::document::{Card, Document};
676    use serde_json::json;
677
678    fn config_with(main_fields: &str, cards: &str) -> QuillConfig {
679        let yaml = format!(
680            r#"
681quill:
682  name: native_validation
683  backend: typst
684  description: Native validator tests
685  version: 1.0.0
686main:
687  fields:
688{main_fields}
689{cards}
690"#
691        );
692        let (config, warnings) = QuillConfig::from_yaml_with_warnings(&yaml).unwrap();
693        assert!(
694            warnings.is_empty(),
695            "config_with produced warnings (test schema is unsupported): {:?}",
696            warnings
697        );
698        config
699    }
700
701    fn doc_from_fm(entries: &[(&str, serde_json::Value)]) -> Document {
702        doc_with_typed_cards(entries, vec![])
703    }
704
705    fn doc_with_typed_cards(fm: &[(&str, serde_json::Value)], cards: Vec<Card>) -> Document {
706        use crate::document::Payload;
707        let mut payload = IndexMap::new();
708        for (k, v) in fm {
709            payload.insert(k.to_string(), QuillValue::from_json(v.clone()));
710        }
711        let mut p = Payload::from_index_map(payload);
712        p.set_quill("test_quill".parse().unwrap());
713        p.set_kind("main");
714        let main = Card::from_parts(p, quillmark_content::Content::empty());
715        Document::from_main_and_cards(main, cards)
716    }
717
718    fn typed_card(tag: &str, fields: &[(&str, serde_json::Value)]) -> Card {
719        let mut card = Card::new(tag).unwrap();
720        for (k, v) in fields {
721            card.store_field(k, QuillValue::from_json(v.clone())).unwrap();
722        }
723        card
724    }
725
726    fn has_error<F>(errors: &[ValidationError], predicate: F) -> bool
727    where
728        F: Fn(&ValidationError) -> bool,
729    {
730        errors.iter().any(predicate)
731    }
732
733    #[test]
734    fn validates_simple_string_field() {
735        let config = config_with("    title:\n      type: string", "");
736        let doc = doc_from_fm(&[("title", json!("Memo"))]);
737        assert!(validate_typed_document(&config, &doc).is_ok());
738    }
739
740    #[test]
741    fn rejects_simple_string_type_mismatch() {
742        // A bare scalar coerces into a string; an array does not, so it
743        // raises a string TypeMismatch.
744        let config = config_with("    title:\n      type: string\n      default: \"\"", "");
745        let doc = doc_from_fm(&[("title", json!([1, 2, 3]))]);
746        let errors = validate_typed_document(&config, &doc).unwrap_err();
747        assert!(has_error(&errors, |e| matches!(
748            e,
749            ValidationError::TypeMismatch { path, expected, actual, source_token, .. }
750            if path == "main.title" && expected == "string" && actual == "array" && source_token == "[…]"
751        )));
752    }
753
754    #[test]
755    fn validates_integer_field_with_integer_value() {
756        let config = config_with("    count:\n      type: integer\n      default: 0", "");
757        let doc = doc_from_fm(&[("count", json!(9))]);
758        assert!(validate_typed_document(&config, &doc).is_ok());
759    }
760
761    #[test]
762    fn rejects_integer_field_with_decimal_value() {
763        let config = config_with("    count:\n      type: integer\n      default: 0", "");
764        let doc = doc_from_fm(&[("count", json!(9.5))]);
765        let errors = validate_typed_document(&config, &doc).unwrap_err();
766        assert!(has_error(&errors, |e| matches!(
767            e,
768            ValidationError::TypeMismatch { path, expected, actual, source_token, .. }
769            if path == "main.count" && expected == "integer" && actual == "number" && source_token == "9.5"
770        )));
771    }
772
773    #[test]
774    fn absent_unendorsed_field_raises_nothing() {
775        // A field with no `default:` absent from the document is a completeness
776        // concern, not a well-formedness one: validation is clean and the field
777        // zero-fills at render.
778        let config = config_with("    memo_for:\n      type: string", "");
779        let doc = doc_from_fm(&[]);
780        assert!(validate_typed_document(&config, &doc).is_ok());
781    }
782
783    #[test]
784    fn present_null_is_treated_as_absent() {
785        // `memo_for:` (a bare/null value) carries no data, so it validates the
786        // same as an omitted field (no type mismatch) for every type.
787        let config = config_with(
788            "    memo_for:\n      type: string\n    n:\n      type: integer",
789            "",
790        );
791        let doc = doc_from_fm(&[("memo_for", json!(null)), ("n", json!(null))]);
792        assert!(
793            validate_typed_document(&config, &doc).is_ok(),
794            "present-null must validate like absence"
795        );
796    }
797
798    #[test]
799    fn missing_field_with_default_is_ok() {
800        // Endorsed field absent from document → no error; default applies.
801        let config = config_with("    memo_for:\n      type: string\n      default: \"\"", "");
802        let doc = doc_from_fm(&[]);
803        assert!(validate_typed_document(&config, &doc).is_ok());
804    }
805
806    #[test]
807    fn absent_object_property_raises_nothing() {
808        // Property `name` is Unendorsed and absent from the row. Like a
809        // top-level absent field, this is a completeness concern, not a
810        // validation error.
811        let config = config_with(
812            "    recipients:\n      type: array\n      default: []\n      items:\n        type: object\n        properties:\n          name:\n            type: string\n          org:\n            type: string\n            default: \"\"",
813            "",
814        );
815        let doc = doc_from_fm(&[("recipients", json!([{ "org": "HQ" }]))]);
816        assert!(validate_typed_document(&config, &doc).is_ok());
817    }
818
819    // NOTE: top-level typed-dictionary fields (`type: object` with `properties`)
820    // are supported. Coverage lives in the `schema.rs` transform-schema tests
821    // (typed tables/dicts) and the blueprint tests. Freeform objects without
822    // properties are rejected at config parse time.
823
824    #[test]
825    fn validates_card_with_valid_discriminator() {
826        let config = config_with(
827            "    title:\n      type: string\n      default: \"\"",
828            "card_kinds:\n  indorsement:\n    fields:\n      signature_block:\n        type: string",
829        );
830        let doc = doc_with_typed_cards(
831            &[],
832            vec![typed_card(
833                "indorsement",
834                &[("signature_block", json!("Signed"))],
835            )],
836        );
837        assert!(validate_typed_document(&config, &doc).is_ok());
838    }
839
840    #[test]
841    fn rejects_unknown_card_discriminator() {
842        let config = config_with(
843            "    title:\n      type: string\n      default: \"\"",
844            "card_kinds:\n  indorsement:\n    fields:\n      signature_block:\n        type: string",
845        );
846        let doc = doc_with_typed_cards(&[], vec![typed_card("unknown", &[])]);
847        let errors = validate_typed_document(&config, &doc).unwrap_err();
848        assert!(has_error(&errors, |e| {
849            matches!(e, ValidationError::UnknownCard { path, card } if path == "cards[0]" && card == "unknown")
850        }));
851    }
852
853    #[test]
854    fn validates_multiple_card_kinds_mixed() {
855        let config = config_with(
856            "    title:\n      type: string\n      default: \"\"",
857            "card_kinds:\n  indorsement:\n    fields:\n      signature_block:\n        type: string\n  routing:\n    fields:\n      office:\n        type: string",
858        );
859        let doc = doc_with_typed_cards(
860            &[],
861            vec![
862                typed_card("indorsement", &[("signature_block", json!("A"))]),
863                typed_card("routing", &[("office", json!("HQ"))]),
864            ],
865        );
866        assert!(validate_typed_document(&config, &doc).is_ok());
867    }
868
869    #[test]
870    fn reports_card_field_paths_with_card_name_and_index() {
871        // A type-mismatched card field anchors the `cards.<kind>[<i>].<field>`
872        // path shape (absence does not raise, so we exercise the path via a
873        // well-formedness error instead).
874        let config = config_with(
875            "    title:\n      type: string\n      default: \"\"",
876            "card_kinds:\n  indorsement:\n    fields:\n      signature_block:\n        type: string",
877        );
878        let doc = doc_with_typed_cards(
879            &[],
880            vec![typed_card(
881                "indorsement",
882                &[("signature_block", json!([1, 2, 3]))],
883            )],
884        );
885        let errors = validate_typed_document(&config, &doc).unwrap_err();
886        assert!(has_error(&errors, |e| {
887            matches!(e, ValidationError::TypeMismatch { path, .. } if path == "cards.indorsement[0].signature_block")
888        }));
889    }
890
891    #[test]
892    fn body_disabled_card_enforces_trim_boundary() {
893        let config = config_with(
894            "    title:\n      type: string\n      default: \"\"",
895            "card_kinds:\n  skills:\n    body:\n      enabled: false\n    fields:\n      items:\n        type: array\n        items:\n          type: string\n        default: []",
896        );
897        // Prose triggers the error; whitespace-only does not.
898        let mut prose_card = typed_card("skills", &[("items", json!(["Rust"]))]);
899        prose_card.revise_body("Should not be here.").unwrap();
900        let doc = doc_with_typed_cards(&[], vec![prose_card]);
901        let errors = validate_typed_document(&config, &doc).unwrap_err();
902        assert!(has_error(&errors, |e| matches!(
903            e,
904            ValidationError::BodyDisabled { path, card }
905            if card == "skills" && path == "cards.skills[0].body"
906        )));
907
908        let mut ws_card = typed_card("skills", &[("items", json!(["Rust"]))]);
909        ws_card.revise_body("\n   \n").unwrap();
910        let ok_doc = doc_with_typed_cards(&[], vec![ws_card]);
911        assert!(validate_typed_document(&config, &ok_doc).is_ok());
912    }
913
914    #[test]
915    fn to_diagnostic_carries_path_code_and_hint() {
916        let err = ValidationError::TypeMismatch {
917            path: "cards.indorsement[0].signature_block".to_string(),
918            expected: "string".to_string(),
919            actual: "integer".to_string(),
920            source_token: "42".to_string(),
921            default: None,
922        };
923        let diag = err.to_diagnostic();
924        assert_eq!(diag.code.as_deref(), Some("validation::type_mismatch"));
925        assert_eq!(
926            diag.path.as_deref(),
927            Some("cards.indorsement[0].signature_block")
928        );
929        assert_eq!(diag.severity, Severity::Error);
930        let hint = diag
931            .hint
932            .as_deref()
933            .expect("type_mismatch diagnostic should carry a hint");
934        assert!(
935            hint.contains("string"),
936            "hint missing expected type: {hint}"
937        );
938    }
939
940    #[test]
941    fn type_mismatch_diagnostic_carries_hint_matching_message() {
942        // The structured hint must equal the exit clause baked into the
943        // prose message, so consumers never need to re-parse.
944        // An array under a `string` schema is a genuine mismatch (not a bare
945        // scalar the coercion layer can adopt), so it still raises TypeMismatch.
946        let config = config_with(
947            "    build_number:\n      type: string\n      default: \"\"",
948            "",
949        );
950        let doc = doc_from_fm(&[("build_number", json!([1, 2, 3]))]);
951        let errors = validate_typed_document(&config, &doc).unwrap_err();
952        let err = errors
953            .iter()
954            .find(|e| matches!(e, ValidationError::TypeMismatch { .. }))
955            .expect("expected TypeMismatch");
956        let diag = err.to_diagnostic();
957        let hint = diag
958            .hint
959            .expect("TypeMismatch diagnostic should carry a hint");
960        assert!(
961            err.to_string().ends_with(&hint),
962            "message tail must equal hint; msg={msg}, hint={hint}",
963            msg = err,
964        );
965        assert!(hint.contains("provide a value of type"));
966    }
967
968    #[test]
969    fn body_disabled_diagnostic_carries_hint() {
970        let err = ValidationError::BodyDisabled {
971            path: "cards.skills[0].body".to_string(),
972            card: "skills".to_string(),
973        };
974        let diag = err.to_diagnostic();
975        let hint = diag
976            .hint
977            .expect("BodyDisabled diagnostic should carry a hint");
978        assert!(hint.contains("remove the body content"));
979    }
980
981    #[test]
982    fn bare_scalar_into_string_field_is_valid() {
983        // Gracious scalar→string: a bare integer/boolean/number under a
984        // `string` schema is unambiguously representable as its canonical text,
985        // so it validates (the coercion layer adopts the token). No
986        // TypeMismatch; see `quill::config::scalar_as_string`.
987        for value in [json!(42), json!(true), json!(1.5)] {
988            let config = config_with(
989                "    build_number:\n      type: string\n      default: \"\"",
990                "",
991            );
992            let doc = doc_from_fm(&[("build_number", value.clone())]);
993            assert!(
994                validate_typed_document(&config, &doc).is_ok(),
995                "bare scalar {value} should validate as a string"
996            );
997        }
998    }
999
1000    #[test]
1001    fn main_body_disabled_with_body_content_is_an_error() {
1002        let config = QuillConfig::from_yaml(
1003            r#"
1004quill:
1005  name: native_validation
1006  backend: typst
1007  description: Native validator tests
1008  version: 1.0.0
1009main:
1010  body:
1011    enabled: false
1012  fields:
1013    title:
1014      type: string
1015      default: ""
1016"#,
1017        )
1018        .unwrap();
1019        use crate::document::Payload;
1020        let mut p = Payload::from_index_map(IndexMap::new());
1021        p.set_quill("test_quill".parse().unwrap());
1022        p.set_kind("main");
1023        let main = Card::from_parts(
1024            p,
1025            crate::document::import_body("Body content that should not be here.").unwrap(),
1026        );
1027        let doc = Document::from_main_and_cards(main, vec![]);
1028        let errors = validate_typed_document(&config, &doc).unwrap_err();
1029        assert!(has_error(&errors, |e| matches!(
1030            e,
1031            ValidationError::BodyDisabled { path, card }
1032            if card == "main" && path == "main.body"
1033        )));
1034    }
1035
1036    #[test]
1037    fn rejects_richtext_inline_with_multi_block_content() {
1038        // A pre-built two-paragraph content reaches the validator directly (no
1039        // coercion), so the validation-layer NotInline backstop must fire.
1040        let config = config_with("    tag:\n      type: richtext\n      inline: true", "");
1041        let rt = quillmark_content::import::from_markdown("one\n\ntwo").unwrap();
1042        let content = quillmark_content::serial::to_canonical_value(&rt);
1043        let doc = doc_from_fm(&[("tag", content)]);
1044        let errors = validate_typed_document(&config, &doc).unwrap_err();
1045        assert!(has_error(&errors, |e| matches!(
1046            e,
1047            ValidationError::NotInline { path } if path == "main.tag"
1048        )));
1049    }
1050
1051    #[test]
1052    fn accepts_richtext_inline_single_para_content() {
1053        let config = config_with("    tag:\n      type: richtext\n      inline: true", "");
1054        let rt = quillmark_content::import::from_markdown("one line only").unwrap();
1055        let content = quillmark_content::serial::to_canonical_value(&rt);
1056        let doc = doc_from_fm(&[("tag", content)]);
1057        assert!(validate_typed_document(&config, &doc).is_ok());
1058    }
1059}