pinakes/crates/pinakes-plugin-api/src/manifest.rs
NotAShelf 2f31242442
treewide: complete book management interface
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If5a21f16221f3c56a8008e139f93edc46a6a6964
2026-02-05 06:34:19 +03:00

263 lines
6.9 KiB
Rust

//! Plugin manifest parsing and validation
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use thiserror::Error;
use crate::{Capabilities, EnvironmentCapability, FilesystemCapability, NetworkCapability};
/// Plugin manifest file format (TOML)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
#[serde(default)]
pub capabilities: ManifestCapabilities,
#[serde(default)]
pub config: HashMap<String, toml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
pub name: String,
pub version: String,
pub api_version: String,
pub author: Option<String>,
pub description: Option<String>,
pub homepage: Option<String>,
pub license: Option<String>,
/// Plugin kind(s) - e.g., ["media_type", "metadata_extractor"]
pub kind: Vec<String>,
/// Binary configuration
pub binary: PluginBinary,
/// Dependencies on other plugins
#[serde(default)]
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginBinary {
/// Path to WASM binary
pub wasm: String,
/// Optional entrypoint function name (default: "_start")
pub entrypoint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ManifestCapabilities {
#[serde(default)]
pub filesystem: ManifestFilesystemCapability,
#[serde(default)]
pub network: bool,
#[serde(default)]
pub environment: Option<Vec<String>>,
#[serde(default)]
pub max_memory_mb: Option<usize>,
#[serde(default)]
pub max_cpu_time_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ManifestFilesystemCapability {
#[serde(default)]
pub read: Vec<String>,
#[serde(default)]
pub write: Vec<String>,
}
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("Failed to read manifest file: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse manifest: {0}")]
ParseError(#[from] toml::de::Error),
#[error("Invalid manifest: {0}")]
ValidationError(String),
}
impl PluginManifest {
/// Load and parse a plugin manifest from a TOML file
pub fn from_file(path: &Path) -> Result<Self, ManifestError> {
let content = std::fs::read_to_string(path)?;
let manifest: Self = toml::from_str(&content)?;
manifest.validate()?;
Ok(manifest)
}
/// Parse a manifest from TOML string
pub fn parse_str(content: &str) -> Result<Self, ManifestError> {
let manifest: Self = toml::from_str(content)?;
manifest.validate()?;
Ok(manifest)
}
/// Validate the manifest
pub fn validate(&self) -> Result<(), ManifestError> {
// Check API version format
if self.plugin.api_version.is_empty() {
return Err(ManifestError::ValidationError(
"api_version cannot be empty".to_string(),
));
}
// Check version format (basic semver check)
if !self.plugin.version.contains('.') {
return Err(ManifestError::ValidationError(
"version must be in semver format (e.g., 1.0.0)".to_string(),
));
}
// Check that at least one kind is specified
if self.plugin.kind.is_empty() {
return Err(ManifestError::ValidationError(
"at least one plugin kind must be specified".to_string(),
));
}
// Check plugin kinds are valid
let valid_kinds = [
"media_type",
"metadata_extractor",
"thumbnail_generator",
"search_backend",
"event_handler",
"theme_provider",
];
for kind in &self.plugin.kind {
if !valid_kinds.contains(&kind.as_str()) {
return Err(ManifestError::ValidationError(format!(
"Invalid plugin kind: {}. Must be one of: {}",
kind,
valid_kinds.join(", ")
)));
}
}
// Check WASM binary path is not empty
if self.plugin.binary.wasm.is_empty() {
return Err(ManifestError::ValidationError(
"WASM binary path cannot be empty".to_string(),
));
}
Ok(())
}
/// Convert manifest capabilities to API capabilities
pub fn to_capabilities(&self) -> Capabilities {
Capabilities {
filesystem: FilesystemCapability {
read: self
.capabilities
.filesystem
.read
.iter()
.map(|s| s.into())
.collect(),
write: self
.capabilities
.filesystem
.write
.iter()
.map(|s| s.into())
.collect(),
},
network: NetworkCapability {
enabled: self.capabilities.network,
allowed_domains: None,
},
environment: EnvironmentCapability {
enabled: self.capabilities.environment.is_some(),
allowed_vars: self.capabilities.environment.clone(),
},
max_memory_bytes: self
.capabilities
.max_memory_mb
.map(|mb| mb.saturating_mul(1024).saturating_mul(1024)),
max_cpu_time_ms: self
.capabilities
.max_cpu_time_secs
.map(|secs| secs.saturating_mul(1000)),
}
}
/// Get plugin ID (derived from name and version)
pub fn plugin_id(&self) -> String {
format!("{}@{}", self.plugin.name, self.plugin.version)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_manifest() {
let toml = r#"
[plugin]
name = "heif-support"
version = "1.0.0"
api_version = "1.0"
author = "Test Author"
description = "HEIF image support"
kind = ["media_type", "metadata_extractor"]
[plugin.binary]
wasm = "plugin.wasm"
[capabilities.filesystem]
read = ["/tmp/pinakes-thumbnails"]
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(manifest.plugin.name, "heif-support");
assert_eq!(manifest.plugin.version, "1.0.0");
assert_eq!(manifest.plugin.kind.len(), 2);
}
#[test]
fn test_invalid_api_version() {
let toml = r#"
[plugin]
name = "test"
version = "1.0.0"
api_version = ""
kind = ["media_type"]
[plugin.binary]
wasm = "plugin.wasm"
"#;
assert!(PluginManifest::parse_str(toml).is_err());
}
#[test]
fn test_invalid_kind() {
let toml = r#"
[plugin]
name = "test"
version = "1.0.0"
api_version = "1.0"
kind = ["invalid_kind"]
[plugin.binary]
wasm = "plugin.wasm"
"#;
assert!(PluginManifest::parse_str(toml).is_err());
}
}