use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BehaviorSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_init: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_call: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_cast: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_info: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_state_change: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_terminate: Option<PathBuf>,
}
impl BehaviorSpec {
pub fn declared_slots(&self) -> impl Iterator<Item = (&'static str, &Path)> {
[
(
crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
self.on_init(),
),
(
crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_CALL,
self.on_call(),
),
(
crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
self.on_cast(),
),
(
crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_INFO,
self.on_info(),
),
(
crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
self.on_state_change(),
),
(
crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
self.on_terminate(),
),
]
.into_iter()
.filter_map(|(slot, opt)| opt.map(|p| (slot, p)))
}
pub fn declared_paths(&self) -> impl Iterator<Item = &Path> {
self.declared_slots().map(|(_slot, p)| p)
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.on_init.is_none()
&& self.on_call.is_none()
&& self.on_cast.is_none()
&& self.on_info.is_none()
&& self.on_state_change.is_none()
&& self.on_terminate.is_none()
}
#[must_use]
pub fn on_state_change(&self) -> Option<&Path> {
self.on_state_change.as_deref()
}
#[must_use]
pub fn on_init(&self) -> Option<&Path> {
self.on_init.as_deref()
}
#[must_use]
pub fn on_call(&self) -> Option<&Path> {
self.on_call.as_deref()
}
#[must_use]
pub fn on_cast(&self) -> Option<&Path> {
self.on_cast.as_deref()
}
#[must_use]
pub fn on_info(&self) -> Option<&Path> {
self.on_info.as_deref()
}
#[must_use]
pub fn on_terminate(&self) -> Option<&Path> {
self.on_terminate.as_deref()
}
pub fn validate(&self) -> Result<(), BehaviorError> {
for (slot, path) in self.declared_slots() {
validate_callback_path(slot, path)?;
}
Ok(())
}
}
fn validate_callback_path(slot: &'static str, path: &Path) -> Result<(), BehaviorError> {
crate::render::require_sandboxed_lisp_path(
path,
|| BehaviorError::empty_path(slot),
|| BehaviorError::absolute_path(slot, path),
|| BehaviorError::parent_escape(slot, path),
|| BehaviorError::non_lisp_extension(slot, path),
)
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum BehaviorError {
#[error(
":behavior {slot} path is empty (omit the slot to fall back to the runtime default \
callback; do not declare an empty path)"
)]
EmptyPath { slot: &'static str },
#[error(
":behavior {slot} path {} is absolute — callbacks must be relative to the caixa root, \
since the layout checker's `root.join(p)` would otherwise escape the project sandbox \
(Path::join replaces the base with an absolute right-hand side)",
path.display()
)]
AbsolutePath { slot: &'static str, path: PathBuf },
#[error(
":behavior {slot} path {} contains a `..` component — callbacks must not traverse \
above the caixa root",
path.display()
)]
ParentEscape { slot: &'static str, path: PathBuf },
#[error(
":behavior {slot} path {} does not terminate in the `.lisp` extension — the M2.5 \
wasm-engine instantiator reads every callback path as tatara-lisp source through \
`tatara_lisp::read` at instance-start time, so any other extension (`.txt`, `.rs`, \
`.lisp.bak`) or no-extension shape is structurally a parser error far from the \
source caixa.lisp, with no field naming the offending `:on-*` slot. Pin a \
relative path under the caixa root whose terminating extension is \
lowercase-`.lisp` (e.g. `\"lib/init.lisp\"`, `\"lib/handlers.lisp\"`, \
`\"lib/migrations/v01-to-v02.lisp\"`) or omit the slot to fall back to the \
runtime default callback",
path.display()
)]
NonLispExtension { slot: &'static str, path: PathBuf },
}
macro_rules! behavior_slot_path_ctors {
($($ctor:ident => $variant:ident),* $(,)?) => {
impl BehaviorError {
$(
#[doc = concat!(
"Construct a [`BehaviorError::",
stringify!($variant),
"`] naming the offending `:behavior :on-*` slot ",
"label and callback `path`. Folds the uniform ",
"`Self::",
stringify!($variant),
" { slot, path: path.to_path_buf() }` two-field ",
"struct-literal onto one substrate primitive so ",
"every closure passed to ",
"[`crate::render::require_sandboxed_lisp_path`] at ",
"[`validate_callback_path`] on this variant reads ",
"through one dispatch rather than the pre-lift ",
"four-line open-coded block. The `slot` label ",
"threads verbatim from ",
"[`BehaviorSpec::declared_slots`] and the `path` ",
"from the same iterator at the call site."
)]
#[must_use]
pub fn $ctor(slot: &'static str, path: &std::path::Path) -> Self {
Self::$variant {
slot,
path: path.to_path_buf(),
}
}
)*
}
};
}
behavior_slot_path_ctors! {
absolute_path => AbsolutePath,
parent_escape => ParentEscape,
non_lisp_extension => NonLispExtension,
}
impl BehaviorError {
#[must_use]
pub const fn empty_path(slot: &'static str) -> Self {
Self::EmptyPath { slot }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::{
M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
};
#[test]
fn empty_behavior_round_trip() {
let b = BehaviorSpec::default();
assert!(b.is_empty());
let json = serde_json::to_string(&b).unwrap();
assert_eq!(json, "{}");
let back: BehaviorSpec = serde_json::from_str("{}").unwrap();
assert_eq!(back, b);
}
#[test]
fn full_behavior_round_trip_through_json() {
let b = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
on_call: Some(PathBuf::from("lib/handlers.lisp")),
on_cast: Some(PathBuf::from("lib/handlers.lisp")),
on_info: Some(PathBuf::from("lib/handlers.lisp")),
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
};
let json = serde_json::to_string(&b).unwrap();
let back: BehaviorSpec = serde_json::from_str(&json).unwrap();
assert_eq!(b, back);
}
#[test]
fn partial_behavior_keeps_explicit_fields() {
let b = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
on_call: Some(PathBuf::from("lib/handlers.lisp")),
..Default::default()
};
assert!(!b.is_empty());
let paths: Vec<PathBuf> = b.declared_paths().map(Path::to_path_buf).collect();
assert_eq!(paths.len(), 2);
assert!(paths.contains(&PathBuf::from("lib/init.lisp")));
assert!(paths.contains(&PathBuf::from("lib/handlers.lisp")));
}
#[test]
fn declared_paths_skips_none() {
let b = BehaviorSpec {
on_init: Some(PathBuf::from("a.lisp")),
on_terminate: Some(PathBuf::from("b.lisp")),
..Default::default()
};
let paths: Vec<PathBuf> = b.declared_paths().map(Path::to_path_buf).collect();
assert_eq!(
paths,
vec![PathBuf::from("a.lisp"), PathBuf::from("b.lisp")]
);
}
#[test]
fn json_keys_are_camelcase() {
let b = BehaviorSpec {
on_init: Some(PathBuf::from("init.lisp")),
on_state_change: Some(PathBuf::from("mig.lisp")),
..Default::default()
};
let json = serde_json::to_string(&b).unwrap();
assert!(json.contains("\"onInit\""));
assert!(json.contains("\"onStateChange\""));
assert!(!json.contains("\"on_init\""));
}
#[test]
fn deserialize_accepts_camelcase() {
let json = r#"{"onInit":"a.lisp","onTerminate":"b.lisp"}"#;
let b: BehaviorSpec = serde_json::from_str(json).unwrap();
assert_eq!(b.on_init, Some(PathBuf::from("a.lisp")));
assert_eq!(b.on_terminate, Some(PathBuf::from("b.lisp")));
}
#[test]
fn behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts() {
let b = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
on_call: Some(PathBuf::from("lib/handlers.lisp")),
on_cast: Some(PathBuf::from("lib/handlers.lisp")),
on_info: Some(PathBuf::from("lib/handlers.lisp")),
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
};
let json = serde_json::to_string(&b).unwrap();
for key in [
crate::render::M2_BEHAVIOR_KEY_ON_INIT,
crate::render::M2_BEHAVIOR_KEY_ON_CALL,
crate::render::M2_BEHAVIOR_KEY_ON_CAST,
crate::render::M2_BEHAVIOR_KEY_ON_INFO,
crate::render::M2_BEHAVIOR_KEY_ON_STATE_CHANGE,
crate::render::M2_BEHAVIOR_KEY_ON_TERMINATE,
] {
let quoted = format!("\"{key}\"");
assert!(
json.contains("ed),
"serialized BehaviorSpec must carry the lifted \
M2_BEHAVIOR_KEY_ON_* byte-sequence {quoted} verbatim \
in the JSON emission (got: {json})",
);
}
}
#[test]
fn m2_behavior_key_consts_are_pairwise_distinct() {
let all = [
crate::render::M2_BEHAVIOR_KEY_ON_INIT,
crate::render::M2_BEHAVIOR_KEY_ON_CALL,
crate::render::M2_BEHAVIOR_KEY_ON_CAST,
crate::render::M2_BEHAVIOR_KEY_ON_INFO,
crate::render::M2_BEHAVIOR_KEY_ON_STATE_CHANGE,
crate::render::M2_BEHAVIOR_KEY_ON_TERMINATE,
];
for (i, a) in all.iter().enumerate() {
for b in all.iter().skip(i + 1) {
assert_ne!(
a, b,
"M2_BEHAVIOR_KEY_ON_* consts must be pairwise-distinct \
canonical byte-sequences — got `{a}` == `{b}`",
);
}
}
}
#[test]
fn m2_behavior_key_consts_are_lower_camel_case_shape() {
for key in [
crate::render::M2_BEHAVIOR_KEY_ON_INIT,
crate::render::M2_BEHAVIOR_KEY_ON_CALL,
crate::render::M2_BEHAVIOR_KEY_ON_CAST,
crate::render::M2_BEHAVIOR_KEY_ON_INFO,
crate::render::M2_BEHAVIOR_KEY_ON_STATE_CHANGE,
crate::render::M2_BEHAVIOR_KEY_ON_TERMINATE,
] {
assert!(
!key.is_empty(),
"M2_BEHAVIOR_KEY_ON_* must be non-empty (got {key:?})"
);
let first = key.chars().next().unwrap();
assert!(
first.is_ascii_lowercase(),
"M2_BEHAVIOR_KEY_ON_* must lead with an ASCII-lowercase \
byte (got {key:?}, leads with {first:?})",
);
assert!(
key.chars().all(|c| c.is_ascii_alphanumeric()),
"M2_BEHAVIOR_KEY_ON_* must be ASCII-alphanumeric only \
— no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
);
}
}
#[test]
fn deserialize_omits_unknown_fields_via_default() {
let json = r#"{"onInit":"a.lisp"}"#;
let b: BehaviorSpec = serde_json::from_str(json).unwrap();
assert_eq!(b.on_init, Some(PathBuf::from("a.lisp")));
assert!(b.on_call.is_none());
}
#[test]
fn validate_default_is_ok() {
BehaviorSpec::default().validate().unwrap();
}
#[test]
fn validate_every_slot_relative_is_ok() {
let b = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
on_call: Some(PathBuf::from("lib/handlers.lisp")),
on_cast: Some(PathBuf::from("lib/handlers.lisp")),
on_info: Some(PathBuf::from("lib/handlers.lisp")),
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
};
b.validate().unwrap();
}
#[test]
fn validate_rejects_empty_path_per_slot() {
let cases: [(&'static str, fn(PathBuf) -> BehaviorSpec); 6] = [
(M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, |p| BehaviorSpec {
on_init: Some(p),
..Default::default()
}),
(M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, |p| BehaviorSpec {
on_call: Some(p),
..Default::default()
}),
(M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, |p| BehaviorSpec {
on_cast: Some(p),
..Default::default()
}),
(M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, |p| BehaviorSpec {
on_info: Some(p),
..Default::default()
}),
(M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, |p| BehaviorSpec {
on_state_change: Some(p),
..Default::default()
}),
(M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE, |p| BehaviorSpec {
on_terminate: Some(p),
..Default::default()
}),
];
for (expected_slot, build) in cases {
let err = build(PathBuf::new()).validate().unwrap_err();
assert!(
matches!(err, BehaviorError::EmptyPath { slot } if slot == expected_slot),
"slot {expected_slot}: got {err:?}",
);
}
}
#[test]
fn validate_rejects_absolute_path() {
let b = BehaviorSpec {
on_init: Some(PathBuf::from("/etc/passwd")),
..Default::default()
};
let err = b.validate().unwrap_err();
assert!(matches!(
err,
BehaviorError::AbsolutePath { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
));
}
#[test]
fn validate_rejects_parent_escape() {
let b = BehaviorSpec {
on_state_change: Some(PathBuf::from("../sibling/migrations.lisp")),
..Default::default()
};
let err = b.validate().unwrap_err();
assert!(matches!(
err,
BehaviorError::ParentEscape { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE
));
}
#[test]
fn validate_rejects_parent_escape_mid_path() {
let b = BehaviorSpec {
on_terminate: Some(PathBuf::from("lib/../../escaped.lisp")),
..Default::default()
};
let err = b.validate().unwrap_err();
assert!(matches!(
err,
BehaviorError::ParentEscape { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE
));
}
#[test]
fn validate_diagnostic_order_is_deterministic() {
let b = BehaviorSpec {
on_init: Some(PathBuf::new()),
on_call: Some(PathBuf::from("/etc/passwd")),
on_terminate: Some(PathBuf::from("../escape.lisp")),
..Default::default()
};
let err = b.validate().unwrap_err();
assert!(matches!(
err,
BehaviorError::EmptyPath { slot } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
));
}
#[test]
fn validate_rejects_non_lisp_extension_per_slot() {
let cases: [(&'static str, fn(PathBuf) -> BehaviorSpec); 6] = [
(M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, |p| BehaviorSpec {
on_init: Some(p),
..Default::default()
}),
(M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, |p| BehaviorSpec {
on_call: Some(p),
..Default::default()
}),
(M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, |p| BehaviorSpec {
on_cast: Some(p),
..Default::default()
}),
(M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, |p| BehaviorSpec {
on_info: Some(p),
..Default::default()
}),
(M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, |p| BehaviorSpec {
on_state_change: Some(p),
..Default::default()
}),
(M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE, |p| BehaviorSpec {
on_terminate: Some(p),
..Default::default()
}),
];
let path = PathBuf::from("lib/init.txt");
for (expected_slot, build) in cases {
let err = build(path.clone()).validate().unwrap_err();
assert!(
matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
if *slot == expected_slot && p == &path),
"slot {expected_slot}: got {err:?}",
);
}
}
#[test]
fn validate_rejects_no_extension() {
let cases = [
PathBuf::from("lib/init"),
PathBuf::from("lib/handlers"),
PathBuf::from("init"),
];
for path in cases {
let b = BehaviorSpec {
on_init: Some(path.clone()),
..Default::default()
};
let err = b.validate().unwrap_err();
assert!(
matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT && p == &path),
"no-extension path {path:?}: got {err:?}",
);
}
}
#[test]
fn validate_rejects_wrong_extension() {
let cases = [
PathBuf::from("lib/init.rs"),
PathBuf::from("lib/init.txt"),
PathBuf::from("lib/init.md"),
PathBuf::from("lib/init.json"),
PathBuf::from("lib/init.yaml"),
PathBuf::from("lib/init.lisp.bak"),
PathBuf::from("lib/init.lispx"),
];
for path in cases {
let b = BehaviorSpec {
on_call: Some(path.clone()),
..Default::default()
};
let err = b.validate().unwrap_err();
assert!(
matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_CALL && p == &path),
"wrong-extension path {path:?}: got {err:?}",
);
}
}
#[test]
fn validate_rejects_uppercase_lisp_extension() {
let cases = [
PathBuf::from("lib/init.LISP"),
PathBuf::from("lib/init.Lisp"),
PathBuf::from("lib/init.LiSp"),
];
for path in cases {
let b = BehaviorSpec {
on_init: Some(path.clone()),
..Default::default()
};
let err = b.validate().unwrap_err();
assert!(
matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT && p == &path),
"uppercase `.lisp` {path:?}: got {err:?}",
);
}
}
#[test]
fn validate_accepts_canonical_lisp_paths() {
let cases = [
PathBuf::from("lib/init.lisp"),
PathBuf::from("lib/handlers.lisp"),
PathBuf::from("lib/migrations/v01-to-v02.lisp"),
PathBuf::from("init.lisp"),
PathBuf::from("a.lisp"),
PathBuf::from("./lib/init.lisp"),
PathBuf::from("lib/./handlers.lisp"),
PathBuf::from("lib/migrations/v.0.1.lisp"),
];
for path in cases {
let b = BehaviorSpec {
on_init: Some(path.clone()),
..Default::default()
};
b.validate()
.unwrap_or_else(|e| panic!("canonical `.lisp` path {path:?} must pass: {e:?}"));
}
}
#[test]
fn validate_path_shape_precedes_extension_arm() {
let b = BehaviorSpec {
on_init: Some(PathBuf::new()),
..Default::default()
};
assert!(matches!(
b.validate().unwrap_err(),
BehaviorError::EmptyPath { slot } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
));
let b = BehaviorSpec {
on_init: Some(PathBuf::from("/etc/init.txt")),
..Default::default()
};
assert!(matches!(
b.validate().unwrap_err(),
BehaviorError::AbsolutePath { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
));
let b = BehaviorSpec {
on_init: Some(PathBuf::from("../sibling/init.txt")),
..Default::default()
};
assert!(matches!(
b.validate().unwrap_err(),
BehaviorError::ParentEscape { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
));
}
#[test]
fn validate_extension_diagnostic_names_offending_slot_and_path() {
let path = PathBuf::from("lib/handlers.rs");
let b = BehaviorSpec {
on_cast: Some(path.clone()),
..Default::default()
};
let err = b.validate().unwrap_err();
let rendered = err.to_string();
assert!(
rendered.contains(M2_BEHAVIOR_AUTHOR_KEY_ON_CAST),
"diagnostic must name the `:on-cast` slot: {rendered}"
);
assert!(
rendered.contains("lib/handlers.rs"),
"diagnostic must carry the offending path verbatim: {rendered}"
);
assert!(
rendered.contains(".lisp"),
"diagnostic must name the expected `.lisp` extension: {rendered}"
);
}
#[test]
fn validate_extension_arm_fires_across_multi_malformed_manifest_in_slot_order() {
let b = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.rs")),
on_call: Some(PathBuf::from("lib/handlers.txt")),
on_state_change: Some(PathBuf::from("lib/migrations.md")),
..Default::default()
};
let err = b.validate().unwrap_err();
assert!(matches!(
&err,
BehaviorError::NonLispExtension { slot, path }
if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
&& path == &PathBuf::from("lib/init.rs")
));
}
#[test]
fn m2_behavior_author_key_consts_pin_canonical_kebab_case_labels() {
assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, ":on-init");
assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, ":on-call");
assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, ":on-cast");
assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, ":on-info");
assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, ":on-state-change");
assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE, ":on-terminate");
}
#[test]
fn declared_slots_labels_route_through_lifted_author_key_consts() {
let b = BehaviorSpec {
on_init: Some(PathBuf::from("a.lisp")),
on_call: Some(PathBuf::from("b.lisp")),
on_cast: Some(PathBuf::from("c.lisp")),
on_info: Some(PathBuf::from("d.lisp")),
on_state_change: Some(PathBuf::from("e.lisp")),
on_terminate: Some(PathBuf::from("f.lisp")),
};
let labels: Vec<&'static str> = b.declared_slots().map(|(s, _)| s).collect();
assert_eq!(
labels,
vec![
M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
M2_BEHAVIOR_AUTHOR_KEY_ON_CALL,
M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
M2_BEHAVIOR_AUTHOR_KEY_ON_INFO,
M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
]
);
}
#[test]
fn declared_slots_paths_route_through_lifted_on_star_accessors() {
let b = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
on_call: Some(PathBuf::from("lib/rpc/call.lisp")),
on_cast: Some(PathBuf::from("lib/rpc/cast.lisp")),
on_info: Some(PathBuf::from("lib/rpc/info.lisp")),
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
};
let entries: Vec<(&'static str, &Path)> = b.declared_slots().collect();
assert_eq!(
entries,
vec![
(M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, b.on_init().unwrap()),
(M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, b.on_call().unwrap()),
(M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, b.on_cast().unwrap()),
(M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, b.on_info().unwrap()),
(
M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
b.on_state_change().unwrap(),
),
(
M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
b.on_terminate().unwrap(),
),
],
"declared_slots must route each of its six per-arm \
Option<&Path> path-values through the sibling lifted \
BehaviorSpec::on_* accessor for its slot, so future \
accessor-side extensions reach both the layout checker \
+ validate gate by construction (got {entries:?})",
);
}
#[test]
fn declared_paths_routes_through_lifted_on_star_accessors() {
let b = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
on_call: Some(PathBuf::from("lib/rpc/call.lisp")),
on_cast: Some(PathBuf::from("lib/rpc/cast.lisp")),
on_info: Some(PathBuf::from("lib/rpc/info.lisp")),
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
};
let paths: Vec<&Path> = b.declared_paths().collect();
assert_eq!(
paths,
vec![
b.on_init().unwrap(),
b.on_call().unwrap(),
b.on_cast().unwrap(),
b.on_info().unwrap(),
b.on_state_change().unwrap(),
b.on_terminate().unwrap(),
],
"declared_paths must project each callback path through \
the sibling lifted BehaviorSpec::on_* accessor for its \
slot (got {paths:?})",
);
}
#[test]
fn behavior_on_state_change_returns_option_path_verbatim_across_permutations() {
for path in [
None,
Some(PathBuf::from("lib/migrations.lisp")),
Some(PathBuf::from("lib/migrations/v01-to-v02.lisp")),
] {
let b = BehaviorSpec {
on_state_change: path.clone(),
..BehaviorSpec::default()
};
assert_eq!(
b.on_state_change(),
path.as_deref(),
"BehaviorSpec::on_state_change must return the \
:behavior :on-state-change PathBuf verbatim as \
Option<&Path> (got {:?}, expected {:?})",
b.on_state_change(),
path.as_deref(),
);
assert_eq!(
b.on_state_change(),
b.on_state_change.as_deref(),
"BehaviorSpec::on_state_change must byte-equal the \
raw .on_state_change.as_deref() field access across \
every value in the accept-set",
);
}
}
#[test]
fn behavior_on_state_change_is_independent_of_peer_on_star_axes() {
let base = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
on_call: Some(PathBuf::from("lib/handlers.lisp")),
on_cast: Some(PathBuf::from("lib/handlers.lisp")),
on_info: Some(PathBuf::from("lib/handlers.lisp")),
on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
..BehaviorSpec::default()
};
assert_eq!(base.on_state_change(), None);
let with = BehaviorSpec {
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
..base.clone()
};
assert_eq!(
with.on_state_change(),
Some(PathBuf::from("lib/migrations.lisp").as_path()),
"BehaviorSpec::on_state_change must project the \
:on-state-change axis independently of every peer :on-* \
axis (got {:?})",
with.on_state_change(),
);
}
#[test]
fn validate_upgrade_from_against_behavior_routes_through_on_state_change_accessor() {
let entries = vec![crate::UpgradeFromEntry {
from: "0.1.0".to_string(),
instructions: vec![
crate::UpgradeInstruction::LoadModule {
module: "codec".to_string(),
},
crate::UpgradeInstruction::StateChange {
script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
},
],
}];
let b = BehaviorSpec {
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
..BehaviorSpec::default()
};
assert!(b.on_state_change().is_some());
crate::validate_upgrade_from_against_behavior(&entries, Some(&b))
.expect("callback declared → gate admits");
let b_no_cb = BehaviorSpec::default();
assert_eq!(b_no_cb.on_state_change(), None);
let err = crate::validate_upgrade_from_against_behavior(&entries, Some(&b_no_cb))
.expect_err(":state-change instruction without callback → refuse");
assert!(matches!(
err,
crate::UpgradeError::StateChangeWithoutOnStateChangeCallback { .. }
));
let err = crate::validate_upgrade_from_against_behavior(&entries, None)
.expect_err("behavior absent + :state-change instruction → refuse");
assert!(matches!(
err,
crate::UpgradeError::StateChangeWithoutOnStateChangeCallback { .. }
));
}
#[test]
fn behavior_on_init_returns_option_path_verbatim_across_permutations() {
for path in [
None,
Some(PathBuf::from("lib/init.lisp")),
Some(PathBuf::from("lib/lifecycle/init.lisp")),
] {
let b = BehaviorSpec {
on_init: path.clone(),
..BehaviorSpec::default()
};
assert_eq!(
b.on_init(),
path.as_deref(),
"BehaviorSpec::on_init must return the \
:behavior :on-init PathBuf verbatim as \
Option<&Path> (got {:?}, expected {:?})",
b.on_init(),
path.as_deref(),
);
assert_eq!(
b.on_init(),
b.on_init.as_deref(),
"BehaviorSpec::on_init must byte-equal the \
raw .on_init.as_deref() field access across \
every value in the accept-set",
);
}
}
#[test]
fn behavior_on_init_is_independent_of_peer_on_star_axes() {
let base = BehaviorSpec {
on_call: Some(PathBuf::from("lib/handlers.lisp")),
on_cast: Some(PathBuf::from("lib/handlers.lisp")),
on_info: Some(PathBuf::from("lib/handlers.lisp")),
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
..BehaviorSpec::default()
};
assert_eq!(base.on_init(), None);
let with = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
..base.clone()
};
assert_eq!(
with.on_init(),
Some(PathBuf::from("lib/init.lisp").as_path()),
"BehaviorSpec::on_init must project the \
:on-init axis independently of every peer :on-* \
axis (got {:?})",
with.on_init(),
);
}
#[test]
fn behavior_on_call_returns_option_path_verbatim_across_permutations() {
for path in [
None,
Some(PathBuf::from("lib/handlers.lisp")),
Some(PathBuf::from("lib/rpc/call.lisp")),
] {
let b = BehaviorSpec {
on_call: path.clone(),
..BehaviorSpec::default()
};
assert_eq!(
b.on_call(),
path.as_deref(),
"BehaviorSpec::on_call must return the \
:behavior :on-call PathBuf verbatim as \
Option<&Path> (got {:?}, expected {:?})",
b.on_call(),
path.as_deref(),
);
assert_eq!(
b.on_call(),
b.on_call.as_deref(),
"BehaviorSpec::on_call must byte-equal the \
raw .on_call.as_deref() field access across \
every value in the accept-set",
);
}
}
#[test]
fn behavior_on_call_is_independent_of_peer_on_star_axes() {
let base = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
on_cast: Some(PathBuf::from("lib/handlers.lisp")),
on_info: Some(PathBuf::from("lib/handlers.lisp")),
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
..BehaviorSpec::default()
};
assert_eq!(base.on_call(), None);
let with = BehaviorSpec {
on_call: Some(PathBuf::from("lib/handlers.lisp")),
..base.clone()
};
assert_eq!(
with.on_call(),
Some(PathBuf::from("lib/handlers.lisp").as_path()),
"BehaviorSpec::on_call must project the \
:on-call axis independently of every peer :on-* \
axis (got {:?})",
with.on_call(),
);
}
#[test]
fn behavior_on_cast_returns_option_path_verbatim_across_permutations() {
for path in [
None,
Some(PathBuf::from("lib/handlers.lisp")),
Some(PathBuf::from("lib/rpc/cast.lisp")),
] {
let b = BehaviorSpec {
on_cast: path.clone(),
..BehaviorSpec::default()
};
assert_eq!(
b.on_cast(),
path.as_deref(),
"BehaviorSpec::on_cast must return the \
:behavior :on-cast PathBuf verbatim as \
Option<&Path> (got {:?}, expected {:?})",
b.on_cast(),
path.as_deref(),
);
assert_eq!(
b.on_cast(),
b.on_cast.as_deref(),
"BehaviorSpec::on_cast must byte-equal the \
raw .on_cast.as_deref() field access across \
every value in the accept-set",
);
}
}
#[test]
fn behavior_on_cast_is_independent_of_peer_on_star_axes() {
let base = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
on_call: Some(PathBuf::from("lib/handlers.lisp")),
on_info: Some(PathBuf::from("lib/handlers.lisp")),
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
..BehaviorSpec::default()
};
assert_eq!(base.on_cast(), None);
let with = BehaviorSpec {
on_cast: Some(PathBuf::from("lib/handlers.lisp")),
..base.clone()
};
assert_eq!(
with.on_cast(),
Some(PathBuf::from("lib/handlers.lisp").as_path()),
"BehaviorSpec::on_cast must project the \
:on-cast axis independently of every peer :on-* \
axis (got {:?})",
with.on_cast(),
);
}
#[test]
fn behavior_on_info_returns_option_path_verbatim_across_permutations() {
for path in [
None,
Some(PathBuf::from("lib/handlers.lisp")),
Some(PathBuf::from("lib/rpc/info.lisp")),
] {
let b = BehaviorSpec {
on_info: path.clone(),
..BehaviorSpec::default()
};
assert_eq!(
b.on_info(),
path.as_deref(),
"BehaviorSpec::on_info must return the \
:behavior :on-info PathBuf verbatim as \
Option<&Path> (got {:?}, expected {:?})",
b.on_info(),
path.as_deref(),
);
assert_eq!(
b.on_info(),
b.on_info.as_deref(),
"BehaviorSpec::on_info must byte-equal the \
raw .on_info.as_deref() field access across \
every value in the accept-set",
);
}
}
#[test]
fn behavior_on_info_is_independent_of_peer_on_star_axes() {
let base = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
on_call: Some(PathBuf::from("lib/handlers.lisp")),
on_cast: Some(PathBuf::from("lib/handlers.lisp")),
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
..BehaviorSpec::default()
};
assert_eq!(base.on_info(), None);
let with = BehaviorSpec {
on_info: Some(PathBuf::from("lib/handlers.lisp")),
..base.clone()
};
assert_eq!(
with.on_info(),
Some(PathBuf::from("lib/handlers.lisp").as_path()),
"BehaviorSpec::on_info must project the \
:on-info axis independently of every peer :on-* \
axis (got {:?})",
with.on_info(),
);
}
#[test]
fn behavior_on_terminate_returns_option_path_verbatim_across_permutations() {
for path in [
None,
Some(PathBuf::from("lib/cleanup.lisp")),
Some(PathBuf::from("lib/lifecycle/terminate.lisp")),
] {
let b = BehaviorSpec {
on_terminate: path.clone(),
..BehaviorSpec::default()
};
assert_eq!(
b.on_terminate(),
path.as_deref(),
"BehaviorSpec::on_terminate must return the \
:behavior :on-terminate PathBuf verbatim as \
Option<&Path> (got {:?}, expected {:?})",
b.on_terminate(),
path.as_deref(),
);
assert_eq!(
b.on_terminate(),
b.on_terminate.as_deref(),
"BehaviorSpec::on_terminate must byte-equal the \
raw .on_terminate.as_deref() field access across \
every value in the accept-set",
);
}
}
#[test]
fn behavior_on_terminate_is_independent_of_peer_on_star_axes() {
let base = BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
on_call: Some(PathBuf::from("lib/handlers.lisp")),
on_cast: Some(PathBuf::from("lib/handlers.lisp")),
on_info: Some(PathBuf::from("lib/handlers.lisp")),
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
..BehaviorSpec::default()
};
assert_eq!(base.on_terminate(), None);
let with = BehaviorSpec {
on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
..base.clone()
};
assert_eq!(
with.on_terminate(),
Some(PathBuf::from("lib/cleanup.lisp").as_path()),
"BehaviorSpec::on_terminate must project the \
:on-terminate axis independently of every peer :on-* \
axis (got {:?})",
with.on_terminate(),
);
}
#[test]
fn absolute_path_ctor_matches_struct_literal_wrap() {
let slot = M2_BEHAVIOR_AUTHOR_KEY_ON_INIT;
let path = Path::new("/etc/nope.lisp");
assert_eq!(
BehaviorError::absolute_path(slot, path),
BehaviorError::AbsolutePath {
slot,
path: path.to_path_buf(),
},
"generated absolute_path ctor must produce byte-equal \
BehaviorError to the open-coded struct-literal wrap on the \
same (&'static str, &Path) fixture",
);
}
#[test]
fn parent_escape_ctor_matches_struct_literal_wrap() {
let slot = M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE;
let path = Path::new("../oops.lisp");
assert_eq!(
BehaviorError::parent_escape(slot, path),
BehaviorError::ParentEscape {
slot,
path: path.to_path_buf(),
},
"generated parent_escape ctor must produce byte-equal \
BehaviorError to the open-coded struct-literal wrap on the \
same (&'static str, &Path) fixture",
);
}
#[test]
fn non_lisp_extension_ctor_matches_struct_literal_wrap() {
let slot = M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE;
let path = Path::new("lib/cleanup.rs");
assert_eq!(
BehaviorError::non_lisp_extension(slot, path),
BehaviorError::NonLispExtension {
slot,
path: path.to_path_buf(),
},
"generated non_lisp_extension ctor must produce byte-equal \
BehaviorError to the open-coded struct-literal wrap on the \
same (&'static str, &Path) fixture",
);
}
#[test]
fn behavior_slot_path_ctors_route_slot_and_path_verbatim() {
let path_owned = PathBuf::from("lib/handlers.lisp");
let path_ref: &Path = path_owned.as_path();
for slot in [
M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
M2_BEHAVIOR_AUTHOR_KEY_ON_CALL,
M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
M2_BEHAVIOR_AUTHOR_KEY_ON_INFO,
M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
] {
for path in [path_ref, &path_owned as &Path] {
assert_eq!(
BehaviorError::absolute_path(slot, path),
BehaviorError::AbsolutePath {
slot,
path: path.to_path_buf(),
},
);
assert_eq!(
BehaviorError::parent_escape(slot, path),
BehaviorError::ParentEscape {
slot,
path: path.to_path_buf(),
},
);
assert_eq!(
BehaviorError::non_lisp_extension(slot, path),
BehaviorError::NonLispExtension {
slot,
path: path.to_path_buf(),
},
);
}
}
}
#[test]
fn empty_path_ctor_matches_struct_literal_wrap() {
let slot = M2_BEHAVIOR_AUTHOR_KEY_ON_INIT;
assert_eq!(
BehaviorError::empty_path(slot),
BehaviorError::EmptyPath { slot },
"generated empty_path ctor must produce byte-equal \
BehaviorError to the open-coded struct-literal wrap on the \
same &'static str fixture",
);
}
#[test]
fn empty_path_ctor_routes_slot_verbatim_across_every_on_star_key() {
for slot in [
M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
M2_BEHAVIOR_AUTHOR_KEY_ON_CALL,
M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
M2_BEHAVIOR_AUTHOR_KEY_ON_INFO,
M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
] {
assert_eq!(
BehaviorError::empty_path(slot),
BehaviorError::EmptyPath { slot },
);
}
}
#[test]
fn behavior_spec_is_empty_is_const_fn_usable_in_const_position() {
const EMPTY: BehaviorSpec = BehaviorSpec {
on_init: None,
on_call: None,
on_cast: None,
on_info: None,
on_state_change: None,
on_terminate: None,
};
const EMPTY_IS_EMPTY: bool = EMPTY.is_empty();
assert_eq!(
EMPTY_IS_EMPTY,
BehaviorSpec::default().is_empty(),
"const-position dispatch on the empty BehaviorSpec must \
yield the same bool as the runtime-position dispatch on \
the sibling default() fixture",
);
}
#[test]
fn behavior_spec_is_empty_agrees_with_declared_paths_across_all_slot_permutations() {
let path = PathBuf::from("lib/callback.lisp");
for mask in 0u8..64u8 {
let bit = |i: u8| (mask & (1u8 << i)) != 0;
let b = BehaviorSpec {
on_init: bit(0).then(|| path.clone()),
on_call: bit(1).then(|| path.clone()),
on_cast: bit(2).then(|| path.clone()),
on_info: bit(3).then(|| path.clone()),
on_state_change: bit(4).then(|| path.clone()),
on_terminate: bit(5).then(|| path.clone()),
};
assert_eq!(
b.is_empty(),
b.declared_paths().next().is_none(),
"is_empty() disagrees with declared_paths().next().is_none() \
at slot-set mask {mask:#08b}"
);
}
}
}