@@ -326,3 +326,154 @@ object:
326326 // Verify the error has proper source_range pointing to same file
327327 assert_eq ! ( json[ "source_range" ] [ "filename" ] , "user.yaml" ) ;
328328}
329+
330+ #[ test]
331+ fn test_custom_error_message_overrides_pattern_hint ( ) {
332+ // A string schema with a non-obvious pattern plus an authored errorMessage.
333+ let custom_hint = r#"Must be "naive" or a standard time zone in the form Area/Location"# ;
334+ let schema_yaml = quarto_yaml:: parse ( & format ! (
335+ r#"
336+ string:
337+ pattern: "^(naive|UTC)$"
338+ errorMessage: '{custom_hint}'
339+ "#
340+ ) )
341+ . unwrap ( ) ;
342+ let schema = Schema :: from_yaml ( & schema_yaml) . unwrap ( ) ;
343+
344+ let doc_content = r#"PST"# ;
345+ let doc = quarto_yaml:: parse_file ( doc_content, "tz.yaml" ) . unwrap ( ) ;
346+ let source_ctx = create_test_context ( "tz.yaml" , doc_content) ;
347+ let registry = quarto_yaml_validation:: SchemaRegistry :: new ( ) ;
348+
349+ let result = validate ( & doc, & schema, & registry, & source_ctx) ;
350+ assert ! ( result. is_err( ) , "Validation should fail for pattern mismatch" ) ;
351+
352+ let error = result. unwrap_err ( ) ;
353+ assert_eq ! (
354+ error. custom_hint. as_deref( ) ,
355+ Some ( custom_hint) ,
356+ "custom_hint should be populated from the schema's errorMessage"
357+ ) ;
358+
359+ let diagnostic = ValidationDiagnostic :: from_validation_error ( & error, & source_ctx) ;
360+
361+ // The authored message replaces the generic pattern hint.
362+ let hints = diagnostic. hints ( ) ;
363+ assert_eq ! (
364+ hints,
365+ vec![ custom_hint. to_string( ) ] ,
366+ "the authored errorMessage should be the only hint"
367+ ) ;
368+ assert ! (
369+ !hints. iter( ) . any( |h| h. contains( "matches the expected format" ) ) ,
370+ "the generic pattern hint must not appear"
371+ ) ;
372+
373+ // The factual primary message is left intact.
374+ assert ! (
375+ diagnostic. message( ) . contains( "does not match pattern" ) ,
376+ "primary message should still report the factual failure, got: {}" ,
377+ diagnostic. message( )
378+ ) ;
379+
380+ // JSON output carries the authored hint too.
381+ let json = diagnostic. to_json ( ) ;
382+ assert_eq ! ( json[ "hints" ] [ 0 ] , custom_hint) ;
383+
384+ // Text output includes the authored message.
385+ let text = diagnostic. to_text ( & source_ctx) ;
386+ assert ! (
387+ text. contains( custom_hint) ,
388+ "text output should include the authored errorMessage, got:\n {}" ,
389+ text
390+ ) ;
391+ }
392+
393+ #[ test]
394+ fn test_custom_error_message_applies_to_any_failure_at_node ( ) {
395+ // The override should apply to whatever failure occurs at the annotated
396+ // node, not just pattern mismatches. Here a type mismatch (number, not
397+ // string) trips the same authored message.
398+ let schema_yaml = quarto_yaml:: parse (
399+ r#"
400+ string:
401+ pattern: "^[a-z]+$"
402+ errorMessage: 'must be a lowercase identifier'
403+ "# ,
404+ )
405+ . unwrap ( ) ;
406+ let schema = Schema :: from_yaml ( & schema_yaml) . unwrap ( ) ;
407+
408+ let doc_content = r#"42"# ;
409+ let doc = quarto_yaml:: parse_file ( doc_content, "id.yaml" ) . unwrap ( ) ;
410+ let source_ctx = create_test_context ( "id.yaml" , doc_content) ;
411+ let registry = quarto_yaml_validation:: SchemaRegistry :: new ( ) ;
412+
413+ let result = validate ( & doc, & schema, & registry, & source_ctx) ;
414+ assert ! ( result. is_err( ) ) ;
415+
416+ let error = result. unwrap_err ( ) ;
417+ assert_eq ! ( error. custom_hint. as_deref( ) , Some ( "must be a lowercase identifier" ) ) ;
418+
419+ let diagnostic = ValidationDiagnostic :: from_validation_error ( & error, & source_ctx) ;
420+ assert_eq ! (
421+ diagnostic. hints( ) ,
422+ vec![ "must be a lowercase identifier" . to_string( ) ]
423+ ) ;
424+ }
425+
426+ #[ test]
427+ fn test_no_custom_error_message_uses_generic_hint ( ) {
428+ // Without errorMessage, the generic hint is still produced.
429+ let schema_yaml = quarto_yaml:: parse (
430+ r#"
431+ string:
432+ pattern: "^[0-9]+$"
433+ "# ,
434+ )
435+ . unwrap ( ) ;
436+ let schema = Schema :: from_yaml ( & schema_yaml) . unwrap ( ) ;
437+
438+ let doc_content = r#"abc"# ;
439+ let doc = quarto_yaml:: parse_file ( doc_content, "p.yaml" ) . unwrap ( ) ;
440+ let source_ctx = create_test_context ( "p.yaml" , doc_content) ;
441+ let registry = quarto_yaml_validation:: SchemaRegistry :: new ( ) ;
442+
443+ let error = validate ( & doc, & schema, & registry, & source_ctx) . unwrap_err ( ) ;
444+ assert_eq ! ( error. custom_hint, None ) ;
445+
446+ let diagnostic = ValidationDiagnostic :: from_validation_error ( & error, & source_ctx) ;
447+ assert_eq ! (
448+ diagnostic. hints( ) ,
449+ vec![ "Check that the string matches the expected format?" . to_string( ) ]
450+ ) ;
451+ }
452+
453+ #[ test]
454+ fn test_custom_error_message_innermost_node_wins ( ) {
455+ // An object property's own errorMessage should win over the outer
456+ // object's — the override binds to the schema node where the failure
457+ // occurs.
458+ let schema_yaml = quarto_yaml:: parse (
459+ r#"
460+ object:
461+ errorMessage: 'outer object message'
462+ properties:
463+ tz:
464+ string:
465+ pattern: "^UTC$"
466+ errorMessage: 'inner tz message'
467+ "# ,
468+ )
469+ . unwrap ( ) ;
470+ let schema = Schema :: from_yaml ( & schema_yaml) . unwrap ( ) ;
471+
472+ let doc_content = "tz: PST" ;
473+ let doc = quarto_yaml:: parse_file ( doc_content, "doc.yaml" ) . unwrap ( ) ;
474+ let source_ctx = create_test_context ( "doc.yaml" , doc_content) ;
475+ let registry = quarto_yaml_validation:: SchemaRegistry :: new ( ) ;
476+
477+ let error = validate ( & doc, & schema, & registry, & source_ctx) . unwrap_err ( ) ;
478+ assert_eq ! ( error. custom_hint. as_deref( ) , Some ( "inner tz message" ) ) ;
479+ }
0 commit comments