Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ia01590cdeed872cc8ebd16f6ca95f3cc6a6a6964
795 lines
18 KiB
Rust
795 lines
18 KiB
Rust
//! Plugin manifest parsing and validation
|
|
|
|
use std::{collections::HashMap, path::Path};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
use crate::{
|
|
Capabilities,
|
|
EnvironmentCapability,
|
|
FilesystemCapability,
|
|
NetworkCapability,
|
|
UiPage,
|
|
UiWidget,
|
|
};
|
|
|
|
/// 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>,
|
|
|
|
/// UI pages provided by this plugin
|
|
#[serde(default)]
|
|
pub ui: UiSection,
|
|
}
|
|
|
|
/// UI section of the plugin manifest
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct UiSection {
|
|
/// UI pages defined by this plugin
|
|
#[serde(default)]
|
|
pub pages: Vec<UiPageEntry>,
|
|
|
|
/// Widgets to inject into existing host pages
|
|
#[serde(default)]
|
|
pub widgets: Vec<UiWidget>,
|
|
|
|
/// API endpoint paths this plugin's UI requires.
|
|
/// Each must start with `/api/`. Informational; host may check availability.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub required_endpoints: Vec<String>,
|
|
|
|
/// CSS custom property overrides provided by this plugin.
|
|
/// Keys are property names (e.g. `--accent-color`), values are CSS values.
|
|
/// The host applies these to `document.documentElement` on startup.
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
pub theme_extensions: HashMap<String, String>,
|
|
}
|
|
|
|
impl UiSection {
|
|
/// Validate that all declared required endpoints start with `/api/`.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error string for the first invalid endpoint found.
|
|
pub fn validate(&self) -> Result<(), String> {
|
|
for ep in &self.required_endpoints {
|
|
if !ep.starts_with("/api/") {
|
|
return Err(format!("required_endpoint must start with '/api/': {ep}"));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Entry for a UI page in the manifest - can be inline or file reference
|
|
#[derive(Debug, Clone)]
|
|
pub enum UiPageEntry {
|
|
/// Inline UI page definition (boxed to reduce enum size)
|
|
Inline(Box<UiPage>),
|
|
/// Reference to a JSON file containing the page definition
|
|
File { file: String },
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for UiPageEntry {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
use serde::de::Error;
|
|
|
|
// First try to deserialize as a file reference (has "file" key)
|
|
// We use toml::Value since the manifest is TOML
|
|
let value = toml::Value::deserialize(deserializer)?;
|
|
|
|
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
|
|
if file.is_empty() {
|
|
return Err(D::Error::custom("file path cannot be empty"));
|
|
}
|
|
return Ok(Self::File {
|
|
file: file.to_string(),
|
|
});
|
|
}
|
|
|
|
// Otherwise try to deserialize as inline UiPage
|
|
// Convert toml::Value back to a deserializer for UiPage
|
|
let page: UiPage = UiPage::deserialize(value)
|
|
.map_err(|e| D::Error::custom(format!("invalid inline UI page: {e}")))?;
|
|
|
|
Ok(Self::Inline(Box::new(page)))
|
|
}
|
|
}
|
|
|
|
impl Serialize for UiPageEntry {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
match self {
|
|
Self::Inline(page) => page.serialize(serializer),
|
|
Self::File { file } => {
|
|
use serde::ser::SerializeStruct;
|
|
let mut state = serializer.serialize_struct("UiPageEntry", 1)?;
|
|
state.serialize_field("file", file)?;
|
|
state.end()
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
const fn default_priority() -> u16 {
|
|
500
|
|
}
|
|
|
|
#[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>,
|
|
|
|
/// Pipeline priority (0-999). Lower values run first. Built-in handlers run
|
|
/// at 100. Default: 500.
|
|
#[serde(default = "default_priority")]
|
|
pub priority: u16,
|
|
|
|
/// 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 allowed_domains: Option<Vec<String>>,
|
|
|
|
#[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
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`ManifestError::IoError`] if the file cannot be read,
|
|
/// [`ManifestError::ParseError`] if the TOML is invalid, or
|
|
/// [`ManifestError::ValidationError`] if the manifest fails validation.
|
|
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
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`ManifestError::ParseError`] if the TOML is invalid, or
|
|
/// [`ManifestError::ValidationError`] if the manifest fails validation.
|
|
pub fn parse_str(content: &str) -> Result<Self, ManifestError> {
|
|
let manifest: Self = toml::from_str(content)?;
|
|
manifest.validate()?;
|
|
Ok(manifest)
|
|
}
|
|
|
|
/// Validate the manifest
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`ManifestError::ValidationError`] if any required field is empty
|
|
/// or otherwise invalid.
|
|
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",
|
|
"ui_page",
|
|
];
|
|
|
|
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(),
|
|
));
|
|
}
|
|
|
|
if self.plugin.priority > 999 {
|
|
return Err(ManifestError::ValidationError(
|
|
"priority must be 0-999".to_string(),
|
|
));
|
|
}
|
|
|
|
// Validate UI section (required_endpoints format); non-fatal: warn only
|
|
if let Err(e) = self.ui.validate() {
|
|
tracing::warn!(
|
|
plugin = %self.plugin.name,
|
|
error = %e,
|
|
"plugin UI section has invalid required_endpoints"
|
|
);
|
|
}
|
|
|
|
// Validate UI pages
|
|
for (idx, page_entry) in self.ui.pages.iter().enumerate() {
|
|
match page_entry {
|
|
UiPageEntry::Inline(page) => {
|
|
if let Err(e) = page.validate() {
|
|
return Err(ManifestError::ValidationError(format!(
|
|
"UI page {} validation failed: {e}",
|
|
idx + 1
|
|
)));
|
|
}
|
|
},
|
|
UiPageEntry::File { file } => {
|
|
if file.is_empty() {
|
|
return Err(ManifestError::ValidationError(format!(
|
|
"UI page {} file path cannot be empty",
|
|
idx + 1
|
|
)));
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load and resolve all UI page definitions
|
|
///
|
|
/// For inline pages, returns them directly. For file references, loads
|
|
/// and parses the JSON file.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `base_path` - Base directory for resolving relative file paths
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`ManifestError::IoError`] if a file cannot be read, or
|
|
/// [`ManifestError::ValidationError`] if JSON parsing fails.
|
|
pub fn load_ui_pages(
|
|
&self,
|
|
base_path: &Path,
|
|
) -> Result<Vec<UiPage>, ManifestError> {
|
|
let mut pages = Vec::with_capacity(self.ui.pages.len());
|
|
|
|
for entry in &self.ui.pages {
|
|
let page = match entry {
|
|
UiPageEntry::Inline(page) => (**page).clone(),
|
|
UiPageEntry::File { file } => {
|
|
let file_path = base_path.join(file);
|
|
let content = std::fs::read_to_string(&file_path)?;
|
|
let page: UiPage = serde_json::from_str(&content).map_err(|e| {
|
|
ManifestError::ValidationError(format!(
|
|
"Failed to parse UI page from file '{}': {e}",
|
|
file_path.display()
|
|
))
|
|
})?;
|
|
if let Err(e) = page.validate() {
|
|
return Err(ManifestError::ValidationError(format!(
|
|
"UI page validation failed for file '{}': {e}",
|
|
file_path.display()
|
|
)));
|
|
}
|
|
page
|
|
},
|
|
};
|
|
pages.push(page);
|
|
}
|
|
|
|
Ok(pages)
|
|
}
|
|
|
|
/// Convert manifest capabilities to API capabilities
|
|
#[must_use]
|
|
pub fn to_capabilities(&self) -> Capabilities {
|
|
Capabilities {
|
|
filesystem: FilesystemCapability {
|
|
read: self
|
|
.capabilities
|
|
.filesystem
|
|
.read
|
|
.iter()
|
|
.map(std::convert::Into::into)
|
|
.collect(),
|
|
write: self
|
|
.capabilities
|
|
.filesystem
|
|
.write
|
|
.iter()
|
|
.map(std::convert::Into::into)
|
|
.collect(),
|
|
},
|
|
network: NetworkCapability {
|
|
enabled: self.capabilities.network,
|
|
allowed_domains: self.capabilities.allowed_domains.clone(),
|
|
},
|
|
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)
|
|
#[must_use]
|
|
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());
|
|
}
|
|
|
|
#[test]
|
|
fn test_priority_default() {
|
|
let toml = r#"
|
|
[plugin]
|
|
name = "test"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
kind = ["media_type"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
"#;
|
|
|
|
let manifest = PluginManifest::parse_str(toml).unwrap();
|
|
assert_eq!(manifest.plugin.priority, 500);
|
|
}
|
|
|
|
#[test]
|
|
fn test_priority_custom() {
|
|
let toml = r#"
|
|
[plugin]
|
|
name = "test"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
priority = 50
|
|
kind = ["media_type"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
"#;
|
|
|
|
let manifest = PluginManifest::parse_str(toml).unwrap();
|
|
assert_eq!(manifest.plugin.priority, 50);
|
|
}
|
|
|
|
#[test]
|
|
fn test_priority_out_of_range() {
|
|
let toml = r#"
|
|
[plugin]
|
|
name = "test"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
priority = 1000
|
|
kind = ["media_type"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
"#;
|
|
|
|
assert!(PluginManifest::parse_str(toml).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ui_page_inline() {
|
|
let toml = r#"
|
|
[plugin]
|
|
name = "ui-demo"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
kind = ["ui_page"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
|
|
[[ui.pages]]
|
|
id = "demo"
|
|
title = "Demo Page"
|
|
route = "/plugins/demo"
|
|
icon = "star"
|
|
|
|
[ui.pages.layout]
|
|
type = "container"
|
|
children = []
|
|
gap = 16
|
|
"#;
|
|
|
|
let manifest = PluginManifest::parse_str(toml).unwrap();
|
|
assert_eq!(manifest.ui.pages.len(), 1);
|
|
|
|
match &manifest.ui.pages[0] {
|
|
UiPageEntry::Inline(page) => {
|
|
assert_eq!(page.id, "demo");
|
|
assert_eq!(page.title, "Demo Page");
|
|
assert_eq!(page.route, "/plugins/demo");
|
|
assert_eq!(page.icon, Some("star".to_string()));
|
|
},
|
|
_ => panic!("Expected inline page"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ui_page_file_reference() {
|
|
let toml = r#"
|
|
[plugin]
|
|
name = "ui-demo"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
kind = ["ui_page"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
|
|
[[ui.pages]]
|
|
file = "pages/demo.json"
|
|
"#;
|
|
|
|
let manifest = PluginManifest::parse_str(toml).unwrap();
|
|
assert_eq!(manifest.ui.pages.len(), 1);
|
|
|
|
match &manifest.ui.pages[0] {
|
|
UiPageEntry::File { file } => {
|
|
assert_eq!(file, "pages/demo.json");
|
|
},
|
|
_ => panic!("Expected file reference"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ui_page_invalid_kind() {
|
|
// ui_page must be in valid_kinds list
|
|
let toml = r#"
|
|
[plugin]
|
|
name = "test"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
kind = ["ui_page"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
"#;
|
|
|
|
// Should succeed now that ui_page is in valid_kinds
|
|
let manifest = PluginManifest::parse_str(toml);
|
|
assert!(manifest.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ui_page_validation_failure() {
|
|
let toml = r#"
|
|
[plugin]
|
|
name = "ui-demo"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
kind = ["ui_page"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
|
|
[[ui.pages]]
|
|
id = ""
|
|
title = "Demo"
|
|
route = "/plugins/demo"
|
|
|
|
[ui.pages.layout]
|
|
type = "container"
|
|
children = []
|
|
gap = 16
|
|
"#;
|
|
|
|
// Empty ID should fail validation
|
|
assert!(PluginManifest::parse_str(toml).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ui_page_empty_file() {
|
|
let toml = r#"
|
|
[plugin]
|
|
name = "ui-demo"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
kind = ["ui_page"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
|
|
[[ui.pages]]
|
|
file = ""
|
|
"#;
|
|
|
|
// Empty file path should fail validation
|
|
assert!(PluginManifest::parse_str(toml).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ui_page_multiple_pages() {
|
|
let toml = r#"
|
|
[plugin]
|
|
name = "ui-demo"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
kind = ["ui_page"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
|
|
[[ui.pages]]
|
|
id = "page1"
|
|
title = "Page 1"
|
|
route = "/plugins/page1"
|
|
|
|
[ui.pages.layout]
|
|
type = "container"
|
|
children = []
|
|
gap = 16
|
|
|
|
[[ui.pages]]
|
|
id = "page2"
|
|
title = "Page 2"
|
|
route = "/plugins/page2"
|
|
|
|
[ui.pages.layout]
|
|
type = "container"
|
|
children = []
|
|
gap = 16
|
|
"#;
|
|
|
|
let manifest = PluginManifest::parse_str(toml).unwrap();
|
|
assert_eq!(manifest.ui.pages.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ui_section_validate_accepts_api_paths() {
|
|
let section = UiSection {
|
|
pages: vec![],
|
|
widgets: vec![],
|
|
required_endpoints: vec![
|
|
"/api/v1/media".to_string(),
|
|
"/api/plugins/my-plugin/data".to_string(),
|
|
],
|
|
theme_extensions: HashMap::new(),
|
|
};
|
|
assert!(section.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ui_section_validate_rejects_non_api_path() {
|
|
let section = UiSection {
|
|
pages: vec![],
|
|
widgets: vec![],
|
|
required_endpoints: vec!["/not-api/something".to_string()],
|
|
theme_extensions: HashMap::new(),
|
|
};
|
|
assert!(section.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ui_section_validate_rejects_empty_sections_with_bad_path() {
|
|
let section = UiSection {
|
|
pages: vec![],
|
|
widgets: vec![],
|
|
required_endpoints: vec!["/api/ok".to_string(), "no-slash".to_string()],
|
|
theme_extensions: HashMap::new(),
|
|
};
|
|
let err = section.validate().unwrap_err();
|
|
assert!(
|
|
err.contains("no-slash"),
|
|
"error should mention the bad endpoint"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_theme_extensions_roundtrip() {
|
|
let toml = r##"
|
|
[plugin]
|
|
name = "theme-plugin"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
kind = ["theme_provider"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
|
|
[ui.theme_extensions]
|
|
"--accent-color" = "#ff6b6b"
|
|
"--sidebar-width" = "280px"
|
|
"##;
|
|
|
|
let manifest = PluginManifest::parse_str(toml).unwrap();
|
|
assert_eq!(
|
|
manifest
|
|
.ui
|
|
.theme_extensions
|
|
.get("--accent-color")
|
|
.map(String::as_str),
|
|
Some("#ff6b6b")
|
|
);
|
|
assert_eq!(
|
|
manifest
|
|
.ui
|
|
.theme_extensions
|
|
.get("--sidebar-width")
|
|
.map(String::as_str),
|
|
Some("280px")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_theme_extensions_empty_by_default() {
|
|
let toml = r#"
|
|
[plugin]
|
|
name = "no-theme"
|
|
version = "1.0.0"
|
|
api_version = "1.0"
|
|
kind = ["media_type"]
|
|
|
|
[plugin.binary]
|
|
wasm = "plugin.wasm"
|
|
"#;
|
|
|
|
let manifest = PluginManifest::parse_str(toml).unwrap();
|
|
assert!(manifest.ui.theme_extensions.is_empty());
|
|
}
|
|
}
|