Skip to main content

fraiseql_cli/schema/validator/
schema_validator.rs

1//! `SchemaValidator` — validates an `IntermediateSchema` with detailed error reporting.
2
3use std::collections::HashSet;
4
5use anyhow::Result;
6use tracing::{debug, info};
7
8use super::{
9    sql_identifier::validate_sql_identifier,
10    types::{ErrorSeverity, ValidationError, ValidationReport},
11};
12use crate::schema::intermediate::IntermediateSchema;
13
14/// Strip GraphQL type modifiers (`!`, `[]`) to extract the base type name.
15///
16/// Examples: `"UUID!"` → `"UUID"`, `"[User!]!"` → `"User"`, `"String"` → `"String"`
17pub(crate) fn extract_base_type(type_str: &str) -> &str {
18    let s = type_str.trim();
19    let s = s.trim_start_matches('[').trim_end_matches(']');
20    let s = s.trim_end_matches('!').trim_start_matches('!');
21    let s = s.trim_start_matches('[').trim_end_matches(']');
22    let s = s.trim_end_matches('!');
23    s.trim()
24}
25
26/// Enhanced schema validator
27pub struct SchemaValidator;
28
29impl SchemaValidator {
30    /// Validate an intermediate schema with detailed error reporting
31    ///
32    /// # Errors
33    ///
34    /// Currently infallible; always returns `Ok` containing the report.
35    /// The `Result` return type is reserved for future validation that may
36    /// require fallible I/O.
37    #[allow(clippy::cognitive_complexity)] // Reason: comprehensive schema validation with many cross-field constraint checks
38    pub fn validate(schema: &IntermediateSchema) -> Result<ValidationReport> {
39        info!("Validating schema structure");
40
41        let mut report = ValidationReport::default();
42
43        // Build type registry.
44        //
45        // `enumerate()` rather than `type_names.len()` for the duplicate path: the count of
46        // unique names seen so far is not the offending element's index, and the two diverge
47        // exactly when there are duplicates — the one case this diagnostic exists for. With
48        // `[A, B, A, A]` the count is stuck at 2, so the old path reported `types[2]` twice
49        // and never named element 3 (#724 item 3).
50        let mut type_names = HashSet::new();
51        for (idx, type_def) in schema.types.iter().enumerate() {
52            if type_names.contains(&type_def.name) {
53                report.errors.push(ValidationError {
54                    message:    format!("Duplicate type name: '{}'", type_def.name),
55                    path:       format!("types[{idx}].name"),
56                    severity:   ErrorSeverity::Error,
57                    suggestion: Some("Type names must be unique".to_string()),
58                });
59            }
60            type_names.insert(type_def.name.clone());
61        }
62
63        // Add input types — valid as mutation argument types (fraiseql/fraiseql#190)
64        for input_type in &schema.input_types {
65            type_names.insert(input_type.name.clone());
66        }
67
68        // Add union type names — valid as mutation/query return types
69        for union_def in &schema.unions {
70            type_names.insert(union_def.name.clone());
71        }
72
73        // Add interfaces — valid as field and return types (GraphQL spec §3.7)
74        for interface in &schema.interfaces {
75            type_names.insert(interface.name.clone());
76        }
77
78        // Add enums — valid as field and argument types
79        for enum_def in &schema.enums {
80            type_names.insert(enum_def.name.clone());
81        }
82
83        // The **composite** types, as GraphQL defines them: objects, interfaces and
84        // unions. A mutation's return type must be one of these (#1358); every other
85        // position accepts the wider `type_names`.
86        let composite_type_names: HashSet<String> = schema
87            .types
88            .iter()
89            .map(|t| t.name.clone())
90            .chain(schema.unions.iter().map(|u| u.name.clone()))
91            .chain(schema.interfaces.iter().map(|i| i.name.clone()))
92            .collect();
93
94        // Add built-in scalars, and the engine's rich scalars (#1530): the converter
95        // resolves both without a declaration, so both are known names here.
96        for scalar in crate::schema::builtin_scalar_names() {
97            type_names.insert(scalar.to_string());
98        }
99        for scalar in fraiseql_core::schema::RICH_SCALARS {
100            type_names.insert((*scalar).to_string());
101        }
102
103        // Add **declared** custom scalars.
104        //
105        // This used to register every type name appearing in any object field as an implicit
106        // custom scalar, which made field-type typos invisible: `"type": "Strng"` validated
107        // cleanly, and the typo then also legalized `Strng` as a query return type, so one
108        // mistake propagated instead of being caught (#724 item 2). Only names the author
109        // actually declared are scalars now; an undeclared field type is reported below.
110        for scalar in schema.custom_scalars.iter().flatten() {
111            type_names.insert(scalar.name.clone());
112        }
113
114        // Refuse field types that resolve to nothing declared (#1530).
115        //
116        // #724 made this a warning, for a `--schema-dir` author whose custom scalar might be
117        // declared in a file this validator could not see. Every workflow merges its files
118        // before validating, so it sees them all; and compiled anyway, the name became an
119        // object reference the server answered with no value, under a `200`. A typo'd
120        // scalar lost data silently, one warning among dozens.
121        for (type_idx, type_def) in schema.types.iter().enumerate() {
122            for (field_idx, field) in type_def.fields.iter().enumerate() {
123                let base = extract_base_type(&field.field_type);
124                if base.is_empty() || type_names.contains(base) {
125                    continue;
126                }
127                report.errors.push(ValidationError {
128                    message:    format!(
129                        "Field '{}.{}' has type '{base}', which is not a declared type, enum, \
130                         interface, union, input type, built-in scalar or custom scalar",
131                        type_def.name, field.name
132                    ),
133                    path:       format!("types[{type_idx}].fields[{field_idx}].type"),
134                    severity:   ErrorSeverity::Error,
135                    suggestion: Some(format!(
136                        "{} If '{base}' is a custom scalar, declare it in `custom_scalars`.",
137                        Self::suggest_similar_type(base, &type_names)
138                    )),
139                });
140            }
141        }
142
143        // Validate queries
144        let mut query_names = HashSet::new();
145        for (idx, query) in schema.queries.iter().enumerate() {
146            debug!("Validating query: {}", query.name);
147
148            // Check for duplicate query names
149            if query_names.contains(&query.name) {
150                report.errors.push(ValidationError {
151                    message:    format!("Duplicate query name: '{}'", query.name),
152                    path:       format!("queries[{idx}].name"),
153                    severity:   ErrorSeverity::Error,
154                    suggestion: Some("Query names must be unique".to_string()),
155                });
156            }
157            query_names.insert(query.name.clone());
158
159            // Validate return type exists (strip ! and [] modifiers)
160            let base_return = extract_base_type(&query.return_type);
161            if !type_names.contains(base_return) {
162                report.errors.push(ValidationError {
163                    message:    format!(
164                        "Query '{}' references unknown type '{}'",
165                        query.name, base_return
166                    ),
167                    path:       format!("queries[{idx}].return_type"),
168                    severity:   ErrorSeverity::Error,
169                    suggestion: Some(Self::suggest_similar_type(base_return, &type_names)),
170                });
171            }
172
173            // Validate argument types
174            for (arg_idx, arg) in query.arguments.iter().enumerate() {
175                let base_arg = extract_base_type(&arg.arg_type);
176                if !type_names.contains(base_arg) {
177                    report.errors.push(ValidationError {
178                        message:    format!(
179                            "Query '{}' argument '{}' references unknown type '{}'",
180                            query.name, arg.name, base_arg
181                        ),
182                        path:       format!("queries[{idx}].arguments[{arg_idx}].type"),
183                        severity:   ErrorSeverity::Error,
184                        suggestion: Some(Self::suggest_similar_type(base_arg, &type_names)),
185                    });
186                }
187            }
188
189            // Validate sql_source is a safe SQL identifier
190            if let Some(sql_source) = &query.sql_source {
191                if let Err(e) = validate_sql_identifier(
192                    sql_source,
193                    "sql_source",
194                    &format!("Query.{}", query.name),
195                ) {
196                    report.errors.push(e);
197                }
198            }
199
200            // Warning for queries without SQL source.
201            //
202            // A function-backed query (#1329) has none by construction and is not
203            // missing anything — warning there would train authors to ignore the
204            // warning that still matters for the SQL-backed case.
205            if query.sql_source.is_none() && query.function.is_none() && query.returns_list {
206                report.errors.push(ValidationError {
207                    message:    format!(
208                        "Query '{}' returns a list but has no sql_source",
209                        query.name
210                    ),
211                    path:       format!("queries[{idx}]"),
212                    severity:   ErrorSeverity::Warning,
213                    suggestion: Some("Add sql_source for SQL-backed queries".to_string()),
214                });
215            }
216        }
217
218        // Validate mutations
219        let mut mutation_names = HashSet::new();
220        for (idx, mutation) in schema.mutations.iter().enumerate() {
221            debug!("Validating mutation: {}", mutation.name);
222
223            // Check for duplicate mutation names
224            if mutation_names.contains(&mutation.name) {
225                report.errors.push(ValidationError {
226                    message:    format!("Duplicate mutation name: '{}'", mutation.name),
227                    path:       format!("mutations[{idx}].name"),
228                    severity:   ErrorSeverity::Error,
229                    suggestion: Some("Mutation names must be unique".to_string()),
230                });
231            }
232            mutation_names.insert(mutation.name.clone());
233
234            // Validate return type exists (strip ! and [] modifiers)
235            let base_return = extract_base_type(&mutation.return_type);
236            if type_names.contains(base_return) {
237                // …and that it is **composite** (#1358).
238                //
239                // A mutation's result is projected out of the `app.mutation_response`
240                // envelope, which is entity-shaped by construction, so a leaf return
241                // type has no coherent meaning under it. Declaring one was accepted:
242                // `type_names` registers enums, built-in scalars and declared custom
243                // scalars alongside the composite types, because every *other*
244                // position — field types, argument types — legitimately takes them.
245                //
246                // What the runtime then did with `mutation { setStatus }` is the
247                // reason this is an error rather than a warning. A leaf return type
248                // needs no selection set, so GraphQL § 5.3.3 (#1357) correctly does
249                // not refuse the document, and the empty set reaches the projector —
250                // where an empty set means "no field filtering". The response was the
251                // whole stored entity object, under a field the schema types as an
252                // enum, with the #423 field authorizer never consulted because a leaf
253                // type has no field list to find a gated field in.
254                if !composite_type_names.contains(base_return) {
255                    report.errors.push(ValidationError {
256                        message:    format!(
257                            "Mutation '{}' returns '{}', which is not a composite type. A                              mutation's return type must be an object, interface or union.",
258                            mutation.name, base_return
259                        ),
260                        path:       format!("mutations[{idx}].return_type"),
261                        severity:   ErrorSeverity::Error,
262                        suggestion: Some(
263                            "Return the entity type the mutation writes, or a union of                              success and error variants."
264                                .to_string(),
265                        ),
266                    });
267                }
268            } else {
269                report.errors.push(ValidationError {
270                    message:    format!(
271                        "Mutation '{}' references unknown type '{}'",
272                        mutation.name, base_return
273                    ),
274                    path:       format!("mutations[{idx}].return_type"),
275                    severity:   ErrorSeverity::Error,
276                    suggestion: Some(Self::suggest_similar_type(base_return, &type_names)),
277                });
278            }
279
280            // Validate argument types
281            for (arg_idx, arg) in mutation.arguments.iter().enumerate() {
282                let base_arg = extract_base_type(&arg.arg_type);
283                if !type_names.contains(base_arg) {
284                    report.errors.push(ValidationError {
285                        message:    format!(
286                            "Mutation '{}' argument '{}' references unknown type '{}'",
287                            mutation.name, arg.name, base_arg
288                        ),
289                        path:       format!("mutations[{idx}].arguments[{arg_idx}].type"),
290                        severity:   ErrorSeverity::Error,
291                        suggestion: Some(Self::suggest_similar_type(base_arg, &type_names)),
292                    });
293                }
294            }
295
296            // Validate sql_source is a safe SQL identifier
297            if let Some(sql_source) = &mutation.sql_source {
298                if let Err(e) = validate_sql_identifier(
299                    sql_source,
300                    "sql_source",
301                    &format!("Mutation.{}", mutation.name),
302                ) {
303                    report.errors.push(e);
304                }
305            }
306
307            // Warn about inject_params ordering contract
308            if !mutation.inject.is_empty() {
309                let inject_names: Vec<&str> = mutation.inject.keys().map(String::as_str).collect();
310                let fn_name = mutation.sql_source.as_deref().unwrap_or("<unknown>");
311                report.errors.push(ValidationError {
312                    message:    format!(
313                        "Mutation '{}' has inject params {:?}. \
314                         These are appended as the LAST positional arguments to \
315                         `{fn_name}`. Your SQL function MUST declare injected \
316                         parameters last, after all client-provided arguments.",
317                        mutation.name, inject_names,
318                    ),
319                    path:       format!("Mutation.{}", mutation.name),
320                    severity:   ErrorSeverity::Warning,
321                    suggestion: None,
322                });
323            }
324        }
325
326        // Validate observers
327        if let Some(observers) = &schema.observers {
328            let mut observer_names = HashSet::new();
329            for (idx, observer) in observers.iter().enumerate() {
330                debug!("Validating observer: {}", observer.name);
331
332                // Check for duplicate observer names
333                if observer_names.contains(&observer.name) {
334                    report.errors.push(ValidationError {
335                        message:    format!("Duplicate observer name: '{}'", observer.name),
336                        path:       format!("observers[{idx}].name"),
337                        severity:   ErrorSeverity::Error,
338                        suggestion: Some("Observer names must be unique".to_string()),
339                    });
340                }
341                observer_names.insert(observer.name.clone());
342
343                // Validate entity type exists
344                if !type_names.contains(&observer.entity) {
345                    report.errors.push(ValidationError {
346                        message:    format!(
347                            "Observer '{}' references unknown entity '{}'",
348                            observer.name, observer.entity
349                        ),
350                        path:       format!("observers[{idx}].entity"),
351                        severity:   ErrorSeverity::Error,
352                        suggestion: Some(Self::suggest_similar_type(&observer.entity, &type_names)),
353                    });
354                }
355
356                // Validate event type
357                let valid_events = ["INSERT", "UPDATE", "DELETE"];
358                if !valid_events.contains(&observer.event.as_str()) {
359                    report.errors.push(ValidationError {
360                        message:    format!(
361                            "Observer '{}' has invalid event '{}'. Must be INSERT, UPDATE, or DELETE",
362                            observer.name, observer.event
363                        ),
364                        path:       format!("observers[{idx}].event"),
365                        severity:   ErrorSeverity::Error,
366                        suggestion: Some("Valid events: INSERT, UPDATE, DELETE".to_string()),
367                    });
368                }
369
370                // Validate at least one action exists
371                if observer.actions.is_empty() {
372                    report.errors.push(ValidationError {
373                        message:    format!(
374                            "Observer '{}' must have at least one action",
375                            observer.name
376                        ),
377                        path:       format!("observers[{idx}].actions"),
378                        severity:   ErrorSeverity::Error,
379                        suggestion: Some("Add a webhook, slack, or email action".to_string()),
380                    });
381                }
382
383                // Validate each action
384                for (action_idx, action) in observer.actions.iter().enumerate() {
385                    if let Some(obj) = action.as_object() {
386                        // Check action has a type field
387                        if let Some(action_type) = obj.get("type").and_then(|v| v.as_str()) {
388                            let valid_action_types = ["webhook", "slack", "email"];
389                            if !valid_action_types.contains(&action_type) {
390                                report.errors.push(ValidationError {
391                                    message:    format!(
392                                        "Observer '{}' action {} has invalid type '{}'",
393                                        observer.name, action_idx, action_type
394                                    ),
395                                    path:       format!(
396                                        "observers[{idx}].actions[{action_idx}].type"
397                                    ),
398                                    severity:   ErrorSeverity::Error,
399                                    suggestion: Some(
400                                        "Valid action types: webhook, slack, email".to_string(),
401                                    ),
402                                });
403                            }
404
405                            // Validate action-specific required fields
406                            match action_type {
407                                "webhook" => {
408                                    let has_url = obj.contains_key("url");
409                                    let has_url_env = obj.contains_key("url_env");
410                                    if !has_url && !has_url_env {
411                                        report.errors.push(ValidationError {
412                                            message:    format!(
413                                                "Observer '{}' webhook action must have 'url' or 'url_env'",
414                                                observer.name
415                                            ),
416                                            path:       format!("observers[{idx}].actions[{action_idx}]"),
417                                            severity:   ErrorSeverity::Error,
418                                            suggestion: Some("Add 'url' or 'url_env' field".to_string()),
419                                        });
420                                    }
421                                },
422                                "slack" => {
423                                    if !obj.contains_key("channel") {
424                                        report.errors.push(ValidationError {
425                                            message:    format!(
426                                                "Observer '{}' slack action must have 'channel' field",
427                                                observer.name
428                                            ),
429                                            path:       format!("observers[{idx}].actions[{action_idx}]"),
430                                            severity:   ErrorSeverity::Error,
431                                            suggestion: Some("Add 'channel' field (e.g., '#sales')".to_string()),
432                                        });
433                                    }
434                                    if !obj.contains_key("message") {
435                                        report.errors.push(ValidationError {
436                                            message:    format!(
437                                                "Observer '{}' slack action must have 'message' field",
438                                                observer.name
439                                            ),
440                                            path:       format!("observers[{idx}].actions[{action_idx}]"),
441                                            severity:   ErrorSeverity::Error,
442                                            suggestion: Some("Add 'message' field".to_string()),
443                                        });
444                                    }
445                                },
446                                "email" => {
447                                    let required_fields = ["to", "subject", "body"];
448                                    for field in &required_fields {
449                                        if !obj.contains_key(*field) {
450                                            report.errors.push(ValidationError {
451                                                message:    format!(
452                                                    "Observer '{}' email action must have '{}' field",
453                                                    observer.name, field
454                                                ),
455                                                path:       format!("observers[{idx}].actions[{action_idx}]"),
456                                                severity:   ErrorSeverity::Error,
457                                                suggestion: Some(format!("Add '{field}' field")),
458                                            });
459                                        }
460                                    }
461                                },
462                                _ => {},
463                            }
464                        } else {
465                            report.errors.push(ValidationError {
466                                message:    format!(
467                                    "Observer '{}' action {} missing 'type' field",
468                                    observer.name, action_idx
469                                ),
470                                path:       format!("observers[{idx}].actions[{action_idx}]"),
471                                severity:   ErrorSeverity::Error,
472                                suggestion: Some(
473                                    "Add 'type' field (webhook, slack, or email)".to_string(),
474                                ),
475                            });
476                        }
477                    } else {
478                        report.errors.push(ValidationError {
479                            message:    format!(
480                                "Observer '{}' action {} must be an object",
481                                observer.name, action_idx
482                            ),
483                            path:       format!("observers[{idx}].actions[{action_idx}]"),
484                            severity:   ErrorSeverity::Error,
485                            suggestion: None,
486                        });
487                    }
488                }
489
490                // Validate retry config
491                let valid_backoff_strategies = ["exponential", "linear", "fixed"];
492                if !valid_backoff_strategies.contains(&observer.retry.backoff_strategy.as_str()) {
493                    report.errors.push(ValidationError {
494                        message:    format!(
495                            "Observer '{}' has invalid backoff_strategy '{}'",
496                            observer.name, observer.retry.backoff_strategy
497                        ),
498                        path:       format!("observers[{idx}].retry.backoff_strategy"),
499                        severity:   ErrorSeverity::Error,
500                        suggestion: Some(
501                            "Valid strategies: exponential, linear, fixed".to_string(),
502                        ),
503                    });
504                }
505
506                if observer.retry.max_attempts == 0 {
507                    report.errors.push(ValidationError {
508                        message:    format!(
509                            "Observer '{}' has max_attempts=0, actions will never execute",
510                            observer.name
511                        ),
512                        path:       format!("observers[{idx}].retry.max_attempts"),
513                        severity:   ErrorSeverity::Warning,
514                        suggestion: Some("Set max_attempts >= 1".to_string()),
515                    });
516                }
517
518                if observer.retry.initial_delay_ms == 0 {
519                    report.errors.push(ValidationError {
520                        message:    format!(
521                            "Observer '{}' has initial_delay_ms=0, retries will be immediate",
522                            observer.name
523                        ),
524                        path:       format!("observers[{idx}].retry.initial_delay_ms"),
525                        severity:   ErrorSeverity::Warning,
526                        suggestion: Some("Consider setting initial_delay_ms > 0".to_string()),
527                    });
528                }
529
530                if observer.retry.max_delay_ms < observer.retry.initial_delay_ms {
531                    report.errors.push(ValidationError {
532                        message:    format!(
533                            "Observer '{}' has max_delay_ms < initial_delay_ms",
534                            observer.name
535                        ),
536                        path:       format!("observers[{idx}].retry.max_delay_ms"),
537                        severity:   ErrorSeverity::Error,
538                        suggestion: Some("max_delay_ms must be >= initial_delay_ms".to_string()),
539                    });
540                }
541            }
542        }
543
544        // Validate sources (#573 scheduled ingress — the dual of observers).
545        if let Some(sources) = &schema.sources {
546            let mut source_names = HashSet::new();
547            let mut cursor_names = HashSet::new();
548            for (idx, source) in sources.iter().enumerate() {
549                // Names key the durable cursor and the advisory lease — they must be unique.
550                if !source_names.insert(source.name.clone()) {
551                    report.errors.push(ValidationError {
552                        message:    format!("Duplicate source name: '{}'", source.name),
553                        path:       format!("sources[{idx}].name"),
554                        severity:   ErrorSeverity::Error,
555                        suggestion: Some("Source names must be unique".to_string()),
556                    });
557                }
558
559                // A shared cursor name would let two sources clobber each other's watermark.
560                let cursor = source.cursor_name().to_string();
561                if !cursor_names.insert(cursor.clone()) {
562                    report.errors.push(ValidationError {
563                        message:    format!(
564                            "Source '{}' reuses cursor '{cursor}' already claimed by another source",
565                            source.name
566                        ),
567                        path:       format!("sources[{idx}].cursor"),
568                        severity:   ErrorSeverity::Error,
569                        suggestion: Some(
570                            "Give each source a distinct cursor (it defaults to the source name)"
571                                .to_string(),
572                        ),
573                    });
574                }
575
576                // The schedule must parse as the runtime parses it: the same parser, so
577                // a schedule the compiler approves is one the scheduler can run (#1399).
578                if let Err(error) =
579                    fraiseql_functions::triggers::CronSchedule::parse(&source.schedule)
580                {
581                    report.errors.push(ValidationError {
582                        message:    format!(
583                            "Source '{}' has an invalid cron schedule '{}': {error}",
584                            source.name, source.schedule
585                        ),
586                        path:       format!("sources[{idx}].schedule"),
587                        severity:   ErrorSeverity::Error,
588                        suggestion: Some(
589                            "Use a 5-field cron expression, e.g. '*/5 * * * *'".to_string(),
590                        ),
591                    });
592                }
593
594                // The bound handler must be named.
595                if source.function.trim().is_empty() {
596                    report.errors.push(ValidationError {
597                        message:    format!("Source '{}' has no function", source.name),
598                        path:       format!("sources[{idx}].function"),
599                        severity:   ErrorSeverity::Error,
600                        suggestion: Some("Set the handler function name".to_string()),
601                    });
602                }
603
604                // The `run_as` authority ceiling (#573 D6): a blank grant is a config
605                // error, and an empty ceiling is fail-closed (the source can write
606                // nothing) — valid, but almost always a mistake, so warn.
607                if let Some(run_as) = &source.run_as {
608                    let has_blank = run_as
609                        .roles
610                        .iter()
611                        .chain(run_as.scopes.iter())
612                        .any(|grant| grant.trim().is_empty())
613                        || run_as.tenant.as_ref().is_some_and(|t| t.trim().is_empty());
614                    if has_blank {
615                        report.errors.push(ValidationError {
616                            message:    format!(
617                                "Source '{}' has a blank role, scope, or tenant in run_as",
618                                source.name
619                            ),
620                            path:       format!("sources[{idx}].run_as"),
621                            severity:   ErrorSeverity::Error,
622                            suggestion: Some(
623                                "Remove empty entries; each role/scope/tenant must be non-blank"
624                                    .to_string(),
625                            ),
626                        });
627                    }
628                    if run_as.roles.is_empty() && run_as.scopes.is_empty() {
629                        report.errors.push(ValidationError {
630                            message:    format!(
631                                "Source '{}' declares run_as with no authority (no roles or \
632                                 scopes): its mutations will be denied (fail-closed)",
633                                source.name
634                            ),
635                            path:       format!("sources[{idx}].run_as"),
636                            severity:   ErrorSeverity::Warning,
637                            suggestion: Some(
638                                "Grant the source the least-privilege roles/scopes its mutations \
639                                 need, or drop run_as if it never mutates"
640                                    .to_string(),
641                            ),
642                        });
643                    }
644                }
645            }
646        }
647
648        info!(
649            "Validation complete: {} errors, {} warnings",
650            report.error_count(),
651            report.warning_count()
652        );
653
654        Ok(report)
655    }
656
657    /// Suggest similar type names for a typo.
658    ///
659    /// Delegates ranking to [`fraiseql_core::runtime::suggest_similar`], a real edit-distance
660    /// match over `char`s. What this replaces did two things wrong at once:
661    ///
662    /// * It sliced `&typo[0..1]` and `&name[0..1]` — **byte** ranges — so an empty base type
663    ///   (`"return_type": ""`) or any name beginning with a multi-byte character *panicked the CLI
664    ///   while it was composing an error message*. The panic was documented in a `# Panics` section
665    ///   that no caller honoured (#724 item 1).
666    /// * It matched on first letter only, behind a comment calling itself "Levenshtein-style". For
667    ///   a typo of `User` it offered `Universe` and `Umbrella`.
668    ///
669    /// Deterministic output: `available` is a `HashSet`, so candidates are sorted before
670    /// ranking and the fallback list is sorted too. Without that, the same schema produced
671    /// different error text run to run.
672    fn suggest_similar_type(typo: &str, available: &HashSet<String>) -> String {
673        let mut candidates: Vec<&str> = available.iter().map(String::as_str).collect();
674        candidates.sort_unstable();
675
676        let similar = fraiseql_core::runtime::suggest_similar(typo, &candidates);
677        if similar.is_empty() {
678            format!("Available types: {}", candidates.join(", "))
679        } else {
680            format!("Did you mean: {}?", similar.join(", "))
681        }
682    }
683}