Skip to main content

razeri_plugin_api/
builder.rs

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