ipfrs_cli/
plugin.rs

1//! Plugin system for IPFRS CLI
2//!
3//! This module provides a plugin system that allows users to extend the IPFRS CLI
4//! with custom commands. Plugins can be written in any language and interfaced
5//! via a simple executable-based protocol.
6//!
7//! # Plugin Discovery
8//!
9//! Plugins are discovered in the following locations (in order):
10//! 1. `~/.ipfrs/plugins/` - User plugins
11//! 2. `/usr/local/lib/ipfrs/plugins/` - System-wide plugins (Unix)
12//! 3. `$IPFRS_PLUGIN_PATH` - Custom plugin directories (colon-separated)
13//!
14//! # Plugin Protocol
15//!
16//! Plugins are executables that follow this naming convention:
17//! - `ipfrs-plugin-<name>` for the executable
18//!
19//! When invoked, plugins receive:
20//! - Arguments passed after the plugin name
21//! - Environment variables:
22//!   - `IPFRS_API_URL` - Daemon API endpoint
23//!   - `IPFRS_DATA_DIR` - Repository data directory
24//!   - `IPFRS_CONFIG` - Config file path
25//!
26//! # Examples
27//!
28//! ```rust
29//! use ipfrs_cli::plugin::PluginManager;
30//!
31//! // Discover all available plugins
32//! let mut manager = PluginManager::new();
33//! let plugins = manager.discover_plugins();
34//!
35//! for plugin in plugins {
36//!     println!("Found plugin: {}", plugin.name());
37//! }
38//! ```
39//!
40//! ## Creating a Plugin
41//!
42//! Create an executable named `ipfrs-plugin-hello`:
43//!
44//! ```bash
45//! #!/bin/bash
46//! # ipfrs-plugin-hello
47//! echo "Hello from plugin!"
48//! echo "API URL: $IPFRS_API_URL"
49//! ```
50//!
51//! Make it executable and place in `~/.ipfrs/plugins/`:
52//!
53//! ```bash
54//! chmod +x ipfrs-plugin-hello
55//! mv ipfrs-plugin-hello ~/.ipfrs/plugins/
56//! ```
57//!
58//! Then use it:
59//!
60//! ```bash
61//! ipfrs plugin hello
62//! ```
63
64use anyhow::{Context, Result};
65use std::collections::HashMap;
66use std::env;
67use std::path::{Path, PathBuf};
68use std::process::{Command, Stdio};
69
70/// Plugin manager for discovering and executing plugins
71pub struct PluginManager {
72    plugin_paths: Vec<PathBuf>,
73    plugins: HashMap<String, Plugin>,
74}
75
76/// Represents a single plugin
77#[derive(Debug, Clone)]
78pub struct Plugin {
79    name: String,
80    path: PathBuf,
81    description: Option<String>,
82}
83
84impl Plugin {
85    /// Create a new plugin
86    pub fn new(name: String, path: PathBuf) -> Self {
87        Self {
88            name,
89            path,
90            description: None,
91        }
92    }
93
94    /// Get the plugin name
95    pub fn name(&self) -> &str {
96        &self.name
97    }
98
99    /// Get the plugin executable path
100    pub fn path(&self) -> &Path {
101        &self.path
102    }
103
104    /// Get the plugin description (if available)
105    pub fn description(&self) -> Option<&str> {
106        self.description.as_deref()
107    }
108
109    /// Set the plugin description
110    pub fn with_description(mut self, desc: String) -> Self {
111        self.description = Some(desc);
112        self
113    }
114
115    /// Execute the plugin with the given arguments
116    pub fn execute(&self, args: &[String], config: &crate::config::Config) -> Result<i32> {
117        let mut cmd = Command::new(&self.path);
118        cmd.args(args);
119
120        // Set environment variables for plugin
121        cmd.env("IPFRS_DATA_DIR", &config.general.data_dir);
122        cmd.env("IPFRS_LOG_LEVEL", &config.general.log_level);
123
124        if let Some(api_url) = &config.api.remote_url {
125            cmd.env("IPFRS_API_URL", api_url);
126        }
127
128        if let Some(api_token) = &config.api.api_token {
129            cmd.env("IPFRS_API_TOKEN", api_token);
130        }
131
132        // Inherit stdio so plugin output goes directly to terminal
133        cmd.stdin(Stdio::inherit())
134            .stdout(Stdio::inherit())
135            .stderr(Stdio::inherit());
136
137        let status = cmd
138            .status()
139            .with_context(|| format!("Failed to execute plugin '{}'", self.name))?;
140
141        Ok(status.code().unwrap_or(1))
142    }
143
144    /// Query plugin metadata (description, version, etc.)
145    /// Plugins should support `--plugin-info` flag to return JSON metadata
146    pub fn query_metadata(&mut self) -> Result<()> {
147        let output = Command::new(&self.path).arg("--plugin-info").output();
148
149        if let Ok(output) = output {
150            if output.status.success() {
151                if let Ok(info) = serde_json::from_slice::<HashMap<String, String>>(&output.stdout)
152                {
153                    if let Some(desc) = info.get("description") {
154                        self.description = Some(desc.clone());
155                    }
156                }
157            }
158        }
159
160        Ok(())
161    }
162}
163
164impl PluginManager {
165    /// Create a new plugin manager
166    pub fn new() -> Self {
167        let mut plugin_paths = Vec::new();
168
169        // Add user plugin directory
170        if let Some(home) = dirs::home_dir() {
171            plugin_paths.push(home.join(".ipfrs").join("plugins"));
172        }
173
174        // Add system plugin directory (Unix)
175        #[cfg(unix)]
176        {
177            plugin_paths.push(PathBuf::from("/usr/local/lib/ipfrs/plugins"));
178        }
179
180        // Add custom plugin path from environment
181        if let Ok(custom_paths) = env::var("IPFRS_PLUGIN_PATH") {
182            for path in custom_paths.split(':') {
183                if !path.is_empty() {
184                    plugin_paths.push(PathBuf::from(path));
185                }
186            }
187        }
188
189        Self {
190            plugin_paths,
191            plugins: HashMap::new(),
192        }
193    }
194
195    /// Add a custom plugin search path
196    pub fn add_plugin_path(&mut self, path: PathBuf) {
197        if !self.plugin_paths.contains(&path) {
198            self.plugin_paths.push(path);
199        }
200    }
201
202    /// Discover all available plugins in the plugin paths
203    pub fn discover_plugins(&mut self) -> Vec<&Plugin> {
204        self.plugins.clear();
205
206        for plugin_dir in &self.plugin_paths {
207            if !plugin_dir.exists() {
208                continue;
209            }
210
211            if let Ok(entries) = std::fs::read_dir(plugin_dir) {
212                for entry in entries.flatten() {
213                    let path = entry.path();
214
215                    // Check if it's an executable file
216                    if !path.is_file() {
217                        continue;
218                    }
219
220                    #[cfg(unix)]
221                    {
222                        use std::os::unix::fs::PermissionsExt;
223                        if let Ok(metadata) = path.metadata() {
224                            let permissions = metadata.permissions();
225                            // Check if executable bit is set
226                            if permissions.mode() & 0o111 == 0 {
227                                continue;
228                            }
229                        }
230                    }
231
232                    // Extract plugin name from filename
233                    if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
234                        if let Some(plugin_name) = filename.strip_prefix("ipfrs-plugin-") {
235                            let mut plugin = Plugin::new(plugin_name.to_string(), path.clone());
236
237                            // Try to query metadata
238                            let _ = plugin.query_metadata();
239
240                            self.plugins.insert(plugin_name.to_string(), plugin);
241                        }
242                    }
243                }
244            }
245        }
246
247        self.plugins.values().collect()
248    }
249
250    /// Get a plugin by name
251    pub fn get_plugin(&self, name: &str) -> Option<&Plugin> {
252        self.plugins.get(name)
253    }
254
255    /// List all discovered plugin names
256    pub fn list_plugins(&self) -> Vec<&str> {
257        self.plugins.keys().map(|s| s.as_str()).collect()
258    }
259
260    /// Execute a plugin with the given arguments
261    pub fn execute_plugin(
262        &self,
263        name: &str,
264        args: &[String],
265        config: &crate::config::Config,
266    ) -> Result<i32> {
267        let plugin = self
268            .get_plugin(name)
269            .with_context(|| format!("Plugin '{}' not found", name))?;
270
271        plugin.execute(args, config)
272    }
273}
274
275impl Default for PluginManager {
276    fn default() -> Self {
277        Self::new()
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_plugin_creation() {
287        let plugin = Plugin::new(
288            "test".to_string(),
289            PathBuf::from("/usr/local/lib/ipfrs/plugins/ipfrs-plugin-test"),
290        );
291
292        assert_eq!(plugin.name(), "test");
293        assert_eq!(
294            plugin.path(),
295            Path::new("/usr/local/lib/ipfrs/plugins/ipfrs-plugin-test")
296        );
297        assert!(plugin.description().is_none());
298    }
299
300    #[test]
301    fn test_plugin_with_description() {
302        let plugin = Plugin::new("test".to_string(), PathBuf::from("/tmp/plugin"))
303            .with_description("A test plugin".to_string());
304
305        assert_eq!(plugin.description(), Some("A test plugin"));
306    }
307
308    #[test]
309    fn test_plugin_manager_creation() {
310        let manager = PluginManager::new();
311        assert!(!manager.plugin_paths.is_empty());
312    }
313
314    #[test]
315    fn test_add_plugin_path() {
316        let mut manager = PluginManager::new();
317        let custom_path = PathBuf::from("/custom/plugins");
318
319        manager.add_plugin_path(custom_path.clone());
320        assert!(manager.plugin_paths.contains(&custom_path));
321
322        // Adding duplicate should not duplicate
323        let initial_count = manager.plugin_paths.len();
324        manager.add_plugin_path(custom_path.clone());
325        assert_eq!(manager.plugin_paths.len(), initial_count);
326    }
327
328    #[test]
329    fn test_plugin_manager_default() {
330        let manager = PluginManager::default();
331        assert!(!manager.plugin_paths.is_empty());
332    }
333
334    #[test]
335    fn test_list_plugins_empty() {
336        let manager = PluginManager::new();
337        assert_eq!(manager.list_plugins().len(), 0);
338    }
339
340    #[test]
341    fn test_get_plugin_not_found() {
342        let manager = PluginManager::new();
343        assert!(manager.get_plugin("nonexistent").is_none());
344    }
345}