Skip to main content

razeri_plugin_api/
conformance.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! **Every obligation a dialect owes, reported at once.**
6//!
7//! Two people built dialects against Razeri's authoring guide without help.
8//! Between them they met twenty-seven obligations one `cargo test` at a time —
9//! a roster fails, you add a row, the next roster fails. Not one of those
10//! failures was discoverable before it fired. This module is the report that
11//! answers all of them in one pass.
12//!
13//! # Running it
14//!
15//! From your own crate's tests, where your dialect is linked and Razeri's
16//! registry can see it:
17//!
18//! ```no_run
19//! # use razeri_plugin_api::conformance;
20//! # fn example(my_dialect: &dyn razeri_plugin_api::PlatformDialect) {
21//! let report = conformance::check(my_dialect);
22//! assert!(report.is_clean(), "{report}");
23//! # }
24//! ```
25//!
26//! # What it can and cannot see
27//!
28//! Everything here is read off the dialect object, so it works wherever your
29//! plugin compiles: it grades what your dialect declares and what its
30//! renderers return. It runs no SQL, so anything only an engine can settle —
31//! whether the SQL your dialect emits is the SQL that engine accepts — stays
32//! yours to test against a real instance.
33//!
34//! # Your dialect's name
35//!
36//! Your platform's names are yours. `DialectInfo::name` is the one Razeri
37//! reports, `aliases` is every other spelling a design may use for it, and the
38//! resolver reads both off the dialects that are loaded — so the report asks
39//! only that your own spellings reach you, and there is no table inside Razeri
40//! for your platform to be missing from. The one thing it will tell you is that
41//! a spelling you declared is one another loaded platform also answers to,
42//! because then the spelling names neither of you.
43
44use crate::PlatformDialect;
45use razeri_plugin_contract::{
46    ConstraintEnforcement, DataType, DdlRenderer, DialectCapabilities, DialectFamily, MergeShape,
47    ProcedureForm, RoutineKind, RoutineSpec, ScalarFunctionForm, SqlRenderer, TableFunctionForm,
48    UNKNOWN_PLATFORM,
49};
50use std::fmt;
51
52/// How much a finding matters.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
54pub enum Severity {
55    /// **This dialect will produce SQL its engine rejects.** Fix it before
56    /// anything else.
57    Blocking,
58    /// **A question this dialect has not answered.** Razeri has a default and
59    /// will use it; the default was written for a different engine.
60    Unanswered,
61    /// Worth a look. Nothing is broken.
62    Note,
63}
64
65impl fmt::Display for Severity {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str(match self {
68            Self::Blocking => "must fix",
69            Self::Unanswered => "unanswered",
70            Self::Note => "worth a look",
71        })
72    }
73}
74
75/// One thing to fix, in the order a reader wants it: what is wrong, then what
76/// to do about it.
77#[derive(Debug, Clone)]
78pub struct Finding {
79    /// How much it matters.
80    pub severity: Severity,
81    /// One line naming what is wrong.
82    pub what: String,
83    /// One line naming what to do.
84    pub fix: String,
85}
86
87/// The whole report for one dialect.
88#[derive(Debug, Clone)]
89pub struct Report {
90    /// The dialect's registry name.
91    pub dialect: String,
92    /// Everything found, worst first.
93    pub findings: Vec<Finding>,
94}
95
96impl Report {
97    /// Whether nothing blocking or unanswered was found.
98    ///
99    /// Notes do not fail a report: they are observations, not obligations.
100    #[must_use]
101    pub fn is_clean(&self) -> bool {
102        !self.findings.iter().any(|f| f.severity != Severity::Note)
103    }
104
105    /// How many findings of one severity.
106    #[must_use]
107    pub fn count(&self, severity: Severity) -> usize {
108        self.findings.iter().filter(|f| f.severity == severity).count()
109    }
110}
111
112impl fmt::Display for Report {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        let name = &self.dialect;
115        if self.findings.is_empty() {
116            return writeln!(
117                f,
118                "{name}: nothing to fix. Every question Razeri asks a dialect, this one answers."
119            );
120        }
121        let blocking = self.count(Severity::Blocking);
122        let unanswered = self.count(Severity::Unanswered);
123        let notes = self.count(Severity::Note);
124        writeln!(f, "{name}: {blocking} to fix, {unanswered} unanswered, {notes} worth a look.")?;
125        for finding in &self.findings {
126            writeln!(f)?;
127            writeln!(f, "  [{}] {}", finding.severity, finding.what)?;
128            writeln!(f, "      → {}", finding.fix)?;
129        }
130        Ok(())
131    }
132}
133
134/// **The report.** Everything about this dialect that Razeri can check by
135/// asking it.
136///
137/// Registration is checked against the dialects linked into this program. A
138/// dialect the host loaded at run time is not among them, so report it with
139/// [`check_with_registry`] and the registry it was loaded into.
140#[must_use]
141pub fn check(dialect: &dyn PlatformDialect) -> Report {
142    check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
143}
144
145/// The same report, against a registry the host built — the path for a dialect
146/// loaded at run time rather than linked in.
147#[must_use]
148pub fn check_with_registry(
149    dialect: &dyn PlatformDialect,
150    registry: &crate::dialect::PlatformRegistry,
151) -> Report {
152    let info = dialect.info();
153    let mut findings = Vec::new();
154
155    check_registration(&info, registry, &mut findings);
156    check_family(info.dialect_family, &mut findings);
157    check_required_rendering(dialect, &mut findings);
158    check_decisions(dialect, &mut findings);
159    check_formula_renderer(dialect, &mut findings);
160    check_paired_facts(dialect, &mut findings);
161
162    findings.sort_by_key(|f| f.severity);
163    Report { dialect: info.name.to_owned(), findings }
164}
165
166/// Every spelling this dialect answers to has to reach this dialect: its own
167/// name, and each alias it declares.
168///
169/// That is the whole obligation, and it is one the author can meet from their
170/// own crate — the spellings are the dialect's own, so there is no list inside
171/// Razeri to be missing from and nothing here can ask for a change only Razeri
172/// could make. What is worth saying is a spelling that reaches somewhere else:
173/// one another loaded platform also claims (so it names neither, and Razeri
174/// refuses it), or one that reaches a different platform because the registry
175/// holds this dialect under another key.
176fn check_registration(
177    info: &crate::dialect::DialectInfo,
178    registry: &crate::dialect::PlatformRegistry,
179    findings: &mut Vec<Finding>,
180) {
181    let name = info.name;
182    if matches!(
183        crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
184        Err(crate::ResolveError::UnknownDialect { .. })
185    ) {
186        findings.push(Finding {
187            severity: Severity::Blocking,
188            what: format!(
189                "the name `{name}` does not resolve — nothing in this program registered it"
190            ),
191            fix: "add `inventory::submit! { DialectRegistration::new(\"<name>\", || \
192                  Arc::new(MyDialect)) }` to your crate, and a `pub fn force_link() {}` beside \
193                  it that the program using your dialect calls once. A crate nothing links is a \
194                  crate the registry cannot see."
195                .to_owned(),
196        });
197        return;
198    }
199    for spelling in info.spellings() {
200        match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
201            Ok(found) if found.info().name == name => {}
202            Ok(found) => findings.push(Finding {
203                severity: Severity::Blocking,
204                what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
205                fix: format!(
206                    "the registry holds `{name}` under another key. Register it under its own \
207                     name, or drop `{spelling}` from its aliases."
208                ),
209            }),
210            Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
211                severity: Severity::Blocking,
212                what: format!(
213                    "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
214                     none of them — Razeri refuses that spelling rather than guessing which one \
215                     a design meant",
216                    claimed_by.join(" and ")
217                ),
218                fix: format!(
219                    "give this dialect a name no other platform answers to. A platform name is \
220                     read off the loaded dialects' own declared spellings, so a spelling another \
221                     platform already claims is never `{name}`'s."
222                ),
223            }),
224            Err(_) => findings.push(Finding {
225                severity: Severity::Blocking,
226                what: format!(
227                    "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
228                ),
229                fix: format!(
230                    "the `{name}` in the registry is a different build of this dialect and does \
231                     not declare `{spelling}`. Register the one you are grading."
232                ),
233            }),
234        }
235    }
236}
237
238/// A family is a claim about spelling, so an unmeasured engine claiming a
239/// measured family's name licenses SQL it may reject.
240fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
241    if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
242        findings.push(Finding {
243            severity: Severity::Blocking,
244            what: "this dialect's family is a custom one with no name".to_owned(),
245            fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
246                  prints and what a reader looks up."
247                .to_owned(),
248        });
249    }
250}
251
252/// The four required methods must answer for every type a design can declare.
253fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
254    let declarable = declarable_types();
255    let empty_column: Vec<String> = declarable
256        .iter()
257        .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
258        .map(|dt| format!("{dt:?}"))
259        .collect();
260    if !empty_column.is_empty() {
261        findings.push(Finding {
262            severity: Severity::Blocking,
263            what: format!(
264                "`render_data_type` answers nothing for {} of the types a design can declare",
265                empty_column.len()
266            ),
267            fix: "every declarable type needs a column spelling on this engine, even if the \
268                  spelling is a wider type that stores it. A blank answer becomes a column \
269                  declaration with no type in it."
270                .to_owned(),
271        });
272    }
273    let empty_cast: Vec<String> = declarable
274        .iter()
275        .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
276        .map(|dt| format!("{dt:?}"))
277        .collect();
278    if !empty_cast.is_empty() {
279        findings.push(Finding {
280            severity: Severity::Blocking,
281            what: format!(
282                "`render_cast_type` answers nothing for {} declarable types",
283                empty_cast.len()
284            ),
285            fix: "the generated load opens by casting every source column to its declared type, \
286                  so a blank answer fails the load on its first statement."
287                .to_owned(),
288        });
289    }
290    let quoted = SqlRenderer::quote_identifier(dialect, "order");
291    if quoted == "order" {
292        findings.push(Finding {
293            severity: Severity::Blocking,
294            what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
295                   reaches this engine bare"
296                .to_owned(),
297            fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
298                  backticks, or brackets."
299                .to_owned(),
300        });
301    }
302    if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
303        findings.push(Finding {
304            severity: Severity::Blocking,
305            what: "`render_current_timestamp` answers nothing, and every generated load stamps \
306                   a row with it"
307                .to_owned(),
308            fix: "return this engine's own expression for the current moment.".to_owned(),
309        });
310    }
311}
312
313/// The decisions a dialect states rather than inherits.
314fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
315    let spelling = SqlRenderer::sid_spelling(dialect);
316    if spelling.platform == UNKNOWN_PLATFORM {
317        findings.push(Finding {
318            severity: Severity::Blocking,
319            what: "this dialect states no surrogate-key spelling, so every key it renders calls \
320                   an `xxhash64` its engine very likely does not have"
321                .to_owned(),
322            fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
323                  TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
324                  hash. If it has no portable hash, name an artifact your prerequisites install \
325                  — never the nearest-looking built-in, which keys a warehouse to a number \
326                  nothing else can reproduce."
327                .to_owned(),
328        });
329    }
330
331    // **A stated body that says nothing is worse than no body at all.** Razeri
332    // writes the `CREATE FUNCTION` envelope around whatever comes back, so an
333    // empty one deploys cleanly and returns nothing — every key in the
334    // warehouse is then the sentinel or garbage, under a green deploy.
335    if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
336        && supplied.body.trim().is_empty()
337    {
338        findings.push(Finding {
339            severity: Severity::Blocking,
340            what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
341                   and then supplies an empty one"
342                .to_owned(),
343            fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
344                  one argument, or answer `None` and let Razeri compose the derivation from \
345                  your `sid_spelling`. An empty body deploys without error and keys every row \
346                  to nothing."
347                .to_owned(),
348        });
349    }
350
351    // A file relation that drops its path reads the same file for every entity
352    // — one table filled, the rest filled wrong, and nothing about it fails.
353    let probe = "'/razeri/probe.csv'";
354    if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
355        && !relation.contains(probe)
356    {
357        findings.push(Finding {
358            severity: Severity::Blocking,
359            what: "`delimited_file_relation` returns a relation that does not carry the path it \
360                   was given"
361                .to_owned(),
362            fix: "put the literal you were handed where your engine's file relation takes a \
363                  path. It arrives already quoted and escaped. A relation that drops it reads \
364                  one file for every entity the design binds, and the generated fill loads the \
365                  wrong rows without failing."
366                .to_owned(),
367        });
368    }
369
370    check_routine_forms(dialect, findings);
371
372    // The note is what a reader of the DDL learns from, and on an engine with
373    // no keyword of its own it is the ONLY place they can learn it.
374    if let ConstraintEnforcement::DeclaredOnly(declared) =
375        DdlRenderer::constraint_enforcement(dialect)
376        && declared.note.split_whitespace().count() < 5
377    {
378        findings.push(Finding {
379            severity: Severity::Blocking,
380            what: "this dialect says its engine accepts constraints it does not enforce, and \
381                   the note explaining that says almost nothing"
382                .to_owned(),
383            fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
384                  guarantee. Say what this engine really does with the declaration, and where \
385                  the guarantee has to come from instead."
386                .to_owned(),
387        });
388    }
389
390    // **The two refusals are different findings, and that is the point of the
391    // second variant.** `None` is silence, and silence is unanswered. A stated
392    // `Unsupported` is a measured claim; what is left to grade there is whether
393    // the sentence is worth reading, because it is the only thing an operator
394    // gets where the load would have been.
395    match DialectCapabilities::merge_shape(dialect) {
396        MergeShape::None => findings.push(Finding {
397            severity: Severity::Unanswered,
398            what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
399                   load for it"
400                .to_owned(),
401            fix: "if this engine has a MERGE or an upsert statement, say which in \
402                  `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
403                  `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
404                  operator can act on, rather than a silence."
405                .to_owned(),
406        }),
407        MergeShape::Unsupported(orientation) => {
408            check_refusal_copy("the upsert refusal", orientation, findings);
409        }
410        _ => {}
411    }
412}
413
414/// The one override nothing tells an author about, and every formula in every
415/// design goes through it.
416fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
417    let formula = SqlRenderer::to_formula_renderer(dialect);
418    let dialect_name = SqlRenderer::dialect_name(dialect);
419    if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
420        findings.push(Finding {
421            severity: Severity::Blocking,
422            what: "`to_formula_renderer` was not overridden, so every formula in every design \
423                   renders through Razeri's ANSI defaults instead of this dialect"
424                .to_owned(),
425            fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
426                  formula this engine sees is silently somebody else's."
427                .to_owned(),
428        });
429    }
430}
431
432/// **The three routine grammars a dialect states**, and what each answer owes.
433///
434/// A grammar Razeri carries needs nothing further. A refusal owes words a
435/// reader can act on. `Custom` owes the render method it promised — see
436/// [`check_custom_grammar`].
437fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
438    match SqlRenderer::procedure_form(dialect) {
439        // Razeri's own agnostic renderer answers `Agnostic` on purpose: the
440        // annotated form IS its output. Every other dialect that answers it has
441        // simply not said.
442        ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
443        ProcedureForm::Agnostic => findings.push(Finding {
444            severity: Severity::Unanswered,
445            what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
446                   annotated form — which no engine parses"
447                .to_owned(),
448            fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
449                  or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
450                  offers instead."
451                .to_owned(),
452        }),
453        ProcedureForm::Unsupported(orientation) => {
454            check_refusal_copy("the stored-procedure refusal", orientation, findings);
455        }
456        ProcedureForm::Custom(grammar) => check_custom_grammar(
457            CustomGrammar {
458                kind: RoutineKind::Procedure,
459                grammar,
460                form_method: "procedure_form",
461                render_method: "render_procedure",
462                noun: "procedures",
463            },
464            |spec| SqlRenderer::render_procedure(dialect, spec),
465            findings,
466        ),
467        _ => {}
468    }
469
470    match SqlRenderer::scalar_function_form(dialect) {
471        ScalarFunctionForm::Unsupported(orientation) => {
472            check_refusal_copy("the scalar-function refusal", orientation, findings);
473        }
474        ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
475            CustomGrammar {
476                kind: RoutineKind::ScalarFunction,
477                grammar,
478                form_method: "scalar_function_form",
479                render_method: "render_scalar_function",
480                noun: "scalar functions",
481            },
482            |spec| SqlRenderer::render_scalar_function(dialect, spec),
483            findings,
484        ),
485        _ => {}
486    }
487
488    match SqlRenderer::table_function_form(dialect) {
489        TableFunctionForm::Unsupported(orientation) => {
490            check_refusal_copy("the table-function refusal", orientation, findings);
491        }
492        TableFunctionForm::Custom(grammar) => check_custom_grammar(
493            CustomGrammar {
494                kind: RoutineKind::TableFunction,
495                grammar,
496                form_method: "table_function_form",
497                render_method: "render_table_function",
498                noun: "table functions",
499            },
500            |spec| SqlRenderer::render_table_function(dialect, spec),
501            findings,
502        ),
503        _ => {}
504    }
505}
506
507/// One routine kind whose grammar a dialect said it spells itself.
508#[derive(Clone, Copy)]
509struct CustomGrammar {
510    /// Which routine kind the claim is about.
511    kind: RoutineKind,
512    /// The grammar's own name, as the dialect wrote it.
513    grammar: &'static str,
514    /// The method that carried the claim.
515    form_method: &'static str,
516    /// The method that owes the rendering.
517    render_method: &'static str,
518    /// What to call the routines in a sentence.
519    noun: &'static str,
520}
521
522/// **A dialect that says it spells its own grammar has to spell it.**
523///
524/// `Custom` moves the whole rendering to the dialect: Razeri substitutes no
525/// near-neighbour grammar and refuses nothing, because both of those say
526/// something false — one about the engine's syntax, the other about whether the
527/// engine has the routine at all. So the claim is only worth anything with the
528/// method behind it, and a dialect that made the claim and left the method
529/// alone ships a build that stops on the routine.
530///
531/// The check is the question itself: hand the render method the shape it will
532/// receive and see whether anything comes back.
533///
534/// Parameters:
535/// - `claim`: which kind, which grammar, and the two method names to name;
536/// - `render`: the dialect's render method for that kind;
537/// - `findings`: where the report is collected.
538fn check_custom_grammar(
539    claim: CustomGrammar,
540    render: impl Fn(&RoutineSpec) -> Option<String>,
541    findings: &mut Vec<Finding>,
542) {
543    let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
544    // The grammar's name is a name, not a refusal: it is read in a report and
545    // in a comment, so it has to say something and does not have to say much.
546    if grammar.trim().is_empty() {
547        findings.push(Finding {
548            severity: Severity::Blocking,
549            what: format!("`{form_method}` says Custom, and names no grammar"),
550            fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
551                  of a report, and of the comment Razeri writes where a routine could not be \
552                  rendered, is told this engine speaks."
553                .to_owned(),
554        });
555    }
556
557    let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
558    probe.or_replace = true;
559    if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
560        findings.push(Finding {
561            severity: Severity::Blocking,
562            what: format!(
563                "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
564                 itself — and `{render_method}` returns nothing"
565            ),
566            fix: format!(
567                "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
568                 composed — name, parameters with their types already in your spelling, body, \
569                 comment — and returns the whole CREATE statement your engine parses. Razeri \
570                 emits what you return and writes nothing of its own: a near-neighbour grammar \
571                 fails on deploy, and a refusal would say this engine has no {noun} when it \
572                 has them in a grammar of its own."
573            ),
574        });
575    }
576}
577
578/// A refusal that does not say what to do instead is a wall.
579fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
580    if copy.trim().is_empty() {
581        findings.push(Finding {
582            severity: Severity::Blocking,
583            what: format!("{subject} has no words in it"),
584            fix: "say what this engine offers instead. A reader who hits a refusal needs the \
585                  next thing to try, and it has to be about their engine."
586                .to_owned(),
587        });
588        return;
589    }
590    if copy.split_whitespace().count() < 5 {
591        findings.push(Finding {
592            severity: Severity::Note,
593            what: format!("{subject} is very short"),
594            fix: "a refusal earns its place by naming the alternative — what this engine has \
595                  instead of the thing it cannot do."
596                .to_owned(),
597        });
598    }
599}
600
601/// Fourteen facts are declared twice and must agree.
602fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
603    let caps: &dyn DialectCapabilities = dialect;
604    let ren: &dyn SqlRenderer = dialect;
605    let ddl: &dyn DdlRenderer = dialect;
606    let mut disagreements: Vec<&'static str> = Vec::new();
607
608    macro_rules! pair {
609        ($m:ident, $renderer_value:expr) => {
610            if caps.$m() != $renderer_value {
611                disagreements.push(stringify!($m));
612            }
613        };
614    }
615    pair!(supports_on_conflict, ren.supports_on_conflict());
616    pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
617    pair!(supports_update_from, ren.supports_update_from());
618    pair!(supports_named_windows, ren.supports_named_windows());
619    pair!(supports_compound_assignment, ren.supports_compound_assignment());
620    pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
621    pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
622    pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
623    pair!(uses_alter_table_update, ren.uses_alter_table_update());
624    pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
625    pair!(supports_cascade_drop, ddl.supports_cascade_drop());
626    pair!(supports_sync_drop, ddl.supports_sync_drop());
627    pair!(supports_force_drop, ddl.supports_force_drop());
628
629    // **The fourteenth, whose two sides are different types.** The capability
630    // is a boolean; the renderer answer is a form that either renders or
631    // carries a refusal. Both are read — the build door reads the form, and
632    // registry-walking rosters read the capability — so a dialect claiming a
633    // table function on one side and refusing on the other has an engine that
634    // both does and does not have one. `SQLite` was exactly that on the day
635    // this check was written: `false` since the capability existed, and a
636    // renderer still inheriting PostgreSQL's `RETURNS TABLE`.
637    if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
638        disagreements.push("supports_table_functions");
639    }
640
641    if !disagreements.is_empty() {
642        findings.push(Finding {
643            severity: Severity::Blocking,
644            what: format!(
645                "this dialect declares {} fact(s) two ways and the two disagree: {}",
646                disagreements.len(),
647                disagreements.join(", ")
648            ),
649            fix: "each of these is declared once on the capability trait and once in the \
650                  renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
651                  and move the other one to match."
652                .to_owned(),
653        });
654    }
655}
656
657/// Every type a design can declare, which is the set the required methods owe
658/// an answer for.
659fn declarable_types() -> Vec<DataType> {
660    vec![
661        DataType::tinyint(),
662        DataType::smallint(),
663        DataType::int(),
664        DataType::bigint(),
665        DataType::double(),
666        DataType::boolean(),
667        DataType::text(),
668        DataType::varchar(200),
669        DataType::uuid(),
670        DataType::json(),
671        DataType::date(),
672        DataType::timestamp(),
673        DataType::timestamp_tz(),
674        DataType::decimal(18, 2),
675    ]
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use crate::dialect::DialectInfo;
682    use razeri_plugin_contract::{
683        Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
684        ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
685        TaskRenderer, TextCast,
686    };
687    use std::marker::PhantomData;
688
689    // ── The broken fixture ────────────────────────────────────────────
690    //
691    // A plugin that compiles, registers nothing it should, and answers every
692    // question with somebody else's default. This is what the kit exists to
693    // catch, and it is deliberately the plugin an author gets by implementing
694    // only the four required methods and stopping — which the guide, until
695    // now, told them was enough.
696
697    struct BrokenPlugin;
698
699    impl SqlRenderer for BrokenPlugin {
700        fn render_data_type(&self, _dt: &DataType) -> String {
701            String::new()
702        }
703        fn quote_identifier(&self, name: &str) -> String {
704            name.to_owned()
705        }
706        fn render_cast_type(&self, _dt: &DataType) -> String {
707            String::new()
708        }
709        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
710            name.to_owned()
711        }
712        fn render_current_timestamp(&self) -> String {
713            String::new()
714        }
715        fn dialect_name(&self) -> &'static str {
716            "brokendb"
717        }
718        // No `sid_spelling`, no `procedure_form`, no `to_formula_renderer` —
719        // and one fact declared twice, two ways.
720        fn supports_on_conflict(&self) -> bool {
721            true
722        }
723    }
724    impl ExpressionRenderer for BrokenPlugin {}
725    impl DdlRenderer for BrokenPlugin {}
726    impl DmlRenderer for BrokenPlugin {}
727    impl ProcedureRenderer for BrokenPlugin {}
728    impl FormulaRenderer for BrokenPlugin {}
729    impl TaskRenderer for BrokenPlugin {}
730    impl ReplicationRenderer for BrokenPlugin {}
731    impl StorageRenderer for BrokenPlugin {}
732    impl DialectCapabilities for BrokenPlugin {
733        fn supports_on_conflict(&self) -> bool {
734            false
735        }
736    }
737    impl PlatformDialect for BrokenPlugin {
738        fn info(&self) -> DialectInfo {
739            DialectInfo {
740                name: "brokendb",
741                display_name: "Broken DB",
742                aliases: &[],
743                dialect_family: DialectFamily::Custom(""),
744                family: "Broken",
745                is_cloud_native: false,
746                is_olap: false,
747                is_postgres_compatible: false,
748            }
749        }
750    }
751
752    // ── The answered fixture, under two names ─────────────────────────
753    //
754    // The same plugin with every question answered, carrying the name it
755    // registers under as a type parameter. Neither name is one Razeri knows a
756    // spelling for; the difference is that one of them is in the registry and
757    // the other is in nothing at all. Those are two different facts about a
758    // dialect, and they read as two different reports.
759
760    trait FixtureName: Send + Sync + 'static {
761        /// The registry name this fixture answers to.
762        const NAME: &'static str;
763        /// How this fixture's engine joins the parts of a composite key. Most
764        /// of them take the inherited variadic `CONCAT`; the one that does not
765        /// is the point of it being a knob.
766        const CONCAT: Concat = Concat::Function("CONCAT");
767    }
768
769    /// Answered, and nothing linked it — what an author sees running the report
770    /// from a crate whose registration nobody calls.
771    struct Unlinked;
772    impl FixtureName for Unlinked {
773        const NAME: &'static str = "healthydb";
774    }
775
776    /// Answered and registered, under a name Razeri ships no spelling for —
777    /// the shape of every dialect written outside Razeri's own tree.
778    struct Outside;
779    impl FixtureName for Outside {
780        const NAME: &'static str = "outsidedb";
781    }
782
783    /// Answered, and joining composite keys with a `CONCAT` that takes exactly
784    /// two arguments — the shape of Db2 and of older Oracle releases. Nothing
785    /// about the join is an obligation, so the report on it must read exactly
786    /// like the report on a variadic engine.
787    struct BinaryConcat;
788    impl FixtureName for BinaryConcat {
789        const NAME: &'static str = "binaryconcatdb";
790        const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
791    }
792
793    struct Answered<N: FixtureName>(PhantomData<N>);
794
795    impl<N: FixtureName> Answered<N> {
796        const fn new() -> Self {
797            Self(PhantomData)
798        }
799    }
800
801    type HealthyPlugin = Answered<Unlinked>;
802    type OutsidePlugin = Answered<Outside>;
803
804    inventory::submit! {
805        crate::builder::DialectRegistration::new(
806            Outside::NAME,
807            || std::sync::Arc::new(OutsidePlugin::new()),
808        )
809    }
810
811    impl<N: FixtureName> SqlRenderer for Answered<N> {
812        fn render_data_type(&self, dt: &DataType) -> String {
813            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
814        }
815        fn quote_identifier(&self, name: &str) -> String {
816            format!("\"{name}\"")
817        }
818        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
819            schema.map_or_else(
820                || self.quote_identifier(name),
821                |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
822            )
823        }
824        fn render_current_timestamp(&self) -> String {
825            "NOW()".to_owned()
826        }
827        fn dialect_name(&self) -> &'static str {
828            N::NAME
829        }
830        fn sid_spelling(&self) -> SidSpelling {
831            SidSpelling {
832                platform: N::NAME,
833                hash: ("answered_xxhash64(", ")"),
834                cast_type: TextCast::Direct("STRING"),
835                concat: N::CONCAT,
836                through_helper: false,
837                ..razeri_plugin_contract::ANSI_SID_SPELLING
838            }
839        }
840        fn procedure_form(&self) -> ProcedureForm {
841            ProcedureForm::Unsupported(
842                "This engine has no stored procedures; write the load as a scheduled query \
843                 instead.",
844            )
845        }
846        fn scalar_function_form(&self) -> ScalarFunctionForm {
847            ScalarFunctionForm::PostgreSql
848        }
849        fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
850            Box::new(Self::new())
851        }
852    }
853    impl<N: FixtureName> ExpressionRenderer for Answered<N> {}
854    impl<N: FixtureName> DdlRenderer for Answered<N> {}
855    impl<N: FixtureName> DmlRenderer for Answered<N> {}
856    impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
857    impl<N: FixtureName> FormulaRenderer for Answered<N> {}
858    impl<N: FixtureName> TaskRenderer for Answered<N> {}
859    impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
860    impl<N: FixtureName> StorageRenderer for Answered<N> {}
861    impl<N: FixtureName> DialectCapabilities for Answered<N> {
862        fn merge_shape(&self) -> MergeShape {
863            MergeShape::Merge(MergeRestrictions::none())
864        }
865    }
866    impl<N: FixtureName> PlatformDialect for Answered<N> {
867        fn info(&self) -> DialectInfo {
868            DialectInfo {
869                name: N::NAME,
870                display_name: "An Answered Engine",
871                // It answers to its own name and nothing else, which is the
872                // shape of a plugin that declares no extra spellings.
873                aliases: &[],
874                dialect_family: DialectFamily::Custom(N::NAME),
875                family: "Answered",
876                is_cloud_native: false,
877                is_olap: false,
878                is_postgres_compatible: false,
879            }
880        }
881    }
882
883    // ── The twin: a dialect that claims a name another platform answers to ──
884    //
885    // Answered in every other respect, so the only thing its report can be
886    // about is the spelling it took.
887
888    struct Twin;
889
890    impl SqlRenderer for Twin {
891        fn render_data_type(&self, dt: &DataType) -> String {
892            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
893        }
894        fn quote_identifier(&self, name: &str) -> String {
895            format!("\"{name}\"")
896        }
897        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
898            self.quote_identifier(name)
899        }
900        fn render_current_timestamp(&self) -> String {
901            "NOW()".to_owned()
902        }
903    }
904    impl ExpressionRenderer for Twin {}
905    impl DdlRenderer for Twin {}
906    impl DmlRenderer for Twin {}
907    impl ProcedureRenderer for Twin {}
908    impl FormulaRenderer for Twin {}
909    impl TaskRenderer for Twin {}
910    impl ReplicationRenderer for Twin {}
911    impl StorageRenderer for Twin {}
912    impl DialectCapabilities for Twin {}
913    impl PlatformDialect for Twin {
914        fn info(&self) -> DialectInfo {
915            DialectInfo {
916                name: "twindb",
917                display_name: "Twin DB",
918                // The name the answered fixture already answers to.
919                aliases: &["healthydb"],
920                dialect_family: DialectFamily::Custom("twindb"),
921                family: "Twin",
922                is_cloud_native: false,
923                is_olap: false,
924                is_postgres_compatible: false,
925            }
926        }
927    }
928
929    /// Every finding whose `what` line mentions one of these subjects.
930    fn mentions(report: &Report, needle: &str) -> bool {
931        report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
932    }
933
934    /// **The kit's own red.** A plugin that implements the four required
935    /// methods and stops is exactly what the guide used to produce, and every
936    /// one of these findings is a defect a shipped dialect actually had.
937    #[test]
938    fn the_kit_catches_a_deliberately_broken_plugin() {
939        let report = check(&BrokenPlugin);
940        assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
941
942        assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
943        assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
944        assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
945        assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
946        assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
947        assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
948        assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
949        assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
950        assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
951        assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
952        assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
953
954        assert!(
955            report.count(Severity::Blocking) >= 8,
956            "the broken plugin owes more than {} blocking fixes:\n{report}",
957            report.count(Severity::Blocking)
958        );
959    }
960
961    /// **The kit does not cry wolf.** The same plugin with its questions
962    /// answered reports only what is actually true of it: it is not
963    /// registered in this test binary, and nothing else.
964    #[test]
965    fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
966        let report = check(&HealthyPlugin::new());
967        let unregistered: Vec<&Finding> =
968            report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
969        assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
970        assert_eq!(
971            report.findings.len(),
972            1,
973            "a plugin that answered every question owes nothing else:\n{report}"
974        );
975    }
976
977    /// **A join written for a two-argument `CONCAT` is an answer, not a
978    /// shortfall.** The report on a dialect that states it reads exactly like
979    /// the report on a variadic one, and the key it renders nests pairwise
980    /// instead of widening a call the engine does not have.
981    #[test]
982    fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
983        let binary = Answered::<BinaryConcat>::new();
984        let report = check(&binary);
985        assert_eq!(
986            report.findings.len(),
987            check(&HealthyPlugin::new()).findings.len(),
988            "the join form is a decision, so stating this one owes nothing extra:\n{report}"
989        );
990        assert!(
991            !report.to_string().contains("joins"),
992            "nothing about the join is outstanding:\n{report}"
993        );
994
995        let key = razeri_plugin_contract::sid::sid_expression(
996            SqlRenderer::sid_spelling(&binary),
997            None,
998            &["a".to_owned(), "b".to_owned(), "c".to_owned()],
999        );
1000        assert!(
1001            key.contains("CONCAT(CONCAT("),
1002            "a two-argument engine gets one call per pair:\n{key}"
1003        );
1004    }
1005
1006    /// **A dialect written outside Razeri reaches a clean report.** It is
1007    /// reached the way every out-of-tree dialect is reached — it registered, and
1008    /// the resolver reads the loaded dialects' own declared spellings — so there
1009    /// is nothing for Razeri to be missing it from and nothing for the report to
1010    /// ask of its author.
1011    #[test]
1012    fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
1013        assert!(
1014            crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
1015            "the fixture registers through the registry, which is the whole path being graded"
1016        );
1017        // The precondition that used to read "no row in Razeri's table": no
1018        // other loaded platform declares this name, so the fixture is reached
1019        // only because it registered and said so itself.
1020        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1021        assert!(
1022            registry
1023                .list()
1024                .iter()
1025                .filter(|info| info.name != Outside::NAME)
1026                .all(|info| !info.answers_to(Outside::NAME)),
1027            "the fixture's name must be one no other platform claims"
1028        );
1029
1030        let report = check(&OutsidePlugin::new());
1031        assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
1032        assert!(report.is_clean(), "and the report says so:\n{report}");
1033        assert_eq!(report.count(Severity::Blocking), 0, "{report}");
1034        assert!(
1035            report.to_string().contains("nothing to fix"),
1036            "a clean dialect is told it is clean:\n{report}"
1037        );
1038    }
1039
1040    /// **A platform cannot be reachable under an alias but not under its own
1041    /// name.** That used to be a judgement about a hand-written table, which
1042    /// could spell a platform `mynew_db` and never `mynewdb`. A dialect now
1043    /// declares its own spellings and its own name leads them, so the defect is
1044    /// unrepresentable rather than caught — and this is the assertion that keeps
1045    /// it that way.
1046    #[test]
1047    fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
1048        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1049        for info in registry.list() {
1050            assert_eq!(
1051                info.spellings().first().copied(),
1052                Some(info.name),
1053                "a platform's own name leads its spellings"
1054            );
1055            assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
1056        }
1057    }
1058
1059    /// **A name another platform already answers to is a collision, not a
1060    /// registration.** A dialect claiming a spelling Healthy DB answers to is
1061    /// named as the collision it is — with both platforms named, and a fix its
1062    /// author can actually perform — rather than being reported as an
1063    /// unregistered platform, which is a different problem with a different fix.
1064    #[test]
1065    fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
1066        let mut registry = crate::dialect::PlatformRegistry::new();
1067        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1068        registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1069
1070        let report = check_with_registry(&Twin, &registry);
1071        let collision = report
1072            .findings
1073            .iter()
1074            .find(|f| f.what.contains("claimed by more than one loaded platform"))
1075            .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
1076        assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
1077        assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
1078        assert!(collision.what.contains("twindb"), "{collision:?}");
1079        assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
1080        assert!(
1081            !mentions(&report, "does not resolve"),
1082            "a collision is not an unregistered platform:\n{report}"
1083        );
1084    }
1085
1086    /// A platform reached under its own name owes nothing, whatever the case or
1087    /// the surrounding whitespace, and a name nothing has ever heard of is
1088    /// simply unknown rather than a finding against this dialect.
1089    #[test]
1090    fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
1091        let mut registry = crate::dialect::PlatformRegistry::new();
1092        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1093
1094        let mut findings = Vec::new();
1095        check_registration(&HealthyPlugin::new().info(), &registry, &mut findings);
1096        assert!(findings.is_empty(), "{findings:?}");
1097
1098        for spelling in ["healthydb", "HealthyDB", "  healthydb  "] {
1099            assert!(
1100                crate::resolve::resolve_dialect_by_name_with_registry(spelling, &registry).is_ok(),
1101                "'{spelling}' is the same platform"
1102            );
1103        }
1104        assert!(
1105            crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", &registry).is_err()
1106        );
1107    }
1108
1109    /// **A dialect the host loaded at run time can be graded at all.** [`check`]
1110    /// asks the dialects linked into this program, which one loaded from a
1111    /// shared library is not among; [`check_with_registry`] asks the registry it
1112    /// was loaded into, and the registration judgement is answered from there.
1113    #[test]
1114    fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
1115        let mut registry = crate::dialect::PlatformRegistry::new();
1116        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1117
1118        let report = check_with_registry(&HealthyPlugin::new(), &registry);
1119        assert!(
1120            report.is_clean(),
1121            "a loaded dialect that answered everything owes nothing:\n{report}"
1122        );
1123    }
1124
1125    /// **Every fix an outside author can read is a fix in their own crate.** A
1126    /// report that names a file inside Razeri is telling an author to edit a
1127    /// tree they do not have, and the fix cannot be performed at all.
1128    #[test]
1129    fn no_fix_line_sends_an_author_into_razeris_own_tree() {
1130        let mut collided = crate::dialect::PlatformRegistry::new();
1131        collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1132        collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1133
1134        let mut fixes: Vec<String> = Vec::new();
1135        for report in [
1136            check(&BrokenPlugin),
1137            check(&HealthyPlugin::new()),
1138            check(&OutsidePlugin::new()),
1139            // The collision and the unregistered-name judgements, which are the
1140            // two registration findings an author can actually reach.
1141            check_with_registry(&Twin, &collided),
1142            check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
1143        ] {
1144            fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
1145        }
1146        assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
1147        for fix in &fixes {
1148            for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
1149                assert!(
1150                    !fix.contains(path),
1151                    "a fix line names `{path}`, which is not in the author's crate:\n  {fix}"
1152                );
1153            }
1154        }
1155    }
1156
1157    /// **A dialect that claims its own grammar and does not spell it is
1158    /// blocking**, and the finding names the method it owes.
1159    ///
1160    /// `Custom` is a promise that the dialect renders the routine itself, and
1161    /// Razeri keeps its side by writing nothing: no near-neighbour grammar,
1162    /// which fails on deploy, and no refusal, which would tell an author their
1163    /// engine has no routines when it has a whole language. A promise with
1164    /// nothing behind it is therefore a build that stops, and it has to be
1165    /// findable before the deploy rather than during it.
1166    #[test]
1167    fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
1168        for (kind, form_method, render_method) in [
1169            (RoutineKind::Procedure, "procedure_form", "render_procedure"),
1170            (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
1171            (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
1172        ] {
1173            let mut promised = Vec::new();
1174            check_custom_grammar(
1175                CustomGrammar {
1176                    kind,
1177                    grammar: "PL/SQL",
1178                    form_method,
1179                    render_method,
1180                    noun: "routines",
1181                },
1182                |_| None,
1183                &mut promised,
1184            );
1185            assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
1186            assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
1187            assert!(promised[0].what.contains(form_method), "{promised:?}");
1188            assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
1189            assert!(promised[0].fix.contains(render_method), "{promised:?}");
1190
1191            // The same claim, kept: nothing to report.
1192            let mut kept = Vec::new();
1193            check_custom_grammar(
1194                CustomGrammar {
1195                    kind,
1196                    grammar: "PL/SQL",
1197                    form_method,
1198                    render_method,
1199                    noun: "routines",
1200                },
1201                |spec| Some(format!("CREATE {} …", spec.name)),
1202                &mut kept,
1203            );
1204            assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
1205        }
1206    }
1207
1208    /// **A render method that answers with nothing at all is the same defect.**
1209    ///
1210    /// An override that returns an empty string is a routine razeri would emit
1211    /// as blank space in the middle of a schema, which is worse than an error:
1212    /// nothing in the deploy says anything is missing.
1213    #[test]
1214    fn a_custom_render_method_that_returns_nothing_is_blocking() {
1215        let mut findings = Vec::new();
1216        check_custom_grammar(
1217            CustomGrammar {
1218                kind: RoutineKind::Procedure,
1219                grammar: "PL/SQL",
1220                form_method: "procedure_form",
1221                render_method: "render_procedure",
1222                noun: "procedures",
1223            },
1224            |_| Some("   \n".to_owned()),
1225            &mut findings,
1226        );
1227        assert_eq!(findings.len(), 1, "{findings:?}");
1228        assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
1229    }
1230
1231    /// **A `Custom` grammar with no name gives a reader nothing to read.**
1232    #[test]
1233    fn a_custom_grammar_with_no_name_is_blocking() {
1234        let mut findings = Vec::new();
1235        check_custom_grammar(
1236            CustomGrammar {
1237                kind: RoutineKind::ScalarFunction,
1238                grammar: "  ",
1239                form_method: "scalar_function_form",
1240                render_method: "render_scalar_function",
1241                noun: "scalar functions",
1242            },
1243            |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
1244            &mut findings,
1245        );
1246        assert_eq!(findings.len(), 1, "{findings:?}");
1247        assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
1248    }
1249
1250    /// **A stated fact that is empty, and a stated fact that ignores its
1251    /// argument.**
1252    ///
1253    /// Both answers deploy without an error and are wrong in the data: an empty
1254    /// helper body keys every row to nothing, and a file relation that drops
1255    /// its path reads one file for every entity a design binds. Neither is
1256    /// visible anywhere else, which is why the kit grades them.
1257    #[test]
1258    fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
1259        struct Overstated;
1260        impl SqlRenderer for Overstated {
1261            fn render_data_type(&self, dt: &DataType) -> String {
1262                razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1263            }
1264            fn quote_identifier(&self, name: &str) -> String {
1265                format!("\"{name}\"")
1266            }
1267            fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1268                name.to_owned()
1269            }
1270            fn render_current_timestamp(&self) -> String {
1271                "CURRENT_TIMESTAMP".to_owned()
1272            }
1273            fn dialect_name(&self) -> &'static str {
1274                "overstateddb"
1275            }
1276            fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
1277                Some(razeri_plugin_contract::SidHelperBody {
1278                    language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
1279                    body: "   \n  ",
1280                })
1281            }
1282            fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
1283                Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
1284            }
1285        }
1286        impl ExpressionRenderer for Overstated {}
1287        impl DdlRenderer for Overstated {}
1288        impl DmlRenderer for Overstated {}
1289        impl ProcedureRenderer for Overstated {}
1290        impl FormulaRenderer for Overstated {}
1291        impl TaskRenderer for Overstated {}
1292        impl ReplicationRenderer for Overstated {}
1293        impl StorageRenderer for Overstated {}
1294        impl DialectCapabilities for Overstated {}
1295        impl PlatformDialect for Overstated {
1296            fn info(&self) -> DialectInfo {
1297                DialectInfo {
1298                    name: "overstateddb",
1299                    display_name: "Overstated DB",
1300                    aliases: &[],
1301                    dialect_family: DialectFamily::Custom(""),
1302                    family: "Overstated",
1303                    is_cloud_native: false,
1304                    is_olap: false,
1305                    is_postgres_compatible: false,
1306                }
1307            }
1308        }
1309
1310        let report = check(&Overstated);
1311        for (fragment, why) in [
1312            ("supplies an empty one", "a blank helper body"),
1313            ("does not carry the path", "a file relation that drops its path"),
1314        ] {
1315            let found = report
1316                .findings
1317                .iter()
1318                .find(|f| f.what.contains(fragment))
1319                .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
1320            assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
1321        }
1322
1323        // And silence is not a finding. The fully-answered fixture states
1324        // neither fact — the majority answer — and owes nothing for it.
1325        let quiet = check(&HealthyPlugin::new());
1326        for fragment in ["supplies an empty one", "does not carry the path"] {
1327            assert!(
1328                !quiet.findings.iter().any(|f| f.what.contains(fragment)),
1329                "silence is the default and owes nothing:\n{quiet}"
1330            );
1331        }
1332    }
1333
1334    /// **A stated refusal is a claim, and a claim owes the reader words** — the
1335    /// same grading every other refusal gets, because these words are the only
1336    /// thing an operator finds where the load would have been.
1337    #[test]
1338    fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
1339        let mut mute = Vec::new();
1340        check_refusal_copy("the upsert refusal", "no merge", &mut mute);
1341        assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
1342        assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
1343
1344        let silence = check(&BrokenPlugin);
1345        assert!(
1346            silence
1347                .findings
1348                .iter()
1349                .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
1350            "a dialect that never answered is unanswered, not wrong:\n{silence}"
1351        );
1352        assert!(
1353            silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
1354            "the fix line names the way to state a measured refusal:\n{silence}"
1355        );
1356    }
1357
1358    /// The report leads with what to fix and says it in words a reader can
1359    /// act on — no identifiers from Razeri's own record-keeping, and a
1360    /// one-line count before the detail.
1361    #[test]
1362    fn the_report_reads_as_a_summary_then_a_fix_list() {
1363        let text = check(&BrokenPlugin).to_string();
1364        let first = text.lines().next().expect("a summary line");
1365        assert!(first.starts_with("brokendb:"), "{first}");
1366        assert!(first.contains("to fix"), "{first}");
1367        assert!(text.contains("      → "), "every finding names the fix:\n{text}");
1368    }
1369}