taiga_plugin_api/
lib.rs

1//! Taiga Plugin API
2//!
3//! This crate provides the core types and traits needed to create plugins for Taiga.
4//!
5//! # Creating a Plugin
6//!
7//! 1. Create a new crate with `crate-type = ["cdylib"]`
8//! 2. Implement the `Plugin` trait
9//! 3. Export the plugin using `export_plugin!` macro
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use taiga_plugin_api::{Plugin, PluginContext, CommandDef, CommandResult, export_plugin};
15//!
16//! pub struct MyPlugin;
17//!
18//! impl Plugin for MyPlugin {
19//!     fn name(&self) -> &str { "my-plugin" }
20//!     fn version(&self) -> &str { "0.1.0" }
21//!     fn description(&self) -> &str { "My awesome plugin" }
22//!
23//!     fn commands(&self) -> Vec<CommandDef> {
24//!         vec![CommandDef::new("greet", "Says hello")]
25//!     }
26//!
27//!     fn execute(&self, cmd: &str, args: &[String], _ctx: &mut PluginContext) -> PluginResult<CommandResult> {
28//!         match cmd {
29//!             "greet" => Ok(CommandResult::Success(Some("Hello!".into()))),
30//!             _ => Ok(CommandResult::Error(format!("Unknown command: {}", cmd))),
31//!         }
32//!     }
33//! }
34//!
35//! export_plugin!(MyPlugin);
36//! ```
37
38pub mod daemon;
39
40use thiserror::Error;
41
42/// Plugin-specific errors
43#[derive(Error, Debug)]
44pub enum PluginError {
45    #[error("Command failed: {0}")]
46    CommandFailed(String),
47
48    #[error("Invalid argument '{arg}': {message}")]
49    InvalidArg { arg: String, message: String },
50
51    #[error("Argument '{arg}' out of range: {value} (expected {min}-{max})")]
52    ArgOutOfRange {
53        arg: String,
54        value: i64,
55        min: i64,
56        max: i64,
57    },
58
59    #[error("IPC connection error: {message}")]
60    IpcConnection {
61        message: String,
62        #[source]
63        source: Option<Box<dyn std::error::Error + Send + Sync>>,
64    },
65
66    #[error("Daemon not running and could not be started")]
67    DaemonNotRunning {
68        #[source]
69        source: Option<Box<dyn std::error::Error + Send + Sync>>,
70    },
71
72    #[error("IO error: {0}")]
73    Io(#[from] std::io::Error),
74
75    #[error("JSON error: {0}")]
76    Json(#[from] serde_json::Error),
77
78    #[error("Plugin error: {0}")]
79    Other(String),
80}
81
82impl PluginError {
83    /// Create an invalid argument error
84    pub fn invalid_arg(arg: impl Into<String>, message: impl Into<String>) -> Self {
85        Self::InvalidArg {
86            arg: arg.into(),
87            message: message.into(),
88        }
89    }
90
91    /// Create an argument out of range error
92    pub fn arg_out_of_range(arg: impl Into<String>, value: i64, min: i64, max: i64) -> Self {
93        Self::ArgOutOfRange {
94            arg: arg.into(),
95            value,
96            min,
97            max,
98        }
99    }
100
101    /// Create an IPC connection error
102    pub fn ipc_connection(message: impl Into<String>) -> Self {
103        Self::IpcConnection {
104            message: message.into(),
105            source: None,
106        }
107    }
108
109    /// Create an IPC connection error with source
110    pub fn ipc_connection_with_source(
111        message: impl Into<String>,
112        source: impl std::error::Error + Send + Sync + 'static,
113    ) -> Self {
114        Self::IpcConnection {
115            message: message.into(),
116            source: Some(Box::new(source)),
117        }
118    }
119
120    /// Create a daemon not running error
121    pub fn daemon_not_running() -> Self {
122        Self::DaemonNotRunning { source: None }
123    }
124
125    /// Create a daemon not running error with source
126    pub fn daemon_not_running_with_source(
127        source: impl std::error::Error + Send + Sync + 'static,
128    ) -> Self {
129        Self::DaemonNotRunning {
130            source: Some(Box::new(source)),
131        }
132    }
133}
134
135/// Result type for plugin operations
136pub type PluginResult<T> = Result<T, PluginError>;
137
138/// Metadata about a plugin command
139#[derive(Debug, Clone)]
140pub struct CommandDef {
141    /// Command name (e.g., "start", "status")
142    pub name: String,
143    /// Short description shown in help
144    pub description: String,
145    /// Usage string (e.g., "<FOCUS> <BREAK> <CYCLES>")
146    pub usage: Option<String>,
147    /// Argument definitions for help text
148    pub args: Vec<ArgDef>,
149}
150
151impl CommandDef {
152    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
153        Self {
154            name: name.into(),
155            description: description.into(),
156            usage: None,
157            args: Vec::new(),
158        }
159    }
160
161    pub fn with_usage(mut self, usage: impl Into<String>) -> Self {
162        self.usage = Some(usage.into());
163        self
164    }
165
166    pub fn with_arg(mut self, arg: ArgDef) -> Self {
167        self.args.push(arg);
168        self
169    }
170}
171
172/// Definition of a command argument
173#[derive(Debug, Clone)]
174pub struct ArgDef {
175    pub name: String,
176    pub description: String,
177    pub required: bool,
178}
179
180impl ArgDef {
181    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
182        Self {
183            name: name.into(),
184            description: description.into(),
185            required: true,
186        }
187    }
188
189    pub fn optional(mut self) -> Self {
190        self.required = false;
191        self
192    }
193}
194
195/// Context passed to plugins during execution
196/// Provides access to shared resources
197#[derive(Debug)]
198pub struct PluginContext {
199    /// Path to the data directory
200    pub data_dir: std::path::PathBuf,
201    /// Additional context data (plugin-specific)
202    pub extra: std::collections::HashMap<String, String>,
203    /// Plugin configuration as JSON string (deserialized by plugin)
204    pub config_json: Option<String>,
205}
206
207impl PluginContext {
208    pub fn new(data_dir: std::path::PathBuf) -> Self {
209        Self {
210            data_dir,
211            extra: std::collections::HashMap::new(),
212            config_json: None,
213        }
214    }
215
216    pub fn with_extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
217        self.extra.insert(key.into(), value.into());
218        self
219    }
220
221    pub fn with_config(mut self, config_json: impl Into<String>) -> Self {
222        self.config_json = Some(config_json.into());
223        self
224    }
225
226    /// Deserialize configuration from JSON
227    /// Returns default if no config is set or parsing fails
228    pub fn get_config<T: serde::de::DeserializeOwned + Default>(&self) -> T {
229        self.config_json
230            .as_ref()
231            .and_then(|json| serde_json::from_str(json).ok())
232            .unwrap_or_default()
233    }
234
235    /// Try to deserialize configuration, returning an error on failure
236    pub fn try_get_config<T: serde::de::DeserializeOwned>(&self) -> PluginResult<Option<T>> {
237        match &self.config_json {
238            Some(json) => Ok(Some(serde_json::from_str(json)?)),
239            None => Ok(None),
240        }
241    }
242}
243
244/// Result of a plugin command execution
245#[derive(Debug)]
246pub enum CommandResult {
247    /// Command completed successfully with optional message
248    Success(Option<String>),
249    /// Command failed with error message
250    Error(String),
251    /// Command started async work (message describes what's happening)
252    Async(String),
253}
254
255/// The main Plugin trait that all plugins must implement
256pub trait Plugin: Send + Sync {
257    /// Returns the plugin's unique name (used as CLI subcommand)
258    fn name(&self) -> &str;
259
260    /// Returns the plugin's version string
261    fn version(&self) -> &str;
262
263    /// Returns a short description of the plugin
264    fn description(&self) -> &str;
265
266    /// Returns the list of commands this plugin provides
267    fn commands(&self) -> Vec<CommandDef>;
268
269    /// Execute a command with the given arguments
270    fn execute(
271        &self,
272        command: &str,
273        args: &[String],
274        ctx: &mut PluginContext,
275    ) -> PluginResult<CommandResult>;
276
277    /// Called when the plugin is loaded (optional initialization)
278    fn on_load(&self) -> PluginResult<()> {
279        Ok(())
280    }
281
282    /// Called when the plugin is unloaded (optional cleanup)
283    fn on_unload(&self) -> PluginResult<()> {
284        Ok(())
285    }
286}
287
288/// Trait for async plugin operations
289#[async_trait::async_trait]
290pub trait AsyncPlugin: Plugin {
291    /// Execute a command asynchronously
292    async fn execute_async(
293        &self,
294        command: &str,
295        args: &[String],
296        ctx: &mut PluginContext,
297    ) -> PluginResult<CommandResult>;
298}
299
300/// Raw plugin data for FFI - contains pointer and vtable as separate values
301#[repr(C)]
302pub struct RawPlugin {
303    pub data: *mut (),
304    pub vtable: *const (),
305}
306
307// Safety: RawPlugin is just pointers, the actual safety is managed by the Plugin trait bounds
308unsafe impl Send for RawPlugin {}
309unsafe impl Sync for RawPlugin {}
310
311impl RawPlugin {
312    /// Create a RawPlugin from a boxed trait object
313    ///
314    /// # Safety
315    /// The returned RawPlugin must be converted back using `into_boxed()`
316    pub fn from_boxed(plugin: Box<dyn Plugin>) -> Self {
317        let raw: *mut dyn Plugin = Box::into_raw(plugin);
318        unsafe {
319            let parts: (*mut (), *const ()) = std::mem::transmute(raw);
320            Self {
321                data: parts.0,
322                vtable: parts.1,
323            }
324        }
325    }
326
327    /// Convert back to a boxed trait object
328    ///
329    /// # Safety
330    /// Must only be called once with a RawPlugin from `from_boxed()`
331    pub unsafe fn into_boxed(self) -> Box<dyn Plugin> {
332        unsafe {
333            let raw: *mut dyn Plugin = std::mem::transmute((self.data, self.vtable));
334            Box::from_raw(raw)
335        }
336    }
337
338    /// Check if the plugin pointer is null
339    pub fn is_null(&self) -> bool {
340        self.data.is_null()
341    }
342}
343
344/// Plugin entry point function type
345pub type PluginCreateFn = unsafe extern "C" fn() -> RawPlugin;
346
347/// Plugin destruction function type
348pub type PluginDestroyFn = unsafe extern "C" fn(RawPlugin);
349
350/// Macro to export a plugin from a cdylib crate
351///
352/// # Example
353/// ```rust,ignore
354/// use taiga_plugin_api::{Plugin, export_plugin};
355///
356/// struct MyPlugin;
357/// impl Plugin for MyPlugin { /* ... */ }
358///
359/// export_plugin!(MyPlugin);
360/// ```
361#[macro_export]
362macro_rules! export_plugin {
363    ($plugin_type:ty) => {
364        #[unsafe(no_mangle)]
365        pub extern "C" fn taiga_plugin_create() -> $crate::RawPlugin {
366            let plugin: Box<dyn $crate::Plugin> = Box::new(<$plugin_type>::new());
367            $crate::RawPlugin::from_boxed(plugin)
368        }
369
370        #[unsafe(no_mangle)]
371        pub extern "C" fn taiga_plugin_destroy(plugin: $crate::RawPlugin) {
372            unsafe {
373                let _ = plugin.into_boxed();
374                // Box is dropped here, calling destructor
375            }
376        }
377    };
378}
379
380/// Plugin metadata for discovery
381#[derive(Debug, Clone)]
382pub struct PluginInfo {
383    pub name: String,
384    pub version: String,
385    pub description: String,
386    pub commands: Vec<CommandDef>,
387}
388
389impl PluginInfo {
390    pub fn from_plugin(plugin: &dyn Plugin) -> Self {
391        Self {
392            name: plugin.name().to_string(),
393            version: plugin.version().to_string(),
394            description: plugin.description().to_string(),
395            commands: plugin.commands(),
396        }
397    }
398}