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