1use indexmap::IndexMap;
2
3use crate::document::Document;
4use crate::error::{Diagnostic, Severity};
5use crate::path::DocPath;
6use crate::quill::formats::{is_valid_date, is_valid_datetime};
7use crate::quill::{CardSchema, FieldSchema, FieldType, QuillConfig};
8use crate::value::QuillValue;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum ValidationError {
24 TypeMismatch {
25 path: String,
26 expected: String,
28 actual: String,
31 source_token: String,
34 default: Option<String>,
37 },
38
39 EnumViolation {
40 path: String,
41 value: String,
42 allowed: Vec<String>,
43 },
44
45 FormatViolation {
46 path: String,
47 format: String,
48 },
49
50 UnknownCard {
51 path: String,
52 card: String,
53 },
54
55 BodyDisabled {
56 path: String,
57 card: String,
58 },
59
60 NotInline {
65 path: String,
66 },
67
68 NotPlain {
73 path: String,
74 },
75}
76
77impl std::error::Error for ValidationError {}
78
79impl std::fmt::Display for ValidationError {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 ValidationError::TypeMismatch {
83 path,
84 expected,
85 actual,
86 source_token,
87 default,
88 } => {
89 write!(
91 f,
92 "Field `{path}` got {actual} `{source_token}`, schema declares `{expected}`"
93 )?;
94 if let Some(d) = default {
95 write!(f, " with default `{d}`")?;
96 }
97 write!(
98 f,
99 ". {hint}",
100 hint = type_mismatch_hint(expected, actual, default.as_deref())
101 )
102 }
103 ValidationError::EnumViolation {
104 path,
105 value,
106 allowed,
107 } => {
108 write!(
109 f,
110 "field `{path}` value `{value}` not in allowed set {allowed:?}"
111 )
112 }
113 ValidationError::FormatViolation { path, format } => {
114 write!(
115 f,
116 "field `{path}` does not match expected format `{format}`"
117 )
118 }
119 ValidationError::UnknownCard { path, card } => {
120 write!(f, "unknown card kind `{card}` at `{path}`")
121 }
122 ValidationError::BodyDisabled { path, card } => {
123 write!(
124 f,
125 "card `{card}` at `{path}` has body content but the card kind declares `body.enabled: false`: {hint}",
126 hint = body_disabled_hint(),
127 )
128 }
129 ValidationError::NotInline { path } => {
130 write!(
131 f,
132 "field `{path}` is `richtext(inline)` but its content is not a single \
133 paragraph: {hint}",
134 hint = not_inline_hint(),
135 )
136 }
137 ValidationError::NotPlain { path } => {
138 write!(
139 f,
140 "field `{path}` is `plaintext` but its content carries formatting: {hint}",
141 hint = not_plain_hint(),
142 )
143 }
144 }
145 }
146}
147
148fn type_mismatch_hint(expected: &str, actual: &str, default: Option<&str>) -> String {
152 if default.is_some() {
153 format!(
154 "Either omit the line (the default will fill in) or provide a value of type `{expected}`."
155 )
156 } else {
157 format!(
158 "Either provide a value of type `{expected}` or change the schema's `type:` to `{actual}`."
159 )
160 }
161}
162
163fn body_disabled_hint() -> &'static str {
166 "remove the body content or set `body.enabled: true` on the card kind"
167}
168
169fn not_inline_hint() -> &'static str {
171 "keep the value to a single paragraph (no blank lines, headings, lists, \
172 quotes, or tables), or change the schema's `type:` to `richtext`"
173}
174
175fn not_plain_hint() -> &'static str {
177 "remove the formatting (marks, tables, images, headings, lists, quotes), or \
178 change the schema's `type:` to `richtext`"
179}
180
181impl ValidationError {
182 pub fn path(&self) -> &str {
186 match self {
187 ValidationError::TypeMismatch { path, .. }
188 | ValidationError::EnumViolation { path, .. }
189 | ValidationError::FormatViolation { path, .. }
190 | ValidationError::UnknownCard { path, .. }
191 | ValidationError::BodyDisabled { path, .. }
192 | ValidationError::NotInline { path, .. }
193 | ValidationError::NotPlain { path, .. } => path,
194 }
195 }
196
197 pub fn code(&self) -> &'static str {
200 match self {
201 ValidationError::TypeMismatch { .. } => "validation::type_mismatch",
202 ValidationError::EnumViolation { .. } => "validation::enum_violation",
203 ValidationError::FormatViolation { .. } => "validation::format_violation",
204 ValidationError::UnknownCard { .. } => "validation::unknown_card",
205 ValidationError::BodyDisabled { .. } => "validation::body_disabled",
206 ValidationError::NotInline { .. } => "richtext::not_inline",
207 ValidationError::NotPlain { .. } => "plaintext::not_plain",
208 }
209 }
210
211 pub fn hint(&self) -> Option<String> {
215 match self {
216 ValidationError::TypeMismatch {
217 expected,
218 actual,
219 default,
220 ..
221 } => Some(type_mismatch_hint(expected, actual, default.as_deref())),
222 ValidationError::BodyDisabled { .. } => Some(body_disabled_hint().to_string()),
223 ValidationError::NotInline { .. } => Some(not_inline_hint().to_string()),
224 ValidationError::NotPlain { .. } => Some(not_plain_hint().to_string()),
225 ValidationError::EnumViolation { .. }
226 | ValidationError::FormatViolation { .. }
227 | ValidationError::UnknownCard { .. } => None,
228 }
229 }
230
231 pub fn to_diagnostic(&self) -> Diagnostic {
235 let mut diag = Diagnostic::new(Severity::Error, self.to_string())
236 .with_code(self.code().to_string())
237 .with_path(self.path().to_string());
238 if let Some(hint) = self.hint() {
239 diag = diag.with_hint(hint);
240 }
241 diag
242 }
243}
244
245fn verbatim_yaml_scalar(value: &serde_json::Value) -> String {
249 match value {
250 serde_json::Value::Null => "null".to_string(),
251 serde_json::Value::Bool(b) => b.to_string(),
252 serde_json::Value::Number(n) => n.to_string(),
253 serde_json::Value::String(s) => format!("\"{s}\""),
254 serde_json::Value::Array(_) => "[…]".to_string(),
255 serde_json::Value::Object(_) => "{…}".to_string(),
256 }
257}
258
259fn yaml_scalar_type(value: &serde_json::Value) -> &'static str {
262 match value {
263 serde_json::Value::Null => "null",
264 serde_json::Value::Bool(_) => "boolean",
265 serde_json::Value::Number(n) => {
266 if n.is_i64() || n.is_u64() {
267 "integer"
268 } else {
269 "number"
270 }
271 }
272 serde_json::Value::String(_) => "string",
273 serde_json::Value::Array(_) => "array",
274 serde_json::Value::Object(_) => "object",
275 }
276}
277
278pub fn validate_typed_document(
282 config: &QuillConfig,
283 doc: &Document,
284) -> Result<(), Vec<ValidationError>> {
285 let main_fields = doc.main().payload().to_index_map();
286 let mut errors = validate_fields_for_card_indexmap(&config.main, &main_fields, &DocPath::main());
287
288 if !config.main.body_enabled() && !doc.main().body().is_blank() {
291 errors.push(ValidationError::BodyDisabled {
292 path: DocPath::main_body().to_string(),
293 card: "main".to_string(),
294 });
295 }
296
297 for (index, card) in doc.cards().iter().enumerate() {
298 let card_name = card.kind().unwrap_or("").to_string();
299
300 let Some(card_schema) = config.card_kind(card_name.as_str()) else {
301 errors.push(ValidationError::UnknownCard {
305 path: DocPath::card(None, index).to_string(),
306 card: card_name,
307 });
308 continue;
309 };
310
311 let card_path = DocPath::card(Some(&card_name), index);
312 let card_fields = card.payload().to_index_map();
313 errors.extend(validate_fields_for_card_indexmap(
314 card_schema,
315 &card_fields,
316 &card_path,
317 ));
318
319 if !card_schema.body_enabled() && !card.body().is_blank() {
320 errors.push(ValidationError::BodyDisabled {
321 path: card_path.body().to_string(),
322 card: card_name,
323 });
324 }
325 }
326
327 if errors.is_empty() {
328 Ok(())
329 } else {
330 Err(errors)
331 }
332}
333
334fn validate_fields_for_card_indexmap(
335 card: &CardSchema,
336 fields: &IndexMap<String, QuillValue>,
337 base: &DocPath,
338) -> Vec<ValidationError> {
339 let mut errors = Vec::new();
340 let mut field_names: Vec<&String> = card.fields.keys().collect();
341 field_names.sort();
342
343 for field_name in field_names {
344 let schema = &card.fields[field_name];
345 let path = base.field(field_name);
346 if let Some(value) = fields.get(field_name) {
350 errors.extend(validate_field(schema, value, &path));
351 }
352 }
353
354 errors
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361enum ValueContext {
362 Document,
365 SchemaLiteral,
369}
370
371fn validate_value(
376 field: &FieldSchema,
377 value: &QuillValue,
378 path: &DocPath,
379 ctx: ValueContext,
380) -> Vec<ValidationError> {
381 if ctx == ValueContext::Document && value.as_json().is_null() {
385 return vec![];
386 }
387
388 let mut errors = Vec::new();
389
390 let type_valid = match field.r#type {
391 FieldType::String | FieldType::Enum => {
398 value.as_str().is_some()
399 || (ctx == ValueContext::Document
400 && super::config::scalar_as_string(value.as_json()).is_some())
401 }
402 FieldType::RichText { .. } | FieldType::PlainText { .. } => {
409 value.as_json().is_object()
410 || value.as_str().is_some()
411 || (ctx == ValueContext::Document
412 && super::config::scalar_as_string(value.as_json()).is_some())
413 }
414 FieldType::Integer => {
415 let json = value.as_json();
416 json.is_i64() || json.is_u64()
417 }
418 FieldType::Number => value.as_json().is_number(),
419 FieldType::Boolean => value.as_bool().is_some(),
420 FieldType::Date | FieldType::DateTime => {
421 if value.as_json().is_null() {
422 true
423 } else {
424 match value.as_str() {
425 Some("") => true,
426 Some(text) => {
427 let (ok, format) = match field.r#type {
428 FieldType::Date => (is_valid_date(text), "date"),
429 _ => (is_valid_datetime(text), "datetime"),
430 };
431 if ok {
432 true
433 } else {
434 errors.push(ValidationError::FormatViolation {
435 path: path.to_string(),
436 format: format.to_string(),
437 });
438 false
439 }
440 }
441 None => false,
442 }
443 }
444 }
445 FieldType::Array => match value.as_array() {
446 Some(items) => {
447 if let Some(item_schema) = &field.items {
452 for (idx, item) in items.iter().enumerate() {
453 let row_path = path.index(idx);
454 errors.extend(validate_value(
455 item_schema,
456 &QuillValue::from_json(item.clone()),
457 &row_path,
458 ctx,
459 ));
460 }
461 }
462 true
463 }
464 None => false,
465 },
466 FieldType::Object => match value.as_object() {
467 Some(object) => {
468 if let Some(properties) = &field.properties {
469 let mut property_names: Vec<&String> = properties.keys().collect();
470 property_names.sort();
471 for property_name in property_names {
472 let property_schema = &properties[property_name];
473 let property_path = path.field(property_name);
474 if let Some(property_value) = object.get(property_name) {
478 errors.extend(validate_value(
479 property_schema,
480 &QuillValue::from_json(property_value.clone()),
481 &property_path,
482 ctx,
483 ));
484 }
485 }
486 }
487 true
488 }
489 None => false,
490 },
491 };
492
493 if type_valid {
501 match field.r#type {
502 FieldType::RichText { inline: true } => {
503 let parsed =
504 crate::document::decode_richtext_value(value.as_json()).and_then(Result::ok);
505 if let Some(rt) = parsed {
506 if !rt.is_inline() {
507 errors.push(ValidationError::NotInline {
508 path: path.to_string(),
509 });
510 }
511 }
512 }
513 FieldType::PlainText { inline } => {
514 if let Some(rt) = crate::document::decode_plaintext_value(value.as_json())
520 .and_then(Result::ok)
521 {
522 if !rt.is_plain() {
523 errors.push(ValidationError::NotPlain {
524 path: path.to_string(),
525 });
526 } else if inline && !rt.is_inline() {
527 errors.push(ValidationError::NotInline {
528 path: path.to_string(),
529 });
530 }
531 }
532 }
533 _ => {}
534 }
535 }
536
537 let format_error_already_reported =
540 matches!(field.r#type, FieldType::Date | FieldType::DateTime) && value.as_str().is_some();
541
542 if !type_valid && !format_error_already_reported {
543 errors.push(ValidationError::TypeMismatch {
544 path: path.to_string(),
545 expected: expected_type_name(&field.r#type).to_string(),
546 actual: yaml_scalar_type(value.as_json()).to_string(),
547 source_token: verbatim_yaml_scalar(value.as_json()),
548 default: match ctx {
552 ValueContext::Document => field
553 .default
554 .as_ref()
555 .map(|d| verbatim_yaml_scalar(d.as_json())),
556 ValueContext::SchemaLiteral => None,
557 },
558 });
559 }
560
561 if type_valid {
562 if let (Some(allowed), Some(actual)) = (&field.enum_values, value.as_str()) {
563 if !allowed.contains(&actual.to_string()) {
564 errors.push(ValidationError::EnumViolation {
565 path: path.to_string(),
566 value: actual.to_string(),
567 allowed: allowed.clone(),
568 });
569 }
570 }
571 }
572
573 errors
574}
575
576pub(crate) fn validate_field(
579 field: &FieldSchema,
580 value: &QuillValue,
581 path: &DocPath,
582) -> Vec<ValidationError> {
583 validate_value(field, value, path, ValueContext::Document)
584}
585
586pub(crate) fn validate_schema_literal(
594 schema: &FieldSchema,
595 value: &QuillValue,
596 path: &DocPath,
597) -> Vec<ValidationError> {
598 validate_value(schema, value, path, ValueContext::SchemaLiteral)
599}
600
601fn expected_type_name(field_type: &FieldType) -> &'static str {
602 match field_type {
603 FieldType::String | FieldType::Date | FieldType::DateTime => "string",
604 FieldType::Enum => "string",
606 FieldType::RichText { .. } => "richtext",
607 FieldType::PlainText { .. } => "plaintext",
608 FieldType::Integer => "integer",
609 FieldType::Number => "number",
610 FieldType::Boolean => "boolean",
611 FieldType::Array => "array",
612 FieldType::Object => "object",
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619 use crate::document::{Card, Document};
620 use serde_json::json;
621
622 fn config_with(main_fields: &str, cards: &str) -> QuillConfig {
623 let yaml = format!(
624 r#"
625quill:
626 name: native_validation
627 backend: typst
628 description: Native validator tests
629 version: 1.0.0
630main:
631 fields:
632{main_fields}
633{cards}
634"#
635 );
636 let (config, warnings) = QuillConfig::from_yaml_with_warnings(&yaml).unwrap();
637 assert!(
638 warnings.is_empty(),
639 "config_with produced warnings (test schema is unsupported): {:?}",
640 warnings
641 );
642 config
643 }
644
645 fn doc_from_fm(entries: &[(&str, serde_json::Value)]) -> Document {
646 doc_with_typed_cards(entries, vec![])
647 }
648
649 fn doc_with_typed_cards(fm: &[(&str, serde_json::Value)], cards: Vec<Card>) -> Document {
650 use crate::document::Payload;
651 let mut payload = IndexMap::new();
652 for (k, v) in fm {
653 payload.insert(k.to_string(), QuillValue::from_json(v.clone()));
654 }
655 let mut p = Payload::from_index_map(payload);
656 p.set_quill("test_quill".parse().unwrap());
657 p.set_kind("main");
658 let main = Card::from_parts(p, quillmark_content::Content::empty());
659 Document::from_main_and_cards(main, cards)
660 }
661
662 fn typed_card(tag: &str, fields: &[(&str, serde_json::Value)]) -> Card {
663 let mut card = Card::new(tag).unwrap();
664 for (k, v) in fields {
665 card.store_field(k, QuillValue::from_json(v.clone())).unwrap();
666 }
667 card
668 }
669
670 fn has_error<F>(errors: &[ValidationError], predicate: F) -> bool
671 where
672 F: Fn(&ValidationError) -> bool,
673 {
674 errors.iter().any(predicate)
675 }
676
677 #[test]
678 fn validates_simple_string_field() {
679 let config = config_with(" title:\n type: string", "");
680 let doc = doc_from_fm(&[("title", json!("Memo"))]);
681 assert!(validate_typed_document(&config, &doc).is_ok());
682 }
683
684 #[test]
685 fn rejects_simple_string_type_mismatch() {
686 let config = config_with(" title:\n type: string\n default: \"\"", "");
689 let doc = doc_from_fm(&[("title", json!([1, 2, 3]))]);
690 let errors = validate_typed_document(&config, &doc).unwrap_err();
691 assert!(has_error(&errors, |e| matches!(
692 e,
693 ValidationError::TypeMismatch { path, expected, actual, source_token, .. }
694 if path == "main.title" && expected == "string" && actual == "array" && source_token == "[…]"
695 )));
696 }
697
698 #[test]
699 fn validates_integer_field_with_integer_value() {
700 let config = config_with(" count:\n type: integer\n default: 0", "");
701 let doc = doc_from_fm(&[("count", json!(9))]);
702 assert!(validate_typed_document(&config, &doc).is_ok());
703 }
704
705 #[test]
706 fn rejects_integer_field_with_decimal_value() {
707 let config = config_with(" count:\n type: integer\n default: 0", "");
708 let doc = doc_from_fm(&[("count", json!(9.5))]);
709 let errors = validate_typed_document(&config, &doc).unwrap_err();
710 assert!(has_error(&errors, |e| matches!(
711 e,
712 ValidationError::TypeMismatch { path, expected, actual, source_token, .. }
713 if path == "main.count" && expected == "integer" && actual == "number" && source_token == "9.5"
714 )));
715 }
716
717 #[test]
718 fn absent_unendorsed_field_raises_nothing() {
719 let config = config_with(" memo_for:\n type: string", "");
723 let doc = doc_from_fm(&[]);
724 assert!(validate_typed_document(&config, &doc).is_ok());
725 }
726
727 #[test]
728 fn present_null_is_treated_as_absent() {
729 let config = config_with(
732 " memo_for:\n type: string\n n:\n type: integer",
733 "",
734 );
735 let doc = doc_from_fm(&[("memo_for", json!(null)), ("n", json!(null))]);
736 assert!(
737 validate_typed_document(&config, &doc).is_ok(),
738 "present-null must validate like absence"
739 );
740 }
741
742 #[test]
743 fn missing_field_with_default_is_ok() {
744 let config = config_with(" memo_for:\n type: string\n default: \"\"", "");
746 let doc = doc_from_fm(&[]);
747 assert!(validate_typed_document(&config, &doc).is_ok());
748 }
749
750 #[test]
751 fn absent_object_property_raises_nothing() {
752 let config = config_with(
756 " recipients:\n type: array\n default: []\n items:\n type: object\n properties:\n name:\n type: string\n org:\n type: string\n default: \"\"",
757 "",
758 );
759 let doc = doc_from_fm(&[("recipients", json!([{ "org": "HQ" }]))]);
760 assert!(validate_typed_document(&config, &doc).is_ok());
761 }
762
763 #[test]
769 fn validates_card_with_valid_discriminator() {
770 let config = config_with(
771 " title:\n type: string\n default: \"\"",
772 "card_kinds:\n indorsement:\n fields:\n signature_block:\n type: string",
773 );
774 let doc = doc_with_typed_cards(
775 &[],
776 vec![typed_card(
777 "indorsement",
778 &[("signature_block", json!("Signed"))],
779 )],
780 );
781 assert!(validate_typed_document(&config, &doc).is_ok());
782 }
783
784 #[test]
785 fn rejects_unknown_card_discriminator() {
786 let config = config_with(
787 " title:\n type: string\n default: \"\"",
788 "card_kinds:\n indorsement:\n fields:\n signature_block:\n type: string",
789 );
790 let doc = doc_with_typed_cards(&[], vec![typed_card("unknown", &[])]);
791 let errors = validate_typed_document(&config, &doc).unwrap_err();
792 assert!(has_error(&errors, |e| {
793 matches!(e, ValidationError::UnknownCard { path, card } if path == "cards[0]" && card == "unknown")
794 }));
795 }
796
797 #[test]
798 fn validates_multiple_card_kinds_mixed() {
799 let config = config_with(
800 " title:\n type: string\n default: \"\"",
801 "card_kinds:\n indorsement:\n fields:\n signature_block:\n type: string\n routing:\n fields:\n office:\n type: string",
802 );
803 let doc = doc_with_typed_cards(
804 &[],
805 vec![
806 typed_card("indorsement", &[("signature_block", json!("A"))]),
807 typed_card("routing", &[("office", json!("HQ"))]),
808 ],
809 );
810 assert!(validate_typed_document(&config, &doc).is_ok());
811 }
812
813 #[test]
814 fn reports_card_field_paths_with_card_name_and_index() {
815 let config = config_with(
819 " title:\n type: string\n default: \"\"",
820 "card_kinds:\n indorsement:\n fields:\n signature_block:\n type: string",
821 );
822 let doc = doc_with_typed_cards(
823 &[],
824 vec![typed_card(
825 "indorsement",
826 &[("signature_block", json!([1, 2, 3]))],
827 )],
828 );
829 let errors = validate_typed_document(&config, &doc).unwrap_err();
830 assert!(has_error(&errors, |e| {
831 matches!(e, ValidationError::TypeMismatch { path, .. } if path == "cards.indorsement[0].signature_block")
832 }));
833 }
834
835 #[test]
836 fn body_disabled_card_enforces_trim_boundary() {
837 let config = config_with(
838 " title:\n type: string\n default: \"\"",
839 "card_kinds:\n skills:\n body:\n enabled: false\n fields:\n items:\n type: array\n items:\n type: string\n default: []",
840 );
841 let mut prose_card = typed_card("skills", &[("items", json!(["Rust"]))]);
843 prose_card.revise_body("Should not be here.").unwrap();
844 let doc = doc_with_typed_cards(&[], vec![prose_card]);
845 let errors = validate_typed_document(&config, &doc).unwrap_err();
846 assert!(has_error(&errors, |e| matches!(
847 e,
848 ValidationError::BodyDisabled { path, card }
849 if card == "skills" && path == "cards.skills[0].body"
850 )));
851
852 let mut ws_card = typed_card("skills", &[("items", json!(["Rust"]))]);
853 ws_card.revise_body("\n \n").unwrap();
854 let ok_doc = doc_with_typed_cards(&[], vec![ws_card]);
855 assert!(validate_typed_document(&config, &ok_doc).is_ok());
856 }
857
858 #[test]
859 fn to_diagnostic_carries_path_code_and_hint() {
860 let err = ValidationError::TypeMismatch {
861 path: "cards.indorsement[0].signature_block".to_string(),
862 expected: "string".to_string(),
863 actual: "integer".to_string(),
864 source_token: "42".to_string(),
865 default: None,
866 };
867 let diag = err.to_diagnostic();
868 assert_eq!(diag.code.as_deref(), Some("validation::type_mismatch"));
869 assert_eq!(
870 diag.path.as_deref(),
871 Some("cards.indorsement[0].signature_block")
872 );
873 assert_eq!(diag.severity, Severity::Error);
874 let hint = diag
875 .hint
876 .as_deref()
877 .expect("type_mismatch diagnostic should carry a hint");
878 assert!(
879 hint.contains("string"),
880 "hint missing expected type: {hint}"
881 );
882 }
883
884 #[test]
885 fn type_mismatch_diagnostic_carries_hint_matching_message() {
886 let config = config_with(
891 " build_number:\n type: string\n default: \"\"",
892 "",
893 );
894 let doc = doc_from_fm(&[("build_number", json!([1, 2, 3]))]);
895 let errors = validate_typed_document(&config, &doc).unwrap_err();
896 let err = errors
897 .iter()
898 .find(|e| matches!(e, ValidationError::TypeMismatch { .. }))
899 .expect("expected TypeMismatch");
900 let diag = err.to_diagnostic();
901 let hint = diag
902 .hint
903 .expect("TypeMismatch diagnostic should carry a hint");
904 assert!(
905 err.to_string().ends_with(&hint),
906 "message tail must equal hint; msg={msg}, hint={hint}",
907 msg = err,
908 );
909 assert!(hint.contains("provide a value of type"));
910 }
911
912 #[test]
913 fn body_disabled_diagnostic_carries_hint() {
914 let err = ValidationError::BodyDisabled {
915 path: "cards.skills[0].body".to_string(),
916 card: "skills".to_string(),
917 };
918 let diag = err.to_diagnostic();
919 let hint = diag
920 .hint
921 .expect("BodyDisabled diagnostic should carry a hint");
922 assert!(hint.contains("remove the body content"));
923 }
924
925 #[test]
926 fn bare_scalar_into_string_field_is_valid() {
927 for value in [json!(42), json!(true), json!(1.5)] {
932 let config = config_with(
933 " build_number:\n type: string\n default: \"\"",
934 "",
935 );
936 let doc = doc_from_fm(&[("build_number", value.clone())]);
937 assert!(
938 validate_typed_document(&config, &doc).is_ok(),
939 "bare scalar {value} should validate as a string"
940 );
941 }
942 }
943
944 #[test]
945 fn main_body_disabled_with_body_content_is_an_error() {
946 let config = QuillConfig::from_yaml(
947 r#"
948quill:
949 name: native_validation
950 backend: typst
951 description: Native validator tests
952 version: 1.0.0
953main:
954 body:
955 enabled: false
956 fields:
957 title:
958 type: string
959 default: ""
960"#,
961 )
962 .unwrap();
963 use crate::document::Payload;
964 let mut p = Payload::from_index_map(IndexMap::new());
965 p.set_quill("test_quill".parse().unwrap());
966 p.set_kind("main");
967 let main = Card::from_parts(
968 p,
969 crate::document::import_body("Body content that should not be here.").unwrap(),
970 );
971 let doc = Document::from_main_and_cards(main, vec![]);
972 let errors = validate_typed_document(&config, &doc).unwrap_err();
973 assert!(has_error(&errors, |e| matches!(
974 e,
975 ValidationError::BodyDisabled { path, card }
976 if card == "main" && path == "main.body"
977 )));
978 }
979
980 #[test]
981 fn rejects_richtext_inline_with_multi_block_content() {
982 let config = config_with(" tag:\n type: richtext\n inline: true", "");
985 let rt = quillmark_content::import::from_markdown("one\n\ntwo").unwrap();
986 let content = quillmark_content::serial::to_canonical_value(&rt);
987 let doc = doc_from_fm(&[("tag", content)]);
988 let errors = validate_typed_document(&config, &doc).unwrap_err();
989 assert!(has_error(&errors, |e| matches!(
990 e,
991 ValidationError::NotInline { path } if path == "main.tag"
992 )));
993 }
994
995 #[test]
996 fn accepts_richtext_inline_single_para_content() {
997 let config = config_with(" tag:\n type: richtext\n inline: true", "");
998 let rt = quillmark_content::import::from_markdown("one line only").unwrap();
999 let content = quillmark_content::serial::to_canonical_value(&rt);
1000 let doc = doc_from_fm(&[("tag", content)]);
1001 assert!(validate_typed_document(&config, &doc).is_ok());
1002 }
1003}