1use crate::PlatformDialect;
45use razeri_plugin_contract::{
46 ColumnEvolution, Concat, ConstraintEnforcement, DataType, DdlRenderer, DialectCapabilities,
47 DialectFamily, EntityRef, FormulaRenderer, MergeRestrictions, MergeShape, ProcedureForm,
48 RoutineKind, RoutineSpec, ScalarFunctionForm, SliceRef, SliceReplacement, SqlRenderer,
49 StorageRenderer, TableFunctionForm, UNKNOWN_PLATFORM,
50 data_type::{ArrayDataType, MapDataType, StructDataType, StructField},
51 storage_strategy::{
52 CompressionStrategy, IndexStrategy, PartitionGranularity, PartitionStrategy, StoragePlan,
53 },
54};
55use std::fmt;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
59pub enum Severity {
60 Blocking,
63 Unanswered,
66 Note,
68}
69
70impl fmt::Display for Severity {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 f.write_str(match self {
73 Self::Blocking => "must fix",
74 Self::Unanswered => "unanswered",
75 Self::Note => "worth a look",
76 })
77 }
78}
79
80#[derive(Debug, Clone)]
83pub struct Finding {
84 pub severity: Severity,
86 pub what: String,
88 pub fix: String,
90}
91
92#[derive(Debug, Clone)]
94pub struct Report {
95 pub dialect: String,
97 pub findings: Vec<Finding>,
99}
100
101impl Report {
102 #[must_use]
106 pub fn is_clean(&self) -> bool {
107 !self.findings.iter().any(|f| f.severity != Severity::Note)
108 }
109
110 #[must_use]
112 pub fn count(&self, severity: Severity) -> usize {
113 self.findings.iter().filter(|f| f.severity == severity).count()
114 }
115}
116
117impl fmt::Display for Report {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 let name = &self.dialect;
120 if self.findings.is_empty() {
121 return writeln!(
122 f,
123 "{name}: nothing to fix. Every question Razeri asks a dialect, this one answers."
124 );
125 }
126 let blocking = self.count(Severity::Blocking);
127 let unanswered = self.count(Severity::Unanswered);
128 let notes = self.count(Severity::Note);
129 writeln!(f, "{name}: {blocking} to fix, {unanswered} unanswered, {notes} worth a look.")?;
130 for finding in &self.findings {
131 writeln!(f)?;
132 writeln!(f, " [{}] {}", finding.severity, finding.what)?;
133 writeln!(f, " → {}", finding.fix)?;
134 }
135 Ok(())
136 }
137}
138
139#[must_use]
146pub fn check(dialect: &dyn PlatformDialect) -> Report {
147 check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
148}
149
150#[must_use]
153pub fn check_with_registry(
154 dialect: &dyn PlatformDialect,
155 registry: &crate::dialect::PlatformRegistry,
156) -> Report {
157 let info = dialect.info();
158 let mut findings = Vec::new();
159
160 check_registration(&info, registry, &mut findings);
161 check_family(info.dialect_family, &mut findings);
162 check_required_rendering(dialect, &mut findings);
163 check_decisions(dialect, &mut findings);
164 check_formula_renderer(dialect, &mut findings);
165 check_evolution(dialect, &mut findings);
166 check_paired_facts(dialect, &mut findings);
167 check_schema_fold(dialect, &mut findings);
168 check_deploy_client(&info, &mut findings);
169
170 findings.sort_by_key(|f| f.severity);
171 Report { dialect: info.name.to_owned(), findings }
172}
173
174fn check_registration(
185 info: &crate::dialect::DialectInfo,
186 registry: &crate::dialect::PlatformRegistry,
187 findings: &mut Vec<Finding>,
188) {
189 let name = info.name;
190 if matches!(
191 crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
192 Err(crate::ResolveError::UnknownDialect { .. })
193 ) {
194 findings.push(Finding {
195 severity: Severity::Blocking,
196 what: format!(
197 "the name `{name}` does not resolve — nothing in this program registered it"
198 ),
199 fix: "add `inventory::submit! { DialectRegistration::new(\"<name>\", || \
200 Arc::new(MyDialect)) }` to your crate, and a `pub fn force_link() {}` beside \
201 it that the program using your dialect calls once. A crate nothing links is a \
202 crate the registry cannot see."
203 .to_owned(),
204 });
205 return;
206 }
207 for spelling in info.spellings() {
208 match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
209 Ok(found) if found.info().name == name => {}
210 Ok(found) => findings.push(Finding {
211 severity: Severity::Blocking,
212 what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
213 fix: format!(
214 "the registry holds `{name}` under another key. Register it under its own \
215 name, or drop `{spelling}` from its aliases."
216 ),
217 }),
218 Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
219 severity: Severity::Blocking,
220 what: format!(
221 "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
222 none of them — Razeri refuses that spelling rather than guessing which one \
223 a design meant",
224 claimed_by.join(" and ")
225 ),
226 fix: format!(
227 "give this dialect a name no other platform answers to. A platform name is \
228 read off the loaded dialects' own declared spellings, so a spelling another \
229 platform already claims is never `{name}`'s."
230 ),
231 }),
232 Err(_) => findings.push(Finding {
233 severity: Severity::Blocking,
234 what: format!(
235 "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
236 ),
237 fix: format!(
238 "the `{name}` in the registry is a different build of this dialect and does \
239 not declare `{spelling}`. Register the one you are grading."
240 ),
241 }),
242 }
243 }
244}
245
246fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
249 if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
250 findings.push(Finding {
251 severity: Severity::Blocking,
252 what: "this dialect's family is a custom one with no name".to_owned(),
253 fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
254 prints and what a reader looks up."
255 .to_owned(),
256 });
257 }
258}
259
260fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
262 let declarable = declarable_types();
263 let empty_column: Vec<String> = declarable
264 .iter()
265 .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
266 .map(|dt| format!("{dt:?}"))
267 .collect();
268 if !empty_column.is_empty() {
269 findings.push(Finding {
270 severity: Severity::Blocking,
271 what: format!(
272 "`render_data_type` answers nothing for {} of the types a design can declare",
273 empty_column.len()
274 ),
275 fix: "every declarable type needs a column spelling on this engine, even if the \
276 spelling is a wider type that stores it. A blank answer becomes a column \
277 declaration with no type in it."
278 .to_owned(),
279 });
280 }
281 let empty_cast: Vec<String> = declarable
282 .iter()
283 .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
284 .map(|dt| format!("{dt:?}"))
285 .collect();
286 if !empty_cast.is_empty() {
287 findings.push(Finding {
288 severity: Severity::Blocking,
289 what: format!(
290 "`render_cast_type` answers nothing for {} declarable types",
291 empty_cast.len()
292 ),
293 fix: "the generated load opens by casting every source column to its declared type, \
294 so a blank answer fails the load on its first statement."
295 .to_owned(),
296 });
297 }
298 let quoted = SqlRenderer::quote_identifier(dialect, "order");
299 if quoted == "order" {
300 findings.push(Finding {
301 severity: Severity::Blocking,
302 what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
303 reaches this engine bare"
304 .to_owned(),
305 fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
306 backticks, or brackets."
307 .to_owned(),
308 });
309 }
310 if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
311 findings.push(Finding {
312 severity: Severity::Blocking,
313 what: "`render_current_timestamp` answers nothing, and every generated load stamps \
314 a row with it"
315 .to_owned(),
316 fix: "return this engine's own expression for the current moment.".to_owned(),
317 });
318 }
319}
320
321fn check_encoded_file_relation(
333 dialect: &dyn PlatformDialect,
334 probe: &str,
335 findings: &mut Vec<Finding>,
336) {
337 let Some(relation) =
338 SqlRenderer::delimited_file_relation_in_encoding(dialect, probe, "windows-1252")
339 else {
340 return;
341 };
342 if !relation.contains(probe) {
343 findings.push(Finding {
344 severity: Severity::Blocking,
345 what: "`delimited_file_relation_in_encoding` returns a relation that does not carry \
346 the path it was given"
347 .to_owned(),
348 fix: "put the literal you were handed where your engine's file relation takes a \
349 path. It arrives already quoted and escaped."
350 .to_owned(),
351 });
352 }
353 if SqlRenderer::delimited_file_relation(dialect, probe).is_none() {
354 findings.push(Finding {
355 severity: Severity::Blocking,
356 what: "this dialect states how to read a file in a named encoding and states no way \
357 to read a file at all"
358 .to_owned(),
359 fix: "answer `delimited_file_relation` as well, or answer neither. Razeri asks the \
360 first question before the second — a source whose bytes are UTF-8 never \
361 reaches the encoding arm — so an engine that answers only the second has \
362 written a relation nothing will ask it for."
363 .to_owned(),
364 });
365 }
366}
367
368fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
370 let spelling = SqlRenderer::sid_spelling(dialect);
371 if spelling.platform == UNKNOWN_PLATFORM {
372 findings.push(Finding {
373 severity: Severity::Blocking,
374 what: "this dialect states no surrogate-key spelling, so every key it renders calls \
375 an `xxhash64` its engine very likely does not have"
376 .to_owned(),
377 fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
378 TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
379 hash. If it has no portable hash, name an artifact your prerequisites install \
380 — never the nearest-looking built-in, which keys a warehouse to a number \
381 nothing else can reproduce."
382 .to_owned(),
383 });
384 }
385
386 let join = match spelling.concat {
390 Concat::Function(name) | Concat::FunctionBinary(name) | Concat::Operator(name) => name,
391 Concat::Plus => "+",
392 };
393 if join.trim().is_empty() {
394 findings.push(Finding {
395 severity: Severity::Blocking,
396 what: "this dialect's surrogate-key spelling joins the parts of a composite key \
397 with a function or operator that has no name"
398 .to_owned(),
399 fix: "name what your engine joins strings with in `SidSpelling::concat`: \
400 `Concat::Function(\"CONCAT\")`, `Concat::FunctionBinary(\"CONCAT\")` where it \
401 takes exactly two arguments, or `Concat::Operator(\"||\")` where it has an \
402 operator and no function."
403 .to_owned(),
404 });
405 }
406
407 if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
412 && supplied.body.trim().is_empty()
413 {
414 findings.push(Finding {
415 severity: Severity::Blocking,
416 what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
417 and then supplies an empty one"
418 .to_owned(),
419 fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
420 one argument, or answer `None` and let Razeri compose the derivation from \
421 your `sid_spelling`. An empty body deploys without error and keys every row \
422 to nothing."
423 .to_owned(),
424 });
425 }
426
427 let probe = "'/razeri/probe.csv'";
430 if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
431 && !relation.contains(probe)
432 {
433 findings.push(Finding {
434 severity: Severity::Blocking,
435 what: "`delimited_file_relation` returns a relation that does not carry the path it \
436 was given"
437 .to_owned(),
438 fix: "put the literal you were handed where your engine's file relation takes a \
439 path. It arrives already quoted and escaped. A relation that drops it reads \
440 one file for every entity the design binds, and the generated fill loads the \
441 wrong rows without failing."
442 .to_owned(),
443 });
444 }
445
446 check_encoded_file_relation(dialect, probe, findings);
447 check_routine_forms(dialect, findings);
448 check_slice_replacement(dialect, findings);
449
450 if let ConstraintEnforcement::DeclaredOnly(declared) =
453 DdlRenderer::constraint_enforcement(dialect)
454 && declared.note.split_whitespace().count() < 5
455 {
456 findings.push(Finding {
457 severity: Severity::Blocking,
458 what: "this dialect says its engine accepts constraints it does not enforce, and \
459 the note explaining that says almost nothing"
460 .to_owned(),
461 fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
462 guarantee. Say what this engine really does with the declaration, and where \
463 the guarantee has to come from instead."
464 .to_owned(),
465 });
466 }
467
468 match DialectCapabilities::merge_shape(dialect) {
474 MergeShape::None => findings.push(Finding {
475 severity: Severity::Unanswered,
476 what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
477 load for it"
478 .to_owned(),
479 fix: "if this engine has a MERGE or an upsert statement, say which in \
480 `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
481 `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
482 operator can act on, rather than a silence."
483 .to_owned(),
484 }),
485 MergeShape::Unsupported(orientation) => {
486 check_refusal_copy("the upsert refusal", orientation, findings);
487 }
488 MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
489 _ => {}
490 }
491}
492
493fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
508 const VALUE: &str = "$razeri_slice_probe";
509 let plan = StoragePlan {
510 partition: PartitionStrategy::RangeByTimeDimension {
511 field: "razeri_probe_day".to_owned(),
512 granularity: PartitionGranularity::Day,
513 retention: None,
514 look_ahead: None,
515 window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
516 },
517 compression: CompressionStrategy::None,
518 index: IndexStrategy::None,
519 };
520 let day = DataType::date();
521 let columns = ["razeri_probe_day"];
522 let entity = EntityRef {
523 schema: Some("razeri_probe"),
524 name: "razeri_probe_table",
525 is_identity_table: false,
526 columns: &columns,
527 partition_column_type: Some(&day),
528 };
529 let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
530 let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
531 else {
532 return;
533 };
534
535 let carries = match &answer {
536 SliceReplacement::Emptied(statements) => {
537 !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
538 }
539 SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
540 };
541 if carries {
547 return;
548 }
549 findings.push(Finding {
550 severity: Severity::Blocking,
551 what: "`render_slice_replacement` returns a statement that does not carry the slice it \
552 was handed"
553 .to_owned(),
554 fix: "put the value you were given where your statement names the slice, and build any \
555 partition NAME from it the same way your extend procedure built it. A replacement \
556 that drops the value clears a slice this call was not given — or every slice — \
557 and the load reports success over what it emptied."
558 .to_owned(),
559 });
560}
561
562fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
579 let answers = DialectCapabilities::evolution_answers(dialect);
580 let boolean = DataType::boolean();
581 let long = DataType::varchar(40);
582
583 if DdlRenderer::render_column_evolution(
584 dialect,
585 &ColumnEvolution::AddColumn {
586 schema: Some("s"),
587 table: "t",
588 column: "c",
589 data_type: &boolean,
590 },
591 )
592 .is_none()
593 {
594 findings.push(Finding {
595 severity: Severity::Blocking,
596 what: "this dialect writes no statement for a column that is new, which is the \
597 commonest change a deployed warehouse ever sees"
598 .to_owned(),
599 fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the \
600 override and take the contract's `ALTER TABLE … ADD COLUMN …`, which most \
601 engines take; some write `ADD` without `COLUMN`, so run it on yours first. An \
602 engine that genuinely has no way to add a column is worth saying out loud in \
603 the crate's own documentation."
604 .to_owned(),
605 });
606 }
607
608 if answers.parameterized_string_widening_is_lossless
609 && DdlRenderer::render_column_evolution(
610 dialect,
611 &ColumnEvolution::WidenColumn {
612 schema: Some("s"),
613 table: "t",
614 column: "c",
615 data_type: &long,
616 },
617 )
618 .is_none()
619 {
620 findings.push(Finding {
621 severity: Severity::Note,
622 what: "this dialect says its engine widens a string's declared length in place, and \
623 writes no statement for the change"
624 .to_owned(),
625 fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this \
626 engine's spelling of it. Without it Razeri tells an operator the widening \
627 costs nothing and then hands them nothing to run, which reads as Razeri not \
628 having finished rather than as a statement they own."
629 .to_owned(),
630 });
631 }
632
633 if answers.nested_field_addition_is_lossless
634 && DdlRenderer::render_column_evolution(
635 dialect,
636 &ColumnEvolution::AddNestedField {
637 schema: Some("s"),
638 table: "t",
639 column: "c",
640 field: "f",
641 data_type: &long,
642 },
643 )
644 .is_none()
645 {
646 findings.push(Finding {
647 severity: Severity::Note,
648 what: "this dialect says its engine keeps every value when a record gains a field, \
649 and writes no statement for the change"
650 .to_owned(),
651 fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some \
652 engines name the new field; some restate the whole column's type, which is \
653 why the change carries both the field's path and the column's type with the \
654 field in it."
655 .to_owned(),
656 });
657 }
658}
659
660fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
671 if restrictions.slice_pruning_needs_literal {
672 findings.push(Finding {
673 severity: Severity::Note,
674 what: "this dialect says its planner reads one slice of a partitioned table only \
675 from a literal, so a load scoped to one day cannot simply bind the day"
676 .to_owned(),
677 fix: "Razeri will put the day into the statement text through this engine's \
678 dynamic-execution form rather than binding it to the load procedure's \
679 parameter. Confirm that is what your engine wants: the statement is composed \
680 once per call and its text differs per day, which some planners cache and \
681 some do not. If a bound parameter does prune here, drop the restriction and \
682 Razeri binds it like everywhere else."
683 .to_owned(),
684 });
685 }
686}
687
688fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
691 let blank: Vec<&str> = [
695 ("render_power_operator", FormulaRenderer::render_power_operator(dialect)),
696 ("render_ilike_operator", FormulaRenderer::render_ilike_operator(dialect)),
697 ("render_not_ilike_operator", FormulaRenderer::render_not_ilike_operator(dialect)),
698 ("render_concat_operator", FormulaRenderer::render_concat_operator(dialect)),
699 ]
700 .into_iter()
701 .filter(|(_, answer)| answer.as_deref().is_some_and(|op| op.trim().is_empty()))
702 .map(|(method, _)| method)
703 .collect();
704 if !blank.is_empty() {
705 findings.push(Finding {
706 severity: Severity::Blocking,
707 what: format!("{} answers an operator with no text in it", blank.join(", ")),
708 fix: "answer `None` where your engine has no such operator: a formula that needs it \
709 is then refused by name, and the reader is told to write the function your \
710 engine has. An empty answer writes the two operands side by side."
711 .to_owned(),
712 });
713 }
714
715 let formula = SqlRenderer::to_formula_renderer(dialect);
716 let dialect_name = SqlRenderer::dialect_name(dialect);
717 if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
718 findings.push(Finding {
719 severity: Severity::Blocking,
720 what: "`to_formula_renderer` was not overridden, so every formula in every design \
721 renders through Razeri's ANSI defaults instead of this dialect"
722 .to_owned(),
723 fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
724 formula this engine sees is silently somebody else's."
725 .to_owned(),
726 });
727 }
728}
729
730fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
736 match SqlRenderer::procedure_form(dialect) {
737 ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
741 ProcedureForm::Agnostic => findings.push(Finding {
742 severity: Severity::Unanswered,
743 what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
744 annotated form — which no engine parses"
745 .to_owned(),
746 fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
747 or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
748 offers instead."
749 .to_owned(),
750 }),
751 ProcedureForm::Unsupported(orientation) => {
752 check_refusal_copy("the stored-procedure refusal", orientation, findings);
753 if DialectCapabilities::supports_stored_procedures(dialect) {
754 findings.push(Finding {
755 severity: Severity::Blocking,
756 what: "this dialect says its engine has stored procedures, and its \
757 procedure form refuses to write one"
758 .to_owned(),
759 fix: "decide which is true. If the engine has none, leave \
760 `DialectCapabilities::supports_stored_procedures` at `false`; if it \
761 has them, answer `procedure_form` with the grammar it speaks."
762 .to_owned(),
763 });
764 }
765 }
766 ProcedureForm::Custom(grammar) => check_custom_grammar(
767 CustomGrammar {
768 kind: RoutineKind::Procedure,
769 grammar,
770 form_method: "procedure_form",
771 render_method: "render_procedure",
772 noun: "procedures",
773 },
774 |spec| SqlRenderer::render_procedure(dialect, spec),
775 findings,
776 ),
777 _ => {}
778 }
779 check_procedures_are_used(dialect, findings);
780
781 match SqlRenderer::scalar_function_form(dialect) {
782 ScalarFunctionForm::Unsupported(orientation) => {
783 check_refusal_copy("the scalar-function refusal", orientation, findings);
784 }
785 ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
786 CustomGrammar {
787 kind: RoutineKind::ScalarFunction,
788 grammar,
789 form_method: "scalar_function_form",
790 render_method: "render_scalar_function",
791 noun: "scalar functions",
792 },
793 |spec| SqlRenderer::render_scalar_function(dialect, spec),
794 findings,
795 ),
796 _ => {}
797 }
798
799 match SqlRenderer::table_function_form(dialect) {
800 TableFunctionForm::Unsupported(orientation) => {
801 check_refusal_copy("the table-function refusal", orientation, findings);
802 }
803 TableFunctionForm::Custom(grammar) => check_custom_grammar(
804 CustomGrammar {
805 kind: RoutineKind::TableFunction,
806 grammar,
807 form_method: "table_function_form",
808 render_method: "render_table_function",
809 noun: "table functions",
810 },
811 |spec| SqlRenderer::render_table_function(dialect, spec),
812 findings,
813 ),
814 _ => {}
815 }
816
817 check_parameter_type_spelling(dialect, findings);
818}
819
820fn check_procedures_are_used(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
832 let Some(grammar) = SqlRenderer::procedure_form(dialect).custom_grammar() else {
833 return;
834 };
835 if DialectCapabilities::supports_stored_procedures(dialect) {
836 return;
837 }
838 findings.push(Finding {
839 severity: Severity::Blocking,
840 what: format!(
841 "this dialect writes procedures in {grammar:?} and says its engine has none, so no \
842 load is ever written as one"
843 ),
844 fix: "answer `DialectCapabilities::supports_stored_procedures` with `true`. Razeri asks \
845 it before it writes a load as a procedure, and its default is `false`."
846 .to_owned(),
847 });
848}
849
850fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
865 let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
866 || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
867 || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
868 if !spells_its_own {
869 return;
870 }
871 let qualified = DataType::varchar(100);
874 let column = SqlRenderer::render_data_type(dialect, &qualified);
875 let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
876 if column == parameter && column.contains('(') {
877 findings.push(Finding {
878 severity: Severity::Note,
879 what: format!(
880 "a parameter of this routine's would be declared `{parameter}`, the same as a \
881 column of that type"
882 ),
883 fix: "that is right for most engines, so this is only worth a look. If yours refuses \
884 a length or a precision in a parameter list — Oracle does, and answers \
885 PLS-00103 at the opening parenthesis — override \
886 `SqlRenderer::render_parameter_data_type` with the bare spelling. Local \
887 declarations keep the length either way."
888 .to_owned(),
889 });
890 }
891}
892
893#[derive(Clone, Copy)]
895struct CustomGrammar {
896 kind: RoutineKind,
898 grammar: &'static str,
900 form_method: &'static str,
902 render_method: &'static str,
904 noun: &'static str,
906}
907
908fn check_custom_grammar(
925 claim: CustomGrammar,
926 render: impl Fn(&RoutineSpec) -> Option<String>,
927 findings: &mut Vec<Finding>,
928) {
929 let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
930 if grammar.trim().is_empty() {
933 findings.push(Finding {
934 severity: Severity::Blocking,
935 what: format!("`{form_method}` says Custom, and names no grammar"),
936 fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
937 of a report, and of the comment Razeri writes where a routine could not be \
938 rendered, is told this engine speaks."
939 .to_owned(),
940 });
941 }
942
943 let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
944 probe.or_replace = true;
945 if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
946 findings.push(Finding {
947 severity: Severity::Blocking,
948 what: format!(
949 "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
950 itself — and `{render_method}` returns nothing"
951 ),
952 fix: format!(
953 "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
954 composed — name, parameters with their types already in your spelling, body, \
955 comment — and returns the whole CREATE statement your engine parses. Razeri \
956 emits what you return and writes nothing of its own: a near-neighbour grammar \
957 fails on deploy, and a refusal would say this engine has no {noun} when it \
958 has them in a grammar of its own."
959 ),
960 });
961 }
962}
963
964fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
966 if copy.trim().is_empty() {
967 findings.push(Finding {
968 severity: Severity::Blocking,
969 what: format!("{subject} has no words in it"),
970 fix: "say what this engine offers instead. A reader who hits a refusal needs the \
971 next thing to try, and it has to be about their engine."
972 .to_owned(),
973 });
974 return;
975 }
976 if copy.split_whitespace().count() < 5 {
977 findings.push(Finding {
978 severity: Severity::Note,
979 what: format!("{subject} is very short"),
980 fix: "a refusal earns its place by naming the alternative — what this engine has \
981 instead of the thing it cannot do."
982 .to_owned(),
983 });
984 }
985}
986
987fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
989 let caps: &dyn DialectCapabilities = dialect;
990 let ren: &dyn SqlRenderer = dialect;
991 let ddl: &dyn DdlRenderer = dialect;
992 let mut disagreements: Vec<&'static str> = Vec::new();
993
994 macro_rules! pair {
995 ($m:ident, $renderer_value:expr) => {
996 if caps.$m() != $renderer_value {
997 disagreements.push(stringify!($m));
998 }
999 };
1000 }
1001 pair!(supports_on_conflict, ren.supports_on_conflict());
1002 pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
1003 pair!(supports_update_from, ren.supports_update_from());
1004 pair!(supports_named_windows, ren.supports_named_windows());
1005 pair!(supports_compound_assignment, ren.supports_compound_assignment());
1006 pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
1007 pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
1008 pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
1009 pair!(uses_alter_table_update, ren.uses_alter_table_update());
1010 pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
1011 pair!(supports_cascade_drop, ddl.supports_cascade_drop());
1012 pair!(supports_sync_drop, ddl.supports_sync_drop());
1013 pair!(supports_force_drop, ddl.supports_force_drop());
1014
1015 if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
1024 disagreements.push("supports_table_functions");
1025 }
1026 if caps.supports_stored_functions() != ren.scalar_function_form().refusal().is_none() {
1029 disagreements.push("supports_stored_functions");
1030 }
1031
1032 if !disagreements.is_empty() {
1033 findings.push(Finding {
1034 severity: Severity::Blocking,
1035 what: format!(
1036 "this dialect declares {} fact(s) two ways and the two disagree: {}",
1037 disagreements.len(),
1038 disagreements.join(", ")
1039 ),
1040 fix: "each of these is declared once on the capability trait and once in the \
1041 renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
1042 and move the other one to match."
1043 .to_owned(),
1044 });
1045 }
1046}
1047
1048fn check_schema_fold(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1057 let (schema, name) = ("razeri_probe_raw", "razeri_probe_table");
1058 let Some(folded) = SqlRenderer::fold_schema_into_name(dialect, schema, name) else {
1059 return;
1060 };
1061 if DialectCapabilities::supports_schemas(dialect) {
1062 findings.push(Finding {
1063 severity: Severity::Blocking,
1064 what: "this dialect folds a schema into an object's name on an engine it says has \
1065 schemas"
1066 .to_owned(),
1067 fix: "answer `fold_schema_into_name` with `None`. A fold is for an engine with no \
1068 schemas, and only there is it asked; or answer \
1069 `DialectCapabilities::supports_schemas` with `false` if your engine has none."
1070 .to_owned(),
1071 });
1072 return;
1073 }
1074 let expected = SqlRenderer::quote_identifier(dialect, &folded);
1075 let qualified = SqlRenderer::qualify_name(dialect, Some(schema), name);
1076 if qualified != expected {
1077 findings.push(Finding {
1078 severity: Severity::Blocking,
1079 what: format!(
1080 "`fold_schema_into_name` names an object `{expected}` and `qualify_name` names \
1081 the same object `{qualified}`"
1082 ),
1083 fix: "make `qualify_name` answer the folded name, quoted, when it is given a \
1084 schema. Razeri names some objects through it and the rest through the fold, \
1085 so a difference creates a table under one name and reads it under another."
1086 .to_owned(),
1087 });
1088 }
1089}
1090
1091fn check_deploy_client(info: &crate::dialect::DialectInfo, findings: &mut Vec<Finding>) {
1098 let Some(crate::dialect::DeployScript::Client(client)) = info.deploy_script else {
1099 return;
1100 };
1101 if client.command.trim().is_empty() {
1102 findings.push(Finding {
1103 severity: Severity::Blocking,
1104 what: "this dialect describes a deploy client with no command".to_owned(),
1105 fix: "name the client's command as an operator has it on `PATH`.".to_owned(),
1106 });
1107 }
1108 let unnamed: Vec<&str> = [("run_file", Some(client.run_file)), ("read_rows", client.read_rows)]
1109 .into_iter()
1110 .filter_map(|(field, args)| {
1111 args.filter(|args| !args.iter().any(|arg| arg.contains("{file}"))).map(|_| field)
1112 })
1113 .collect();
1114 if !unnamed.is_empty() {
1115 findings.push(Finding {
1116 severity: Severity::Blocking,
1117 what: format!(
1118 "the deploy client's {} never names the file to run",
1119 unnamed.join(" and ")
1120 ),
1121 fix: "put `{file}` where your client takes the file of SQL to run — on its own, or \
1122 inside an argument such as `--file={file}`. The script writes the file there \
1123 and nowhere else."
1124 .to_owned(),
1125 });
1126 }
1127}
1128
1129fn declarable_types() -> Vec<DataType> {
1137 vec![
1138 DataType::tinyint(),
1139 DataType::smallint(),
1140 DataType::int(),
1141 DataType::bigint(),
1142 DataType::double(),
1143 DataType::boolean(),
1144 DataType::text(),
1145 DataType::varchar(200),
1146 DataType::uuid(),
1147 DataType::json(),
1148 DataType::date(),
1149 DataType::timestamp(),
1150 DataType::timestamp_tz(),
1151 DataType::decimal(18, 2),
1152 DataType::Array(ArrayDataType::new(DataType::varchar(100))),
1153 DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
1154 DataType::Struct(StructDataType::new(vec![
1155 StructField::new("street", DataType::varchar(200)),
1156 StructField::new("zip", DataType::varchar(10)),
1157 ])),
1158 DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
1159 StructField::new("key", DataType::varchar(100)),
1160 StructField::new("value", DataType::varchar(1000)),
1161 ])))),
1162 ]
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167 use super::*;
1168 use crate::dialect::DialectInfo;
1169 use razeri_plugin_contract::{
1170 Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
1171 ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
1172 TaskRenderer, TextCast,
1173 };
1174 use std::marker::PhantomData;
1175
1176 struct BrokenPlugin;
1184
1185 impl SqlRenderer for BrokenPlugin {
1186 fn render_data_type(&self, _dt: &DataType) -> String {
1187 String::new()
1188 }
1189 fn quote_identifier(&self, name: &str) -> String {
1190 name.to_owned()
1191 }
1192 fn render_cast_type(&self, _dt: &DataType) -> String {
1193 String::new()
1194 }
1195 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1196 name.to_owned()
1197 }
1198 fn render_current_timestamp(&self) -> String {
1199 String::new()
1200 }
1201 fn dialect_name(&self) -> &'static str {
1202 "brokendb"
1203 }
1204 fn supports_on_conflict(&self) -> bool {
1207 true
1208 }
1209 }
1210 impl ExpressionRenderer for BrokenPlugin {}
1211 impl DdlRenderer for BrokenPlugin {}
1212 impl DmlRenderer for BrokenPlugin {}
1213 impl ProcedureRenderer for BrokenPlugin {}
1214 impl FormulaRenderer for BrokenPlugin {}
1215 impl TaskRenderer for BrokenPlugin {}
1216 impl ReplicationRenderer for BrokenPlugin {}
1217 impl StorageRenderer for BrokenPlugin {}
1218 impl DialectCapabilities for BrokenPlugin {
1219 fn supports_on_conflict(&self) -> bool {
1220 false
1221 }
1222 }
1223 impl PlatformDialect for BrokenPlugin {
1224 fn info(&self) -> DialectInfo {
1225 DialectInfo {
1226 name: "brokendb",
1227 display_name: "Broken DB",
1228 aliases: &[],
1229 dialect_family: DialectFamily::Custom(""),
1230 family: "Broken",
1231 is_cloud_native: false,
1232 is_olap: false,
1233 is_postgres_compatible: false,
1234 versions: &[],
1235 deploy_script: None,
1236 }
1237 }
1238 }
1239
1240 trait FixtureName: Send + Sync + 'static {
1249 const NAME: &'static str;
1251 const CONCAT: Concat = Concat::Function("CONCAT");
1255 const POWER: Option<&'static str> = Some("^");
1258 const DEPLOY: Option<crate::dialect::DeployScript> = None;
1260 }
1261
1262 struct Unlinked;
1265 impl FixtureName for Unlinked {
1266 const NAME: &'static str = "healthydb";
1267 }
1268
1269 struct Outside;
1272 impl FixtureName for Outside {
1273 const NAME: &'static str = "outsidedb";
1274 }
1275
1276 struct BinaryConcat;
1281 impl FixtureName for BinaryConcat {
1282 const NAME: &'static str = "binaryconcatdb";
1283 const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1284 }
1285
1286 struct OperatorConcat;
1290 impl FixtureName for OperatorConcat {
1291 const NAME: &'static str = "operatorconcatdb";
1292 const CONCAT: Concat = Concat::Operator("||");
1293 }
1294
1295 struct NamelessConcat;
1297 impl FixtureName for NamelessConcat {
1298 const NAME: &'static str = "namelessconcatdb";
1299 const CONCAT: Concat = Concat::Operator(" ");
1300 }
1301
1302 struct NoPowerOperator;
1305 impl FixtureName for NoPowerOperator {
1306 const NAME: &'static str = "nopowerdb";
1307 const POWER: Option<&'static str> = None;
1308 }
1309
1310 struct BlankPowerOperator;
1312 impl FixtureName for BlankPowerOperator {
1313 const NAME: &'static str = "blankpowerdb";
1314 const POWER: Option<&'static str> = Some(" ");
1315 }
1316
1317 struct DescribedClient;
1319 impl FixtureName for DescribedClient {
1320 const NAME: &'static str = "describedclientdb";
1321 const DEPLOY: Option<crate::dialect::DeployScript> =
1322 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1323 command: "sqlite3",
1324 run_file: &["-bail", "{target}", ".read {file}"],
1325 read_rows: Some(&["-csv", "-noheader", "{target}", ".read {file}"]),
1326 }));
1327 }
1328
1329 struct FilelessClient;
1331 impl FixtureName for FilelessClient {
1332 const NAME: &'static str = "filelessclientdb";
1333 const DEPLOY: Option<crate::dialect::DeployScript> =
1334 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1335 command: "sqlite3",
1336 run_file: &["-bail", "{target}"],
1337 read_rows: None,
1338 }));
1339 }
1340
1341 struct Answered<N: FixtureName>(PhantomData<N>);
1342
1343 impl<N: FixtureName> Answered<N> {
1344 const fn new() -> Self {
1345 Self(PhantomData)
1346 }
1347 }
1348
1349 type HealthyPlugin = Answered<Unlinked>;
1350 type OutsidePlugin = Answered<Outside>;
1351
1352 inventory::submit! {
1353 crate::builder::DialectRegistration::new(
1354 Outside::NAME,
1355 || std::sync::Arc::new(OutsidePlugin::new()),
1356 )
1357 }
1358
1359 impl<N: FixtureName> SqlRenderer for Answered<N> {
1360 fn render_data_type(&self, dt: &DataType) -> String {
1361 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1362 }
1363 fn quote_identifier(&self, name: &str) -> String {
1364 format!("\"{name}\"")
1365 }
1366 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1367 schema.map_or_else(
1368 || self.quote_identifier(name),
1369 |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
1370 )
1371 }
1372 fn render_current_timestamp(&self) -> String {
1373 "NOW()".to_owned()
1374 }
1375 fn dialect_name(&self) -> &'static str {
1376 N::NAME
1377 }
1378 fn sid_spelling(&self) -> SidSpelling {
1379 SidSpelling {
1380 platform: N::NAME,
1381 hash: ("answered_xxhash64(", ")"),
1382 cast_type: TextCast::Direct("STRING"),
1383 concat: N::CONCAT,
1384 through_helper: false,
1385 ..razeri_plugin_contract::ANSI_SID_SPELLING
1386 }
1387 }
1388 fn procedure_form(&self) -> ProcedureForm {
1389 ProcedureForm::Unsupported(
1390 "This engine has no stored procedures; write the load as a scheduled query \
1391 instead.",
1392 )
1393 }
1394 fn scalar_function_form(&self) -> ScalarFunctionForm {
1395 ScalarFunctionForm::PostgreSql
1396 }
1397 fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
1398 Box::new(Self::new())
1399 }
1400 }
1401 impl<N: FixtureName> ExpressionRenderer for Answered<N> {}
1402 impl<N: FixtureName> DdlRenderer for Answered<N> {}
1403 impl<N: FixtureName> DmlRenderer for Answered<N> {}
1404 impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
1405 impl<N: FixtureName> FormulaRenderer for Answered<N> {
1406 fn render_power_operator(&self) -> Option<String> {
1407 N::POWER.map(str::to_owned)
1408 }
1409 }
1410 impl<N: FixtureName> TaskRenderer for Answered<N> {}
1411 impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
1412 impl<N: FixtureName> StorageRenderer for Answered<N> {}
1413 impl<N: FixtureName> DialectCapabilities for Answered<N> {
1414 fn merge_shape(&self) -> MergeShape {
1415 MergeShape::Merge(MergeRestrictions::none())
1416 }
1417 }
1418 impl<N: FixtureName> PlatformDialect for Answered<N> {
1419 fn info(&self) -> DialectInfo {
1420 DialectInfo {
1421 name: N::NAME,
1422 display_name: "An Answered Engine",
1423 aliases: &[],
1426 dialect_family: DialectFamily::Custom(N::NAME),
1427 family: "Answered",
1428 is_cloud_native: false,
1429 is_olap: false,
1430 is_postgres_compatible: false,
1431 versions: &[],
1432 deploy_script: N::DEPLOY,
1433 }
1434 }
1435 }
1436
1437 struct Twin;
1443
1444 impl SqlRenderer for Twin {
1445 fn render_data_type(&self, dt: &DataType) -> String {
1446 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1447 }
1448 fn quote_identifier(&self, name: &str) -> String {
1449 format!("\"{name}\"")
1450 }
1451 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1452 self.quote_identifier(name)
1453 }
1454 fn render_current_timestamp(&self) -> String {
1455 "NOW()".to_owned()
1456 }
1457 }
1458 impl ExpressionRenderer for Twin {}
1459 impl DdlRenderer for Twin {}
1460 impl DmlRenderer for Twin {}
1461 impl ProcedureRenderer for Twin {}
1462 impl FormulaRenderer for Twin {}
1463 impl TaskRenderer for Twin {}
1464 impl ReplicationRenderer for Twin {}
1465 impl StorageRenderer for Twin {}
1466 impl DialectCapabilities for Twin {}
1467 impl PlatformDialect for Twin {
1468 fn info(&self) -> DialectInfo {
1469 DialectInfo {
1470 name: "twindb",
1471 display_name: "Twin DB",
1472 aliases: &["healthydb"],
1474 dialect_family: DialectFamily::Custom("twindb"),
1475 family: "Twin",
1476 is_cloud_native: false,
1477 is_olap: false,
1478 is_postgres_compatible: false,
1479 versions: &[],
1480 deploy_script: None,
1481 }
1482 }
1483 }
1484
1485 fn mentions(report: &Report, needle: &str) -> bool {
1487 report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
1488 }
1489
1490 #[test]
1494 fn the_kit_catches_a_deliberately_broken_plugin() {
1495 let report = check(&BrokenPlugin);
1496 assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
1497
1498 assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
1499 assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
1500 assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
1501 assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
1502 assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
1503 assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
1504 assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
1505 assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
1506 assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
1507 assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
1508 assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
1509
1510 assert!(
1511 report.count(Severity::Blocking) >= 8,
1512 "the broken plugin owes more than {} blocking fixes:\n{report}",
1513 report.count(Severity::Blocking)
1514 );
1515 }
1516
1517 #[test]
1521 fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
1522 let report = check(&HealthyPlugin::new());
1523 let unregistered: Vec<&Finding> =
1524 report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
1525 assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
1526 assert_eq!(
1527 report.findings.len(),
1528 1,
1529 "a plugin that answered every question owes nothing else:\n{report}"
1530 );
1531 }
1532
1533 #[test]
1538 fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
1539 let binary = Answered::<BinaryConcat>::new();
1540 let report = check(&binary);
1541 assert_eq!(
1542 report.findings.len(),
1543 check(&HealthyPlugin::new()).findings.len(),
1544 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
1545 );
1546 assert!(
1547 !report.to_string().contains("joins"),
1548 "nothing about the join is outstanding:\n{report}"
1549 );
1550
1551 let key = razeri_plugin_contract::sid::sid_expression(
1552 SqlRenderer::sid_spelling(&binary),
1553 None,
1554 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1555 );
1556 assert!(
1557 key.contains("CONCAT(CONCAT("),
1558 "a two-argument engine gets one call per pair:\n{key}"
1559 );
1560 }
1561
1562 #[test]
1566 fn the_kit_takes_an_operator_join_as_an_answer_like_any_other() {
1567 let operator = Answered::<OperatorConcat>::new();
1568 let report = check(&operator);
1569 assert_eq!(
1570 report.findings.len(),
1571 check(&HealthyPlugin::new()).findings.len(),
1572 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
1573 );
1574 let key = razeri_plugin_contract::sid::sid_expression(
1575 SqlRenderer::sid_spelling(&operator),
1576 None,
1577 &["a".to_owned(), "b".to_owned()],
1578 );
1579 assert!(key.contains(" || '|' || "), "the operator joins each part:\n{key}");
1580 assert!(!key.contains("CONCAT"), "an operator engine calls no function:\n{key}");
1581 }
1582
1583 #[test]
1586 fn a_join_that_names_nothing_must_be_fixed() {
1587 let report = check(&Answered::<NamelessConcat>::new());
1588 assert!(
1589 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
1590 && finding.what.contains("joins the parts of a composite key")),
1591 "{report}"
1592 );
1593 assert!(
1594 !check(&Answered::<OperatorConcat>::new())
1595 .findings
1596 .iter()
1597 .any(|finding| finding.what.contains("joins the parts of a composite key")),
1598 "a named operator is not the finding"
1599 );
1600 }
1601
1602 #[test]
1606 fn an_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
1607 let none = check(&Answered::<NoPowerOperator>::new());
1608 assert_eq!(
1609 none.findings.len(),
1610 check(&HealthyPlugin::new()).findings.len(),
1611 "saying the engine has no such operator owes nothing:\n{none}"
1612 );
1613 let blank = check(&Answered::<BlankPowerOperator>::new());
1614 assert!(
1615 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
1616 && finding.what.contains("render_power_operator")),
1617 "{blank}"
1618 );
1619 }
1620
1621 #[test]
1624 fn a_deploy_client_has_to_be_told_which_file_to_run() {
1625 let described = check(&Answered::<DescribedClient>::new());
1626 assert_eq!(
1627 described.findings.len(),
1628 check(&HealthyPlugin::new()).findings.len(),
1629 "a client described in full owes nothing:\n{described}"
1630 );
1631 let fileless = check(&Answered::<FilelessClient>::new());
1632 assert!(
1633 fileless.findings.iter().any(|finding| finding.severity == Severity::Blocking
1634 && finding.what.contains("run_file never names the file")),
1635 "{fileless}"
1636 );
1637 }
1638
1639 #[test]
1643 fn a_schema_fold_agrees_with_qualify_name_on_an_engine_with_no_schemas() {
1644 struct Folder<const SCHEMAS: bool, const AGREES: bool>;
1645 impl<const SCHEMAS: bool, const AGREES: bool> SqlRenderer for Folder<SCHEMAS, AGREES> {
1646 fn render_data_type(&self, dt: &DataType) -> String {
1647 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1648 }
1649 fn quote_identifier(&self, name: &str) -> String {
1650 format!("\"{name}\"")
1651 }
1652 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1653 match schema {
1654 Some(schema) if AGREES => self.quote_identifier(&format!("{schema}_{name}")),
1655 Some(schema) => format!("{schema}.{}", self.quote_identifier(name)),
1656 None => self.quote_identifier(name),
1657 }
1658 }
1659 fn render_current_timestamp(&self) -> String {
1660 "CURRENT_TIMESTAMP".to_owned()
1661 }
1662 fn fold_schema_into_name(&self, schema: &str, name: &str) -> Option<String> {
1663 Some(format!("{schema}_{name}"))
1664 }
1665 }
1666 impl<const S: bool, const A: bool> ExpressionRenderer for Folder<S, A> {}
1667 impl<const S: bool, const A: bool> DdlRenderer for Folder<S, A> {}
1668 impl<const S: bool, const A: bool> DmlRenderer for Folder<S, A> {}
1669 impl<const S: bool, const A: bool> ProcedureRenderer for Folder<S, A> {}
1670 impl<const S: bool, const A: bool> FormulaRenderer for Folder<S, A> {}
1671 impl<const S: bool, const A: bool> TaskRenderer for Folder<S, A> {}
1672 impl<const S: bool, const A: bool> ReplicationRenderer for Folder<S, A> {}
1673 impl<const S: bool, const A: bool> StorageRenderer for Folder<S, A> {}
1674 impl<const S: bool, const A: bool> DialectCapabilities for Folder<S, A> {
1675 fn supports_schemas(&self) -> bool {
1676 S
1677 }
1678 }
1679 impl<const S: bool, const A: bool> PlatformDialect for Folder<S, A> {
1680 fn info(&self) -> DialectInfo {
1681 DialectInfo {
1682 name: "folderdb",
1683 display_name: "Folder DB",
1684 aliases: &[],
1685 dialect_family: DialectFamily::Custom("folderdb"),
1686 family: "Folder",
1687 is_cloud_native: false,
1688 is_olap: false,
1689 is_postgres_compatible: false,
1690 versions: &[],
1691 deploy_script: None,
1692 }
1693 }
1694 }
1695
1696 let fold_findings = |dialect: &dyn PlatformDialect| {
1697 let mut findings = Vec::new();
1698 check_schema_fold(dialect, &mut findings);
1699 findings
1700 };
1701 assert!(fold_findings(&Folder::<false, true>).is_empty(), "a fold kept everywhere");
1702 let disagrees = fold_findings(&Folder::<false, false>);
1703 assert_eq!(disagrees.len(), 1, "{disagrees:?}");
1704 assert!(disagrees[0].what.contains("`qualify_name` names"), "{disagrees:?}");
1705 let has_schemas = fold_findings(&Folder::<true, true>);
1706 assert_eq!(has_schemas.len(), 1, "{has_schemas:?}");
1707 assert!(has_schemas[0].what.contains("says has schemas"), "{has_schemas:?}");
1708 }
1709
1710 #[test]
1719 fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
1720 let mut quiet = Vec::new();
1721 check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
1722 assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
1723
1724 let mut stated = Vec::new();
1725 check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
1726 assert_eq!(stated.len(), 1, "the stated restriction is reported once");
1727 assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
1728 assert!(
1729 stated[0].what.contains("literal"),
1730 "the finding names the restriction:\n{}",
1731 stated[0].what
1732 );
1733 assert!(
1734 stated[0].fix.contains("dynamic-execution"),
1735 "and says what Razeri does with the answer:\n{}",
1736 stated[0].fix
1737 );
1738
1739 let mut two_clauses = Vec::new();
1742 check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
1743 assert!(two_clauses.is_empty(), "one restriction is not the other");
1744 }
1745
1746 #[test]
1752 fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
1753 assert!(
1754 crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
1755 "the fixture registers through the registry, which is the whole path being graded"
1756 );
1757 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1761 assert!(
1762 registry
1763 .list()
1764 .iter()
1765 .filter(|info| info.name != Outside::NAME)
1766 .all(|info| !info.answers_to(Outside::NAME)),
1767 "the fixture's name must be one no other platform claims"
1768 );
1769
1770 let report = check(&OutsidePlugin::new());
1771 assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
1772 assert!(report.is_clean(), "and the report says so:\n{report}");
1773 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
1774 assert!(
1775 report.to_string().contains("nothing to fix"),
1776 "a clean dialect is told it is clean:\n{report}"
1777 );
1778 }
1779
1780 #[test]
1786 fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
1787 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1788 for info in registry.list() {
1789 assert_eq!(
1790 info.spellings().first().copied(),
1791 Some(info.name),
1792 "a platform's own name leads its spellings"
1793 );
1794 assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
1795 }
1796 }
1797
1798 #[test]
1804 fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
1805 let mut registry = crate::dialect::PlatformRegistry::new();
1806 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1807 registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1808
1809 let report = check_with_registry(&Twin, ®istry);
1810 let collision = report
1811 .findings
1812 .iter()
1813 .find(|f| f.what.contains("claimed by more than one loaded platform"))
1814 .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
1815 assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
1816 assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
1817 assert!(collision.what.contains("twindb"), "{collision:?}");
1818 assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
1819 assert!(
1820 !mentions(&report, "does not resolve"),
1821 "a collision is not an unregistered platform:\n{report}"
1822 );
1823 }
1824
1825 #[test]
1829 fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
1830 let mut registry = crate::dialect::PlatformRegistry::new();
1831 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1832
1833 let mut findings = Vec::new();
1834 check_registration(&HealthyPlugin::new().info(), ®istry, &mut findings);
1835 assert!(findings.is_empty(), "{findings:?}");
1836
1837 for spelling in ["healthydb", "HealthyDB", " healthydb "] {
1838 assert!(
1839 crate::resolve::resolve_dialect_by_name_with_registry(spelling, ®istry).is_ok(),
1840 "'{spelling}' is the same platform"
1841 );
1842 }
1843 assert!(
1844 crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", ®istry).is_err()
1845 );
1846 }
1847
1848 #[test]
1853 fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
1854 let mut registry = crate::dialect::PlatformRegistry::new();
1855 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1856
1857 let report = check_with_registry(&HealthyPlugin::new(), ®istry);
1858 assert!(
1859 report.is_clean(),
1860 "a loaded dialect that answered everything owes nothing:\n{report}"
1861 );
1862 }
1863
1864 #[test]
1868 fn no_fix_line_sends_an_author_into_razeris_own_tree() {
1869 let mut collided = crate::dialect::PlatformRegistry::new();
1870 collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1871 collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1872
1873 let mut fixes: Vec<String> = Vec::new();
1874 for report in [
1875 check(&BrokenPlugin),
1876 check(&HealthyPlugin::new()),
1877 check(&OutsidePlugin::new()),
1878 check_with_registry(&Twin, &collided),
1881 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
1882 ] {
1883 fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
1884 }
1885 assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
1886 for fix in &fixes {
1887 for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
1888 assert!(
1889 !fix.contains(path),
1890 "a fix line names `{path}`, which is not in the author's crate:\n {fix}"
1891 );
1892 }
1893 }
1894 }
1895
1896 #[test]
1903 fn no_finding_holds_a_run_of_spaces() {
1904 struct Declining;
1905 impl SqlRenderer for Declining {
1906 fn render_data_type(&self, dt: &DataType) -> String {
1907 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1908 }
1909 fn quote_identifier(&self, name: &str) -> String {
1910 format!("\"{name}\"")
1911 }
1912 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1913 name.to_owned()
1914 }
1915 fn render_current_timestamp(&self) -> String {
1916 "CURRENT_TIMESTAMP".to_owned()
1917 }
1918 fn procedure_form(&self) -> ProcedureForm {
1919 ProcedureForm::Custom("Declining PSQL")
1920 }
1921 }
1922 impl ExpressionRenderer for Declining {}
1923 impl DdlRenderer for Declining {
1924 fn render_column_evolution(&self, _change: &ColumnEvolution<'_>) -> Option<String> {
1925 None
1926 }
1927 }
1928 impl DialectCapabilities for Declining {
1929 fn evolution_answers(&self) -> razeri_plugin_contract::EvolutionAnswers {
1930 razeri_plugin_contract::EvolutionAnswers::widening_a_string_in_place()
1931 .and_a_nested_field()
1932 }
1933 }
1934 impl DmlRenderer for Declining {}
1935 impl ProcedureRenderer for Declining {}
1936 impl FormulaRenderer for Declining {}
1937 impl TaskRenderer for Declining {}
1938 impl ReplicationRenderer for Declining {}
1939 impl StorageRenderer for Declining {}
1940 impl PlatformDialect for Declining {
1941 fn info(&self) -> DialectInfo {
1942 DialectInfo {
1943 name: "decliningdb",
1944 display_name: "Declining DB",
1945 aliases: &[],
1946 dialect_family: DialectFamily::Custom("decliningdb"),
1947 family: "Declining",
1948 is_cloud_native: false,
1949 is_olap: false,
1950 is_postgres_compatible: false,
1951 versions: &[],
1952 deploy_script: None,
1953 }
1954 }
1955 }
1956
1957 let mut findings = Vec::new();
1958 check_evolution(&Declining, &mut findings);
1959 check_parameter_type_spelling(&Declining, &mut findings);
1960 assert_eq!(findings.len(), 4, "every column change and the parameter note: {findings:?}");
1961
1962 for report in [
1963 check(&BrokenPlugin),
1964 check(&HealthyPlugin::new()),
1965 check(&OutsidePlugin::new()),
1966 check(&Declining),
1967 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
1968 ] {
1969 findings.extend(report.findings);
1970 }
1971 for finding in &findings {
1972 for text in [&finding.what, &finding.fix] {
1973 assert!(!text.contains(" "), "a finding holds a run of spaces:\n {text:?}");
1974 }
1975 }
1976 }
1977
1978 #[test]
1988 fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
1989 for (kind, form_method, render_method) in [
1990 (RoutineKind::Procedure, "procedure_form", "render_procedure"),
1991 (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
1992 (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
1993 ] {
1994 let mut promised = Vec::new();
1995 check_custom_grammar(
1996 CustomGrammar {
1997 kind,
1998 grammar: "PL/SQL",
1999 form_method,
2000 render_method,
2001 noun: "routines",
2002 },
2003 |_| None,
2004 &mut promised,
2005 );
2006 assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
2007 assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
2008 assert!(promised[0].what.contains(form_method), "{promised:?}");
2009 assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
2010 assert!(promised[0].fix.contains(render_method), "{promised:?}");
2011
2012 let mut kept = Vec::new();
2014 check_custom_grammar(
2015 CustomGrammar {
2016 kind,
2017 grammar: "PL/SQL",
2018 form_method,
2019 render_method,
2020 noun: "routines",
2021 },
2022 |spec| Some(format!("CREATE {} …", spec.name)),
2023 &mut kept,
2024 );
2025 assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
2026 }
2027 }
2028
2029 #[test]
2034 fn a_procedure_form_and_the_procedure_capability_must_agree() {
2035 fn check_forms(dialect: &dyn PlatformDialect) -> Report {
2036 let mut findings = Vec::new();
2037 check_routine_forms(dialect, &mut findings);
2038 Report { dialect: "claimsdb".to_owned(), findings }
2039 }
2040
2041 struct Claims<const CUSTOM: bool, const HAS: bool>;
2044 impl<const CUSTOM: bool, const HAS: bool> SqlRenderer for Claims<CUSTOM, HAS> {
2045 fn render_data_type(&self, dt: &DataType) -> String {
2046 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2047 }
2048 fn quote_identifier(&self, name: &str) -> String {
2049 format!("\"{name}\"")
2050 }
2051 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2052 self.quote_identifier(name)
2053 }
2054 fn render_current_timestamp(&self) -> String {
2055 "CURRENT_TIMESTAMP".to_owned()
2056 }
2057 fn procedure_form(&self) -> ProcedureForm {
2058 if CUSTOM {
2059 ProcedureForm::Custom("Firebird PSQL")
2060 } else {
2061 ProcedureForm::Unsupported(
2062 "This engine has no stored procedures; schedule the load as a query.",
2063 )
2064 }
2065 }
2066 fn render_procedure(&self, spec: &RoutineSpec) -> Option<String> {
2067 Some(format!("CREATE PROCEDURE {} AS BEGIN END", spec.name))
2068 }
2069 }
2070 impl<const CUSTOM: bool, const HAS: bool> ExpressionRenderer for Claims<CUSTOM, HAS> {}
2071 impl<const CUSTOM: bool, const HAS: bool> DdlRenderer for Claims<CUSTOM, HAS> {}
2072 impl<const CUSTOM: bool, const HAS: bool> DmlRenderer for Claims<CUSTOM, HAS> {}
2073 impl<const CUSTOM: bool, const HAS: bool> ProcedureRenderer for Claims<CUSTOM, HAS> {}
2074 impl<const CUSTOM: bool, const HAS: bool> FormulaRenderer for Claims<CUSTOM, HAS> {}
2075 impl<const CUSTOM: bool, const HAS: bool> TaskRenderer for Claims<CUSTOM, HAS> {}
2076 impl<const CUSTOM: bool, const HAS: bool> ReplicationRenderer for Claims<CUSTOM, HAS> {}
2077 impl<const CUSTOM: bool, const HAS: bool> StorageRenderer for Claims<CUSTOM, HAS> {}
2078 impl<const CUSTOM: bool, const HAS: bool> DialectCapabilities for Claims<CUSTOM, HAS> {
2079 fn supports_stored_procedures(&self) -> bool {
2080 HAS
2081 }
2082 }
2083 impl<const CUSTOM: bool, const HAS: bool> PlatformDialect for Claims<CUSTOM, HAS> {
2084 fn info(&self) -> DialectInfo {
2085 DialectInfo {
2086 name: "claimsdb",
2087 display_name: "Claims DB",
2088 aliases: &[],
2089 dialect_family: DialectFamily::Custom("claimsdb"),
2090 family: "Claims",
2091 is_cloud_native: false,
2092 is_olap: false,
2093 is_postgres_compatible: false,
2094 versions: &[],
2095 deploy_script: None,
2096 }
2097 }
2098 }
2099
2100 let disagreement = |report: &Report| {
2101 report
2102 .findings
2103 .iter()
2104 .filter(|finding| {
2105 finding.severity == Severity::Blocking
2106 && (finding.what.contains("says its engine has none")
2107 || finding.what.contains("refuses to write one"))
2108 })
2109 .count()
2110 };
2111 let mut findings = Vec::new();
2112 check_routine_forms(&Claims::<true, false>, &mut findings);
2113 let report = Report { dialect: "claimsdb".to_owned(), findings };
2114 assert_eq!(disagreement(&report), 1, "{report}");
2115 assert!(report.to_string().contains("supports_stored_procedures"), "{report}");
2116
2117 for (agrees, label) in [
2118 (check_forms(&Claims::<true, true>), "spelled and used"),
2119 (check_forms(&Claims::<false, false>), "refused and absent"),
2120 ] {
2121 assert_eq!(disagreement(&agrees), 0, "{label}: {agrees}");
2122 }
2123 assert_eq!(disagreement(&check_forms(&Claims::<false, true>)), 1, "refused and claimed");
2124 }
2125
2126 #[test]
2132 fn a_custom_render_method_that_returns_nothing_is_blocking() {
2133 let mut findings = Vec::new();
2134 check_custom_grammar(
2135 CustomGrammar {
2136 kind: RoutineKind::Procedure,
2137 grammar: "PL/SQL",
2138 form_method: "procedure_form",
2139 render_method: "render_procedure",
2140 noun: "procedures",
2141 },
2142 |_| Some(" \n".to_owned()),
2143 &mut findings,
2144 );
2145 assert_eq!(findings.len(), 1, "{findings:?}");
2146 assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
2147 }
2148
2149 #[test]
2151 fn a_custom_grammar_with_no_name_is_blocking() {
2152 let mut findings = Vec::new();
2153 check_custom_grammar(
2154 CustomGrammar {
2155 kind: RoutineKind::ScalarFunction,
2156 grammar: " ",
2157 form_method: "scalar_function_form",
2158 render_method: "render_scalar_function",
2159 noun: "scalar functions",
2160 },
2161 |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
2162 &mut findings,
2163 );
2164 assert_eq!(findings.len(), 1, "{findings:?}");
2165 assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
2166 }
2167
2168 #[test]
2176 fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
2177 struct Overstated;
2178 impl SqlRenderer for Overstated {
2179 fn render_data_type(&self, dt: &DataType) -> String {
2180 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2181 }
2182 fn quote_identifier(&self, name: &str) -> String {
2183 format!("\"{name}\"")
2184 }
2185 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2186 name.to_owned()
2187 }
2188 fn render_current_timestamp(&self) -> String {
2189 "CURRENT_TIMESTAMP".to_owned()
2190 }
2191 fn dialect_name(&self) -> &'static str {
2192 "overstateddb"
2193 }
2194 fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
2195 Some(razeri_plugin_contract::SidHelperBody {
2196 language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
2197 body: " \n ",
2198 })
2199 }
2200 fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
2201 Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
2202 }
2203 }
2204 impl ExpressionRenderer for Overstated {}
2205 impl DdlRenderer for Overstated {}
2206 impl DmlRenderer for Overstated {}
2207 impl ProcedureRenderer for Overstated {}
2208 impl FormulaRenderer for Overstated {}
2209 impl TaskRenderer for Overstated {}
2210 impl ReplicationRenderer for Overstated {}
2211 impl StorageRenderer for Overstated {}
2212 impl DialectCapabilities for Overstated {}
2213 impl PlatformDialect for Overstated {
2214 fn info(&self) -> DialectInfo {
2215 DialectInfo {
2216 name: "overstateddb",
2217 display_name: "Overstated DB",
2218 aliases: &[],
2219 dialect_family: DialectFamily::Custom(""),
2220 family: "Overstated",
2221 is_cloud_native: false,
2222 is_olap: false,
2223 is_postgres_compatible: false,
2224 versions: &[],
2225 deploy_script: None,
2226 }
2227 }
2228 }
2229
2230 let report = check(&Overstated);
2231 for (fragment, why) in [
2232 ("supplies an empty one", "a blank helper body"),
2233 ("does not carry the path", "a file relation that drops its path"),
2234 ] {
2235 let found = report
2236 .findings
2237 .iter()
2238 .find(|f| f.what.contains(fragment))
2239 .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
2240 assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
2241 }
2242
2243 let quiet = check(&HealthyPlugin::new());
2246 for fragment in ["supplies an empty one", "does not carry the path"] {
2247 assert!(
2248 !quiet.findings.iter().any(|f| f.what.contains(fragment)),
2249 "silence is the default and owes nothing:\n{quiet}"
2250 );
2251 }
2252 }
2253
2254 #[test]
2258 fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
2259 let mut mute = Vec::new();
2260 check_refusal_copy("the upsert refusal", "no merge", &mut mute);
2261 assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
2262 assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
2263
2264 let silence = check(&BrokenPlugin);
2265 assert!(
2266 silence
2267 .findings
2268 .iter()
2269 .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
2270 "a dialect that never answered is unanswered, not wrong:\n{silence}"
2271 );
2272 assert!(
2273 silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
2274 "the fix line names the way to state a measured refusal:\n{silence}"
2275 );
2276 }
2277
2278 #[test]
2282 fn the_report_reads_as_a_summary_then_a_fix_list() {
2283 let text = check(&BrokenPlugin).to_string();
2284 let first = text.lines().next().expect("a summary line");
2285 assert!(first.starts_with("brokendb:"), "{first}");
2286 assert!(first.contains("to fix"), "{first}");
2287 assert!(text.contains(" → "), "every finding names the fix:\n{text}");
2288 }
2289}