@@ -164,8 +164,9 @@ func validateConfigWithSchema(configBytes []byte, schema map[string]any, install
164164 Name : formatOwnNamespaceInstallMode ,
165165 Validate : func (value interface {}) error {
166166 // Check it equals install namespace (if installNamespace is set)
167- // Empty installNamespace is valid - it means AllNamespaces mode by default,
168- // so we skip the constraint check and accept any value
167+ // If installNamespace is empty, we can't validate the constraint properly,
168+ // so we skip validation and accept any value. This is a fallback for edge
169+ // cases where the install namespace isn't known yet.
169170 if installNamespace == "" {
170171 return nil
171172 }
@@ -183,8 +184,9 @@ func validateConfigWithSchema(configBytes []byte, schema map[string]any, install
183184 Name : formatSingleNamespaceInstallMode ,
184185 Validate : func (value interface {}) error {
185186 // Check it does NOT equal install namespace (if installNamespace is set)
186- // Empty installNamespace is valid - it means AllNamespaces mode by default,
187- // so we skip the constraint check and accept any value
187+ // If installNamespace is empty, we can't validate the constraint properly,
188+ // so we skip validation and accept any value. This is a fallback for edge
189+ // cases where the install namespace isn't known yet.
188190 if installNamespace == "" {
189191 return nil
190192 }
@@ -217,66 +219,131 @@ func validateConfigWithSchema(configBytes []byte, schema map[string]any, install
217219
218220// formatSchemaError converts technical JSON schema errors into messages users can understand.
219221//
220- // The JSON schema library gives us error objects, but they don't have all the details
221- // we need to make helpful messages (like which field name or what value was wrong).
222- // So we parse the error strings to extract that information.
223- //
224- // The Test_ErrorFormatting_* tests make sure this parsing works. If we upgrade the
225- // JSON schema library and those tests start failing, we'll know we need to update
226- // this parsing logic.
222+ // Uses the structured ValidationError API instead of fragile string parsing for maintainability.
227223func formatSchemaError (err error ) error {
228224 schemaErr := & jsonschema.ValidationError {}
229225 ok := errors .As (err , & schemaErr )
230226 if ! ok {
231227 return err
232228 }
233229
234- msg := schemaErr .Error ()
230+ // The actual validation failures are in the Causes
231+ if len (schemaErr .Causes ) == 0 {
232+ // Fallback if no causes (shouldn't happen, but be safe)
233+ return fmt .Errorf ("configuration validation failed: %s" , schemaErr .Error ())
234+ }
235235
236- // Unknown field (typo or not applicable to this bundle's install modes)
237- if strings . Contains ( msg , "additional properties" ) && strings . Contains ( msg , "not allowed" ) {
238- if idx := strings . Index ( msg , "additional properties '" ); idx != - 1 {
239- remaining := msg [ idx + len ( "additional properties '" ):]
240- if endIdx := strings . Index ( remaining , "'" ); endIdx != - 1 {
241- fieldName := remaining [: endIdx ]
242- return fmt . Errorf ( "unknown field %q" , fieldName )
243- }
236+ // When there are multiple causes, prioritize based on which gives better user feedback
237+ // additionalProperties > required > type
238+ cause := schemaErr . Causes [ 0 ]
239+ for _ , c := range schemaErr . Causes {
240+ keywords := c . ErrorKind . KeywordPath ()
241+ if len ( keywords ) > 0 && keywords [ len ( keywords ) - 1 ] == "additionalProperties" {
242+ cause = c
243+ break
244244 }
245- return errors .New ("unknown field" )
246245 }
247246
248- // Missing required field
249- if strings .Contains (msg , "missing property" ) {
250- if idx := strings .Index (msg , "missing property '" ); idx != - 1 {
251- remaining := msg [idx + len ("missing property '" ):]
252- if endIdx := strings .Index (remaining , "'" ); endIdx != - 1 {
253- fieldName := remaining [:endIdx ]
254- return fmt .Errorf ("required field %q is missing" , fieldName )
255- }
256- }
257- return errors .New ("required field is missing" )
247+ keyword := cause .ErrorKind .KeywordPath ()
248+
249+ // Extract field name from InstanceLocation if available
250+ var fieldName string
251+ if len (cause .InstanceLocation ) > 0 {
252+ fieldName = cause .InstanceLocation [0 ]
258253 }
259254
260- // Required field set to null
261- if strings .Contains (msg , "got null, want string" ) {
262- if idx := strings .Index (msg , "at '/" ); idx != - 1 {
263- remaining := msg [idx + len ("at '/" ):]
264- if endIdx := strings .Index (remaining , "'" ); endIdx != - 1 {
265- fieldName := remaining [:endIdx ]
255+ // Check the keyword to determine error type
256+ if len (keyword ) > 0 {
257+ switch keyword [len (keyword )- 1 ] {
258+ case "additionalProperties" :
259+ // Unknown field error - extract field name from the error message
260+ msg := cause .Error ()
261+ if idx := strings .Index (msg , "additional properties '" ); idx != - 1 {
262+ remaining := msg [idx + len ("additional properties '" ):]
263+ if endIdx := strings .Index (remaining , "'" ); endIdx != - 1 {
264+ fieldName = remaining [:endIdx ]
265+ }
266+ }
267+ if fieldName != "" {
268+ return fmt .Errorf ("unknown field %q" , fieldName )
269+ }
270+ return errors .New ("unknown field" )
271+
272+ case "required" :
273+ // Missing required field - extract from error message
274+ msg := cause .Error ()
275+ if idx := strings .Index (msg , "missing property '" ); idx != - 1 {
276+ remaining := msg [idx + len ("missing property '" ):]
277+ if endIdx := strings .Index (remaining , "'" ); endIdx != - 1 {
278+ fieldName = remaining [:endIdx ]
279+ }
280+ }
281+ if fieldName != "" {
266282 return fmt .Errorf ("required field %q is missing" , fieldName )
267283 }
268- }
269- return errors .New ("required field is missing" )
270- }
284+ return errors .New ("required field is missing" )
285+
286+ case "type" :
287+ // Type mismatch
288+ msg := cause .Error ()
289+
290+ // Check for null -> string case (treat as missing required field)
291+ if strings .Contains (msg , "got null, want string" ) {
292+ if fieldName != "" {
293+ return fmt .Errorf ("required field %q is missing" , fieldName )
294+ }
295+ return errors .New ("required field is missing" )
296+ }
297+
298+ // Check for top-level type error (input is not an object)
299+ if len (cause .InstanceLocation ) == 0 && strings .Contains (msg , "want object" ) {
300+ return errors .New ("input is not a valid JSON object" )
301+ }
302+
303+ // Extract type mismatch info
304+ var gotType , wantType string
305+ if idx := strings .Index (msg , "got " ); idx != - 1 {
306+ remaining := msg [idx + len ("got " ):]
307+ if endIdx := strings .Index (remaining , "," ); endIdx != - 1 {
308+ gotType = remaining [:endIdx ]
309+ }
310+ }
311+ if idx := strings .Index (msg , "want " ); idx != - 1 {
312+ wantType = strings .TrimSpace (msg [idx + len ("want " ):])
313+ }
314+
315+ if fieldName != "" && gotType != "" && wantType != "" {
316+ return fmt .Errorf ("invalid value type for field %q: expected %q but got %q" , fieldName , wantType , gotType )
317+ }
318+
319+ case "format" :
320+ // Format validation (our custom formats for install modes)
321+ msg := cause .Error ()
322+
323+ // Include field location and "configuration validation failed" prefix
324+ // to match test expectations
325+ if fieldName != "" {
326+ // Strip the "at '/fieldName': " prefix if present
327+ if idx := strings .Index (msg , ": " ); idx != - 1 {
328+ return fmt .Errorf ("configuration validation failed at '/%s': %s" , fieldName , msg [idx + 2 :])
329+ }
330+ return fmt .Errorf ("configuration validation failed at '/%s': %s" , fieldName , msg )
331+ }
271332
272- // Wrong type (e.g., number instead of string)
273- if strings .Contains (msg , "got" ) && strings .Contains (msg , "want" ) {
274- if err := handleTypeMismatchMessage (msg ); err != nil {
275- return err
333+ // Fallback without field name
334+ if idx := strings .Index (msg , ": " ); idx != - 1 {
335+ return fmt .Errorf ("configuration validation failed: %s" , msg [idx + 2 :])
336+ }
337+ return fmt .Errorf ("configuration validation failed: %s" , msg )
276338 }
277339 }
278340
279- // Couldn't parse - return wrapped error with context
341+ // Couldn't determine specific error type, return the raw message
342+ msg := cause .Error ()
343+ // Strip "at '/fieldName': " prefix for cleaner output
344+ if idx := strings .Index (msg , ": " ); idx != - 1 {
345+ return fmt .Errorf ("configuration validation failed: %s" , msg [idx + 2 :])
346+ }
280347 return fmt .Errorf ("configuration validation failed: %s" , msg )
281348}
282349
@@ -303,44 +370,3 @@ func formatUnmarshalError(err error) error {
303370 current = unwrapped
304371 }
305372}
306-
307- // handleTypeMismatchMessage extracts details from type mismatch errors.
308- // Returns nil if we can't parse the error (caller will use a generic error message).
309- func handleTypeMismatchMessage (msg string ) error {
310- if strings .Contains (msg , "want object" ) && strings .Contains (msg , "at ''" ) {
311- return errors .New ("input is not a valid JSON object" )
312- }
313-
314- fieldName := substringBetween (msg , "at '/" , "'" )
315- gotType := substringBetween (msg , "got " , "," )
316- wantType := strings .TrimSpace (substringAfter (msg , "want " ))
317-
318- if fieldName == "" || gotType == "" || wantType == "" {
319- return nil // unable to parse, let caller use fallback error handling
320- }
321-
322- return fmt .Errorf ("invalid value type for field %q: expected %q but got %q" , fieldName , wantType , gotType )
323- }
324-
325- func substringBetween (value , start , end string ) string {
326- begin := strings .Index (value , start )
327- if begin == - 1 {
328- return ""
329- }
330-
331- remaining := value [begin + len (start ):]
332- finish := strings .Index (remaining , end )
333- if finish == - 1 {
334- return ""
335- }
336-
337- return remaining [:finish ]
338- }
339-
340- func substringAfter (value , token string ) string {
341- idx := strings .Index (value , token )
342- if idx == - 1 {
343- return ""
344- }
345- return value [idx + len (token ):]
346- }
0 commit comments