Skip to main content

razeri_plugin_api/
builder.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//! Fluent builder for constructing a [`PluginRegistry`] at application startup.
6//!
7//! Also includes `inventory` registration types for compile-time plugin discovery.
8
9use crate::dialect::{PlatformDialect, PlatformRegistry};
10use crate::event::{EventRegistry, EventSink, RazeriEvent};
11use crate::language::{LanguageRegistry, LanguageRendererFactory};
12use crate::llm::{LlmClientFactory, LlmRegistry};
13use crate::model::{LlmModelAdapter, ModelRegistry};
14use crate::registry::PluginRegistry;
15use crate::storage::{StorageProviderFactory, StorageRegistry};
16use chrono::Utc;
17use razeri_plugin_contract::StoragePlatform;
18use std::sync::Arc;
19
20/// Fluent builder for constructing a [`PluginRegistry`] at application startup.
21///
22/// # Example
23///
24/// ```ignore
25/// let registry = PluginRegistryBuilder::new()
26///     .with_dialect("postgres", Arc::new(PostgresDialect))
27///     .with_llm("ollama".into(), Arc::new(OllamaFactory::new()))
28///     .with_auto_discovered()
29///     .build();
30/// ```
31pub struct PluginRegistryBuilder {
32    dialects: PlatformRegistry,
33    storage: StorageRegistry,
34    llm: LlmRegistry,
35    models: ModelRegistry,
36    language: LanguageRegistry,
37    events: EventRegistry,
38}
39
40impl PluginRegistryBuilder {
41    /// Create a new empty builder.
42    #[must_use]
43    pub fn new() -> Self {
44        Self {
45            dialects: PlatformRegistry::new(),
46            storage: StorageRegistry::new(),
47            llm: LlmRegistry::new(),
48            models: ModelRegistry::new(),
49            language: LanguageRegistry::new(),
50            events: EventRegistry::new(),
51        }
52    }
53
54    // ── Dialect registration ───────────────────────────────────────
55
56    /// Register a dialect by its canonical string name.
57    #[must_use]
58    pub fn with_dialect(mut self, name: &str, dialect: Arc<dyn PlatformDialect>) -> Self {
59        self.dialects.register(name.to_string(), dialect);
60        self
61    }
62
63    /// Register one dialect under the name it declares for itself.
64    ///
65    /// The `&mut self` form of [`with_dialect`](Self::with_dialect), for the
66    /// one caller that cannot use a builder chain: a plugin's registration
67    /// function, which is handed a `&mut PluginRegistryBuilder` the host owns.
68    pub fn register_dialect(&mut self, dialect: Arc<dyn PlatformDialect>) {
69        let name = dialect.info().name.to_string();
70        self.dialects.register(name, dialect);
71    }
72
73    // ── Storage registration ───────────────────────────────────────
74
75    /// Register a storage provider for a known (enum) storage platform.
76    #[must_use]
77    pub fn with_storage(
78        mut self,
79        platform: StoragePlatform,
80        factory: Arc<dyn StorageProviderFactory>,
81    ) -> Self {
82        self.storage.register_known(platform, factory);
83        self
84    }
85
86    /// Register a storage provider for an extension (string-named) platform.
87    #[must_use]
88    pub fn with_storage_extension(
89        mut self,
90        name: String,
91        factory: Arc<dyn StorageProviderFactory>,
92    ) -> Self {
93        self.storage.register_extension(name, factory);
94        self
95    }
96
97    // ── LLM registration ──────────────────────────────────────────
98
99    /// Register an LLM provider factory by name.
100    #[must_use]
101    pub fn with_llm(mut self, name: String, factory: Arc<dyn LlmClientFactory>) -> Self {
102        self.llm.register(name, factory);
103        self
104    }
105
106    // ── Model registration ────────────────────────────────────────
107
108    /// Register a model adapter (keyed by provider + `model_id`).
109    #[must_use]
110    pub fn with_model(mut self, adapter: Arc<dyn LlmModelAdapter>) -> Self {
111        self.models.register(adapter);
112        self
113    }
114
115    // ── Language renderer registration ────────────────────────────
116
117    /// Register a language renderer factory by language name.
118    #[must_use]
119    pub fn with_language(
120        mut self,
121        language: String,
122        factory: Arc<dyn LanguageRendererFactory>,
123    ) -> Self {
124        self.language.register(language, factory);
125        self
126    }
127
128    // ── Event sink registration ───────────────────────────────────
129
130    /// Register an event sink for observability.
131    #[must_use]
132    pub fn with_event_sink(mut self, sink: Arc<dyn EventSink>) -> Self {
133        self.events.register(sink);
134        self
135    }
136
137    // ── Auto-discovery ────────────────────────────────────────────
138
139    /// Discover and register all plugins submitted via `inventory::submit!`.
140    #[must_use]
141    pub fn with_auto_discovered(mut self) -> Self {
142        for reg in inventory::iter::<DialectRegistration> {
143            let dialect = (reg.factory)();
144            let name = dialect.info().name.to_string();
145            self.dialects.register(name, dialect);
146            self.record_plugin_loaded(reg.name, "dialect", "inventory");
147        }
148        for reg in inventory::iter::<StorageRegistration> {
149            let factory = (reg.factory)();
150            if let Some(platform) = factory.known_platform() {
151                self.storage.register_known(platform, factory);
152            } else {
153                self.storage.register_extension(reg.name.to_string(), factory);
154            }
155            self.record_plugin_loaded(reg.name, "storage", "inventory");
156        }
157        for reg in inventory::iter::<LlmRegistration> {
158            let factory = (reg.factory)();
159            self.llm.register(reg.name.to_string(), factory);
160            self.record_plugin_loaded(reg.name, "llm", "inventory");
161        }
162        for reg in inventory::iter::<ModelRegistration> {
163            let adapter = (reg.factory)();
164            self.models.register(adapter);
165            self.record_plugin_loaded(reg.model_id, "model", "inventory");
166        }
167        for reg in inventory::iter::<LanguageRegistration> {
168            let factory = (reg.factory)();
169            self.language.register(reg.language.to_string(), factory);
170            self.record_plugin_loaded(reg.language, "language", "inventory");
171        }
172        for reg in inventory::iter::<EventSinkRegistration> {
173            let sink = (reg.factory)();
174            self.events.register(sink);
175            self.record_plugin_loaded(reg.name, "event_sink", "inventory");
176        }
177        // Plugin crates linked into this binary with `export_plugin!`.
178        for reg in inventory::iter::<PluginRegistration> {
179            (reg.register)(&mut self);
180        }
181        self
182    }
183
184    /// Record a `PluginLoaded` event into the event history.
185    fn record_plugin_loaded(&self, name: &str, plugin_type: &str, source: &str) {
186        if let Ok(event) = RazeriEvent::plugin_loaded(
187            format!("plugin_loaded:{plugin_type}:{name}"),
188            Utc::now(),
189            name,
190            plugin_type,
191            source,
192        ) {
193            self.events.record(event);
194        }
195    }
196
197    // ── Dynamic loading ─────────────────────────────────────────
198
199    /// Discover and load all plugin shared libraries from `plugin_dir`.
200    ///
201    /// Scans the directory for `.so`/`.dylib`/`.dll` files, checks their
202    /// API version, and calls their registration function. Invalid or
203    /// incompatible plugins are logged and skipped.
204    ///
205    /// Requires the `dynamic` feature. Native-only: shared-library loading does
206    /// not exist on wasm32-unknown-unknown (a browser cannot dlopen `.so`s).
207    #[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
208    #[must_use]
209    pub fn with_dynamic_plugins(mut self, plugin_dir: &std::path::Path) -> Self {
210        let results = crate::dynamic::load_plugins_from_dir(plugin_dir, &mut self);
211        for result in &results {
212            match result {
213                crate::dynamic::PluginLoadResult::Loaded { name, path } => {
214                    tracing::info!("Loaded dynamic plugin '{name}' from {}", path.display());
215                    self.record_plugin_loaded(name, "dynamic", &path.display().to_string());
216                }
217                crate::dynamic::PluginLoadResult::Refused { path, refusal } => {
218                    tracing::warn!("Plugin {} was not loaded: {refusal}", path.display());
219                }
220                crate::dynamic::PluginLoadResult::Failed { path, error } => {
221                    tracing::warn!("Failed to load plugin from {}: {error}", path.display());
222                }
223            }
224        }
225        self
226    }
227
228    // ── Build ─────────────────────────────────────────────────────
229
230    /// Consume the builder and produce the final [`PluginRegistry`].
231    pub fn build(self) -> PluginRegistry {
232        PluginRegistry {
233            dialects: self.dialects,
234            storage: self.storage,
235            llm: self.llm,
236            models: self.models,
237            language: self.language,
238            events: self.events,
239        }
240    }
241}
242
243impl Default for PluginRegistryBuilder {
244    /// Create a fresh plugin-registry builder with empty sub-registries.
245    /// Parameters:
246    /// - none: Returns a new builder equivalent to `PluginRegistryBuilder::new()`.
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252// ── Inventory registration types ──────────────────────────────────────
253// Third-party crates submit these via `inventory::submit!` for automatic
254// discovery at startup.
255
256/// Registration entry for auto-discovered dialects.
257pub struct DialectRegistration {
258    /// Canonical dialect name (e.g. `"postgres"`).
259    pub name: &'static str,
260    /// Factory function that constructs the dialect plugin.
261    pub factory: fn() -> Arc<dyn PlatformDialect>,
262}
263
264impl DialectRegistration {
265    /// Create a new dialect registration entry.
266    pub const fn new(name: &'static str, factory: fn() -> Arc<dyn PlatformDialect>) -> Self {
267        Self { name, factory }
268    }
269}
270
271inventory::collect!(DialectRegistration);
272
273/// Registration entry for a whole plugin: the function that registers
274/// everything the plugin provides.
275///
276/// [`export_plugin!`](crate::export_plugin) submits one of these, so a plugin
277/// crate linked into a razeri binary is found at start-up with no other line.
278pub struct PluginRegistration {
279    /// The plugin's registration function.
280    pub register: fn(&mut PluginRegistryBuilder),
281}
282
283impl PluginRegistration {
284    /// Create a new plugin registration entry.
285    pub const fn new(register: fn(&mut PluginRegistryBuilder)) -> Self {
286        Self { register }
287    }
288}
289
290inventory::collect!(PluginRegistration);
291
292/// Registration entry for auto-discovered storage providers.
293pub struct StorageRegistration {
294    /// Canonical storage platform name (e.g. `"s3"`).
295    pub name: &'static str,
296    /// Factory function that constructs the storage provider.
297    pub factory: fn() -> Arc<dyn StorageProviderFactory>,
298}
299
300impl StorageRegistration {
301    /// Create a new storage registration entry.
302    pub const fn new(name: &'static str, factory: fn() -> Arc<dyn StorageProviderFactory>) -> Self {
303        Self { name, factory }
304    }
305}
306
307inventory::collect!(StorageRegistration);
308
309/// Registration entry for auto-discovered LLM providers.
310pub struct LlmRegistration {
311    /// Canonical provider name (e.g. `"ollama"`).
312    pub name: &'static str,
313    /// Factory function that constructs the LLM client factory.
314    pub factory: fn() -> Arc<dyn LlmClientFactory>,
315}
316
317impl LlmRegistration {
318    /// Create a new LLM provider registration entry.
319    pub const fn new(name: &'static str, factory: fn() -> Arc<dyn LlmClientFactory>) -> Self {
320        Self { name, factory }
321    }
322}
323
324inventory::collect!(LlmRegistration);
325
326/// Registration entry for auto-discovered model adapters.
327pub struct ModelRegistration {
328    /// Canonical model identifier (e.g. `"llama3"`).
329    pub model_id: &'static str,
330    /// Factory function that constructs the model adapter.
331    pub factory: fn() -> Arc<dyn LlmModelAdapter>,
332}
333
334impl ModelRegistration {
335    /// Create a new model adapter registration entry.
336    pub const fn new(model_id: &'static str, factory: fn() -> Arc<dyn LlmModelAdapter>) -> Self {
337        Self { model_id, factory }
338    }
339}
340
341inventory::collect!(ModelRegistration);
342
343/// Registration entry for auto-discovered language renderers.
344pub struct LanguageRegistration {
345    /// Target language name (e.g. `"python"`).
346    pub language: &'static str,
347    /// Factory function that constructs the language renderer factory.
348    pub factory: fn() -> Arc<dyn LanguageRendererFactory>,
349}
350
351impl LanguageRegistration {
352    /// Create a new language renderer registration entry.
353    pub const fn new(
354        language: &'static str,
355        factory: fn() -> Arc<dyn LanguageRendererFactory>,
356    ) -> Self {
357        Self { language, factory }
358    }
359}
360
361inventory::collect!(LanguageRegistration);
362
363/// Registration entry for auto-discovered event sinks.
364pub struct EventSinkRegistration {
365    /// Canonical sink name (e.g. `"stdout"`).
366    pub name: &'static str,
367    /// Factory function that constructs the event sink.
368    pub factory: fn() -> Arc<dyn EventSink>,
369}
370
371impl EventSinkRegistration {
372    /// Create a new event sink registration entry.
373    pub const fn new(name: &'static str, factory: fn() -> Arc<dyn EventSink>) -> Self {
374        Self { name, factory }
375    }
376}
377
378inventory::collect!(EventSinkRegistration);
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::dialect::{DialectInfo, PlatformDialect};
384    use crate::error::{EventSinkError, LanguageError, LlmError};
385    use crate::event::RazeriEvent;
386    use crate::llm::{ChatResponse, LlmClient, Message, ModelInfo, ToolDefinition};
387    use crate::model::ModelCapabilities;
388    use async_trait::async_trait;
389    use razeri_plugin_contract::DataType;
390    use razeri_plugin_contract::DialectFamily;
391    use razeri_plugin_contract::{
392        DdlRenderer, DialectCapabilities, DmlRenderer, ExpressionRenderer, FormulaRenderer,
393        ProcedureRenderer, ReplicationRenderer, SqlRenderer, StorageRenderer, TaskRenderer,
394    };
395    use std::collections::HashMap;
396
397    // ── Test doubles ──────────────────────────────────────────────
398
399    struct TestDialect;
400
401    impl SqlRenderer for TestDialect {
402        /// Render a placeholder data type for test dialect behavior.
403        /// Parameters:
404        /// - self: Test dialect instance.
405        /// - _dt: Data type to render (ignored in test double).
406        fn render_data_type(&self, _dt: &DataType) -> String {
407            "TEXT".to_string()
408        }
409        /// Quote an identifier using double quotes in test dialect output.
410        /// Parameters:
411        /// - self: Test dialect instance.
412        /// - name: Identifier text to quote.
413        fn quote_identifier(&self, name: &str) -> String {
414            format!("\"{name}\"")
415        }
416        /// Render a qualified name for test dialect output.
417        /// Parameters:
418        /// - self: Test dialect instance.
419        /// - _schema: Optional schema segment (ignored in test double).
420        /// - name: Object name segment to render.
421        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
422            name.to_string()
423        }
424        /// Render current timestamp expression for test dialect output.
425        /// Parameters:
426        /// - self: Test dialect instance.
427        fn render_current_timestamp(&self) -> String {
428            "NOW()".to_string()
429        }
430    }
431
432    impl ExpressionRenderer for TestDialect {}
433    impl DdlRenderer for TestDialect {}
434    impl DmlRenderer for TestDialect {}
435    impl ProcedureRenderer for TestDialect {}
436    impl FormulaRenderer for TestDialect {}
437    impl TaskRenderer for TestDialect {}
438    impl ReplicationRenderer for TestDialect {}
439    impl StorageRenderer for TestDialect {}
440
441    impl DialectCapabilities for TestDialect {
442        /// State this test dialect's upsert shape.
443        /// Parameters:
444        /// - self: Test dialect instance.
445        fn merge_shape(&self) -> razeri_plugin_contract::MergeShape {
446            razeri_plugin_contract::MergeShape::Merge(
447                razeri_plugin_contract::MergeRestrictions::none(),
448            )
449        }
450        /// Indicate test dialect support for identity columns.
451        /// Parameters:
452        /// - self: Test dialect instance.
453        fn supports_identity(&self) -> bool {
454            true
455        }
456        /// Indicate test dialect support for sequences.
457        /// Parameters:
458        /// - self: Test dialect instance.
459        fn supports_sequences(&self) -> bool {
460            true
461        }
462        /// Indicate test dialect support for stored procedures.
463        /// Parameters:
464        /// - self: Test dialect instance.
465        fn supports_stored_procedures(&self) -> bool {
466            true
467        }
468    }
469
470    impl PlatformDialect for TestDialect {
471        /// Return static dialect metadata for the test dialect.
472        /// Parameters:
473        /// - self: Test dialect instance.
474        fn info(&self) -> DialectInfo {
475            DialectInfo {
476                name: "test",
477                display_name: "Test DB",
478                aliases: &[],
479                dialect_family: DialectFamily::PostgreSql,
480                family: "PostgreSQL",
481                is_cloud_native: false,
482                is_olap: false,
483                is_postgres_compatible: false,
484                versions: &[],
485                deploy_script: None,
486            }
487        }
488    }
489
490    struct TestStorageFactory;
491    #[async_trait]
492    impl StorageProviderFactory for TestStorageFactory {
493        /// Return canonical name for test storage provider.
494        /// Parameters:
495        /// - self: Test storage factory instance.
496        fn name(&self) -> &'static str {
497            "test-storage"
498        }
499        /// Return display name for test storage provider.
500        /// Parameters:
501        /// - self: Test storage factory instance.
502        fn display_name(&self) -> &'static str {
503            "Test Storage"
504        }
505        /// Return known platform mapping for test storage provider.
506        /// Parameters:
507        /// - self: Test storage factory instance.
508        fn known_platform(&self) -> Option<StoragePlatform> {
509            None
510        }
511        async fn create(
512            &self,
513            _config: &HashMap<String, String>,
514        ) -> Result<Box<dyn crate::storage::StorageOps>, crate::error::StorageError> {
515            unimplemented!("test only")
516        }
517        /// Validate test storage config payload.
518        /// Parameters:
519        /// - self: Test storage factory instance.
520        /// - _config: Storage config payload (unused in test double).
521        fn validate_config(&self, _config: &HashMap<String, String>) -> Vec<String> {
522            vec![]
523        }
524        /// Return required test storage configuration keys.
525        /// Parameters:
526        /// - self: Test storage factory instance.
527        fn required_config_keys(&self) -> Vec<&str> {
528            vec![]
529        }
530    }
531
532    struct TestLlmFactory;
533    impl LlmClientFactory for TestLlmFactory {
534        /// Return canonical name for test LLM provider.
535        /// Parameters:
536        /// - self: Test LLM factory instance.
537        fn name(&self) -> &'static str {
538            "test-llm"
539        }
540        /// Return display name for test LLM provider.
541        /// Parameters:
542        /// - self: Test LLM factory instance.
543        fn display_name(&self) -> &'static str {
544            "Test LLM"
545        }
546        /// Construct a test LLM client instance.
547        /// Parameters:
548        /// - self: Test LLM factory instance.
549        /// - _config: Provider config payload (unused in test double).
550        fn create(
551            &self,
552            _config: &HashMap<String, String>,
553        ) -> Result<Arc<dyn LlmClient>, LlmError> {
554            Ok(Arc::new(TestLlmClient))
555        }
556        /// Validate test LLM config payload.
557        /// Parameters:
558        /// - self: Test LLM factory instance.
559        /// - _config: Provider config payload (unused in test double).
560        fn validate_config(&self, _config: &HashMap<String, String>) -> Vec<String> {
561            vec![]
562        }
563        /// Return required test LLM configuration keys.
564        /// Parameters:
565        /// - self: Test LLM factory instance.
566        fn required_config_keys(&self) -> Vec<&str> {
567            vec![]
568        }
569    }
570
571    struct TestLlmClient;
572    #[async_trait]
573    impl LlmClient for TestLlmClient {
574        async fn chat(
575            &self,
576            _messages: &[Message],
577            _model: Option<&str>,
578            _tools: Option<Vec<ToolDefinition>>,
579        ) -> Result<ChatResponse, LlmError> {
580            Ok(ChatResponse { content: "ok".into(), model: "test".into(), usage: None })
581        }
582        async fn chat_json(
583            &self,
584            _messages: &[Message],
585            _model: Option<&str>,
586            _tools: Option<Vec<ToolDefinition>>,
587        ) -> Result<serde_json::Value, LlmError> {
588            Ok(serde_json::json!({}))
589        }
590        async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError> {
591            Ok(vec![])
592        }
593        async fn health_check(&self) -> Result<bool, LlmError> {
594            Ok(true)
595        }
596    }
597
598    struct TestModelAdapter;
599    impl LlmModelAdapter for TestModelAdapter {
600        /// Return canonical identifier for the test model adapter.
601        /// Parameters:
602        /// - self: Test model adapter instance.
603        fn model_id(&self) -> &'static str {
604            "test-model"
605        }
606        /// Return owning provider name for the test model adapter.
607        /// Parameters:
608        /// - self: Test model adapter instance.
609        fn provider_name(&self) -> &'static str {
610            "test-llm"
611        }
612        /// Return display name for the test model adapter.
613        /// Parameters:
614        /// - self: Test model adapter instance.
615        fn display_name(&self) -> &'static str {
616            "Test Model"
617        }
618        /// Return static capabilities for the test model adapter.
619        /// Parameters:
620        /// - self: Test model adapter instance.
621        fn capabilities(&self) -> ModelCapabilities {
622            ModelCapabilities {
623                max_context_tokens: 4096,
624                supports_json_mode: true,
625                supports_tools: false,
626                supports_streaming: false,
627            }
628        }
629    }
630
631    struct TestLanguageFactory;
632    impl LanguageRendererFactory for TestLanguageFactory {
633        /// Return canonical language key for the test language factory.
634        /// Parameters:
635        /// - self: Test language factory instance.
636        fn language(&self) -> &'static str {
637            "test-lang"
638        }
639        /// Return display name for the test language factory.
640        /// Parameters:
641        /// - self: Test language factory instance.
642        fn display_name(&self) -> &'static str {
643            "Test Language"
644        }
645        /// Construct a language renderer in test contexts.
646        /// Parameters:
647        /// - self: Test language factory instance.
648        /// - _config: Language renderer config payload (unused in test double).
649        fn create(
650            &self,
651            _config: &HashMap<String, String>,
652        ) -> Result<Arc<dyn crate::language::LanguageRenderer>, LanguageError> {
653            unimplemented!("test only")
654        }
655    }
656
657    struct TestEventSink;
658    #[async_trait]
659    impl crate::event::EventSink for TestEventSink {
660        /// Return canonical sink name for the test event sink.
661        /// Parameters:
662        /// - self: Test event sink instance.
663        fn name(&self) -> &'static str {
664            "test-sink"
665        }
666        async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
667            Ok(())
668        }
669        async fn flush(&self) -> Result<(), EventSinkError> {
670            Ok(())
671        }
672    }
673
674    // ── Tests ─────────────────────────────────────────────────────
675
676    /// Verify builder produces a registry populated across all plugin sub-registries.
677    /// Parameters:
678    /// - none: Uses in-test test-double plugin registrations.
679    #[test]
680    fn builder_creates_registry_with_all_sub_registries() {
681        let registry = PluginRegistryBuilder::new()
682            .with_dialect("postgres", Arc::new(TestDialect))
683            .with_storage_extension("test".into(), Arc::new(TestStorageFactory))
684            .with_llm("test-llm".into(), Arc::new(TestLlmFactory))
685            .with_model(Arc::new(TestModelAdapter))
686            .with_language("test-lang".into(), Arc::new(TestLanguageFactory))
687            .with_event_sink(Arc::new(TestEventSink))
688            .build();
689
690        assert!(registry.dialects.get_by_name("postgres").is_some());
691        assert!(registry.llm.get("test-llm").is_some());
692        assert!(registry.models.get("test-llm", "test-model").is_some());
693        assert!(registry.language.get("test-lang").is_some());
694        assert!(!registry.events.is_empty());
695    }
696
697    /// Verify fluent builder chaining remains ergonomic and type-safe.
698    /// Parameters:
699    /// - none: Uses in-test chained builder calls across plugin categories.
700    #[test]
701    fn builder_chaining_is_ergonomic() {
702        // Verify fluent API compiles and works without clone/move issues
703        let _registry = PluginRegistryBuilder::new()
704            .with_dialect("postgres", Arc::new(TestDialect))
705            .with_dialect("snowflake", Arc::new(TestDialect))
706            .with_llm("a".into(), Arc::new(TestLlmFactory))
707            .with_llm("b".into(), Arc::new(TestLlmFactory))
708            .with_event_sink(Arc::new(TestEventSink))
709            .with_event_sink(Arc::new(TestEventSink))
710            .build();
711    }
712
713    /// Verify builder supports mixed known-platform and extension registrations.
714    /// Parameters:
715    /// - none: Uses in-test mixed dialect/storage registration fixture.
716    #[test]
717    fn builder_with_mixed_known_and_extension() {
718        let registry = PluginRegistryBuilder::new()
719            .with_dialect("postgres", Arc::new(TestDialect))
720            .with_dialect("teradata", Arc::new(TestDialect))
721            .with_storage(StoragePlatform::S3, Arc::new(TestStorageFactory))
722            .with_storage_extension("minio".into(), Arc::new(TestStorageFactory))
723            .build();
724
725        assert!(registry.dialects.get_by_name("postgres").is_some());
726        assert!(registry.dialects.get_by_name("teradata").is_some());
727    }
728
729    /// Verify auto-discovery finds exactly the dialects this test binary
730    /// submitted.
731    /// Parameters:
732    /// - none: Uses the one dialect the conformance fixtures submit.
733    #[test]
734    fn builder_with_auto_discovered_finds_inventory_entries() {
735        // Two dialects reach this binary's registry: one submitted directly —
736        // the conformance report is graded against a registered plugin, so it
737        // needs one registered — and one exported with `export_plugin!`.
738        let registry = PluginRegistryBuilder::new().with_auto_discovered().build();
739        let mut names = registry.dialects.registered_names();
740        names.sort();
741        assert_eq!(names, vec!["linked_by_export".to_owned(), "outsidedb".to_owned()]);
742    }
743
744    /// Verify manual plugin-loaded event recording is captured in the event registry.
745    /// Parameters:
746    /// - none: Uses in-test direct event-recording fixture.
747    #[test]
748    fn manual_registration_records_plugin_loaded_events() {
749        let builder = PluginRegistryBuilder::new()
750            .with_dialect("postgres", Arc::new(TestDialect))
751            .with_llm("test-llm".into(), Arc::new(TestLlmFactory));
752
753        // record_plugin_loaded is only called from auto-discovery and dynamic loading,
754        // so manual registration does not emit events.
755        // We test the method directly.
756        builder.record_plugin_loaded("TestDialect", "dialect", "manual");
757        builder.record_plugin_loaded("TestLlm", "llm", "manual");
758
759        let registry = builder.build();
760        let events = registry.events.events();
761        assert_eq!(events.len(), 2);
762
763        assert_eq!(events[0].category(), crate::event::EventCategory::Plugin);
764        assert_eq!(events[0].summary().title, "Plugin loaded");
765        assert!(events[0].summary().detail.contains("dialect"));
766
767        assert_eq!(events[1].category(), crate::event::EventCategory::Plugin);
768        assert!(events[1].summary().detail.contains("llm"));
769    }
770
771    /// Verify auto-discovery registers built-in sinks and emits load events.
772    /// Parameters:
773    /// - none: Uses in-test auto-discovery run against built-in inventory entries.
774    #[test]
775    fn auto_discovery_registers_built_in_sinks() {
776        let registry = PluginRegistryBuilder::new().with_auto_discovered().build();
777        // Built-in sinks (stdout, jsonl, webhook) register via inventory::submit!
778        // so auto-discovery should find at least 3 event sinks.
779        assert!(
780            registry.events.len() >= 3,
781            "Expected at least 3 built-in sinks, found {}",
782            registry.events.len()
783        );
784
785        // Verify auto-discovery emitted PluginLoaded events for sinks
786        let events = registry.events.events();
787        let sink_events: Vec<_> =
788            events.iter().filter(|e| e.summary().detail.contains("event_sink")).collect();
789        assert!(
790            sink_events.len() >= 3,
791            "Expected at least 3 event_sink load events, found {}",
792            sink_events.len()
793        );
794    }
795
796    /// A dialect that reaches the registry only through `export_plugin!`.
797    struct Linked;
798
799    impl SqlRenderer for Linked {
800        fn render_data_type(&self, _dt: &DataType) -> String {
801            "TEXT".to_string()
802        }
803        fn quote_identifier(&self, name: &str) -> String {
804            format!("\"{name}\"")
805        }
806        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
807            name.to_string()
808        }
809        fn render_current_timestamp(&self) -> String {
810            "NOW()".to_string()
811        }
812    }
813    impl ExpressionRenderer for Linked {}
814    impl DdlRenderer for Linked {}
815    impl DmlRenderer for Linked {}
816    impl ProcedureRenderer for Linked {}
817    impl FormulaRenderer for Linked {}
818    impl TaskRenderer for Linked {}
819    impl ReplicationRenderer for Linked {}
820    impl StorageRenderer for Linked {}
821    impl DialectCapabilities for Linked {}
822    impl PlatformDialect for Linked {
823        fn info(&self) -> DialectInfo {
824            DialectInfo {
825                name: "linked_by_export",
826                display_name: "Linked by export",
827                aliases: &[],
828                dialect_family: DialectFamily::PostgreSql,
829                family: "PostgreSQL",
830                is_cloud_native: false,
831                is_olap: false,
832                is_postgres_compatible: false,
833                versions: &[],
834                deploy_script: None,
835            }
836        }
837    }
838
839    fn register_linked(builder: &mut PluginRegistryBuilder) {
840        builder.register_dialect(Arc::new(Linked));
841    }
842
843    crate::export_plugin!(register_linked);
844
845    /// **A crate exported with the macro and linked in is found at start-up**,
846    /// by the builder and by the dialect registry every by-name lookup reads,
847    /// with no other line — the same way razeri's own dialects are found.
848    #[test]
849    fn a_plugin_exported_with_the_macro_and_linked_in_is_discovered() {
850        let registry = PluginRegistryBuilder::new().with_auto_discovered().build();
851        assert!(
852            registry.dialects.get_by_name("linked_by_export").is_some(),
853            "the builder did not find the linked plugin: {:?}",
854            registry.dialects.registered_names()
855        );
856        assert!(
857            PlatformRegistry::with_auto_discovered().get_by_name("linked_by_export").is_some(),
858            "the dialect registry did not find the linked plugin"
859        );
860    }
861
862    /// **The macro exports the build this crate was made with.**
863    #[test]
864    fn the_macro_exports_this_build() {
865        // SAFETY: the pointer is to the static record the macro exported.
866        let exported = unsafe { crate::dynamic::read_declaration(razeri_plugin_build()) };
867        assert_eq!(exported, Ok(crate::dynamic::BuildDeclaration::this_build()));
868    }
869}