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