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        self
178    }
179
180    /// Record a `PluginLoaded` event into the event history.
181    fn record_plugin_loaded(&self, name: &str, plugin_type: &str, source: &str) {
182        if let Ok(event) = RazeriEvent::plugin_loaded(
183            format!("plugin_loaded:{plugin_type}:{name}"),
184            Utc::now(),
185            name,
186            plugin_type,
187            source,
188        ) {
189            self.events.record(event);
190        }
191    }
192
193    // ── Dynamic loading ─────────────────────────────────────────
194
195    /// Discover and load all plugin shared libraries from `plugin_dir`.
196    ///
197    /// Scans the directory for `.so`/`.dylib`/`.dll` files, checks their
198    /// API version, and calls their registration function. Invalid or
199    /// incompatible plugins are logged and skipped.
200    ///
201    /// Requires the `dynamic` feature. Native-only: shared-library loading does
202    /// not exist on wasm32-unknown-unknown (a browser cannot dlopen `.so`s).
203    #[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
204    #[must_use]
205    pub fn with_dynamic_plugins(mut self, plugin_dir: &std::path::Path) -> Self {
206        let results = crate::dynamic::load_plugins_from_dir(plugin_dir, &mut self);
207        for result in &results {
208            match result {
209                crate::dynamic::PluginLoadResult::Loaded { name, path } => {
210                    tracing::info!("Loaded dynamic plugin '{name}' from {}", path.display());
211                    self.record_plugin_loaded(name, "dynamic", &path.display().to_string());
212                }
213                crate::dynamic::PluginLoadResult::VersionMismatch { path, expected, found } => {
214                    tracing::warn!(
215                        "Plugin {} API version mismatch (expected {expected}, found {found})",
216                        path.display()
217                    );
218                }
219                crate::dynamic::PluginLoadResult::Failed { path, error } => {
220                    tracing::warn!("Failed to load plugin from {}: {error}", path.display());
221                }
222            }
223        }
224        self
225    }
226
227    // ── Build ─────────────────────────────────────────────────────
228
229    /// Consume the builder and produce the final [`PluginRegistry`].
230    pub fn build(self) -> PluginRegistry {
231        PluginRegistry {
232            dialects: self.dialects,
233            storage: self.storage,
234            llm: self.llm,
235            models: self.models,
236            language: self.language,
237            events: self.events,
238        }
239    }
240}
241
242impl Default for PluginRegistryBuilder {
243    /// Create a fresh plugin-registry builder with empty sub-registries.
244    /// Parameters:
245    /// - none: Returns a new builder equivalent to `PluginRegistryBuilder::new()`.
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251// ── Inventory registration types ──────────────────────────────────────
252// Third-party crates submit these via `inventory::submit!` for automatic
253// discovery at startup.
254
255/// Registration entry for auto-discovered dialects.
256pub struct DialectRegistration {
257    /// Canonical dialect name (e.g. `"postgres"`).
258    pub name: &'static str,
259    /// Factory function that constructs the dialect plugin.
260    pub factory: fn() -> Arc<dyn PlatformDialect>,
261}
262
263impl DialectRegistration {
264    /// Create a new dialect registration entry.
265    pub const fn new(name: &'static str, factory: fn() -> Arc<dyn PlatformDialect>) -> Self {
266        Self { name, factory }
267    }
268}
269
270inventory::collect!(DialectRegistration);
271
272/// Registration entry for auto-discovered storage providers.
273pub struct StorageRegistration {
274    /// Canonical storage platform name (e.g. `"s3"`).
275    pub name: &'static str,
276    /// Factory function that constructs the storage provider.
277    pub factory: fn() -> Arc<dyn StorageProviderFactory>,
278}
279
280impl StorageRegistration {
281    /// Create a new storage registration entry.
282    pub const fn new(name: &'static str, factory: fn() -> Arc<dyn StorageProviderFactory>) -> Self {
283        Self { name, factory }
284    }
285}
286
287inventory::collect!(StorageRegistration);
288
289/// Registration entry for auto-discovered LLM providers.
290pub struct LlmRegistration {
291    /// Canonical provider name (e.g. `"ollama"`).
292    pub name: &'static str,
293    /// Factory function that constructs the LLM client factory.
294    pub factory: fn() -> Arc<dyn LlmClientFactory>,
295}
296
297impl LlmRegistration {
298    /// Create a new LLM provider registration entry.
299    pub const fn new(name: &'static str, factory: fn() -> Arc<dyn LlmClientFactory>) -> Self {
300        Self { name, factory }
301    }
302}
303
304inventory::collect!(LlmRegistration);
305
306/// Registration entry for auto-discovered model adapters.
307pub struct ModelRegistration {
308    /// Canonical model identifier (e.g. `"llama3"`).
309    pub model_id: &'static str,
310    /// Factory function that constructs the model adapter.
311    pub factory: fn() -> Arc<dyn LlmModelAdapter>,
312}
313
314impl ModelRegistration {
315    /// Create a new model adapter registration entry.
316    pub const fn new(model_id: &'static str, factory: fn() -> Arc<dyn LlmModelAdapter>) -> Self {
317        Self { model_id, factory }
318    }
319}
320
321inventory::collect!(ModelRegistration);
322
323/// Registration entry for auto-discovered language renderers.
324pub struct LanguageRegistration {
325    /// Target language name (e.g. `"python"`).
326    pub language: &'static str,
327    /// Factory function that constructs the language renderer factory.
328    pub factory: fn() -> Arc<dyn LanguageRendererFactory>,
329}
330
331impl LanguageRegistration {
332    /// Create a new language renderer registration entry.
333    pub const fn new(
334        language: &'static str,
335        factory: fn() -> Arc<dyn LanguageRendererFactory>,
336    ) -> Self {
337        Self { language, factory }
338    }
339}
340
341inventory::collect!(LanguageRegistration);
342
343/// Registration entry for auto-discovered event sinks.
344pub struct EventSinkRegistration {
345    /// Canonical sink name (e.g. `"stdout"`).
346    pub name: &'static str,
347    /// Factory function that constructs the event sink.
348    pub factory: fn() -> Arc<dyn EventSink>,
349}
350
351impl EventSinkRegistration {
352    /// Create a new event sink registration entry.
353    pub const fn new(name: &'static str, factory: fn() -> Arc<dyn EventSink>) -> Self {
354        Self { name, factory }
355    }
356}
357
358inventory::collect!(EventSinkRegistration);
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::dialect::{DialectInfo, PlatformDialect};
364    use crate::error::{EventSinkError, LanguageError, LlmError};
365    use crate::event::RazeriEvent;
366    use crate::llm::{ChatResponse, LlmClient, Message, ModelInfo, ToolDefinition};
367    use crate::model::ModelCapabilities;
368    use async_trait::async_trait;
369    use razeri_plugin_contract::DataType;
370    use razeri_plugin_contract::DialectFamily;
371    use razeri_plugin_contract::{
372        DdlRenderer, DialectCapabilities, DmlRenderer, ExpressionRenderer, FormulaRenderer,
373        ProcedureRenderer, ReplicationRenderer, SqlRenderer, StorageRenderer, TaskRenderer,
374    };
375    use std::collections::HashMap;
376
377    // ── Test doubles ──────────────────────────────────────────────
378
379    struct TestDialect;
380
381    impl SqlRenderer for TestDialect {
382        /// Render a placeholder data type for test dialect behavior.
383        /// Parameters:
384        /// - self: Test dialect instance.
385        /// - _dt: Data type to render (ignored in test double).
386        fn render_data_type(&self, _dt: &DataType) -> String {
387            "TEXT".to_string()
388        }
389        /// Quote an identifier using double quotes in test dialect output.
390        /// Parameters:
391        /// - self: Test dialect instance.
392        /// - name: Identifier text to quote.
393        fn quote_identifier(&self, name: &str) -> String {
394            format!("\"{name}\"")
395        }
396        /// Render a qualified name for test dialect output.
397        /// Parameters:
398        /// - self: Test dialect instance.
399        /// - _schema: Optional schema segment (ignored in test double).
400        /// - name: Object name segment to render.
401        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
402            name.to_string()
403        }
404        /// Render current timestamp expression for test dialect output.
405        /// Parameters:
406        /// - self: Test dialect instance.
407        fn render_current_timestamp(&self) -> String {
408            "NOW()".to_string()
409        }
410    }
411
412    impl ExpressionRenderer for TestDialect {}
413    impl DdlRenderer for TestDialect {}
414    impl DmlRenderer for TestDialect {}
415    impl ProcedureRenderer for TestDialect {}
416    impl FormulaRenderer for TestDialect {}
417    impl TaskRenderer for TestDialect {}
418    impl ReplicationRenderer for TestDialect {}
419    impl StorageRenderer for TestDialect {}
420
421    impl DialectCapabilities for TestDialect {
422        /// State this test dialect's upsert shape.
423        /// Parameters:
424        /// - self: Test dialect instance.
425        fn merge_shape(&self) -> razeri_plugin_contract::MergeShape {
426            razeri_plugin_contract::MergeShape::Merge(
427                razeri_plugin_contract::MergeRestrictions::none(),
428            )
429        }
430        /// Indicate test dialect support for identity columns.
431        /// Parameters:
432        /// - self: Test dialect instance.
433        fn supports_identity(&self) -> bool {
434            true
435        }
436        /// Indicate test dialect support for sequences.
437        /// Parameters:
438        /// - self: Test dialect instance.
439        fn supports_sequences(&self) -> bool {
440            true
441        }
442        /// Indicate test dialect support for stored procedures.
443        /// Parameters:
444        /// - self: Test dialect instance.
445        fn supports_stored_procedures(&self) -> bool {
446            true
447        }
448    }
449
450    impl PlatformDialect for TestDialect {
451        /// Return static dialect metadata for the test dialect.
452        /// Parameters:
453        /// - self: Test dialect instance.
454        fn info(&self) -> DialectInfo {
455            DialectInfo {
456                name: "test",
457                display_name: "Test DB",
458                aliases: &[],
459                dialect_family: DialectFamily::PostgreSql,
460                family: "PostgreSQL",
461                is_cloud_native: false,
462                is_olap: false,
463                is_postgres_compatible: false,
464            }
465        }
466    }
467
468    struct TestStorageFactory;
469    #[async_trait]
470    impl StorageProviderFactory for TestStorageFactory {
471        /// Return canonical name for test storage provider.
472        /// Parameters:
473        /// - self: Test storage factory instance.
474        fn name(&self) -> &'static str {
475            "test-storage"
476        }
477        /// Return display name for test storage provider.
478        /// Parameters:
479        /// - self: Test storage factory instance.
480        fn display_name(&self) -> &'static str {
481            "Test Storage"
482        }
483        /// Return known platform mapping for test storage provider.
484        /// Parameters:
485        /// - self: Test storage factory instance.
486        fn known_platform(&self) -> Option<StoragePlatform> {
487            None
488        }
489        async fn create(
490            &self,
491            _config: &HashMap<String, String>,
492        ) -> Result<Box<dyn crate::storage::StorageOps>, crate::error::StorageError> {
493            unimplemented!("test only")
494        }
495        /// Validate test storage config payload.
496        /// Parameters:
497        /// - self: Test storage factory instance.
498        /// - _config: Storage config payload (unused in test double).
499        fn validate_config(&self, _config: &HashMap<String, String>) -> Vec<String> {
500            vec![]
501        }
502        /// Return required test storage configuration keys.
503        /// Parameters:
504        /// - self: Test storage factory instance.
505        fn required_config_keys(&self) -> Vec<&str> {
506            vec![]
507        }
508    }
509
510    struct TestLlmFactory;
511    impl LlmClientFactory for TestLlmFactory {
512        /// Return canonical name for test LLM provider.
513        /// Parameters:
514        /// - self: Test LLM factory instance.
515        fn name(&self) -> &'static str {
516            "test-llm"
517        }
518        /// Return display name for test LLM provider.
519        /// Parameters:
520        /// - self: Test LLM factory instance.
521        fn display_name(&self) -> &'static str {
522            "Test LLM"
523        }
524        /// Construct a test LLM client instance.
525        /// Parameters:
526        /// - self: Test LLM factory instance.
527        /// - _config: Provider config payload (unused in test double).
528        fn create(
529            &self,
530            _config: &HashMap<String, String>,
531        ) -> Result<Arc<dyn LlmClient>, LlmError> {
532            Ok(Arc::new(TestLlmClient))
533        }
534        /// Validate test LLM config payload.
535        /// Parameters:
536        /// - self: Test LLM factory instance.
537        /// - _config: Provider config payload (unused in test double).
538        fn validate_config(&self, _config: &HashMap<String, String>) -> Vec<String> {
539            vec![]
540        }
541        /// Return required test LLM configuration keys.
542        /// Parameters:
543        /// - self: Test LLM factory instance.
544        fn required_config_keys(&self) -> Vec<&str> {
545            vec![]
546        }
547    }
548
549    struct TestLlmClient;
550    #[async_trait]
551    impl LlmClient for TestLlmClient {
552        async fn chat(
553            &self,
554            _messages: &[Message],
555            _model: Option<&str>,
556            _tools: Option<Vec<ToolDefinition>>,
557        ) -> Result<ChatResponse, LlmError> {
558            Ok(ChatResponse { content: "ok".into(), model: "test".into(), usage: None })
559        }
560        async fn chat_json(
561            &self,
562            _messages: &[Message],
563            _model: Option<&str>,
564            _tools: Option<Vec<ToolDefinition>>,
565        ) -> Result<serde_json::Value, LlmError> {
566            Ok(serde_json::json!({}))
567        }
568        async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError> {
569            Ok(vec![])
570        }
571        async fn health_check(&self) -> Result<bool, LlmError> {
572            Ok(true)
573        }
574    }
575
576    struct TestModelAdapter;
577    impl LlmModelAdapter for TestModelAdapter {
578        /// Return canonical identifier for the test model adapter.
579        /// Parameters:
580        /// - self: Test model adapter instance.
581        fn model_id(&self) -> &'static str {
582            "test-model"
583        }
584        /// Return owning provider name for the test model adapter.
585        /// Parameters:
586        /// - self: Test model adapter instance.
587        fn provider_name(&self) -> &'static str {
588            "test-llm"
589        }
590        /// Return display name for the test model adapter.
591        /// Parameters:
592        /// - self: Test model adapter instance.
593        fn display_name(&self) -> &'static str {
594            "Test Model"
595        }
596        /// Return static capabilities for the test model adapter.
597        /// Parameters:
598        /// - self: Test model adapter instance.
599        fn capabilities(&self) -> ModelCapabilities {
600            ModelCapabilities {
601                max_context_tokens: 4096,
602                supports_json_mode: true,
603                supports_tools: false,
604                supports_streaming: false,
605            }
606        }
607    }
608
609    struct TestLanguageFactory;
610    impl LanguageRendererFactory for TestLanguageFactory {
611        /// Return canonical language key for the test language factory.
612        /// Parameters:
613        /// - self: Test language factory instance.
614        fn language(&self) -> &'static str {
615            "test-lang"
616        }
617        /// Return display name for the test language factory.
618        /// Parameters:
619        /// - self: Test language factory instance.
620        fn display_name(&self) -> &'static str {
621            "Test Language"
622        }
623        /// Construct a language renderer in test contexts.
624        /// Parameters:
625        /// - self: Test language factory instance.
626        /// - _config: Language renderer config payload (unused in test double).
627        fn create(
628            &self,
629            _config: &HashMap<String, String>,
630        ) -> Result<Arc<dyn crate::language::LanguageRenderer>, LanguageError> {
631            unimplemented!("test only")
632        }
633    }
634
635    struct TestEventSink;
636    #[async_trait]
637    impl crate::event::EventSink for TestEventSink {
638        /// Return canonical sink name for the test event sink.
639        /// Parameters:
640        /// - self: Test event sink instance.
641        fn name(&self) -> &'static str {
642            "test-sink"
643        }
644        async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
645            Ok(())
646        }
647        async fn flush(&self) -> Result<(), EventSinkError> {
648            Ok(())
649        }
650    }
651
652    // ── Tests ─────────────────────────────────────────────────────
653
654    /// Verify builder produces a registry populated across all plugin sub-registries.
655    /// Parameters:
656    /// - none: Uses in-test test-double plugin registrations.
657    #[test]
658    fn builder_creates_registry_with_all_sub_registries() {
659        let registry = PluginRegistryBuilder::new()
660            .with_dialect("postgres", Arc::new(TestDialect))
661            .with_storage_extension("test".into(), Arc::new(TestStorageFactory))
662            .with_llm("test-llm".into(), Arc::new(TestLlmFactory))
663            .with_model(Arc::new(TestModelAdapter))
664            .with_language("test-lang".into(), Arc::new(TestLanguageFactory))
665            .with_event_sink(Arc::new(TestEventSink))
666            .build();
667
668        assert!(registry.dialects.get_by_name("postgres").is_some());
669        assert!(registry.llm.get("test-llm").is_some());
670        assert!(registry.models.get("test-llm", "test-model").is_some());
671        assert!(registry.language.get("test-lang").is_some());
672        assert!(!registry.events.is_empty());
673    }
674
675    /// Verify fluent builder chaining remains ergonomic and type-safe.
676    /// Parameters:
677    /// - none: Uses in-test chained builder calls across plugin categories.
678    #[test]
679    fn builder_chaining_is_ergonomic() {
680        // Verify fluent API compiles and works without clone/move issues
681        let _registry = PluginRegistryBuilder::new()
682            .with_dialect("postgres", Arc::new(TestDialect))
683            .with_dialect("snowflake", Arc::new(TestDialect))
684            .with_llm("a".into(), Arc::new(TestLlmFactory))
685            .with_llm("b".into(), Arc::new(TestLlmFactory))
686            .with_event_sink(Arc::new(TestEventSink))
687            .with_event_sink(Arc::new(TestEventSink))
688            .build();
689    }
690
691    /// Verify builder supports mixed known-platform and extension registrations.
692    /// Parameters:
693    /// - none: Uses in-test mixed dialect/storage registration fixture.
694    #[test]
695    fn builder_with_mixed_known_and_extension() {
696        let registry = PluginRegistryBuilder::new()
697            .with_dialect("postgres", Arc::new(TestDialect))
698            .with_dialect("teradata", Arc::new(TestDialect))
699            .with_storage(StoragePlatform::S3, Arc::new(TestStorageFactory))
700            .with_storage_extension("minio".into(), Arc::new(TestStorageFactory))
701            .build();
702
703        assert!(registry.dialects.get_by_name("postgres").is_some());
704        assert!(registry.dialects.get_by_name("teradata").is_some());
705    }
706
707    /// Verify auto-discovery finds exactly the dialects this test binary
708    /// submitted.
709    /// Parameters:
710    /// - none: Uses the one dialect the conformance fixtures submit.
711    #[test]
712    fn builder_with_auto_discovered_finds_inventory_entries() {
713        // One dialect is submitted in this binary — the conformance report is
714        // graded against a registered plugin, so it needs one registered.
715        let registry = PluginRegistryBuilder::new().with_auto_discovered().build();
716        assert_eq!(registry.dialects.registered_names(), vec!["outsidedb".to_owned()]);
717    }
718
719    /// Verify manual plugin-loaded event recording is captured in the event registry.
720    /// Parameters:
721    /// - none: Uses in-test direct event-recording fixture.
722    #[test]
723    fn manual_registration_records_plugin_loaded_events() {
724        let builder = PluginRegistryBuilder::new()
725            .with_dialect("postgres", Arc::new(TestDialect))
726            .with_llm("test-llm".into(), Arc::new(TestLlmFactory));
727
728        // record_plugin_loaded is only called from auto-discovery and dynamic loading,
729        // so manual registration does not emit events.
730        // We test the method directly.
731        builder.record_plugin_loaded("TestDialect", "dialect", "manual");
732        builder.record_plugin_loaded("TestLlm", "llm", "manual");
733
734        let registry = builder.build();
735        let events = registry.events.events();
736        assert_eq!(events.len(), 2);
737
738        assert_eq!(events[0].category(), crate::event::EventCategory::Plugin);
739        assert_eq!(events[0].summary().title, "Plugin loaded");
740        assert!(events[0].summary().detail.contains("dialect"));
741
742        assert_eq!(events[1].category(), crate::event::EventCategory::Plugin);
743        assert!(events[1].summary().detail.contains("llm"));
744    }
745
746    /// Verify auto-discovery registers built-in sinks and emits load events.
747    /// Parameters:
748    /// - none: Uses in-test auto-discovery run against built-in inventory entries.
749    #[test]
750    fn auto_discovery_registers_built_in_sinks() {
751        let registry = PluginRegistryBuilder::new().with_auto_discovered().build();
752        // Built-in sinks (stdout, jsonl, webhook) register via inventory::submit!
753        // so auto-discovery should find at least 3 event sinks.
754        assert!(
755            registry.events.len() >= 3,
756            "Expected at least 3 built-in sinks, found {}",
757            registry.events.len()
758        );
759
760        // Verify auto-discovery emitted PluginLoaded events for sinks
761        let events = registry.events.events();
762        let sink_events: Vec<_> =
763            events.iter().filter(|e| e.summary().detail.contains("event_sink")).collect();
764        assert!(
765            sink_events.len() >= 3,
766            "Expected at least 3 event_sink load events, found {}",
767            sink_events.len()
768        );
769    }
770}