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}