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