use indexmap::IndexMap;
use crate::document::Document;
use crate::error::{Diagnostic, Severity};
use crate::quill::formats::{is_valid_date, is_valid_datetime};
use crate::quill::{CardSchema, FieldSchema, FieldType, QuillConfig};
use crate::value::QuillValue;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
TypeMismatch {
path: String,
expected: String,
actual: String,
source_token: String,
default: Option<String>,
},
EnumViolation {
path: String,
value: String,
allowed: Vec<String>,
},
FormatViolation {
path: String,
format: String,
},
UnknownCard {
path: String,
card: String,
},
BodyDisabled {
path: String,
card: String,
},
NotInline {
path: String,
},
NotPlain {
path: String,
},
}
impl std::error::Error for ValidationError {}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationError::TypeMismatch {
path,
expected,
actual,
source_token,
default,
} => {
write!(
f,
"Field `{path}` got {actual} `{source_token}`, schema declares `{expected}`"
)?;
if let Some(d) = default {
write!(f, " with default `{d}`")?;
}
write!(
f,
". {hint}",
hint = type_mismatch_hint(expected, actual, default.as_deref())
)
}
ValidationError::EnumViolation {
path,
value,
allowed,
} => {
write!(
f,
"field `{path}` value `{value}` not in allowed set {allowed:?}"
)
}
ValidationError::FormatViolation { path, format } => {
write!(
f,
"field `{path}` does not match expected format `{format}`"
)
}
ValidationError::UnknownCard { path, card } => {
write!(f, "unknown card kind `{card}` at `{path}`")
}
ValidationError::BodyDisabled { path, card } => {
write!(
f,
"card `{card}` at `{path}` has body content but the card kind declares `body.enabled: false` — {hint}",
hint = body_disabled_hint(),
)
}
ValidationError::NotInline { path } => {
write!(
f,
"field `{path}` is `richtext(inline)` but its content is not a single \
paragraph — {hint}",
hint = not_inline_hint(),
)
}
ValidationError::NotPlain { path } => {
write!(
f,
"field `{path}` is `plaintext` but its content carries formatting — {hint}",
hint = not_plain_hint(),
)
}
}
}
}
fn type_mismatch_hint(expected: &str, actual: &str, default: Option<&str>) -> String {
if default.is_some() {
format!(
"Either omit the line (the default will fill in) or provide a value of type `{expected}`."
)
} else {
format!(
"Either provide a value of type `{expected}` or change the schema's `type:` to `{actual}`."
)
}
}
fn body_disabled_hint() -> &'static str {
"remove the body content or set `body.enabled: true` on the card kind"
}
fn not_inline_hint() -> &'static str {
"keep the value to a single paragraph (no blank lines, headings, lists, \
quotes, or tables), or change the schema's `type:` to `richtext`"
}
fn not_plain_hint() -> &'static str {
"remove the formatting (marks, tables, images, headings, lists, quotes), or \
change the schema's `type:` to `richtext`"
}
impl ValidationError {
pub fn path(&self) -> &str {
match self {
ValidationError::TypeMismatch { path, .. }
| ValidationError::EnumViolation { path, .. }
| ValidationError::FormatViolation { path, .. }
| ValidationError::UnknownCard { path, .. }
| ValidationError::BodyDisabled { path, .. }
| ValidationError::NotInline { path, .. }
| ValidationError::NotPlain { path, .. } => path,
}
}
pub fn code(&self) -> &'static str {
match self {
ValidationError::TypeMismatch { .. } => "validation::type_mismatch",
ValidationError::EnumViolation { .. } => "validation::enum_violation",
ValidationError::FormatViolation { .. } => "validation::format_violation",
ValidationError::UnknownCard { .. } => "validation::unknown_card",
ValidationError::BodyDisabled { .. } => "validation::body_disabled",
ValidationError::NotInline { .. } => "richtext::not_inline",
ValidationError::NotPlain { .. } => "plaintext::not_plain",
}
}
pub fn hint(&self) -> Option<String> {
match self {
ValidationError::TypeMismatch {
expected,
actual,
default,
..
} => Some(type_mismatch_hint(expected, actual, default.as_deref())),
ValidationError::BodyDisabled { .. } => Some(body_disabled_hint().to_string()),
ValidationError::NotInline { .. } => Some(not_inline_hint().to_string()),
ValidationError::NotPlain { .. } => Some(not_plain_hint().to_string()),
ValidationError::EnumViolation { .. }
| ValidationError::FormatViolation { .. }
| ValidationError::UnknownCard { .. } => None,
}
}
pub fn to_diagnostic(&self) -> Diagnostic {
let mut diag = Diagnostic::new(Severity::Error, self.to_string())
.with_code(self.code().to_string())
.with_path(self.path().to_string());
if let Some(hint) = self.hint() {
diag = diag.with_hint(hint);
}
diag
}
}
fn verbatim_yaml_scalar(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "null".to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => format!("\"{s}\""),
serde_json::Value::Array(_) => "[…]".to_string(),
serde_json::Value::Object(_) => "{…}".to_string(),
}
}
fn yaml_scalar_type(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(n) => {
if n.is_i64() || n.is_u64() {
"integer"
} else {
"number"
}
}
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
pub fn validate_typed_document(
config: &QuillConfig,
doc: &Document,
) -> Result<(), Vec<ValidationError>> {
let main_fields = doc.main().payload().to_index_map();
let mut errors = validate_fields_for_card_indexmap(&config.main, &main_fields, "");
if !config.main.body_enabled() && !doc.main().body().is_blank() {
errors.push(ValidationError::BodyDisabled {
path: "main.body".to_string(),
card: "main".to_string(),
});
}
for (index, card) in doc.cards().iter().enumerate() {
let card_name = card.kind().unwrap_or("").to_string();
let item_path = format!("cards[{index}]");
let Some(card_schema) = config.card_kind(card_name.as_str()) else {
errors.push(ValidationError::UnknownCard {
path: item_path,
card: card_name,
});
continue;
};
let card_path = format!("cards.{card_name}[{index}]");
let card_fields = card.payload().to_index_map();
errors.extend(validate_fields_for_card_indexmap(
card_schema,
&card_fields,
&card_path,
));
if !card_schema.body_enabled() && !card.body().is_blank() {
errors.push(ValidationError::BodyDisabled {
path: format!("{card_path}.body"),
card: card_name,
});
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn validate_fields_for_card_indexmap(
card: &CardSchema,
fields: &IndexMap<String, QuillValue>,
base_path: &str,
) -> Vec<ValidationError> {
let mut errors = Vec::new();
let mut field_names: Vec<&String> = card.fields.keys().collect();
field_names.sort();
for field_name in field_names {
let schema = &card.fields[field_name];
let path = child_path(base_path, field_name);
if let Some(value) = fields.get(field_name) {
errors.extend(validate_field(schema, value, &path));
}
}
errors
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ValueContext {
Document,
SchemaLiteral,
}
fn validate_value(
field: &FieldSchema,
value: &QuillValue,
path: &str,
ctx: ValueContext,
) -> Vec<ValidationError> {
if ctx == ValueContext::Document && value.as_json().is_null() {
return vec![];
}
let mut errors = Vec::new();
let type_valid = match field.r#type {
FieldType::String | FieldType::Enum => {
value.as_str().is_some()
|| (ctx == ValueContext::Document
&& super::config::scalar_as_string(value.as_json()).is_some())
}
FieldType::RichText { .. } | FieldType::PlainText { .. } => {
value.as_json().is_object()
|| value.as_str().is_some()
|| (ctx == ValueContext::Document
&& super::config::scalar_as_string(value.as_json()).is_some())
}
FieldType::Integer => {
let json = value.as_json();
json.is_i64() || json.is_u64()
}
FieldType::Number => value.as_json().is_number(),
FieldType::Boolean => value.as_bool().is_some(),
FieldType::Date | FieldType::DateTime => {
if value.as_json().is_null() {
true
} else {
match value.as_str() {
Some("") => true,
Some(text) => {
let (ok, format) = match field.r#type {
FieldType::Date => (is_valid_date(text), "date"),
_ => (is_valid_datetime(text), "datetime"),
};
if ok {
true
} else {
errors.push(ValidationError::FormatViolation {
path: path.to_string(),
format: format.to_string(),
});
false
}
}
None => false,
}
}
}
FieldType::Array => match value.as_array() {
Some(items) => {
if let Some(item_schema) = &field.items {
for (idx, item) in items.iter().enumerate() {
let row_path = format!("{}[{}]", path, idx);
errors.extend(validate_value(
item_schema,
&QuillValue::from_json(item.clone()),
&row_path,
ctx,
));
}
}
true
}
None => false,
},
FieldType::Object => match value.as_object() {
Some(object) => {
if let Some(properties) = &field.properties {
let mut property_names: Vec<&String> = properties.keys().collect();
property_names.sort();
for property_name in property_names {
let property_schema = &properties[property_name];
let property_path = child_path(path, property_name);
if let Some(property_value) = object.get(property_name) {
errors.extend(validate_value(
property_schema,
&QuillValue::from_json(property_value.clone()),
&property_path,
ctx,
));
}
}
}
true
}
None => false,
},
};
if type_valid {
match field.r#type {
FieldType::RichText { inline: true } => {
let parsed =
crate::document::decode_richtext_value(value.as_json()).and_then(Result::ok);
if let Some(rt) = parsed {
if !rt.is_inline() {
errors.push(ValidationError::NotInline {
path: path.to_string(),
});
}
}
}
FieldType::PlainText { inline } => {
if let Some(rt) = crate::document::decode_plaintext_value(value.as_json())
.and_then(Result::ok)
{
if !rt.is_plain() {
errors.push(ValidationError::NotPlain {
path: path.to_string(),
});
} else if inline && !rt.is_inline() {
errors.push(ValidationError::NotInline {
path: path.to_string(),
});
}
}
}
_ => {}
}
}
let format_error_already_reported =
matches!(field.r#type, FieldType::Date | FieldType::DateTime) && value.as_str().is_some();
if !type_valid && !format_error_already_reported {
errors.push(ValidationError::TypeMismatch {
path: path.to_string(),
expected: expected_type_name(&field.r#type).to_string(),
actual: yaml_scalar_type(value.as_json()).to_string(),
source_token: verbatim_yaml_scalar(value.as_json()),
default: match ctx {
ValueContext::Document => field
.default
.as_ref()
.map(|d| verbatim_yaml_scalar(d.as_json())),
ValueContext::SchemaLiteral => None,
},
});
}
if type_valid {
if let (Some(allowed), Some(actual)) = (&field.enum_values, value.as_str()) {
if !allowed.contains(&actual.to_string()) {
errors.push(ValidationError::EnumViolation {
path: path.to_string(),
value: actual.to_string(),
allowed: allowed.clone(),
});
}
}
}
errors
}
pub(crate) fn validate_field(
field: &FieldSchema,
value: &QuillValue,
path: &str,
) -> Vec<ValidationError> {
validate_value(field, value, path, ValueContext::Document)
}
pub(crate) fn validate_schema_literal(
schema: &FieldSchema,
value: &QuillValue,
path: &str,
) -> Vec<ValidationError> {
validate_value(schema, value, path, ValueContext::SchemaLiteral)
}
fn expected_type_name(field_type: &FieldType) -> &'static str {
match field_type {
FieldType::String | FieldType::Date | FieldType::DateTime => "string",
FieldType::Enum => "string",
FieldType::RichText { .. } => "richtext",
FieldType::PlainText { .. } => "plaintext",
FieldType::Integer => "integer",
FieldType::Number => "number",
FieldType::Boolean => "boolean",
FieldType::Array => "array",
FieldType::Object => "object",
}
}
fn child_path(parent: &str, child: &str) -> String {
if parent.is_empty() {
child.to_string()
} else {
format!("{parent}.{child}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{Card, Document};
use serde_json::json;
fn config_with(main_fields: &str, cards: &str) -> QuillConfig {
let yaml = format!(
r#"
quill:
name: native_validation
backend: typst
description: Native validator tests
version: 1.0.0
main:
fields:
{main_fields}
{cards}
"#
);
let (config, warnings) = QuillConfig::from_yaml_with_warnings(&yaml).unwrap();
assert!(
warnings.is_empty(),
"config_with produced warnings (test schema is unsupported): {:?}",
warnings
);
config
}
fn doc_from_fm(entries: &[(&str, serde_json::Value)]) -> Document {
doc_with_typed_cards(entries, vec![])
}
fn doc_with_typed_cards(fm: &[(&str, serde_json::Value)], cards: Vec<Card>) -> Document {
use crate::document::Payload;
let mut payload = IndexMap::new();
for (k, v) in fm {
payload.insert(k.to_string(), QuillValue::from_json(v.clone()));
}
let mut p = Payload::from_index_map(payload);
p.set_quill("test_quill".parse().unwrap());
p.set_kind("main");
let main = Card::from_parts(p, quillmark_content::Content::empty());
Document::from_main_and_cards(main, cards)
}
fn typed_card(tag: &str, fields: &[(&str, serde_json::Value)]) -> Card {
let mut card = Card::new(tag).unwrap();
for (k, v) in fields {
card.store_field(k, QuillValue::from_json(v.clone())).unwrap();
}
card
}
fn has_error<F>(errors: &[ValidationError], predicate: F) -> bool
where
F: Fn(&ValidationError) -> bool,
{
errors.iter().any(predicate)
}
#[test]
fn validates_simple_string_field() {
let config = config_with(" title:\n type: string", "");
let doc = doc_from_fm(&[("title", json!("Memo"))]);
assert!(validate_typed_document(&config, &doc).is_ok());
}
#[test]
fn rejects_simple_string_type_mismatch() {
let config = config_with(" title:\n type: string\n default: \"\"", "");
let doc = doc_from_fm(&[("title", json!([1, 2, 3]))]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(has_error(&errors, |e| matches!(
e,
ValidationError::TypeMismatch { path, expected, actual, source_token, .. }
if path == "title" && expected == "string" && actual == "array" && source_token == "[…]"
)));
}
#[test]
fn validates_integer_field_with_integer_value() {
let config = config_with(" count:\n type: integer\n default: 0", "");
let doc = doc_from_fm(&[("count", json!(9))]);
assert!(validate_typed_document(&config, &doc).is_ok());
}
#[test]
fn rejects_integer_field_with_decimal_value() {
let config = config_with(" count:\n type: integer\n default: 0", "");
let doc = doc_from_fm(&[("count", json!(9.5))]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(has_error(&errors, |e| matches!(
e,
ValidationError::TypeMismatch { path, expected, actual, source_token, .. }
if path == "count" && expected == "integer" && actual == "number" && source_token == "9.5"
)));
}
#[test]
fn absent_unendorsed_field_raises_nothing() {
let config = config_with(" memo_for:\n type: string", "");
let doc = doc_from_fm(&[]);
assert!(validate_typed_document(&config, &doc).is_ok());
}
#[test]
fn present_null_is_treated_as_absent() {
let config = config_with(
" memo_for:\n type: string\n n:\n type: integer",
"",
);
let doc = doc_from_fm(&[("memo_for", json!(null)), ("n", json!(null))]);
assert!(
validate_typed_document(&config, &doc).is_ok(),
"present-null must validate like absence"
);
}
#[test]
fn missing_field_with_default_is_ok() {
let config = config_with(" memo_for:\n type: string\n default: \"\"", "");
let doc = doc_from_fm(&[]);
assert!(validate_typed_document(&config, &doc).is_ok());
}
#[test]
fn absent_object_property_raises_nothing() {
let config = config_with(
" 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: \"\"",
"",
);
let doc = doc_from_fm(&[("recipients", json!([{ "org": "HQ" }]))]);
assert!(validate_typed_document(&config, &doc).is_ok());
}
#[test]
fn validates_card_with_valid_discriminator() {
let config = config_with(
" title:\n type: string\n default: \"\"",
"card_kinds:\n indorsement:\n fields:\n signature_block:\n type: string",
);
let doc = doc_with_typed_cards(
&[],
vec![typed_card(
"indorsement",
&[("signature_block", json!("Signed"))],
)],
);
assert!(validate_typed_document(&config, &doc).is_ok());
}
#[test]
fn rejects_unknown_card_discriminator() {
let config = config_with(
" title:\n type: string\n default: \"\"",
"card_kinds:\n indorsement:\n fields:\n signature_block:\n type: string",
);
let doc = doc_with_typed_cards(&[], vec![typed_card("unknown", &[])]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(has_error(&errors, |e| {
matches!(e, ValidationError::UnknownCard { path, card } if path == "cards[0]" && card == "unknown")
}));
}
#[test]
fn validates_multiple_card_kinds_mixed() {
let config = config_with(
" title:\n type: string\n default: \"\"",
"card_kinds:\n indorsement:\n fields:\n signature_block:\n type: string\n routing:\n fields:\n office:\n type: string",
);
let doc = doc_with_typed_cards(
&[],
vec![
typed_card("indorsement", &[("signature_block", json!("A"))]),
typed_card("routing", &[("office", json!("HQ"))]),
],
);
assert!(validate_typed_document(&config, &doc).is_ok());
}
#[test]
fn reports_card_field_paths_with_card_name_and_index() {
let config = config_with(
" title:\n type: string\n default: \"\"",
"card_kinds:\n indorsement:\n fields:\n signature_block:\n type: string",
);
let doc = doc_with_typed_cards(
&[],
vec![typed_card(
"indorsement",
&[("signature_block", json!([1, 2, 3]))],
)],
);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(has_error(&errors, |e| {
matches!(e, ValidationError::TypeMismatch { path, .. } if path == "cards.indorsement[0].signature_block")
}));
}
#[test]
fn body_disabled_card_enforces_trim_boundary() {
let config = config_with(
" title:\n type: string\n default: \"\"",
"card_kinds:\n skills:\n body:\n enabled: false\n fields:\n items:\n type: array\n items:\n type: string\n default: []",
);
let mut prose_card = typed_card("skills", &[("items", json!(["Rust"]))]);
prose_card.revise_body("Should not be here.").unwrap();
let doc = doc_with_typed_cards(&[], vec![prose_card]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(has_error(&errors, |e| matches!(
e,
ValidationError::BodyDisabled { path, card }
if card == "skills" && path == "cards.skills[0].body"
)));
let mut ws_card = typed_card("skills", &[("items", json!(["Rust"]))]);
ws_card.revise_body("\n \n").unwrap();
let ok_doc = doc_with_typed_cards(&[], vec![ws_card]);
assert!(validate_typed_document(&config, &ok_doc).is_ok());
}
#[test]
fn to_diagnostic_carries_path_code_and_hint() {
let err = ValidationError::TypeMismatch {
path: "cards.indorsement[0].signature_block".to_string(),
expected: "string".to_string(),
actual: "integer".to_string(),
source_token: "42".to_string(),
default: None,
};
let diag = err.to_diagnostic();
assert_eq!(diag.code.as_deref(), Some("validation::type_mismatch"));
assert_eq!(
diag.path.as_deref(),
Some("cards.indorsement[0].signature_block")
);
assert_eq!(diag.severity, Severity::Error);
let hint = diag
.hint
.as_deref()
.expect("type_mismatch diagnostic should carry a hint");
assert!(
hint.contains("string"),
"hint missing expected type: {hint}"
);
}
#[test]
fn type_mismatch_diagnostic_carries_hint_matching_message() {
let config = config_with(
" build_number:\n type: string\n default: \"\"",
"",
);
let doc = doc_from_fm(&[("build_number", json!([1, 2, 3]))]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
let err = errors
.iter()
.find(|e| matches!(e, ValidationError::TypeMismatch { .. }))
.expect("expected TypeMismatch");
let diag = err.to_diagnostic();
let hint = diag
.hint
.expect("TypeMismatch diagnostic should carry a hint");
assert!(
err.to_string().ends_with(&hint),
"message tail must equal hint; msg={msg}, hint={hint}",
msg = err,
);
assert!(hint.contains("provide a value of type"));
}
#[test]
fn body_disabled_diagnostic_carries_hint() {
let err = ValidationError::BodyDisabled {
path: "cards.skills[0].body".to_string(),
card: "skills".to_string(),
};
let diag = err.to_diagnostic();
let hint = diag
.hint
.expect("BodyDisabled diagnostic should carry a hint");
assert!(hint.contains("remove the body content"));
}
#[test]
fn bare_scalar_into_string_field_is_valid() {
for value in [json!(42), json!(true), json!(1.5)] {
let config = config_with(
" build_number:\n type: string\n default: \"\"",
"",
);
let doc = doc_from_fm(&[("build_number", value.clone())]);
assert!(
validate_typed_document(&config, &doc).is_ok(),
"bare scalar {value} should validate as a string"
);
}
}
#[test]
fn main_body_disabled_with_body_content_is_an_error() {
let config = QuillConfig::from_yaml(
r#"
quill:
name: native_validation
backend: typst
description: Native validator tests
version: 1.0.0
main:
body:
enabled: false
fields:
title:
type: string
default: ""
"#,
)
.unwrap();
use crate::document::Payload;
let mut p = Payload::from_index_map(IndexMap::new());
p.set_quill("test_quill".parse().unwrap());
p.set_kind("main");
let main = Card::from_parts(
p,
crate::document::import_body("Body content that should not be here.").unwrap(),
);
let doc = Document::from_main_and_cards(main, vec![]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(has_error(&errors, |e| matches!(
e,
ValidationError::BodyDisabled { path, card }
if card == "main" && path == "main.body"
)));
}
#[test]
fn rejects_richtext_inline_with_multi_block_content() {
let config = config_with(" tag:\n type: richtext\n inline: true", "");
let rt = quillmark_content::import::from_markdown("one\n\ntwo").unwrap();
let content = quillmark_content::serial::to_canonical_value(&rt);
let doc = doc_from_fm(&[("tag", content)]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(has_error(&errors, |e| matches!(
e,
ValidationError::NotInline { path } if path == "tag"
)));
}
#[test]
fn accepts_richtext_inline_single_para_content() {
let config = config_with(" tag:\n type: richtext\n inline: true", "");
let rt = quillmark_content::import::from_markdown("one line only").unwrap();
let content = quillmark_content::serial::to_canonical_value(&rt);
let doc = doc_from_fm(&[("tag", content)]);
assert!(validate_typed_document(&config, &doc).is_ok());
}
}