Skip to main content

lean_ctx/core/plugins/
manifest.rs

1use 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}