@@ -202,6 +202,77 @@ impl ValidationDiagnostic {
202202 self . diagnostic . to_text ( Some ( source_ctx) )
203203 }
204204
205+ /// Render as a single compact line, optimized for token-efficient
206+ /// consumption (e.g. feeding validation errors to an LLM).
207+ ///
208+ /// Format: `file:line:col [CODE] path: message (hint: ...)`
209+ ///
210+ /// The location prefix is omitted when no source range is available, and
211+ /// the path renders as `(root)` for top-level errors. Unlike [`to_text`],
212+ /// this drops the ariadne box drawing, source snippet, and the redundant
213+ /// schema-constraint line; hints are kept because they materially help a
214+ /// model propose a correct fix. Multiple hints are joined with `; `.
215+ ///
216+ /// [`to_text`]: Self::to_text
217+ ///
218+ /// # Example
219+ ///
220+ /// ```ignore
221+ /// for d in diagnostics {
222+ /// println!("{}", d.to_compact());
223+ /// }
224+ /// ```
225+ pub fn to_compact ( & self ) -> String {
226+ let mut out = String :: new ( ) ;
227+
228+ if let Some ( range) = & self . source_range {
229+ out. push_str ( & format ! (
230+ "{}:{}:{} " ,
231+ range. filename, range. start_line, range. start_column
232+ ) ) ;
233+ }
234+
235+ out. push_str ( & format ! ( "[{}] " , self . code) ) ;
236+
237+ let path = Self :: instance_path_string ( & self . instance_path ) ;
238+ if path. is_empty ( ) {
239+ out. push_str ( "(root): " ) ;
240+ } else {
241+ out. push_str ( & path) ;
242+ out. push_str ( ": " ) ;
243+ }
244+
245+ out. push_str ( & self . message ( ) ) ;
246+
247+ let hints = self . hints ( ) ;
248+ if !hints. is_empty ( ) {
249+ out. push_str ( & format ! ( " (Hint: {})" , hints. join( "; " ) ) ) ;
250+ }
251+
252+ out
253+ }
254+
255+ /// Render an instance path as a compact dotted/indexed string.
256+ ///
257+ /// e.g. `[Key("authors"), Index(0), Key("name")]` -> `authors[0].name`.
258+ fn instance_path_string ( segments : & [ PathSegment ] ) -> String {
259+ let mut out = String :: new ( ) ;
260+ for seg in segments {
261+ match seg {
262+ PathSegment :: Key ( k) => {
263+ if !out. is_empty ( ) {
264+ out. push ( '.' ) ;
265+ }
266+ out. push_str ( k) ;
267+ }
268+ PathSegment :: Index ( i) => {
269+ out. push_str ( & format ! ( "[{}]" , i) ) ;
270+ }
271+ }
272+ }
273+ out
274+ }
275+
205276 /// Helper: Build DiagnosticMessage for text rendering
206277 fn build_diagnostic_message (
207278 error : & ValidationError ,
@@ -407,6 +478,159 @@ mod tests {
407478 assert_eq ! ( error. error_code( ) , "Q-1-18" ) ;
408479 }
409480
481+ #[ test]
482+ fn test_instance_path_string ( ) {
483+ assert_eq ! ( ValidationDiagnostic :: instance_path_string( & [ ] ) , "" ) ;
484+ assert_eq ! (
485+ ValidationDiagnostic :: instance_path_string( & [ PathSegment :: Key ( "format" . to_string( ) ) ] ) ,
486+ "format"
487+ ) ;
488+ assert_eq ! (
489+ ValidationDiagnostic :: instance_path_string( & [
490+ PathSegment :: Key ( "authors" . to_string( ) ) ,
491+ PathSegment :: Index ( 0 ) ,
492+ PathSegment :: Key ( "name" . to_string( ) ) ,
493+ ] ) ,
494+ "authors[0].name"
495+ ) ;
496+ }
497+
498+ /// Strip ANSI SGR color codes and OSC-8 hyperlink sequences so the human
499+ /// (ariadne) rendering can be snapshotted in a clean, machine-independent
500+ /// form. The OSC-8 hyperlink embeds an absolute `file://` path, which is
501+ /// not portable across machines; stripping it leaves the visible
502+ /// `filename:line:col` text untouched.
503+ fn strip_ansi ( s : & str ) -> String {
504+ let mut out = String :: new ( ) ;
505+ let mut chars = s. chars ( ) . peekable ( ) ;
506+ while let Some ( c) = chars. next ( ) {
507+ if c != '\u{1b}' {
508+ out. push ( c) ;
509+ continue ;
510+ }
511+ match chars. peek ( ) {
512+ // CSI (e.g. color): ESC [ ... <final byte in @..~>
513+ Some ( '[' ) => {
514+ chars. next ( ) ;
515+ while let Some ( & nc) = chars. peek ( ) {
516+ chars. next ( ) ;
517+ if ( '@' ..='~' ) . contains ( & nc) {
518+ break ;
519+ }
520+ }
521+ }
522+ // OSC (e.g. hyperlink): ESC ] ... <BEL or ST (ESC \)>
523+ Some ( ']' ) => {
524+ chars. next ( ) ;
525+ while let Some ( nc) = chars. next ( ) {
526+ if nc == '\u{07}' {
527+ break ;
528+ }
529+ if nc == '\u{1b}' {
530+ if let Some ( '\\' ) = chars. peek ( ) {
531+ chars. next ( ) ;
532+ }
533+ break ;
534+ }
535+ }
536+ }
537+ _ => { }
538+ }
539+ }
540+ out
541+ }
542+
543+ fn test_source_context ( filename : & str , content : & str ) -> SourceContext {
544+ use std:: collections:: hash_map:: DefaultHasher ;
545+ use std:: hash:: { Hash , Hasher } ;
546+
547+ let mut ctx = SourceContext :: new ( ) ;
548+ let mut hasher = DefaultHasher :: new ( ) ;
549+ filename. hash ( & mut hasher) ;
550+ let file_id = quarto_source_map:: FileId ( hasher. finish ( ) as usize ) ;
551+ ctx. add_file_with_id ( file_id, filename. to_string ( ) , Some ( content. to_string ( ) ) ) ;
552+ ctx
553+ }
554+
555+ /// Snapshot the three rendering variants (compact, JSON, human) for a
556+ /// single validation error, so changes to any format's wording/structure
557+ /// are caught in one place. The error is produced end-to-end through
558+ /// `validate()` so the source range is real.
559+ #[ test]
560+ fn test_all_three_formats_snapshot ( ) {
561+ use crate :: { Schema , SchemaRegistry , validate} ;
562+
563+ let schema_yaml = quarto_yaml:: parse (
564+ r#"
565+ object:
566+ properties:
567+ age:
568+ number:
569+ minimum: 0
570+ maximum: 100
571+ "# ,
572+ )
573+ . unwrap ( ) ;
574+ let schema = Schema :: from_yaml ( & schema_yaml) . unwrap ( ) ;
575+
576+ let doc_content = r#"age: "not a number""# ;
577+ let doc = quarto_yaml:: parse_file ( doc_content, "test.yaml" ) . unwrap ( ) ;
578+ let source_ctx = test_source_context ( "test.yaml" , doc_content) ;
579+
580+ let registry = SchemaRegistry :: new ( ) ;
581+ let error = validate ( & doc, & schema, & registry, & source_ctx)
582+ . expect_err ( "validation should fail for type mismatch" ) ;
583+ let diagnostic = ValidationDiagnostic :: from_validation_error ( & error, & source_ctx) ;
584+
585+ let combined = format ! (
586+ "=== compact ===\n {}\n \n === json ===\n {}\n \n === human (ANSI stripped) ===\n {}" ,
587+ diagnostic. to_compact( ) ,
588+ serde_json:: to_string_pretty( & diagnostic. to_json( ) ) . unwrap( ) ,
589+ strip_ansi( & diagnostic. to_text( & source_ctx) ) ,
590+ ) ;
591+
592+ insta:: assert_snapshot!( combined) ;
593+ }
594+
595+ #[ test]
596+ fn test_to_compact ( ) {
597+ use crate :: error:: ValidationErrorKind ;
598+
599+ let source_ctx = SourceContext :: new ( ) ;
600+
601+ // Error at a nested path: location omitted (no yaml_node), code + path + message + hint.
602+ let mut path = InstancePath :: new ( ) ;
603+ path. push_key ( "format" ) ;
604+ path. push_key ( "html" ) ;
605+ let error = ValidationError :: new (
606+ ValidationErrorKind :: TypeMismatch {
607+ expected : "boolean" . to_string ( ) ,
608+ got : "string" . to_string ( ) ,
609+ } ,
610+ path,
611+ ) ;
612+ let vd = ValidationDiagnostic :: from_validation_error ( & error, & source_ctx) ;
613+ let compact = vd. to_compact ( ) ;
614+ assert_eq ! (
615+ compact,
616+ "[Q-1-11] format.html: Expected boolean, got string (Hint: Use `true` or `false` (YAML 1.2 standard)?)"
617+ ) ;
618+
619+ // Root-level error renders `(root)` rather than an empty path.
620+ let error = ValidationError :: new (
621+ ValidationErrorKind :: MissingRequiredProperty {
622+ property : "version" . to_string ( ) ,
623+ allowed : None ,
624+ expected_type : None ,
625+ } ,
626+ InstancePath :: new ( ) ,
627+ ) ;
628+ let vd = ValidationDiagnostic :: from_validation_error ( & error, & source_ctx) ;
629+ let compact = vd. to_compact ( ) ;
630+ assert ! ( compact. starts_with( "[Q-1-10] (root): Missing required property 'version'" ) ) ;
631+ assert ! ( !compact. contains( '\n' ) , "compact output must be a single line" ) ;
632+ }
633+
410634 #[ test]
411635 fn test_suggest_fixes ( ) {
412636 use crate :: error:: ValidationErrorKind ;
0 commit comments