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