Skip to main content

razeri_plugin_api/
conformance.rs

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