Skip to main content

sova_core/
plugin.rs

1//! Plugin extension trait and SDK metadata.
2//!
3//! # Writing a plugin
4//!
5//! A plugin is any type that implements [`Plugin`]. On install it typically:
6//!
7//! 1. Registers middleware via [`crate::App::use_middleware`] / [`crate::extend::with_leaked`]
8//! 2. Inserts shared state with [`crate::App::state`]
9//! 3. Adds routes (`get` / `post` / …)
10//! 4. Optionally registers lifecycle hooks, CLI commands, or checks
11//!
12//! ## Identity and dependencies
13//!
14//! Override [`Plugin::id`] with a short stable string (`"cookies"`, `"session"`).
15//! Use [`Plugin::requires`] so dependents fail at [`crate::App::build`] if a
16//! dependency was not installed first. Prefer short ids over `type_name`.
17//!
18//! ## SDK versioning
19//!
20//! [`PLUGIN_SDK_VERSION`] is the plugin-author surface version (independent of
21//! the crate semver). Declare the version your plugin was built against via
22//! [`PluginMeta::sdk`] (default = current). Compatibility on install:
23//!
24//! - **different major** → hard error at build
25//! - **plugin newer** than core (same major) → hard error
26//! - **core newer** than plugin (same major) → `tracing` warning
27//!
28//! Bump `PLUGIN_SDK_VERSION` major only when the author-facing API breaks.
29//!
30//! ## Metadata
31//!
32//! [`Plugin::meta`] returns human-readable info for CLI (`plugins`) and docs.
33//!
34//! # Examples
35//!
36//! ```rust,ignore
37//! use sova_core::extend::with_leaked;
38//! use sova_core::{App, Plugin, PluginMeta, Request, Response};
39//!
40//! struct HelloHeader;
41//!
42//! impl Plugin for HelloHeader {
43//!     fn id(&self) -> &'static str {
44//!         "hello-header"
45//!     }
46//!
47//!     fn meta(&self) -> PluginMeta {
48//!         PluginMeta::new("Hello Header")
49//!             .description("Adds an X-Hello response header")
50//!             .version(env!("CARGO_PKG_VERSION"))
51//!     }
52//!
53//!     fn install(self, app: &mut App) {
54//!         app.use_middleware(with_leaked((), |_s, req, next| async move {
55//!             let mut res = next(req).await;
56//!             res = res.header("x-hello", "sova");
57//!             res
58//!         }));
59//!     }
60//! }
61//!
62//! let mut app = App::new();
63//! app.install(HelloHeader);
64//! ```
65//!
66//! Closure plugins work without a named type:
67//!
68//! ```rust,ignore
69//! app.install(|app: &mut App| {
70//!     app.get("/healthz", || async { Response::text("ok") });
71//! });
72//! ```
73//!
74//! Scaffold a new crate with `cargo sovax generate plugin <name>`.
75
76use crate::app::App;
77use std::cmp::Ordering;
78use std::fmt;
79
80/// Current Plugin SDK version (author-facing surface, not crate semver).
81pub const PLUGIN_SDK_VERSION: PluginSdkVersion = PluginSdkVersion::new(1, 0, 0);
82
83/// Semantic version of the Plugin SDK (`major.minor.patch`).
84#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
85pub struct PluginSdkVersion {
86    pub major: u32,
87    pub minor: u32,
88    pub patch: u32,
89}
90
91impl PluginSdkVersion {
92    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
93        Self {
94            major,
95            minor,
96            patch,
97        }
98    }
99
100    /// Parse `"1.2.3"` / `"1.2"` / `"1"`. Invalid input → `None`.
101    pub fn parse(s: &str) -> Option<Self> {
102        let mut parts = s.trim().split('.');
103        let major = parts.next()?.parse().ok()?;
104        let minor = parts.next().unwrap_or("0").parse().ok()?;
105        let patch = parts.next().unwrap_or("0").parse().ok()?;
106        if parts.next().is_some() {
107            return None;
108        }
109        Some(Self::new(major, minor, patch))
110    }
111}
112
113impl fmt::Display for PluginSdkVersion {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
116    }
117}
118
119/// Result of comparing a plugin's declared SDK against core.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum SdkCompat {
122    /// Exact match (or plugin == core).
123    Ok,
124    /// Same major; core is newer — plugin may miss new APIs later; warn only.
125    Warn { core: PluginSdkVersion, plugin: PluginSdkVersion },
126    /// Hard failure: major mismatch or plugin requires a newer core.
127    Error(String),
128}
129
130/// Compare `plugin` SDK declaration against `core` ([`PLUGIN_SDK_VERSION`]).
131pub fn check_plugin_sdk(plugin: PluginSdkVersion, core: PluginSdkVersion) -> SdkCompat {
132    if plugin.major != core.major {
133        return SdkCompat::Error(format!(
134            "plugin SDK {plugin} is incompatible with core SDK {core} (major version mismatch)"
135        ));
136    }
137    match plugin.cmp(&core) {
138        Ordering::Greater => SdkCompat::Error(format!(
139            "plugin SDK {plugin} requires core SDK >= {plugin} (running {core})"
140        )),
141        Ordering::Less => SdkCompat::Warn { core, plugin },
142        Ordering::Equal => SdkCompat::Ok,
143    }
144}
145
146/// Human-readable plugin metadata (CLI, docs, introspection).
147#[derive(Debug, Clone)]
148pub struct PluginMeta {
149    /// Display name (defaults to plugin id).
150    pub name: &'static str,
151    /// One-line description of what the plugin does.
152    pub description: &'static str,
153    /// Plugin crate / package version (not SDK), e.g. `env!("CARGO_PKG_VERSION")`.
154    pub version: &'static str,
155    /// Optional author / maintainer.
156    pub author: &'static str,
157    /// Plugin SDK version this plugin was written against.
158    pub sdk: PluginSdkVersion,
159}
160
161impl PluginMeta {
162    /// Start a builder with a display `name`; SDK defaults to [`PLUGIN_SDK_VERSION`].
163    pub fn new(name: &'static str) -> Self {
164        Self {
165            name,
166            description: "",
167            version: "",
168            author: "",
169            sdk: PLUGIN_SDK_VERSION,
170        }
171    }
172
173    /// Minimal meta for plugins that only set [`Plugin::id`].
174    pub fn for_id(id: &'static str) -> Self {
175        Self::new(id)
176    }
177
178    pub fn description(mut self, description: &'static str) -> Self {
179        self.description = description;
180        self
181    }
182
183    pub fn version(mut self, version: &'static str) -> Self {
184        self.version = version;
185        self
186    }
187
188    pub fn author(mut self, author: &'static str) -> Self {
189        self.author = author;
190        self
191    }
192
193    pub fn sdk(mut self, sdk: PluginSdkVersion) -> Self {
194        self.sdk = sdk;
195        self
196    }
197}
198
199/// Snapshot of an installed plugin (for CLI / introspection).
200#[derive(Debug, Clone)]
201pub struct InstalledPlugin {
202    pub id: &'static str,
203    pub meta: PluginMeta,
204}
205
206/// Single extension trait for the framework.
207///
208/// Prefer `app.install(|app| { ... })` or `app.install(Cors::new())` —
209/// application code rarely needs to name this trait; plugin authors implement it.
210pub trait Plugin {
211    /// Stable plugin identifier used for dependency checks and [`crate::App::has_plugin`].
212    ///
213    /// Prefer a short constant (`"session"`) over the default [`std::any::type_name`].
214    fn id(&self) -> &'static str {
215        std::any::type_name::<Self>()
216    }
217
218    /// Required plugin ids that must be installed beforehand.
219    ///
220    /// Missing deps are collected at install and reported on [`crate::App::build`].
221    fn requires(&self) -> &'static [&'static str] {
222        &[]
223    }
224
225    /// Display name, description, and declared [`PluginMeta::sdk`] version.
226    fn meta(&self) -> PluginMeta {
227        PluginMeta::for_id(self.id())
228    }
229
230    /// Register middleware, state, routes, and hooks on `app`.
231    fn install(self, app: &mut App);
232}
233
234impl<F> Plugin for F
235where
236    F: FnOnce(&mut App),
237{
238    fn install(self, app: &mut App) {
239        self(app);
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn parse_sdk_version() {
249        assert_eq!(
250            PluginSdkVersion::parse("1.2.3"),
251            Some(PluginSdkVersion::new(1, 2, 3))
252        );
253        assert_eq!(
254            PluginSdkVersion::parse("2"),
255            Some(PluginSdkVersion::new(2, 0, 0))
256        );
257        assert!(PluginSdkVersion::parse("x").is_none());
258    }
259
260    #[test]
261    fn sdk_compat_rules() {
262        let core = PluginSdkVersion::new(1, 1, 0);
263        assert_eq!(
264            check_plugin_sdk(PluginSdkVersion::new(1, 1, 0), core),
265            SdkCompat::Ok
266        );
267        assert!(matches!(
268            check_plugin_sdk(PluginSdkVersion::new(1, 0, 0), core),
269            SdkCompat::Warn { .. }
270        ));
271        assert!(matches!(
272            check_plugin_sdk(PluginSdkVersion::new(1, 2, 0), core),
273            SdkCompat::Error(_)
274        ));
275        assert!(matches!(
276            check_plugin_sdk(PluginSdkVersion::new(0, 9, 0), core),
277            SdkCompat::Error(_)
278        ));
279    }
280}