use indexmap::IndexMap;
use crate::document::Document;
use crate::error::{Diagnostic, Severity};
use crate::quill::formats::is_valid_datetime;
use crate::quill::{CardSchema, FieldSchema, FieldType, QuillConfig};
use crate::value::QuillValue;
pub const MUST_FILL_SENTINEL: &str = "<must-fill>";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
FieldAbsent {
path: String,
expected: String,
},
MustFillSentinel {
path: String,
expected: String,
},
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,
},
}
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::FieldAbsent { path, expected } => write!(
f,
"Field `{path}` is missing, schema declares `{expected}` with no default. \
{hint}",
hint = field_absent_hint(expected),
),
ValidationError::MustFillSentinel { path, expected } => write!(
f,
"Field `{path}` still carries the `{MUST_FILL_SENTINEL}` blueprint sentinel, \
schema declares `{expected}`. {hint}",
hint = must_fill_sentinel_hint(expected),
),
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(path, expected, actual, source_token, 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(),
)
}
}
}
}
fn quotable_actual(actual: &str) -> bool {
matches!(actual, "integer" | "number" | "boolean")
}
fn field_absent_hint(expected: &str) -> String {
format!("Provide a value of type `{expected}`.")
}
fn must_fill_sentinel_hint(expected: &str) -> String {
format!("Replace `{MUST_FILL_SENTINEL}` with a value of type `{expected}`.")
}
fn type_mismatch_hint(
path: &str,
expected: &str,
actual: &str,
source_token: &str,
default: Option<&str>,
) -> String {
if default.is_some() && actual == "null" {
format!(
"To use the default, delete this entire line (do NOT write \
`{path}:`, `{path}: null`, or `{path}: ~` — all three parse as \
null). To set an explicit value, replace the right-hand side \
with a {expected}."
)
} else if expected == "string" && quotable_actual(actual) {
format!(
"Either quote the value (`{path}: \"{source_token}\"`) or change the schema's `type:` to `{actual}`."
)
} else 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"
}
impl ValidationError {
pub fn path(&self) -> &str {
match self {
ValidationError::FieldAbsent { path, .. }
| ValidationError::MustFillSentinel { path, .. }
| ValidationError::TypeMismatch { path, .. }
| ValidationError::EnumViolation { path, .. }
| ValidationError::FormatViolation { path, .. }
| ValidationError::UnknownCard { path, .. }
| ValidationError::BodyDisabled { path, .. } => path,
}
}
pub fn code(&self) -> &'static str {
match self {
ValidationError::FieldAbsent { .. } => "validation::field_absent",
ValidationError::MustFillSentinel { .. } => "validation::must_fill_sentinel",
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",
}
}
pub fn hint(&self) -> Option<String> {
match self {
ValidationError::FieldAbsent { expected, .. } => Some(field_absent_hint(expected)),
ValidationError::MustFillSentinel { expected, .. } => {
Some(must_fill_sentinel_hint(expected))
}
ValidationError::TypeMismatch {
path,
expected,
actual,
source_token,
default,
} => Some(type_mismatch_hint(
path,
expected,
actual,
source_token,
default.as_deref(),
)),
ValidationError::BodyDisabled { .. } => Some(body_disabled_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().trim().is_empty() {
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().trim().is_empty() {
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);
match fields.get(field_name) {
Some(value) => errors.extend(validate_field(schema, value, &path)),
None if schema.default.is_none() => {
errors.push(ValidationError::FieldAbsent {
path,
expected: expected_type_name(&schema.r#type).to_string(),
})
}
None => {}
}
}
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 {
if let Some(text) = value.as_str() {
let candidate = if matches!(field.r#type, FieldType::Markdown) {
text.trim()
} else {
text
};
if candidate == MUST_FILL_SENTINEL {
return vec![ValidationError::MustFillSentinel {
path: path.to_string(),
expected: expected_type_name(&field.r#type).to_string(),
}];
}
}
}
let mut errors = Vec::new();
let type_valid = match field.r#type {
FieldType::String | FieldType::Markdown => value.as_str().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::DateTime => {
if value.as_json().is_null() {
true
} else {
match value.as_str() {
Some("") => true,
Some(text) => {
if is_valid_datetime(text) {
true
} else {
errors.push(ValidationError::FormatViolation {
path: path.to_string(),
format: "datetime".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);
match object.get(property_name) {
Some(property_value) => errors.extend(validate_value(
property_schema,
&QuillValue::from_json(property_value.clone()),
&property_path,
ctx,
)),
None if ctx == ValueContext::Document
&& property_schema.default.is_none() =>
{
errors.push(ValidationError::FieldAbsent {
path: property_path,
expected: expected_type_name(&property_schema.r#type)
.to_string(),
})
}
None => {}
}
}
}
true
}
None => false,
},
};
let format_error_already_reported =
matches!(field.r#type, 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::Markdown | FieldType::DateTime => "string",
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, String::new());
Document::from_main_and_cards(main, cards, vec![])
}
fn typed_card(tag: &str, fields: &[(&str, serde_json::Value)]) -> Card {
let mut card = Card::new(tag).unwrap();
for (k, v) in fields {
card.set_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!(9))]);
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 == "integer" && source_token == "9"
)));
}
#[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 reports_absent_must_fill_field() {
let config = config_with(" memo_for:\n type: string", "");
let doc = doc_from_fm(&[]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(has_error(&errors, |e| {
matches!(e, ValidationError::FieldAbsent { path, expected } if path == "memo_for" && expected == "string")
}));
}
#[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 detects_must_fill_sentinel() {
let config = config_with(" memo_for:\n type: string", "");
let doc = doc_from_fm(&[("memo_for", json!(MUST_FILL_SENTINEL))]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(has_error(&errors, |e| {
matches!(e, ValidationError::MustFillSentinel { path, expected } if path == "memo_for" && expected == "string")
}));
}
#[test]
fn detects_must_fill_sentinel_in_markdown_block() {
let config = config_with(" body:\n type: markdown", "");
let doc = doc_from_fm(&[("body", json!(format!(" {}\n", MUST_FILL_SENTINEL)))]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(has_error(&errors, |e| {
matches!(e, ValidationError::MustFillSentinel { path, .. } if path == "body")
}));
}
#[test]
fn reports_must_fill_property_absent_in_array_object() {
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" }]))]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(has_error(&errors, |e| {
matches!(e, ValidationError::FieldAbsent { path, .. } if path == "recipients[0].name")
}));
}
#[test]
fn detects_must_fill_sentinel_as_typed_table_row() {
let config = config_with(
" recipients:\n type: array\n items:\n type: object\n properties:\n name:\n type: string\n org:\n type: string",
"",
);
let doc = doc_from_fm(&[("recipients", json!([MUST_FILL_SENTINEL]))]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(
has_error(&errors, |e| matches!(
e,
ValidationError::MustFillSentinel { path, .. }
if path == "recipients[0]"
)),
"expected sentinel error at row path; got {errors:?}"
);
assert!(
!has_error(&errors, |e| matches!(
e,
ValidationError::FieldAbsent { path, .. }
if path.starts_with("recipients[0].")
)),
"row-level sentinel should suppress per-property Absent errors; got {errors:?}"
);
}
#[test]
fn accumulates_multiple_absent_must_fill_errors() {
let config = config_with(
" memo_for:\n type: string\n memo_from:\n type: string",
"",
);
let doc = doc_from_fm(&[]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
let missing_paths: Vec<&str> = errors
.iter()
.filter_map(|e| match e {
ValidationError::FieldAbsent { path, .. } => Some(path.as_str()),
_ => None,
})
.collect();
assert!(missing_paths.contains(&"memo_for"));
assert!(missing_paths.contains(&"memo_from"));
}
#[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_instances_same_type() {
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!("A"))]),
typed_card("indorsement", &[("signature_block", json!("B"))]),
],
);
assert!(validate_typed_document(&config, &doc).is_ok());
}
#[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", &[])]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
assert!(has_error(&errors, |e| {
matches!(e, ValidationError::FieldAbsent { 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.replace_body("Should not be here.");
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.replace_body("\n \n");
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_and_code() {
let err = ValidationError::FieldAbsent {
path: "cards.indorsement[0].signature_block".to_string(),
expected: "string".to_string(),
};
let diag = err.to_diagnostic();
assert_eq!(diag.code.as_deref(), Some("validation::field_absent"));
assert_eq!(
diag.path.as_deref(),
Some("cards.indorsement[0].signature_block")
);
assert_eq!(diag.severity, Severity::Error);
}
#[test]
fn field_absent_diagnostic_carries_actionable_hint() {
let err = ValidationError::FieldAbsent {
path: "title".to_string(),
expected: "string".to_string(),
};
let diag = err.to_diagnostic();
let hint = diag
.hint
.as_deref()
.expect("field_absent diagnostic should carry a hint");
assert!(hint.contains("string"), "hint missing expected type: {hint}");
assert!(
!hint.contains(MUST_FILL_SENTINEL),
"absent-branch hint must not mention the sentinel: {hint}"
);
}
#[test]
fn must_fill_sentinel_diagnostic_carries_actionable_hint() {
let err = ValidationError::MustFillSentinel {
path: "title".to_string(),
expected: "string".to_string(),
};
let diag = err.to_diagnostic();
assert_eq!(
diag.code.as_deref(),
Some("validation::must_fill_sentinel")
);
let hint = diag
.hint
.as_deref()
.expect("must_fill_sentinel diagnostic should carry a hint");
assert!(hint.contains(MUST_FILL_SENTINEL));
assert!(hint.contains("string"));
}
#[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!(42))]);
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("quote the value"));
}
#[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 type_mismatch_message_has_canonical_shape_quote_exit() {
let config = config_with(" build_number:\n type: string\n default: \"\"", "");
let doc = doc_from_fm(&[("build_number", json!(42))]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
let msg = errors
.iter()
.find_map(|e| match e {
ValidationError::TypeMismatch { .. } => Some(e.to_string()),
_ => None,
})
.expect("expected TypeMismatch");
assert!(
msg.contains("Field `build_number` got integer `42`, schema declares `string`"),
"wrong head: {msg}"
);
assert!(
msg.contains("quote the value (`build_number: \"42\"`)"),
"missing quote exit: {msg}"
);
assert!(
msg.contains("change the schema's `type:` to `integer`"),
"missing schema-change exit: {msg}"
);
}
#[test]
fn type_mismatch_message_has_canonical_shape_default_exit() {
let config = config_with(
" subtitle:\n type: string\n default: \"My Subtitle\"",
"",
);
let doc = doc_from_fm(&[("subtitle", json!(null))]);
let errors = validate_typed_document(&config, &doc).unwrap_err();
let msg = errors
.iter()
.find_map(|e| match e {
ValidationError::TypeMismatch { .. } => Some(e.to_string()),
_ => None,
})
.expect("expected TypeMismatch");
assert!(
msg.contains(
"Field `subtitle` got null `null`, schema declares `string` with default `\"My Subtitle\"`"
),
"wrong head: {msg}"
);
assert!(
msg.contains("delete this entire line"),
"missing omit-line exit: {msg}"
);
assert!(
msg.contains("`subtitle: ~`"),
"expected message to name the `~` shorthand: {msg}"
);
}
#[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, "Body content that should not be here.".to_string());
let doc = Document::from_main_and_cards(main, vec![], 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"
)));
}
}