Skip to main content

lenso_plugin_authoring/
lib.rs

1//! Runtime-neutral authoring primitives for strongly typed Lenso Plugins.
2
3use std::{cell::OnceCell, ops::Deref, rc::Rc};
4
5/// One Plugin operation failure with an explicit Domain/Runtime split.
6///
7/// Ordinary operations can return `Result<T, DomainError>` directly. Use this
8/// type only when Plugin code must deliberately surface an Adapter-specific
9/// runtime failure in addition to its Capability-defined Domain Errors.
10#[derive(Clone, Debug, PartialEq)]
11pub enum PluginError<DomainError, RuntimeError> {
12    /// An expected Capability-defined business rejection.
13    Domain(DomainError),
14    /// An infrastructure or execution failure outside the Capability contract.
15    Runtime(RuntimeError),
16}
17
18impl<DomainError, RuntimeError> PluginError<DomainError, RuntimeError> {
19    /// Creates a Capability-defined Domain Error.
20    pub const fn domain(error: DomainError) -> Self {
21        Self::Domain(error)
22    }
23
24    /// Creates an Adapter-specific Runtime Error.
25    pub const fn runtime(error: RuntimeError) -> Self {
26        Self::Runtime(error)
27    }
28
29    /// Maps the Domain Error while preserving the Runtime Error.
30    pub fn map_domain<Other>(
31        self,
32        map: impl FnOnce(DomainError) -> Other,
33    ) -> PluginError<Other, RuntimeError> {
34        match self {
35            Self::Domain(error) => PluginError::Domain(map(error)),
36            Self::Runtime(error) => PluginError::Runtime(error),
37        }
38    }
39}
40
41/// A generated, strongly typed client for one required Capability.
42///
43/// Capability binding generators implement this trait for their client type so
44/// Plugin authoring frontends can connect typed Ports without knowing the
45/// Capability's operation kinds or handle layout. Implementations must use only
46/// the supplied Plan-owned dependencies; they must not perform discovery.
47pub trait CapabilityClient: Sized + 'static {
48    /// Adapter-owned dependency view used to connect this client.
49    type Dependencies: ?Sized;
50    /// Adapter-owned failure returned when connection cannot complete.
51    type Error;
52
53    /// Stable Capability identity required by this client.
54    const CAPABILITY_ID: &'static str;
55    /// Exact Descriptor version understood by this generated client.
56    const DESCRIPTOR_VERSION: &'static str;
57
58    /// Connects this client to one Plugin Instance's resolved dependencies.
59    fn from_dependencies(dependencies: &Self::Dependencies) -> Result<Self, Self::Error>;
60
61    /// Creates the adapter failure for an invalid second connection attempt.
62    fn already_connected() -> Self::Error;
63}
64
65/// A generated Capability client that can be connected to every explicitly
66/// bound provider in deterministic Resolved App Plan order.
67pub trait CapabilityClientMany: CapabilityClient {
68    /// Connects one typed client per bound provider without performing discovery.
69    fn many_from_dependencies(
70        dependencies: &Self::Dependencies,
71    ) -> Result<Vec<BoundCapabilityClient<Self>>, Self::Error>;
72}
73
74/// One typed Capability client paired with its App-local provider Instance key.
75#[derive(Debug)]
76pub struct BoundCapabilityClient<C> {
77    provider_instance: String,
78    client: C,
79}
80
81impl<C> BoundCapabilityClient<C> {
82    /// Creates one Plan-bound client entry.
83    #[must_use]
84    pub fn new(provider_instance: impl Into<String>, client: C) -> Self {
85        Self {
86            provider_instance: provider_instance.into(),
87            client,
88        }
89    }
90
91    /// Returns the App-local provider Instance key selected by Composition.
92    #[must_use]
93    pub fn provider_instance(&self) -> &str {
94        &self.provider_instance
95    }
96
97    /// Returns the generated typed client.
98    #[must_use]
99    pub const fn client(&self) -> &C {
100        &self.client
101    }
102}
103
104impl<C> Deref for BoundCapabilityClient<C> {
105    type Target = C;
106
107    fn deref(&self) -> &Self::Target {
108        &self.client
109    }
110}
111
112/// A typed, lifecycle-bound Capability requirement declared by a Plugin.
113///
114/// Generated Plugin glue connects the Port during activation. Plugin behavior
115/// can then call the generated Capability client directly through `Deref`.
116/// A fresh Plugin generation owns fresh Ports; reconnecting one Port is an
117/// invalid lifecycle transition.
118pub struct Port<C: CapabilityClient> {
119    client: Rc<OnceCell<C>>,
120}
121
122impl<C: CapabilityClient> Port<C> {
123    /// Creates a disconnected typed Port.
124    #[must_use]
125    pub fn new() -> Self {
126        Self {
127            client: Rc::new(OnceCell::new()),
128        }
129    }
130
131    /// Connects the Port from this Plugin Instance's resolved dependencies.
132    pub fn connect(&self, dependencies: &C::Dependencies) -> Result<(), C::Error> {
133        let client = C::from_dependencies(dependencies)?;
134        self.client.set(client).map_err(|_| C::already_connected())
135    }
136
137    /// Returns whether lifecycle activation connected this Port.
138    #[must_use]
139    pub fn is_connected(&self) -> bool {
140        self.client.get().is_some()
141    }
142}
143
144impl<C: CapabilityClient> Clone for Port<C> {
145    fn clone(&self) -> Self {
146        Self {
147            client: Rc::clone(&self.client),
148        }
149    }
150}
151
152impl<C: CapabilityClient> std::fmt::Debug for Port<C> {
153    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        formatter
155            .debug_struct("Port")
156            .field("capability_id", &C::CAPABILITY_ID)
157            .field("descriptor_version", &C::DESCRIPTOR_VERSION)
158            .field("connected", &self.is_connected())
159            .finish()
160    }
161}
162
163impl<C: CapabilityClient> Default for Port<C> {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169impl<C: CapabilityClient> Deref for Port<C> {
170    type Target = C;
171
172    fn deref(&self) -> &Self::Target {
173        self.client.get().unwrap_or_else(|| {
174            panic!(
175                "Capability Port {} was used before Plugin activation",
176                C::CAPABILITY_ID
177            )
178        })
179    }
180}
181
182/// A typed, lifecycle-bound `many` Capability requirement declared by a Plugin.
183///
184/// Generated Plugin glue connects one client per explicitly bound provider during
185/// activation. Entries retain their provider Instance keys and resolved order.
186pub struct ManyPort<C: CapabilityClientMany> {
187    clients: Rc<OnceCell<Vec<BoundCapabilityClient<C>>>>,
188}
189
190impl<C: CapabilityClientMany> ManyPort<C> {
191    /// Creates a disconnected typed `many` Port.
192    #[must_use]
193    pub fn new() -> Self {
194        Self {
195            clients: Rc::new(OnceCell::new()),
196        }
197    }
198
199    /// Connects the Port from this Plugin Instance's resolved dependencies.
200    pub fn connect(&self, dependencies: &C::Dependencies) -> Result<(), C::Error> {
201        let clients = C::many_from_dependencies(dependencies)?;
202        self.clients
203            .set(clients)
204            .map_err(|_| C::already_connected())
205    }
206
207    /// Returns whether lifecycle activation connected this Port.
208    #[must_use]
209    pub fn is_connected(&self) -> bool {
210        self.clients.get().is_some()
211    }
212}
213
214impl<C: CapabilityClientMany> Clone for ManyPort<C> {
215    fn clone(&self) -> Self {
216        Self {
217            clients: Rc::clone(&self.clients),
218        }
219    }
220}
221
222impl<C: CapabilityClientMany> std::fmt::Debug for ManyPort<C> {
223    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        formatter
225            .debug_struct("ManyPort")
226            .field("capability_id", &C::CAPABILITY_ID)
227            .field("descriptor_version", &C::DESCRIPTOR_VERSION)
228            .field("connected", &self.is_connected())
229            .field("provider_count", &self.clients.get().map(Vec::len))
230            .finish()
231    }
232}
233
234impl<C: CapabilityClientMany> Default for ManyPort<C> {
235    fn default() -> Self {
236        Self::new()
237    }
238}
239
240impl<C: CapabilityClientMany> Deref for ManyPort<C> {
241    type Target = [BoundCapabilityClient<C>];
242
243    fn deref(&self) -> &Self::Target {
244        self.clients.get().map_or_else(
245            || {
246                panic!(
247                    "Capability ManyPort {} was used before Plugin activation",
248                    C::CAPABILITY_ID
249                )
250            },
251            Vec::as_slice,
252        )
253    }
254}
255
256/// Common imports for a Plugin authoring frontend.
257pub mod prelude {
258    pub use crate::{
259        BoundCapabilityClient, CapabilityClient, CapabilityClientMany, ManyPort, PluginError, Port,
260    };
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[derive(Debug, Eq, PartialEq)]
268    struct ExampleClient(u64);
269
270    #[derive(Debug, Eq, PartialEq)]
271    enum ExampleError {
272        AlreadyConnected,
273    }
274
275    impl CapabilityClient for ExampleClient {
276        type Dependencies = ();
277        type Error = ExampleError;
278
279        const CAPABILITY_ID: &'static str = "example.echo@1";
280        const DESCRIPTOR_VERSION: &'static str = "1.0.0";
281
282        fn from_dependencies(_dependencies: &Self::Dependencies) -> Result<Self, Self::Error> {
283            Ok(Self(42))
284        }
285
286        fn already_connected() -> Self::Error {
287            ExampleError::AlreadyConnected
288        }
289    }
290
291    impl CapabilityClientMany for ExampleClient {
292        fn many_from_dependencies(
293            _dependencies: &Self::Dependencies,
294        ) -> Result<Vec<BoundCapabilityClient<Self>>, Self::Error> {
295            Ok(vec![
296                BoundCapabilityClient::new("alpha", Self(1)),
297                BoundCapabilityClient::new("beta", Self(2)),
298            ])
299        }
300    }
301
302    #[test]
303    fn port_connects_once_and_is_shared_by_plugin_clones() {
304        let port = Port::<ExampleClient>::new();
305        let plugin_clone = port.clone();
306        assert!(!port.is_connected());
307
308        port.connect(&())
309            .expect("the generated client should connect");
310
311        assert!(plugin_clone.is_connected());
312        assert_eq!(plugin_clone.0, 42);
313        assert_eq!(port.connect(&()), Err(ExampleError::AlreadyConnected));
314    }
315
316    #[test]
317    fn many_port_preserves_provider_identity_and_resolved_order() {
318        let port = ManyPort::<ExampleClient>::new();
319        let plugin_clone = port.clone();
320        assert!(!port.is_connected());
321
322        port.connect(&())
323            .expect("the generated clients should connect");
324
325        assert!(plugin_clone.is_connected());
326        assert_eq!(plugin_clone[0].provider_instance(), "alpha");
327        assert_eq!(plugin_clone[0].client().0, 1);
328        assert_eq!(plugin_clone[1].provider_instance(), "beta");
329        assert_eq!(plugin_clone[1].client().0, 2);
330        assert_eq!(port.connect(&()), Err(ExampleError::AlreadyConnected));
331    }
332
333    #[test]
334    fn plugin_error_preserves_runtime_failures_while_mapping_domain_errors() {
335        let domain = PluginError::<_, &str>::domain("missing").map_domain(str::len);
336        assert_eq!(domain, PluginError::Domain(7));
337
338        let runtime = PluginError::<&str, _>::runtime("cancelled").map_domain(str::len);
339        assert_eq!(runtime, PluginError::Runtime("cancelled"));
340    }
341}