1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
19pub struct PluginId(String);
20
21impl<'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 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 #[must_use]
47 pub fn from_static(id: &str) -> Self {
48 Self(id.to_string())
49 }
50
51 #[must_use]
53 pub fn as_str(&self) -> &str {
54 &self.0
55 }
56
57 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#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum PluginState {
96 Unloaded,
98 Loading,
100 Ready,
102 Failed(String),
104 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#[async_trait]
122pub trait Plugin: Send + Sync {
123 fn id(&self) -> &PluginId;
125
126 fn manifest(&self) -> &PluginManifest;
128
129 fn state(&self) -> PluginState;
131
132 async fn load(&mut self, ctx: &PluginContext) -> PluginResult<()>;
137
138 async fn unload(&mut self) -> PluginResult<()>;
143
144 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 assert!(PluginId::new("").is_err());
166 assert!(PluginId::new("MyPlugin").is_err());
168 assert!(PluginId::new("my plugin").is_err());
170 assert!(PluginId::new("my_plugin").is_err());
172 assert!(PluginId::new("-plugin").is_err());
174 assert!(PluginId::new("plugin-").is_err());
176 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 for state in &states {
216 let _ = format!("{state:?}");
217 }
218 }
219}