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