Skip to main content

astrid_plugins/
plugin.rs

1//! Plugin trait and core types.
2
3use std::fmt;
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7
8use crate::context::PluginContext;
9use crate::error::PluginResult;
10use crate::manifest::PluginManifest;
11use crate::tool::PluginTool;
12
13/// Unique, stable, human-readable plugin identifier.
14///
15/// Plugin IDs are strings like `"my-cool-plugin"` or `"openclaw-git-tools"`.
16/// They must be non-empty and contain only lowercase alphanumeric characters
17/// and hyphens.
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
19pub struct PluginId(String);
20
21/// Deserialize with validation — rejects malformed IDs (e.g. path traversal
22/// payloads in crafted lockfiles).
23impl<'de> Deserialize<'de> for PluginId {
24    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
25    where
26        D: serde::Deserializer<'de>,
27    {
28        let s = String::deserialize(deserializer)?;
29        Self::new(s).map_err(serde::de::Error::custom)
30    }
31}
32
33impl PluginId {
34    /// Create a new `PluginId`, validating the format.
35    ///
36    /// # Errors
37    ///
38    /// Returns an error if the ID is empty or contains invalid characters.
39    pub fn new(id: impl Into<String>) -> PluginResult<Self> {
40        let id = id.into();
41        Self::validate(&id)?;
42        Ok(Self(id))
43    }
44
45    /// Create a `PluginId` without validation (for tests and internal use).
46    #[must_use]
47    pub fn from_static(id: &str) -> Self {
48        Self(id.to_string())
49    }
50
51    /// Get the inner string value.
52    #[must_use]
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56
57    /// Validate that a plugin ID string is well-formed.
58    fn validate(id: &str) -> PluginResult<()> {
59        if id.is_empty() {
60            return Err(crate::error::PluginError::InvalidId(
61                "plugin id must not be empty".into(),
62            ));
63        }
64        if !id
65            .chars()
66            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
67        {
68            return Err(crate::error::PluginError::InvalidId(format!(
69                "plugin id must contain only lowercase alphanumeric characters and hyphens, got: {id}"
70            )));
71        }
72        if id.starts_with('-') || id.ends_with('-') {
73            return Err(crate::error::PluginError::InvalidId(format!(
74                "plugin id must not start or end with a hyphen, got: {id}"
75            )));
76        }
77        Ok(())
78    }
79}
80
81impl fmt::Display for PluginId {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        f.write_str(&self.0)
84    }
85}
86
87impl AsRef<str> for PluginId {
88    fn as_ref(&self) -> &str {
89        &self.0
90    }
91}
92
93/// The lifecycle state of a plugin.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum PluginState {
96    /// Plugin is registered but not yet loaded.
97    Unloaded,
98    /// Plugin is currently loading.
99    Loading,
100    /// Plugin is loaded and ready to serve tools.
101    Ready,
102    /// Plugin failed to load or encountered a fatal error.
103    Failed(String),
104    /// Plugin is shutting down.
105    Unloading,
106}
107
108impl std::fmt::Debug for dyn Plugin {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        f.debug_struct("Plugin")
111            .field("id", self.id())
112            .field("state", &self.state())
113            .finish_non_exhaustive()
114    }
115}
116
117/// A loaded plugin that can provide tools to the runtime.
118///
119/// Implementors handle the plugin lifecycle (load/unload) and expose
120/// tools that the agent can invoke.
121#[async_trait]
122pub trait Plugin: Send + Sync {
123    /// The unique identifier for this plugin.
124    fn id(&self) -> &PluginId;
125
126    /// The manifest that describes this plugin.
127    fn manifest(&self) -> &PluginManifest;
128
129    /// Current lifecycle state.
130    fn state(&self) -> PluginState;
131
132    /// Load the plugin, initializing any resources it needs.
133    ///
134    /// Called once when the plugin is first activated. The plugin should
135    /// transition from `Unloaded` → `Loading` → `Ready` (or `Failed`).
136    async fn load(&mut self, ctx: &PluginContext) -> PluginResult<()>;
137
138    /// Unload the plugin, releasing resources.
139    ///
140    /// Called when the plugin is being deactivated or the runtime is
141    /// shutting down.
142    async fn unload(&mut self) -> PluginResult<()>;
143
144    /// The tools this plugin provides.
145    ///
146    /// Returns an empty slice if the plugin has no tools or is not loaded.
147    fn tools(&self) -> &[Box<dyn PluginTool>];
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_valid_plugin_ids() {
156        assert!(PluginId::new("my-plugin").is_ok());
157        assert!(PluginId::new("openclaw-git-tools").is_ok());
158        assert!(PluginId::new("plugin123").is_ok());
159        assert!(PluginId::new("a").is_ok());
160    }
161
162    #[test]
163    fn test_invalid_plugin_ids() {
164        // Empty
165        assert!(PluginId::new("").is_err());
166        // Uppercase
167        assert!(PluginId::new("MyPlugin").is_err());
168        // Spaces
169        assert!(PluginId::new("my plugin").is_err());
170        // Underscores
171        assert!(PluginId::new("my_plugin").is_err());
172        // Leading hyphen
173        assert!(PluginId::new("-plugin").is_err());
174        // Trailing hyphen
175        assert!(PluginId::new("plugin-").is_err());
176        // Special characters
177        assert!(PluginId::new("plugin@1").is_err());
178    }
179
180    #[test]
181    fn test_plugin_id_display() {
182        let id = PluginId::new("my-plugin").unwrap();
183        assert_eq!(id.to_string(), "my-plugin");
184        assert_eq!(id.as_str(), "my-plugin");
185    }
186
187    #[test]
188    fn test_plugin_id_equality() {
189        let a = PluginId::new("test-plugin").unwrap();
190        let b = PluginId::new("test-plugin").unwrap();
191        let c = PluginId::new("other-plugin").unwrap();
192        assert_eq!(a, b);
193        assert_ne!(a, c);
194    }
195
196    #[test]
197    fn test_plugin_id_serde_round_trip() {
198        let id = PluginId::new("my-plugin").unwrap();
199        let json = serde_json::to_string(&id).unwrap();
200        assert_eq!(json, "\"my-plugin\"");
201        let deserialized: PluginId = serde_json::from_str(&json).unwrap();
202        assert_eq!(deserialized, id);
203    }
204
205    #[test]
206    fn test_plugin_state_variants() {
207        let states = vec![
208            PluginState::Unloaded,
209            PluginState::Loading,
210            PluginState::Ready,
211            PluginState::Failed("timeout".into()),
212            PluginState::Unloading,
213        ];
214        // Ensure Debug works
215        for state in &states {
216            let _ = format!("{state:?}");
217        }
218    }
219}