Skip to main content

persona_wire_core/application/
plugin_registry.rs

1//! PluginRegistry — 3 軸 Plugin (Adapter / TemplateEngine / Projection) を統合管理。
2//!
3//! server boot 時に register、 runtime mutation なし (= immutable after `build()`)。
4//! Plugin の物理境界は外部 crate (例: `wire-adapter-pg` / `wire-template-jinja` /
5//! `wire-projection-llm`)、 boot 側 (`persona-wire-mcp` / `persona-wire` bin) で
6//! `PluginRegistry::builder()` に流し込んで構築する。
7//!
8//! ## boot 例
9//!
10//! ```ignore
11//! use persona_wire_core::application::plugin_registry::PluginRegistry;
12//! use persona_wire_core::infrastructure::adapter::FileAdapter;
13//! use persona_wire_core::infrastructure::template::HandlebarsEngine;
14//! use persona_wire_core::infrastructure::projection::StaticProjection;
15//! use persona_wire_adapter_mini_app::MiniAppAdapter;
16//!
17//! let registry = PluginRegistry::default_builder_for_wire()
18//!     .with_adapter(MiniAppAdapter)
19//!     .build()
20//!     .expect("plugin registry build");
21//! ```
22//!
23//! P3a stage: registry skeleton + builder + lookup surface のみ。 use_cases.rs
24//! 側の dispatch 配線 (registry を引数で受け取って fetch / render を引く form)
25//! は P3a 後段で順次差し替え (現状は free fn `fetch_via_adapter` + `rendering::render`
26//! 直呼びを維持、 後方互換)。
27
28use std::collections::HashMap;
29use std::fmt;
30use std::sync::Arc;
31
32use crate::domain::error::{WireError, WireResult};
33use crate::domain::port::ProjectionRenderer;
34use crate::infrastructure::adapter::Adapter;
35use crate::infrastructure::filter::FilterCap;
36use crate::infrastructure::template::TemplateEngine;
37use crate::infrastructure::wire_uri::WireUri;
38
39/// `wire_doctor` display row for one registered [`Adapter`]: its scheme plus
40/// the cross-cutting [`FilterCap`]s it declared support for.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct AdapterInfo {
43    /// URI scheme identifier (matches [`Adapter::scheme`]).
44    pub scheme: &'static str,
45    /// Cross-cutting filter capabilities the adapter declared (matches
46    /// [`Adapter::filter_caps`]). Empty means "no cross-cutting filter
47    /// support".
48    pub filter_caps: Vec<FilterCap>,
49}
50
51/// 3 軸 Plugin を統合管理する immutable registry。
52///
53/// build 後の mutation 不可。 dispatch は scheme / id / kind 文字列引きで O(1)。
54#[derive(Clone, Default)]
55pub struct PluginRegistry {
56    adapters: HashMap<&'static str, Arc<dyn Adapter>>,
57    engines: HashMap<&'static str, Arc<dyn TemplateEngine>>,
58    projections: HashMap<&'static str, Arc<dyn ProjectionRenderer>>,
59}
60
61impl fmt::Debug for PluginRegistry {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.debug_struct("PluginRegistry")
64            .field("schemes", &self.schemes())
65            .field("engine_ids", &self.engine_ids())
66            .field("projection_kinds", &self.projection_kinds())
67            .finish()
68    }
69}
70
71impl PluginRegistry {
72    /// builder pattern 入口。
73    pub fn builder() -> PluginRegistryBuilder {
74        PluginRegistryBuilder::default()
75    }
76
77    /// Core 同梱 plugin の builder を返す convenience 関数。
78    ///
79    /// P3b で mini-app adapter が外部 crate (`persona-wire-adapter-mini-app`) に分離
80    /// されたため、 consumer (`persona-wire-mcp` / `persona-wire` bin) は本 builder に
81    /// `.with_adapter(MiniAppAdapter)` を chain して使う。
82    ///
83    /// 同梱内訳 (core):
84    /// - `FileAdapter` (scheme `"file"`)
85    /// - `HandlebarsEngine` (id `"handlebars"`)
86    /// - `StaticProjection` (kind `"static"`)
87    pub fn default_builder_for_wire() -> PluginRegistryBuilder {
88        use crate::infrastructure::adapter::FileAdapter;
89        use crate::infrastructure::projection::StaticProjection;
90        use crate::infrastructure::template::HandlebarsEngine;
91        Self::builder()
92            .with_adapter(FileAdapter)
93            .with_engine(HandlebarsEngine::new())
94            .with_projection(StaticProjection::new())
95    }
96
97    /// Core 同梱 plugin のみで registry を build する shortcut。
98    /// mini-app scheme を含めたい場合は [`default_builder_for_wire`] を使い、
99    /// caller 側で `MiniAppAdapter` を chain すること。
100    pub fn default_for_wire() -> WireResult<Self> {
101        Self::default_builder_for_wire().build()
102    }
103
104    /// `source_uri` の scheme prefix に該当する adapter を引く (parse なし、 lookup のみ)。
105    /// 未登録 scheme は `None`。
106    ///
107    /// Adapter の `fetch` を呼ぶ場合は [`route`](Self::route) を使うこと
108    /// (parse + lookup を 1 箇所に集約する canonical 経路)。
109    pub fn adapter_for_uri(&self, source_uri: &str) -> Option<&Arc<dyn Adapter>> {
110        let scheme = source_uri.split_once(':').map(|(s, _)| s)?;
111        self.adapters.get(scheme)
112    }
113
114    /// URI grammar parse + scheme dispatch を 1 step で行う canonical entry。
115    ///
116    /// 返り値の `(adapter, WireUri)` をそのまま `adapter.fetch(&uri).await` に流せる。
117    /// scheme 未登録は `WireError::Storage` (Adapter trait の `fetch` 失敗と同 error 軸)。
118    pub fn route(&self, source_uri: &str) -> WireResult<(Arc<dyn Adapter>, WireUri)> {
119        let uri = WireUri::parse(source_uri)?;
120        let adapter = self.adapters.get(uri.scheme()).cloned().ok_or_else(|| {
121            WireError::Storage(format!(
122                "plugin registry: no adapter registered for scheme `{}` (uri: {})",
123                uri.scheme(),
124                source_uri,
125            ))
126        })?;
127        Ok((adapter, uri))
128    }
129
130    /// scheme literal から adapter を引く。
131    pub fn adapter(&self, scheme: &str) -> Option<&Arc<dyn Adapter>> {
132        self.adapters.get(scheme)
133    }
134
135    /// engine id から template engine を引く。
136    pub fn engine(&self, id: &str) -> Option<&Arc<dyn TemplateEngine>> {
137        self.engines.get(id)
138    }
139
140    /// kind id から projection を引く。
141    pub fn projection(&self, kind: &str) -> Option<&Arc<dyn ProjectionRenderer>> {
142        self.projections.get(kind)
143    }
144
145    /// 登録済 scheme 一覧 (`wire_doctor` 表示用)。
146    pub fn schemes(&self) -> Vec<&'static str> {
147        let mut v: Vec<_> = self.adapters.keys().copied().collect();
148        v.sort_unstable();
149        v
150    }
151
152    /// `wire_doctor` 表示用: 登録 adapter の scheme + filter capability 一覧
153    /// (scheme 昇順、既存 [`schemes`](Self::schemes) と同順序規約)。
154    pub fn describe(&self) -> Vec<AdapterInfo> {
155        let mut v: Vec<AdapterInfo> = self
156            .adapters
157            .iter()
158            .map(|(&scheme, adapter)| AdapterInfo {
159                scheme,
160                filter_caps: adapter.filter_caps().to_vec(),
161            })
162            .collect();
163        v.sort_unstable_by_key(|info| info.scheme);
164        v
165    }
166
167    /// 登録済 engine id 一覧。
168    pub fn engine_ids(&self) -> Vec<&'static str> {
169        let mut v: Vec<_> = self.engines.keys().copied().collect();
170        v.sort_unstable();
171        v
172    }
173
174    /// 登録済 projection kind 一覧。
175    pub fn projection_kinds(&self) -> Vec<&'static str> {
176        let mut v: Vec<_> = self.projections.keys().copied().collect();
177        v.sort_unstable();
178        v
179    }
180}
181
182/// builder。 同一 scheme / id / kind の重複登録は `build()` 時に fail-fast。
183#[derive(Default)]
184pub struct PluginRegistryBuilder {
185    adapters: Vec<Arc<dyn Adapter>>,
186    engines: Vec<Arc<dyn TemplateEngine>>,
187    projections: Vec<Arc<dyn ProjectionRenderer>>,
188}
189
190impl PluginRegistryBuilder {
191    pub fn with_adapter<A: Adapter + 'static>(mut self, adapter: A) -> Self {
192        self.adapters.push(Arc::new(adapter));
193        self
194    }
195
196    pub fn with_engine<E: TemplateEngine + 'static>(mut self, engine: E) -> Self {
197        self.engines.push(Arc::new(engine));
198        self
199    }
200
201    pub fn with_projection<P: ProjectionRenderer + 'static>(mut self, projection: P) -> Self {
202        self.projections.push(Arc::new(projection));
203        self
204    }
205
206    /// fail-fast: 同一 scheme / id / kind が複数あれば error。
207    pub fn build(self) -> WireResult<PluginRegistry> {
208        let mut adapters = HashMap::new();
209        for a in self.adapters {
210            let scheme = a.scheme();
211            if adapters.insert(scheme, a).is_some() {
212                return Err(WireError::Storage(format!(
213                    "plugin registry: duplicate adapter scheme `{scheme}`"
214                )));
215            }
216        }
217        let mut engines = HashMap::new();
218        for e in self.engines {
219            let id = e.id();
220            if engines.insert(id, e).is_some() {
221                return Err(WireError::Storage(format!(
222                    "plugin registry: duplicate template engine id `{id}`"
223                )));
224            }
225        }
226        let mut projections = HashMap::new();
227        for p in self.projections {
228            let kind = p.kind();
229            if projections.insert(kind, p).is_some() {
230                return Err(WireError::Storage(format!(
231                    "plugin registry: duplicate projection kind `{kind}`"
232                )));
233            }
234        }
235        Ok(PluginRegistry {
236            adapters,
237            engines,
238            projections,
239        })
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::infrastructure::adapter::FileAdapter;
247    use crate::infrastructure::projection::StaticProjection;
248    use crate::infrastructure::template::HandlebarsEngine;
249
250    #[test]
251    fn empty_registry_has_no_plugins() {
252        let reg = PluginRegistry::builder().build().unwrap();
253        assert!(reg.schemes().is_empty());
254        assert!(reg.engine_ids().is_empty());
255        assert!(reg.projection_kinds().is_empty());
256    }
257
258    #[test]
259    fn registers_all_three_axes() {
260        let reg = PluginRegistry::builder()
261            .with_adapter(FileAdapter)
262            .with_engine(HandlebarsEngine::new())
263            .with_projection(StaticProjection::new())
264            .build()
265            .unwrap();
266        assert_eq!(reg.schemes(), vec!["file"]);
267        assert_eq!(reg.engine_ids(), vec!["handlebars"]);
268        assert_eq!(reg.projection_kinds(), vec!["static"]);
269    }
270
271    #[test]
272    fn default_builder_for_wire_has_core_plugins_only() {
273        let reg = PluginRegistry::default_builder_for_wire().build().unwrap();
274        assert_eq!(reg.schemes(), vec!["file"]);
275        assert_eq!(reg.engine_ids(), vec!["handlebars"]);
276        assert_eq!(reg.projection_kinds(), vec!["static"]);
277    }
278
279    /// Test-only adapter with no filter caps, scheme sorts before `"file"`.
280    struct NoFilterAdapter;
281
282    #[async_trait::async_trait]
283    impl Adapter for NoFilterAdapter {
284        fn scheme(&self) -> &'static str {
285            "aaa-test"
286        }
287
288        async fn fetch(&self, _uri: &WireUri) -> WireResult<serde_json::Value> {
289            Ok(serde_json::json!({}))
290        }
291    }
292
293    #[test]
294    fn describe_returns_scheme_and_filter_caps_sorted_by_scheme() {
295        let reg = PluginRegistry::builder()
296            .with_adapter(FileAdapter)
297            .with_adapter(NoFilterAdapter)
298            .build()
299            .unwrap();
300        let info = reg.describe();
301        assert_eq!(info.len(), 2);
302        // scheme ascending: "aaa-test" < "file"
303        assert_eq!(info[0].scheme, "aaa-test");
304        assert!(
305            info[0].filter_caps.is_empty(),
306            "adapter without filter_caps override should describe as empty"
307        );
308        assert_eq!(info[1].scheme, "file");
309        assert_eq!(
310            info[1].filter_caps,
311            vec![FilterCap::LineRange, FilterCap::Tail { n_max: 1000 }],
312        );
313    }
314
315    #[test]
316    fn describe_empty_registry_returns_empty_vec() {
317        let reg = PluginRegistry::builder().build().unwrap();
318        assert!(reg.describe().is_empty());
319    }
320
321    #[test]
322    fn adapter_for_uri_dispatches_by_scheme() {
323        let reg = PluginRegistry::builder()
324            .with_adapter(FileAdapter)
325            .build()
326            .unwrap();
327        assert!(reg.adapter_for_uri("file:///tmp/x").is_some());
328        assert!(reg.adapter_for_uri("mini-app://x").is_none());
329        assert!(reg.adapter_for_uri("no-scheme").is_none());
330    }
331
332    #[test]
333    fn duplicate_scheme_fails_build() {
334        let err = PluginRegistry::builder()
335            .with_adapter(FileAdapter)
336            .with_adapter(FileAdapter)
337            .build()
338            .unwrap_err();
339        let msg = format!("{:?}", err);
340        assert!(msg.contains("duplicate adapter scheme"));
341        assert!(msg.contains("file"));
342    }
343
344    #[test]
345    fn duplicate_engine_fails_build() {
346        let err = PluginRegistry::builder()
347            .with_engine(HandlebarsEngine::new())
348            .with_engine(HandlebarsEngine::new())
349            .build()
350            .unwrap_err();
351        let msg = format!("{:?}", err);
352        assert!(msg.contains("duplicate template engine id"));
353    }
354
355    #[test]
356    fn duplicate_projection_fails_build() {
357        let err = PluginRegistry::builder()
358            .with_projection(StaticProjection::new())
359            .with_projection(StaticProjection::new())
360            .build()
361            .unwrap_err();
362        let msg = format!("{:?}", err);
363        assert!(msg.contains("duplicate projection kind"));
364    }
365}