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 be a 5-field POSIX cron expression (the runtime parses it
572                // fully).
573                if source.schedule.split_whitespace().count() != 5 {
574                    report.errors.push(ValidationError {
575                        message:    format!(
576                            "Source '{}' has an invalid cron schedule '{}': expected 5 \
577                             whitespace-separated fields",
578                            source.name, source.schedule
579                        ),
580                        path:       format!("sources[{idx}].schedule"),
581                        severity:   ErrorSeverity::Error,
582                        suggestion: Some(
583                            "Use a 5-field cron expression, e.g. '*/5 * * * *'".to_string(),
584                        ),
585                    });
586                }
587
588                // The bound handler must be named.
589                if source.function.trim().is_empty() {
590                    report.errors.push(ValidationError {
591                        message:    format!("Source '{}' has no function", source.name),
592                        path:       format!("sources[{idx}].function"),
593                        severity:   ErrorSeverity::Error,
594                        suggestion: Some("Set the handler function name".to_string()),
595                    });
596                }
597
598                // The `run_as` authority ceiling (#573 D6): a blank grant is a config
599                // error, and an empty ceiling is fail-closed (the source can write
600                // nothing) — valid, but almost always a mistake, so warn.
601                if let Some(run_as) = &source.run_as {
602                    let has_blank = run_as
603                        .roles
604                        .iter()
605                        .chain(run_as.scopes.iter())
606                        .any(|grant| grant.trim().is_empty())
607                        || run_as.tenant.as_ref().is_some_and(|t| t.trim().is_empty());
608                    if has_blank {
609                        report.errors.push(ValidationError {
610                            message:    format!(
611                                "Source '{}' has a blank role, scope, or tenant in run_as",
612                                source.name
613                            ),
614                            path:       format!("sources[{idx}].run_as"),
615                            severity:   ErrorSeverity::Error,
616                            suggestion: Some(
617                                "Remove empty entries; each role/scope/tenant must be non-blank"
618                                    .to_string(),
619                            ),
620                        });
621                    }
622                    if run_as.roles.is_empty() && run_as.scopes.is_empty() {
623                        report.errors.push(ValidationError {
624                            message:    format!(
625                                "Source '{}' declares run_as with no authority (no roles or \
626                                 scopes): its mutations will be denied (fail-closed)",
627                                source.name
628                            ),
629                            path:       format!("sources[{idx}].run_as"),
630                            severity:   ErrorSeverity::Warning,
631                            suggestion: Some(
632                                "Grant the source the least-privilege roles/scopes its mutations \
633                                 need, or drop run_as if it never mutates"
634                                    .to_string(),
635                            ),
636                        });
637                    }
638                }
639            }
640        }
641
642        info!(
643            "Validation complete: {} errors, {} warnings",
644            report.error_count(),
645            report.warning_count()
646        );
647
648        Ok(report)
649    }
650
651    /// Suggest similar type names for a typo.
652    ///
653    /// Delegates ranking to [`fraiseql_core::runtime::suggest_similar`], a real edit-distance
654    /// match over `char`s. What this replaces did two things wrong at once:
655    ///
656    /// * It sliced `&typo[0..1]` and `&name[0..1]` — **byte** ranges — so an empty base type
657    ///   (`"return_type": ""`) or any name beginning with a multi-byte character *panicked the CLI
658    ///   while it was composing an error message*. The panic was documented in a `# Panics` section
659    ///   that no caller honoured (#724 item 1).
660    /// * It matched on first letter only, behind a comment calling itself "Levenshtein-style". For
661    ///   a typo of `User` it offered `Universe` and `Umbrella`.
662    ///
663    /// Deterministic output: `available` is a `HashSet`, so candidates are sorted before
664    /// ranking and the fallback list is sorted too. Without that, the same schema produced
665    /// different error text run to run.
666    fn suggest_similar_type(typo: &str, available: &HashSet<String>) -> String {
667        let mut candidates: Vec<&str> = available.iter().map(String::as_str).collect();
668        candidates.sort_unstable();
669
670        let similar = fraiseql_core::runtime::suggest_similar(typo, &candidates);
671        if similar.is_empty() {
672            format!("Available types: {}", candidates.join(", "))
673        } else {
674            format!("Did you mean: {}?", similar.join(", "))
675        }
676    }
677}