Skip to main content

scarab_plugin_api/
plugin.rs

1//! Core plugin trait and metadata definitions
2
3use crate::{
4    context::PluginContext,
5    error::Result,
6    menu::MenuItem,
7    types::{Action, ModalItem},
8};
9use async_trait::async_trait;
10
11/// Main plugin trait that all plugins must implement
12///
13/// Plugins can hook into various events in the terminal lifecycle.
14/// All methods have default implementations that do nothing.
15#[async_trait]
16pub trait Plugin: Send + Sync {
17    /// Get plugin metadata
18    fn metadata(&self) -> &PluginMetadata;
19
20    /// Get the menu items for this plugin's dock menu
21    ///
22    /// This defines the menu that appears when the plugin is activated from the dock.
23    /// Return an empty Vec if the plugin has no menu.
24    ///
25    /// # Example
26    ///
27    /// ```rust,ignore
28    /// use scarab_plugin_api::menu::{MenuItem, MenuAction};
29    ///
30    /// fn get_menu(&self) -> Vec<MenuItem> {
31    ///     vec![
32    ///         MenuItem::new("Chat", MenuAction::remote("open_chat"))
33    ///             .with_icon("💬"),
34    ///         MenuItem::new("Settings", MenuAction::remote("settings"))
35    ///             .with_icon("⚙️"),
36    ///     ]
37    /// }
38    /// ```
39    fn get_menu(&self) -> Vec<MenuItem> {
40        Vec::new()
41    }
42
43    /// Get list of commands provided by this plugin
44    fn get_commands(&self) -> Vec<ModalItem> {
45        Vec::new()
46    }
47
48    /// Called when the plugin is loaded
49    ///
50    /// This is where plugins should initialize their state and resources.
51    async fn on_load(&mut self, _ctx: &mut PluginContext) -> Result<()> {
52        Ok(())
53    }
54
55    /// Called when the plugin is being unloaded
56    ///
57    /// Plugins should clean up resources here.
58    async fn on_unload(&mut self) -> Result<()> {
59        Ok(())
60    }
61
62    /// Hook called before output is displayed to the terminal
63    ///
64    /// Plugins can modify, block, or pass through the output.
65    async fn on_output(&mut self, _line: &str, _ctx: &PluginContext) -> Result<Action> {
66        Ok(Action::Continue)
67    }
68
69    /// Hook called after input is received from the user
70    ///
71    /// Plugins can intercept and modify input before it reaches the PTY.
72    async fn on_input(&mut self, _input: &[u8], _ctx: &PluginContext) -> Result<Action> {
73        Ok(Action::Continue)
74    }
75
76    /// Hook called before a command is executed
77    async fn on_pre_command(&mut self, _command: &str, _ctx: &PluginContext) -> Result<Action> {
78        Ok(Action::Continue)
79    }
80
81    /// Hook called after a command completes
82    async fn on_post_command(
83        &mut self,
84        _command: &str,
85        _exit_code: i32,
86        _ctx: &PluginContext,
87    ) -> Result<()> {
88        Ok(())
89    }
90
91    /// Hook called when terminal is resized
92    async fn on_resize(&mut self, _cols: u16, _rows: u16, _ctx: &PluginContext) -> Result<()> {
93        Ok(())
94    }
95
96    /// Hook called when a client attaches to the session
97    async fn on_attach(&mut self, _client_id: u64, _ctx: &PluginContext) -> Result<()> {
98        Ok(())
99    }
100
101    /// Hook called when a client detaches from the session
102    async fn on_detach(&mut self, _client_id: u64, _ctx: &PluginContext) -> Result<()> {
103        Ok(())
104    }
105
106    /// Hook called when a remote command is selected/triggered by the client
107    ///
108    /// This is called when a user selects a menu item with `MenuAction::Remote(id)`.
109    async fn on_remote_command(&mut self, _id: &str, _ctx: &PluginContext) -> Result<()> {
110        Ok(())
111    }
112}
113
114/// Plugin metadata with personality
115#[derive(Debug, Clone)]
116pub struct PluginMetadata {
117    /// Plugin name (must be unique)
118    pub name: String,
119    /// Plugin version (semver)
120    pub version: String,
121    /// Short description
122    pub description: String,
123    /// Author name
124    pub author: String,
125    /// Homepage URL
126    pub homepage: Option<String>,
127    /// API version this plugin was built against
128    pub api_version: String,
129    /// Minimum Scarab version required
130    pub min_scarab_version: String,
131    /// Plugin emoji/icon for visual identification
132    pub emoji: Option<String>,
133    /// Plugin theme color (hex code)
134    pub color: Option<String>,
135    /// Plugin catchphrase or motto
136    pub catchphrase: Option<String>,
137}
138
139impl PluginMetadata {
140    /// Create new plugin metadata
141    pub fn new(
142        name: impl Into<String>,
143        version: impl Into<String>,
144        description: impl Into<String>,
145        author: impl Into<String>,
146    ) -> Self {
147        Self {
148            name: name.into(),
149            version: version.into(),
150            description: description.into(),
151            author: author.into(),
152            homepage: None,
153            api_version: crate::API_VERSION.to_string(),
154            min_scarab_version: "0.1.0".to_string(),
155            emoji: None,
156            color: None,
157            catchphrase: None,
158        }
159    }
160
161    /// Set homepage URL
162    pub fn with_homepage(mut self, homepage: impl Into<String>) -> Self {
163        self.homepage = Some(homepage.into());
164        self
165    }
166
167    /// Set API version
168    pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
169        self.api_version = version.into();
170        self
171    }
172
173    /// Set minimum Scarab version
174    pub fn with_min_scarab_version(mut self, version: impl Into<String>) -> Self {
175        self.min_scarab_version = version.into();
176        self
177    }
178
179    /// Set plugin emoji for visual flair
180    pub fn with_emoji(mut self, emoji: impl Into<String>) -> Self {
181        self.emoji = Some(emoji.into());
182        self
183    }
184
185    /// Set plugin theme color (hex code like "#FF5733")
186    pub fn with_color(mut self, color: impl Into<String>) -> Self {
187        self.color = Some(color.into());
188        self
189    }
190
191    /// Set plugin catchphrase or motto
192    pub fn with_catchphrase(mut self, catchphrase: impl Into<String>) -> Self {
193        self.catchphrase = Some(catchphrase.into());
194        self
195    }
196
197    /// Get display name with emoji if available
198    pub fn display_name(&self) -> String {
199        if let Some(emoji) = &self.emoji {
200            format!("{} {}", emoji, self.name)
201        } else {
202            self.name.clone()
203        }
204    }
205
206    /// Check if this plugin is compatible with the current API version
207    pub fn is_compatible(&self, current_api_version: &str) -> bool {
208        use semver::Version;
209
210        let Ok(plugin_version) = Version::parse(&self.api_version) else {
211            return false;
212        };
213
214        let Ok(current_version) = Version::parse(current_api_version) else {
215            return false;
216        };
217
218        // Compatible if major versions match and plugin minor <= current minor
219        plugin_version.major == current_version.major
220            && plugin_version.minor <= current_version.minor
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_version_compatibility() {
230        let meta = PluginMetadata::new("test", "1.0.0", "Test plugin", "Test Author")
231            .with_api_version("0.1.0");
232
233        assert!(meta.is_compatible("0.1.0"));
234        assert!(meta.is_compatible("0.2.0"));
235        assert!(!meta.is_compatible("1.0.0"));
236        assert!(!meta.is_compatible("0.0.1"));
237    }
238
239    #[test]
240    fn test_display_name_with_emoji() {
241        let meta =
242            PluginMetadata::new("awesome-plugin", "1.0.0", "Cool plugin", "Dev").with_emoji("🚀");
243
244        assert_eq!(meta.display_name(), "🚀 awesome-plugin");
245    }
246
247    #[test]
248    fn test_display_name_without_emoji() {
249        let meta = PluginMetadata::new("plain-plugin", "1.0.0", "Plain plugin", "Dev");
250
251        assert_eq!(meta.display_name(), "plain-plugin");
252    }
253}