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            }
485        }
486    }
487
488    struct TestStorageFactory;
489    #[async_trait]
490    impl StorageProviderFactory for TestStorageFactory {
491        /// Return canonical name for test storage provider.
492        /// Parameters:
493        /// - self: Test storage factory instance.
494        fn name(&self) -> &'static str {
495            "test-storage"
496        }
497        /// Return display name for test storage provider.
498        /// Parameters:
499        /// - self: Test storage factory instance.
500        fn display_name(&self) -> &'static str {
501            "Test Storage"
502        }
503        /// Return known platform mapping for test storage provider.
504        /// Parameters:
505        /// - self: Test storage factory instance.
506        fn known_platform(&self) -> Option<StoragePlatform> {
507            None
508        }
509        async fn create(
510            &self,
511            _config: &HashMap<String, String>,
512        ) -> Result<Box<dyn crate::storage::StorageOps>, crate::error::StorageError> {
513            unimplemented!("test only")
514        }
515        /// Validate test storage config payload.
516        /// Parameters:
517        /// - self: Test storage factory instance.
518        /// - _config: Storage config payload (unused in test double).
519        fn validate_config(&self, _config: &HashMap<String, String>) -> Vec<String> {
520            vec![]
521        }
522        /// Return required test storage configuration keys.
523        /// Parameters:
524        /// - self: Test storage factory instance.
525        fn required_config_keys(&self) -> Vec<&str> {
526            vec![]
527        }
528    }
529
530    struct TestLlmFactory;
531    impl LlmClientFactory for TestLlmFactory {
532        /// Return canonical name for test LLM provider.
533        /// Parameters:
534        /// - self: Test LLM factory instance.
535        fn name(&self) -> &'static str {
536            "test-llm"
537        }
538        /// Return display name for test LLM provider.
539        /// Parameters:
540        /// - self: Test LLM factory instance.
541        fn display_name(&self) -> &'static str {
542            "Test LLM"
543        }
544        /// Construct a test LLM client instance.
545        /// Parameters:
546        /// - self: Test LLM factory instance.
547        /// - _config: Provider config payload (unused in test double).
548        fn create(
549            &self,
550            _config: &HashMap<String, String>,
551        ) -> Result<Arc<dyn LlmClient>, LlmError> {
552            Ok(Arc::new(TestLlmClient))
553        }
554        /// Validate test LLM config payload.
555        /// Parameters:
556        /// - self: Test LLM factory instance.
557        /// - _config: Provider config payload (unused in test double).
558        fn validate_config(&self, _config: &HashMap<String, String>) -> Vec<String> {
559            vec![]
560        }
561        /// Return required test LLM configuration keys.
562        /// Parameters:
563        /// - self: Test LLM factory instance.
564        fn required_config_keys(&self) -> Vec<&str> {
565            vec![]
566        }
567    }
568
569    struct TestLlmClient;
570    #[async_trait]
571    impl LlmClient for TestLlmClient {
572        async fn chat(
573            &self,
574            _messages: &[Message],
575            _model: Option<&str>,
576            _tools: Option<Vec<ToolDefinition>>,
577        ) -> Result<ChatResponse, LlmError> {
578            Ok(ChatResponse { content: "ok".into(), model: "test".into(), usage: None })
579        }
580        async fn chat_json(
581            &self,
582            _messages: &[Message],
583            _model: Option<&str>,
584            _tools: Option<Vec<ToolDefinition>>,
585        ) -> Result<serde_json::Value, LlmError> {
586            Ok(serde_json::json!({}))
587        }
588        async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError> {
589            Ok(vec![])
590        }
591        async fn health_check(&self) -> Result<bool, LlmError> {
592            Ok(true)
593        }
594    }
595
596    struct TestModelAdapter;
597    impl LlmModelAdapter for TestModelAdapter {
598        /// Return canonical identifier for the test model adapter.
599        /// Parameters:
600        /// - self: Test model adapter instance.
601        fn model_id(&self) -> &'static str {
602            "test-model"
603        }
604        /// Return owning provider name for the test model adapter.
605        /// Parameters:
606        /// - self: Test model adapter instance.
607        fn provider_name(&self) -> &'static str {
608            "test-llm"
609        }
610        /// Return display name for the test model adapter.
611        /// Parameters:
612        /// - self: Test model adapter instance.
613        fn display_name(&self) -> &'static str {
614            "Test Model"
615        }
616        /// Return static capabilities for the test model adapter.
617        /// Parameters:
618        /// - self: Test model adapter instance.
619        fn capabilities(&self) -> ModelCapabilities {
620            ModelCapabilities {
621                max_context_tokens: 4096,
622                supports_json_mode: true,
623                supports_tools: false,
624                supports_streaming: false,
625            }
626        }
627    }
628
629    struct TestLanguageFactory;
630    impl LanguageRendererFactory for TestLanguageFactory {
631        /// Return canonical language key for the test language factory.
632        /// Parameters:
633        /// - self: Test language factory instance.
634        fn language(&self) -> &'static str {
635            "test-lang"
636        }
637        /// Return display name for the test language factory.
638        /// Parameters:
639        /// - self: Test language factory instance.
640        fn display_name(&self) -> &'static str {
641            "Test Language"
642        }
643        /// Construct a language renderer in test contexts.
644        /// Parameters:
645        /// - self: Test language factory instance.
646        /// - _config: Language renderer config payload (unused in test double).
647        fn create(
648            &self,
649            _config: &HashMap<String, String>,
650        ) -> Result<Arc<dyn crate::language::LanguageRenderer>, LanguageError> {
651            unimplemented!("test only")
652        }
653    }
654
655    struct TestEventSink;
656    #[async_trait]
657    impl crate::event::EventSink for TestEventSink {
658        /// Return canonical sink name for the test event sink.
659        /// Parameters:
660        /// - self: Test event sink instance.
661        fn name(&self) -> &'static str {
662            "test-sink"
663        }
664        async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
665            Ok(())
666        }
667        async fn flush(&self) -> Result<(), EventSinkError> {
668            Ok(())
669        }
670    }
671
672    // ── Tests ─────────────────────────────────────────────────────
673
674    /// Verify builder produces a registry populated across all plugin sub-registries.
675    /// Parameters:
676    /// - none: Uses in-test test-double plugin registrations.
677    #[test]
678    fn builder_creates_registry_with_all_sub_registries() {
679        let registry = PluginRegistryBuilder::new()
680            .with_dialect("postgres", Arc::new(TestDialect))
681            .with_storage_extension("test".into(), Arc::new(TestStorageFactory))
682            .with_llm("test-llm".into(), Arc::new(TestLlmFactory))
683            .with_model(Arc::new(TestModelAdapter))
684            .with_language("test-lang".into(), Arc::new(TestLanguageFactory))
685            .with_event_sink(Arc::new(TestEventSink))
686            .build();
687
688        assert!(registry.dialects.get_by_name("postgres").is_some());
689        assert!(registry.llm.get("test-llm").is_some());
690        assert!(registry.models.get("test-llm", "test-model").is_some());
691        assert!(registry.language.get("test-lang").is_some());
692        assert!(!registry.events.is_empty());
693    }
694
695    /// Verify fluent builder chaining remains ergonomic and type-safe.
696    /// Parameters:
697    /// - none: Uses in-test chained builder calls across plugin categories.
698    #[test]
699    fn builder_chaining_is_ergonomic() {
700        // Verify fluent API compiles and works without clone/move issues
701        let _registry = PluginRegistryBuilder::new()
702            .with_dialect("postgres", Arc::new(TestDialect))
703            .with_dialect("snowflake", Arc::new(TestDialect))
704            .with_llm("a".into(), Arc::new(TestLlmFactory))
705            .with_llm("b".into(), Arc::new(TestLlmFactory))
706            .with_event_sink(Arc::new(TestEventSink))
707            .with_event_sink(Arc::new(TestEventSink))
708            .build();
709    }
710
711    /// Verify builder supports mixed known-platform and extension registrations.
712    /// Parameters:
713    /// - none: Uses in-test mixed dialect/storage registration fixture.
714    #[test]
715    fn builder_with_mixed_known_and_extension() {
716        let registry = PluginRegistryBuilder::new()
717            .with_dialect("postgres", Arc::new(TestDialect))
718            .with_dialect("teradata", Arc::new(TestDialect))
719            .with_storage(StoragePlatform::S3, Arc::new(TestStorageFactory))
720            .with_storage_extension("minio".into(), Arc::new(TestStorageFactory))
721            .build();
722
723        assert!(registry.dialects.get_by_name("postgres").is_some());
724        assert!(registry.dialects.get_by_name("teradata").is_some());
725    }
726
727    /// Verify auto-discovery finds exactly the dialects this test binary
728    /// submitted.
729    /// Parameters:
730    /// - none: Uses the one dialect the conformance fixtures submit.
731    #[test]
732    fn builder_with_auto_discovered_finds_inventory_entries() {
733        // Two dialects reach this binary's registry: one submitted directly —
734        // the conformance report is graded against a registered plugin, so it
735        // needs one registered — and one exported with `export_plugin!`.
736        let registry = PluginRegistryBuilder::new().with_auto_discovered().build();
737        let mut names = registry.dialects.registered_names();
738        names.sort();
739        assert_eq!(names, vec!["linked_by_export".to_owned(), "outsidedb".to_owned()]);
740    }
741
742    /// Verify manual plugin-loaded event recording is captured in the event registry.
743    /// Parameters:
744    /// - none: Uses in-test direct event-recording fixture.
745    #[test]
746    fn manual_registration_records_plugin_loaded_events() {
747        let builder = PluginRegistryBuilder::new()
748            .with_dialect("postgres", Arc::new(TestDialect))
749            .with_llm("test-llm".into(), Arc::new(TestLlmFactory));
750
751        // record_plugin_loaded is only called from auto-discovery and dynamic loading,
752        // so manual registration does not emit events.
753        // We test the method directly.
754        builder.record_plugin_loaded("TestDialect", "dialect", "manual");
755        builder.record_plugin_loaded("TestLlm", "llm", "manual");
756
757        let registry = builder.build();
758        let events = registry.events.events();
759        assert_eq!(events.len(), 2);
760
761        assert_eq!(events[0].category(), crate::event::EventCategory::Plugin);
762        assert_eq!(events[0].summary().title, "Plugin loaded");
763        assert!(events[0].summary().detail.contains("dialect"));
764
765        assert_eq!(events[1].category(), crate::event::EventCategory::Plugin);
766        assert!(events[1].summary().detail.contains("llm"));
767    }
768
769    /// Verify auto-discovery registers built-in sinks and emits load events.
770    /// Parameters:
771    /// - none: Uses in-test auto-discovery run against built-in inventory entries.
772    #[test]
773    fn auto_discovery_registers_built_in_sinks() {
774        let registry = PluginRegistryBuilder::new().with_auto_discovered().build();
775        // Built-in sinks (stdout, jsonl, webhook) register via inventory::submit!
776        // so auto-discovery should find at least 3 event sinks.
777        assert!(
778            registry.events.len() >= 3,
779            "Expected at least 3 built-in sinks, found {}",
780            registry.events.len()
781        );
782
783        // Verify auto-discovery emitted PluginLoaded events for sinks
784        let events = registry.events.events();
785        let sink_events: Vec<_> =
786            events.iter().filter(|e| e.summary().detail.contains("event_sink")).collect();
787        assert!(
788            sink_events.len() >= 3,
789            "Expected at least 3 event_sink load events, found {}",
790            sink_events.len()
791        );
792    }
793
794    /// A dialect that reaches the registry only through `export_plugin!`.
795    struct Linked;
796
797    impl SqlRenderer for Linked {
798        fn render_data_type(&self, _dt: &DataType) -> String {
799            "TEXT".to_string()
800        }
801        fn quote_identifier(&self, name: &str) -> String {
802            format!("\"{name}\"")
803        }
804        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
805            name.to_string()
806        }
807        fn render_current_timestamp(&self) -> String {
808            "NOW()".to_string()
809        }
810    }
811    impl ExpressionRenderer for Linked {}
812    impl DdlRenderer for Linked {}
813    impl DmlRenderer for Linked {}
814    impl ProcedureRenderer for Linked {}
815    impl FormulaRenderer for Linked {}
816    impl TaskRenderer for Linked {}
817    impl ReplicationRenderer for Linked {}
818    impl StorageRenderer for Linked {}
819    impl DialectCapabilities for Linked {}
820    impl PlatformDialect for Linked {
821        fn info(&self) -> DialectInfo {
822            DialectInfo {
823                name: "linked_by_export",
824                display_name: "Linked by export",
825                aliases: &[],
826                dialect_family: DialectFamily::PostgreSql,
827                family: "PostgreSQL",
828                is_cloud_native: false,
829                is_olap: false,
830                is_postgres_compatible: false,
831            }
832        }
833    }
834
835    fn register_linked(builder: &mut PluginRegistryBuilder) {
836        builder.register_dialect(Arc::new(Linked));
837    }
838
839    crate::export_plugin!(register_linked);
840
841    /// **A crate exported with the macro and linked in is found at start-up**,
842    /// by the builder and by the dialect registry every by-name lookup reads,
843    /// with no other line — the same way razeri's own dialects are found.
844    #[test]
845    fn a_plugin_exported_with_the_macro_and_linked_in_is_discovered() {
846        let registry = PluginRegistryBuilder::new().with_auto_discovered().build();
847        assert!(
848            registry.dialects.get_by_name("linked_by_export").is_some(),
849            "the builder did not find the linked plugin: {:?}",
850            registry.dialects.registered_names()
851        );
852        assert!(
853            PlatformRegistry::with_auto_discovered().get_by_name("linked_by_export").is_some(),
854            "the dialect registry did not find the linked plugin"
855        );
856    }
857
858    /// **The macro exports the build this crate was made with.**
859    #[test]
860    fn the_macro_exports_this_build() {
861        // SAFETY: the pointer is to the static record the macro exported.
862        let exported = unsafe { crate::dynamic::read_declaration(razeri_plugin_build()) };
863        assert_eq!(exported, Ok(crate::dynamic::BuildDeclaration::this_build()));
864    }
865}