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