Skip to main content

razeri_plugin_api/
resolve.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! Resolving a platform name to the dialect that renders it.
6//!
7//! Every service crate (CLI, API, MCP, AI, pipeline) calls
8//! [`resolve_dialect_by_name`] instead of matching on names itself.
9//!
10//! The accepted spellings are not written down here. Each dialect declares its
11//! own name and the aliases it answers to (`DialectInfo::aliases`), and this
12//! module reads them off whatever dialects are loaded — so a platform that
13//! arrives as a plugin, whether linked in or loaded from a shared library at
14//! run time, brings its spellings with it and needs no change to Razeri.
15
16use crate::PlatformDialect;
17use crate::dialect::{PlatformRegistry, UnsupportedVersion};
18use razeri_plugin_contract::{EngineVersion, EngineVersionError, split_platform};
19use std::sync::Arc;
20use thiserror::Error;
21
22/// Error returned when a platform name cannot be resolved to one dialect.
23#[derive(Debug, Error)]
24pub enum ResolveError {
25    /// No loaded dialect answers to the name.
26    #[error(
27        "unknown dialect: '{name}'. {suggestions_text}Registered: {registered}",
28        suggestions_text = if suggestions.is_empty() {
29            String::new()
30        } else {
31            format!("Did you mean: {}? ", suggestions.join(", "))
32        },
33        registered = registered.join(", ")
34    )]
35    UnknownDialect {
36        /// The name that was looked up.
37        name: String,
38        /// Near-miss suggestions (by edit distance).
39        suggestions: Vec<String>,
40        /// All registered dialect names.
41        registered: Vec<String>,
42    },
43
44    /// More than one loaded dialect answers to the name, so it names neither.
45    ///
46    /// Two plugins claiming one spelling is a mistake in how they are named,
47    /// not something to guess at: whichever dialect happened to be found first
48    /// would silently render the other one's platform.
49    #[error(
50        "the platform name '{name}' belongs to {claimed_by_text} at once: use one platform's \
51         own name, or load only one of them",
52        claimed_by_text = claimed_by.join(" and ")
53    )]
54    AmbiguousName {
55        /// The name that was looked up.
56        name: String,
57        /// The registered dialects that each answer to it, sorted.
58        claimed_by: Vec<String>,
59    },
60
61    /// The name is not a platform's: a colon in it, or a version after its
62    /// `@` that is not one.
63    #[error("'{name}' is not a platform name: {reason}")]
64    NotAPlatformName {
65        /// The name that was looked up.
66        name: String,
67        /// What is wrong with it, with the spelling that is accepted where
68        /// there is one.
69        reason: EngineVersionError,
70    },
71
72    /// A version was named for an engine that takes none: a managed service
73    /// that chooses its own, or a target rendered the same for every version.
74    #[error(
75        "{display_name} {why}, so a platform name gives it no version: write `{engine}`, not \
76         '{name}'",
77        why = if *chosen_by_its_service {
78            "chooses its own version"
79        } else {
80            "is rendered the same for every version"
81        }
82    )]
83    VersionChosenByTheService {
84        /// The name that was looked up.
85        name: String,
86        /// The engine's own name, which is the spelling to write.
87        engine: String,
88        /// The engine's name as its vendor writes it.
89        display_name: String,
90        /// Whether the engine is a managed service, which chooses its own
91        /// version.
92        chosen_by_its_service: bool,
93    },
94
95    /// A version the engine's dialect does not answer for.
96    #[error(transparent)]
97    UnsupportedVersion(#[from] UnsupportedVersion),
98}
99
100/// Resolve a platform name to the dialect that renders it.
101///
102/// Reads the loaded dialects' declared spellings — the canonical name plus each
103/// alias — ignoring case and surrounding whitespace. A platform is an engine at
104/// a version: `postgres@17` is the dialect at 17, and `postgres` the dialect at
105/// the floor it declares, the oldest version it answers for.
106///
107/// # Errors
108///
109/// [`ResolveError::UnknownDialect`], with near-miss suggestions, if no loaded
110/// dialect answers to the name; [`ResolveError::AmbiguousName`] if two do;
111/// [`ResolveError::NotAPlatformName`] for a colon, or a version that is not
112/// one; [`ResolveError::VersionChosenByTheService`] for a version on an engine
113/// that takes none; and [`ResolveError::UnsupportedVersion`] for a version the
114/// dialect does not answer for, with the ones it does.
115///
116/// # Examples
117///
118/// ```no_run
119/// use razeri_plugin_api::ResolveError;
120/// use razeri_plugin_api::resolve::resolve_dialect_by_name;
121///
122/// # fn main() -> Result<(), ResolveError> {
123/// let dialect = resolve_dialect_by_name("pg")?;
124/// println!("{}", dialect.info().name);
125///
126/// let dialect = resolve_dialect_by_name("SNOWFLAKE")?;
127///
128/// let dialect = resolve_dialect_by_name("postgres@17")?;
129/// println!("{:?}", dialect.engine_version());
130/// # Ok(())
131/// # }
132/// ```
133pub fn resolve_dialect_by_name(name: &str) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
134    resolve_dialect_by_name_with_registry(name, crate::process::process_registry())
135}
136
137/// Resolve using an explicit registry — the path a host takes when it has built
138/// its own, including one holding dialects loaded at run time.
139///
140/// # Errors
141///
142/// The same errors as [`resolve_dialect_by_name`].
143pub fn resolve_dialect_by_name_with_registry(
144    name: &str,
145    registry: &PlatformRegistry,
146) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
147    let (engine, version) = split_platform(name).map_err(|reason| {
148        ResolveError::NotAPlatformName { name: name.trim().to_owned(), reason }
149    })?;
150    let dialect = resolve_engine(engine, name, registry)?;
151    at_named_version(dialect, version, name)
152}
153
154/// **The dialect at the version a name gives it**, or at its floor when the
155/// name gives none.
156fn at_named_version(
157    dialect: Arc<dyn PlatformDialect>,
158    version: Option<EngineVersion>,
159    name: &str,
160) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
161    let info = dialect.info();
162    let Some(asked) = version else {
163        return match info.versions.first() {
164            Some(floor) => Ok(dialect.at_version(floor)?),
165            None => Ok(dialect),
166        };
167    };
168    if info.versions.is_empty() {
169        return Err(ResolveError::VersionChosenByTheService {
170            name: name.trim().to_owned(),
171            engine: info.name.to_owned(),
172            display_name: info.display_name.to_owned(),
173            chosen_by_its_service: info.is_cloud_native,
174        });
175    }
176    match info.versions.iter().find(|declared| **declared == asked) {
177        Some(declared) => Ok(dialect.at_version(declared)?),
178        None => Err(UnsupportedVersion::of(&info, asked.as_str()).into()),
179    }
180}
181
182/// The dialect that answers to `wanted`, the engine's name with any version
183/// already split off.
184fn resolve_engine(
185    wanted: &str,
186    name: &str,
187    registry: &PlatformRegistry,
188) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
189    let wanted = wanted.trim();
190    let mut claimants: Vec<String> = registry
191        .registered_names()
192        .into_iter()
193        .filter(|key| answers_to(registry, key, wanted))
194        .collect();
195    claimants.sort();
196    claimants.dedup();
197
198    match claimants.as_slice() {
199        [] => {
200            let all_names = registered_names(registry);
201            let suggestions = near_misses(&wanted.to_lowercase(), &all_names);
202            Err(ResolveError::UnknownDialect {
203                name: name.to_string(),
204                suggestions,
205                registered: all_names,
206            })
207        }
208        [only] => registry.get_by_name(only).map(Arc::clone).ok_or_else(|| {
209            let all_names = registered_names(registry);
210            ResolveError::UnknownDialect {
211                name: name.to_string(),
212                suggestions: Vec::new(),
213                registered: all_names,
214            }
215        }),
216        _ => Err(ResolveError::AmbiguousName { name: wanted.to_string(), claimed_by: claimants }),
217    }
218}
219
220/// Every spelling the loaded dialects answer to, sorted.
221///
222/// Read off the dialects themselves, so a plugin's own names appear here the
223/// moment it is loaded.
224#[must_use]
225pub fn accepted_names(registry: &PlatformRegistry) -> Vec<String> {
226    let mut names: Vec<String> = registry
227        .list()
228        .iter()
229        .flat_map(|info| info.spellings().into_iter().map(str::to_owned))
230        .collect();
231    // The keys a host registered under resolve too, whatever the dialect calls
232    // itself.
233    names.extend(registry.registered_names());
234    names.sort();
235    names.dedup();
236    names
237}
238
239/// Every spelling the dialects linked into this program answer to, sorted.
240#[must_use]
241pub fn all_accepted_names() -> Vec<String> {
242    accepted_names(&PlatformRegistry::with_auto_discovered())
243}
244
245// ── Internal helpers ────────────────────────────────────────────────────
246
247/// Does the dialect registered under `key` answer to `wanted`?
248///
249/// The registration key counts alongside the dialect's own declared spellings:
250/// a host may register a dialect under a name of its choosing, and the name it
251/// chose has to resolve.
252fn answers_to(registry: &PlatformRegistry, key: &str, wanted: &str) -> bool {
253    if key.trim().eq_ignore_ascii_case(wanted) {
254        return true;
255    }
256    registry.get_by_name(key).is_some_and(|dialect| dialect.info().answers_to(wanted))
257}
258
259/// Collect all registered dialect names from the registry.
260fn registered_names(registry: &PlatformRegistry) -> Vec<String> {
261    let mut names: Vec<String> = registry.list().iter().map(|info| info.name.to_string()).collect();
262    names.sort();
263    names
264}
265
266/// Find near-miss suggestions using Levenshtein distance ≤ 3.
267fn near_misses(input: &str, candidates: &[String]) -> Vec<String> {
268    let mut scored: Vec<(&str, usize)> = candidates
269        .iter()
270        .filter_map(|c| {
271            let dist = levenshtein(input, c);
272            if dist <= 3 { Some((c.as_str(), dist)) } else { None }
273        })
274        .collect();
275    scored.sort_by_key(|(_, d)| *d);
276    scored.into_iter().map(|(name, _)| name.to_string()).collect()
277}
278
279/// Simple Levenshtein distance (no allocation beyond the DP row).
280fn levenshtein(a: &str, b: &str) -> usize {
281    let a_bytes = a.as_bytes();
282    let b_bytes = b.as_bytes();
283    let m = a_bytes.len();
284    let n = b_bytes.len();
285
286    let mut prev: Vec<usize> = (0..=n).collect();
287    let mut curr = vec![0; n + 1];
288
289    for i in 1..=m {
290        curr[0] = i;
291        for j in 1..=n {
292            let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
293            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
294        }
295        std::mem::swap(&mut prev, &mut curr);
296    }
297    prev[n]
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::dialect::{DialectInfo, PlatformDialect, PlatformRegistry};
304    use razeri_plugin_contract::DataType;
305    use razeri_plugin_contract::DialectFamily;
306    use razeri_plugin_contract::{
307        DdlRenderer, DialectCapabilities, DmlRenderer, ExpressionRenderer, FormulaRenderer,
308        ProcedureRenderer, ReplicationRenderer, SqlRenderer, StorageRenderer, TaskRenderer,
309    };
310
311    /// Minimal test dialect, answering for each of its versions with an
312    /// instance of its own, the way a dialect whose answers change between
313    /// versions does.
314    struct StubDialect {
315        info: DialectInfo,
316        at: Option<EngineVersion>,
317    }
318
319    impl SqlRenderer for StubDialect {
320        fn render_data_type(&self, _dt: &DataType) -> String {
321            String::new()
322        }
323        fn quote_identifier(&self, name: &str) -> String {
324            format!("\"{name}\"")
325        }
326        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
327            self.quote_identifier(name)
328        }
329        fn render_current_timestamp(&self) -> String {
330            String::new()
331        }
332    }
333    impl ExpressionRenderer for StubDialect {}
334    impl DdlRenderer for StubDialect {}
335    impl DmlRenderer for StubDialect {}
336    impl ProcedureRenderer for StubDialect {}
337    impl FormulaRenderer for StubDialect {}
338    impl TaskRenderer for StubDialect {}
339    impl DialectCapabilities for StubDialect {}
340    impl ReplicationRenderer for StubDialect {}
341    impl StorageRenderer for StubDialect {}
342    impl PlatformDialect for StubDialect {
343        fn info(&self) -> DialectInfo {
344            self.info.clone()
345        }
346
347        fn at_version(
348            self: Arc<Self>,
349            version: &EngineVersion,
350        ) -> Result<Arc<dyn PlatformDialect>, UnsupportedVersion> {
351            Ok(Arc::new(StubDialect { info: self.info.clone(), at: Some(*version) }))
352        }
353
354        fn engine_version(&self) -> Option<EngineVersion> {
355            self.at
356        }
357    }
358
359    /// The shipped-platform shape in miniature: a name, and the other
360    /// spellings that name answers to.
361    fn test_registry() -> PlatformRegistry {
362        let mut reg = PlatformRegistry::new();
363        let entries: &[(&str, &str, &[&str], DialectFamily, &str)] = &[
364            (
365                "postgres",
366                "PostgreSQL",
367                &["postgresql", "pg"],
368                DialectFamily::PostgreSql,
369                "PostgreSQL",
370            ),
371            ("snowflake", "Snowflake", &["sf"], DialectFamily::Snowflake, "Snowflake"),
372            (
373                "sqlserver",
374                "SQL Server",
375                &["tsql", "mssql", "sql_server"],
376                DialectFamily::TSql,
377                "T-SQL",
378            ),
379            ("databricks", "Databricks", &["dbx", "spark"], DialectFamily::SparkSql, "Spark SQL"),
380            ("clickhouse", "ClickHouse", &["ch"], DialectFamily::ClickHouse, "ClickHouse SQL"),
381            ("duckdb", "DuckDB", &["duck"], DialectFamily::PostgreSql, "PostgreSQL"),
382            ("citus", "Citus", &["citusdb"], DialectFamily::PostgreSql, "PostgreSQL"),
383            ("mariadb", "MariaDB", &["mariadb_columnstore"], DialectFamily::MariaDB, "MariaDB"),
384            ("mysql", "MySQL", &[], DialectFamily::MariaDB, "MariaDB"),
385            ("agnostic", "Agnostic", &[], DialectFamily::Razeri, "ANSI"),
386            ("sqlite", "SQLite", &[], DialectFamily::MariaDB, "SQLite"),
387        ];
388        for &(name, display, aliases, family, fam_str) in entries {
389            reg.register(name.to_string(), Arc::new(stub(name, display, aliases, family, fam_str)));
390        }
391        reg
392    }
393
394    fn stub(
395        name: &'static str,
396        display: &'static str,
397        aliases: &'static [&'static str],
398        family: DialectFamily,
399        fam_str: &'static str,
400    ) -> StubDialect {
401        StubDialect {
402            info: DialectInfo {
403                name,
404                display_name: display,
405                aliases,
406                dialect_family: family,
407                family: fam_str,
408                is_cloud_native: false,
409                is_olap: false,
410                is_postgres_compatible: false,
411                versions: &[],
412                deploy_script: None,
413            },
414            at: None,
415        }
416    }
417
418    const POSTGRES_VERSIONS: &[EngineVersion] = &[
419        EngineVersion::declared("15"),
420        EngineVersion::declared("16"),
421        EngineVersion::declared("17"),
422    ];
423    const ORACLE_VERSIONS: &[EngineVersion] = &[EngineVersion::declared("23ai")];
424
425    /// The test registry, with `PostgreSQL` answering for 15, 16 and 17, Oracle
426    /// for 23ai, and Snowflake a service that chooses its own.
427    fn versioned_registry() -> PlatformRegistry {
428        let mut reg = PlatformRegistry::new();
429        for (name, display, aliases, versions, is_service) in [
430            ("postgres", "PostgreSQL", &["postgresql", "pg"][..], POSTGRES_VERSIONS, false),
431            ("oracle", "Oracle", &[][..], ORACLE_VERSIONS, false),
432            ("snowflake", "Snowflake", &["sf"][..], &[][..], true),
433        ] {
434            let mut dialect = stub(name, display, aliases, DialectFamily::Custom(name), display);
435            dialect.info.versions = versions;
436            dialect.info.is_cloud_native = is_service;
437            reg.register(name.to_string(), Arc::new(dialect));
438        }
439        reg
440    }
441
442    fn version_of(name: &str, registry: &PlatformRegistry) -> String {
443        resolve_dialect_by_name_with_registry(name, registry)
444            .unwrap_or_else(|e| panic!("'{name}' resolves: {e}"))
445            .engine_version()
446            .map_or_else(|| "none".to_owned(), |v| v.to_string())
447    }
448
449    fn refusal(name: &str, registry: &PlatformRegistry) -> ResolveError {
450        match resolve_dialect_by_name_with_registry(name, registry) {
451            Ok(dialect) => panic!("'{name}' resolved to {}", dialect.info().name),
452            Err(error) => error,
453        }
454    }
455
456    /// **A name with no version is the engine at its floor** (D1).
457    #[test]
458    fn a_name_with_no_version_is_the_engine_at_its_floor() {
459        let reg = versioned_registry();
460        assert_eq!(version_of("postgres", &reg), "15");
461        assert_eq!(version_of("PG", &reg), "15");
462        assert_eq!(version_of("snowflake", &reg), "none");
463    }
464
465    #[test]
466    fn a_named_version_is_the_dialect_at_that_version() {
467        let reg = versioned_registry();
468        assert_eq!(version_of("postgres@17", &reg), "17");
469        assert_eq!(version_of(" PostgreSQL@16 ", &reg), "16");
470        assert_eq!(resolved("pg@17", &reg), "postgres");
471    }
472
473    /// **The version is matched against the one declared, and the declared
474    /// spelling is kept**: Oracle's `23` is its `23ai`.
475    #[test]
476    fn a_version_is_kept_in_the_spelling_the_dialect_declares() {
477        let reg = versioned_registry();
478        assert_eq!(version_of("oracle@23", &reg), "23ai");
479        assert_eq!(version_of("oracle@23ai", &reg), "23ai");
480    }
481
482    #[test]
483    fn a_version_the_engine_does_not_answer_for_is_refused_with_the_list() {
484        let reg = versioned_registry();
485        let error = refusal("postgres@12", &reg);
486        assert!(matches!(error, ResolveError::UnsupportedVersion(_)), "{error:?}");
487        let said = error.to_string();
488        assert!(said.contains("15, 16 and 17"), "{said}");
489        assert!(said.contains("`postgres@17`"), "{said}");
490    }
491
492    #[test]
493    fn a_version_on_a_service_that_chooses_its_own_is_refused_by_name() {
494        let reg = versioned_registry();
495        let error = refusal("snowflake@8", &reg);
496        assert!(matches!(error, ResolveError::VersionChosenByTheService { .. }), "{error:?}");
497        let said = error.to_string();
498        assert!(said.contains("Snowflake chooses its own version"), "{said}");
499        assert!(said.contains("write `snowflake`"), "{said}");
500    }
501
502    #[test]
503    fn a_dot_before_letters_is_refused_showing_the_accepted_spelling() {
504        let reg = versioned_registry();
505        let error = refusal("oracle@23.ai", &reg);
506        assert!(matches!(error, ResolveError::NotAPlatformName { .. }), "{error:?}");
507        assert!(error.to_string().contains("`oracle@23ai`"), "{error}");
508    }
509
510    #[test]
511    fn a_colon_is_refused() {
512        let reg = versioned_registry();
513        let error = refusal("postgres:17", &reg);
514        assert!(matches!(error, ResolveError::NotAPlatformName { .. }), "{error:?}");
515        assert!(error.to_string().contains("`postgres@17`"), "{error}");
516    }
517
518    #[test]
519    fn an_unknown_engine_with_a_version_is_refused_as_an_unknown_engine() {
520        let reg = versioned_registry();
521        let error = refusal("postgress@17", &reg);
522        let ResolveError::UnknownDialect { suggestions, .. } = &error else { panic!("{error:?}") };
523        assert_eq!(suggestions, &vec!["postgres".to_owned()]);
524    }
525
526    fn resolved(name: &str, registry: &PlatformRegistry) -> String {
527        resolve_dialect_by_name_with_registry(name, registry)
528            .unwrap_or_else(|e| panic!("'{name}' resolves: {e}"))
529            .info()
530            .name
531            .to_owned()
532    }
533
534    /// Every spelling a loaded dialect declares resolves back to that dialect.
535    ///
536    /// The resolver keeps no list of its own, so this is the whole contract:
537    /// what the dialects say is what Razeri accepts.
538    #[test]
539    fn every_declared_spelling_resolves_to_the_dialect_that_declared_it() {
540        let reg = test_registry();
541        for info in reg.list() {
542            for spelling in info.spellings() {
543                assert_eq!(resolved(spelling, &reg), info.name, "spelling '{spelling}'");
544            }
545        }
546    }
547
548    #[test]
549    fn the_declared_spellings_are_the_accepted_names_and_nothing_else_is() {
550        let reg = test_registry();
551        let accepted = accepted_names(&reg);
552        for info in reg.list() {
553            for spelling in info.spellings() {
554                assert!(accepted.contains(&spelling.to_owned()), "'{spelling}' is accepted");
555            }
556        }
557        for name in accepted {
558            assert!(
559                reg.list().iter().any(|info| info.spellings().contains(&name.as_str())),
560                "'{name}' is accepted, so some loaded dialect declares it"
561            );
562        }
563    }
564
565    #[test]
566    fn resolution_ignores_case_and_surrounding_whitespace() {
567        let reg = test_registry();
568        assert_eq!(resolved("SNOWFLAKE", &reg), "snowflake");
569        assert_eq!(resolved("  DuckDB  ", &reg), "duckdb");
570        assert_eq!(resolved("PG", &reg), "postgres");
571    }
572
573    #[test]
574    fn a_name_the_host_registered_resolves_even_if_the_dialect_never_declared_it() {
575        let mut reg = PlatformRegistry::new();
576        reg.register(
577            "housename".to_string(),
578            Arc::new(stub("mydb", "MyDB", &[], DialectFamily::Custom("mydb"), "MyDB")),
579        );
580        assert_eq!(resolved("housename", &reg), "mydb");
581        assert_eq!(resolved("mydb", &reg), "mydb");
582    }
583
584    /// **A platform Razeri has never heard of resolves by the names it brings
585    /// with it.** Nothing in this crate mentions `outsidedb` or `odb` — the
586    /// dialect declares them and the resolver reads them off the registry, which
587    /// is what makes a dialect loaded at run time as usable as a built-in one.
588    #[test]
589    fn a_dialect_razeri_never_heard_of_resolves_by_its_own_spellings() {
590        let mut reg = test_registry();
591        reg.register(
592            "outsidedb".to_string(),
593            Arc::new(stub(
594                "outsidedb",
595                "Outside DB",
596                &["odb", "outside_db"],
597                DialectFamily::Custom("outsidedb"),
598                "OutsideDB",
599            )),
600        );
601        for spelling in ["outsidedb", "odb", "outside_db", "  ODB "] {
602            assert_eq!(resolved(spelling, &reg), "outsidedb", "spelling '{spelling}'");
603        }
604        // It renders through the same trait objects as any built-in.
605        let dialect = resolve_dialect_by_name_with_registry("odb", &reg).expect("resolves");
606        assert_eq!(dialect.quote_identifier("orders"), "\"orders\"");
607        assert!(accepted_names(&reg).contains(&"odb".to_string()));
608    }
609
610    #[test]
611    fn resolve_unknown_returns_error_with_suggestions() {
612        let reg = test_registry();
613        let result = resolve_dialect_by_name_with_registry("postres", &reg);
614        let Err(err) = result else { panic!("expected error") };
615        let msg = err.to_string();
616        assert!(msg.contains("unknown dialect"), "error should mention unknown dialect: {msg}");
617        assert!(msg.contains("postgres"), "error should suggest postgres: {msg}");
618    }
619
620    #[test]
621    fn resolve_completely_unknown_returns_registered_list() {
622        let reg = test_registry();
623        let result = resolve_dialect_by_name_with_registry("zzzznotadb", &reg);
624        let Err(err) = result else { panic!("expected error") };
625        let msg = err.to_string();
626        assert!(msg.contains("Registered:"), "error should list registered: {msg}");
627    }
628
629    /// **A spelling two platforms claim names neither, and saying so is the
630    /// point.** Handing back whichever was found first would render one
631    /// platform's SQL under the other's name.
632    #[test]
633    fn a_spelling_two_dialects_claim_is_refused_and_both_are_named() {
634        let mut reg = test_registry();
635        reg.register(
636            "sparkler".to_string(),
637            Arc::new(stub(
638                "sparkler",
639                "Sparkler",
640                &["spark"],
641                DialectFamily::Custom("sparkler"),
642                "Sparkler",
643            )),
644        );
645        let Err(err) = resolve_dialect_by_name_with_registry("spark", &reg) else {
646            panic!("a spelling two dialects claim must not resolve")
647        };
648        let msg = err.to_string();
649        assert!(msg.contains("databricks"), "names the first claimant: {msg}");
650        assert!(msg.contains("sparkler"), "names the second claimant: {msg}");
651        assert!(msg.contains("use one platform's own name"), "says what to do: {msg}");
652        // The unshared spellings still resolve — one shared alias does not take
653        // either platform down with it.
654        assert_eq!(resolved("databricks", &reg), "databricks");
655        assert_eq!(resolved("dbx", &reg), "databricks");
656        assert_eq!(resolved("sparkler", &reg), "sparkler");
657    }
658
659    #[test]
660    fn a_dialect_claiming_another_dialects_own_name_is_refused_too() {
661        let mut reg = test_registry();
662        reg.register(
663            "impostor".to_string(),
664            Arc::new(stub(
665                "impostor",
666                "Impostor",
667                &["duckdb"],
668                DialectFamily::Custom("impostor"),
669                "Impostor",
670            )),
671        );
672        let Err(err) = resolve_dialect_by_name_with_registry("duckdb", &reg) else {
673            panic!("a claimed name must not resolve to whichever came first")
674        };
675        assert!(err.to_string().contains("impostor"), "{err}");
676    }
677
678    #[test]
679    fn levenshtein_distance_basic() {
680        assert_eq!(levenshtein("postgres", "postgres"), 0);
681        assert_eq!(levenshtein("postgre", "postgres"), 1);
682        assert_eq!(levenshtein("postgrse", "postgres"), 2);
683        assert_eq!(levenshtein("", "abc"), 3);
684    }
685}