lean_ctx/core/plugins/
manifest.rs1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::Path;
4
5#[derive(Debug, Clone, Deserialize)]
6pub struct PluginManifest {
7 pub plugin: PluginMeta,
8 #[serde(default)]
9 pub hooks: HashMap<String, HookEntry>,
10}
11
12#[derive(Debug, Clone, Deserialize)]
13pub struct PluginMeta {
14 pub name: String,
15 pub version: String,
16 #[serde(default)]
17 pub description: String,
18 #[serde(default)]
19 pub author: String,
20}
21
22#[derive(Debug, Clone, Deserialize)]
23pub struct HookEntry {
24 pub command: String,
25 #[serde(default = "default_timeout_ms")]
26 pub timeout_ms: u64,
27}
28
29fn default_timeout_ms() -> u64 {
30 5000
31}
32
33impl PluginManifest {
34 pub fn from_file(path: &Path) -> Result<Self, ManifestError> {
35 let content = std::fs::read_to_string(path).map_err(|e| ManifestError::Io {
36 path: path.to_path_buf(),
37 source: e,
38 })?;
39 Self::from_str(&content, path)
40 }
41
42 pub fn from_str(content: &str, path: &Path) -> Result<Self, ManifestError> {
43 let manifest: Self = toml::from_str(content).map_err(|e| ManifestError::Parse {
44 path: path.to_path_buf(),
45 source: e,
46 })?;
47 manifest.validate(path)?;
48 Ok(manifest)
49 }
50
51 fn validate(&self, path: &Path) -> Result<(), ManifestError> {
52 if self.plugin.name.is_empty() {
53 return Err(ManifestError::Validation {
54 path: path.to_path_buf(),
55 field: "plugin.name".to_string(),
56 reason: "must not be empty".to_string(),
57 });
58 }
59 if self.plugin.version.is_empty() {
60 return Err(ManifestError::Validation {
61 path: path.to_path_buf(),
62 field: "plugin.version".to_string(),
63 reason: "must not be empty".to_string(),
64 });
65 }
66 for (hook_name, entry) in &self.hooks {
67 if entry.command.is_empty() {
68 return Err(ManifestError::Validation {
69 path: path.to_path_buf(),
70 field: format!("hooks.{hook_name}.command"),
71 reason: "must not be empty".to_string(),
72 });
73 }
74 }
75 Ok(())
76 }
77}
78
79#[derive(Debug, thiserror::Error)]
80pub enum ManifestError {
81 #[error("failed to read plugin manifest at {path}: {source}")]
82 Io {
83 path: std::path::PathBuf,
84 source: std::io::Error,
85 },
86 #[error("failed to parse plugin manifest at {path}: {source}")]
87 Parse {
88 path: std::path::PathBuf,
89 source: toml::de::Error,
90 },
91 #[error("invalid plugin manifest at {path}: {field} {reason}")]
92 Validation {
93 path: std::path::PathBuf,
94 field: String,
95 reason: String,
96 },
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use std::path::PathBuf;
103
104 #[test]
105 fn parse_valid_manifest() {
106 let toml = r#"
107[plugin]
108name = "test-plugin"
109version = "0.1.0"
110description = "A test plugin"
111author = "Test Author"
112
113[hooks.on_session_start]
114command = "test-binary start"
115timeout_ms = 3000
116
117[hooks.pre_read]
118command = "test-binary pre-read"
119"#;
120 let manifest = PluginManifest::from_str(toml, &PathBuf::from("test.toml")).unwrap();
121 assert_eq!(manifest.plugin.name, "test-plugin");
122 assert_eq!(manifest.plugin.version, "0.1.0");
123 assert_eq!(manifest.hooks.len(), 2);
124 assert_eq!(manifest.hooks["on_session_start"].timeout_ms, 3000);
125 assert_eq!(manifest.hooks["pre_read"].timeout_ms, 5000);
126 }
127
128 #[test]
129 fn reject_empty_name() {
130 let toml = r#"
131[plugin]
132name = ""
133version = "0.1.0"
134"#;
135 let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
136 assert!(err.to_string().contains("plugin.name"));
137 }
138
139 #[test]
140 fn reject_empty_version() {
141 let toml = r#"
142[plugin]
143name = "test"
144version = ""
145"#;
146 let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
147 assert!(err.to_string().contains("plugin.version"));
148 }
149
150 #[test]
151 fn reject_empty_command() {
152 let toml = r#"
153[plugin]
154name = "test"
155version = "0.1.0"
156
157[hooks.pre_read]
158command = ""
159"#;
160 let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
161 assert!(err.to_string().contains("hooks.pre_read.command"));
162 }
163
164 #[test]
165 fn minimal_manifest_no_hooks() {
166 let toml = r#"
167[plugin]
168name = "minimal"
169version = "1.0.0"
170"#;
171 let manifest = PluginManifest::from_str(toml, &PathBuf::from("minimal.toml")).unwrap();
172 assert_eq!(manifest.plugin.name, "minimal");
173 assert!(manifest.hooks.is_empty());
174 }
175
176 #[test]
177 fn default_timeout_applied() {
178 let toml = r#"
179[plugin]
180name = "defaults"
181version = "0.1.0"
182
183[hooks.on_session_end]
184command = "plugin-bin stop"
185"#;
186 let manifest = PluginManifest::from_str(toml, &PathBuf::from("test.toml")).unwrap();
187 assert_eq!(manifest.hooks["on_session_end"].timeout_ms, 5000);
188 }
189}