1use std::collections::BTreeMap;
2
3use indexmap::IndexMap;
4
5use crate::document::Document;
6use crate::error::{Diagnostic, Severity, diag_args};
7use crate::path::DocPath;
8use crate::quill::formats::{is_valid_date, is_valid_datetime};
9use crate::quill::{CardSchema, FieldSchema, FieldType, QuillConfig};
10use crate::value::QuillValue;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum ValidationError {
26 TypeMismatch {
27 path: String,
28 expected: String,
30 actual: String,
33 source_token: String,
36 default: Option<String>,
39 },
40
41 EnumViolation {
42 path: String,
43 value: String,
44 allowed: Vec<String>,
45 },
46
47 FormatViolation {
48 path: String,
49 format: String,
50 },
51
52 UnknownCard {
53 path: String,
54 card: String,
55 },
56
57 BodyDisabled {
58 path: String,
59 card: String,
60 },
61
62 NotInline {
67 path: String,
68 },
69
70 NotPlain {
75 path: String,
76 },
77}
78
79impl std::error::Error for ValidationError {}
80
81impl std::fmt::Display for ValidationError {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 ValidationError::TypeMismatch {
85 path,
86 expected,
87 actual,
88 source_token,
89 default,
90 } => {
91 write!(
93 f,
94 "Field `{path}` got {actual} `{source_token}`, schema declares `{expected}`"
95 )?;
96 if let Some(d) = default {
97 write!(f, " with default `{d}`")?;
98 }
99 write!(
100 f,
101 ". {hint}",
102 hint = type_mismatch_hint(expected, actual, default.as_deref())
103 )
104 }
105 ValidationError::EnumViolation {
106 path,
107 value,
108 allowed,
109 } => {
110 write!(
111 f,
112 "field `{path}` value `{value}` not in allowed set {allowed:?}"
113 )
114 }
115 ValidationError::FormatViolation { path, format } => {
116 write!(
117 f,
118 "field `{path}` does not match expected format `{format}`"
119 )
120 }
121 ValidationError::UnknownCard { path, card } => {
122 write!(f, "unknown card kind `{card}` at `{path}`")
123 }
124 ValidationError::BodyDisabled { path, card } => {
125 write!(
126 f,
127 "card `{card}` at `{path}` has body content but the card kind declares `body.enabled: false`: {hint}",
128 hint = body_disabled_hint(),
129 )
130 }
131 ValidationError::NotInline { path } => {
132 write!(
133 f,
134 "field `{path}` is `richtext(inline)` but its content is not a single \
135 paragraph: {hint}",
136 hint = not_inline_hint(),
137 )
138 }
139 ValidationError::NotPlain { path } => {
140 write!(
141 f,
142 "field `{path}` is `plaintext` but its content carries formatting: {hint}",
143 hint = not_plain_hint(),
144 )
145 }
146 }
147 }
148}
149
150fn type_mismatch_hint(expected: &str, actual: &str, default: Option<&str>) -> String {
154 if default.is_some() {
155 format!(
156 "Either omit the line (the default will fill in) or provide a value of type `{expected}`."
157 )
158 } else {
159 format!(
160 "Either provide a value of type `{expected}` or change the schema's `type:` to `{actual}`."
161 )
162 }
163}
164
165fn body_disabled_hint() -> &'static str {
168 "remove the body content or set `body.enabled: true` on the card kind"
169}
170
171fn not_inline_hint() -> &'static str {
173 "keep the value to a single paragraph (no blank lines, headings, lists, \
174 quotes, or tables), or change the schema's `type:` to `richtext`"
175}
176
177fn not_plain_hint() -> &'static str {
179 "remove the formatting (marks, tables, images, headings, lists, quotes), or \
180 change the schema's `type:` to `richtext`"
181}
182
183impl ValidationError {
184 pub fn path(&self) -> &str {
188 match self {
189 ValidationError::TypeMismatch { path, .. }
190 | ValidationError::EnumViolation { path, .. }
191 | ValidationError::FormatViolation { path, .. }
192 | ValidationError::UnknownCard { path, .. }
193 | ValidationError::BodyDisabled { path, .. }
194 | ValidationError::NotInline { path, .. }
195 | ValidationError::NotPlain { path, .. } => path,
196 }
197 }
198
199 pub fn code(&self) -> &'static str {
202 match self {
203 ValidationError::TypeMismatch { .. } => "validation::type_mismatch",
204 ValidationError::EnumViolation { .. } => "validation::enum_violation",
205 ValidationError::FormatViolation { .. } => "validation::format_violation",
206 ValidationError::UnknownCard { .. } => "validation::unknown_card",
207 ValidationError::BodyDisabled { .. } => "validation::body_disabled",
208 ValidationError::NotInline { .. } => "richtext::not_inline",
209 ValidationError::NotPlain { .. } => "plaintext::not_plain",
210 }
211 }
212
213 pub fn args(&self) -> BTreeMap<String, serde_json::Value> {
226 match self {
227 ValidationError::TypeMismatch {
228 path: _,
229 expected,
230 actual,
231 source_token,
232 default,
233 } => {
234 let mut args = diag_args! {
235 "expected" => expected,
236 "actual" => actual,
237 "sourceToken" => source_token,
238 };
239 if let Some(default) = default {
240 args.insert("default".to_string(), serde_json::json!(default));
241 }
242 args
243 }
244 ValidationError::EnumViolation {
245 path: _,
246 value,
247 allowed,
248 } => diag_args! {
249 "value" => value,
250 "allowed" => allowed,
251 },
252 ValidationError::FormatViolation { path: _, format } => diag_args! {
253 "format" => format,
254 },
255 ValidationError::UnknownCard { path: _, card } => diag_args! {
256 "card" => card,
257 },
258 ValidationError::BodyDisabled { path: _, card } => diag_args! {
259 "card" => card,
260 },
261 ValidationError::NotInline { path: _ } => diag_args! {},
262 ValidationError::NotPlain { path: _ } => diag_args! {},
263 }
264 }
265
266 pub fn hint(&self) -> Option<String> {
270 match self {
271 ValidationError::TypeMismatch {
272 expected,
273 actual,
274 default,
275 ..
276 } => Some(type_mismatch_hint(expected, actual, default.as_deref())),
277 ValidationError::BodyDisabled { .. } => Some(body_disabled_hint().to_string()),
278 ValidationError::NotInline { .. } => Some(not_inline_hint().to_string()),
279 ValidationError::NotPlain { .. } => Some(not_plain_hint().to_string()),
280 ValidationError::EnumViolation { .. }
281 | ValidationError::FormatViolation { .. }
282 | ValidationError::UnknownCard { .. } => None,
283 }
284 }
285
286 pub fn to_diagnostic(&self) -> Diagnostic {
290 let mut diag = Diagnostic::new(Severity::Error, self.to_string())
291 .with_code(self.code().to_string())
292 .with_path(self.path().to_string())
293 .with_args(self.args());
294 if let Some(hint) = self.hint() {
295 diag = diag.with_hint(hint);
296 }
297 diag
298 }
299}
300
301fn verbatim_yaml_scalar(value: &serde_json::Value) -> String {
305 match value {
306 serde_json::Value::Null => "null".to_string(),
307 serde_json::Value::Bool(b) => b.to_string(),
308 serde_json::Value::Number(n) => n.to_string(),
309 serde_json::Value::String(s) => format!("\"{s}\""),
310 serde_json::Value::Array(_) => "[…]".to_string(),
311 serde_json::Value::Object(_) => "{…}".to_string(),
312 }
313}
314
315fn yaml_scalar_type(value: &serde_json::Value) -> &'static str {
318 match value {
319 serde_json::Value::Null => "null",
320 serde_json::Value::Bool(_) => "boolean",
321 serde_json::Value::Number(n) => {
322 if n.is_i64() || n.is_u64() {
323 "integer"
324 } else {
325 "number"
326 }
327 }
328 serde_json::Value::String(_) => "string",
329 serde_json::Value::Array(_) => "array",
330 serde_json::Value::Object(_) => "object",
331 }
332}
333
334pub fn validate_typed_document(
338 config: &QuillConfig,
339 doc: &Document,
340) -> Result<(), Vec<ValidationError>> {
341 let main_fields = doc.main().payload().to_index_map();
342 let mut errors = validate_fields_for_card_indexmap(&config.main, &main_fields, &DocPath::main());
343
344 if !config.main.body_enabled() && !doc.main().body().is_blank() {
347 errors.push(ValidationError::BodyDisabled {
348 path: DocPath::main_body().to_string(),
349 card: "main".to_string(),
350 });
351 }
352
353 for (index, card) in doc.cards().iter().enumerate() {
354 let card_name = card.kind().unwrap_or("").to_string();
355
356 let Some(card_schema) = config.card_kind(card_name.as_str()) else {
357 errors.push(ValidationError::UnknownCard {
361 path: DocPath::card(None, index).to_string(),
362 card: card_name,
363 });
364 continue;
365 };
366
367 let card_path = DocPath::card(Some(&card_name), index);
368 let card_fields = card.payload().to_index_map();
369 errors.extend(validate_fields_for_card_indexmap(
370 card_schema,
371 &card_fields,
372 &card_path,
373 ));
374
375 if !card_schema.body_enabled() && !card.body().is_blank() {
376 errors.push(ValidationError::BodyDisabled {
377 path: card_path.body().to_string(),
378 card: card_name,
379 });
380 }
381 }
382
383 if errors.is_empty() {
384 Ok(())
385 } else {
386 Err(errors)
387 }
388}
389
390fn validate_fields_for_card_indexmap(
391 card: &CardSchema,
392 fields: &IndexMap<String, QuillValue>,
393 base: &DocPath,
394) -> Vec<ValidationError> {
395 let mut errors = Vec::new();
396 let mut field_names: Vec<&String> = card.fields.keys().collect();
397 field_names.sort();
398
399 for field_name in field_names {
400 let schema = &card.fields[field_name];
401 let path = base.field(field_name);
402 if let Some(value) = fields.get(field_name) {
406 errors.extend(validate_field(schema, value, &path));
407 }
408 }
409
410 errors
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417enum ValueContext {
418 Document,
421 SchemaLiteral,
425}
426
427fn validate_value(
432 field: &FieldSchema,
433 value: &QuillValue,
434 path: &DocPath,
435 ctx: ValueContext,
436) -> Vec<ValidationError> {
437 if ctx == ValueContext::Document && value.as_json().is_null() {
441 return vec![];
442 }
443
444 let mut errors = Vec::new();
445
446 let type_valid = match field.r#type {
447 FieldType::String | FieldType::Enum => {
454 value.as_str().is_some()
455 || (ctx == ValueContext::Document
456 && super::config::scalar_as_string(value.as_json()).is_some())
457 }
458 FieldType::RichText { .. } | FieldType::PlainText { .. } => {
465 value.as_json().is_object()
466 || value.as_str().is_some()
467 || (ctx == ValueContext::Document
468 && super::config::scalar_as_string(value.as_json()).is_some())
469 }
470 FieldType::Integer => {
471 let json = value.as_json();
472 json.is_i64() || json.is_u64()
473 }
474 FieldType::Number => value.as_json().is_number(),
475 FieldType::Boolean => value.as_bool().is_some(),
476 FieldType::Date | FieldType::DateTime => {
477 if value.as_json().is_null() {
478 true
479 } else {
480 match value.as_str() {
481 Some("") => true,
482 Some(text) => {
483 let (ok, format) = match field.r#type {
484 FieldType::Date => (is_valid_date(text), "date"),
485 _ => (is_valid_datetime(text), "datetime"),
486 };
487 if ok {
488 true
489 } else {
490 errors.push(ValidationError::FormatViolation {
491 path: path.to_string(),
492 format: format.to_string(),
493 });
494 false
495 }
496 }
497 None => false,
498 }
499 }
500 }
501 FieldType::Array => match value.as_array() {
502 Some(items) => {
503 if let Some(item_schema) = &field.items {
508 for (idx, item) in items.iter().enumerate() {
509 let row_path = path.index(idx);
510 errors.extend(validate_value(
511 item_schema,
512 &QuillValue::from_json(item.clone()),
513 &row_path,
514 ctx,
515 ));
516 }
517 }
518 true
519 }
520 None => false,
521 },
522 FieldType::Object => match value.as_object() {
523 Some(object) => {
524 if let Some(properties) = &field.properties {
525 let mut property_names: Vec<&String> = properties.keys().collect();
526 property_names.sort();
527 for property_name in property_names {
528 let property_schema = &properties[property_name];
529 let property_path = path.field(property_name);
530 if let Some(property_value) = object.get(property_name) {
534 errors.extend(validate_value(
535 property_schema,
536 &QuillValue::from_json(property_value.clone()),
537 &property_path,
538 ctx,
539 ));
540 }
541 }
542 }
543 true
544 }
545 None => false,
546 },
547 };
548
549 if type_valid {
557 match field.r#type {
558 FieldType::RichText { inline: true } => {
559 let parsed =
560 crate::document::decode_richtext_value(value.as_json()).and_then(Result::ok);
561 if let Some(rt) = parsed {
562 if !rt.is_inline() {
563 errors.push(ValidationError::NotInline {
564 path: path.to_string(),
565 });
566 }
567 }
568 }
569 FieldType::PlainText { inline } => {
570 if let Some(rt) = crate::document::decode_plaintext_value(value.as_json())
576 .and_then(Result::ok)
577 {
578 if !rt.is_plain() {
579 errors.push(ValidationError::NotPlain {
580 path: path.to_string(),
581 });
582 } else if inline && !rt.is_inline() {
583 errors.push(ValidationError::NotInline {
584 path: path.to_string(),
585 });
586 }
587 }
588 }
589 _ => {}
590 }
591 }
592
593 let format_error_already_reported =
596 matches!(field.r#type, FieldType::Date | FieldType::DateTime) && value.as_str().is_some();
597
598 if !type_valid && !format_error_already_reported {
599 errors.push(ValidationError::TypeMismatch {
600 path: path.to_string(),
601 expected: expected_type_name(&field.r#type).to_string(),
602 actual: yaml_scalar_type(value.as_json()).to_string(),
603 source_token: verbatim_yaml_scalar(value.as_json()),
604 default: match ctx {
608 ValueContext::Document => field
609 .default
610 .as_ref()
611 .map(|d| verbatim_yaml_scalar(d.as_json())),
612 ValueContext::SchemaLiteral => None,
613 },
614 });
615 }
616
617 if type_valid {
618 if let (Some(allowed), Some(actual)) = (&field.enum_values, value.as_str()) {
619 if !allowed.contains(&actual.to_string()) {
620 errors.push(ValidationError::EnumViolation {
621 path: path.to_string(),
622 value: actual.to_string(),
623 allowed: allowed.clone(),
624 });
625 }
626 }
627 }
628
629 errors
630}
631
632pub(crate) fn validate_field(
635 field: &FieldSchema,
636 value: &QuillValue,
637 path: &DocPath,
638) -> Vec<ValidationError> {
639 validate_value(field, value, path, ValueContext::Document)
640}
641
642pub(crate) fn validate_schema_literal(
650 schema: &FieldSchema,
651 value: &QuillValue,
652 path: &DocPath,
653) -> Vec<ValidationError> {
654 validate_value(schema, value, path, ValueContext::SchemaLiteral)
655}
656
657fn expected_type_name(field_type: &FieldType) -> &'static str {
658 match field_type {
659 FieldType::String | FieldType::Date | FieldType::DateTime => "string",
660 FieldType::Enum => "string",
662 FieldType::RichText { .. } => "richtext",
663 FieldType::PlainText { .. } => "plaintext",
664 FieldType::Integer => "integer",
665 FieldType::Number => "number",
666 FieldType::Boolean => "boolean",
667 FieldType::Array => "array",
668 FieldType::Object => "object",
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675 use crate::document::{Card, Document};
676 use serde_json::json;
677
678 fn config_with(main_fields: &str, cards: &str) -> QuillConfig {
679 let yaml = format!(
680 r#"
681quill:
682 name: native_validation
683 backend: typst
684 description: Native validator tests
685 version: 1.0.0
686main:
687 fields:
688{main_fields}
689{cards}
690"#
691 );
692 let (config, warnings) = QuillConfig::from_yaml_with_warnings(&yaml).unwrap();
693 assert!(
694 warnings.is_empty(),
695 "config_with produced warnings (test schema is unsupported): {:?}",
696 warnings
697 );
698 config
699 }
700
701 fn doc_from_fm(entries: &[(&str, serde_json::Value)]) -> Document {
702 doc_with_typed_cards(entries, vec![])
703 }
704
705 fn doc_with_typed_cards(fm: &[(&str, serde_json::Value)], cards: Vec<Card>) -> Document {
706 use crate::document::Payload;
707 let mut payload = IndexMap::new();
708 for (k, v) in fm {
709 payload.insert(k.to_string(), QuillValue::from_json(v.clone()));
710 }
711 let mut p = Payload::from_index_map(payload);
712 p.set_quill("test_quill".parse().unwrap());
713 p.set_kind("main");
714 let main = Card::from_parts(p, quillmark_content::Content::empty());
715 Document::from_main_and_cards(main, cards)
716 }
717
718 fn typed_card(tag: &str, fields: &[(&str, serde_json::Value)]) -> Card {
719 let mut card = Card::new(tag).unwrap();
720 for (k, v) in fields {
721 card.store_field(k, QuillValue::from_json(v.clone())).unwrap();
722 }
723 card
724 }
725
726 fn has_error<F>(errors: &[ValidationError], predicate: F) -> bool
727 where
728 F: Fn(&ValidationError) -> bool,
729 {
730 errors.iter().any(predicate)
731 }
732
733 #[test]
734 fn validates_simple_string_field() {
735 let config = config_with(" title:\n type: string", "");
736 let doc = doc_from_fm(&[("title", json!("Memo"))]);
737 assert!(validate_typed_document(&config, &doc).is_ok());
738 }
739
740 #[test]
741 fn rejects_simple_string_type_mismatch() {
742 let config = config_with(" title:\n type: string\n default: \"\"", "");
745 let doc = doc_from_fm(&[("title", json!([1, 2, 3]))]);
746 let errors = validate_typed_document(&config, &doc).unwrap_err();
747 assert!(has_error(&errors, |e| matches!(
748 e,
749 ValidationError::TypeMismatch { path, expected, actual, source_token, .. }
750 if path == "main.title" && expected == "string" && actual == "array" && source_token == "[…]"
751 )));
752 }
753
754 #[test]
755 fn validates_integer_field_with_integer_value() {
756 let config = config_with(" count:\n type: integer\n default: 0", "");
757 let doc = doc_from_fm(&[("count", json!(9))]);
758 assert!(validate_typed_document(&config, &doc).is_ok());
759 }
760
761 #[test]
762 fn rejects_integer_field_with_decimal_value() {
763 let config = config_with(" count:\n type: integer\n default: 0", "");
764 let doc = doc_from_fm(&[("count", json!(9.5))]);
765 let errors = validate_typed_document(&config, &doc).unwrap_err();
766 assert!(has_error(&errors, |e| matches!(
767 e,
768 ValidationError::TypeMismatch { path, expected, actual, source_token, .. }
769 if path == "main.count" && expected == "integer" && actual == "number" && source_token == "9.5"
770 )));
771 }
772
773 #[test]
774 fn absent_unendorsed_field_raises_nothing() {
775 let config = config_with(" memo_for:\n type: string", "");
779 let doc = doc_from_fm(&[]);
780 assert!(validate_typed_document(&config, &doc).is_ok());
781 }
782
783 #[test]
784 fn present_null_is_treated_as_absent() {
785 let config = config_with(
788 " memo_for:\n type: string\n n:\n type: integer",
789 "",
790 );
791 let doc = doc_from_fm(&[("memo_for", json!(null)), ("n", json!(null))]);
792 assert!(
793 validate_typed_document(&config, &doc).is_ok(),
794 "present-null must validate like absence"
795 );
796 }
797
798 #[test]
799 fn missing_field_with_default_is_ok() {
800 let config = config_with(" memo_for:\n type: string\n default: \"\"", "");
802 let doc = doc_from_fm(&[]);
803 assert!(validate_typed_document(&config, &doc).is_ok());
804 }
805
806 #[test]
807 fn absent_object_property_raises_nothing() {
808 let config = config_with(
812 " 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: \"\"",
813 "",
814 );
815 let doc = doc_from_fm(&[("recipients", json!([{ "org": "HQ" }]))]);
816 assert!(validate_typed_document(&config, &doc).is_ok());
817 }
818
819 #[test]
825 fn validates_card_with_valid_discriminator() {
826 let config = config_with(
827 " title:\n type: string\n default: \"\"",
828 "card_kinds:\n indorsement:\n fields:\n signature_block:\n type: string",
829 );
830 let doc = doc_with_typed_cards(
831 &[],
832 vec![typed_card(
833 "indorsement",
834 &[("signature_block", json!("Signed"))],
835 )],
836 );
837 assert!(validate_typed_document(&config, &doc).is_ok());
838 }
839
840 #[test]
841 fn rejects_unknown_card_discriminator() {
842 let config = config_with(
843 " title:\n type: string\n default: \"\"",
844 "card_kinds:\n indorsement:\n fields:\n signature_block:\n type: string",
845 );
846 let doc = doc_with_typed_cards(&[], vec![typed_card("unknown", &[])]);
847 let errors = validate_typed_document(&config, &doc).unwrap_err();
848 assert!(has_error(&errors, |e| {
849 matches!(e, ValidationError::UnknownCard { path, card } if path == "cards[0]" && card == "unknown")
850 }));
851 }
852
853 #[test]
854 fn validates_multiple_card_kinds_mixed() {
855 let config = config_with(
856 " title:\n type: string\n default: \"\"",
857 "card_kinds:\n indorsement:\n fields:\n signature_block:\n type: string\n routing:\n fields:\n office:\n type: string",
858 );
859 let doc = doc_with_typed_cards(
860 &[],
861 vec![
862 typed_card("indorsement", &[("signature_block", json!("A"))]),
863 typed_card("routing", &[("office", json!("HQ"))]),
864 ],
865 );
866 assert!(validate_typed_document(&config, &doc).is_ok());
867 }
868
869 #[test]
870 fn reports_card_field_paths_with_card_name_and_index() {
871 let config = config_with(
875 " title:\n type: string\n default: \"\"",
876 "card_kinds:\n indorsement:\n fields:\n signature_block:\n type: string",
877 );
878 let doc = doc_with_typed_cards(
879 &[],
880 vec![typed_card(
881 "indorsement",
882 &[("signature_block", json!([1, 2, 3]))],
883 )],
884 );
885 let errors = validate_typed_document(&config, &doc).unwrap_err();
886 assert!(has_error(&errors, |e| {
887 matches!(e, ValidationError::TypeMismatch { path, .. } if path == "cards.indorsement[0].signature_block")
888 }));
889 }
890
891 #[test]
892 fn body_disabled_card_enforces_trim_boundary() {
893 let config = config_with(
894 " title:\n type: string\n default: \"\"",
895 "card_kinds:\n skills:\n body:\n enabled: false\n fields:\n items:\n type: array\n items:\n type: string\n default: []",
896 );
897 let mut prose_card = typed_card("skills", &[("items", json!(["Rust"]))]);
899 prose_card.revise_body("Should not be here.").unwrap();
900 let doc = doc_with_typed_cards(&[], vec![prose_card]);
901 let errors = validate_typed_document(&config, &doc).unwrap_err();
902 assert!(has_error(&errors, |e| matches!(
903 e,
904 ValidationError::BodyDisabled { path, card }
905 if card == "skills" && path == "cards.skills[0].body"
906 )));
907
908 let mut ws_card = typed_card("skills", &[("items", json!(["Rust"]))]);
909 ws_card.revise_body("\n \n").unwrap();
910 let ok_doc = doc_with_typed_cards(&[], vec![ws_card]);
911 assert!(validate_typed_document(&config, &ok_doc).is_ok());
912 }
913
914 #[test]
915 fn to_diagnostic_carries_path_code_and_hint() {
916 let err = ValidationError::TypeMismatch {
917 path: "cards.indorsement[0].signature_block".to_string(),
918 expected: "string".to_string(),
919 actual: "integer".to_string(),
920 source_token: "42".to_string(),
921 default: None,
922 };
923 let diag = err.to_diagnostic();
924 assert_eq!(diag.code.as_deref(), Some("validation::type_mismatch"));
925 assert_eq!(
926 diag.path.as_deref(),
927 Some("cards.indorsement[0].signature_block")
928 );
929 assert_eq!(diag.severity, Severity::Error);
930 let hint = diag
931 .hint
932 .as_deref()
933 .expect("type_mismatch diagnostic should carry a hint");
934 assert!(
935 hint.contains("string"),
936 "hint missing expected type: {hint}"
937 );
938 }
939
940 #[test]
941 fn type_mismatch_diagnostic_carries_hint_matching_message() {
942 let config = config_with(
947 " build_number:\n type: string\n default: \"\"",
948 "",
949 );
950 let doc = doc_from_fm(&[("build_number", json!([1, 2, 3]))]);
951 let errors = validate_typed_document(&config, &doc).unwrap_err();
952 let err = errors
953 .iter()
954 .find(|e| matches!(e, ValidationError::TypeMismatch { .. }))
955 .expect("expected TypeMismatch");
956 let diag = err.to_diagnostic();
957 let hint = diag
958 .hint
959 .expect("TypeMismatch diagnostic should carry a hint");
960 assert!(
961 err.to_string().ends_with(&hint),
962 "message tail must equal hint; msg={msg}, hint={hint}",
963 msg = err,
964 );
965 assert!(hint.contains("provide a value of type"));
966 }
967
968 #[test]
969 fn body_disabled_diagnostic_carries_hint() {
970 let err = ValidationError::BodyDisabled {
971 path: "cards.skills[0].body".to_string(),
972 card: "skills".to_string(),
973 };
974 let diag = err.to_diagnostic();
975 let hint = diag
976 .hint
977 .expect("BodyDisabled diagnostic should carry a hint");
978 assert!(hint.contains("remove the body content"));
979 }
980
981 #[test]
982 fn bare_scalar_into_string_field_is_valid() {
983 for value in [json!(42), json!(true), json!(1.5)] {
988 let config = config_with(
989 " build_number:\n type: string\n default: \"\"",
990 "",
991 );
992 let doc = doc_from_fm(&[("build_number", value.clone())]);
993 assert!(
994 validate_typed_document(&config, &doc).is_ok(),
995 "bare scalar {value} should validate as a string"
996 );
997 }
998 }
999
1000 #[test]
1001 fn main_body_disabled_with_body_content_is_an_error() {
1002 let config = QuillConfig::from_yaml(
1003 r#"
1004quill:
1005 name: native_validation
1006 backend: typst
1007 description: Native validator tests
1008 version: 1.0.0
1009main:
1010 body:
1011 enabled: false
1012 fields:
1013 title:
1014 type: string
1015 default: ""
1016"#,
1017 )
1018 .unwrap();
1019 use crate::document::Payload;
1020 let mut p = Payload::from_index_map(IndexMap::new());
1021 p.set_quill("test_quill".parse().unwrap());
1022 p.set_kind("main");
1023 let main = Card::from_parts(
1024 p,
1025 crate::document::import_body("Body content that should not be here.").unwrap(),
1026 );
1027 let doc = Document::from_main_and_cards(main, vec![]);
1028 let errors = validate_typed_document(&config, &doc).unwrap_err();
1029 assert!(has_error(&errors, |e| matches!(
1030 e,
1031 ValidationError::BodyDisabled { path, card }
1032 if card == "main" && path == "main.body"
1033 )));
1034 }
1035
1036 #[test]
1037 fn rejects_richtext_inline_with_multi_block_content() {
1038 let config = config_with(" tag:\n type: richtext\n inline: true", "");
1041 let rt = quillmark_content::import::from_markdown("one\n\ntwo").unwrap();
1042 let content = quillmark_content::serial::to_canonical_value(&rt);
1043 let doc = doc_from_fm(&[("tag", content)]);
1044 let errors = validate_typed_document(&config, &doc).unwrap_err();
1045 assert!(has_error(&errors, |e| matches!(
1046 e,
1047 ValidationError::NotInline { path } if path == "main.tag"
1048 )));
1049 }
1050
1051 #[test]
1052 fn accepts_richtext_inline_single_para_content() {
1053 let config = config_with(" tag:\n type: richtext\n inline: true", "");
1054 let rt = quillmark_content::import::from_markdown("one line only").unwrap();
1055 let content = quillmark_content::serial::to_canonical_value(&rt);
1056 let doc = doc_from_fm(&[("tag", content)]);
1057 assert!(validate_typed_document(&config, &doc).is_ok());
1058 }
1059}