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