Skip to main content

razeri_plugin_api/
resolve.rs

1// Copyright (C) 2026 Gabriel Shea <gshea@ideapivot.com> - All Rights Reserved
2
3//! Canonical dialect resolver with alias support and near-miss suggestions.
4//!
5//! All service crates (CLI, API, MCP, AI, pipeline) should call
6//! [`resolve_dialect_by_name`] instead of maintaining their own match blocks.
7
8use crate::PlatformDialect;
9use crate::dialect::PlatformRegistry;
10use std::sync::Arc;
11use thiserror::Error;
12
13/// Error returned when a dialect name cannot be resolved.
14#[derive(Debug, Error)]
15pub enum ResolveError {
16    /// The name did not match any registered dialect or known alias.
17    #[error(
18        "unknown dialect: '{name}'. {suggestions_text}Registered: {registered}",
19        suggestions_text = if suggestions.is_empty() {
20            String::new()
21        } else {
22            format!("Did you mean: {}? ", suggestions.join(", "))
23        },
24        registered = registered.join(", ")
25    )]
26    UnknownDialect {
27        /// The name that was looked up.
28        name: String,
29        /// Near-miss suggestions (by edit distance).
30        suggestions: Vec<String>,
31        /// All registered dialect names.
32        registered: Vec<String>,
33    },
34}
35
36/// Alias table mapping user-facing names to canonical dialect names.
37///
38/// The canonical name must match `DialectInfo::name` of a registered dialect.
39/// Order does not matter — the first match on the left column wins.
40const ALIAS_TABLE: &[(&str, &str)] = &[
41    // PostgreSQL
42    ("postgres", "postgres"),
43    ("postgresql", "postgres"),
44    ("pg", "postgres"),
45    // Snowflake
46    ("snowflake", "snowflake"),
47    ("sf", "snowflake"),
48    // SQL Server / T-SQL
49    ("sqlserver", "sqlserver"),
50    ("tsql", "sqlserver"),
51    ("mssql", "sqlserver"),
52    ("sql_server", "sqlserver"),
53    // Databricks / Spark
54    ("databricks", "databricks"),
55    ("dbx", "databricks"),
56    ("spark", "databricks"),
57    // DuckDB
58    ("duckdb", "duckdb"),
59    ("duck", "duckdb"),
60    // ClickHouse
61    ("clickhouse", "clickhouse"),
62    ("ch", "clickhouse"),
63    // MariaDB / MySQL
64    ("mariadb", "mariadb"),
65    ("mariadb_columnstore", "mariadb"),
66    ("mysql", "mysql"),
67    // CitusDB
68    ("citusdb", "citus"),
69    ("citus", "citus"),
70    // Agnostic (Razeri's universal cross-database dialect)
71    ("agnostic", "agnostic"),
72    // SQLite (extension dialect)
73    ("sqlite", "sqlite"),
74    // Google BigQuery
75    ("bigquery", "bigquery"),
76    ("big_query", "bigquery"),
77    ("bq", "bigquery"),
78    // Amazon Redshift
79    ("redshift", "redshift"),
80    ("amazon_redshift", "redshift"),
81    ("rs", "redshift"),
82];
83
84/// Resolve a dialect by user-facing name string.
85///
86/// Performs case-insensitive matching with alias support. Falls back to the
87/// [`PlatformRegistry`] for any name not in the alias table (e.g. third-party
88/// extension plugins).
89///
90/// # Errors
91///
92/// Returns [`ResolveError::UnknownDialect`] with near-miss suggestions if the
93/// name is not recognized.
94///
95/// # Examples
96///
97/// ```no_run
98/// use razeri_plugin_api::ResolveError;
99/// use razeri_plugin_api::resolve::resolve_dialect_by_name;
100///
101/// # fn main() -> Result<(), ResolveError> {
102/// let dialect = resolve_dialect_by_name("pg")?;
103/// println!("{}", dialect.info().name);
104///
105/// let dialect = resolve_dialect_by_name("SNOWFLAKE")?;
106/// # Ok(())
107/// # }
108/// ```
109pub fn resolve_dialect_by_name(name: &str) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
110    let registry = PlatformRegistry::with_auto_discovered();
111    resolve_dialect_by_name_with_registry(name, &registry)
112}
113
114/// Resolve using an explicit registry (useful for testing and composition root).
115///
116/// # Errors
117///
118/// Returns an error if the operation fails.
119pub fn resolve_dialect_by_name_with_registry(
120    name: &str,
121    registry: &PlatformRegistry,
122) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
123    let normalized = name.trim().to_lowercase();
124
125    // 1. Try alias table → canonical name → registry
126    if let Some(canonical) = alias_lookup(&normalized)
127        && let Some(dialect) = registry.get_by_name(canonical)
128    {
129        return Ok(Arc::clone(dialect));
130    }
131
132    // 2. Try direct registry lookup (covers third-party extensions not in alias table)
133    if let Some(dialect) = registry.get_by_name(&normalized) {
134        return Ok(Arc::clone(dialect));
135    }
136
137    // 3. Build error with suggestions
138    let all_names = registered_names(registry);
139    let suggestions = near_misses(&normalized, &all_names);
140    Err(ResolveError::UnknownDialect { name: name.to_string(), suggestions, registered: all_names })
141}
142
143/// **The accepted spellings, as `(spelling, canonical name)` pairs.**
144///
145/// A design names its platform in whatever spelling its author reaches for, and
146/// this is the table that turns one into the other. A platform whose own name
147/// is missing from it is refused at the CLI door however well its dialect
148/// renders.
149#[must_use]
150pub const fn alias_spellings() -> &'static [(&'static str, &'static str)] {
151    ALIAS_TABLE
152}
153
154/// List all names accepted by the resolver (alias table + registered names).
155#[must_use]
156pub fn all_accepted_names() -> Vec<String> {
157    let registry = PlatformRegistry::with_auto_discovered();
158    let mut names: Vec<String> =
159        ALIAS_TABLE.iter().map(|(alias, _)| (*alias).to_string()).collect();
160    for info in registry.list() {
161        let n = info.name.to_string();
162        if !names.contains(&n) {
163            names.push(n);
164        }
165    }
166    names.sort();
167    names.dedup();
168    names
169}
170
171// ── Internal helpers ────────────────────────────────────────────────────
172
173/// Look up an alias (already lowercased) in the alias table.
174fn alias_lookup(normalized: &str) -> Option<&'static str> {
175    ALIAS_TABLE.iter().find(|(alias, _)| *alias == normalized).map(|(_, canonical)| *canonical)
176}
177
178/// Collect all registered dialect names from the registry.
179fn registered_names(registry: &PlatformRegistry) -> Vec<String> {
180    let mut names: Vec<String> = registry.list().iter().map(|info| info.name.to_string()).collect();
181    names.sort();
182    names
183}
184
185/// Find near-miss suggestions using Levenshtein distance ≤ 3.
186fn near_misses(input: &str, candidates: &[String]) -> Vec<String> {
187    let mut scored: Vec<(&str, usize)> = candidates
188        .iter()
189        .filter_map(|c| {
190            let dist = levenshtein(input, c);
191            if dist <= 3 { Some((c.as_str(), dist)) } else { None }
192        })
193        .collect();
194    scored.sort_by_key(|(_, d)| *d);
195    scored.into_iter().map(|(name, _)| name.to_string()).collect()
196}
197
198/// Simple Levenshtein distance (no allocation beyond the DP row).
199fn levenshtein(a: &str, b: &str) -> usize {
200    let a_bytes = a.as_bytes();
201    let b_bytes = b.as_bytes();
202    let m = a_bytes.len();
203    let n = b_bytes.len();
204
205    let mut prev: Vec<usize> = (0..=n).collect();
206    let mut curr = vec![0; n + 1];
207
208    for i in 1..=m {
209        curr[0] = i;
210        for j in 1..=n {
211            let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
212            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
213        }
214        std::mem::swap(&mut prev, &mut curr);
215    }
216    prev[n]
217}
218
219// ── Also include alias lookup in the ALIAS_TABLE entries ────────────────
220/// All known aliases (for documentation and help messages).
221#[must_use]
222pub fn all_aliases() -> &'static [(&'static str, &'static str)] {
223    ALIAS_TABLE
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::dialect::{DialectInfo, PlatformDialect, PlatformRegistry};
230    use razeri_plugin_contract::DataType;
231    use razeri_plugin_contract::DialectFamily;
232    use razeri_plugin_contract::{
233        DdlRenderer, DialectCapabilities, DmlRenderer, ExpressionRenderer, FormulaRenderer,
234        ProcedureRenderer, ReplicationRenderer, SqlRenderer, StorageRenderer, TaskRenderer,
235    };
236
237    /// Minimal test dialect.
238    struct StubDialect {
239        info: DialectInfo,
240    }
241
242    impl SqlRenderer for StubDialect {
243        fn render_data_type(&self, _dt: &DataType) -> String {
244            String::new()
245        }
246        fn quote_identifier(&self, name: &str) -> String {
247            format!("\"{name}\"")
248        }
249        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
250            self.quote_identifier(name)
251        }
252        fn render_current_timestamp(&self) -> String {
253            String::new()
254        }
255    }
256    impl ExpressionRenderer for StubDialect {}
257    impl DdlRenderer for StubDialect {}
258    impl DmlRenderer for StubDialect {}
259    impl ProcedureRenderer for StubDialect {}
260    impl FormulaRenderer for StubDialect {}
261    impl TaskRenderer for StubDialect {}
262    impl DialectCapabilities for StubDialect {}
263    impl ReplicationRenderer for StubDialect {}
264    impl StorageRenderer for StubDialect {}
265    impl PlatformDialect for StubDialect {
266        fn info(&self) -> DialectInfo {
267            self.info.clone()
268        }
269    }
270
271    fn test_registry() -> PlatformRegistry {
272        let mut reg = PlatformRegistry::new();
273        let entries: &[(&str, &str, DialectFamily, &str, bool, bool, bool)] = &[
274            ("postgres", "PostgreSQL", DialectFamily::PostgreSql, "PostgreSQL", false, false, true),
275            ("snowflake", "Snowflake", DialectFamily::Snowflake, "Snowflake", true, true, false),
276            ("sqlserver", "SQL Server", DialectFamily::TSql, "T-SQL", false, false, false),
277            ("databricks", "Databricks", DialectFamily::SparkSql, "Spark SQL", true, true, false),
278            (
279                "clickhouse",
280                "ClickHouse",
281                DialectFamily::ClickHouse,
282                "ClickHouse SQL",
283                false,
284                true,
285                false,
286            ),
287            ("duckdb", "DuckDB", DialectFamily::PostgreSql, "PostgreSQL", false, true, true),
288            ("citus", "Citus", DialectFamily::PostgreSql, "PostgreSQL", false, false, true),
289            ("mariadb", "MariaDB", DialectFamily::MariaDB, "MariaDB", false, true, false),
290            ("mysql", "MySQL", DialectFamily::MariaDB, "MariaDB", false, false, false),
291            ("agnostic", "Agnostic", DialectFamily::Razeri, "ANSI", false, false, false),
292            ("sqlite", "SQLite", DialectFamily::MariaDB, "SQLite", false, false, false),
293        ];
294        for &(name, display, family, fam_str, cloud, olap, pg_compat) in entries {
295            reg.register(
296                name.to_string(),
297                Arc::new(StubDialect {
298                    info: DialectInfo {
299                        name,
300                        display_name: display,
301                        dialect_family: family,
302                        family: fam_str,
303                        is_cloud_native: cloud,
304                        is_olap: olap,
305                        is_postgres_compatible: pg_compat,
306                    },
307                }),
308            );
309        }
310        reg
311    }
312
313    #[test]
314    fn alias_lookup_finds_canonical_names() {
315        assert_eq!(alias_lookup("pg"), Some("postgres"));
316        assert_eq!(alias_lookup("postgresql"), Some("postgres"));
317        assert_eq!(alias_lookup("tsql"), Some("sqlserver"));
318        assert_eq!(alias_lookup("mssql"), Some("sqlserver"));
319        assert_eq!(alias_lookup("sf"), Some("snowflake"));
320        assert_eq!(alias_lookup("ch"), Some("clickhouse"));
321        assert_eq!(alias_lookup("duck"), Some("duckdb"));
322        assert_eq!(alias_lookup("dbx"), Some("databricks"));
323        assert_eq!(alias_lookup("spark"), Some("databricks"));
324        assert_eq!(alias_lookup("citusdb"), Some("citus"));
325        assert_eq!(alias_lookup("sqlite"), Some("sqlite"));
326        assert_eq!(alias_lookup("agnostic"), Some("agnostic"));
327    }
328
329    #[test]
330    fn alias_lookup_with_unknown_returns_none() {
331        assert_eq!(alias_lookup("nosuchdb"), None);
332    }
333
334    #[test]
335    fn levenshtein_distance_basic() {
336        assert_eq!(levenshtein("postgres", "postgres"), 0);
337        assert_eq!(levenshtein("postgre", "postgres"), 1);
338        assert_eq!(levenshtein("postgrse", "postgres"), 2);
339        assert_eq!(levenshtein("", "abc"), 3);
340    }
341
342    #[test]
343    fn resolve_with_canonical_name_succeeds() {
344        let reg = test_registry();
345        assert!(resolve_dialect_by_name_with_registry("postgres", &reg).is_ok());
346    }
347
348    #[test]
349    fn resolve_with_alias_succeeds() {
350        let reg = test_registry();
351        assert!(resolve_dialect_by_name_with_registry("pg", &reg).is_ok());
352    }
353
354    #[test]
355    fn resolve_is_case_insensitive() {
356        let reg = test_registry();
357        assert!(resolve_dialect_by_name_with_registry("SNOWFLAKE", &reg).is_ok());
358    }
359
360    #[test]
361    fn resolve_sqlite_extension_succeeds() {
362        let reg = test_registry();
363        assert!(resolve_dialect_by_name_with_registry("sqlite", &reg).is_ok());
364    }
365
366    #[test]
367    fn resolve_databricks_succeeds() {
368        let reg = test_registry();
369        assert!(resolve_dialect_by_name_with_registry("databricks", &reg).is_ok());
370    }
371
372    #[test]
373    fn resolve_mssql_alias_succeeds() {
374        let reg = test_registry();
375        assert!(resolve_dialect_by_name_with_registry("mssql", &reg).is_ok());
376    }
377
378    #[test]
379    fn resolve_unknown_returns_error_with_suggestions() {
380        let reg = test_registry();
381        let result = resolve_dialect_by_name_with_registry("postres", &reg);
382        let Err(err) = result else { panic!("expected error") };
383        let msg = err.to_string();
384        assert!(msg.contains("unknown dialect"), "error should mention unknown dialect: {msg}");
385        assert!(msg.contains("postgres"), "error should suggest postgres: {msg}");
386    }
387
388    #[test]
389    fn resolve_completely_unknown_returns_registered_list() {
390        let reg = test_registry();
391        let result = resolve_dialect_by_name_with_registry("zzzznotadb", &reg);
392        let Err(err) = result else { panic!("expected error") };
393        let msg = err.to_string();
394        assert!(msg.contains("Registered:"), "error should list registered: {msg}");
395    }
396
397    #[test]
398    fn all_accepted_names_includes_aliases_and_registered() {
399        let names = all_accepted_names();
400        assert!(names.contains(&"pg".to_string()));
401        assert!(names.contains(&"postgres".to_string()));
402        assert!(names.contains(&"snowflake".to_string()));
403    }
404
405    /// The published design schema's `DatabasePlatform` enum must speak the
406    /// SAME vocabulary the resolver accepts.
407    ///
408    /// A design's `design.platforms` / `design.default_platform` strings are
409    /// resolved — everywhere they are resolved at all — through
410    /// [`resolve_dialect_by_name`], whose left-hand [`ALIAS_TABLE`] column is
411    /// the operative accepted set. When the schema's enum is a *subset* of it,
412    /// designs that parse and render cleanly fail their own published schema
413    /// (`designs/examples/partitioned-sales.yaml` declared `postgres`,
414    /// `sqlserver`, `mysql`, `sqlite` and failed all four); when it is a
415    /// *superset*, the schema advertises a spelling the resolver refuses. Set
416    /// equality is the only honest relation, so this test pins it in the crate
417    /// that owns the table — the schema cannot drift away from the resolver,
418    /// and the resolver cannot grow a spelling the schema never learned.
419    ///
420    /// It is *set* equality, not the ordered equality the sibling
421    /// `ArtifactType` pin in `razeri-design` asserts, because [`ALIAS_TABLE`]'s
422    /// own contract says its order does not matter — an alias moved between
423    /// two families is not a drift, and a gate that reddened on it would be
424    /// lying.
425    #[test]
426    fn design_schema_platform_enum_matches_the_alias_table() {
427        let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
428            .join("../../schemas/razeri-design.schema.json");
429        let text = std::fs::read_to_string(&schema_path)
430            .unwrap_or_else(|e| panic!("read {}: {e}", schema_path.display()));
431        let schema: serde_json::Value =
432            serde_json::from_str(&text).unwrap_or_else(|e| panic!("parse the design schema: {e}"));
433
434        let enum_values = schema["$defs"]["DatabasePlatform"]["enum"]
435            .as_array()
436            .expect("$defs.DatabasePlatform.enum is an array in the published design schema");
437        let mut published: Vec<String> = enum_values
438            .iter()
439            .map(|v| v.as_str().expect("every DatabasePlatform enum value is a string").to_string())
440            .collect();
441        published.sort();
442        published.dedup();
443
444        let mut accepted: Vec<String> =
445            ALIAS_TABLE.iter().map(|(alias, _)| (*alias).to_string()).collect();
446        accepted.sort();
447        accepted.dedup();
448
449        let advertised_but_refused: Vec<&String> =
450            published.iter().filter(|n| !accepted.contains(n)).collect();
451        let accepted_but_unpublished: Vec<&String> =
452            accepted.iter().filter(|n| !published.contains(n)).collect();
453
454        assert!(
455            advertised_but_refused.is_empty() && accepted_but_unpublished.is_empty(),
456            "schemas/razeri-design.schema.json $defs.DatabasePlatform has drifted from the \
457             canonical resolver's ALIAS_TABLE.\n  advertised by the schema but REFUSED by the \
458             resolver: {advertised_but_refused:?}\n  accepted by the resolver but MISSING from \
459             the schema: {accepted_but_unpublished:?}\nThe alias table is the authority: add the \
460             spelling to the schema's enum, or take it out of the alias table — do not let the \
461             two vocabularies differ."
462        );
463    }
464}