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