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}