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