Skip to main content

max / goingson

9.6 KB · 344 lines History Blame Raw
1 //! Plugin manifest parsing.
2 //!
3 //! Parses plugin.toml manifests into structured types.
4
5 use serde::Deserialize;
6 use std::path::Path;
7
8 use crate::error::{PluginError, Result};
9 use goingson_core::{
10 ExportPluginConfig, ImportPluginConfig, PluginCapabilities, PluginMeta, PluginType,
11 };
12
13 /// Raw manifest as parsed from TOML.
14 #[derive(Debug, Deserialize)]
15 pub struct PluginManifest {
16 pub plugin: PluginSection,
17 }
18
19 #[derive(Debug, Deserialize)]
20 pub struct PluginSection {
21 pub name: String,
22 pub version: String,
23 pub description: String,
24 #[serde(default)]
25 pub author: String,
26 pub min_app_version: Option<String>,
27 #[serde(rename = "type")]
28 pub plugin_type: PluginTypeSection,
29 #[serde(default)]
30 pub import: Option<ImportSection>,
31 #[serde(default)]
32 pub export: Option<ExportSection>,
33 #[serde(default)]
34 pub capabilities: CapabilitiesSection,
35 }
36
37 #[derive(Debug, Deserialize)]
38 pub struct PluginTypeSection {
39 pub kind: String,
40 }
41
42 #[derive(Debug, Deserialize, Default)]
43 pub struct ImportSection {
44 #[serde(default)]
45 pub file_extensions: Vec<String>,
46 #[serde(default)]
47 pub entity_types: Vec<String>,
48 }
49
50 #[derive(Debug, Deserialize, Default)]
51 pub struct ExportSection {
52 #[serde(default)]
53 pub file_extension: String,
54 #[serde(default)]
55 pub entity_types: Vec<String>,
56 }
57
58 #[derive(Debug, Deserialize, Default)]
59 pub struct CapabilitiesSection {
60 #[serde(default)]
61 pub file_read: bool,
62 #[serde(default)]
63 pub database_write: bool,
64 #[serde(default)]
65 pub network: bool,
66 }
67
68 impl PluginManifest {
69 /// Parses a plugin manifest from a TOML file.
70 #[tracing::instrument(skip_all)]
71 pub fn from_file(path: &Path) -> Result<Self> {
72 let content = std::fs::read_to_string(path).map_err(|e| {
73 PluginError::FileError(format!("Failed to read {}: {}", path.display(), e))
74 })?;
75 Self::parse(&content)
76 }
77
78 /// Parses a plugin manifest from a TOML string.
79 #[tracing::instrument(skip_all)]
80 pub fn parse(content: &str) -> Result<Self> {
81 toml::from_str(content)
82 .map_err(|e| PluginError::InvalidManifest(format!("TOML parse error: {}", e)))
83 }
84
85 /// Converts the raw manifest to a PluginMeta with the given ID.
86 #[tracing::instrument(skip_all)]
87 pub fn to_meta(&self, id: String) -> Result<PluginMeta> {
88 let plugin_type = self.parse_plugin_type()?;
89
90 Ok(PluginMeta {
91 id,
92 name: self.plugin.name.clone(),
93 version: self.plugin.version.clone(),
94 description: self.plugin.description.clone(),
95 author: self.plugin.author.clone(),
96 min_app_version: self.plugin.min_app_version.clone(),
97 plugin_type,
98 capabilities: PluginCapabilities {
99 file_read: self.plugin.capabilities.file_read,
100 database_write: self.plugin.capabilities.database_write,
101 network: self.plugin.capabilities.network,
102 },
103 })
104 }
105
106 fn parse_plugin_type(&self) -> Result<PluginType> {
107 match self.plugin.plugin_type.kind.as_str() {
108 "import" => {
109 let import = self.plugin.import.as_ref().ok_or_else(|| {
110 PluginError::InvalidManifest(
111 "Import plugin requires [plugin.import] section".to_string(),
112 )
113 })?;
114 Ok(PluginType::Import(ImportPluginConfig {
115 file_extensions: import.file_extensions.clone(),
116 entity_types: import.entity_types.clone(),
117 }))
118 }
119 "export" => {
120 let export = self.plugin.export.as_ref().ok_or_else(|| {
121 PluginError::InvalidManifest(
122 "Export plugin requires [plugin.export] section".to_string(),
123 )
124 })?;
125 Ok(PluginType::Export(ExportPluginConfig {
126 file_extension: export.file_extension.clone(),
127 entity_types: export.entity_types.clone(),
128 }))
129 }
130 "command" => Ok(PluginType::Command),
131 "hook" => Ok(PluginType::Hook),
132 other => Err(PluginError::InvalidManifest(format!(
133 "Unknown plugin kind: {}",
134 other
135 ))),
136 }
137 }
138 }
139
140 #[cfg(test)]
141 mod tests {
142 use super::*;
143
144 #[test]
145 fn test_parse_import_manifest() {
146 let toml = r#"
147 [plugin]
148 name = "csv-import"
149 version = "1.0.0"
150 description = "Import tasks from CSV files"
151 author = "Community"
152 min_app_version = "0.2.0"
153
154 [plugin.type]
155 kind = "import"
156
157 [plugin.import]
158 file_extensions = ["csv", "tsv"]
159 entity_types = ["task", "project"]
160
161 [plugin.capabilities]
162 file_read = true
163 database_write = true
164 network = false
165 "#;
166
167 let manifest = PluginManifest::parse(toml).unwrap();
168 let meta = manifest.to_meta("csv-import".to_string()).unwrap();
169
170 assert_eq!(meta.name, "csv-import");
171 assert_eq!(meta.version, "1.0.0");
172 assert!(matches!(meta.plugin_type, PluginType::Import(_)));
173
174 if let PluginType::Import(config) = &meta.plugin_type {
175 assert_eq!(config.file_extensions, vec!["csv", "tsv"]);
176 assert_eq!(config.entity_types, vec!["task", "project"]);
177 }
178
179 assert!(meta.capabilities.file_read);
180 assert!(meta.capabilities.database_write);
181 assert!(!meta.capabilities.network);
182 }
183
184 #[test]
185 fn test_parse_export_manifest() {
186 let toml = r#"
187 [plugin]
188 name = "json-export"
189 version = "1.0.0"
190 description = "Export tasks to JSON"
191 author = "Community"
192
193 [plugin.type]
194 kind = "export"
195
196 [plugin.export]
197 file_extension = "json"
198 entity_types = ["task"]
199
200 [plugin.capabilities]
201 file_read = false
202 database_write = false
203 "#;
204
205 let manifest = PluginManifest::parse(toml).unwrap();
206 let meta = manifest.to_meta("json-export".to_string()).unwrap();
207
208 assert!(matches!(meta.plugin_type, PluginType::Export(_)));
209 }
210
211 #[test]
212 fn test_missing_import_section() {
213 let toml = r#"
214 [plugin]
215 name = "bad-plugin"
216 version = "1.0.0"
217 description = "Missing import section"
218
219 [plugin.type]
220 kind = "import"
221
222 [plugin.capabilities]
223 "#;
224
225 let manifest = PluginManifest::parse(toml).unwrap();
226 let result = manifest.to_meta("bad-plugin".to_string());
227 assert!(result.is_err());
228 }
229
230 // ============ Corrupt / Invalid Manifest ============
231
232 #[test]
233 fn corrupt_toml_returns_error_not_panic() {
234 let garbage = "{{{{ not valid toml at all !@#$";
235 let result = PluginManifest::parse(garbage);
236 assert!(result.is_err());
237 match result.unwrap_err() {
238 PluginError::InvalidManifest(msg) => {
239 assert!(msg.contains("TOML parse error"), "Unexpected message: {}", msg);
240 }
241 other => panic!("Expected InvalidManifest, got {:?}", other),
242 }
243 }
244
245 #[test]
246 fn empty_string_manifest_returns_error() {
247 let result = PluginManifest::parse("");
248 assert!(result.is_err());
249 match result.unwrap_err() {
250 PluginError::InvalidManifest(_) => {}
251 other => panic!("Expected InvalidManifest, got {:?}", other),
252 }
253 }
254
255 #[test]
256 fn manifest_missing_required_fields_returns_error() {
257 // Valid TOML but missing required plugin fields (name, version, description)
258 let toml = r#"
259 [plugin]
260 name = "incomplete"
261 "#;
262 let result = PluginManifest::parse(toml);
263 assert!(result.is_err());
264 }
265
266 #[test]
267 fn manifest_with_truncated_toml_returns_error() {
268 // Simulates a file that was partially written (e.g. crash mid-update)
269 let truncated = r#"
270 [plugin]
271 name = "half-written"
272 version = "1.0.0"
273 description = "Truncated during write"
274
275 [plugin.type]
276 kind = "import"
277
278 [plugin.import
279 "#;
280 let result = PluginManifest::parse(truncated);
281 assert!(result.is_err());
282 match result.unwrap_err() {
283 PluginError::InvalidManifest(msg) => {
284 assert!(msg.contains("TOML parse error"), "Unexpected message: {}", msg);
285 }
286 other => panic!("Expected InvalidManifest, got {:?}", other),
287 }
288 }
289
290 // ============ Unsupported Plugin Kind ============
291
292 #[test]
293 fn unsupported_plugin_kind_returns_error() {
294 let toml = r#"
295 [plugin]
296 name = "bad-kind"
297 version = "1.0.0"
298 description = "Uses a kind that does not exist"
299
300 [plugin.type]
301 kind = "transformer"
302
303 [plugin.capabilities]
304 "#;
305 let manifest = PluginManifest::parse(toml).unwrap();
306 let result = manifest.to_meta("bad-kind".to_string());
307 assert!(result.is_err());
308 match result.unwrap_err() {
309 PluginError::InvalidManifest(msg) => {
310 assert!(
311 msg.contains("Unknown plugin kind: transformer"),
312 "Unexpected message: {}",
313 msg
314 );
315 }
316 other => panic!("Expected InvalidManifest, got {:?}", other),
317 }
318 }
319
320 #[test]
321 fn empty_plugin_kind_returns_error() {
322 let toml = r#"
323 [plugin]
324 name = "empty-kind"
325 version = "1.0.0"
326 description = "Kind field is empty"
327
328 [plugin.type]
329 kind = ""
330
331 [plugin.capabilities]
332 "#;
333 let manifest = PluginManifest::parse(toml).unwrap();
334 let result = manifest.to_meta("empty-kind".to_string());
335 assert!(result.is_err());
336 match result.unwrap_err() {
337 PluginError::InvalidManifest(msg) => {
338 assert!(msg.contains("Unknown plugin kind"), "Unexpected message: {}", msg);
339 }
340 other => panic!("Expected InvalidManifest, got {:?}", other),
341 }
342 }
343 }
344