Skip to main content

quillmark_core/quill/
validation.rs

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