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//! A dialect owes Razeri more than the methods its traits require: decisions
8//! whose default is another engine's answer, facts it declares twice that must
9//! agree, the wording of its refusals, the names it answers to. Met one at a
10//! time, each is a separate failing test, found only once it fires. This
11//! module is the report that 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//! ```rust
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    BitwiseOperator, CatalogueReach, ColumnEvolution, Concat, ConstraintEnforcement, DataType,
47    DdlRenderer, DialectCapabilities, DialectFamily, EntityRef, ExpressionRenderer,
48    FormulaRenderer, HeldAs, MergeRestrictions, MergeShape, ProcedureForm, RoutineKind,
49    RoutineSpec, ScalarFunctionForm, SliceRef, SliceReplacement, SqlRenderer, StorageRenderer,
50    TableFunctionForm, TableRole, UNKNOWN_PLATFORM, WrittenObject,
51    data_type::{ArrayDataType, MapDataType, StructDataType, StructField},
52    storage_strategy::{
53        CompressionStrategy, IndexStrategy, PartitionGranularity, PartitionStrategy, StoragePlan,
54    },
55};
56use std::fmt;
57use std::fmt::Write as _;
58
59/// How much a finding matters.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61pub enum Severity {
62    /// **This dialect will produce SQL its engine rejects.** Fix it before
63    /// anything else.
64    Blocking,
65    /// **A question this dialect has not answered.** Razeri has a default and
66    /// will use it; the default was written for a different engine.
67    Unanswered,
68    /// Worth a look. Nothing is broken.
69    Note,
70}
71
72impl fmt::Display for Severity {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.write_str(match self {
75            Self::Blocking => "must fix",
76            Self::Unanswered => "unanswered",
77            Self::Note => "worth a look",
78        })
79    }
80}
81
82/// One thing to fix, in the order a reader wants it: what is wrong, then what
83/// to do about it.
84#[derive(Debug, Clone)]
85pub struct Finding {
86    /// How much it matters.
87    pub severity: Severity,
88    /// One line naming what is wrong.
89    pub what: String,
90    /// One line naming what to do.
91    pub fix: String,
92}
93
94/// The whole report for one dialect.
95#[derive(Debug, Clone)]
96pub struct Report {
97    /// The dialect's registry name.
98    pub dialect: String,
99    /// Everything found, worst first.
100    pub findings: Vec<Finding>,
101    /// **The answers that shape a statement this dialect left to the
102    /// contract**, in the order the bundle's files run — see
103    /// [`taken_as_default`].
104    pub taken_as_default: Vec<TakenAsDefault>,
105}
106
107/// **One answer a dialect left to the contract**: it answers as the
108/// contract's own method body does, so Razeri writes the default's SQL there.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct TakenAsDefault {
111    /// The trait and the method.
112    pub name: &'static str,
113    /// The file it shows up in.
114    pub shows_in: &'static str,
115    /// The contract's answer, which Razeri writes for this dialect.
116    pub answer: String,
117}
118
119impl Report {
120    /// Whether nothing blocking or unanswered was found.
121    ///
122    /// Notes do not fail a report: they are observations, not obligations.
123    #[must_use]
124    pub fn is_clean(&self) -> bool {
125        !self.findings.iter().any(|f| f.severity != Severity::Note)
126    }
127
128    /// How many findings of one severity.
129    #[must_use]
130    pub fn count(&self, severity: Severity) -> usize {
131        self.findings.iter().filter(|f| f.severity == severity).count()
132    }
133}
134
135impl Report {
136    /// **The report's opening and its findings**: what it read and what it
137    /// ran, then each finding, worst first.
138    ///
139    /// The `Display` of the report is this and then the answers left to the
140    /// contract; `razeri plugin conform` prints this and then the same answers
141    /// with how many of the engines Razeri ships write their own.
142    #[must_use]
143    pub fn head(&self) -> String {
144        let mut out = String::new();
145        let name = &self.dialect;
146        let blocking = self.count(Severity::Blocking);
147        let summary = if self.findings.is_empty() {
148            "nothing to fix".to_owned()
149        } else {
150            format!(
151                "{blocking} to fix, {} unanswered, {} worth a look",
152                self.count(Severity::Unanswered),
153                self.count(Severity::Note)
154            )
155        };
156        let _ = writeln!(out, "{name}: {summary}, in the answers this dialect states.");
157        let zero = if blocking == 0 {
158            let phrase = if self.findings.is_empty() { "nothing to fix" } else { "0 to fix" };
159            format!(" \"{phrase}\" means no question is open, not that the SQL runs.")
160        } else {
161            String::new()
162        };
163        let _ = writeln!(
164            out,
165            "This report read the dialect's answers and ran nothing on an engine.{zero} Only the \
166             SQL Razeri renders for it, run on your engine, shows that it runs."
167        );
168        for finding in &self.findings {
169            let _ = writeln!(out);
170            let _ = writeln!(out, "  [{}] {}", finding.severity, finding.what);
171            let _ = writeln!(out, "      → {}", finding.fix);
172        }
173        out
174    }
175}
176
177impl fmt::Display for Report {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        f.write_str(&self.head())?;
180        writeln!(f)?;
181        if self.taken_as_default.is_empty() {
182            return writeln!(
183                f,
184                "This dialect answers every question Razeri asks here about the shape of a \
185                 statement differently from the contract's default."
186            );
187        }
188        let count = self.taken_as_default.len();
189        writeln!(
190            f,
191            "Taken as the contract's default: this dialect answers {count} {} as the contract's \
192             default does, so Razeri writes the default's SQL, shown beside each. Confirm each \
193             on your engine.",
194            if count == 1 { "question" } else { "questions" }
195        )?;
196        for taken in &self.taken_as_default {
197            writeln!(f)?;
198            writeln!(f, "  {} → {}", taken.name, taken.answer)?;
199            writeln!(f, "      in {}", taken.shows_in)?;
200        }
201        Ok(())
202    }
203}
204
205/// **The four traits a statement's shape is asked of**, so one answer can be
206/// read off a dialect and off the contract's own defaults alike.
207#[derive(Clone, Copy)]
208enum Asked {
209    Sql(fn(&dyn SqlRenderer) -> String),
210    Expression(fn(&dyn ExpressionRenderer) -> String),
211    Ddl(fn(&dyn DdlRenderer) -> String),
212    Capabilities(fn(&dyn DialectCapabilities) -> String),
213}
214
215/// One answer a dialect gives that decides the shape of a statement Razeri
216/// writes, and the file of a bundle it shows up in.
217#[derive(Clone, Copy)]
218struct Seam {
219    name: &'static str,
220    shows_in: &'static str,
221    asked: Asked,
222}
223
224impl Seam {
225    /// The dialect's answer, as text two answers are compared by.
226    fn answer_of(&self, dialect: &dyn PlatformDialect) -> String {
227        match self.asked {
228            Asked::Sql(answer) => answer(dialect),
229            Asked::Expression(answer) => answer(dialect),
230            Asked::Ddl(answer) => answer(dialect),
231            Asked::Capabilities(answer) => answer(dialect),
232        }
233    }
234
235    /// The contract's own answer, spelled with the dialect's quoting.
236    fn default_of(&self, defaults: &ContractDefaults<'_>) -> String {
237        match self.asked {
238            Asked::Sql(answer) => answer(defaults),
239            Asked::Expression(answer) => answer(defaults),
240            Asked::Ddl(answer) => answer(defaults),
241            Asked::Capabilities(answer) => answer(defaults),
242        }
243    }
244}
245
246/// **Every answer that shapes a statement Razeri writes into a bundle**, in
247/// the order the bundle's files run.
248///
249/// Each is read by the render, so an answer written here changes what the
250/// engine is given; and each is a statement an engine has refused when a
251/// dialect left it to the contract. Answers this report grades elsewhere
252/// (the routine forms, the key, the merge) are not repeated.
253fn statement_seams() -> &'static [Seam] {
254    SEAMS
255}
256
257const SCHEMA: &str = "schema.sql";
258const LOADS: &str = "loads.sql";
259const RECONCILE: &str = "reconcile.sql";
260const EVERY: &str = "every file";
261const fn seam(name: &'static str, shows_in: &'static str, asked: Asked) -> Seam {
262    Seam { name, shows_in, asked }
263}
264const SEAMS: &[Seam] = &[
265    seam(
266        "DdlRenderer::supports_if_not_exists",
267        SCHEMA,
268        Asked::Ddl(|d| said(d.supports_if_not_exists())),
269    ),
270    seam(
271        "DdlRenderer::supports_primary_key_constraint",
272        SCHEMA,
273        Asked::Ddl(|d| said(d.supports_primary_key_constraint())),
274    ),
275    seam(
276        "DdlRenderer::supports_foreign_key_constraint",
277        SCHEMA,
278        Asked::Ddl(|d| said(d.supports_foreign_key_constraint())),
279    ),
280    seam(
281        "DdlRenderer::render_create_view_prefix",
282        SCHEMA,
283        Asked::Ddl(|d| quoted(d.render_create_view_prefix())),
284    ),
285    seam(
286        "DdlRenderer::render_drop_view_prefix",
287        SCHEMA,
288        Asked::Ddl(|d| quoted(&d.render_drop_view_prefix(true))),
289    ),
290    seam(
291        "DdlRenderer::render_drop_table_prefix",
292        SCHEMA,
293        Asked::Ddl(|d| quoted(&d.render_drop_table_prefix(true))),
294    ),
295    seam(
296        "DdlRenderer::render_drop_function",
297        SCHEMA,
298        Asked::Ddl(|d| quoted(&d.render_drop_function("f", &["BIGINT"], true))),
299    ),
300    seam(
301        "DdlRenderer::supports_drop_function_arg_types",
302        SCHEMA,
303        Asked::Ddl(|d| said(d.supports_drop_function_arg_types())),
304    ),
305    seam(
306        "DdlRenderer::render_create_schema_statement",
307        SCHEMA,
308        Asked::Ddl(|d| quoted(&d.render_create_schema_statement("s", true))),
309    ),
310    seam(
311        "DialectCapabilities::supports_schemas",
312        SCHEMA,
313        Asked::Capabilities(|d| said(d.supports_schemas())),
314    ),
315    seam(
316        "SqlRenderer::uses_schema_in_qualified_names",
317        SCHEMA,
318        Asked::Sql(|d| said(d.uses_schema_in_qualified_names())),
319    ),
320    seam(
321        "DdlRenderer::supports_ddl_transactions",
322        SCHEMA,
323        Asked::Ddl(|d| said(d.supports_ddl_transactions())),
324    ),
325    seam(
326        "DdlRenderer::render_begin_transaction",
327        SCHEMA,
328        Asked::Ddl(|d| quoted(d.render_begin_transaction())),
329    ),
330    seam("DdlRenderer::render_commit", SCHEMA, Asked::Ddl(|d| quoted(d.render_commit()))),
331    seam(
332        "SqlRenderer::scalar_function_body_is_expression",
333        SCHEMA,
334        Asked::Sql(|d| said(d.scalar_function_body_is_expression())),
335    ),
336    seam(
337        "DialectCapabilities::max_identifier_length",
338        EVERY,
339        Asked::Capabilities(|d| said(d.max_identifier_length())),
340    ),
341    seam(
342        "DialectCapabilities::begin_transaction_keyword",
343        LOADS,
344        Asked::Capabilities(|d| quoted(d.begin_transaction_keyword())),
345    ),
346    seam(
347        "SqlRenderer::merge_source_requires_inlined_ctes",
348        LOADS,
349        Asked::Sql(|d| said(d.merge_source_requires_inlined_ctes())),
350    ),
351    seam(
352        "SqlRenderer::insert_accepts_leading_ctes",
353        LOADS,
354        Asked::Sql(|d| said(d.insert_accepts_leading_ctes())),
355    ),
356    seam(
357        "SqlRenderer::render_empty_table",
358        LOADS,
359        Asked::Sql(|d| quoted(&d.render_empty_table("t"))),
360    ),
361    seam(
362        "SqlRenderer::render_procedure_variable_reference",
363        LOADS,
364        Asked::Sql(|d| quoted(&d.render_procedure_variable_reference("v"))),
365    ),
366    seam(
367        "ExpressionRenderer::render_concat",
368        LOADS,
369        Asked::Expression(|d| quoted(&d.render_concat(&["a".into(), "b".into()]))),
370    ),
371    seam(
372        "SqlRenderer::statement_separator",
373        LOADS,
374        Asked::Sql(|d| quoted(d.statement_separator())),
375    ),
376    seam(
377        "SqlRenderer::render_one_row_source",
378        RECONCILE,
379        Asked::Sql(|d| match d.render_one_row_source() {
380            Some(source) => quoted(&source),
381            None => "none: a `SELECT` with no `FROM`".to_owned(),
382        }),
383    ),
384];
385
386/// A yes, a no or a number, as the report prints it.
387fn said(answer: impl fmt::Display) -> String {
388    format!("`{answer}`")
389}
390
391/// A piece of SQL, as the report prints it, its line breaks shown.
392fn quoted(sql: &str) -> String {
393    format!("`{}`", sql.replace('\n', "\\n"))
394}
395
396/// **The answers `dialect` left to the contract**, in the order the bundle's
397/// files run.
398///
399/// A dialect "takes the default" when its answer is the one the contract's
400/// own method body gives. That is read by asking both: the dialect, and a
401/// stand-in that writes only what the contract requires — the column types,
402/// the quoting, the qualified name and the current timestamp, borrowed from
403/// the dialect — and answers every other question with the contract's
404/// default. A dialect that wrote the default's answer out by hand reads the
405/// same, and is listed to confirm, not as wrong.
406#[must_use]
407pub fn taken_as_default(dialect: &dyn PlatformDialect) -> Vec<TakenAsDefault> {
408    let defaults = ContractDefaults { graded: dialect };
409    statement_seams()
410        .iter()
411        .filter_map(|seam| {
412            let default = seam.default_of(&defaults);
413            (seam.answer_of(dialect) == default).then_some(TakenAsDefault {
414                name: seam.name,
415                shows_in: seam.shows_in,
416                answer: default,
417            })
418        })
419        .collect()
420}
421
422/// A dialect that writes only what the contract requires, and borrows those
423/// four answers from the dialect being graded: every other answer is the
424/// contract's own default, spelled with the graded dialect's quoting.
425struct ContractDefaults<'a> {
426    graded: &'a dyn PlatformDialect,
427}
428
429impl SqlRenderer for ContractDefaults<'_> {
430    fn render_data_type(&self, dt: &DataType) -> String {
431        self.graded.render_data_type(dt)
432    }
433
434    fn quote_identifier(&self, name: &str) -> String {
435        self.graded.quote_identifier(name)
436    }
437
438    fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
439        self.graded.qualify_name(schema, name)
440    }
441
442    fn render_current_timestamp(&self) -> String {
443        self.graded.render_current_timestamp()
444    }
445}
446impl ExpressionRenderer for ContractDefaults<'_> {}
447impl DdlRenderer for ContractDefaults<'_> {}
448impl DialectCapabilities for ContractDefaults<'_> {}
449
450/// **The report.** Everything about this dialect that Razeri can check by
451/// asking it.
452///
453/// Registration is checked against the dialects linked into this program. A
454/// dialect the host loaded at run time is not among them, so report it with
455/// [`check_with_registry`] and the registry it was loaded into.
456#[must_use]
457pub fn check(dialect: &dyn PlatformDialect) -> Report {
458    check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
459}
460
461/// The same report, against a registry the host built — the path for a dialect
462/// loaded at run time rather than linked in.
463#[must_use]
464pub fn check_with_registry(
465    dialect: &dyn PlatformDialect,
466    registry: &crate::dialect::PlatformRegistry,
467) -> Report {
468    let info = dialect.info();
469    let mut findings = Vec::new();
470
471    check_registration(&info, registry, &mut findings);
472    check_family(info.dialect_family, &mut findings);
473    check_required_rendering(dialect, &mut findings);
474    check_decisions(dialect, &mut findings);
475    check_formula_renderer(dialect, &mut findings);
476    check_evolution(dialect, &mut findings);
477    check_run_again(dialect, &mut findings);
478    check_transactions(dialect, &mut findings);
479    check_paired_facts(dialect, &mut findings);
480    check_schema_fold(dialect, &mut findings);
481    check_deploy_client(&info, &mut findings);
482
483    findings.sort_by_key(|f| f.severity);
484    Report { dialect: info.name.to_owned(), findings, taken_as_default: taken_as_default(dialect) }
485}
486
487/// Every spelling this dialect answers to has to reach this dialect: its own
488/// name, and each alias it declares.
489///
490/// That is the whole obligation, and it is one the author can meet from their
491/// own crate — the spellings are the dialect's own, so there is no list inside
492/// Razeri to be missing from and nothing here can ask for a change only Razeri
493/// could make. What is worth saying is a spelling that reaches somewhere else:
494/// one another loaded platform also claims (so it names neither, and Razeri
495/// refuses it), or one that reaches a different platform because the registry
496/// holds this dialect under another key.
497fn check_registration(
498    info: &crate::dialect::DialectInfo,
499    registry: &crate::dialect::PlatformRegistry,
500    findings: &mut Vec<Finding>,
501) {
502    let name = info.name;
503    if matches!(
504        crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
505        Err(crate::ResolveError::UnknownDialect { .. })
506    ) {
507        findings.push(Finding {
508            severity: Severity::Blocking,
509            what: format!(
510                "the name `{name}` does not resolve — nothing in this program registered it"
511            ),
512            fix: "add `razeri_plugin_api::export_plugin!(register);` at the top of your \
513                  crate's `lib.rs`, where `register` calls \
514                  `builder.register_dialect(Arc::new(MyDialect))`. The one line makes the \
515                  dialect one the registry finds, whether razeri loads your library from a \
516                  plugin directory or links your crate in."
517                .to_owned(),
518        });
519        return;
520    }
521    for spelling in info.spellings() {
522        match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
523            Ok(found) if found.info().name == name => {}
524            Ok(found) => findings.push(Finding {
525                severity: Severity::Blocking,
526                what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
527                fix: format!(
528                    "the registry holds `{name}` under another key. Register it under its own \
529                     name, or drop `{spelling}` from its aliases."
530                ),
531            }),
532            Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
533                severity: Severity::Blocking,
534                what: format!(
535                    "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
536                     none of them — Razeri refuses that spelling rather than guessing which one \
537                     a design meant",
538                    claimed_by.join(" and ")
539                ),
540                fix: format!(
541                    "give this dialect a name no other platform answers to. A platform name is \
542                     read off the loaded dialects' own declared spellings, so a spelling another \
543                     platform already claims is never `{name}`'s."
544                ),
545            }),
546            Err(_) => findings.push(Finding {
547                severity: Severity::Blocking,
548                what: format!(
549                    "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
550                ),
551                fix: format!(
552                    "the `{name}` in the registry is a different build of this dialect and does \
553                     not declare `{spelling}`. Register the one you are grading."
554                ),
555            }),
556        }
557    }
558}
559
560/// A family is a claim about spelling, so an unmeasured engine claiming a
561/// measured family's name licenses SQL it may reject.
562fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
563    if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
564        findings.push(Finding {
565            severity: Severity::Blocking,
566            what: "this dialect's family is a custom one with no name".to_owned(),
567            fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
568                  prints and what a reader looks up."
569                .to_owned(),
570        });
571    }
572}
573
574/// The four required methods must answer for every type a design can declare.
575fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
576    let declarable = declarable_types();
577    let empty_column: Vec<String> = declarable
578        .iter()
579        .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
580        .map(|dt| format!("{dt:?}"))
581        .collect();
582    if !empty_column.is_empty() {
583        findings.push(Finding {
584            severity: Severity::Blocking,
585            what: format!(
586                "`render_data_type` answers nothing for {} of the types a design can declare",
587                empty_column.len()
588            ),
589            fix: "every declarable type needs a column spelling on this engine, even if the \
590                  spelling is a wider type that stores it. A blank answer becomes a column \
591                  declaration with no type in it."
592                .to_owned(),
593        });
594    }
595    let empty_cast: Vec<String> = declarable
596        .iter()
597        .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
598        .map(|dt| format!("{dt:?}"))
599        .collect();
600    if !empty_cast.is_empty() {
601        findings.push(Finding {
602            severity: Severity::Blocking,
603            what: format!(
604                "`render_cast_type` answers nothing for {} declarable types",
605                empty_cast.len()
606            ),
607            fix: "the generated load opens by casting every source column to its declared type, \
608                  so a blank answer fails the load on its first statement."
609                .to_owned(),
610        });
611    }
612    let quoted = SqlRenderer::quote_identifier(dialect, "order");
613    if quoted == "order" {
614        findings.push(Finding {
615            severity: Severity::Blocking,
616            what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
617                   reaches this engine bare"
618                .to_owned(),
619            fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
620                  backticks, or brackets."
621                .to_owned(),
622        });
623    }
624    if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
625        findings.push(Finding {
626            severity: Severity::Blocking,
627            what: "`render_current_timestamp` answers nothing, and every generated load stamps \
628                   a row with it"
629                .to_owned(),
630            fix: "return this engine's own expression for the current moment.".to_owned(),
631        });
632    }
633}
634
635/// **Reading a file in a named encoding**, where the dialect states a way to.
636///
637/// The same path question [`check_decisions`] asks of the plain relation, for
638/// the same reason, plus one only this method can get wrong: an engine that
639/// reads no file at all cannot read one in a named encoding, and a dialect that
640/// answers here and not there states a relation nothing will ever ask it for.
641///
642/// Parameters:
643/// - `dialect`: the plugin under test.
644/// - `probe`: the same quoted path literal the plain relation was asked about.
645/// - `findings`: where anything wrong is recorded.
646fn check_encoded_file_relation(
647    dialect: &dyn PlatformDialect,
648    probe: &str,
649    findings: &mut Vec<Finding>,
650) {
651    let Some(relation) =
652        SqlRenderer::delimited_file_relation_in_encoding(dialect, probe, "windows-1252")
653    else {
654        return;
655    };
656    if !relation.contains(probe) {
657        findings.push(Finding {
658            severity: Severity::Blocking,
659            what: "`delimited_file_relation_in_encoding` returns a relation that does not carry \
660                   the path it was given"
661                .to_owned(),
662            fix: "put the literal you were handed where your engine's file relation takes a \
663                  path. It arrives already quoted and escaped."
664                .to_owned(),
665        });
666    }
667    if SqlRenderer::delimited_file_relation(dialect, probe).is_none() {
668        findings.push(Finding {
669            severity: Severity::Blocking,
670            what: "this dialect states how to read a file in a named encoding and states no way \
671                   to read a file at all"
672                .to_owned(),
673            fix: "answer `delimited_file_relation` as well, or answer neither. Razeri asks the \
674                  first question before the second — a source whose bytes are UTF-8 never \
675                  reaches the encoding arm — so an engine that answers only the second has \
676                  written a relation nothing will ask it for."
677                .to_owned(),
678        });
679    }
680}
681
682/// The decisions a dialect states rather than inherits.
683fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
684    let spelling = SqlRenderer::sid_spelling(dialect);
685    if spelling.platform == UNKNOWN_PLATFORM {
686        findings.push(Finding {
687            severity: Severity::Blocking,
688            what: "this dialect states no surrogate-key spelling, so every key it renders calls \
689                   an `xxhash64` its engine very likely does not have"
690                .to_owned(),
691            fix: "override `SqlRenderer::sid_spelling`. Say how this engine writes a \
692                  replacement and a Unicode literal, how it joins strings, how it casts to text, \
693                  and how it spells a 64-bit hash. If it has no portable hash, name an artifact your prerequisites install \
694                  — never the nearest-looking built-in, which keys a warehouse to a number \
695                  nothing else can reproduce."
696                .to_owned(),
697        });
698    }
699
700    // A join with no name in it writes `(a, '|', b)` or `a  '|'  b`: a
701    // composite key the engine cannot parse, met at the first load that
702    // joins two parts.
703    let join = match spelling.concat {
704        Concat::Function(name) | Concat::FunctionBinary(name) | Concat::Operator(name) => name,
705        Concat::Plus => "+",
706    };
707    if join.trim().is_empty() {
708        findings.push(Finding {
709            severity: Severity::Blocking,
710            what: "this dialect's surrogate-key spelling joins the parts of a composite key \
711                   with a function or operator that has no name"
712                .to_owned(),
713            fix: "name what your engine joins strings with in `SidSpelling::concat`: \
714                  `Concat::Function(\"CONCAT\")`, `Concat::FunctionBinary(\"CONCAT\")` where it \
715                  takes exactly two arguments, or `Concat::Operator(\"||\")` where it has an \
716                  operator and no function."
717                .to_owned(),
718        });
719    }
720
721    // **A stated body that says nothing is worse than no body at all.** Razeri
722    // writes the `CREATE FUNCTION` envelope around whatever comes back, so an
723    // empty one deploys cleanly and returns nothing — every key in the
724    // warehouse is then the sentinel or garbage, under a green deploy.
725    if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
726        && supplied.body.trim().is_empty()
727    {
728        findings.push(Finding {
729            severity: Severity::Blocking,
730            what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
731                   and then supplies an empty one"
732                .to_owned(),
733            fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
734                  one argument, or answer `None` and let Razeri compose the derivation from \
735                  your `sid_spelling`. An empty body deploys without error and keys every row \
736                  to nothing."
737                .to_owned(),
738        });
739    }
740
741    // A file relation that drops its path reads the same file for every entity
742    // — one table filled, the rest filled wrong, and nothing about it fails.
743    let probe = "'/razeri/probe.csv'";
744    if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
745        && !relation.contains(probe)
746    {
747        findings.push(Finding {
748            severity: Severity::Blocking,
749            what: "`delimited_file_relation` returns a relation that does not carry the path it \
750                   was given"
751                .to_owned(),
752            fix: "put the literal you were handed where your engine's file relation takes a \
753                  path. It arrives already quoted and escaped. A relation that drops it reads \
754                  one file for every entity the design binds, and the generated fill loads the \
755                  wrong rows without failing."
756                .to_owned(),
757        });
758    }
759
760    check_encoded_file_relation(dialect, probe, findings);
761    check_routine_forms(dialect, findings);
762    check_slice_replacement(dialect, findings);
763
764    // The note is what a reader of the DDL learns from, and on an engine with
765    // no keyword of its own it is the ONLY place they can learn it.
766    if let ConstraintEnforcement::DeclaredOnly(declared) =
767        DdlRenderer::constraint_enforcement(dialect)
768        && declared.note.split_whitespace().count() < 5
769    {
770        findings.push(Finding {
771            severity: Severity::Blocking,
772            what: "this dialect says its engine accepts constraints it does not enforce, and \
773                   the note explaining that says almost nothing"
774                .to_owned(),
775            fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
776                  guarantee. Say what this engine really does with the declaration, and where \
777                  the guarantee has to come from instead."
778                .to_owned(),
779        });
780    }
781
782    // **The two refusals are different findings, and that is the point of the
783    // second variant.** `None` is silence, and silence is unanswered. A stated
784    // `Unsupported` is a measured claim; what is left to grade there is whether
785    // the sentence is worth reading, because it is the only thing an operator
786    // gets where the load would have been.
787    match DialectCapabilities::merge_shape(dialect) {
788        MergeShape::None => findings.push(Finding {
789            severity: Severity::Unanswered,
790            what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
791                   load for it"
792                .to_owned(),
793            fix: "if this engine has a MERGE or an upsert statement, say which in \
794                  `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
795                  `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
796                  operator can act on, rather than a silence."
797                .to_owned(),
798        }),
799        MergeShape::Unsupported(orientation) => {
800            check_refusal_copy("the upsert refusal", orientation, findings);
801        }
802        MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
803        _ => {}
804    }
805}
806
807/// **A dialect that names a verb for replacing one slice has to put the slice
808/// in it.**
809///
810/// Most dialects answer nothing here, and nothing is a complete answer: Razeri
811/// then writes a delete filtered to the slice, which is correct on every
812/// engine. What is worth grading is the dialect that *does* answer, because its
813/// statement runs in front of a load's write and clears rows. A statement that
814/// forgot the value it was handed does not clear the day this call was given —
815/// it clears some other day, or every day, and the load then reports success
816/// over a table it emptied.
817///
818/// The probe is a daily partition of a date column, which is the case every
819/// partition-level verb is allowed to answer for. A dialect that answers
820/// nothing to it has said the portable form is what it wants.
821fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
822    const VALUE: &str = "$razeri_slice_probe";
823    let plan = StoragePlan {
824        partition: PartitionStrategy::RangeByTimeDimension {
825            field: "razeri_probe_day".to_owned(),
826            granularity: PartitionGranularity::Day,
827            retention: None,
828            look_ahead: None,
829            window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
830        },
831        compression: CompressionStrategy::None,
832        index: IndexStrategy::None,
833    };
834    let day = DataType::date();
835    let columns = ["razeri_probe_day"];
836    let entity = EntityRef {
837        schema: Some("razeri_probe"),
838        name: "razeri_probe_table",
839        role: TableRole::Written,
840        columns: &columns,
841        partition_column_type: Some(&day),
842    };
843    let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
844    let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
845    else {
846        return;
847    };
848
849    let carries = match &answer {
850        SliceReplacement::Emptied(statements) => {
851            !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
852        }
853        SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
854    };
855    // **A verb that carries the slice is graded nowhere**, deliberately. The
856    // kit cannot tell a measured answer from a lucky one, and a note on every
857    // dialect that got it right would fire on exactly the dialects that need no
858    // help. What is left to say is said where it is useful — in the trait's own
859    // documentation, which an author reads before writing the override.
860    if carries {
861        return;
862    }
863    findings.push(Finding {
864        severity: Severity::Blocking,
865        what: "`render_slice_replacement` returns a statement that does not carry the slice it \
866               was handed"
867            .to_owned(),
868        fix: "put the value you were given where your statement names the slice, and build any \
869              partition NAME from it the same way your extend procedure built it. A replacement \
870              that drops the value clears a slice this call was not given — or every slice — \
871              and the load reports success over what it emptied."
872            .to_owned(),
873    });
874}
875
876/// **The two halves of an evolution, held together**: what this engine says it
877/// keeps when a loaded table changes shape, and the statement it writes for the
878/// change.
879///
880/// An answer left at its default is graded nowhere, for the reason
881/// [`check_merge_restrictions`] states: from outside, a measured *"my engine
882/// does not do that"* and a question nobody asked read the same, and a finding
883/// made out of that silence would fire on every dialect.
884///
885/// What is worth saying is a **pair that disagrees**. A dialect that says its
886/// engine widens a string in place and then writes no statement for a widening
887/// has told Razeri to call the change safe and left the operator to work out
888/// the `ALTER` alone — a gap that reads to them as Razeri not having finished.
889/// And a dialect that answers `None` to a new column has declined the one
890/// evolution every warehouse has, which is nearly always an override that meant
891/// to narrow something and narrowed everything.
892fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
893    let answers = DialectCapabilities::evolution_answers(dialect);
894    let boolean = DataType::boolean();
895    let long = DataType::varchar(40);
896
897    if DdlRenderer::render_column_evolution(
898        dialect,
899        &ColumnEvolution::AddColumn {
900            schema: Some("s"),
901            table: "t",
902            column: "c",
903            data_type: &boolean,
904        },
905    )
906    .is_none()
907    {
908        findings.push(Finding {
909            severity: Severity::Blocking,
910            what: "this dialect writes no statement for a column that is new, which is the \
911                  commonest change a deployed warehouse ever sees"
912                .to_owned(),
913            fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the \
914                 override and take the contract's `ALTER TABLE … ADD COLUMN …`, which most \
915                 engines take; some write `ADD` without `COLUMN`, so run it on yours first. An \
916                 engine that genuinely has no way to add a column is worth saying out loud in \
917                 the crate's own documentation."
918                .to_owned(),
919        });
920    }
921
922    if answers.parameterized_string_widening_is_lossless
923        && DdlRenderer::render_column_evolution(
924            dialect,
925            &ColumnEvolution::WidenColumn {
926                schema: Some("s"),
927                table: "t",
928                column: "c",
929                data_type: &long,
930            },
931        )
932        .is_none()
933    {
934        findings.push(Finding {
935            severity: Severity::Note,
936            what: "this dialect says its engine widens a string's declared length in place, and \
937                  writes no statement for the change"
938                .to_owned(),
939            fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this \
940                 engine's spelling of it. Without it Razeri tells an operator the widening \
941                 costs nothing and then hands them nothing to run, which reads as Razeri not \
942                 having finished rather than as a statement they own."
943                .to_owned(),
944        });
945    }
946
947    if answers.nested_field_addition_is_lossless
948        && DdlRenderer::render_column_evolution(
949            dialect,
950            &ColumnEvolution::AddNestedField {
951                schema: Some("s"),
952                table: "t",
953                column: "c",
954                field: "f",
955                data_type: &long,
956            },
957        )
958        .is_none()
959    {
960        findings.push(Finding {
961            severity: Severity::Note,
962            what: "this dialect says its engine keeps every value when a record gains a field, \
963                  and writes no statement for the change"
964                .to_owned(),
965            fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some \
966                 engines name the new field; some restate the whole column's type, which is \
967                 why the change carries both the field's path and the column's type with the \
968                 field in it."
969                .to_owned(),
970        });
971    }
972}
973
974/// **The statements a redeploy and a file of drops run again**, each carrying
975/// the object it was handed.
976///
977/// A drop that forgot the column it was asked about drops another one, or
978/// none, under a green run; a guard around a `CREATE TABLE` that left the
979/// statement out makes nothing, and the next file meets a table that is not
980/// there. Each answer is asked about a table of the dialect's own spelling and
981/// read for the names it was given.
982fn check_run_again(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
983    let schema = SqlRenderer::quote_identifier(dialect, "razeri_probe_schema");
984    let name = SqlRenderer::quote_identifier(dialect, "razeri_probe_table");
985    let relation = format!("{schema}.{name}");
986    let table = WrittenObject::new(Some(&schema), &name, &relation);
987    let column = SqlRenderer::quote_identifier(dialect, "razeri_probe_column");
988    let mut lost: Vec<&str> = Vec::new();
989
990    match DdlRenderer::render_drop_column(dialect, table, &column, false) {
991        Some(drop) if drop.text().contains(&column) => {}
992        _ => lost.push("render_drop_column"),
993    }
994    if let Some(guarded) = DdlRenderer::render_drop_column(dialect, table, &column, true)
995        && !guarded.text().contains(&column)
996    {
997        lost.push("render_drop_column (only where it stands)");
998    }
999    for only_where_it_stands in [false, true] {
1000        if let Some(drop) = DdlRenderer::render_drop_view(dialect, table, only_where_it_stands)
1001            && !drop.text().contains(&name)
1002        {
1003            lost.push("render_drop_view");
1004        }
1005    }
1006    let create = format!("CREATE TABLE {relation} (a INTEGER)");
1007    if let Some(guarded) = DdlRenderer::render_create_table_if_absent(dialect, table, &create)
1008        && !guarded.text().contains(&create)
1009    {
1010        lost.push("render_create_table_if_absent");
1011    }
1012    lost.dedup();
1013    if !lost.is_empty() {
1014        findings.push(Finding {
1015            severity: Severity::Blocking,
1016            what: format!(
1017                "{} returns a statement that does not carry the object it was handed",
1018                lost.join(", ")
1019            ),
1020            fix: "put the names you were given where your statement names the object, and keep \
1021                  a create you guard whole inside the guard. A drop that loses its column drops \
1022                  another one or none, and a guard that loses its statement makes nothing — \
1023                  both under a run that reports success."
1024                .to_owned(),
1025        });
1026    }
1027
1028    // A comparison that loses the value it stands for compares something
1029    // else, and the load's search for a row that arrives twice finds none.
1030    let value = "razeri_probe_value";
1031    let loses_its_value = declarable_types().iter().any(|data_type| {
1032        [HeldAs::Declared, HeldAs::Converted].into_iter().any(|held| {
1033            ExpressionRenderer::render_comparable(dialect, value, data_type, held)
1034                .is_some_and(|compared| !compared.contains(value))
1035        })
1036    });
1037    if loses_its_value {
1038        findings.push(Finding {
1039            severity: Severity::Blocking,
1040            what: "`render_comparable` answers an expression that does not carry the value it \
1041                   stands for"
1042                .to_owned(),
1043            fix: "answer the value itself where your engine compares the type directly, an \
1044                  expression over it where it compares one only that way, and `None` where it \
1045                  cannot compare one at all — then Razeri refuses such a column by name."
1046                .to_owned(),
1047        });
1048    }
1049
1050    // A text ceiling of nothing refuses every varchar, and a blank word for
1051    // what a text cannot be says nothing beside the type.
1052    if let Some(limits) = DialectCapabilities::text_limits(dialect)
1053        && (limits.longest_varchar == Some(0)
1054            || limits.text_is_not.is_some_and(|what| what.trim().is_empty()))
1055    {
1056        findings.push(Finding {
1057            severity: Severity::Blocking,
1058            what: "`text_limits` states a longest varchar of 0, or a blank word for what an \
1059                   unbounded text cannot be"
1060                .to_owned(),
1061            fix: "state the longest varchar(n) your engine creates — one more is refused at \
1062                  CREATE TABLE — and name what it refuses a text column as, or leave it None."
1063                .to_owned(),
1064        });
1065    }
1066
1067    match DialectCapabilities::catalogue_reach(dialect) {
1068        CatalogueReach::HeldColumnsOnly(reason) | CatalogueReach::Nothing(reason) => {
1069            check_refusal_copy("the reason the schema check is not asked", reason, findings);
1070        }
1071        _ => {}
1072    }
1073}
1074
1075/// **A begin statement that leaves the commit to the client**, said by an
1076/// engine that has no transaction spanning statements, is two answers that
1077/// cannot both be true.
1078fn check_transactions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1079    // A log line answered with no text writes a blank statement into every
1080    // routine that logs; `None` is how an engine with no log statement says so.
1081    if SqlRenderer::render_procedure_log(
1082        dialect,
1083        razeri_plugin_contract::ProcedureLogLevel::Info,
1084        "'razeri'",
1085    )
1086    .is_some_and(|line| line.trim().is_empty())
1087    {
1088        findings.push(Finding {
1089            severity: Severity::Blocking,
1090            what: "`render_procedure_log` answers a log line with no text in it".to_owned(),
1091            fix: "answer `None` where your engine has no statement that writes a message: the \
1092                  line is then left out of the routine."
1093                .to_owned(),
1094        });
1095    }
1096    if DialectCapabilities::begin_leaves_the_commit_to_the_client(dialect)
1097        && !DialectCapabilities::supports_multi_statement_transactions(dialect)
1098    {
1099        findings.push(Finding {
1100            severity: Severity::Blocking,
1101            what: "this dialect says its begin statement leaves the commit to the client, and \
1102                   that its engine has no transaction spanning statements"
1103                .to_owned(),
1104            fix: "answer `begin_leaves_the_commit_to_the_client` only for an engine whose loads \
1105                  run in one transaction. Where there is none, the bundle already tells the \
1106                  operator the boundary is theirs."
1107                .to_owned(),
1108        });
1109    }
1110}
1111
1112/// **What a stated `MERGE` restriction changes about the SQL Razeri writes for
1113/// this engine**, said back to the author while the claim is still cheap to
1114/// withdraw.
1115///
1116/// A restriction left at its default costs nothing and is graded nowhere: the
1117/// kit cannot tell a measured *"my engine is fine with this"* from a question
1118/// nobody asked, and inventing a finding out of that silence would fire on
1119/// every dialect. What it can do is report the restriction a dialect does
1120/// state, because each one makes Razeri emit something other than the
1121/// statement it emits everywhere else.
1122fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
1123    if restrictions.slice_pruning_needs_literal {
1124        findings.push(Finding {
1125            severity: Severity::Note,
1126            what: "this dialect says its planner reads one slice of a partitioned table only \
1127                   from a literal, so a load scoped to one day cannot simply bind the day"
1128                .to_owned(),
1129            fix: "Razeri will put the day into the statement text through this engine's \
1130                  dynamic-execution form rather than binding it to the load procedure's \
1131                  parameter. Confirm that is what your engine wants: the statement is composed \
1132                  once per call and its text differs per day, which some planners cache and \
1133                  some do not. If a bound parameter does prune here, drop the restriction and \
1134                  Razeri binds it like everywhere else."
1135                .to_owned(),
1136        });
1137    }
1138}
1139
1140/// The one override nothing tells an author about, and every formula in every
1141/// design goes through it — and the operators a formula is written with.
1142fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1143    // An operator answered with no text writes `a  b`, which reads as two
1144    // values side by side. `None` is the answer for an operator the engine
1145    // lacks, and it is refused by name where a formula needs it.
1146    let bitwise = BitwiseOperator::ALL.map(|operator| {
1147        (
1148            format!("render_bitwise_operator({operator:?})"),
1149            FormulaRenderer::render_bitwise_operator(dialect, operator),
1150        )
1151    });
1152    let blank: Vec<String> = [
1153        ("render_power_operator".to_owned(), FormulaRenderer::render_power_operator(dialect)),
1154        ("render_ilike_operator".to_owned(), FormulaRenderer::render_ilike_operator(dialect)),
1155        (
1156            "render_not_ilike_operator".to_owned(),
1157            FormulaRenderer::render_not_ilike_operator(dialect),
1158        ),
1159        ("render_concat_operator".to_owned(), FormulaRenderer::render_concat_operator(dialect)),
1160    ]
1161    .into_iter()
1162    .chain(bitwise)
1163    .filter(|(_, answer)| answer.as_deref().is_some_and(|op| op.trim().is_empty()))
1164    .map(|(method, _)| method)
1165    .collect();
1166    if !blank.is_empty() {
1167        findings.push(Finding {
1168            severity: Severity::Blocking,
1169            what: format!("{} answers an operator with no text in it", blank.join(", ")),
1170            fix: "answer `None` where your engine has no such operator: a formula that needs it \
1171                  is then refused by name, and the reader is told to write the function your \
1172                  engine has. An empty answer writes the two operands side by side."
1173                .to_owned(),
1174        });
1175    }
1176
1177    let formula = SqlRenderer::to_formula_renderer(dialect);
1178    let dialect_name = SqlRenderer::dialect_name(dialect);
1179    if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
1180        findings.push(Finding {
1181            severity: Severity::Blocking,
1182            what: "`to_formula_renderer` was not overridden, so every formula in every design \
1183                   renders through Razeri's ANSI defaults instead of this dialect"
1184                .to_owned(),
1185            fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
1186                  formula this engine sees is silently somebody else's."
1187                .to_owned(),
1188        });
1189    }
1190}
1191
1192/// **The three routine grammars a dialect states**, and what each answer owes.
1193///
1194/// A grammar Razeri carries needs nothing further. A refusal owes words a
1195/// reader can act on. `Custom` owes the render method it promised — see
1196/// [`check_custom_grammar`].
1197fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1198    match SqlRenderer::procedure_form(dialect) {
1199        // Razeri's own agnostic renderer answers `Agnostic` on purpose: the
1200        // annotated form IS its output. Every other dialect that answers it has
1201        // simply not said.
1202        ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
1203        ProcedureForm::Agnostic => findings.push(Finding {
1204            severity: Severity::Unanswered,
1205            what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
1206                   annotated form — which no engine parses"
1207                .to_owned(),
1208            fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
1209                  or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
1210                  offers instead."
1211                .to_owned(),
1212        }),
1213        ProcedureForm::Unsupported(orientation) => {
1214            check_refusal_copy("the stored-procedure refusal", orientation, findings);
1215            if DialectCapabilities::supports_stored_procedures(dialect) {
1216                findings.push(Finding {
1217                    severity: Severity::Blocking,
1218                    what: "this dialect says its engine has stored procedures, and its \
1219                           procedure form refuses to write one"
1220                        .to_owned(),
1221                    fix: "decide which is true. If the engine has none, leave \
1222                          `DialectCapabilities::supports_stored_procedures` at `false`; if it \
1223                          has them, answer `procedure_form` with the grammar it speaks."
1224                        .to_owned(),
1225                });
1226            }
1227        }
1228        ProcedureForm::Custom(grammar) => check_custom_grammar(
1229            CustomGrammar {
1230                kind: RoutineKind::Procedure,
1231                grammar,
1232                form_method: "procedure_form",
1233                render_method: "render_procedure",
1234                noun: "procedures",
1235            },
1236            |spec| SqlRenderer::render_procedure(dialect, spec),
1237            findings,
1238        ),
1239        _ => {}
1240    }
1241    check_procedures_are_used(dialect, findings);
1242
1243    match SqlRenderer::scalar_function_form(dialect) {
1244        ScalarFunctionForm::Unsupported(orientation) => {
1245            check_refusal_copy("the scalar-function refusal", orientation, findings);
1246        }
1247        ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
1248            CustomGrammar {
1249                kind: RoutineKind::ScalarFunction,
1250                grammar,
1251                form_method: "scalar_function_form",
1252                render_method: "render_scalar_function",
1253                noun: "scalar functions",
1254            },
1255            |spec| SqlRenderer::render_scalar_function(dialect, spec),
1256            findings,
1257        ),
1258        _ => {}
1259    }
1260
1261    match SqlRenderer::table_function_form(dialect) {
1262        TableFunctionForm::Unsupported(orientation) => {
1263            check_refusal_copy("the table-function refusal", orientation, findings);
1264        }
1265        TableFunctionForm::Custom(grammar) => check_custom_grammar(
1266            CustomGrammar {
1267                kind: RoutineKind::TableFunction,
1268                grammar,
1269                form_method: "table_function_form",
1270                render_method: "render_table_function",
1271                noun: "table functions",
1272            },
1273            |spec| SqlRenderer::render_table_function(dialect, spec),
1274            findings,
1275        ),
1276        _ => {}
1277    }
1278
1279    check_parameter_type_spelling(dialect, findings);
1280}
1281
1282/// **A dialect that spells its own procedures has to say its engine has
1283/// them**, or Razeri never writes one.
1284///
1285/// Two answers carry the fact. `procedure_form` says how a procedure is
1286/// written; `DialectCapabilities::supports_stored_procedures` says whether a
1287/// load is one, and its default is `false`. A dialect that answers `Custom`
1288/// and leaves the capability alone renders a procedure nothing asks for:
1289/// every load is written as plain statements, and the bundle says the engine
1290/// has no stored procedures. A grammar Razeri carries is left out on purpose,
1291/// because Razeri keeps that pair for its own engines: `ClickHouse`'s form
1292/// writes executable functions, which no load runs as a procedure.
1293fn check_procedures_are_used(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1294    let Some(grammar) = SqlRenderer::procedure_form(dialect).custom_grammar() else {
1295        return;
1296    };
1297    if DialectCapabilities::supports_stored_procedures(dialect) {
1298        return;
1299    }
1300    findings.push(Finding {
1301        severity: Severity::Blocking,
1302        what: format!(
1303            "this dialect writes procedures in {grammar:?} and says its engine has none, so no \
1304             load is ever written as one"
1305        ),
1306        fix: "answer `DialectCapabilities::supports_stored_procedures` with `true`. Razeri asks \
1307              it before it writes a load as a procedure, and its default is `false`."
1308            .to_owned(),
1309    });
1310}
1311
1312/// **A parameter's type is not always spelled the way a column's is**, and this
1313/// asks whether the dialect has looked.
1314///
1315/// Oracle is the measured case: a column there takes `NUMBER(19, 0)` and
1316/// `VARCHAR2(100)`, and the same types in a parameter list are a compile error
1317/// — the bare `NUMBER` and `VARCHAR2` are what a parameter clause takes, with
1318/// the precision belonging on the local declaration instead. A dialect that has
1319/// not said otherwise gets the column spelling in its parameter lists, which is
1320/// right for most engines and a routine that will not compile on one like that.
1321///
1322/// It is a note and not a failure, because for most engines the two spellings
1323/// genuinely are the same and there is nothing to say. And it is asked only of a
1324/// dialect spelling a grammar of its own, because that is the population where
1325/// nobody has measured the answer yet.
1326fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1327    let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
1328        || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
1329        || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
1330    if !spells_its_own {
1331        return;
1332    }
1333    // A type that carries a length is the only kind the two spellings can
1334    // disagree about, so it is the only kind worth asking with.
1335    let qualified = DataType::varchar(100);
1336    let column = SqlRenderer::render_data_type(dialect, &qualified);
1337    let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
1338    if column == parameter && column.contains('(') {
1339        findings.push(Finding {
1340            severity: Severity::Note,
1341            what: format!(
1342                "a parameter of this routine's would be declared `{parameter}`, the same as a \
1343                column of that type"
1344            ),
1345            fix: "that is right for most engines, so this is only worth a look. If yours refuses \
1346                 a length or a precision in a parameter list — Oracle does, and answers \
1347                 PLS-00103 at the opening parenthesis — override \
1348                 `SqlRenderer::render_parameter_data_type` with the bare spelling. Local \
1349                 declarations keep the length either way."
1350                .to_owned(),
1351        });
1352    }
1353}
1354
1355/// One routine kind whose grammar a dialect said it spells itself.
1356#[derive(Clone, Copy)]
1357struct CustomGrammar {
1358    /// Which routine kind the claim is about.
1359    kind: RoutineKind,
1360    /// The grammar's own name, as the dialect wrote it.
1361    grammar: &'static str,
1362    /// The method that carried the claim.
1363    form_method: &'static str,
1364    /// The method that owes the rendering.
1365    render_method: &'static str,
1366    /// What to call the routines in a sentence.
1367    noun: &'static str,
1368}
1369
1370/// **A dialect that says it spells its own grammar has to spell it.**
1371///
1372/// `Custom` moves the whole rendering to the dialect: Razeri substitutes no
1373/// near-neighbour grammar and refuses nothing, because both of those say
1374/// something false — one about the engine's syntax, the other about whether the
1375/// engine has the routine at all. So the claim is only worth anything with the
1376/// method behind it, and a dialect that made the claim and left the method
1377/// alone ships a build that stops on the routine.
1378///
1379/// The check is the question itself: hand the render method the shape it will
1380/// receive and see whether anything comes back.
1381///
1382/// Parameters:
1383/// - `claim`: which kind, which grammar, and the two method names to name;
1384/// - `render`: the dialect's render method for that kind;
1385/// - `findings`: where the report is collected.
1386fn check_custom_grammar(
1387    claim: CustomGrammar,
1388    render: impl Fn(&RoutineSpec) -> Option<String>,
1389    findings: &mut Vec<Finding>,
1390) {
1391    let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
1392    // The grammar's name is a name, not a refusal: it is read in a report and
1393    // in a comment, so it has to say something and does not have to say much.
1394    if grammar.trim().is_empty() {
1395        findings.push(Finding {
1396            severity: Severity::Blocking,
1397            what: format!("`{form_method}` says Custom, and names no grammar"),
1398            fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
1399                  of a report, and of the comment Razeri writes where a routine could not be \
1400                  rendered, is told this engine speaks."
1401                .to_owned(),
1402        });
1403    }
1404
1405    let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
1406    probe.or_replace = true;
1407    if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
1408        findings.push(Finding {
1409            severity: Severity::Blocking,
1410            what: format!(
1411                "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
1412                 itself — and `{render_method}` returns nothing"
1413            ),
1414            fix: format!(
1415                "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
1416                 composed — name, parameters with their types already in your spelling, body, \
1417                 comment — and returns the whole CREATE statement your engine parses. Razeri \
1418                 emits what you return and writes nothing of its own: a near-neighbour grammar \
1419                 fails on deploy, and a refusal would say this engine has no {noun} when it \
1420                 has them in a grammar of its own."
1421            ),
1422        });
1423    }
1424}
1425
1426/// A refusal that does not say what to do instead is a wall.
1427fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
1428    if copy.trim().is_empty() {
1429        findings.push(Finding {
1430            severity: Severity::Blocking,
1431            what: format!("{subject} has no words in it"),
1432            fix: "say what this engine offers instead. A reader who hits a refusal needs the \
1433                  next thing to try, and it has to be about their engine."
1434                .to_owned(),
1435        });
1436        return;
1437    }
1438    if copy.split_whitespace().count() < 5 {
1439        findings.push(Finding {
1440            severity: Severity::Note,
1441            what: format!("{subject} is very short"),
1442            fix: "a refusal earns its place by naming the alternative — what this engine has \
1443                  instead of the thing it cannot do."
1444                .to_owned(),
1445        });
1446    }
1447}
1448
1449/// Fifteen facts are declared twice and must agree.
1450fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1451    let caps: &dyn DialectCapabilities = dialect;
1452    let ren: &dyn SqlRenderer = dialect;
1453    let ddl: &dyn DdlRenderer = dialect;
1454    let mut disagreements: Vec<&'static str> = Vec::new();
1455
1456    macro_rules! pair {
1457        ($m:ident, $renderer_value:expr) => {
1458            if caps.$m() != $renderer_value {
1459                disagreements.push(stringify!($m));
1460            }
1461        };
1462    }
1463    pair!(supports_on_conflict, ren.supports_on_conflict());
1464    pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
1465    pair!(supports_update_from, ren.supports_update_from());
1466    pair!(supports_named_windows, ren.supports_named_windows());
1467    pair!(supports_compound_assignment, ren.supports_compound_assignment());
1468    pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
1469    pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
1470    pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
1471    pair!(uses_alter_table_update, ren.uses_alter_table_update());
1472    pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
1473    pair!(supports_cascade_drop, ddl.supports_cascade_drop());
1474    pair!(supports_sync_drop, ddl.supports_sync_drop());
1475    pair!(supports_force_drop, ddl.supports_force_drop());
1476
1477    // **The fourteenth, whose two sides are different types.** The capability
1478    // is a boolean; the renderer answer is a form that either renders or
1479    // carries a refusal. Both are read — the build door reads the form, and
1480    // registry-walking rosters read the capability — so a dialect claiming a
1481    // table function on one side and refusing on the other has an engine that
1482    // both does and does not have one. `SQLite` was exactly that on the day
1483    // this check was written: `false` since the capability existed, and a
1484    // renderer still inheriting PostgreSQL's `RETURNS TABLE`.
1485    if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
1486        disagreements.push("supports_table_functions");
1487    }
1488    // **The fifteenth, the same shape for a scalar function.** The build door
1489    // reads the capability; the renderer reads the form.
1490    if caps.supports_stored_functions() != ren.scalar_function_form().refusal().is_none() {
1491        disagreements.push("supports_stored_functions");
1492    }
1493
1494    if !disagreements.is_empty() {
1495        findings.push(Finding {
1496            severity: Severity::Blocking,
1497            what: format!(
1498                "this dialect declares {} {} two ways and the two disagree: {}",
1499                disagreements.len(),
1500                if disagreements.len() == 1 { "fact" } else { "facts" },
1501                disagreements.join(", ")
1502            ),
1503            fix: "each of these is declared once on the capability trait and once in the \
1504                  renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
1505                  and move the other one to match."
1506                .to_owned(),
1507        });
1508    }
1509}
1510
1511/// **A dialect that folds each schema into an object's name has to fold it
1512/// everywhere.**
1513///
1514/// Razeri writes most names itself, asking `fold_schema_into_name`, and some
1515/// through the dialect's own `qualify_name`. The two have to name the same
1516/// object, or a table is created under one name and read under another. And a
1517/// fold is asked for only where the engine has no schemas: on one that has
1518/// them, the schema is written apart from the name, and a fold says otherwise.
1519fn check_schema_fold(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1520    let (schema, name) = ("razeri_probe_raw", "razeri_probe_table");
1521    let Some(folded) = SqlRenderer::fold_schema_into_name(dialect, schema, name) else {
1522        return;
1523    };
1524    if DialectCapabilities::supports_schemas(dialect) {
1525        findings.push(Finding {
1526            severity: Severity::Blocking,
1527            what: "this dialect folds a schema into an object's name on an engine it says has \
1528                   schemas"
1529                .to_owned(),
1530            fix: "answer `fold_schema_into_name` with `None`. A fold is for an engine with no \
1531                  schemas, and only there is it asked; or answer \
1532                  `DialectCapabilities::supports_schemas` with `false` if your engine has none."
1533                .to_owned(),
1534        });
1535        return;
1536    }
1537    let expected = SqlRenderer::quote_identifier(dialect, &folded);
1538    let qualified = SqlRenderer::qualify_name(dialect, Some(schema), name);
1539    if qualified != expected {
1540        findings.push(Finding {
1541            severity: Severity::Blocking,
1542            what: format!(
1543                "`fold_schema_into_name` names an object `{expected}` and `qualify_name` names \
1544                 the same object `{qualified}`"
1545            ),
1546            fix: "make `qualify_name` answer the folded name, quoted, when it is given a \
1547                  schema. Razeri names some objects through it and the rest through the fold, \
1548                  so a difference creates a table under one name and reads it under another."
1549                .to_owned(),
1550        });
1551    }
1552}
1553
1554/// **A described deploy client has to be told which file to run.**
1555///
1556/// The generated script substitutes the file for `{file}` in the arguments
1557/// the dialect names, and reads nothing else, so an argument list without it
1558/// runs the client with no SQL at all — and a client given no file usually
1559/// waits for one on its input.
1560fn check_deploy_client(info: &crate::dialect::DialectInfo, findings: &mut Vec<Finding>) {
1561    let Some(crate::dialect::DeployScript::Client(client)) = info.deploy_script else {
1562        return;
1563    };
1564    if client.command.trim().is_empty() {
1565        findings.push(Finding {
1566            severity: Severity::Blocking,
1567            what: "this dialect describes a deploy client with no command".to_owned(),
1568            fix: "name the client's command as an operator has it on `PATH`.".to_owned(),
1569        });
1570    }
1571    let unnamed: Vec<&str> = [("run_file", Some(client.run_file)), ("read_rows", client.read_rows)]
1572        .into_iter()
1573        .filter_map(|(field, args)| {
1574            args.filter(|args| !args.iter().any(|arg| arg.contains("{file}"))).map(|_| field)
1575        })
1576        .collect();
1577    if !unnamed.is_empty() {
1578        findings.push(Finding {
1579            severity: Severity::Blocking,
1580            what: format!(
1581                "the deploy client's {} never names the file to run",
1582                unnamed.join(" and ")
1583            ),
1584            fix: "put `{file}` where your client takes the file of SQL to run — on its own, or \
1585                  inside an argument such as `--file={file}`. The script writes the file there \
1586                  and nowhere else."
1587                .to_owned(),
1588        });
1589    }
1590    if let Some(terminator) = client.block_terminator
1591        && [terminator.open, terminator.end, terminator.close]
1592            .iter()
1593            .any(|part| part.trim().is_empty())
1594    {
1595        findings.push(Finding {
1596            severity: Severity::Blocking,
1597            what: "the deploy client's block terminator leaves a part blank".to_owned(),
1598            fix: "name the line before a routine, what ends it, and the line after it — \
1599                  `SET TERM ^ ;`, `^` and `SET TERM ; ^` for Firebird's isql. A blank end leaves \
1600                  the routine open, and the next statement is read into it."
1601                .to_owned(),
1602        });
1603    }
1604}
1605
1606/// Every type a design can declare, which is the set the required methods owe
1607/// an answer for.
1608///
1609/// The last four are the ones whose spelling actually varies between engines:
1610/// an array, a map, a struct, and an array of structs. A dialect that answers
1611/// only for scalars is a dialect that has not been graded on the types a
1612/// nested source table is made of.
1613fn declarable_types() -> Vec<DataType> {
1614    vec![
1615        DataType::tinyint(),
1616        DataType::smallint(),
1617        DataType::int(),
1618        DataType::bigint(),
1619        DataType::double(),
1620        DataType::boolean(),
1621        DataType::text(),
1622        DataType::varchar(200),
1623        DataType::uuid(),
1624        DataType::json(),
1625        DataType::date(),
1626        DataType::timestamp(),
1627        DataType::timestamp_tz(),
1628        DataType::decimal(18, 2),
1629        DataType::Array(ArrayDataType::new(DataType::varchar(100))),
1630        DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
1631        DataType::Struct(StructDataType::new(vec![
1632            StructField::new("street", DataType::varchar(200)),
1633            StructField::new("zip", DataType::varchar(10)),
1634        ])),
1635        DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
1636            StructField::new("key", DataType::varchar(100)),
1637            StructField::new("value", DataType::varchar(1000)),
1638        ])))),
1639    ]
1640}
1641
1642#[cfg(test)]
1643mod tests {
1644    use super::*;
1645    use crate::dialect::DialectInfo;
1646    use razeri_plugin_contract::{
1647        Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
1648        ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
1649        TaskRenderer, TextCast,
1650    };
1651    use std::marker::PhantomData;
1652
1653    // ── The broken fixture ────────────────────────────────────────────
1654    //
1655    // A plugin that compiles, registers nothing it should, and answers every
1656    // question with somebody else's default. This is what the kit exists to
1657    // catch, and it is deliberately the plugin an author gets by implementing
1658    // only the four required methods and stopping.
1659
1660    struct BrokenPlugin;
1661
1662    impl SqlRenderer for BrokenPlugin {
1663        fn render_data_type(&self, _dt: &DataType) -> String {
1664            String::new()
1665        }
1666        fn quote_identifier(&self, name: &str) -> String {
1667            name.to_owned()
1668        }
1669        fn render_cast_type(&self, _dt: &DataType) -> String {
1670            String::new()
1671        }
1672        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1673            name.to_owned()
1674        }
1675        fn render_current_timestamp(&self) -> String {
1676            String::new()
1677        }
1678        fn dialect_name(&self) -> &'static str {
1679            "brokendb"
1680        }
1681        // No `sid_spelling`, no `procedure_form`, no `to_formula_renderer` —
1682        // and one fact declared twice, two ways.
1683        fn supports_on_conflict(&self) -> bool {
1684            true
1685        }
1686    }
1687    impl ExpressionRenderer for BrokenPlugin {}
1688    impl DdlRenderer for BrokenPlugin {}
1689    impl DmlRenderer for BrokenPlugin {}
1690    impl ProcedureRenderer for BrokenPlugin {}
1691    impl FormulaRenderer for BrokenPlugin {}
1692    impl TaskRenderer for BrokenPlugin {}
1693    impl ReplicationRenderer for BrokenPlugin {}
1694    impl StorageRenderer for BrokenPlugin {}
1695    impl DialectCapabilities for BrokenPlugin {
1696        fn supports_on_conflict(&self) -> bool {
1697            false
1698        }
1699    }
1700    impl PlatformDialect for BrokenPlugin {
1701        fn info(&self) -> DialectInfo {
1702            DialectInfo {
1703                name: "brokendb",
1704                display_name: "Broken DB",
1705                aliases: &[],
1706                dialect_family: DialectFamily::Custom(""),
1707                family: "Broken",
1708                is_cloud_native: false,
1709                is_olap: false,
1710                is_postgres_compatible: false,
1711                versions: &[],
1712                deploy_script: None,
1713            }
1714        }
1715    }
1716
1717    // ── The answered fixture, under two names ─────────────────────────
1718    //
1719    // The same plugin with every question answered, carrying the name it
1720    // registers under as a type parameter. Neither name is one Razeri knows a
1721    // spelling for; the difference is that one of them is in the registry and
1722    // the other is in nothing at all. Those are two different facts about a
1723    // dialect, and they read as two different reports.
1724
1725    trait FixtureName: Send + Sync + 'static {
1726        /// The registry name this fixture answers to.
1727        const NAME: &'static str;
1728        /// How this fixture's engine joins the parts of a composite key. Most
1729        /// of them take the inherited variadic `CONCAT`; the one that does not
1730        /// is the point of it being a knob.
1731        const CONCAT: Concat = Concat::Function("CONCAT");
1732        /// This fixture's engine's exponentiation operator; `None` where it
1733        /// has none.
1734        const POWER: Option<&'static str> = Some("^");
1735        /// This fixture's engine's left shift; `None` where it has none.
1736        const SHIFT_LEFT: Option<&'static str> = Some("<<");
1737        /// Whether this fixture's column drop forgets the column it was handed.
1738        const DROP_LOSES_ITS_COLUMN: bool = false;
1739        /// Whether this fixture compares something other than the value.
1740        const COMPARES_SOMETHING_ELSE: bool = false;
1741        /// Whether this fixture says its begin leaves the commit to the client
1742        /// while it has no transaction spanning statements.
1743        const BEGIN_WITHOUT_TRANSACTIONS: bool = false;
1744        /// The client this fixture's deploy script drives, if it names one.
1745        const DEPLOY: Option<crate::dialect::DeployScript> = None;
1746    }
1747
1748    /// Answered, and nothing linked it — what an author sees running the report
1749    /// from a crate whose registration nobody calls.
1750    struct Unlinked;
1751    impl FixtureName for Unlinked {
1752        const NAME: &'static str = "healthydb";
1753    }
1754
1755    /// Answered and registered, under a name Razeri ships no spelling for —
1756    /// the shape of every dialect written outside Razeri's own tree.
1757    struct Outside;
1758    impl FixtureName for Outside {
1759        const NAME: &'static str = "outsidedb";
1760    }
1761
1762    /// Answered, and joining composite keys with a `CONCAT` that takes exactly
1763    /// two arguments — the shape of Db2 and of older Oracle releases. Nothing
1764    /// about the join is an obligation, so the report on it must read exactly
1765    /// like the report on a variadic engine.
1766    struct BinaryConcat;
1767    impl FixtureName for BinaryConcat {
1768        const NAME: &'static str = "binaryconcatdb";
1769        const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1770    }
1771
1772    /// Answered, and joining composite keys with the `||` operator — the
1773    /// shape of an engine with no `CONCAT` function at all. Like the
1774    /// two-argument join, it is a decision and owes nothing.
1775    struct OperatorConcat;
1776    impl FixtureName for OperatorConcat {
1777        const NAME: &'static str = "operatorconcatdb";
1778        const CONCAT: Concat = Concat::Operator("||");
1779    }
1780
1781    /// Answered, with a join that names nothing.
1782    struct NamelessConcat;
1783    impl FixtureName for NamelessConcat {
1784        const NAME: &'static str = "namelessconcatdb";
1785        const CONCAT: Concat = Concat::Operator(" ");
1786    }
1787
1788    /// Answered, with an engine that raises to a power only through a
1789    /// function: no operator, said out loud.
1790    struct NoPowerOperator;
1791    impl FixtureName for NoPowerOperator {
1792        const NAME: &'static str = "nopowerdb";
1793        const POWER: Option<&'static str> = None;
1794    }
1795
1796    /// Answered, with an operator spelled as nothing at all.
1797    struct BlankPowerOperator;
1798    impl FixtureName for BlankPowerOperator {
1799        const NAME: &'static str = "blankpowerdb";
1800        const POWER: Option<&'static str> = Some(" ");
1801    }
1802
1803    /// Answered, with a comparison that forgets the value it stands for.
1804    struct ComparesSomethingElse;
1805    impl FixtureName for ComparesSomethingElse {
1806        const NAME: &'static str = "comparesotherdb";
1807        const COMPARES_SOMETHING_ELSE: bool = true;
1808    }
1809
1810    /// Answered, with a column drop that forgets its column.
1811    struct DropLosesItsColumn;
1812    impl FixtureName for DropLosesItsColumn {
1813        const NAME: &'static str = "droplosesdb";
1814        const DROP_LOSES_ITS_COLUMN: bool = true;
1815    }
1816
1817    /// Answered, with a begin that leaves the commit to a client on an engine
1818    /// with no transaction spanning statements.
1819    struct BeginWithoutTransactions;
1820    impl FixtureName for BeginWithoutTransactions {
1821        const NAME: &'static str = "beginwithouttxdb";
1822        const BEGIN_WITHOUT_TRANSACTIONS: bool = true;
1823    }
1824
1825    /// Answered, with an engine that has no left shift — an answer.
1826    struct NoShiftOperator;
1827    impl FixtureName for NoShiftOperator {
1828        const NAME: &'static str = "noshiftdb";
1829        const SHIFT_LEFT: Option<&'static str> = None;
1830    }
1831
1832    /// Answered, with a left shift that has no text in it.
1833    struct BlankShiftOperator;
1834    impl FixtureName for BlankShiftOperator {
1835        const NAME: &'static str = "blankshiftdb";
1836        const SHIFT_LEFT: Option<&'static str> = Some("");
1837    }
1838
1839    /// Answered, with a deploy client described well.
1840    struct DescribedClient;
1841    impl FixtureName for DescribedClient {
1842        const NAME: &'static str = "describedclientdb";
1843        const DEPLOY: Option<crate::dialect::DeployScript> =
1844            Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1845                command: "sqlite3",
1846                run_file: &["-bail", "{target}", ".read {file}"],
1847                read_rows: Some(&["-csv", "-noheader", "{target}", ".read {file}"]),
1848                block_terminator: None,
1849            }));
1850    }
1851
1852    /// Answered, with a deploy client that is never told which file to run.
1853    struct FilelessClient;
1854    impl FixtureName for FilelessClient {
1855        const NAME: &'static str = "filelessclientdb";
1856        const DEPLOY: Option<crate::dialect::DeployScript> =
1857            Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1858                command: "sqlite3",
1859                run_file: &["-bail", "{target}"],
1860                read_rows: None,
1861                block_terminator: None,
1862            }));
1863    }
1864
1865    /// Answered, with a deploy client whose routine end is blank.
1866    struct BlankBlockEnd;
1867    impl FixtureName for BlankBlockEnd {
1868        const NAME: &'static str = "blankblockenddb";
1869        const DEPLOY: Option<crate::dialect::DeployScript> =
1870            Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1871                command: "isql",
1872                run_file: &["-bail", "-input", "{file}", "{target}"],
1873                read_rows: None,
1874                block_terminator: Some(crate::dialect::BlockTerminator {
1875                    open: "SET TERM ^ ;",
1876                    end: " ",
1877                    close: "SET TERM ; ^",
1878                }),
1879            }));
1880    }
1881
1882    struct Answered<N: FixtureName>(PhantomData<N>);
1883
1884    impl<N: FixtureName> Answered<N> {
1885        const fn new() -> Self {
1886            Self(PhantomData)
1887        }
1888    }
1889
1890    type HealthyPlugin = Answered<Unlinked>;
1891    type OutsidePlugin = Answered<Outside>;
1892
1893    inventory::submit! {
1894        crate::builder::DialectRegistration::new(
1895            Outside::NAME,
1896            || std::sync::Arc::new(OutsidePlugin::new()),
1897        )
1898    }
1899
1900    impl<N: FixtureName> SqlRenderer for Answered<N> {
1901        fn render_data_type(&self, dt: &DataType) -> String {
1902            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1903        }
1904        fn quote_identifier(&self, name: &str) -> String {
1905            format!("\"{name}\"")
1906        }
1907        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1908            schema.map_or_else(
1909                || self.quote_identifier(name),
1910                |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
1911            )
1912        }
1913        fn render_current_timestamp(&self) -> String {
1914            "NOW()".to_owned()
1915        }
1916        fn dialect_name(&self) -> &'static str {
1917            N::NAME
1918        }
1919        fn sid_spelling(&self) -> SidSpelling {
1920            SidSpelling {
1921                platform: N::NAME,
1922                hash: ("answered_xxhash64(", ")"),
1923                cast_type: TextCast::Direct("STRING"),
1924                concat: N::CONCAT,
1925                through_helper: false,
1926                ..razeri_plugin_contract::ANSI_SID_SPELLING
1927            }
1928        }
1929        fn procedure_form(&self) -> ProcedureForm {
1930            ProcedureForm::Unsupported(
1931                "This engine has no stored procedures; write the load as a scheduled query \
1932                 instead.",
1933            )
1934        }
1935        fn scalar_function_form(&self) -> ScalarFunctionForm {
1936            ScalarFunctionForm::PostgreSql
1937        }
1938        fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
1939            Box::new(Self::new())
1940        }
1941    }
1942    impl<N: FixtureName> ExpressionRenderer for Answered<N> {
1943        fn render_comparable(
1944            &self,
1945            value: &str,
1946            _data_type: &DataType,
1947            _held: HeldAs,
1948        ) -> Option<String> {
1949            Some(if N::COMPARES_SOMETHING_ELSE { "1".to_owned() } else { value.to_owned() })
1950        }
1951    }
1952    impl<N: FixtureName> DdlRenderer for Answered<N> {
1953        fn render_drop_column(
1954            &self,
1955            table: WrittenObject<'_>,
1956            column: &str,
1957            only_where_it_stands: bool,
1958        ) -> Option<razeri_plugin_contract::Written> {
1959            if N::DROP_LOSES_ITS_COLUMN {
1960                return Some(razeri_plugin_contract::Written::Statement(format!(
1961                    "ALTER TABLE {} DROP COLUMN c",
1962                    table.relation
1963                )));
1964            }
1965            let guard = if only_where_it_stands { " IF EXISTS" } else { "" };
1966            Some(razeri_plugin_contract::Written::Statement(format!(
1967                "ALTER TABLE {} DROP COLUMN{guard} {column}",
1968                table.relation
1969            )))
1970        }
1971    }
1972    impl<N: FixtureName> DmlRenderer for Answered<N> {}
1973    impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
1974    impl<N: FixtureName> FormulaRenderer for Answered<N> {
1975        fn render_power_operator(&self) -> Option<String> {
1976            N::POWER.map(str::to_owned)
1977        }
1978        fn render_bitwise_operator(&self, operator: BitwiseOperator) -> Option<String> {
1979            match operator {
1980                BitwiseOperator::ShiftLeft => N::SHIFT_LEFT.map(str::to_owned),
1981                other => Some(other.standard_spelling().to_owned()),
1982            }
1983        }
1984    }
1985    impl<N: FixtureName> TaskRenderer for Answered<N> {}
1986    impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
1987    impl<N: FixtureName> StorageRenderer for Answered<N> {}
1988    impl<N: FixtureName> DialectCapabilities for Answered<N> {
1989        fn begin_leaves_the_commit_to_the_client(&self) -> bool {
1990            N::BEGIN_WITHOUT_TRANSACTIONS
1991        }
1992        fn supports_multi_statement_transactions(&self) -> bool {
1993            !N::BEGIN_WITHOUT_TRANSACTIONS
1994        }
1995        fn merge_shape(&self) -> MergeShape {
1996            MergeShape::Merge(MergeRestrictions::none())
1997        }
1998    }
1999    impl<N: FixtureName> PlatformDialect for Answered<N> {
2000        fn info(&self) -> DialectInfo {
2001            DialectInfo {
2002                name: N::NAME,
2003                display_name: "An Answered Engine",
2004                // It answers to its own name and nothing else, which is the
2005                // shape of a plugin that declares no extra spellings.
2006                aliases: &[],
2007                dialect_family: DialectFamily::Custom(N::NAME),
2008                family: "Answered",
2009                is_cloud_native: false,
2010                is_olap: false,
2011                is_postgres_compatible: false,
2012                versions: &[],
2013                deploy_script: N::DEPLOY,
2014            }
2015        }
2016    }
2017
2018    // ── The twin: a dialect that claims a name another platform answers to ──
2019    //
2020    // Answered in every other respect, so the only thing its report can be
2021    // about is the spelling it took.
2022
2023    struct Twin;
2024
2025    impl SqlRenderer for Twin {
2026        fn render_data_type(&self, dt: &DataType) -> String {
2027            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2028        }
2029        fn quote_identifier(&self, name: &str) -> String {
2030            format!("\"{name}\"")
2031        }
2032        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2033            self.quote_identifier(name)
2034        }
2035        fn render_current_timestamp(&self) -> String {
2036            "NOW()".to_owned()
2037        }
2038    }
2039    impl ExpressionRenderer for Twin {}
2040    impl DdlRenderer for Twin {}
2041    impl DmlRenderer for Twin {}
2042    impl ProcedureRenderer for Twin {}
2043    impl FormulaRenderer for Twin {}
2044    impl TaskRenderer for Twin {}
2045    impl ReplicationRenderer for Twin {}
2046    impl StorageRenderer for Twin {}
2047    impl DialectCapabilities for Twin {}
2048    impl PlatformDialect for Twin {
2049        fn info(&self) -> DialectInfo {
2050            DialectInfo {
2051                name: "twindb",
2052                display_name: "Twin DB",
2053                // The name the answered fixture already answers to.
2054                aliases: &["healthydb"],
2055                dialect_family: DialectFamily::Custom("twindb"),
2056                family: "Twin",
2057                is_cloud_native: false,
2058                is_olap: false,
2059                is_postgres_compatible: false,
2060                versions: &[],
2061                deploy_script: None,
2062            }
2063        }
2064    }
2065
2066    /// Every finding whose `what` line mentions one of these subjects.
2067    fn mentions(report: &Report, needle: &str) -> bool {
2068        report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
2069    }
2070
2071    /// **The author's own test prints what the command prints**
2072    /// (`fr-1021-48`): what the report read, that it ran nothing on an
2073    /// engine, and the answers the dialect left to the contract.
2074    #[test]
2075    fn the_report_says_what_it_read_and_what_it_took_as_the_default() {
2076        let report = check(&BrokenPlugin);
2077        let printed = report.to_string();
2078        let first = printed.lines().next().unwrap_or_default();
2079        assert!(first.ends_with("in the answers this dialect states."), "{printed}");
2080        assert!(printed.contains("ran nothing on an engine"), "{printed}");
2081        assert!(printed.contains("Taken as the contract's default"), "{printed}");
2082        let names: Vec<&str> = report.taken_as_default.iter().map(|taken| taken.name).collect();
2083        assert!(names.contains(&"DdlRenderer::supports_if_not_exists"), "{names:?}");
2084        let view = report
2085            .taken_as_default
2086            .iter()
2087            .find(|taken| taken.name == "DdlRenderer::render_create_view_prefix")
2088            .expect("listed");
2089        assert!(view.answer.contains("CREATE OR REPLACE VIEW"), "{view:?}");
2090        assert_eq!(view.shows_in, "schema.sql");
2091        assert!(printed.contains(&view.answer), "{printed}");
2092    }
2093
2094    /// **An answer the dialect wrote is not listed**, even where it reads the
2095    /// same as the contract's: the healthy fixture writes its own transaction
2096    /// keyword.
2097    #[test]
2098    fn a_report_with_nothing_to_fix_still_says_it_ran_nothing() {
2099        let report = check(&Answered::<Outside>::new());
2100        let printed = report.to_string();
2101        assert!(printed.contains("ran nothing on an engine"), "{printed}");
2102        assert!(!printed.lines().next().unwrap_or_default().ends_with("0 to fix."), "{printed}");
2103    }
2104
2105    /// **The key's fix names what the key runs** (`fr-1021-53`): a
2106    /// replacement, a Unicode literal, a join, a cast and a 64-bit hash —
2107    /// never LOWER and TRIM, which the key no longer calls.
2108    #[test]
2109    fn a_dialect_with_no_key_spelling_is_asked_for_what_the_key_runs() {
2110        let report = check(&BrokenPlugin);
2111        let key = report
2112            .findings
2113            .iter()
2114            .find(|finding| finding.what.contains("surrogate-key spelling"))
2115            .expect("the key's finding");
2116        for words in ["a replacement and a Unicode literal", "joins strings", "casts to text"] {
2117            assert!(key.fix.contains(words), "no {words}: {}", key.fix);
2118        }
2119        assert!(!key.fix.contains("LOWER") && !key.fix.contains("TRIM"), "{}", key.fix);
2120    }
2121
2122    /// **The kit's own red.** A plugin that implements the four required
2123    /// methods and stops compiles and registers, and every one of these
2124    /// findings is a defect a shipped dialect actually had.
2125    #[test]
2126    fn the_kit_catches_a_deliberately_broken_plugin() {
2127        let report = check(&BrokenPlugin);
2128        assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
2129
2130        assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
2131        assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
2132        assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
2133        assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
2134        assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
2135        assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
2136        assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
2137        assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
2138        assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
2139        assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
2140        assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
2141
2142        assert!(
2143            report.count(Severity::Blocking) >= 8,
2144            "the broken plugin owes more than {} blocking fixes:\n{report}",
2145            report.count(Severity::Blocking)
2146        );
2147    }
2148
2149    /// **The kit does not cry wolf.** The same plugin with its questions
2150    /// answered reports only what is actually true of it: it is not
2151    /// registered in this test binary, and nothing else.
2152    #[test]
2153    fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
2154        let report = check(&HealthyPlugin::new());
2155        let unregistered: Vec<&Finding> =
2156            report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
2157        assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
2158        assert_eq!(
2159            report.findings.len(),
2160            1,
2161            "a plugin that answered every question owes nothing else:\n{report}"
2162        );
2163    }
2164
2165    /// **A join written for a two-argument `CONCAT` is an answer, not a
2166    /// shortfall.** The report on a dialect that states it reads exactly like
2167    /// the report on a variadic one, and the key it renders nests pairwise
2168    /// instead of widening a call the engine does not have.
2169    #[test]
2170    fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
2171        let binary = Answered::<BinaryConcat>::new();
2172        let report = check(&binary);
2173        assert_eq!(
2174            report.findings.len(),
2175            check(&HealthyPlugin::new()).findings.len(),
2176            "the join form is a decision, so stating this one owes nothing extra:\n{report}"
2177        );
2178        assert!(
2179            !report.to_string().contains("joins"),
2180            "nothing about the join is outstanding:\n{report}"
2181        );
2182
2183        let key = razeri_plugin_contract::sid::sid_expression(
2184            SqlRenderer::sid_spelling(&binary),
2185            razeri_plugin_contract::sid::Backslash::of(&binary),
2186            None,
2187            &["a".to_owned(), "b".to_owned(), "c".to_owned()],
2188        );
2189        assert!(
2190            key.contains("CONCAT(CONCAT("),
2191            "a two-argument engine gets one call per pair:\n{key}"
2192        );
2193    }
2194
2195    /// **A join written with an operator is an answer too**, and the key it
2196    /// renders is one expression with the operator between each part and the
2197    /// separator.
2198    #[test]
2199    fn the_kit_takes_an_operator_join_as_an_answer_like_any_other() {
2200        let operator = Answered::<OperatorConcat>::new();
2201        let report = check(&operator);
2202        assert_eq!(
2203            report.findings.len(),
2204            check(&HealthyPlugin::new()).findings.len(),
2205            "the join form is a decision, so stating this one owes nothing extra:\n{report}"
2206        );
2207        let key = razeri_plugin_contract::sid::sid_expression(
2208            SqlRenderer::sid_spelling(&operator),
2209            razeri_plugin_contract::sid::Backslash::of(&operator),
2210            None,
2211            &["a".to_owned(), "b".to_owned()],
2212        );
2213        assert!(key.contains(" || '|' || "), "the operator joins each part:\n{key}");
2214        assert!(!key.contains("CONCAT"), "an operator engine calls no function:\n{key}");
2215    }
2216
2217    /// **A join that names nothing is refused**, because the key it writes is
2218    /// one the engine cannot parse.
2219    #[test]
2220    fn a_join_that_names_nothing_must_be_fixed() {
2221        let report = check(&Answered::<NamelessConcat>::new());
2222        assert!(
2223            report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2224                && finding.what.contains("joins the parts of a composite key")),
2225            "{report}"
2226        );
2227        assert!(
2228            !check(&Answered::<OperatorConcat>::new())
2229                .findings
2230                .iter()
2231                .any(|finding| finding.what.contains("joins the parts of a composite key")),
2232            "a named operator is not the finding"
2233        );
2234    }
2235
2236    /// **An operator the engine lacks is answered `None`, and that is an
2237    /// answer**; an operator answered with no text is the finding, because it
2238    /// writes two operands side by side.
2239    #[test]
2240    fn an_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
2241        let none = check(&Answered::<NoPowerOperator>::new());
2242        assert_eq!(
2243            none.findings.len(),
2244            check(&HealthyPlugin::new()).findings.len(),
2245            "saying the engine has no such operator owes nothing:\n{none}"
2246        );
2247        let blank = check(&Answered::<BlankPowerOperator>::new());
2248        assert!(
2249            blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
2250                && finding.what.contains("render_power_operator")),
2251            "{blank}"
2252        );
2253    }
2254
2255    /// **A bitwise operator the engine lacks is answered `None`, and that is
2256    /// an answer** (`fr-1018-121`); one answered with no text is the finding.
2257    #[test]
2258    fn a_bitwise_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
2259        let none = check(&Answered::<NoShiftOperator>::new());
2260        assert_eq!(
2261            none.findings.len(),
2262            check(&HealthyPlugin::new()).findings.len(),
2263            "saying the engine has no left shift owes nothing:\n{none}"
2264        );
2265        let blank = check(&Answered::<BlankShiftOperator>::new());
2266        assert!(
2267            blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
2268                && finding.what.contains("render_bitwise_operator(ShiftLeft)")),
2269            "{blank}"
2270        );
2271    }
2272
2273    /// **A column drop that loses the column it was handed is the finding**
2274    /// (`fr-1018-84`); the healthy fixture's drop carries it.
2275    #[test]
2276    fn a_drop_that_loses_its_column_must_be_fixed() {
2277        let healthy = check(&HealthyPlugin::new());
2278        assert!(!healthy.to_string().contains("render_drop_column"), "{healthy}");
2279        let lost = check(&Answered::<DropLosesItsColumn>::new());
2280        assert!(
2281            lost.findings.iter().any(|finding| finding.severity == Severity::Blocking
2282                && finding.what.contains("render_drop_column")),
2283            "{lost}"
2284        );
2285    }
2286
2287    /// **A comparison that loses the value it stands for is the finding**
2288    /// (`fr-1019-71`).
2289    #[test]
2290    fn a_comparison_that_loses_its_value_must_be_fixed() {
2291        assert!(!check(&HealthyPlugin::new()).to_string().contains("render_comparable"));
2292        let report = check(&Answered::<ComparesSomethingElse>::new());
2293        assert!(
2294            report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2295                && finding.what.contains("render_comparable")),
2296            "{report}"
2297        );
2298    }
2299
2300    /// **A begin that leaves the commit to a client, on an engine with no
2301    /// transaction spanning statements, is two answers that disagree**
2302    /// (`fr-1019-62`).
2303    #[test]
2304    fn a_begin_that_leaves_the_commit_needs_a_transaction() {
2305        let report = check(&Answered::<BeginWithoutTransactions>::new());
2306        assert!(
2307            report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2308                && finding.what.contains("leaves the commit to the client")),
2309            "{report}"
2310        );
2311    }
2312
2313    /// **A block terminator with a blank part is the finding** (`fr-1016-28`).
2314    #[test]
2315    fn a_block_terminator_with_a_blank_part_must_be_fixed() {
2316        let report = check(&Answered::<BlankBlockEnd>::new());
2317        assert!(
2318            report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2319                && finding.what.contains("block terminator")),
2320            "{report}"
2321        );
2322    }
2323
2324    /// **A described deploy client is an answer; one that is never told which
2325    /// file to run is the finding.**
2326    #[test]
2327    fn a_deploy_client_has_to_be_told_which_file_to_run() {
2328        let described = check(&Answered::<DescribedClient>::new());
2329        assert_eq!(
2330            described.findings.len(),
2331            check(&HealthyPlugin::new()).findings.len(),
2332            "a client described in full owes nothing:\n{described}"
2333        );
2334        let fileless = check(&Answered::<FilelessClient>::new());
2335        assert!(
2336            fileless.findings.iter().any(|finding| finding.severity == Severity::Blocking
2337                && finding.what.contains("run_file never names the file")),
2338            "{fileless}"
2339        );
2340    }
2341
2342    /// **A fold has to agree with `qualify_name`, and is for an engine with no
2343    /// schemas.** `SCHEMAS`: what the engine says about schemas. `AGREES`:
2344    /// whether `qualify_name` answers the folded name.
2345    #[test]
2346    fn a_schema_fold_agrees_with_qualify_name_on_an_engine_with_no_schemas() {
2347        struct Folder<const SCHEMAS: bool, const AGREES: bool>;
2348        impl<const SCHEMAS: bool, const AGREES: bool> SqlRenderer for Folder<SCHEMAS, AGREES> {
2349            fn render_data_type(&self, dt: &DataType) -> String {
2350                razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2351            }
2352            fn quote_identifier(&self, name: &str) -> String {
2353                format!("\"{name}\"")
2354            }
2355            fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
2356                match schema {
2357                    Some(schema) if AGREES => self.quote_identifier(&format!("{schema}_{name}")),
2358                    Some(schema) => format!("{schema}.{}", self.quote_identifier(name)),
2359                    None => self.quote_identifier(name),
2360                }
2361            }
2362            fn render_current_timestamp(&self) -> String {
2363                "CURRENT_TIMESTAMP".to_owned()
2364            }
2365            fn fold_schema_into_name(&self, schema: &str, name: &str) -> Option<String> {
2366                Some(format!("{schema}_{name}"))
2367            }
2368        }
2369        impl<const S: bool, const A: bool> ExpressionRenderer for Folder<S, A> {}
2370        impl<const S: bool, const A: bool> DdlRenderer for Folder<S, A> {}
2371        impl<const S: bool, const A: bool> DmlRenderer for Folder<S, A> {}
2372        impl<const S: bool, const A: bool> ProcedureRenderer for Folder<S, A> {}
2373        impl<const S: bool, const A: bool> FormulaRenderer for Folder<S, A> {}
2374        impl<const S: bool, const A: bool> TaskRenderer for Folder<S, A> {}
2375        impl<const S: bool, const A: bool> ReplicationRenderer for Folder<S, A> {}
2376        impl<const S: bool, const A: bool> StorageRenderer for Folder<S, A> {}
2377        impl<const S: bool, const A: bool> DialectCapabilities for Folder<S, A> {
2378            fn supports_schemas(&self) -> bool {
2379                S
2380            }
2381        }
2382        impl<const S: bool, const A: bool> PlatformDialect for Folder<S, A> {
2383            fn info(&self) -> DialectInfo {
2384                DialectInfo {
2385                    name: "folderdb",
2386                    display_name: "Folder DB",
2387                    aliases: &[],
2388                    dialect_family: DialectFamily::Custom("folderdb"),
2389                    family: "Folder",
2390                    is_cloud_native: false,
2391                    is_olap: false,
2392                    is_postgres_compatible: false,
2393                    versions: &[],
2394                    deploy_script: None,
2395                }
2396            }
2397        }
2398
2399        let fold_findings = |dialect: &dyn PlatformDialect| {
2400            let mut findings = Vec::new();
2401            check_schema_fold(dialect, &mut findings);
2402            findings
2403        };
2404        assert!(fold_findings(&Folder::<false, true>).is_empty(), "a fold kept everywhere");
2405        let disagrees = fold_findings(&Folder::<false, false>);
2406        assert_eq!(disagrees.len(), 1, "{disagrees:?}");
2407        assert!(disagrees[0].what.contains("`qualify_name` names"), "{disagrees:?}");
2408        let has_schemas = fold_findings(&Folder::<true, true>);
2409        assert_eq!(has_schemas.len(), 1, "{has_schemas:?}");
2410        assert!(has_schemas[0].what.contains("says has schemas"), "{has_schemas:?}");
2411    }
2412
2413    /// **A stated `MERGE` restriction is reported; a default one is not.**
2414    ///
2415    /// The asymmetry is the point. A dialect that says its planner needs a
2416    /// literal slice gets Razeri's plan for that answer back, while the engine
2417    /// it is describing is still in front of the author. A dialect that has
2418    /// said nothing gets nothing, because the kit cannot tell a measured
2419    /// *"a parameter is fine here"* from a question never asked, and a finding
2420    /// made out of that silence would fire on every dialect alike.
2421    #[test]
2422    fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
2423        let mut quiet = Vec::new();
2424        check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
2425        assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
2426
2427        let mut stated = Vec::new();
2428        check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
2429        assert_eq!(stated.len(), 1, "the stated restriction is reported once");
2430        assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
2431        assert!(
2432            stated[0].what.contains("literal"),
2433            "the finding names the restriction:\n{}",
2434            stated[0].what
2435        );
2436        assert!(
2437            stated[0].fix.contains("dynamic-execution"),
2438            "and says what Razeri does with the answer:\n{}",
2439            stated[0].fix
2440        );
2441
2442        // The other restriction travels on the same struct and must not drag
2443        // the new finding along with it.
2444        let mut two_clauses = Vec::new();
2445        check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
2446        assert!(two_clauses.is_empty(), "one restriction is not the other");
2447    }
2448
2449    /// **A dialect written outside Razeri reaches a clean report.** It is
2450    /// reached the way every out-of-tree dialect is reached — it registered, and
2451    /// the resolver reads the loaded dialects' own declared spellings — so there
2452    /// is nothing for Razeri to be missing it from and nothing for the report to
2453    /// ask of its author.
2454    #[test]
2455    fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
2456        assert!(
2457            crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
2458            "the fixture registers through the registry, which is the whole path being graded"
2459        );
2460        // The precondition that used to read "no row in Razeri's table": no
2461        // other loaded platform declares this name, so the fixture is reached
2462        // only because it registered and said so itself.
2463        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2464        assert!(
2465            registry
2466                .list()
2467                .iter()
2468                .filter(|info| info.name != Outside::NAME)
2469                .all(|info| !info.answers_to(Outside::NAME)),
2470            "the fixture's name must be one no other platform claims"
2471        );
2472
2473        let report = check(&OutsidePlugin::new());
2474        assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
2475        assert!(report.is_clean(), "and the report says so:\n{report}");
2476        assert_eq!(report.count(Severity::Blocking), 0, "{report}");
2477        assert!(
2478            report.to_string().contains("nothing to fix"),
2479            "a clean dialect is told it is clean:\n{report}"
2480        );
2481    }
2482
2483    /// **A platform cannot be reachable under an alias but not under its own
2484    /// name.** A dialect declares its own spellings and its own name leads
2485    /// them, so a platform reachable as `mynew_db` and never as `mynewdb`
2486    /// cannot be written down — and this is the assertion that keeps it that
2487    /// way.
2488    #[test]
2489    fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
2490        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2491        for info in registry.list() {
2492            assert_eq!(
2493                info.spellings().first().copied(),
2494                Some(info.name),
2495                "a platform's own name leads its spellings"
2496            );
2497            assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
2498        }
2499    }
2500
2501    /// **A name another platform already answers to is a collision, not a
2502    /// registration.** A dialect claiming a spelling Healthy DB answers to is
2503    /// named as the collision it is — with both platforms named, and a fix its
2504    /// author can actually perform — rather than being reported as an
2505    /// unregistered platform, which is a different problem with a different fix.
2506    #[test]
2507    fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
2508        let mut registry = crate::dialect::PlatformRegistry::new();
2509        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2510        registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2511
2512        let report = check_with_registry(&Twin, &registry);
2513        let collision = report
2514            .findings
2515            .iter()
2516            .find(|f| f.what.contains("claimed by more than one loaded platform"))
2517            .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
2518        assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
2519        assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
2520        assert!(collision.what.contains("twindb"), "{collision:?}");
2521        assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
2522        assert!(
2523            !mentions(&report, "does not resolve"),
2524            "a collision is not an unregistered platform:\n{report}"
2525        );
2526    }
2527
2528    /// A platform reached under its own name owes nothing, whatever the case or
2529    /// the surrounding whitespace, and a name nothing has ever heard of is
2530    /// simply unknown rather than a finding against this dialect.
2531    #[test]
2532    fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
2533        let mut registry = crate::dialect::PlatformRegistry::new();
2534        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2535
2536        let mut findings = Vec::new();
2537        check_registration(&HealthyPlugin::new().info(), &registry, &mut findings);
2538        assert!(findings.is_empty(), "{findings:?}");
2539
2540        for spelling in ["healthydb", "HealthyDB", "  healthydb  "] {
2541            assert!(
2542                crate::resolve::resolve_dialect_by_name_with_registry(spelling, &registry).is_ok(),
2543                "'{spelling}' is the same platform"
2544            );
2545        }
2546        assert!(
2547            crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", &registry).is_err()
2548        );
2549    }
2550
2551    /// **A dialect the host loaded at run time can be graded at all.** [`check`]
2552    /// asks the dialects linked into this program, which one loaded from a
2553    /// shared library is not among; [`check_with_registry`] asks the registry it
2554    /// was loaded into, and the registration judgement is answered from there.
2555    #[test]
2556    fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
2557        let mut registry = crate::dialect::PlatformRegistry::new();
2558        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2559
2560        let report = check_with_registry(&HealthyPlugin::new(), &registry);
2561        assert!(
2562            report.is_clean(),
2563            "a loaded dialect that answered everything owes nothing:\n{report}"
2564        );
2565    }
2566
2567    /// **Every fix an outside author can read is a fix in their own crate.** A
2568    /// report that names a file inside Razeri is telling an author to edit a
2569    /// tree they do not have, and the fix cannot be performed at all.
2570    #[test]
2571    fn no_fix_line_sends_an_author_into_razeris_own_tree() {
2572        let mut collided = crate::dialect::PlatformRegistry::new();
2573        collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2574        collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2575
2576        let mut fixes: Vec<String> = Vec::new();
2577        for report in [
2578            check(&BrokenPlugin),
2579            check(&HealthyPlugin::new()),
2580            check(&OutsidePlugin::new()),
2581            // The collision and the unregistered-name judgements, which are the
2582            // two registration findings an author can actually reach.
2583            check_with_registry(&Twin, &collided),
2584            check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2585        ] {
2586            fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
2587        }
2588        assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
2589        for fix in &fixes {
2590            for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
2591                assert!(
2592                    !fix.contains(path),
2593                    "a fix line names `{path}`, which is not in the author's crate:\n  {fix}"
2594                );
2595            }
2596        }
2597    }
2598
2599    /// **No finding prints a run of spaces in the middle of a sentence.** A
2600    /// string literal broken across source lines without a `\` keeps the next
2601    /// line's indentation, and the report then reads "the same as a", a run of
2602    /// eighteen spaces, then "column". This reaches every finding the fixtures
2603    /// can reach, plus the four that only a dialect declining every column
2604    /// change and spelling its own routine grammar can.
2605    #[test]
2606    fn no_finding_holds_a_run_of_spaces() {
2607        struct Declining;
2608        impl SqlRenderer for Declining {
2609            fn render_data_type(&self, dt: &DataType) -> String {
2610                razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2611            }
2612            fn quote_identifier(&self, name: &str) -> String {
2613                format!("\"{name}\"")
2614            }
2615            fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2616                name.to_owned()
2617            }
2618            fn render_current_timestamp(&self) -> String {
2619                "CURRENT_TIMESTAMP".to_owned()
2620            }
2621            fn procedure_form(&self) -> ProcedureForm {
2622                ProcedureForm::Custom("Declining PSQL")
2623            }
2624        }
2625        impl ExpressionRenderer for Declining {}
2626        impl DdlRenderer for Declining {
2627            fn render_column_evolution(&self, _change: &ColumnEvolution<'_>) -> Option<String> {
2628                None
2629            }
2630        }
2631        impl DialectCapabilities for Declining {
2632            fn evolution_answers(&self) -> razeri_plugin_contract::EvolutionAnswers {
2633                razeri_plugin_contract::EvolutionAnswers::widening_a_string_in_place()
2634                    .and_a_nested_field()
2635            }
2636        }
2637        impl DmlRenderer for Declining {}
2638        impl ProcedureRenderer for Declining {}
2639        impl FormulaRenderer for Declining {}
2640        impl TaskRenderer for Declining {}
2641        impl ReplicationRenderer for Declining {}
2642        impl StorageRenderer for Declining {}
2643        impl PlatformDialect for Declining {
2644            fn info(&self) -> DialectInfo {
2645                DialectInfo {
2646                    name: "decliningdb",
2647                    display_name: "Declining DB",
2648                    aliases: &[],
2649                    dialect_family: DialectFamily::Custom("decliningdb"),
2650                    family: "Declining",
2651                    is_cloud_native: false,
2652                    is_olap: false,
2653                    is_postgres_compatible: false,
2654                    versions: &[],
2655                    deploy_script: None,
2656                }
2657            }
2658        }
2659
2660        let mut findings = Vec::new();
2661        check_evolution(&Declining, &mut findings);
2662        check_parameter_type_spelling(&Declining, &mut findings);
2663        assert_eq!(findings.len(), 4, "every column change and the parameter note: {findings:?}");
2664
2665        for report in [
2666            check(&BrokenPlugin),
2667            check(&HealthyPlugin::new()),
2668            check(&OutsidePlugin::new()),
2669            check(&Declining),
2670            check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2671        ] {
2672            findings.extend(report.findings);
2673        }
2674        for finding in &findings {
2675            for text in [&finding.what, &finding.fix] {
2676                assert!(!text.contains("  "), "a finding holds a run of spaces:\n  {text:?}");
2677            }
2678        }
2679    }
2680
2681    /// **A dialect that claims its own grammar and does not spell it is
2682    /// blocking**, and the finding names the method it owes.
2683    ///
2684    /// `Custom` is a promise that the dialect renders the routine itself, and
2685    /// Razeri keeps its side by writing nothing: no near-neighbour grammar,
2686    /// which fails on deploy, and no refusal, which would tell an author their
2687    /// engine has no routines when it has a whole language. A promise with
2688    /// nothing behind it is therefore a build that stops, and it has to be
2689    /// findable before the deploy rather than during it.
2690    #[test]
2691    fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
2692        for (kind, form_method, render_method) in [
2693            (RoutineKind::Procedure, "procedure_form", "render_procedure"),
2694            (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
2695            (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
2696        ] {
2697            let mut promised = Vec::new();
2698            check_custom_grammar(
2699                CustomGrammar {
2700                    kind,
2701                    grammar: "PL/SQL",
2702                    form_method,
2703                    render_method,
2704                    noun: "routines",
2705                },
2706                |_| None,
2707                &mut promised,
2708            );
2709            assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
2710            assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
2711            assert!(promised[0].what.contains(form_method), "{promised:?}");
2712            assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
2713            assert!(promised[0].fix.contains(render_method), "{promised:?}");
2714
2715            // The same claim, kept: nothing to report.
2716            let mut kept = Vec::new();
2717            check_custom_grammar(
2718                CustomGrammar {
2719                    kind,
2720                    grammar: "PL/SQL",
2721                    form_method,
2722                    render_method,
2723                    noun: "routines",
2724                },
2725                |spec| Some(format!("CREATE {} …", spec.name)),
2726                &mut kept,
2727            );
2728            assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
2729        }
2730    }
2731
2732    /// **The two answers about stored procedures have to agree.** A dialect
2733    /// that writes its own procedures and leaves the capability at its default
2734    /// `false` gets no load written as one; a dialect that says its engine has
2735    /// them and refuses to write one says both things at once.
2736    #[test]
2737    fn a_procedure_form_and_the_procedure_capability_must_agree() {
2738        fn check_forms(dialect: &dyn PlatformDialect) -> Report {
2739            let mut findings = Vec::new();
2740            check_routine_forms(dialect, &mut findings);
2741            Report { dialect: "claimsdb".to_owned(), findings, taken_as_default: Vec::new() }
2742        }
2743
2744        /// `CUSTOM`: the form spells its own grammar, or refuses. `HAS`: the
2745        /// capability's answer.
2746        struct Claims<const CUSTOM: bool, const HAS: bool>;
2747        impl<const CUSTOM: bool, const HAS: bool> SqlRenderer for Claims<CUSTOM, HAS> {
2748            fn render_data_type(&self, dt: &DataType) -> String {
2749                razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2750            }
2751            fn quote_identifier(&self, name: &str) -> String {
2752                format!("\"{name}\"")
2753            }
2754            fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2755                self.quote_identifier(name)
2756            }
2757            fn render_current_timestamp(&self) -> String {
2758                "CURRENT_TIMESTAMP".to_owned()
2759            }
2760            fn procedure_form(&self) -> ProcedureForm {
2761                if CUSTOM {
2762                    ProcedureForm::Custom("Firebird PSQL")
2763                } else {
2764                    ProcedureForm::Unsupported(
2765                        "This engine has no stored procedures; schedule the load as a query.",
2766                    )
2767                }
2768            }
2769            fn render_procedure(&self, spec: &RoutineSpec) -> Option<String> {
2770                Some(format!("CREATE PROCEDURE {} AS BEGIN END", spec.name))
2771            }
2772        }
2773        impl<const CUSTOM: bool, const HAS: bool> ExpressionRenderer for Claims<CUSTOM, HAS> {}
2774        impl<const CUSTOM: bool, const HAS: bool> DdlRenderer for Claims<CUSTOM, HAS> {}
2775        impl<const CUSTOM: bool, const HAS: bool> DmlRenderer for Claims<CUSTOM, HAS> {}
2776        impl<const CUSTOM: bool, const HAS: bool> ProcedureRenderer for Claims<CUSTOM, HAS> {}
2777        impl<const CUSTOM: bool, const HAS: bool> FormulaRenderer for Claims<CUSTOM, HAS> {}
2778        impl<const CUSTOM: bool, const HAS: bool> TaskRenderer for Claims<CUSTOM, HAS> {}
2779        impl<const CUSTOM: bool, const HAS: bool> ReplicationRenderer for Claims<CUSTOM, HAS> {}
2780        impl<const CUSTOM: bool, const HAS: bool> StorageRenderer for Claims<CUSTOM, HAS> {}
2781        impl<const CUSTOM: bool, const HAS: bool> DialectCapabilities for Claims<CUSTOM, HAS> {
2782            fn supports_stored_procedures(&self) -> bool {
2783                HAS
2784            }
2785        }
2786        impl<const CUSTOM: bool, const HAS: bool> PlatformDialect for Claims<CUSTOM, HAS> {
2787            fn info(&self) -> DialectInfo {
2788                DialectInfo {
2789                    name: "claimsdb",
2790                    display_name: "Claims DB",
2791                    aliases: &[],
2792                    dialect_family: DialectFamily::Custom("claimsdb"),
2793                    family: "Claims",
2794                    is_cloud_native: false,
2795                    is_olap: false,
2796                    is_postgres_compatible: false,
2797                    versions: &[],
2798                    deploy_script: None,
2799                }
2800            }
2801        }
2802
2803        let disagreement = |report: &Report| {
2804            report
2805                .findings
2806                .iter()
2807                .filter(|finding| {
2808                    finding.severity == Severity::Blocking
2809                        && (finding.what.contains("says its engine has none")
2810                            || finding.what.contains("refuses to write one"))
2811                })
2812                .count()
2813        };
2814        let mut findings = Vec::new();
2815        check_routine_forms(&Claims::<true, false>, &mut findings);
2816        let report =
2817            Report { dialect: "claimsdb".to_owned(), findings, taken_as_default: Vec::new() };
2818        assert_eq!(disagreement(&report), 1, "{report}");
2819        assert!(report.to_string().contains("supports_stored_procedures"), "{report}");
2820
2821        for (agrees, label) in [
2822            (check_forms(&Claims::<true, true>), "spelled and used"),
2823            (check_forms(&Claims::<false, false>), "refused and absent"),
2824        ] {
2825            assert_eq!(disagreement(&agrees), 0, "{label}: {agrees}");
2826        }
2827        assert_eq!(disagreement(&check_forms(&Claims::<false, true>)), 1, "refused and claimed");
2828    }
2829
2830    /// **A render method that answers with nothing at all is the same defect.**
2831    ///
2832    /// An override that returns an empty string is a routine razeri would emit
2833    /// as blank space in the middle of a schema, which is worse than an error:
2834    /// nothing in the deploy says anything is missing.
2835    #[test]
2836    fn a_custom_render_method_that_returns_nothing_is_blocking() {
2837        let mut findings = Vec::new();
2838        check_custom_grammar(
2839            CustomGrammar {
2840                kind: RoutineKind::Procedure,
2841                grammar: "PL/SQL",
2842                form_method: "procedure_form",
2843                render_method: "render_procedure",
2844                noun: "procedures",
2845            },
2846            |_| Some("   \n".to_owned()),
2847            &mut findings,
2848        );
2849        assert_eq!(findings.len(), 1, "{findings:?}");
2850        assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
2851    }
2852
2853    /// **A `Custom` grammar with no name gives a reader nothing to read.**
2854    #[test]
2855    fn a_custom_grammar_with_no_name_is_blocking() {
2856        let mut findings = Vec::new();
2857        check_custom_grammar(
2858            CustomGrammar {
2859                kind: RoutineKind::ScalarFunction,
2860                grammar: "  ",
2861                form_method: "scalar_function_form",
2862                render_method: "render_scalar_function",
2863                noun: "scalar functions",
2864            },
2865            |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
2866            &mut findings,
2867        );
2868        assert_eq!(findings.len(), 1, "{findings:?}");
2869        assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
2870    }
2871
2872    /// **A stated fact that is empty, and a stated fact that ignores its
2873    /// argument.**
2874    ///
2875    /// Both answers deploy without an error and are wrong in the data: an empty
2876    /// helper body keys every row to nothing, and a file relation that drops
2877    /// its path reads one file for every entity a design binds. Neither is
2878    /// visible anywhere else, which is why the kit grades them.
2879    #[test]
2880    fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
2881        struct Overstated;
2882        impl SqlRenderer for Overstated {
2883            fn render_data_type(&self, dt: &DataType) -> String {
2884                razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2885            }
2886            fn quote_identifier(&self, name: &str) -> String {
2887                format!("\"{name}\"")
2888            }
2889            fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2890                name.to_owned()
2891            }
2892            fn render_current_timestamp(&self) -> String {
2893                "CURRENT_TIMESTAMP".to_owned()
2894            }
2895            fn dialect_name(&self) -> &'static str {
2896                "overstateddb"
2897            }
2898            fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
2899                Some(razeri_plugin_contract::SidHelperBody {
2900                    language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
2901                    body: "   \n  ",
2902                })
2903            }
2904            fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
2905                Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
2906            }
2907        }
2908        impl ExpressionRenderer for Overstated {}
2909        impl DdlRenderer for Overstated {}
2910        impl DmlRenderer for Overstated {}
2911        impl ProcedureRenderer for Overstated {}
2912        impl FormulaRenderer for Overstated {}
2913        impl TaskRenderer for Overstated {}
2914        impl ReplicationRenderer for Overstated {}
2915        impl StorageRenderer for Overstated {}
2916        impl DialectCapabilities for Overstated {}
2917        impl PlatformDialect for Overstated {
2918            fn info(&self) -> DialectInfo {
2919                DialectInfo {
2920                    name: "overstateddb",
2921                    display_name: "Overstated DB",
2922                    aliases: &[],
2923                    dialect_family: DialectFamily::Custom(""),
2924                    family: "Overstated",
2925                    is_cloud_native: false,
2926                    is_olap: false,
2927                    is_postgres_compatible: false,
2928                    versions: &[],
2929                    deploy_script: None,
2930                }
2931            }
2932        }
2933
2934        let report = check(&Overstated);
2935        for (fragment, why) in [
2936            ("supplies an empty one", "a blank helper body"),
2937            ("does not carry the path", "a file relation that drops its path"),
2938        ] {
2939            let found = report
2940                .findings
2941                .iter()
2942                .find(|f| f.what.contains(fragment))
2943                .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
2944            assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
2945        }
2946
2947        // And silence is not a finding. The fully-answered fixture states
2948        // neither fact — the majority answer — and owes nothing for it.
2949        let quiet = check(&HealthyPlugin::new());
2950        for fragment in ["supplies an empty one", "does not carry the path"] {
2951            assert!(
2952                !quiet.findings.iter().any(|f| f.what.contains(fragment)),
2953                "silence is the default and owes nothing:\n{quiet}"
2954            );
2955        }
2956    }
2957
2958    /// **A stated refusal is a claim, and a claim owes the reader words** — the
2959    /// same grading every other refusal gets, because these words are the only
2960    /// thing an operator finds where the load would have been.
2961    #[test]
2962    fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
2963        let mut mute = Vec::new();
2964        check_refusal_copy("the upsert refusal", "no merge", &mut mute);
2965        assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
2966        assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
2967
2968        let silence = check(&BrokenPlugin);
2969        assert!(
2970            silence
2971                .findings
2972                .iter()
2973                .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
2974            "a dialect that never answered is unanswered, not wrong:\n{silence}"
2975        );
2976        assert!(
2977            silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
2978            "the fix line names the way to state a measured refusal:\n{silence}"
2979        );
2980    }
2981
2982    /// The report leads with what to fix and says it in words a reader can
2983    /// act on — no identifiers from Razeri's own record-keeping, and a
2984    /// one-line count before the detail.
2985    #[test]
2986    fn the_report_reads_as_a_summary_then_a_fix_list() {
2987        let text = check(&BrokenPlugin).to_string();
2988        let first = text.lines().next().expect("a summary line");
2989        assert!(first.starts_with("brokendb:"), "{first}");
2990        assert!(first.contains("to fix"), "{first}");
2991        assert!(text.contains("      → "), "every finding names the fix:\n{text}");
2992    }
2993}