Skip to main content

sova_core/
plugin.rs

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