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                aliases: &[],
449                dialect_family: DialectFamily::PostgreSql,
450                family: "PostgreSQL",
451                is_cloud_native: false,
452                is_olap: false,
453                is_postgres_compatible: false,
454            }
455        }
456    }
457
458    struct TestStorageFactory;
459    #[async_trait]
460    impl StorageProviderFactory for TestStorageFactory {
461        /// Return canonical name for test storage provider.
462        /// Parameters:
463        /// - self: Test storage factory instance.
464        fn name(&self) -> &'static str {
465            "test-storage"
466        }
467        /// Return display name for test storage provider.
468        /// Parameters:
469        /// - self: Test storage factory instance.
470        fn display_name(&self) -> &'static str {
471            "Test Storage"
472        }
473        /// Return known platform mapping for test storage provider.
474        /// Parameters:
475        /// - self: Test storage factory instance.
476        fn known_platform(&self) -> Option<StoragePlatform> {
477            None
478        }
479        async fn create(
480            &self,
481            _config: &HashMap<String, String>,
482        ) -> Result<Box<dyn crate::storage::StorageOps>, crate::error::StorageError> {
483            unimplemented!("test only")
484        }
485        /// Validate test storage config payload.
486        /// Parameters:
487        /// - self: Test storage factory instance.
488        /// - _config: Storage config payload (unused in test double).
489        fn validate_config(&self, _config: &HashMap<String, String>) -> Vec<String> {
490            vec![]
491        }
492        /// Return required test storage configuration keys.
493        /// Parameters:
494        /// - self: Test storage factory instance.
495        fn required_config_keys(&self) -> Vec<&str> {
496            vec![]
497        }
498    }
499
500    struct TestLlmFactory;
501    impl LlmClientFactory for TestLlmFactory {
502        /// Return canonical name for test LLM provider.
503        /// Parameters:
504        /// - self: Test LLM factory instance.
505        fn name(&self) -> &'static str {
506            "test-llm"
507        }
508        /// Return display name for test LLM provider.
509        /// Parameters:
510        /// - self: Test LLM factory instance.
511        fn display_name(&self) -> &'static str {
512            "Test LLM"
513        }
514        /// Construct a test LLM client instance.
515        /// Parameters:
516        /// - self: Test LLM factory instance.
517        /// - _config: Provider config payload (unused in test double).
518        fn create(
519            &self,
520            _config: &HashMap<String, String>,
521        ) -> Result<Arc<dyn LlmClient>, LlmError> {
522            Ok(Arc::new(TestLlmClient))
523        }
524        /// Validate test LLM config payload.
525        /// Parameters:
526        /// - self: Test LLM factory instance.
527        /// - _config: Provider config payload (unused in test double).
528        fn validate_config(&self, _config: &HashMap<String, String>) -> Vec<String> {
529            vec![]
530        }
531        /// Return required test LLM configuration keys.
532        /// Parameters:
533        /// - self: Test LLM factory instance.
534        fn required_config_keys(&self) -> Vec<&str> {
535            vec![]
536        }
537    }
538
539    struct TestLlmClient;
540    #[async_trait]
541    impl LlmClient for TestLlmClient {
542        async fn chat(
543            &self,
544            _messages: &[Message],
545            _model: Option<&str>,
546            _tools: Option<Vec<ToolDefinition>>,
547        ) -> Result<ChatResponse, LlmError> {
548            Ok(ChatResponse { content: "ok".into(), model: "test".into(), usage: None })
549        }
550        async fn chat_json(
551            &self,
552            _messages: &[Message],
553            _model: Option<&str>,
554            _tools: Option<Vec<ToolDefinition>>,
555        ) -> Result<serde_json::Value, LlmError> {
556            Ok(serde_json::json!({}))
557        }
558        async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError> {
559            Ok(vec![])
560        }
561        async fn health_check(&self) -> Result<bool, LlmError> {
562            Ok(true)
563        }
564    }
565
566    struct TestModelAdapter;
567    impl LlmModelAdapter for TestModelAdapter {
568        /// Return canonical identifier for the test model adapter.
569        /// Parameters:
570        /// - self: Test model adapter instance.
571        fn model_id(&self) -> &'static str {
572            "test-model"
573        }
574        /// Return owning provider name for the test model adapter.
575        /// Parameters:
576        /// - self: Test model adapter instance.
577        fn provider_name(&self) -> &'static str {
578            "test-llm"
579        }
580        /// Return display name for the test model adapter.
581        /// Parameters:
582        /// - self: Test model adapter instance.
583        fn display_name(&self) -> &'static str {
584            "Test Model"
585        }
586        /// Return static capabilities for the test model adapter.
587        /// Parameters:
588        /// - self: Test model adapter instance.
589        fn capabilities(&self) -> ModelCapabilities {
590            ModelCapabilities {
591                max_context_tokens: 4096,
592                supports_json_mode: true,
593                supports_tools: false,
594                supports_streaming: false,
595            }
596        }
597    }
598
599    struct TestLanguageFactory;
600    impl LanguageRendererFactory for TestLanguageFactory {
601        /// Return canonical language key for the test language factory.
602        /// Parameters:
603        /// - self: Test language factory instance.
604        fn language(&self) -> &'static str {
605            "test-lang"
606        }
607        /// Return display name for the test language factory.
608        /// Parameters:
609        /// - self: Test language factory instance.
610        fn display_name(&self) -> &'static str {
611            "Test Language"
612        }
613        /// Construct a language renderer in test contexts.
614        /// Parameters:
615        /// - self: Test language factory instance.
616        /// - _config: Language renderer config payload (unused in test double).
617        fn create(
618            &self,
619            _config: &HashMap<String, String>,
620        ) -> Result<Arc<dyn crate::language::LanguageRenderer>, LanguageError> {
621            unimplemented!("test only")
622        }
623    }
624
625    struct TestEventSink;
626    #[async_trait]
627    impl crate::event::EventSink for TestEventSink {
628        /// Return canonical sink name for the test event sink.
629        /// Parameters:
630        /// - self: Test event sink instance.
631        fn name(&self) -> &'static str {
632            "test-sink"
633        }
634        async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
635            Ok(())
636        }
637        async fn flush(&self) -> Result<(), EventSinkError> {
638            Ok(())
639        }
640    }
641
642    // ── Tests ─────────────────────────────────────────────────────
643
644    /// Verify builder produces a registry populated across all plugin sub-registries.
645    /// Parameters:
646    /// - none: Uses in-test test-double plugin registrations.
647    #[test]
648    fn builder_creates_registry_with_all_sub_registries() {
649        let registry = PluginRegistryBuilder::new()
650            .with_dialect("postgres", Arc::new(TestDialect))
651            .with_storage_extension("test".into(), Arc::new(TestStorageFactory))
652            .with_llm("test-llm".into(), Arc::new(TestLlmFactory))
653            .with_model(Arc::new(TestModelAdapter))
654            .with_language("test-lang".into(), Arc::new(TestLanguageFactory))
655            .with_event_sink(Arc::new(TestEventSink))
656            .build();
657
658        assert!(registry.dialects.get_by_name("postgres").is_some());
659        assert!(registry.llm.get("test-llm").is_some());
660        assert!(registry.models.get("test-llm", "test-model").is_some());
661        assert!(registry.language.get("test-lang").is_some());
662        assert!(!registry.events.is_empty());
663    }
664
665    /// Verify fluent builder chaining remains ergonomic and type-safe.
666    /// Parameters:
667    /// - none: Uses in-test chained builder calls across plugin categories.
668    #[test]
669    fn builder_chaining_is_ergonomic() {
670        // Verify fluent API compiles and works without clone/move issues
671        let _registry = PluginRegistryBuilder::new()
672            .with_dialect("postgres", Arc::new(TestDialect))
673            .with_dialect("snowflake", Arc::new(TestDialect))
674            .with_llm("a".into(), Arc::new(TestLlmFactory))
675            .with_llm("b".into(), Arc::new(TestLlmFactory))
676            .with_event_sink(Arc::new(TestEventSink))
677            .with_event_sink(Arc::new(TestEventSink))
678            .build();
679    }
680
681    /// Verify builder supports mixed known-platform and extension registrations.
682    /// Parameters:
683    /// - none: Uses in-test mixed dialect/storage registration fixture.
684    #[test]
685    fn builder_with_mixed_known_and_extension() {
686        let registry = PluginRegistryBuilder::new()
687            .with_dialect("postgres", Arc::new(TestDialect))
688            .with_dialect("teradata", Arc::new(TestDialect))
689            .with_storage(StoragePlatform::S3, Arc::new(TestStorageFactory))
690            .with_storage_extension("minio".into(), Arc::new(TestStorageFactory))
691            .build();
692
693        assert!(registry.dialects.get_by_name("postgres").is_some());
694        assert!(registry.dialects.get_by_name("teradata").is_some());
695    }
696
697    /// Verify auto-discovery finds exactly the dialects this test binary
698    /// submitted.
699    /// Parameters:
700    /// - none: Uses the one dialect the conformance fixtures submit.
701    #[test]
702    fn builder_with_auto_discovered_finds_inventory_entries() {
703        // One dialect is submitted in this binary — the conformance report is
704        // graded against a registered plugin, so it needs one registered.
705        let registry = PluginRegistryBuilder::new().with_auto_discovered().build();
706        assert_eq!(registry.dialects.registered_names(), vec!["outsidedb".to_owned()]);
707    }
708
709    /// Verify manual plugin-loaded event recording is captured in the event registry.
710    /// Parameters:
711    /// - none: Uses in-test direct event-recording fixture.
712    #[test]
713    fn manual_registration_records_plugin_loaded_events() {
714        let builder = PluginRegistryBuilder::new()
715            .with_dialect("postgres", Arc::new(TestDialect))
716            .with_llm("test-llm".into(), Arc::new(TestLlmFactory));
717
718        // record_plugin_loaded is only called from auto-discovery and dynamic loading,
719        // so manual registration does not emit events.
720        // We test the method directly.
721        builder.record_plugin_loaded("TestDialect", "dialect", "manual");
722        builder.record_plugin_loaded("TestLlm", "llm", "manual");
723
724        let registry = builder.build();
725        let events = registry.events.events();
726        assert_eq!(events.len(), 2);
727
728        assert_eq!(events[0].category(), crate::event::EventCategory::Plugin);
729        assert_eq!(events[0].summary().title, "Plugin loaded");
730        assert!(events[0].summary().detail.contains("dialect"));
731
732        assert_eq!(events[1].category(), crate::event::EventCategory::Plugin);
733        assert!(events[1].summary().detail.contains("llm"));
734    }
735
736    /// Verify auto-discovery registers built-in sinks and emits load events.
737    /// Parameters:
738    /// - none: Uses in-test auto-discovery run against built-in inventory entries.
739    #[test]
740    fn auto_discovery_registers_built_in_sinks() {
741        let registry = PluginRegistryBuilder::new().with_auto_discovered().build();
742        // Built-in sinks (stdout, jsonl, webhook) register via inventory::submit!
743        // so auto-discovery should find at least 3 event sinks.
744        assert!(
745            registry.events.len() >= 3,
746            "Expected at least 3 built-in sinks, found {}",
747            registry.events.len()
748        );
749
750        // Verify auto-discovery emitted PluginLoaded events for sinks
751        let events = registry.events.events();
752        let sink_events: Vec<_> =
753            events.iter().filter(|e| e.summary().detail.contains("event_sink")).collect();
754        assert!(
755            sink_events.len() >= 3,
756            "Expected at least 3 event_sink load events, found {}",
757            sink_events.len()
758        );
759    }
760}