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//! Two people built dialects against Razeri's authoring guide without help.
8//! Between them they met twenty-seven obligations one `cargo test` at a time —
9//! a roster fails, you add a row, the next roster fails. Not one of those
10//! failures was discoverable before it fired. This module is the report that
11//! 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//! ```no_run
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//! Inside Razeri's own repository, `razeri plugin conform <name>` prints the
27//! same report for any registered platform, followed by the repository-side
28//! obligations a command cannot run for you.
29//!
30//! # What it can and cannot see
31//!
32//! Everything here is read off the dialect object, so it works wherever your
33//! plugin compiles. What it cannot do is run Razeri's frozen matrices or
34//! regenerate its language bindings — those are commands, and
35//! [`repository_obligations`] lists them in the order they are owed.
36//!
37//! # Your dialect's name
38//!
39//! Your platform's names are yours. `DialectInfo::name` is the one Razeri
40//! reports, `aliases` is every other spelling a design may use for it, and the
41//! resolver reads both off the dialects that are loaded — so the report asks
42//! only that your own spellings reach you, and there is no table inside Razeri
43//! for your platform to be missing from. The one thing it will tell you is that
44//! a spelling you declared is one another loaded platform also answers to,
45//! because then the spelling names neither of you.
46
47use crate::PlatformDialect;
48use razeri_plugin_contract::{
49    ConstraintEnforcement, DataType, DdlRenderer, DialectCapabilities, DialectFamily, MergeShape,
50    ProcedureForm, SqlRenderer, UNKNOWN_PLATFORM,
51};
52use std::fmt;
53
54/// How much a finding matters.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
56pub enum Severity {
57    /// **This dialect will produce SQL its engine rejects.** Fix it before
58    /// anything else.
59    Blocking,
60    /// **A question this dialect has not answered.** Razeri has a default and
61    /// will use it; the default was written for a different engine.
62    Unanswered,
63    /// Worth a look. Nothing is broken.
64    Note,
65}
66
67impl fmt::Display for Severity {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.write_str(match self {
70            Self::Blocking => "must fix",
71            Self::Unanswered => "unanswered",
72            Self::Note => "worth a look",
73        })
74    }
75}
76
77/// One thing to fix, in the order a reader wants it: what is wrong, then what
78/// to do about it.
79#[derive(Debug, Clone)]
80pub struct Finding {
81    /// How much it matters.
82    pub severity: Severity,
83    /// One line naming what is wrong.
84    pub what: String,
85    /// One line naming what to do.
86    pub fix: String,
87}
88
89/// A command a plugin author runs inside Razeri's repository, and why.
90#[derive(Debug, Clone, Copy)]
91pub struct Obligation {
92    /// What this step is for.
93    pub what: &'static str,
94    /// The command, exactly as typed.
95    pub command: &'static str,
96}
97
98/// The whole report for one dialect.
99#[derive(Debug, Clone)]
100pub struct Report {
101    /// The dialect's registry name.
102    pub dialect: String,
103    /// Everything found, worst first.
104    pub findings: Vec<Finding>,
105}
106
107impl Report {
108    /// Whether nothing blocking or unanswered was found.
109    ///
110    /// Notes do not fail a report: they are observations, not obligations.
111    #[must_use]
112    pub fn is_clean(&self) -> bool {
113        !self.findings.iter().any(|f| f.severity != Severity::Note)
114    }
115
116    /// How many findings of one severity.
117    #[must_use]
118    pub fn count(&self, severity: Severity) -> usize {
119        self.findings.iter().filter(|f| f.severity == severity).count()
120    }
121}
122
123impl fmt::Display for Report {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        let name = &self.dialect;
126        if self.findings.is_empty() {
127            return writeln!(
128                f,
129                "{name}: nothing to fix. Every question Razeri asks a dialect, this one answers."
130            );
131        }
132        let blocking = self.count(Severity::Blocking);
133        let unanswered = self.count(Severity::Unanswered);
134        let notes = self.count(Severity::Note);
135        writeln!(f, "{name}: {blocking} to fix, {unanswered} unanswered, {notes} worth a look.")?;
136        for finding in &self.findings {
137            writeln!(f)?;
138            writeln!(f, "  [{}] {}", finding.severity, finding.what)?;
139            writeln!(f, "      → {}", finding.fix)?;
140        }
141        Ok(())
142    }
143}
144
145/// **The report.** Everything about this dialect that Razeri can check by
146/// asking it.
147///
148/// Registration is checked against the dialects linked into this program. A
149/// dialect the host loaded at run time is not among them, so report it with
150/// [`check_with_registry`] and the registry it was loaded into.
151#[must_use]
152pub fn check(dialect: &dyn PlatformDialect) -> Report {
153    check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
154}
155
156/// The same report, against a registry the host built — the path for a dialect
157/// loaded at run time rather than linked in.
158#[must_use]
159pub fn check_with_registry(
160    dialect: &dyn PlatformDialect,
161    registry: &crate::dialect::PlatformRegistry,
162) -> Report {
163    let info = dialect.info();
164    let mut findings = Vec::new();
165
166    check_registration(&info, registry, &mut findings);
167    check_family(info.dialect_family, &mut findings);
168    check_required_rendering(dialect, &mut findings);
169    check_decisions(dialect, &mut findings);
170    check_formula_renderer(dialect, &mut findings);
171    check_paired_facts(dialect, &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 `inventory::submit! { DialectRegistration::new(\"<name>\", || \
203                  Arc::new(MyDialect)) }` to your crate, and a `pub fn force_link() {}` beside \
204                  it that the program using your dialect calls once. A crate nothing links is a \
205                  crate the registry cannot see."
206                .to_owned(),
207        });
208        return;
209    }
210    for spelling in info.spellings() {
211        match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
212            Ok(found) if found.info().name == name => {}
213            Ok(found) => findings.push(Finding {
214                severity: Severity::Blocking,
215                what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
216                fix: format!(
217                    "the registry holds `{name}` under another key. Register it under its own \
218                     name, or drop `{spelling}` from its aliases."
219                ),
220            }),
221            Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
222                severity: Severity::Blocking,
223                what: format!(
224                    "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
225                     none of them — Razeri refuses that spelling rather than guessing which one \
226                     a design meant",
227                    claimed_by.join(" and ")
228                ),
229                fix: format!(
230                    "give this dialect a name no other platform answers to. A platform name is \
231                     read off the loaded dialects' own declared spellings, so a spelling another \
232                     platform already claims is never `{name}`'s."
233                ),
234            }),
235            Err(_) => findings.push(Finding {
236                severity: Severity::Blocking,
237                what: format!(
238                    "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
239                ),
240                fix: format!(
241                    "the `{name}` in the registry is a different build of this dialect and does \
242                     not declare `{spelling}`. Register the one you are grading."
243                ),
244            }),
245        }
246    }
247}
248
249/// A family is a claim about spelling, so an unmeasured engine claiming a
250/// measured family's name licenses SQL it may reject.
251fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
252    if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
253        findings.push(Finding {
254            severity: Severity::Blocking,
255            what: "this dialect's family is a custom one with no name".to_owned(),
256            fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
257                  prints and what a reader looks up."
258                .to_owned(),
259        });
260    }
261}
262
263/// The four required methods must answer for every type a design can declare.
264fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
265    let declarable = declarable_types();
266    let empty_column: Vec<String> = declarable
267        .iter()
268        .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
269        .map(|dt| format!("{dt:?}"))
270        .collect();
271    if !empty_column.is_empty() {
272        findings.push(Finding {
273            severity: Severity::Blocking,
274            what: format!(
275                "`render_data_type` answers nothing for {} of the types a design can declare",
276                empty_column.len()
277            ),
278            fix: "every declarable type needs a column spelling on this engine, even if the \
279                  spelling is a wider type that stores it. A blank answer becomes a column \
280                  declaration with no type in it."
281                .to_owned(),
282        });
283    }
284    let empty_cast: Vec<String> = declarable
285        .iter()
286        .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
287        .map(|dt| format!("{dt:?}"))
288        .collect();
289    if !empty_cast.is_empty() {
290        findings.push(Finding {
291            severity: Severity::Blocking,
292            what: format!(
293                "`render_cast_type` answers nothing for {} declarable types",
294                empty_cast.len()
295            ),
296            fix: "the generated load opens by casting every source column to its declared type, \
297                  so a blank answer fails the load on its first statement."
298                .to_owned(),
299        });
300    }
301    let quoted = SqlRenderer::quote_identifier(dialect, "order");
302    if quoted == "order" {
303        findings.push(Finding {
304            severity: Severity::Blocking,
305            what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
306                   reaches this engine bare"
307                .to_owned(),
308            fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
309                  backticks, or brackets."
310                .to_owned(),
311        });
312    }
313    if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
314        findings.push(Finding {
315            severity: Severity::Blocking,
316            what: "`render_current_timestamp` answers nothing, and every generated load stamps \
317                   a row with it"
318                .to_owned(),
319            fix: "return this engine's own expression for the current moment.".to_owned(),
320        });
321    }
322}
323
324/// The decisions a dialect states rather than inherits.
325fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
326    let spelling = SqlRenderer::sid_spelling(dialect);
327    if spelling.platform == UNKNOWN_PLATFORM {
328        findings.push(Finding {
329            severity: Severity::Blocking,
330            what: "this dialect states no surrogate-key spelling, so every key it renders calls \
331                   an `xxhash64` its engine very likely does not have"
332                .to_owned(),
333            fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
334                  TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
335                  hash. If it has no portable hash, name an artifact your prerequisites install \
336                  — never the nearest-looking built-in, which keys a warehouse to a number \
337                  nothing else can reproduce."
338                .to_owned(),
339        });
340    }
341
342    match SqlRenderer::procedure_form(dialect) {
343        // Razeri's own agnostic renderer answers `Agnostic` on purpose: the
344        // annotated form IS its output. Every other dialect that answers it has
345        // simply not said.
346        ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
347        ProcedureForm::Agnostic => findings.push(Finding {
348            severity: Severity::Unanswered,
349            what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
350                   annotated form — which no engine parses"
351                .to_owned(),
352            fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
353                  or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
354                  offers instead."
355                .to_owned(),
356        }),
357        ProcedureForm::Unsupported(orientation) => {
358            check_refusal_copy("the stored-procedure refusal", orientation, findings);
359        }
360        _ => {}
361    }
362
363    if let Some(orientation) = SqlRenderer::table_function_form(dialect).refusal() {
364        check_refusal_copy("the table-function refusal", orientation, findings);
365    }
366
367    // The note is what a reader of the DDL learns from, and on an engine with
368    // no keyword of its own it is the ONLY place they can learn it.
369    if let ConstraintEnforcement::DeclaredOnly(declared) =
370        DdlRenderer::constraint_enforcement(dialect)
371        && declared.note.split_whitespace().count() < 5
372    {
373        findings.push(Finding {
374            severity: Severity::Blocking,
375            what: "this dialect says its engine accepts constraints it does not enforce, and \
376                   the note explaining that says almost nothing"
377                .to_owned(),
378            fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
379                  guarantee. Say what this engine really does with the declaration, and where \
380                  the guarantee has to come from instead."
381                .to_owned(),
382        });
383    }
384
385    // **The two refusals are different findings, and that is the point of the
386    // second variant.** `None` is silence, and silence is unanswered. A stated
387    // `Unsupported` is a measured claim; what is left to grade there is whether
388    // the sentence is worth reading, because it is the only thing an operator
389    // gets where the load would have been.
390    match DialectCapabilities::merge_shape(dialect) {
391        MergeShape::None => findings.push(Finding {
392            severity: Severity::Unanswered,
393            what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
394                   load for it"
395                .to_owned(),
396            fix: "if this engine has a MERGE or an upsert statement, say which in \
397                  `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
398                  `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
399                  operator can act on, rather than a silence."
400                .to_owned(),
401        }),
402        MergeShape::Unsupported(orientation) => {
403            check_refusal_copy("the upsert refusal", orientation, findings);
404        }
405        _ => {}
406    }
407}
408
409/// The one override nothing tells an author about, and every formula in every
410/// design goes through it.
411fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
412    let formula = SqlRenderer::to_formula_renderer(dialect);
413    let dialect_name = SqlRenderer::dialect_name(dialect);
414    if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
415        findings.push(Finding {
416            severity: Severity::Blocking,
417            what: "`to_formula_renderer` was not overridden, so every formula in every design \
418                   renders through Razeri's ANSI defaults instead of this dialect"
419                .to_owned(),
420            fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
421                  formula this engine sees is silently somebody else's."
422                .to_owned(),
423        });
424    }
425}
426
427/// A refusal that does not say what to do instead is a wall.
428fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
429    if copy.trim().is_empty() {
430        findings.push(Finding {
431            severity: Severity::Blocking,
432            what: format!("{subject} has no words in it"),
433            fix: "say what this engine offers instead. A reader who hits a refusal needs the \
434                  next thing to try, and it has to be about their engine."
435                .to_owned(),
436        });
437        return;
438    }
439    if copy.split_whitespace().count() < 5 {
440        findings.push(Finding {
441            severity: Severity::Note,
442            what: format!("{subject} is very short"),
443            fix: "a refusal earns its place by naming the alternative — what this engine has \
444                  instead of the thing it cannot do."
445                .to_owned(),
446        });
447    }
448}
449
450/// Fourteen facts are declared twice and must agree.
451fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
452    let caps: &dyn DialectCapabilities = dialect;
453    let ren: &dyn SqlRenderer = dialect;
454    let ddl: &dyn DdlRenderer = dialect;
455    let mut disagreements: Vec<&'static str> = Vec::new();
456
457    macro_rules! pair {
458        ($m:ident, $renderer_value:expr) => {
459            if caps.$m() != $renderer_value {
460                disagreements.push(stringify!($m));
461            }
462        };
463    }
464    pair!(supports_on_conflict, ren.supports_on_conflict());
465    pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
466    pair!(supports_update_from, ren.supports_update_from());
467    pair!(supports_named_windows, ren.supports_named_windows());
468    pair!(supports_compound_assignment, ren.supports_compound_assignment());
469    pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
470    pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
471    pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
472    pair!(uses_alter_table_update, ren.uses_alter_table_update());
473    pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
474    pair!(supports_cascade_drop, ddl.supports_cascade_drop());
475    pair!(supports_sync_drop, ddl.supports_sync_drop());
476    pair!(supports_force_drop, ddl.supports_force_drop());
477
478    // **The fourteenth, whose two sides are different types.** The capability
479    // is a boolean; the renderer answer is a form that either renders or
480    // carries a refusal. Both are read — the build door reads the form, and
481    // registry-walking rosters read the capability — so a dialect claiming a
482    // table function on one side and refusing on the other has an engine that
483    // both does and does not have one. `SQLite` was exactly that on the day
484    // this check was written: `false` since the capability existed, and a
485    // renderer still inheriting PostgreSQL's `RETURNS TABLE`.
486    if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
487        disagreements.push("supports_table_functions");
488    }
489
490    if !disagreements.is_empty() {
491        findings.push(Finding {
492            severity: Severity::Blocking,
493            what: format!(
494                "this dialect declares {} fact(s) two ways and the two disagree: {}",
495                disagreements.len(),
496                disagreements.join(", ")
497            ),
498            fix: "each of these is declared once on the capability trait and once in the \
499                  renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
500                  and move the other one to match."
501                .to_owned(),
502        });
503    }
504}
505
506/// **What a command has to do for you**, in the order it is owed.
507///
508/// These are Razeri's repository obligations: frozen matrices, generated
509/// bindings, and the allowlist a new crate joins. A dialect built outside this
510/// repository owes none of them; a dialect built inside it owes all of them,
511/// and each has an excellent failure message that nobody can read before it
512/// fires.
513#[must_use]
514pub const fn repository_obligations() -> &'static [Obligation] {
515    &[
516        Obligation {
517            what: "classify the new crate — every workspace member is named exactly once, and \
518                   dialect crates are tier 1",
519            command: "scripts/check-tier-boundary.sh",
520        },
521        Obligation {
522            what: "freeze the DDL your platform renders for all eleven corpus designs",
523            command: "UPDATE_GOLDEN=1 cargo test -p razeri-materialize --test golden_ddl",
524        },
525        Obligation {
526            what: "freeze the derived-column projections your platform renders",
527            command: "UPDATE_GOLDEN=1 cargo test -p razeri-materialize --test expression_matrix",
528        },
529        Obligation {
530            what: "freeze the transaction envelope your platform renders",
531            command: "UPDATE_GOLDEN=1 cargo test -p razeri-materialize \
532                      --test load_transaction_matrix",
533        },
534        Obligation {
535            what: "regenerate the typed platform constants for Python, TypeScript, Go, C# and \
536                   Java",
537            command: "cargo run -p razeri-dialects --bin gen-platform-constants",
538        },
539        Obligation {
540            what: "give every registry-walking roster a row for your platform — they fail one \
541                   at a time otherwise",
542            command: "cargo test -p razeri-materialize -p razeri-sql",
543        },
544        Obligation {
545            what: "prove the artifacts deploy and run: an execution leg per routine kind and \
546                   per generated load is what finished means here",
547            command: "cargo test -p integration-tests",
548        },
549    ]
550}
551
552/// Every type a design can declare, which is the set the required methods owe
553/// an answer for.
554fn declarable_types() -> Vec<DataType> {
555    vec![
556        DataType::tinyint(),
557        DataType::smallint(),
558        DataType::int(),
559        DataType::bigint(),
560        DataType::double(),
561        DataType::boolean(),
562        DataType::text(),
563        DataType::varchar(200),
564        DataType::uuid(),
565        DataType::json(),
566        DataType::date(),
567        DataType::timestamp(),
568        DataType::timestamp_tz(),
569        DataType::decimal(18, 2),
570    ]
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::dialect::DialectInfo;
577    use razeri_plugin_contract::{
578        DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions, ProcedureRenderer,
579        ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer, TaskRenderer,
580        TextCast,
581    };
582    use std::marker::PhantomData;
583
584    // ── The broken fixture ────────────────────────────────────────────
585    //
586    // A plugin that compiles, registers nothing it should, and answers every
587    // question with somebody else's default. This is what the kit exists to
588    // catch, and it is deliberately the plugin an author gets by implementing
589    // only the four required methods and stopping — which the guide, until
590    // now, told them was enough.
591
592    struct BrokenPlugin;
593
594    impl SqlRenderer for BrokenPlugin {
595        fn render_data_type(&self, _dt: &DataType) -> String {
596            String::new()
597        }
598        fn quote_identifier(&self, name: &str) -> String {
599            name.to_owned()
600        }
601        fn render_cast_type(&self, _dt: &DataType) -> String {
602            String::new()
603        }
604        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
605            name.to_owned()
606        }
607        fn render_current_timestamp(&self) -> String {
608            String::new()
609        }
610        fn dialect_name(&self) -> &'static str {
611            "brokendb"
612        }
613        // No `sid_spelling`, no `procedure_form`, no `to_formula_renderer` —
614        // and one fact declared twice, two ways.
615        fn supports_on_conflict(&self) -> bool {
616            true
617        }
618    }
619    impl ExpressionRenderer for BrokenPlugin {}
620    impl DdlRenderer for BrokenPlugin {}
621    impl DmlRenderer for BrokenPlugin {}
622    impl ProcedureRenderer for BrokenPlugin {}
623    impl FormulaRenderer for BrokenPlugin {}
624    impl TaskRenderer for BrokenPlugin {}
625    impl ReplicationRenderer for BrokenPlugin {}
626    impl StorageRenderer for BrokenPlugin {}
627    impl DialectCapabilities for BrokenPlugin {
628        fn supports_on_conflict(&self) -> bool {
629            false
630        }
631    }
632    impl PlatformDialect for BrokenPlugin {
633        fn info(&self) -> DialectInfo {
634            DialectInfo {
635                name: "brokendb",
636                display_name: "Broken DB",
637                aliases: &[],
638                dialect_family: DialectFamily::Custom(""),
639                family: "Broken",
640                is_cloud_native: false,
641                is_olap: false,
642                is_postgres_compatible: false,
643            }
644        }
645    }
646
647    // ── The answered fixture, under two names ─────────────────────────
648    //
649    // The same plugin with every question answered, carrying the name it
650    // registers under as a type parameter. Neither name is one Razeri knows a
651    // spelling for; the difference is that one of them is in the registry and
652    // the other is in nothing at all. Those are two different facts about a
653    // dialect, and they read as two different reports.
654
655    trait FixtureName: Send + Sync + 'static {
656        /// The registry name this fixture answers to.
657        const NAME: &'static str;
658    }
659
660    /// Answered, and nothing linked it — what an author sees running the report
661    /// from a crate whose registration nobody calls.
662    struct Unlinked;
663    impl FixtureName for Unlinked {
664        const NAME: &'static str = "healthydb";
665    }
666
667    /// Answered and registered, under a name Razeri ships no spelling for —
668    /// the shape of every dialect written outside Razeri's own tree.
669    struct Outside;
670    impl FixtureName for Outside {
671        const NAME: &'static str = "outsidedb";
672    }
673
674    struct Answered<N: FixtureName>(PhantomData<N>);
675
676    impl<N: FixtureName> Answered<N> {
677        const fn new() -> Self {
678            Self(PhantomData)
679        }
680    }
681
682    type HealthyPlugin = Answered<Unlinked>;
683    type OutsidePlugin = Answered<Outside>;
684
685    inventory::submit! {
686        crate::builder::DialectRegistration::new(
687            Outside::NAME,
688            || std::sync::Arc::new(OutsidePlugin::new()),
689        )
690    }
691
692    impl<N: FixtureName> SqlRenderer for Answered<N> {
693        fn render_data_type(&self, dt: &DataType) -> String {
694            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
695        }
696        fn quote_identifier(&self, name: &str) -> String {
697            format!("\"{name}\"")
698        }
699        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
700            schema.map_or_else(
701                || self.quote_identifier(name),
702                |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
703            )
704        }
705        fn render_current_timestamp(&self) -> String {
706            "NOW()".to_owned()
707        }
708        fn dialect_name(&self) -> &'static str {
709            N::NAME
710        }
711        fn sid_spelling(&self) -> SidSpelling {
712            SidSpelling {
713                platform: N::NAME,
714                hash: ("answered_xxhash64(", ")"),
715                cast_type: TextCast::Direct("STRING"),
716                through_helper: false,
717                ..razeri_plugin_contract::ANSI_SID_SPELLING
718            }
719        }
720        fn procedure_form(&self) -> ProcedureForm {
721            ProcedureForm::Unsupported(
722                "This engine has no stored procedures; write the load as a scheduled query \
723                 instead.",
724            )
725        }
726        fn scalar_function_form(&self) -> ScalarFunctionForm {
727            ScalarFunctionForm::PostgreSql
728        }
729        fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
730            Box::new(Self::new())
731        }
732    }
733    impl<N: FixtureName> ExpressionRenderer for Answered<N> {}
734    impl<N: FixtureName> DdlRenderer for Answered<N> {}
735    impl<N: FixtureName> DmlRenderer for Answered<N> {}
736    impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
737    impl<N: FixtureName> FormulaRenderer for Answered<N> {}
738    impl<N: FixtureName> TaskRenderer for Answered<N> {}
739    impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
740    impl<N: FixtureName> StorageRenderer for Answered<N> {}
741    impl<N: FixtureName> DialectCapabilities for Answered<N> {
742        fn merge_shape(&self) -> MergeShape {
743            MergeShape::Merge(MergeRestrictions::none())
744        }
745    }
746    impl<N: FixtureName> PlatformDialect for Answered<N> {
747        fn info(&self) -> DialectInfo {
748            DialectInfo {
749                name: N::NAME,
750                display_name: "An Answered Engine",
751                // It answers to its own name and nothing else, which is the
752                // shape of a plugin that declares no extra spellings.
753                aliases: &[],
754                dialect_family: DialectFamily::Custom(N::NAME),
755                family: "Answered",
756                is_cloud_native: false,
757                is_olap: false,
758                is_postgres_compatible: false,
759            }
760        }
761    }
762
763    // ── The twin: a dialect that claims a name another platform answers to ──
764    //
765    // Answered in every other respect, so the only thing its report can be
766    // about is the spelling it took.
767
768    struct Twin;
769
770    impl SqlRenderer for Twin {
771        fn render_data_type(&self, dt: &DataType) -> String {
772            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
773        }
774        fn quote_identifier(&self, name: &str) -> String {
775            format!("\"{name}\"")
776        }
777        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
778            self.quote_identifier(name)
779        }
780        fn render_current_timestamp(&self) -> String {
781            "NOW()".to_owned()
782        }
783    }
784    impl ExpressionRenderer for Twin {}
785    impl DdlRenderer for Twin {}
786    impl DmlRenderer for Twin {}
787    impl ProcedureRenderer for Twin {}
788    impl FormulaRenderer for Twin {}
789    impl TaskRenderer for Twin {}
790    impl ReplicationRenderer for Twin {}
791    impl StorageRenderer for Twin {}
792    impl DialectCapabilities for Twin {}
793    impl PlatformDialect for Twin {
794        fn info(&self) -> DialectInfo {
795            DialectInfo {
796                name: "twindb",
797                display_name: "Twin DB",
798                // The name the answered fixture already answers to.
799                aliases: &["healthydb"],
800                dialect_family: DialectFamily::Custom("twindb"),
801                family: "Twin",
802                is_cloud_native: false,
803                is_olap: false,
804                is_postgres_compatible: false,
805            }
806        }
807    }
808
809    /// Every finding whose `what` line mentions one of these subjects.
810    fn mentions(report: &Report, needle: &str) -> bool {
811        report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
812    }
813
814    /// **The kit's own red.** A plugin that implements the four required
815    /// methods and stops is exactly what the guide used to produce, and every
816    /// one of these findings is a defect a shipped dialect actually had.
817    #[test]
818    fn the_kit_catches_a_deliberately_broken_plugin() {
819        let report = check(&BrokenPlugin);
820        assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
821
822        assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
823        assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
824        assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
825        assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
826        assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
827        assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
828        assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
829        assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
830        assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
831        assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
832        assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
833
834        assert!(
835            report.count(Severity::Blocking) >= 8,
836            "the broken plugin owes more than {} blocking fixes:\n{report}",
837            report.count(Severity::Blocking)
838        );
839    }
840
841    /// **The kit does not cry wolf.** The same plugin with its questions
842    /// answered reports only what is actually true of it: it is not
843    /// registered in this test binary, and nothing else.
844    #[test]
845    fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
846        let report = check(&HealthyPlugin::new());
847        let unregistered: Vec<&Finding> =
848            report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
849        assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
850        assert_eq!(
851            report.findings.len(),
852            1,
853            "a plugin that answered every question owes nothing else:\n{report}"
854        );
855    }
856
857    /// **A dialect written outside Razeri reaches a clean report.** It is
858    /// reached the way every out-of-tree dialect is reached — it registered, and
859    /// the resolver reads the loaded dialects' own declared spellings — so there
860    /// is nothing for Razeri to be missing it from and nothing for the report to
861    /// ask of its author.
862    #[test]
863    fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
864        assert!(
865            crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
866            "the fixture registers through the registry, which is the whole path being graded"
867        );
868        // The precondition that used to read "no row in Razeri's table": no
869        // other loaded platform declares this name, so the fixture is reached
870        // only because it registered and said so itself.
871        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
872        assert!(
873            registry
874                .list()
875                .iter()
876                .filter(|info| info.name != Outside::NAME)
877                .all(|info| !info.answers_to(Outside::NAME)),
878            "the fixture's name must be one no other platform claims"
879        );
880
881        let report = check(&OutsidePlugin::new());
882        assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
883        assert!(report.is_clean(), "and the report says so:\n{report}");
884        assert_eq!(report.count(Severity::Blocking), 0, "{report}");
885        assert!(
886            report.to_string().contains("nothing to fix"),
887            "a clean dialect is told it is clean:\n{report}"
888        );
889    }
890
891    /// **A platform cannot be reachable under an alias but not under its own
892    /// name.** That used to be a judgement about a hand-written table, which
893    /// could spell a platform `mynew_db` and never `mynewdb`. A dialect now
894    /// declares its own spellings and its own name leads them, so the defect is
895    /// unrepresentable rather than caught — and this is the assertion that keeps
896    /// it that way.
897    #[test]
898    fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
899        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
900        for info in registry.list() {
901            assert_eq!(
902                info.spellings().first().copied(),
903                Some(info.name),
904                "a platform's own name leads its spellings"
905            );
906            assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
907        }
908    }
909
910    /// **A name another platform already answers to is a collision, not a
911    /// registration.** A dialect claiming a spelling Healthy DB answers to is
912    /// named as the collision it is — with both platforms named, and a fix its
913    /// author can actually perform — rather than being reported as an
914    /// unregistered platform, which is a different problem with a different fix.
915    #[test]
916    fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
917        let mut registry = crate::dialect::PlatformRegistry::new();
918        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
919        registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
920
921        let report = check_with_registry(&Twin, &registry);
922        let collision = report
923            .findings
924            .iter()
925            .find(|f| f.what.contains("claimed by more than one loaded platform"))
926            .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
927        assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
928        assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
929        assert!(collision.what.contains("twindb"), "{collision:?}");
930        assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
931        assert!(
932            !mentions(&report, "does not resolve"),
933            "a collision is not an unregistered platform:\n{report}"
934        );
935    }
936
937    /// A platform reached under its own name owes nothing, whatever the case or
938    /// the surrounding whitespace, and a name nothing has ever heard of is
939    /// simply unknown rather than a finding against this dialect.
940    #[test]
941    fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
942        let mut registry = crate::dialect::PlatformRegistry::new();
943        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
944
945        let mut findings = Vec::new();
946        check_registration(&HealthyPlugin::new().info(), &registry, &mut findings);
947        assert!(findings.is_empty(), "{findings:?}");
948
949        for spelling in ["healthydb", "HealthyDB", "  healthydb  "] {
950            assert!(
951                crate::resolve::resolve_dialect_by_name_with_registry(spelling, &registry).is_ok(),
952                "'{spelling}' is the same platform"
953            );
954        }
955        assert!(
956            crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", &registry).is_err()
957        );
958    }
959
960    /// **A dialect the host loaded at run time can be graded at all.** [`check`]
961    /// asks the dialects linked into this program, which one loaded from a
962    /// shared library is not among; [`check_with_registry`] asks the registry it
963    /// was loaded into, and the registration judgement is answered from there.
964    #[test]
965    fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
966        let mut registry = crate::dialect::PlatformRegistry::new();
967        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
968
969        let report = check_with_registry(&HealthyPlugin::new(), &registry);
970        assert!(
971            report.is_clean(),
972            "a loaded dialect that answered everything owes nothing:\n{report}"
973        );
974    }
975
976    /// **Every fix an outside author can read is a fix in their own crate.** A
977    /// report that names a file inside Razeri is telling an author to edit a
978    /// tree they do not have, and the fix cannot be performed at all.
979    #[test]
980    fn no_fix_line_sends_an_author_into_razeris_own_tree() {
981        let mut collided = crate::dialect::PlatformRegistry::new();
982        collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
983        collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
984
985        let mut fixes: Vec<String> = Vec::new();
986        for report in [
987            check(&BrokenPlugin),
988            check(&HealthyPlugin::new()),
989            check(&OutsidePlugin::new()),
990            // The collision and the unregistered-name judgements, which are the
991            // two registration findings an author can actually reach.
992            check_with_registry(&Twin, &collided),
993            check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
994        ] {
995            fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
996        }
997        assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
998        for fix in &fixes {
999            for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
1000                assert!(
1001                    !fix.contains(path),
1002                    "a fix line names `{path}`, which is not in the author's crate:\n  {fix}"
1003                );
1004            }
1005        }
1006    }
1007
1008    /// **A stated refusal is a claim, and a claim owes the reader words** — the
1009    /// same grading every other refusal gets, because these words are the only
1010    /// thing an operator finds where the load would have been.
1011    #[test]
1012    fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
1013        let mut mute = Vec::new();
1014        check_refusal_copy("the upsert refusal", "no merge", &mut mute);
1015        assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
1016        assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
1017
1018        let silence = check(&BrokenPlugin);
1019        assert!(
1020            silence
1021                .findings
1022                .iter()
1023                .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
1024            "a dialect that never answered is unanswered, not wrong:\n{silence}"
1025        );
1026        assert!(
1027            silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
1028            "the fix line names the way to state a measured refusal:\n{silence}"
1029        );
1030    }
1031
1032    /// The report leads with what to fix and says it in words a reader can
1033    /// act on — no identifiers from Razeri's own record-keeping, and a
1034    /// one-line count before the detail.
1035    #[test]
1036    fn the_report_reads_as_a_summary_then_a_fix_list() {
1037        let text = check(&BrokenPlugin).to_string();
1038        let first = text.lines().next().expect("a summary line");
1039        assert!(first.starts_with("brokendb:"), "{first}");
1040        assert!(first.contains("to fix"), "{first}");
1041        assert!(text.contains("      → "), "every finding names the fix:\n{text}");
1042    }
1043
1044    /// The repository obligations are commands, in the order they are owed,
1045    /// and every one of them says what it is for.
1046    #[test]
1047    fn every_repository_obligation_names_a_command_and_a_reason() {
1048        let obligations = repository_obligations();
1049        assert!(obligations.len() >= 5);
1050        for o in obligations {
1051            assert!(!o.command.trim().is_empty(), "{o:?}");
1052            assert!(o.what.split_whitespace().count() >= 5, "{o:?}");
1053        }
1054    }
1055}