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