feat(homecore-plugins/p1): ADR-128 plugin runtime scaffold

Adds `v2/crates/homecore-plugins` (0.1.0-alpha.0) — the P1 scaffold for
the HOMECORE-PLUGINS WASM integration system (ADR-128):

- `manifest.rs`: `PluginManifest` — superset of HA manifest.json; serde
  round-trip + required-field validation (`domain`/`name`/`version`).
- `error.rs`: `PluginError` typed enum (InvalidManifest, AlreadyLoaded,
  NotFound, RuntimeError, SetupFailed, UnloadFailed, Io).
- `plugin.rs`: `HomeCorePlugin` async trait + `PluginId` newtype.
- `runtime.rs`: `PluginRuntime` trait + `InProcessRuntime` (native Rust,
  first-party plugins). `WasmtimeRuntime` stub gated on `--features wasmtime`
  (default-off; 30 MB dep deferred to P2).
- `registry.rs`: `PluginRegistry<R>` — load/unload/list/contains via RwLock.
- 10 unit tests, 0 failed.

Wasmtime vs wasm3 runtime selection is still open (ADR-128 §8 Q2);
this scaffold makes the choice swappable via the `PluginRuntime` trait.
The `wasmtime` and `wasm3` features are default-off; P2 resolves the choice
and wires host ABI (`hc_state_get`/`hc_state_set`/etc.) to ADR-127.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-25 18:13:53 -04:00
parent 4d30304a1c
commit c04906e7a8
10 changed files with 1714 additions and 29 deletions

941
v2/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,8 @@ members = [
"crates/nvsim",
"crates/nvsim-server",
"crates/homecore", # ADR-127 — HOMECORE state machine
"crates/homecore-plugins", # ADR-128 — HOMECORE-PLUGINS WASM runtime (P1 scaffold)
"crates/homecore-api", # ADR-130 — HOMECORE REST + WS API
# ADR-100/ADR-101: Cognitum Cog packaging — first Cog from this repo.
# Ships the wifi-densepose pose-estimation model as a signed binary +
# JSONL manifest installable by the Cognitum V0 appliance (cognitum-v0,

View File

@ -0,0 +1,57 @@
# HOMECORE-PLUGINS — WASM integration plugin system.
# Implements ADR-128 (HOMECORE-PLUGINS), P1 scaffold:
# - PluginManifest (serde-deserialised, superset of HA manifest.json)
# - HomeCorePlugin async trait + PluginId + PluginError
# - PluginRuntime trait + InProcessRuntime (native Rust, first-party plugins)
# - PluginRegistry (load / unload / list)
#
# P2 will add the `wasmtime` feature (gated below, default-off) for the real
# Wasmtime JIT sandbox. wasm3 interpretation mode lands behind `--features wasm3`
# in P3 for constrained-hardware targets.
[package]
name = "homecore-plugins"
version = "0.1.0-alpha.0"
edition = "2021"
license = "MIT"
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
description = "WASM integration plugin runtime for HOMECORE (ADR-128 P1 scaffold)"
repository = "https://github.com/ruvnet/RuView"
[lib]
name = "homecore_plugins"
path = "src/lib.rs"
[features]
default = []
# P2: real Wasmtime JIT sandbox (Cranelift; ~15 MB binary delta on Pi 5).
# Do not enable in production until the host ABI is frozen (ADR-128 §8 risk).
wasmtime = ["dep:wasmtime"]
# P3: wasm3 interpretation mode for constrained hardware (~50 kB).
wasm3 = ["dep:wasm3"]
[dependencies]
# HOMECORE state machine — local path (ADR-127).
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
# Async runtime — same version as workspace.
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
# Async trait support for HomeCorePlugin.
async-trait = "0.1"
# Error handling.
thiserror = "1"
# Serialisation (manifest JSON + ABI call payloads).
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Optional Wasmtime runtime (P2, default-off — 30 MB dep).
wasmtime = { version = "25", optional = true }
# Optional wasm3 interpretation runtime (P3, default-off).
wasm3 = { version = "0.3", optional = true }
[dev-dependencies]
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] }

View File

@ -0,0 +1,35 @@
//! `PluginError` — typed error enum for the homecore-plugins crate.
use thiserror::Error;
/// Errors produced by the HOMECORE plugin system.
#[derive(Debug, Error)]
pub enum PluginError {
/// The plugin manifest JSON is missing required fields or is malformed.
#[error("invalid manifest: {0}")]
InvalidManifest(String),
/// A plugin with this ID is already loaded in the registry.
#[error("plugin already loaded: {0}")]
AlreadyLoaded(String),
/// No plugin with this ID is loaded in the registry.
#[error("plugin not found: {0}")]
NotFound(String),
/// The plugin runtime failed to spawn or execute the plugin.
#[error("runtime error: {0}")]
RuntimeError(String),
/// The plugin's `setup` hook returned an error.
#[error("plugin setup failed: {0}")]
SetupFailed(String),
/// The plugin's `unload` hook returned an error.
#[error("plugin unload failed: {0}")]
UnloadFailed(String),
/// IO error (manifest file not found, WASM binary missing, etc.).
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}

View File

@ -0,0 +1,51 @@
//! HOMECORE-PLUGINS — WASM integration plugin system.
//!
//! Implements [ADR-128](../../docs/adr/ADR-128-homecore-integration-plugin-system.md)
//! P1 scaffold: manifest parsing, the `HomeCorePlugin` async trait, the
//! `PluginRuntime` abstraction, and the `PluginRegistry`.
//!
//! ## What's here (P1)
//!
//! - [`manifest`] — `PluginManifest`: superset of HA `manifest.json`; serde
//! round-trip + required-field validation.
//! - [`plugin`] — `HomeCorePlugin` async trait, `PluginId` newtype.
//! - [`runtime`] — `PluginRuntime` trait + `InProcessRuntime` (native Rust,
//! first-party plugins compiled into the binary).
//! - [`registry`] — `PluginRegistry<R>`: load / unload / list plugins.
//! - [`error`] — `PluginError` typed error enum.
//!
//! ## What's NOT here yet (deferred)
//!
//! - `WasmtimeRuntime` (P2, `--features wasmtime`): Cranelift JIT sandbox on
//! Pi 5 / x86_64. The runtime-selection question (Wasmtime vs wasm3) is still
//! open (ADR-128 §8) and will be resolved in Q2 before P2 begins.
//! - Host ABI wiring: `hc_state_get`, `hc_state_set`, `hc_event_fire`, etc.
//! (P2 — requires ADR-127 state machine API freeze first).
//! - Config entry lifecycle + hot-load (P3).
//! - Cog registry distribution + Ed25519 signature verification (P4).
//! - Permission enforcement (P5).
//!
//! ## Feature flags
//!
//! | Feature | Default | Description |
//! |---------|---------|-------------|
//! | `wasmtime` | off | Wasmtime Cranelift JIT runtime (P2) |
//! | `wasm3` | off | wasm3 interpreter runtime for constrained hardware (P3) |
pub mod error;
pub mod manifest;
pub mod plugin;
pub mod registry;
pub mod runtime;
pub use error::PluginError;
pub use manifest::{IotClass, IntegrationType, PluginManifest};
pub use plugin::{HomeCorePlugin, PluginId};
pub use registry::PluginRegistry;
pub use runtime::{InProcessRuntime, LoadedPlugin, PluginRuntime};
#[cfg(feature = "wasmtime")]
pub use runtime::wasmtime_rt::WasmtimeRuntime;
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,144 @@
//! Plugin manifest — superset of HA's `manifest.json`.
//!
//! See ADR-128 §3 for the full field list. Fields present in HA's schema
//! are preserved verbatim. HOMECORE-specific fields are marked `[HOMECORE]`.
use serde::{Deserialize, Serialize};
use crate::error::PluginError;
/// Coarse-grained permission claim string (glob pattern).
/// Example: `"state:write:sensor.*"`.
pub type PermissionClaim = String;
/// HA `iot_class` values (non-exhaustive — HA adds new classes over time).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IotClass {
LocalPush,
LocalPolling,
CloudPush,
CloudPolling,
AssumedState,
Calculated,
#[serde(other)]
Other,
}
/// HOMECORE integration type.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IntegrationType {
Integration,
Helper,
Entity,
#[serde(other)]
Other,
}
/// Parsed and validated plugin manifest.
///
/// Serialises to/from HA-compatible `manifest.json`. HOMECORE-only fields
/// are `Option<…>` so that a plain HA manifest is a valid (native-only)
/// HOMECORE manifest.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginManifest {
/// Unique integration domain identifier (e.g. `"mqtt"`).
pub domain: String,
/// Human-readable integration name.
pub name: String,
/// SemVer-ish version string (HA uses calendar-versioning, e.g. `"2025.1.0"`).
pub version: String,
/// Optional documentation URL.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub documentation: Option<String>,
/// HA `iot_class` — how the integration communicates with the device.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub iot_class: Option<IotClass>,
/// Whether this integration ships a UI config flow.
#[serde(default)]
pub config_flow: bool,
/// HOMECORE integration type (optional, defaults to Integration).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub integration_type: Option<IntegrationType>,
/// Intra-HOMECORE dependencies (other plugin domains this one requires).
#[serde(default)]
pub dependencies: Vec<String>,
/// External package requirements — kept for schema compat, ignored in HOMECORE
/// (WASM modules carry their own static deps, no pip).
#[serde(default)]
pub requirements: Vec<String>,
// ── [HOMECORE] fields ──────────────────────────────────────────────────
/// [HOMECORE] Relative path to the `.wasm` binary (absent for native plugins).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wasm_module: Option<String>,
/// [HOMECORE] `sha256:<hex>` hash of the wasm binary; verified before execution.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wasm_module_hash: Option<String>,
/// [HOMECORE] Ed25519 signature of the wasm binary hash (`ed25519:<base64>`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wasm_module_sig: Option<String>,
/// [HOMECORE] Ed25519 public key of the plugin publisher.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher_key: Option<String>,
/// [HOMECORE] Minimum HOMECORE version required by this plugin.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_homecore_version: Option<String>,
/// [HOMECORE] Subset of host functions the WASM module imports.
#[serde(default)]
pub host_imports_required: Vec<String>,
/// [HOMECORE] Coarse-grained permission claims (glob patterns).
#[serde(default)]
pub homecore_permissions: Vec<PermissionClaim>,
/// [HOMECORE] Seed app registry cog ID for distribution.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cog_id: Option<String>,
}
impl PluginManifest {
/// Parse a `manifest.json` JSON string and validate required fields.
///
/// Required fields: `domain`, `name`, `version`.
pub fn parse_json(s: &str) -> Result<Self, PluginError> {
let m: Self = serde_json::from_str(s)
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
m.validate()?;
Ok(m)
}
fn validate(&self) -> Result<(), PluginError> {
if self.domain.trim().is_empty() {
return Err(PluginError::InvalidManifest(
"manifest `domain` must not be empty".into(),
));
}
if self.name.trim().is_empty() {
return Err(PluginError::InvalidManifest(
"manifest `name` must not be empty".into(),
));
}
if self.version.trim().is_empty() {
return Err(PluginError::InvalidManifest(
"manifest `version` must not be empty".into(),
));
}
Ok(())
}
}

View File

@ -0,0 +1,59 @@
//! `HomeCorePlugin` trait + `PluginId` newtype.
//!
//! Every first-party and third-party HOMECORE integration must implement
//! `HomeCorePlugin`. P1 provides an in-process native Rust implementation;
//! the WASM ABI wrapper (which maps the WASM exports `setup_entry`,
//! `call_service_handler`, `receive_event` to this trait) lands in P2.
use std::fmt;
use async_trait::async_trait;
use homecore::HomeCore;
use crate::error::PluginError;
/// Unique identifier for a loaded plugin — mirrors the `domain` field of
/// the plugin's `PluginManifest` (e.g. `"mqtt"`, `"homecore_lights"`).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PluginId(pub String);
impl PluginId {
/// Create a new `PluginId` from any string-like value.
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
/// Return the inner domain string.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for PluginId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// Lifecycle trait that every HOMECORE integration must implement.
///
/// Implementing types are passed to [`PluginRuntime::load`]; the runtime
/// calls these methods at the appropriate lifecycle points.
///
/// # Async
/// Both methods are `async` to allow network / IO initialisation without
/// blocking the Tokio runtime. The `async_trait` macro erases the `impl`
/// return type so it works in trait objects.
#[async_trait]
pub trait HomeCorePlugin: Send + Sync + 'static {
/// Called once when the plugin's config entry is being set up.
///
/// The plugin receives a reference to the `HomeCore` runtime and should
/// register its entities, services, and event subscriptions here.
async fn setup(&self, hc: HomeCore) -> Result<(), PluginError>;
/// Called when the plugin is being removed from the registry.
///
/// The plugin should clean up subscriptions and deregister its entities.
async fn unload(&self) -> Result<(), PluginError>;
}

View File

@ -0,0 +1,102 @@
//! `PluginRegistry` — load, unload, and list HOMECORE plugins.
//!
//! The registry is runtime-agnostic: it accepts any type that implements
//! [`PluginRuntime`] and delegates load/unload to it. This allows swapping
//! the `InProcessRuntime` (P1) for a `WasmtimeRuntime` (P2) without
//! changing registry code.
use std::collections::HashMap;
use std::sync::Arc;
use homecore::HomeCore;
use tokio::sync::RwLock;
use crate::error::PluginError;
use crate::manifest::PluginManifest;
use crate::plugin::{HomeCorePlugin, PluginId};
use crate::runtime::{LoadedPlugin, PluginRuntime};
/// Holds all loaded plugins keyed by `PluginId`.
///
/// Thread-safe via `RwLock` — concurrent reads are cheap; writes (load /
/// unload) take an exclusive lock only while mutating the map.
pub struct PluginRegistry<R: PluginRuntime> {
runtime: R,
plugins: RwLock<HashMap<PluginId, LoadedPlugin>>,
}
impl<R: PluginRuntime> PluginRegistry<R> {
/// Create an empty registry backed by `runtime`.
pub fn new(runtime: R) -> Self {
Self {
runtime,
plugins: RwLock::new(HashMap::new()),
}
}
/// Load a plugin, call its `setup` hook, and insert it into the registry.
///
/// Returns `PluginError::AlreadyLoaded` if a plugin with the same ID is
/// already registered.
pub async fn load(
&self,
manifest: PluginManifest,
plugin: Arc<dyn HomeCorePlugin>,
hc: HomeCore,
) -> Result<PluginId, PluginError> {
let id = PluginId::new(&manifest.domain);
{
let guard = self.plugins.read().await;
if guard.contains_key(&id) {
return Err(PluginError::AlreadyLoaded(id.to_string()));
}
}
let loaded = self
.runtime
.load(id.clone(), manifest, plugin)
.await?;
loaded
.setup(hc)
.await
.map_err(|e| PluginError::SetupFailed(e.to_string()))?;
self.plugins.write().await.insert(id.clone(), loaded);
Ok(id)
}
/// Unload a plugin by ID, calling its `unload` hook first.
///
/// Returns `PluginError::NotFound` if the plugin was not loaded.
pub async fn unload(&self, id: &PluginId) -> Result<(), PluginError> {
let loaded = {
let mut guard = self.plugins.write().await;
guard
.remove(id)
.ok_or_else(|| PluginError::NotFound(id.to_string()))?
};
loaded
.unload()
.await
.map_err(|e| PluginError::UnloadFailed(e.to_string()))?;
Ok(())
}
/// Return a snapshot of currently loaded plugin IDs and their manifest domains.
pub async fn list(&self) -> Vec<(PluginId, String)> {
let guard = self.plugins.read().await;
guard
.iter()
.map(|(id, lp)| (id.clone(), lp.manifest.domain.clone()))
.collect()
}
/// Return `true` if a plugin with this ID is loaded.
pub async fn contains(&self, id: &PluginId) -> bool {
self.plugins.read().await.contains_key(id)
}
}

View File

@ -0,0 +1,119 @@
//! `PluginRuntime` trait + `InProcessRuntime` (P1).
//!
//! Abstracts over Wasmtime (P2, `--features wasmtime`) and native in-process
//! Rust plugins (P1, always-on). A third backend, wasm3 (P3), will provide
//! interpretation mode for constrained hardware.
//!
//! # Architecture
//!
//! ```text
//! PluginRegistry
//! │
//! ▼
//! PluginRuntime ◄─── InProcessRuntime (P1, native Rust, <1 µs call)
//! ◄─── WasmtimeRuntime (P2, Cranelift JIT, ~5 ms cold start)
//! ◄─── Wasm3Runtime (P3, interpreter, ~50 kB, Pi Zero)
//! ```
use std::sync::Arc;
use async_trait::async_trait;
use homecore::HomeCore;
use crate::error::PluginError;
use crate::manifest::PluginManifest;
use crate::plugin::{HomeCorePlugin, PluginId};
/// A loaded plugin handle — returned by [`PluginRuntime::load`].
pub struct LoadedPlugin {
pub id: PluginId,
pub manifest: PluginManifest,
/// Underlying plugin instance (boxed trait object).
pub(crate) instance: Arc<dyn HomeCorePlugin>,
}
impl LoadedPlugin {
/// Delegate to the inner plugin's `setup` method.
pub async fn setup(&self, hc: HomeCore) -> Result<(), PluginError> {
self.instance.setup(hc).await
}
/// Delegate to the inner plugin's `unload` method.
pub async fn unload(&self) -> Result<(), PluginError> {
self.instance.unload().await
}
}
/// Abstraction over the WASM (and native) plugin execution environment.
///
/// P2 will supply a `WasmtimeRuntime` that compiles `.wasm` bytes with
/// Cranelift; P3 adds a `Wasm3Runtime` for constrained targets. Both will
/// implement this trait so the registry is runtime-agnostic.
#[async_trait]
pub trait PluginRuntime: Send + Sync + 'static {
/// Load a plugin from a boxed [`HomeCorePlugin`] implementation and a
/// parsed `PluginManifest`. Returns a `LoadedPlugin` handle.
async fn load(
&self,
id: PluginId,
manifest: PluginManifest,
plugin: Arc<dyn HomeCorePlugin>,
) -> Result<LoadedPlugin, PluginError>;
}
/// Native in-process runtime — loads first-party Rust plugins directly.
///
/// No WASM compilation; no sandbox. Intended for first-party plugins
/// (RuView MQTT bridge, presence sensor, etc.) that are compiled into the
/// HOMECORE binary and therefore trusted. Third-party / community plugins
/// must use the `WasmtimeRuntime` (P2) for isolation.
pub struct InProcessRuntime;
#[async_trait]
impl PluginRuntime for InProcessRuntime {
async fn load(
&self,
id: PluginId,
manifest: PluginManifest,
plugin: Arc<dyn HomeCorePlugin>,
) -> Result<LoadedPlugin, PluginError> {
Ok(LoadedPlugin {
id,
manifest,
instance: plugin,
})
}
}
// ── Feature-gated Wasmtime stub (P2) ──────────────────────────────────────
#[cfg(feature = "wasmtime")]
pub mod wasmtime_rt {
//! Wasmtime JIT runtime — P2 stub.
//!
//! This module intentionally does not compile to a usable runtime yet.
//! It exists so that `cargo check --features wasmtime` exercises the
//! dependency graph and catches obvious breakage early.
//!
//! Full implementation tracked in ADR-128 §7 P2.
use super::*;
/// Wasmtime-backed plugin runtime (Cranelift JIT on Pi 5 and x86_64).
/// Not yet implemented — P2 work.
pub struct WasmtimeRuntime;
#[async_trait]
impl PluginRuntime for WasmtimeRuntime {
async fn load(
&self,
_id: PluginId,
_manifest: PluginManifest,
_plugin: Arc<dyn HomeCorePlugin>,
) -> Result<LoadedPlugin, PluginError> {
Err(PluginError::RuntimeError(
"WasmtimeRuntime is not yet implemented (ADR-128 P2)".into(),
))
}
}
}

View File

@ -0,0 +1,233 @@
//! Unit tests for homecore-plugins P1 scaffold.
//!
//! Covers: manifest parse + round-trip, manifest field validation,
//! PluginRegistry load/unload/list/duplicate, InProcessRuntime,
//! and PluginError variants.
#[cfg(test)]
mod tests {
use std::sync::Arc;
use async_trait::async_trait;
use homecore::HomeCore;
use tokio::sync::Mutex;
use crate::error::PluginError;
use crate::manifest::PluginManifest;
use crate::plugin::{HomeCorePlugin, PluginId};
use crate::registry::PluginRegistry;
use crate::runtime::InProcessRuntime;
// ── Test double ────────────────────────────────────────────────────────
/// Minimal plugin that records setup/unload calls.
struct TestPlugin {
pub setup_called: Mutex<bool>,
pub unload_called: Mutex<bool>,
}
impl TestPlugin {
fn new() -> Arc<Self> {
Arc::new(Self {
setup_called: Mutex::new(false),
unload_called: Mutex::new(false),
})
}
}
#[async_trait]
impl HomeCorePlugin for TestPlugin {
async fn setup(&self, _hc: HomeCore) -> Result<(), PluginError> {
*self.setup_called.lock().await = true;
Ok(())
}
async fn unload(&self) -> Result<(), PluginError> {
*self.unload_called.lock().await = true;
Ok(())
}
}
fn minimal_manifest(domain: &str) -> PluginManifest {
PluginManifest {
domain: domain.into(),
name: "Test Plugin".into(),
version: "1.0.0".into(),
documentation: None,
iot_class: None,
config_flow: false,
integration_type: None,
dependencies: vec![],
requirements: vec![],
wasm_module: None,
wasm_module_hash: None,
wasm_module_sig: None,
publisher_key: None,
min_homecore_version: None,
host_imports_required: vec![],
homecore_permissions: vec![],
cog_id: None,
}
}
// ── Manifest tests ─────────────────────────────────────────────────────
#[test]
fn manifest_parse_round_trip() {
let json = r#"{
"domain": "mqtt",
"name": "MQTT",
"version": "2025.1.0",
"iot_class": "local_push",
"config_flow": true,
"dependencies": [],
"requirements": [],
"wasm_module": "mqtt.wasm",
"homecore_permissions": ["state:write:sensor.*"]
}"#;
let m = PluginManifest::parse_json(json).expect("should parse");
assert_eq!(m.domain, "mqtt");
assert_eq!(m.version, "2025.1.0");
assert!(m.config_flow);
assert_eq!(m.homecore_permissions, vec!["state:write:sensor.*"]);
// round-trip: serialize back to JSON and re-parse
let serialised = serde_json::to_string(&m).expect("should serialise");
let m2 = PluginManifest::parse_json(&serialised).expect("round-trip should parse");
assert_eq!(m.domain, m2.domain);
assert_eq!(m.version, m2.version);
}
#[test]
fn manifest_rejects_empty_domain() {
let json = r#"{"domain":"","name":"X","version":"1.0.0"}"#;
let err = PluginManifest::parse_json(json).unwrap_err();
assert!(
err.to_string().contains("domain"),
"error should mention domain: {err}"
);
}
#[test]
fn manifest_rejects_missing_domain() {
let json = r#"{"name":"X","version":"1.0.0"}"#;
// serde will fill domain as "" due to missing field → validation rejects
let err = PluginManifest::parse_json(json).unwrap_err();
// Either a serde error (missing field) or a validation error is acceptable
let s = err.to_string();
assert!(!s.is_empty(), "should produce a non-empty error");
}
#[test]
fn manifest_rejects_empty_version() {
let json = r#"{"domain":"lights","name":"Lights","version":""}"#;
let err = PluginManifest::parse_json(json).unwrap_err();
assert!(
err.to_string().contains("version"),
"error should mention version: {err}"
);
}
// ── Registry + InProcessRuntime tests ─────────────────────────────────
#[tokio::test]
async fn registry_load_and_list() {
let hc = HomeCore::new();
let registry = PluginRegistry::new(InProcessRuntime);
let plugin = TestPlugin::new();
let manifest = minimal_manifest("lights");
let id = registry
.load(manifest, plugin.clone(), hc)
.await
.expect("load should succeed");
assert_eq!(id.as_str(), "lights");
assert!(*plugin.setup_called.lock().await, "setup should have been called");
let listing = registry.list().await;
assert_eq!(listing.len(), 1);
assert_eq!(listing[0].0.as_str(), "lights");
}
#[tokio::test]
async fn registry_unload_removes_plugin() {
let hc = HomeCore::new();
let registry = PluginRegistry::new(InProcessRuntime);
let plugin = TestPlugin::new();
let id = registry
.load(minimal_manifest("switch"), plugin.clone(), hc)
.await
.expect("load should succeed");
registry.unload(&id).await.expect("unload should succeed");
assert!(*plugin.unload_called.lock().await, "unload should have been called");
assert_eq!(registry.list().await.len(), 0);
}
#[tokio::test]
async fn registry_rejects_duplicate_load() {
let hc1 = HomeCore::new();
let hc2 = HomeCore::new();
let registry = PluginRegistry::new(InProcessRuntime);
registry
.load(minimal_manifest("sensor"), TestPlugin::new(), hc1)
.await
.expect("first load should succeed");
let err = registry
.load(minimal_manifest("sensor"), TestPlugin::new(), hc2)
.await
.unwrap_err();
assert!(
matches!(err, PluginError::AlreadyLoaded(_)),
"expected AlreadyLoaded, got: {err:?}"
);
}
#[tokio::test]
async fn registry_unload_unknown_plugin_returns_not_found() {
let registry = PluginRegistry::new(InProcessRuntime);
let id = PluginId::new("nonexistent");
let err = registry.unload(&id).await.unwrap_err();
assert!(
matches!(err, PluginError::NotFound(_)),
"expected NotFound, got: {err:?}"
);
}
#[tokio::test]
async fn in_process_runtime_setup_called() {
let hc = HomeCore::new();
let registry = PluginRegistry::new(InProcessRuntime);
let plugin = TestPlugin::new();
registry
.load(minimal_manifest("climate"), plugin.clone(), hc)
.await
.expect("load should succeed");
assert!(
*plugin.setup_called.lock().await,
"InProcessRuntime must call setup"
);
}
// ── Error display ──────────────────────────────────────────────────────
#[test]
fn error_display_variants() {
let e1 = PluginError::AlreadyLoaded("mqtt".into());
assert!(e1.to_string().contains("mqtt"));
let e2 = PluginError::NotFound("climate".into());
assert!(e2.to_string().contains("climate"));
let e3 = PluginError::RuntimeError("boom".into());
assert!(e3.to_string().contains("boom"));
}
}