Skip to main content

razeri_plugin_api/
conformance.rs

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