Skip to main content

razeri_plugin_api/
conformance.rs

1// Copyright (C) 2026 Gabriel Shea <gshea@ideapivot.com> - All Rights Reserved
2
3//! **Every obligation a dialect owes, reported at once.**
4//!
5//! Two people built dialects against Razeri's authoring guide without help.
6//! Between them they met twenty-seven obligations one `cargo test` at a time —
7//! a roster fails, you add a row, the next roster fails. Not one of those
8//! failures was discoverable before it fired. This module is the report that
9//! answers all of them in one pass.
10//!
11//! # Running it
12//!
13//! From your own crate's tests, where your dialect is linked and Razeri's
14//! registry can see it:
15//!
16//! ```no_run
17//! # use razeri_plugin_api::conformance;
18//! # fn example(my_dialect: &dyn razeri_plugin_api::PlatformDialect) {
19//! let report = conformance::check(my_dialect);
20//! assert!(report.is_clean(), "{report}");
21//! # }
22//! ```
23//!
24//! Inside Razeri's own repository, `razeri plugin conform <name>` prints the
25//! same report for any registered platform, followed by the repository-side
26//! obligations a command cannot run for you.
27//!
28//! # What it can and cannot see
29//!
30//! Everything here is read off the dialect object, so it works wherever your
31//! plugin compiles. What it cannot do is run Razeri's frozen matrices or
32//! regenerate its language bindings — those are commands, and
33//! [`repository_obligations`] lists them in the order they are owed.
34
35use crate::PlatformDialect;
36use razeri_plugin_contract::{
37    ConstraintEnforcement, DataType, DdlRenderer, DialectCapabilities, DialectFamily, MergeShape,
38    ProcedureForm, SqlRenderer, UNKNOWN_PLATFORM,
39};
40use std::fmt;
41
42/// How much a finding matters.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
44pub enum Severity {
45    /// **This dialect will produce SQL its engine rejects.** Fix it before
46    /// anything else.
47    Blocking,
48    /// **A question this dialect has not answered.** Razeri has a default and
49    /// will use it; the default was written for a different engine.
50    Unanswered,
51    /// Worth a look. Nothing is broken.
52    Note,
53}
54
55impl fmt::Display for Severity {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        f.write_str(match self {
58            Self::Blocking => "must fix",
59            Self::Unanswered => "unanswered",
60            Self::Note => "worth a look",
61        })
62    }
63}
64
65/// One thing to fix, in the order a reader wants it: what is wrong, then what
66/// to do about it.
67#[derive(Debug, Clone)]
68pub struct Finding {
69    /// How much it matters.
70    pub severity: Severity,
71    /// One line naming what is wrong.
72    pub what: String,
73    /// One line naming what to do.
74    pub fix: String,
75}
76
77/// A command a plugin author runs inside Razeri's repository, and why.
78#[derive(Debug, Clone, Copy)]
79pub struct Obligation {
80    /// What this step is for.
81    pub what: &'static str,
82    /// The command, exactly as typed.
83    pub command: &'static str,
84}
85
86/// The whole report for one dialect.
87#[derive(Debug, Clone)]
88pub struct Report {
89    /// The dialect's registry name.
90    pub dialect: String,
91    /// Everything found, worst first.
92    pub findings: Vec<Finding>,
93}
94
95impl Report {
96    /// Whether nothing blocking or unanswered was found.
97    ///
98    /// Notes do not fail a report: they are observations, not obligations.
99    #[must_use]
100    pub fn is_clean(&self) -> bool {
101        !self.findings.iter().any(|f| f.severity != Severity::Note)
102    }
103
104    /// How many findings of one severity.
105    #[must_use]
106    pub fn count(&self, severity: Severity) -> usize {
107        self.findings.iter().filter(|f| f.severity == severity).count()
108    }
109}
110
111impl fmt::Display for Report {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        let name = &self.dialect;
114        if self.findings.is_empty() {
115            return writeln!(
116                f,
117                "{name}: nothing to fix. Every question Razeri asks a dialect, this one answers."
118            );
119        }
120        let blocking = self.count(Severity::Blocking);
121        let unanswered = self.count(Severity::Unanswered);
122        let notes = self.count(Severity::Note);
123        writeln!(f, "{name}: {blocking} to fix, {unanswered} unanswered, {notes} worth a look.")?;
124        for finding in &self.findings {
125            writeln!(f)?;
126            writeln!(f, "  [{}] {}", finding.severity, finding.what)?;
127            writeln!(f, "      → {}", finding.fix)?;
128        }
129        Ok(())
130    }
131}
132
133/// **The report.** Everything about this dialect that Razeri can check by
134/// asking it.
135#[must_use]
136pub fn check(dialect: &dyn PlatformDialect) -> Report {
137    let info = dialect.info();
138    let mut findings = Vec::new();
139
140    check_registration(info.name, &mut findings);
141    check_family(info.dialect_family, &mut findings);
142    check_required_rendering(dialect, &mut findings);
143    check_decisions(dialect, &mut findings);
144    check_formula_renderer(dialect, &mut findings);
145    check_paired_facts(dialect, &mut findings);
146
147    findings.sort_by_key(|f| f.severity);
148    Report { dialect: info.name.to_owned(), findings }
149}
150
151/// The name has to resolve, and it has to resolve to exactly one platform.
152fn check_registration(name: &str, findings: &mut Vec<Finding>) {
153    if crate::resolve_dialect_by_name(name).is_err() {
154        findings.push(Finding {
155            severity: Severity::Blocking,
156            what: format!(
157                "the name `{name}` does not resolve — nothing in this program registered it"
158            ),
159            fix: "add `inventory::submit! { DialectRegistration::new(\"<name>\", || \
160                  Arc::new(MyDialect)) }` to your crate, and a `pub fn force_link() {}` beside \
161                  it that the program using your dialect calls once. A crate nothing links is a \
162                  crate the registry cannot see."
163                .to_owned(),
164        });
165        return;
166    }
167    if !crate::resolve::alias_spellings()
168        .iter()
169        .any(|(spelling, canonical)| *spelling == name && *canonical == name)
170    {
171        findings.push(Finding {
172            severity: Severity::Blocking,
173            what: format!(
174                "`{name}` is registered but the resolver's spelling table does not carry it, so \
175                 a design naming this platform is refused at the door"
176            ),
177            fix: "add your platform's accepted spellings to the resolver's alias table, including its \
178                  own \
179                  name, and add the same spellings to `schemas/razeri-design.schema.json`'s \
180                  platform list — a test holds the two set-equal."
181                .to_owned(),
182        });
183    }
184}
185
186/// A family is a claim about spelling, so an unmeasured engine claiming a
187/// measured family's name licenses SQL it may reject.
188fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
189    if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
190        findings.push(Finding {
191            severity: Severity::Blocking,
192            what: "this dialect's family is a custom one with no name".to_owned(),
193            fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
194                  prints and what a reader looks up."
195                .to_owned(),
196        });
197    }
198}
199
200/// The four required methods must answer for every type a design can declare.
201fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
202    let declarable = declarable_types();
203    let empty_column: Vec<String> = declarable
204        .iter()
205        .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
206        .map(|dt| format!("{dt:?}"))
207        .collect();
208    if !empty_column.is_empty() {
209        findings.push(Finding {
210            severity: Severity::Blocking,
211            what: format!(
212                "`render_data_type` answers nothing for {} of the types a design can declare",
213                empty_column.len()
214            ),
215            fix: "every declarable type needs a column spelling on this engine, even if the \
216                  spelling is a wider type that stores it. A blank answer becomes a column \
217                  declaration with no type in it."
218                .to_owned(),
219        });
220    }
221    let empty_cast: Vec<String> = declarable
222        .iter()
223        .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
224        .map(|dt| format!("{dt:?}"))
225        .collect();
226    if !empty_cast.is_empty() {
227        findings.push(Finding {
228            severity: Severity::Blocking,
229            what: format!(
230                "`render_cast_type` answers nothing for {} declarable types",
231                empty_cast.len()
232            ),
233            fix: "the generated load opens by casting every source column to its declared type, \
234                  so a blank answer fails the load on its first statement."
235                .to_owned(),
236        });
237    }
238    let quoted = SqlRenderer::quote_identifier(dialect, "order");
239    if quoted == "order" {
240        findings.push(Finding {
241            severity: Severity::Blocking,
242            what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
243                   reaches this engine bare"
244                .to_owned(),
245            fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
246                  backticks, or brackets."
247                .to_owned(),
248        });
249    }
250    if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
251        findings.push(Finding {
252            severity: Severity::Blocking,
253            what: "`render_current_timestamp` answers nothing, and every generated load stamps \
254                   a row with it"
255                .to_owned(),
256            fix: "return this engine's own expression for the current moment.".to_owned(),
257        });
258    }
259}
260
261/// The decisions a dialect states rather than inherits.
262fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
263    let spelling = SqlRenderer::sid_spelling(dialect);
264    if spelling.platform == UNKNOWN_PLATFORM {
265        findings.push(Finding {
266            severity: Severity::Blocking,
267            what: "this dialect states no surrogate-key spelling, so every key it renders calls \
268                   an `xxhash64` its engine very likely does not have"
269                .to_owned(),
270            fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
271                  TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
272                  hash. If it has no portable hash, name an artifact your prerequisites install \
273                  — never the nearest-looking built-in, which keys a warehouse to a number \
274                  nothing else can reproduce."
275                .to_owned(),
276        });
277    }
278
279    match SqlRenderer::procedure_form(dialect) {
280        // Razeri's own agnostic renderer answers `Agnostic` on purpose: the
281        // annotated form IS its output. Every other dialect that answers it has
282        // simply not said.
283        ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
284        ProcedureForm::Agnostic => findings.push(Finding {
285            severity: Severity::Unanswered,
286            what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
287                   annotated form — which no engine parses"
288                .to_owned(),
289            fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
290                  or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
291                  offers instead."
292                .to_owned(),
293        }),
294        ProcedureForm::Unsupported(orientation) => {
295            check_refusal_copy("the stored-procedure refusal", orientation, findings);
296        }
297        _ => {}
298    }
299
300    if let Some(orientation) = SqlRenderer::table_function_form(dialect).refusal() {
301        check_refusal_copy("the table-function refusal", orientation, findings);
302    }
303
304    // The note is what a reader of the DDL learns from, and on an engine with
305    // no keyword of its own it is the ONLY place they can learn it.
306    if let ConstraintEnforcement::DeclaredOnly(declared) =
307        DdlRenderer::constraint_enforcement(dialect)
308        && declared.note.split_whitespace().count() < 5
309    {
310        findings.push(Finding {
311            severity: Severity::Blocking,
312            what: "this dialect says its engine accepts constraints it does not enforce, and \
313                   the note explaining that says almost nothing"
314                .to_owned(),
315            fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
316                  guarantee. Say what this engine really does with the declaration, and where \
317                  the guarantee has to come from instead."
318                .to_owned(),
319        });
320    }
321
322    // **The two refusals are different findings, and that is the point of the
323    // second variant.** `None` is silence, and silence is unanswered. A stated
324    // `Unsupported` is a measured claim; what is left to grade there is whether
325    // the sentence is worth reading, because it is the only thing an operator
326    // gets where the load would have been.
327    match DialectCapabilities::merge_shape(dialect) {
328        MergeShape::None => findings.push(Finding {
329            severity: Severity::Unanswered,
330            what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
331                   load for it"
332                .to_owned(),
333            fix: "if this engine has a MERGE or an upsert statement, say which in \
334                  `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
335                  `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
336                  operator can act on, rather than a silence."
337                .to_owned(),
338        }),
339        MergeShape::Unsupported(orientation) => {
340            check_refusal_copy("the upsert refusal", orientation, findings);
341        }
342        _ => {}
343    }
344}
345
346/// The one override nothing tells an author about, and every formula in every
347/// design goes through it.
348fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
349    let formula = SqlRenderer::to_formula_renderer(dialect);
350    let dialect_name = SqlRenderer::dialect_name(dialect);
351    if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
352        findings.push(Finding {
353            severity: Severity::Blocking,
354            what: "`to_formula_renderer` was not overridden, so every formula in every design \
355                   renders through Razeri's ANSI defaults instead of this dialect"
356                .to_owned(),
357            fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
358                  formula this engine sees is silently somebody else's."
359                .to_owned(),
360        });
361    }
362}
363
364/// A refusal that does not say what to do instead is a wall.
365fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
366    if copy.trim().is_empty() {
367        findings.push(Finding {
368            severity: Severity::Blocking,
369            what: format!("{subject} has no words in it"),
370            fix: "say what this engine offers instead. A reader who hits a refusal needs the \
371                  next thing to try, and it has to be about their engine."
372                .to_owned(),
373        });
374        return;
375    }
376    if copy.split_whitespace().count() < 5 {
377        findings.push(Finding {
378            severity: Severity::Note,
379            what: format!("{subject} is very short"),
380            fix: "a refusal earns its place by naming the alternative — what this engine has \
381                  instead of the thing it cannot do."
382                .to_owned(),
383        });
384    }
385}
386
387/// Fourteen facts are declared twice and must agree.
388fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
389    let caps: &dyn DialectCapabilities = dialect;
390    let ren: &dyn SqlRenderer = dialect;
391    let ddl: &dyn DdlRenderer = dialect;
392    let mut disagreements: Vec<&'static str> = Vec::new();
393
394    macro_rules! pair {
395        ($m:ident, $renderer_value:expr) => {
396            if caps.$m() != $renderer_value {
397                disagreements.push(stringify!($m));
398            }
399        };
400    }
401    pair!(supports_on_conflict, ren.supports_on_conflict());
402    pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
403    pair!(supports_update_from, ren.supports_update_from());
404    pair!(supports_named_windows, ren.supports_named_windows());
405    pair!(supports_compound_assignment, ren.supports_compound_assignment());
406    pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
407    pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
408    pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
409    pair!(uses_alter_table_update, ren.uses_alter_table_update());
410    pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
411    pair!(supports_cascade_drop, ddl.supports_cascade_drop());
412    pair!(supports_sync_drop, ddl.supports_sync_drop());
413    pair!(supports_force_drop, ddl.supports_force_drop());
414
415    // **The fourteenth, whose two sides are different types.** The capability
416    // is a boolean; the renderer answer is a form that either renders or
417    // carries a refusal. Both are read — the build door reads the form, and
418    // registry-walking rosters read the capability — so a dialect claiming a
419    // table function on one side and refusing on the other has an engine that
420    // both does and does not have one. `SQLite` was exactly that on the day
421    // this check was written: `false` since the capability existed, and a
422    // renderer still inheriting PostgreSQL's `RETURNS TABLE`.
423    if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
424        disagreements.push("supports_table_functions");
425    }
426
427    if !disagreements.is_empty() {
428        findings.push(Finding {
429            severity: Severity::Blocking,
430            what: format!(
431                "this dialect declares {} fact(s) two ways and the two disagree: {}",
432                disagreements.len(),
433                disagreements.join(", ")
434            ),
435            fix: "each of these is declared once on the capability trait and once in the \
436                  renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
437                  and move the other one to match."
438                .to_owned(),
439        });
440    }
441}
442
443/// **What a command has to do for you**, in the order it is owed.
444///
445/// These are Razeri's repository obligations: frozen matrices, generated
446/// bindings, and the allowlist a new crate joins. A dialect built outside this
447/// repository owes none of them; a dialect built inside it owes all of them,
448/// and each has an excellent failure message that nobody can read before it
449/// fires.
450#[must_use]
451pub const fn repository_obligations() -> &'static [Obligation] {
452    &[
453        Obligation {
454            what: "classify the new crate — every workspace member is named exactly once, and \
455                   dialect crates are tier 1",
456            command: "scripts/check-tier-boundary.sh",
457        },
458        Obligation {
459            what: "freeze the DDL your platform renders for all eleven corpus designs",
460            command: "UPDATE_GOLDEN=1 cargo test -p razeri-materialize --test golden_ddl",
461        },
462        Obligation {
463            what: "freeze the derived-column projections your platform renders",
464            command: "UPDATE_GOLDEN=1 cargo test -p razeri-materialize --test expression_matrix",
465        },
466        Obligation {
467            what: "freeze the transaction envelope your platform renders",
468            command: "UPDATE_GOLDEN=1 cargo test -p razeri-materialize \
469                      --test load_transaction_matrix",
470        },
471        Obligation {
472            what: "regenerate the typed platform constants for Python, TypeScript, Go, C# and \
473                   Java",
474            command: "cargo run -p razeri-dialects --bin gen-platform-constants",
475        },
476        Obligation {
477            what: "give every registry-walking roster a row for your platform — they fail one \
478                   at a time otherwise",
479            command: "cargo test -p razeri-materialize -p razeri-sql",
480        },
481        Obligation {
482            what: "prove the artifacts deploy and run: an execution leg per routine kind and \
483                   per generated load is what finished means here",
484            command: "cargo test -p integration-tests",
485        },
486    ]
487}
488
489/// Every type a design can declare, which is the set the required methods owe
490/// an answer for.
491fn declarable_types() -> Vec<DataType> {
492    vec![
493        DataType::tinyint(),
494        DataType::smallint(),
495        DataType::int(),
496        DataType::bigint(),
497        DataType::double(),
498        DataType::boolean(),
499        DataType::text(),
500        DataType::varchar(200),
501        DataType::uuid(),
502        DataType::json(),
503        DataType::date(),
504        DataType::timestamp(),
505        DataType::timestamp_tz(),
506        DataType::decimal(18, 2),
507    ]
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::dialect::DialectInfo;
514    use razeri_plugin_contract::{
515        DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions, ProcedureRenderer,
516        ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer, TaskRenderer,
517        TextCast,
518    };
519
520    // ── The broken fixture ────────────────────────────────────────────
521    //
522    // A plugin that compiles, registers nothing it should, and answers every
523    // question with somebody else's default. This is what the kit exists to
524    // catch, and it is deliberately the plugin an author gets by implementing
525    // only the four required methods and stopping — which the guide, until
526    // now, told them was enough.
527
528    struct BrokenPlugin;
529
530    impl SqlRenderer for BrokenPlugin {
531        fn render_data_type(&self, _dt: &DataType) -> String {
532            String::new()
533        }
534        fn quote_identifier(&self, name: &str) -> String {
535            name.to_owned()
536        }
537        fn render_cast_type(&self, _dt: &DataType) -> String {
538            String::new()
539        }
540        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
541            name.to_owned()
542        }
543        fn render_current_timestamp(&self) -> String {
544            String::new()
545        }
546        fn dialect_name(&self) -> &'static str {
547            "brokendb"
548        }
549        // No `sid_spelling`, no `procedure_form`, no `to_formula_renderer` —
550        // and one fact declared twice, two ways.
551        fn supports_on_conflict(&self) -> bool {
552            true
553        }
554    }
555    impl ExpressionRenderer for BrokenPlugin {}
556    impl DdlRenderer for BrokenPlugin {}
557    impl DmlRenderer for BrokenPlugin {}
558    impl ProcedureRenderer for BrokenPlugin {}
559    impl FormulaRenderer for BrokenPlugin {}
560    impl TaskRenderer for BrokenPlugin {}
561    impl ReplicationRenderer for BrokenPlugin {}
562    impl StorageRenderer for BrokenPlugin {}
563    impl DialectCapabilities for BrokenPlugin {
564        fn supports_on_conflict(&self) -> bool {
565            false
566        }
567    }
568    impl PlatformDialect for BrokenPlugin {
569        fn info(&self) -> DialectInfo {
570            DialectInfo {
571                name: "brokendb",
572                display_name: "Broken DB",
573                dialect_family: DialectFamily::Custom(""),
574                family: "Broken",
575                is_cloud_native: false,
576                is_olap: false,
577                is_postgres_compatible: false,
578            }
579        }
580    }
581
582    // ── The healthy fixture ───────────────────────────────────────────
583    //
584    // The same plugin with every question answered. It is not registered
585    // either, so its report still carries the registration finding — which is
586    // the honest report for a dialect nothing has linked, and the one
587    // difference between "run me from your own crate's tests" and "run me
588    // from Razeri's CLI".
589
590    struct HealthyPlugin;
591
592    impl SqlRenderer for HealthyPlugin {
593        fn render_data_type(&self, dt: &DataType) -> String {
594            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
595        }
596        fn quote_identifier(&self, name: &str) -> String {
597            format!("\"{name}\"")
598        }
599        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
600            schema.map_or_else(
601                || self.quote_identifier(name),
602                |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
603            )
604        }
605        fn render_current_timestamp(&self) -> String {
606            "NOW()".to_owned()
607        }
608        fn dialect_name(&self) -> &'static str {
609            "healthydb"
610        }
611        fn sid_spelling(&self) -> SidSpelling {
612            SidSpelling {
613                platform: "healthydb",
614                hash: ("healthy_xxhash64(", ")"),
615                cast_type: TextCast::Direct("STRING"),
616                through_helper: false,
617                ..razeri_plugin_contract::ANSI_SID_SPELLING
618            }
619        }
620        fn procedure_form(&self) -> ProcedureForm {
621            ProcedureForm::Unsupported(
622                "Healthy DB has no stored procedures; write the load as a scheduled query \
623                 instead.",
624            )
625        }
626        fn scalar_function_form(&self) -> ScalarFunctionForm {
627            ScalarFunctionForm::PostgreSql
628        }
629        fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
630            Box::new(Self)
631        }
632    }
633    impl ExpressionRenderer for HealthyPlugin {}
634    impl DdlRenderer for HealthyPlugin {}
635    impl DmlRenderer for HealthyPlugin {}
636    impl ProcedureRenderer for HealthyPlugin {}
637    impl FormulaRenderer for HealthyPlugin {}
638    impl TaskRenderer for HealthyPlugin {}
639    impl ReplicationRenderer for HealthyPlugin {}
640    impl StorageRenderer for HealthyPlugin {}
641    impl DialectCapabilities for HealthyPlugin {
642        fn merge_shape(&self) -> MergeShape {
643            MergeShape::Merge(MergeRestrictions::none())
644        }
645    }
646    impl PlatformDialect for HealthyPlugin {
647        fn info(&self) -> DialectInfo {
648            DialectInfo {
649                name: "healthydb",
650                display_name: "Healthy DB",
651                dialect_family: DialectFamily::Custom("healthydb"),
652                family: "Healthy",
653                is_cloud_native: false,
654                is_olap: false,
655                is_postgres_compatible: false,
656            }
657        }
658    }
659
660    /// Every finding whose `what` line mentions one of these subjects.
661    fn mentions(report: &Report, needle: &str) -> bool {
662        report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
663    }
664
665    /// **The kit's own red.** A plugin that implements the four required
666    /// methods and stops is exactly what the guide used to produce, and every
667    /// one of these findings is a defect a shipped dialect actually had.
668    #[test]
669    fn the_kit_catches_a_deliberately_broken_plugin() {
670        let report = check(&BrokenPlugin);
671        assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
672
673        assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
674        assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
675        assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
676        assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
677        assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
678        assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
679        assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
680        assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
681        assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
682        assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
683        assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
684
685        assert!(
686            report.count(Severity::Blocking) >= 8,
687            "the broken plugin owes more than {} blocking fixes:\n{report}",
688            report.count(Severity::Blocking)
689        );
690    }
691
692    /// **The kit does not cry wolf.** The same plugin with its questions
693    /// answered reports only what is actually true of it: it is not
694    /// registered in this test binary, and nothing else.
695    #[test]
696    fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
697        let report = check(&HealthyPlugin);
698        let unregistered: Vec<&Finding> =
699            report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
700        assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
701        assert_eq!(
702            report.findings.len(),
703            1,
704            "a plugin that answered every question owes nothing else:\n{report}"
705        );
706    }
707
708    /// **A stated refusal is a claim, and a claim owes the reader words** — the
709    /// same grading every other refusal gets, because these words are the only
710    /// thing an operator finds where the load would have been.
711    #[test]
712    fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
713        let mut mute = Vec::new();
714        check_refusal_copy("the upsert refusal", "no merge", &mut mute);
715        assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
716        assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
717
718        let silence = check(&BrokenPlugin);
719        assert!(
720            silence
721                .findings
722                .iter()
723                .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
724            "a dialect that never answered is unanswered, not wrong:\n{silence}"
725        );
726        assert!(
727            silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
728            "the fix line names the way to state a measured refusal:\n{silence}"
729        );
730    }
731
732    /// The report leads with what to fix and says it in words a reader can
733    /// act on — no identifiers from Razeri's own record-keeping, and a
734    /// one-line count before the detail.
735    #[test]
736    fn the_report_reads_as_a_summary_then_a_fix_list() {
737        let text = check(&BrokenPlugin).to_string();
738        let first = text.lines().next().expect("a summary line");
739        assert!(first.starts_with("brokendb:"), "{first}");
740        assert!(first.contains("to fix"), "{first}");
741        assert!(text.contains("      → "), "every finding names the fix:\n{text}");
742    }
743
744    /// The repository obligations are commands, in the order they are owed,
745    /// and every one of them says what it is for.
746    #[test]
747    fn every_repository_obligation_names_a_command_and_a_reason() {
748        let obligations = repository_obligations();
749        assert!(obligations.len() >= 5);
750        for o in obligations {
751            assert!(!o.command.trim().is_empty(), "{o:?}");
752            assert!(o.what.split_whitespace().count() >= 5, "{o:?}");
753        }
754    }
755}