use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use async_trait::async_trait;
use khive_gate::{ActorRef, AllowAllGate, AuditEvent, GateDecision, GateRef, GateRequest};
use khive_storage::{Event, EventStore, SubstrateKind};
use khive_types::{EventOutcome, Namespace};
use serde_json::Value;
pub use khive_types::{EdgeEndpointRule, EndpointKind, VerbDef};
use crate::error::{
CircularPackDependency, MissingPackDependencies, MissingPackDependency, RuntimeError,
};
use crate::KhiveRuntime;
#[async_trait]
pub trait PackRuntime: Send + Sync {
fn name(&self) -> &str;
fn note_kinds(&self) -> &'static [&'static str];
fn entity_kinds(&self) -> &'static [&'static str];
fn verbs(&self) -> &'static [VerbDef];
fn edge_rules(&self) -> &'static [EdgeEndpointRule] {
&[]
}
fn requires(&self) -> &'static [&'static str] {
&[]
}
fn kind_hook(&self, _kind: &str) -> Option<Arc<dyn KindHook>> {
None
}
async fn dispatch(
&self,
verb: &str,
params: Value,
registry: &VerbRegistry,
) -> Result<Value, RuntimeError>;
}
#[async_trait]
pub trait KindHook: Send + Sync + std::fmt::Debug {
async fn prepare_create(
&self,
runtime: &KhiveRuntime,
args: &mut Value,
) -> Result<(), RuntimeError>;
async fn after_create(
&self,
runtime: &KhiveRuntime,
id: uuid::Uuid,
args: &Value,
) -> Result<(), RuntimeError>;
}
pub struct VerbRegistryBuilder {
packs: Vec<Box<dyn PackRuntime>>,
gate: GateRef,
default_namespace: String,
event_store: Option<Arc<dyn EventStore>>,
}
impl VerbRegistryBuilder {
pub fn new() -> Self {
Self {
packs: Vec::new(),
gate: std::sync::Arc::new(AllowAllGate),
default_namespace: Namespace::default_ns().as_str().to_string(),
event_store: None,
}
}
pub fn register<P: khive_types::Pack + PackRuntime + 'static>(&mut self, pack: P) -> &mut Self {
self.packs.push(Box::new(pack));
self
}
pub fn with_gate(&mut self, gate: GateRef) -> &mut Self {
self.gate = gate;
self
}
pub fn with_default_namespace(&mut self, ns: impl Into<String>) -> &mut Self {
self.default_namespace = ns.into();
self
}
pub fn with_event_store(&mut self, store: Arc<dyn EventStore>) -> &mut Self {
self.event_store = Some(store);
self
}
pub fn build(self) -> Result<VerbRegistry, RuntimeError> {
let packs = self.packs;
let mut name_to_idx: HashMap<&str, usize> = HashMap::with_capacity(packs.len());
for (idx, pack) in packs.iter().enumerate() {
if let Some(prev_idx) = name_to_idx.insert(pack.name(), idx) {
return Err(RuntimeError::PackRedeclared {
name: pack.name().to_string(),
first_idx: prev_idx,
second_idx: idx,
});
}
}
let mut missing: Vec<MissingPackDependency> = Vec::new();
let mut indegree = vec![0usize; packs.len()];
let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); packs.len()];
for (idx, pack) in packs.iter().enumerate() {
for &requires in pack.requires() {
match name_to_idx.get(requires).copied() {
Some(dep_idx) => {
dependents[dep_idx].push(idx);
indegree[idx] += 1;
}
None => missing.push(MissingPackDependency {
from: pack.name().to_string(),
requires: requires.to_string(),
}),
}
}
}
if !missing.is_empty() {
return if missing.len() == 1 {
Err(RuntimeError::MissingPackDependency(missing.remove(0)))
} else {
Err(RuntimeError::MissingPackDependencies(
MissingPackDependencies { missing },
))
};
}
let mut ready: VecDeque<usize> = indegree
.iter()
.enumerate()
.filter_map(|(idx, degree)| (*degree == 0).then_some(idx))
.collect();
let mut ordered_indices = Vec::with_capacity(packs.len());
while let Some(idx) = ready.pop_front() {
ordered_indices.push(idx);
for &dep_idx in &dependents[idx] {
indegree[dep_idx] -= 1;
if indegree[dep_idx] == 0 {
ready.push_back(dep_idx);
}
}
}
if ordered_indices.len() != packs.len() {
let cycle_nodes: HashSet<usize> = indegree
.iter()
.enumerate()
.filter_map(|(idx, degree)| (*degree > 0).then_some(idx))
.collect();
let cycle = find_pack_dependency_cycle(&packs, &name_to_idx, &cycle_nodes);
return Err(RuntimeError::CircularPackDependency(
CircularPackDependency { cycle },
));
}
let mut slots: Vec<Option<Box<dyn PackRuntime>>> = packs.into_iter().map(Some).collect();
let ordered_packs: Vec<Box<dyn PackRuntime>> = ordered_indices
.into_iter()
.map(|idx| slots[idx].take().expect("topological index must exist"))
.collect();
Ok(VerbRegistry {
packs: Arc::new(ordered_packs),
gate: self.gate,
default_namespace: self.default_namespace,
event_store: self.event_store,
})
}
}
fn find_pack_dependency_cycle(
packs: &[Box<dyn PackRuntime>],
name_to_idx: &HashMap<&str, usize>,
cycle_nodes: &HashSet<usize>,
) -> Vec<String> {
fn visit(
idx: usize,
packs: &[Box<dyn PackRuntime>],
name_to_idx: &HashMap<&str, usize>,
cycle_nodes: &HashSet<usize>,
visiting: &mut Vec<usize>,
visited: &mut HashSet<usize>,
) -> Option<Vec<String>> {
if let Some(pos) = visiting.iter().position(|&seen| seen == idx) {
let mut cycle: Vec<String> = visiting[pos..]
.iter()
.map(|&i| packs[i].name().to_string())
.collect();
cycle.push(packs[idx].name().to_string());
return Some(cycle);
}
if !visited.insert(idx) {
return None;
}
visiting.push(idx);
for &req in packs[idx].requires() {
let Some(&dep_idx) = name_to_idx.get(req) else {
continue;
};
if cycle_nodes.contains(&dep_idx) {
if let Some(cycle) =
visit(dep_idx, packs, name_to_idx, cycle_nodes, visiting, visited)
{
return Some(cycle);
}
}
}
visiting.pop();
None
}
let mut visited = HashSet::new();
for &idx in cycle_nodes {
let mut visiting = Vec::new();
if let Some(cycle) = visit(
idx,
packs,
name_to_idx,
cycle_nodes,
&mut visiting,
&mut visited,
) {
return cycle;
}
}
cycle_nodes
.iter()
.map(|&idx| packs[idx].name().to_string())
.collect()
}
impl Default for VerbRegistryBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone)]
pub struct VerbRegistry {
packs: std::sync::Arc<Vec<Box<dyn PackRuntime>>>,
gate: GateRef,
default_namespace: String,
event_store: Option<Arc<dyn EventStore>>,
}
impl VerbRegistry {
pub async fn dispatch(&self, verb: &str, params: Value) -> Result<Value, RuntimeError> {
let ns_str = params
.get("namespace")
.and_then(Value::as_str)
.unwrap_or(&self.default_namespace);
let gate_req = GateRequest::new(
ActorRef::anonymous(),
Namespace::new(ns_str),
verb,
params.clone(),
);
let gate_blocked = match self.gate.check(&gate_req) {
Ok(decision) => {
let is_deny = matches!(decision, GateDecision::Deny { .. });
let audit = AuditEvent::from_check(&gate_req, &decision, self.gate.impl_name());
tracing::info!(
audit_event = %serde_json::to_string(&audit)
.unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
"gate.check"
);
if let Some(store) = &self.event_store {
let outcome = if is_deny {
EventOutcome::Denied
} else {
EventOutcome::Success
};
let audit_data = serde_json::to_value(&audit).unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to serialize AuditEvent for EventStore");
serde_json::Value::Null
});
let storage_event = Event::new(
gate_req.namespace.as_str(),
verb,
SubstrateKind::Event,
format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
)
.with_outcome(outcome)
.with_data(audit_data);
if let Err(store_err) = store.append_event(storage_event).await {
tracing::warn!(
verb,
error = %store_err,
"audit event store write failed (non-fatal)"
);
}
}
if is_deny {
let reason = match decision {
GateDecision::Deny { reason } => reason,
_ => String::new(),
};
Some(reason)
} else {
None
}
}
Err(err) => {
tracing::warn!(verb, error = %err, "gate check failed (fail-open)");
None
}
};
if let Some(reason) = gate_blocked {
return Err(RuntimeError::PermissionDenied {
verb: verb.to_string(),
reason,
});
}
for pack in self.packs.iter() {
if pack.verbs().iter().any(|v| v.name == verb) {
return pack.dispatch(verb, params, self).await;
}
}
let available: Vec<&str> = self
.packs
.iter()
.flat_map(|p| p.verbs().iter().map(|v| v.name))
.collect();
Err(RuntimeError::InvalidInput(format!(
"unknown verb {verb:?}; available: {}",
available.join(", ")
)))
}
pub fn find_kind_hook(&self, kind: &str) -> Option<Arc<dyn KindHook>> {
for pack in self.packs.iter() {
let owns = pack.note_kinds().contains(&kind) || pack.entity_kinds().contains(&kind);
if owns {
if let Some(hook) = pack.kind_hook(kind) {
return Some(hook);
}
}
}
None
}
pub fn all_verbs(&self) -> Vec<&'static VerbDef> {
self.packs.iter().flat_map(|p| p.verbs().iter()).collect()
}
pub fn all_note_kinds(&self) -> Vec<&'static str> {
let mut seen = std::collections::HashSet::new();
self.packs
.iter()
.flat_map(|p| p.note_kinds().iter().copied())
.filter(|k| seen.insert(*k))
.collect()
}
pub fn all_entity_kinds(&self) -> Vec<&'static str> {
let mut seen = std::collections::HashSet::new();
self.packs
.iter()
.flat_map(|p| p.entity_kinds().iter().copied())
.filter(|k| seen.insert(*k))
.collect()
}
pub fn pack_names(&self) -> Vec<&str> {
self.packs.iter().map(|p| p.name()).collect()
}
pub fn pack_requires(&self, name: &str) -> Option<&'static [&'static str]> {
self.packs
.iter()
.find(|p| p.name() == name)
.map(|p| p.requires())
}
pub fn all_edge_rules(&self) -> Vec<EdgeEndpointRule> {
self.packs
.iter()
.flat_map(|p| p.edge_rules().iter().copied())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use khive_types::Pack;
struct AlphaPack;
impl Pack for AlphaPack {
const NAME: &'static str = "alpha";
const NOTE_KINDS: &'static [&'static str] = &["memo", "log"];
const ENTITY_KINDS: &'static [&'static str] = &["widget"];
const VERBS: &'static [VerbDef] = &[
VerbDef {
name: "create",
description: "create a widget",
},
VerbDef {
name: "list",
description: "list widgets",
},
];
}
#[async_trait]
impl PackRuntime for AlphaPack {
fn name(&self) -> &str {
AlphaPack::NAME
}
fn note_kinds(&self) -> &'static [&'static str] {
AlphaPack::NOTE_KINDS
}
fn entity_kinds(&self) -> &'static [&'static str] {
AlphaPack::ENTITY_KINDS
}
fn verbs(&self) -> &'static [VerbDef] {
AlphaPack::VERBS
}
async fn dispatch(
&self,
verb: &str,
_params: Value,
_registry: &VerbRegistry,
) -> Result<Value, RuntimeError> {
Ok(serde_json::json!({ "pack": "alpha", "verb": verb }))
}
}
struct BetaPack;
impl Pack for BetaPack {
const NAME: &'static str = "beta";
const NOTE_KINDS: &'static [&'static str] = &["log", "alert"];
const ENTITY_KINDS: &'static [&'static str] = &["widget", "gadget"];
const VERBS: &'static [VerbDef] = &[
VerbDef {
name: "notify",
description: "send alert",
},
VerbDef {
name: "create",
description: "create a gadget",
},
];
}
#[async_trait]
impl PackRuntime for BetaPack {
fn name(&self) -> &str {
BetaPack::NAME
}
fn note_kinds(&self) -> &'static [&'static str] {
BetaPack::NOTE_KINDS
}
fn entity_kinds(&self) -> &'static [&'static str] {
BetaPack::ENTITY_KINDS
}
fn verbs(&self) -> &'static [VerbDef] {
BetaPack::VERBS
}
async fn dispatch(
&self,
verb: &str,
_params: Value,
_registry: &VerbRegistry,
) -> Result<Value, RuntimeError> {
Ok(serde_json::json!({ "pack": "beta", "verb": verb }))
}
}
fn build_registry() -> VerbRegistry {
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.register(BetaPack);
builder.build().expect("registry builds")
}
#[tokio::test]
async fn dispatch_routes_to_correct_pack() {
let reg = build_registry();
let res = reg.dispatch("list", Value::Null).await.unwrap();
assert_eq!(res["pack"], "alpha");
let res = reg.dispatch("notify", Value::Null).await.unwrap();
assert_eq!(res["pack"], "beta");
}
#[tokio::test]
async fn dispatch_first_registered_wins_on_collision() {
let reg = build_registry();
let res = reg.dispatch("create", Value::Null).await.unwrap();
assert_eq!(res["pack"], "alpha", "first registered pack wins");
}
#[tokio::test]
async fn dispatch_unknown_verb_returns_error() {
let reg = build_registry();
let err = reg.dispatch("explode", Value::Null).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("explode"));
assert!(msg.contains("create"));
}
#[test]
fn all_verbs_aggregates_across_packs() {
let reg = build_registry();
let verbs: Vec<&str> = reg.all_verbs().iter().map(|v| v.name).collect();
assert_eq!(verbs, vec!["create", "list", "notify", "create"]);
}
#[test]
fn note_kinds_are_deduplicated() {
let reg = build_registry();
let kinds = reg.all_note_kinds();
assert_eq!(kinds, vec!["memo", "log", "alert"]);
}
#[test]
fn entity_kinds_are_deduplicated() {
let reg = build_registry();
let kinds = reg.all_entity_kinds();
assert_eq!(kinds, vec!["widget", "gadget"]);
}
use khive_gate::{Gate, GateError};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
#[derive(Default, Debug)]
struct CountingGate {
calls: AtomicUsize,
deny_verb: Option<&'static str>,
}
impl Gate for CountingGate {
fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
self.calls.fetch_add(1, Ordering::SeqCst);
if Some(req.verb.as_str()) == self.deny_verb {
Ok(GateDecision::deny(format!("test deny for {}", req.verb)))
} else {
Ok(GateDecision::allow())
}
}
}
#[tokio::test]
async fn dispatch_consults_the_gate() {
let gate = Arc::new(CountingGate::default());
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(gate.clone());
let reg = builder.build().expect("registry builds");
reg.dispatch("list", Value::Null).await.unwrap();
reg.dispatch("create", Value::Null).await.unwrap();
assert_eq!(
gate.calls.load(Ordering::SeqCst),
2,
"gate should be consulted once per dispatch"
);
}
#[tokio::test]
async fn dispatch_returns_permission_denied_on_deny_v03() {
let gate = Arc::new(CountingGate {
calls: AtomicUsize::new(0),
deny_verb: Some("create"),
});
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(gate.clone());
let reg = builder.build().expect("registry builds");
let err = reg.dispatch("create", Value::Null).await.unwrap_err();
assert!(
matches!(err, RuntimeError::PermissionDenied { ref verb, .. } if verb == "create"),
"expected PermissionDenied, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("create"),
"error message must name the verb: {msg}"
);
assert!(
msg.contains("test deny for create"),
"error message must carry the deny reason: {msg}"
);
assert_eq!(gate.calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn dispatch_allow_verb_succeeds_even_with_deny_gate_for_other_verb() {
let gate = Arc::new(CountingGate {
calls: AtomicUsize::new(0),
deny_verb: Some("create"),
});
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(gate.clone());
let reg = builder.build().expect("registry builds");
let res = reg.dispatch("list", Value::Null).await.unwrap();
assert_eq!(res["pack"], "alpha");
}
#[tokio::test]
async fn dispatch_uses_allow_all_gate_by_default() {
let reg = build_registry();
let res = reg.dispatch("list", Value::Null).await.unwrap();
assert_eq!(res["pack"], "alpha");
}
#[derive(Default, Debug)]
struct NamespaceCapturingGate {
seen: std::sync::Mutex<Vec<String>>,
}
impl Gate for NamespaceCapturingGate {
fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
self.seen
.lock()
.unwrap()
.push(req.namespace.as_str().to_string());
Ok(GateDecision::allow())
}
}
#[tokio::test]
async fn dispatch_propagates_params_namespace_to_gate() {
let gate = Arc::new(NamespaceCapturingGate::default());
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(gate.clone());
builder.with_default_namespace("tenant-x");
let reg = builder.build().expect("registry builds");
reg.dispatch("list", serde_json::json!({"namespace": "tenant-y"}))
.await
.unwrap();
reg.dispatch("list", Value::Null).await.unwrap();
reg.dispatch("list", serde_json::json!({"namespace": ""}))
.await
.unwrap();
let seen = gate.seen.lock().unwrap().clone();
assert_eq!(seen, vec!["tenant-y", "tenant-x", ""]);
}
#[tokio::test]
async fn dispatch_falls_back_to_local_when_no_default_set() {
let gate = Arc::new(NamespaceCapturingGate::default());
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(gate.clone());
let reg = builder.build().expect("registry builds");
reg.dispatch("list", Value::Null).await.unwrap();
let seen = gate.seen.lock().unwrap().clone();
assert_eq!(seen, vec!["local"]);
}
use khive_gate::{AuditDecision, AuditEvent, Obligation};
#[derive(Default, Debug)]
struct AuditCapturingGate {
events: std::sync::Mutex<Vec<AuditEvent>>,
deny_verb: Option<&'static str>,
}
impl Gate for AuditCapturingGate {
fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
let decision = if Some(req.verb.as_str()) == self.deny_verb {
GateDecision::deny("test deny")
} else {
GateDecision::allow_with(vec![Obligation::Audit {
tag: format!("{}.check", req.verb),
}])
};
let ev = AuditEvent::from_check(req, &decision, self.impl_name());
self.events.lock().unwrap().push(ev);
Ok(decision)
}
fn impl_name(&self) -> &'static str {
"AuditCapturingGate"
}
}
#[tokio::test]
async fn dispatch_emits_one_audit_event_per_call() {
let gate = Arc::new(AuditCapturingGate::default());
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(gate.clone());
let reg = builder.build().expect("registry builds");
reg.dispatch("list", Value::Null).await.unwrap();
reg.dispatch("create", Value::Null).await.unwrap();
let evs = gate.events.lock().unwrap();
assert_eq!(evs.len(), 2, "exactly one audit event per dispatch call");
}
#[tokio::test]
async fn dispatch_audit_event_allow_carries_obligations() {
let gate = Arc::new(AuditCapturingGate::default());
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(gate.clone());
let reg = builder.build().expect("registry builds");
reg.dispatch("list", Value::Null).await.unwrap();
let evs = gate.events.lock().unwrap();
let ev = &evs[0];
assert_eq!(ev.verb, "list");
assert_eq!(ev.decision, AuditDecision::Allow);
assert!(ev.deny_reason.is_none());
assert_eq!(ev.obligations.len(), 1);
assert_eq!(ev.gate_impl, "AuditCapturingGate");
}
#[tokio::test]
async fn dispatch_audit_event_deny_carries_reason() {
let gate = Arc::new(AuditCapturingGate {
events: Default::default(),
deny_verb: Some("create"),
});
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(gate.clone());
let reg = builder.build().expect("registry builds");
let err = reg.dispatch("create", Value::Null).await.unwrap_err();
assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
let evs = gate.events.lock().unwrap();
let ev = &evs[0];
assert_eq!(ev.verb, "create");
assert_eq!(ev.decision, AuditDecision::Deny);
assert_eq!(ev.deny_reason.as_deref(), Some("test deny"));
assert!(ev.obligations.is_empty());
}
#[tokio::test]
async fn dispatch_audit_event_fields_match_gate_request() {
let gate = Arc::new(AuditCapturingGate::default());
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(gate.clone());
builder.with_default_namespace("tenant-z");
let reg = builder.build().expect("registry builds");
reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
.await
.unwrap();
let evs = gate.events.lock().unwrap();
let ev = &evs[0];
assert_eq!(ev.namespace, "tenant-q");
assert_eq!(ev.verb, "list");
assert_eq!(ev.actor.kind, "anonymous");
}
use std::sync::Mutex as StdMutex;
use tracing::field::{Field, Visit};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::Layer;
#[derive(Clone, Debug, Default)]
struct CapturedEvent {
message: Option<String>,
audit_event: Option<String>,
}
#[derive(Default)]
struct CapturedEventVisitor(CapturedEvent);
impl Visit for CapturedEventVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
match field.name() {
"message" => self.0.message = Some(value.to_string()),
"audit_event" => self.0.audit_event = Some(value.to_string()),
_ => {}
}
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
let formatted = format!("{value:?}");
let cleaned = formatted
.trim_start_matches('"')
.trim_end_matches('"')
.to_string();
match field.name() {
"message" => self.0.message = Some(cleaned),
"audit_event" => self.0.audit_event = Some(cleaned),
_ => {}
}
}
}
struct CaptureLayer(Arc<StdMutex<Vec<CapturedEvent>>>);
impl<S: tracing::Subscriber> Layer<S> for CaptureLayer {
fn on_event(
&self,
event: &tracing::Event<'_>,
_: tracing_subscriber::layer::Context<'_, S>,
) {
let mut visitor = CapturedEventVisitor::default();
event.record(&mut visitor);
self.0.lock().unwrap().push(visitor.0);
}
}
fn capture_dispatch_events<Fut>(future: Fut) -> Vec<CapturedEvent>
where
Fut: std::future::Future<Output = ()>,
{
let captured: Arc<StdMutex<Vec<CapturedEvent>>> = Arc::new(StdMutex::new(Vec::new()));
let subscriber = tracing_subscriber::registry().with(CaptureLayer(Arc::clone(&captured)));
tracing::subscriber::with_default(subscriber, || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build current-thread tokio runtime");
rt.block_on(future);
});
let guard = captured.lock().unwrap();
guard.clone()
}
fn gate_check_events(events: &[CapturedEvent]) -> Vec<&CapturedEvent> {
events
.iter()
.filter(|e| e.message.as_deref() == Some("gate.check"))
.collect()
}
#[test]
fn dispatch_tracing_emits_one_gate_check_event_on_allow() {
let events = capture_dispatch_events(async {
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(Arc::new(AllowAllGate));
builder.with_default_namespace("tenant-default");
let reg = builder.build().expect("registry builds");
reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
.await
.unwrap();
});
let gate_events = gate_check_events(&events);
assert_eq!(
gate_events.len(),
1,
"exactly one gate.check tracing event per dispatch (allow); got {gate_events:?}"
);
let payload = gate_events[0]
.audit_event
.as_ref()
.expect("gate.check event must carry an audit_event field");
let audit: khive_gate::AuditEvent =
serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
assert_eq!(audit.decision, AuditDecision::Allow);
assert_eq!(audit.verb, "list");
assert_eq!(audit.namespace, "tenant-q");
assert_eq!(audit.gate_impl, "AllowAllGate");
assert!(
audit.deny_reason.is_none(),
"deny_reason must be None on Allow"
);
}
use async_trait::async_trait;
use khive_storage::{
BatchWriteSummary, Event, EventFilter, EventStore, Page, PageRequest, SubstrateKind,
};
use khive_types::EventOutcome;
#[derive(Default, Debug)]
struct MemoryEventStore {
events: std::sync::Mutex<Vec<Event>>,
}
#[async_trait]
impl EventStore for MemoryEventStore {
async fn append_event(&self, event: Event) -> khive_storage::StorageResult<()> {
self.events.lock().unwrap().push(event);
Ok(())
}
async fn append_events(
&self,
events: Vec<Event>,
) -> khive_storage::StorageResult<BatchWriteSummary> {
let attempted = events.len() as u64;
let affected = attempted;
self.events.lock().unwrap().extend(events);
Ok(BatchWriteSummary {
attempted,
affected,
failed: 0,
first_error: String::new(),
})
}
async fn get_event(&self, id: uuid::Uuid) -> khive_storage::StorageResult<Option<Event>> {
Ok(self
.events
.lock()
.unwrap()
.iter()
.find(|e| e.id == id)
.cloned())
}
async fn query_events(
&self,
_filter: EventFilter,
_page: PageRequest,
) -> khive_storage::StorageResult<Page<Event>> {
let items = self.events.lock().unwrap().clone();
let total = items.len() as u64;
Ok(Page {
items,
total: Some(total),
})
}
async fn count_events(&self, _filter: EventFilter) -> khive_storage::StorageResult<u64> {
Ok(self.events.lock().unwrap().len() as u64)
}
}
#[tokio::test]
async fn allow_all_gate_default_remains_backward_compatible() {
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
let reg = builder.build().expect("registry builds");
let res = reg.dispatch("list", Value::Null).await.unwrap();
assert_eq!(
res["pack"], "alpha",
"AllowAllGate must allow every verb — backward compat guarantee"
);
let res = reg.dispatch("create", Value::Null).await.unwrap();
assert_eq!(res["pack"], "alpha");
}
#[tokio::test]
async fn deny_gate_returns_permission_denied_pack_never_invoked() {
#[derive(Debug)]
struct AlwaysDenyGate;
impl Gate for AlwaysDenyGate {
fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
Ok(GateDecision::deny("test: always deny"))
}
}
#[derive(Debug)]
struct TrackedPack {
invoked: Arc<AtomicUsize>,
}
impl khive_types::Pack for TrackedPack {
const NAME: &'static str = "tracked";
const NOTE_KINDS: &'static [&'static str] = &[];
const ENTITY_KINDS: &'static [&'static str] = &[];
const VERBS: &'static [VerbDef] = &[VerbDef {
name: "guarded",
description: "a guarded verb",
}];
}
#[async_trait]
impl PackRuntime for TrackedPack {
fn name(&self) -> &str {
Self::NAME
}
fn note_kinds(&self) -> &'static [&'static str] {
Self::NOTE_KINDS
}
fn entity_kinds(&self) -> &'static [&'static str] {
Self::ENTITY_KINDS
}
fn verbs(&self) -> &'static [VerbDef] {
Self::VERBS
}
async fn dispatch(
&self,
_verb: &str,
_params: Value,
_registry: &VerbRegistry,
) -> Result<Value, RuntimeError> {
self.invoked.fetch_add(1, Ordering::SeqCst);
Ok(serde_json::json!({"invoked": true}))
}
}
let invoked = Arc::new(AtomicUsize::new(0));
let mut builder = VerbRegistryBuilder::new();
builder.register(TrackedPack {
invoked: invoked.clone(),
});
builder.with_gate(Arc::new(AlwaysDenyGate));
let reg = builder.build().expect("registry builds");
let err = reg.dispatch("guarded", Value::Null).await.unwrap_err();
assert!(
matches!(err, RuntimeError::PermissionDenied { ref verb, ref reason } if verb == "guarded" && reason.contains("always deny")),
"expected PermissionDenied with verb=guarded and reason, got: {err:?}"
);
assert_eq!(
invoked.load(Ordering::SeqCst),
0,
"pack dispatch MUST NOT be invoked when gate denies"
);
}
#[tokio::test]
async fn audit_event_persists_to_event_store_on_allow() {
let store = Arc::new(MemoryEventStore::default());
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_event_store(store.clone());
let reg = builder.build().expect("registry builds");
reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
.await
.unwrap();
let count = store.count_events(EventFilter::default()).await.unwrap();
assert_eq!(count, 1, "one audit event persisted to EventStore on allow");
let page = store
.query_events(
EventFilter::default(),
PageRequest {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
let ev = &page.items[0];
assert_eq!(ev.verb, "list");
assert_eq!(ev.namespace, "test-ns");
assert_eq!(ev.substrate, SubstrateKind::Event);
assert_eq!(ev.outcome, EventOutcome::Success);
}
#[tokio::test]
async fn audit_event_persists_to_event_store_on_deny() {
#[derive(Debug)]
struct AlwaysDenyGate;
impl Gate for AlwaysDenyGate {
fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
Ok(GateDecision::deny("denied by test"))
}
}
let store = Arc::new(MemoryEventStore::default());
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(Arc::new(AlwaysDenyGate));
builder.with_event_store(store.clone());
let reg = builder.build().expect("registry builds");
let err = reg
.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
.await
.unwrap_err();
assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
let count = store.count_events(EventFilter::default()).await.unwrap();
assert_eq!(count, 1, "one audit event persisted to EventStore on deny");
let page = store
.query_events(
EventFilter::default(),
PageRequest {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
let ev = &page.items[0];
assert_eq!(ev.verb, "list");
assert_eq!(ev.outcome, EventOutcome::Denied);
}
#[tokio::test]
async fn gate_error_does_not_persist_to_event_store() {
#[derive(Debug)]
struct FailingGate;
impl Gate for FailingGate {
fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
Err(khive_gate::GateError::Internal("gate broken".into()))
}
}
let store = Arc::new(MemoryEventStore::default());
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(Arc::new(FailingGate));
builder.with_event_store(store.clone());
let reg = builder.build().expect("registry builds");
let res = reg.dispatch("list", Value::Null).await.unwrap();
assert_eq!(
res["pack"], "alpha",
"gate error must fail-open, not block dispatch"
);
let count = store.count_events(EventFilter::default()).await.unwrap();
assert_eq!(
count, 0,
"gate infrastructure error must NOT produce an audit event in EventStore"
);
}
#[tokio::test]
async fn no_event_store_configured_tracing_only() {
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
let reg = builder.build().expect("registry builds");
let res = reg.dispatch("list", Value::Null).await.unwrap();
assert_eq!(res["pack"], "alpha");
}
#[test]
fn dispatch_tracing_emits_gate_check_event_with_deny_payload() {
#[derive(Debug)]
struct AlwaysDenyGate;
impl Gate for AlwaysDenyGate {
fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
Ok(GateDecision::deny("denied by test gate"))
}
fn impl_name(&self) -> &'static str {
"AlwaysDenyGate"
}
}
let events = capture_dispatch_events(async {
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(Arc::new(AlwaysDenyGate));
let reg = builder.build().expect("registry builds");
let _ = reg.dispatch("create", serde_json::Value::Null).await;
});
let gate_events = gate_check_events(&events);
assert_eq!(
gate_events.len(),
1,
"exactly one gate.check tracing event per dispatch (deny); got {gate_events:?}"
);
let payload = gate_events[0]
.audit_event
.as_ref()
.expect("gate.check event must carry an audit_event field on Deny");
let audit: khive_gate::AuditEvent =
serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
assert_eq!(audit.decision, AuditDecision::Deny);
assert_eq!(audit.deny_reason.as_deref(), Some("denied by test gate"));
assert_eq!(audit.gate_impl, "AlwaysDenyGate");
let payload_json: serde_json::Value =
serde_json::from_str(payload).expect("payload must be valid JSON");
assert_eq!(
payload_json["obligations"],
serde_json::Value::Array(Vec::new()),
"obligations must be `[]` on Deny on the tracing payload, not omitted"
);
}
#[tokio::test]
async fn audit_envelope_round_trips_deny_reason_and_gate_impl_through_event_store() {
#[derive(Debug)]
struct DenyGateWithName;
impl Gate for DenyGateWithName {
fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
Ok(GateDecision::deny("policy: write forbidden for anon"))
}
fn impl_name(&self) -> &'static str {
"DenyGateWithName"
}
}
let store = Arc::new(MemoryEventStore::default());
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(Arc::new(DenyGateWithName));
builder.with_event_store(store.clone());
let reg = builder.build().expect("registry builds");
let err = reg
.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
.await
.unwrap_err();
assert!(
matches!(err, RuntimeError::PermissionDenied { .. }),
"expected PermissionDenied, got {err:?}"
);
let page = store
.query_events(
EventFilter::default(),
PageRequest {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(
page.items.len(),
1,
"one audit event must be persisted on deny"
);
let ev = &page.items[0];
assert_eq!(ev.outcome, EventOutcome::Denied);
let data = ev
.data
.as_ref()
.expect("Event.data must be Some — full AuditEvent envelope must be persisted");
let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
.expect("Event.data must deserialize to AuditEvent");
assert_eq!(
audit.deny_reason.as_deref(),
Some("policy: write forbidden for anon"),
"deny_reason must be preserved through EventStore"
);
assert_eq!(
audit.gate_impl, "DenyGateWithName",
"gate_impl must be preserved through EventStore"
);
assert_eq!(
audit.decision,
khive_gate::AuditDecision::Deny,
"decision field must be preserved through EventStore"
);
}
#[tokio::test]
async fn audit_envelope_round_trips_obligations_through_event_store() {
use khive_gate::Obligation;
#[derive(Debug)]
struct ObligationGate;
impl Gate for ObligationGate {
fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
Ok(GateDecision::allow_with(vec![Obligation::Audit {
tag: "billing.meter".into(),
}]))
}
fn impl_name(&self) -> &'static str {
"ObligationGate"
}
}
let store = Arc::new(MemoryEventStore::default());
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(Arc::new(ObligationGate));
builder.with_event_store(store.clone());
let reg = builder.build().expect("registry builds");
reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
.await
.unwrap();
let page = store
.query_events(
EventFilter::default(),
PageRequest {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(page.items.len(), 1);
let ev = &page.items[0];
assert_eq!(ev.outcome, EventOutcome::Success);
let data = ev
.data
.as_ref()
.expect("Event.data must be Some — AuditEvent envelope must be persisted on allow");
let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
.expect("Event.data must deserialize to AuditEvent");
assert_eq!(audit.gate_impl, "ObligationGate");
assert_eq!(
audit.obligations.len(),
1,
"obligations must be preserved through EventStore"
);
match &audit.obligations[0] {
Obligation::Audit { tag } => assert_eq!(tag, "billing.meter"),
other => panic!("expected Audit obligation, got {other:?}"),
}
}
#[tokio::test]
async fn sql_backed_audit_envelope_round_trips_deny_reason_gate_impl_and_obligations() {
#[derive(Debug)]
struct SqlTestDenyGate;
impl Gate for SqlTestDenyGate {
fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
Ok(GateDecision::deny("sql-path: write denied"))
}
fn impl_name(&self) -> &'static str {
"SqlTestDenyGate"
}
}
let rt = KhiveRuntime::memory().expect("in-memory runtime");
let sql_store = rt
.events(Some("test-ns"))
.expect("events_for_namespace must succeed");
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(Arc::new(SqlTestDenyGate));
builder.with_event_store(sql_store.clone());
let reg = builder.build().expect("registry builds");
let err = reg
.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
.await
.unwrap_err();
assert!(
matches!(err, RuntimeError::PermissionDenied { .. }),
"expected PermissionDenied, got {err:?}"
);
let page = sql_store
.query_events(
EventFilter::default(),
PageRequest {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(
page.items.len(),
1,
"one audit event must be persisted on deny through SqlEventStore"
);
let ev = &page.items[0];
assert_eq!(ev.outcome, EventOutcome::Denied);
let data = ev
.data
.as_ref()
.expect("Event.data must be Some — SqlEventStore must persist AuditEvent envelope");
let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
.expect("Event.data must deserialize to AuditEvent after SQL round-trip");
assert_eq!(
audit.deny_reason.as_deref(),
Some("sql-path: write denied"),
"deny_reason must survive the SQL text round-trip"
);
assert_eq!(
audit.gate_impl, "SqlTestDenyGate",
"gate_impl must survive the SQL text round-trip"
);
assert_eq!(
audit.decision,
khive_gate::AuditDecision::Deny,
"decision field must survive the SQL text round-trip"
);
assert!(
audit.obligations.is_empty(),
"obligations must be preserved as empty [] through SQL round-trip"
);
}
#[tokio::test]
async fn sql_backed_audit_envelope_round_trips_non_empty_obligations() {
use khive_gate::Obligation;
#[derive(Debug)]
struct SqlTestAllowWithObligationGate;
impl Gate for SqlTestAllowWithObligationGate {
fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
Ok(GateDecision::allow_with(vec![Obligation::Audit {
tag: "sql-path-billing.meter".into(),
}]))
}
fn impl_name(&self) -> &'static str {
"SqlTestAllowWithObligationGate"
}
}
let rt = KhiveRuntime::memory().expect("in-memory runtime");
let sql_store = rt
.events(Some("test-ns"))
.expect("events_for_namespace must succeed");
let mut builder = VerbRegistryBuilder::new();
builder.register(AlphaPack);
builder.with_gate(Arc::new(SqlTestAllowWithObligationGate));
builder.with_event_store(sql_store.clone());
let reg = builder.build().expect("registry builds");
reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
.await
.expect("dispatch must succeed when gate allows");
let page = sql_store
.query_events(
EventFilter::default(),
PageRequest {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(
page.items.len(),
1,
"one audit event must be persisted on allow through SqlEventStore"
);
let ev = &page.items[0];
assert_eq!(ev.outcome, EventOutcome::Success);
let data = ev
.data
.as_ref()
.expect("Event.data must be Some — SqlEventStore must persist AuditEvent envelope");
let obligations_raw = data
.get("obligations")
.expect("Event.data JSON must contain 'obligations' key");
let obligations_arr = obligations_raw
.as_array()
.expect("'obligations' must be a JSON array");
assert!(
!obligations_arr.is_empty(),
"raw Event.data['obligations'] must be non-empty after SQL round-trip"
);
let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
.expect("Event.data must deserialize to AuditEvent after SQL round-trip");
assert_eq!(
audit.gate_impl, "SqlTestAllowWithObligationGate",
"gate_impl must survive the SQL text round-trip"
);
assert_eq!(
audit.decision,
khive_gate::AuditDecision::Allow,
"decision field must survive the SQL text round-trip"
);
assert_eq!(
audit.obligations.len(),
1,
"obligations must be non-empty after SQL round-trip (not silently defaulted to [])"
);
match &audit.obligations[0] {
Obligation::Audit { tag } => assert_eq!(
tag, "sql-path-billing.meter",
"Audit obligation tag must survive the SQL text round-trip"
),
other => panic!("expected Audit obligation, got {other:?}"),
}
}
}
#[cfg(test)]
mod dep_tests {
use super::*;
use async_trait::async_trait;
use khive_types::Pack;
use serde_json::Value;
struct KgDepPack;
struct MemoryDepPack;
struct ADepPack;
struct BDepPack;
impl Pack for KgDepPack {
const NAME: &'static str = "kg_dep";
const NOTE_KINDS: &'static [&'static str] = &["observation"];
const ENTITY_KINDS: &'static [&'static str] = &["concept"];
const VERBS: &'static [VerbDef] = &[];
}
impl Pack for MemoryDepPack {
const NAME: &'static str = "memory_dep";
const NOTE_KINDS: &'static [&'static str] = &["memory"];
const ENTITY_KINDS: &'static [&'static str] = &[];
const VERBS: &'static [VerbDef] = &[];
const REQUIRES: &'static [&'static str] = &["kg_dep"];
}
impl Pack for ADepPack {
const NAME: &'static str = "pack_a";
const NOTE_KINDS: &'static [&'static str] = &[];
const ENTITY_KINDS: &'static [&'static str] = &[];
const VERBS: &'static [VerbDef] = &[];
const REQUIRES: &'static [&'static str] = &["pack_b"];
}
impl Pack for BDepPack {
const NAME: &'static str = "pack_b";
const NOTE_KINDS: &'static [&'static str] = &[];
const ENTITY_KINDS: &'static [&'static str] = &[];
const VERBS: &'static [VerbDef] = &[];
const REQUIRES: &'static [&'static str] = &["pack_a"];
}
#[async_trait]
impl PackRuntime for KgDepPack {
fn name(&self) -> &str {
Self::NAME
}
fn note_kinds(&self) -> &'static [&'static str] {
Self::NOTE_KINDS
}
fn entity_kinds(&self) -> &'static [&'static str] {
Self::ENTITY_KINDS
}
fn verbs(&self) -> &'static [VerbDef] {
Self::VERBS
}
async fn dispatch(
&self,
verb: &str,
_: Value,
_: &VerbRegistry,
) -> Result<Value, RuntimeError> {
Err(RuntimeError::InvalidInput(format!(
"KgDepPack has no verbs: {verb}"
)))
}
}
#[async_trait]
impl PackRuntime for MemoryDepPack {
fn name(&self) -> &str {
Self::NAME
}
fn note_kinds(&self) -> &'static [&'static str] {
Self::NOTE_KINDS
}
fn entity_kinds(&self) -> &'static [&'static str] {
Self::ENTITY_KINDS
}
fn verbs(&self) -> &'static [VerbDef] {
Self::VERBS
}
fn requires(&self) -> &'static [&'static str] {
Self::REQUIRES
}
async fn dispatch(
&self,
verb: &str,
_: Value,
_: &VerbRegistry,
) -> Result<Value, RuntimeError> {
Err(RuntimeError::InvalidInput(format!(
"MemoryDepPack has no verbs: {verb}"
)))
}
}
#[async_trait]
impl PackRuntime for ADepPack {
fn name(&self) -> &str {
Self::NAME
}
fn note_kinds(&self) -> &'static [&'static str] {
Self::NOTE_KINDS
}
fn entity_kinds(&self) -> &'static [&'static str] {
Self::ENTITY_KINDS
}
fn verbs(&self) -> &'static [VerbDef] {
Self::VERBS
}
fn requires(&self) -> &'static [&'static str] {
Self::REQUIRES
}
async fn dispatch(
&self,
verb: &str,
_: Value,
_: &VerbRegistry,
) -> Result<Value, RuntimeError> {
Err(RuntimeError::InvalidInput(format!(
"ADepPack has no verbs: {verb}"
)))
}
}
#[async_trait]
impl PackRuntime for BDepPack {
fn name(&self) -> &str {
Self::NAME
}
fn note_kinds(&self) -> &'static [&'static str] {
Self::NOTE_KINDS
}
fn entity_kinds(&self) -> &'static [&'static str] {
Self::ENTITY_KINDS
}
fn verbs(&self) -> &'static [VerbDef] {
Self::VERBS
}
fn requires(&self) -> &'static [&'static str] {
Self::REQUIRES
}
async fn dispatch(
&self,
verb: &str,
_: Value,
_: &VerbRegistry,
) -> Result<Value, RuntimeError> {
Err(RuntimeError::InvalidInput(format!(
"BDepPack has no verbs: {verb}"
)))
}
}
#[test]
fn test_pack_deps_happy_path() {
let mut builder = VerbRegistryBuilder::new();
builder.register(MemoryDepPack);
builder.register(KgDepPack);
let reg = builder
.build()
.expect("kg_dep satisfies memory_dep dependency");
assert_eq!(reg.pack_requires("memory_dep").unwrap(), &["kg_dep"]);
let names = reg.pack_names();
let kg_pos = names.iter().position(|&n| n == "kg_dep").unwrap();
let mem_pos = names.iter().position(|&n| n == "memory_dep").unwrap();
assert!(
kg_pos < mem_pos,
"kg_dep must be loaded before memory_dep; order: {names:?}"
);
}
#[test]
fn test_pack_deps_missing() {
let mut builder = VerbRegistryBuilder::new();
builder.register(MemoryDepPack);
let err = match builder.build() {
Ok(_) => panic!("expected Err, got Ok"),
Err(e) => e,
};
assert!(
matches!(err, RuntimeError::MissingPackDependency(_)),
"expected MissingPackDependency, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("memory_dep"),
"error must name the dependent pack: {msg}"
);
assert!(
msg.contains("kg_dep"),
"error must name the missing dep: {msg}"
);
}
#[test]
fn test_pack_deps_circular() {
let mut builder = VerbRegistryBuilder::new();
builder.register(ADepPack);
builder.register(BDepPack);
let err = match builder.build() {
Ok(_) => panic!("expected Err, got Ok"),
Err(e) => e,
};
assert!(
matches!(err, RuntimeError::CircularPackDependency(_)),
"expected CircularPackDependency, got {err:?}"
);
let msg = err.to_string();
assert!(msg.contains("pack_a"), "error must name pack_a: {msg}");
assert!(msg.contains("pack_b"), "error must name pack_b: {msg}");
}
#[test]
fn test_pack_deps_no_deps() {
struct NoDepsA;
struct NoDepsB;
impl Pack for NoDepsA {
const NAME: &'static str = "no_deps_a";
const NOTE_KINDS: &'static [&'static str] = &[];
const ENTITY_KINDS: &'static [&'static str] = &[];
const VERBS: &'static [VerbDef] = &[];
}
impl Pack for NoDepsB {
const NAME: &'static str = "no_deps_b";
const NOTE_KINDS: &'static [&'static str] = &[];
const ENTITY_KINDS: &'static [&'static str] = &[];
const VERBS: &'static [VerbDef] = &[];
}
#[async_trait]
impl PackRuntime for NoDepsA {
fn name(&self) -> &str {
Self::NAME
}
fn note_kinds(&self) -> &'static [&'static str] {
Self::NOTE_KINDS
}
fn entity_kinds(&self) -> &'static [&'static str] {
Self::ENTITY_KINDS
}
fn verbs(&self) -> &'static [VerbDef] {
Self::VERBS
}
async fn dispatch(
&self,
verb: &str,
_: Value,
_: &VerbRegistry,
) -> Result<Value, RuntimeError> {
Err(RuntimeError::InvalidInput(format!("NoDepsA: {verb}")))
}
}
#[async_trait]
impl PackRuntime for NoDepsB {
fn name(&self) -> &str {
Self::NAME
}
fn note_kinds(&self) -> &'static [&'static str] {
Self::NOTE_KINDS
}
fn entity_kinds(&self) -> &'static [&'static str] {
Self::ENTITY_KINDS
}
fn verbs(&self) -> &'static [VerbDef] {
Self::VERBS
}
async fn dispatch(
&self,
verb: &str,
_: Value,
_: &VerbRegistry,
) -> Result<Value, RuntimeError> {
Err(RuntimeError::InvalidInput(format!("NoDepsB: {verb}")))
}
}
let mut builder = VerbRegistryBuilder::new();
builder.register(NoDepsA);
builder.register(NoDepsB);
let reg = builder.build().expect("packs with REQUIRES=&[] build");
assert_eq!(reg.pack_requires("no_deps_a").unwrap(), &[] as &[&str]);
assert_eq!(reg.pack_requires("no_deps_b").unwrap(), &[] as &[&str]);
}
}