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