use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(
Serialize,
Deserialize,
Debug,
Clone,
PartialEq,
Eq,
gen_platform::TypedDispatcher,
gen_platform::Discriminant,
gen_platform::IsVariant,
)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum UpgradeInstruction {
LoadModule { module: String },
StateChange { script: PathBuf },
SoftPurge { module: String },
Purge { module: String },
Restart,
}
gen_platform::register_dispatcher!("caixa.upgrade-instruction", UpgradeInstruction);
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UpgradeFromEntry {
pub from: String,
#[serde(default)]
pub instructions: Vec<UpgradeInstruction>,
}
impl UpgradeFromEntry {
#[must_use]
pub const fn prior_versao(&self) -> &str {
self.from.as_str()
}
#[must_use]
pub const fn instructions(&self) -> &[UpgradeInstruction] {
self.instructions.as_slice()
}
pub fn validate(&self) -> Result<(), UpgradeError> {
use semver::Version;
Version::parse(self.prior_versao())
.map_err(|e| UpgradeError::from_invalid(self.prior_versao(), &e.to_string()))?;
for instr in self.instructions() {
instr.validate()?;
}
self.validate_restart_exclusive()?;
self.validate_state_change_ordering()?;
self.validate_purge_ordering()?;
self.validate_state_change_before_cleanup()?;
self.validate_load_singularity()?;
self.validate_state_change_singularity()?;
self.validate_cleanup_singularity()?;
Ok(())
}
fn validate_restart_exclusive(&self) -> Result<(), UpgradeError> {
let instructions = self.instructions();
let restart_count = instructions.iter().filter(|i| i.is_restart()).count();
if restart_count == 0 {
return Ok(());
}
if restart_count == 1 && instructions.len() == 1 {
return Ok(());
}
let other_kinds: Vec<&'static str> = instructions
.iter()
.filter(|i| !i.is_restart())
.map(UpgradeInstruction::lisp_form)
.collect();
Err(UpgradeError::restart_not_exclusive(
self.prior_versao(),
restart_count,
other_kinds,
))
}
fn validate_state_change_ordering(&self) -> Result<(), UpgradeError> {
let mut loaded = false;
for instr in self.instructions() {
if instr.is_load_module() {
loaded = true;
} else if !loaded && let Some(script) = instr.declared_path() {
return Err(UpgradeError::state_change_without_prior_load(
self.prior_versao(),
script,
));
}
}
Ok(())
}
fn validate_purge_ordering(&self) -> Result<(), UpgradeError> {
let mut loaded = false;
for instr in self.instructions() {
if instr.is_load_module() {
loaded = true;
} else if instr.is_cleanup() && !loaded {
return Err(UpgradeError::purge_without_prior_load(
self.prior_versao(),
instr.lisp_form(),
instr
.declared_module()
.expect("is_cleanup() implies declared_module() is Some"),
));
}
}
Ok(())
}
fn validate_state_change_before_cleanup(&self) -> Result<(), UpgradeError> {
let mut prior_cleanup: Option<(&str, &'static str)> = None;
for instr in self.instructions() {
if instr.is_cleanup() && prior_cleanup.is_none() {
prior_cleanup = Some((
instr
.declared_module()
.expect("is_cleanup() implies declared_module() is Some"),
instr.lisp_form(),
));
} else if let Some(script) = instr.declared_path()
&& let Some((prior_module, prior_kind)) = prior_cleanup
{
return Err(UpgradeError::state_change_after_cleanup(
self.prior_versao(),
script,
prior_kind,
prior_module,
));
}
}
Ok(())
}
fn validate_cleanup_singularity(&self) -> Result<(), UpgradeError> {
let mut seen: Vec<(&str, &'static str)> = Vec::new();
for instr in self.instructions() {
if !instr.is_cleanup() {
continue;
}
let module = instr
.declared_module()
.expect("is_cleanup() implies declared_module() is Some");
let kind = instr.lisp_form();
if let Some(prior_idx) = seen.iter().position(|(m, _)| *m == module) {
let prior_kind = seen[prior_idx].1;
return Err(UpgradeError::duplicate_cleanup(
self.prior_versao(),
module,
vec![prior_kind, kind],
));
}
seen.push((module, kind));
}
Ok(())
}
fn validate_load_singularity(&self) -> Result<(), UpgradeError> {
let mut seen: Vec<&str> = Vec::new();
for instr in self.instructions() {
if !instr.is_load_module() {
continue;
}
let module = instr
.declared_module()
.expect("is_load_module() implies declared_module() is Some");
if seen.contains(&module) {
return Err(UpgradeError::duplicate_load_module(
self.prior_versao(),
module,
));
}
seen.push(module);
}
Ok(())
}
fn validate_state_change_singularity(&self) -> Result<(), UpgradeError> {
let mut seen: Vec<&std::path::Path> = Vec::new();
for instr in self.instructions() {
let Some(script) = instr.declared_path() else {
continue;
};
let script = script.as_path();
if seen.contains(&script) {
return Err(UpgradeError::duplicate_state_change(
self.prior_versao(),
script,
));
}
seen.push(script);
}
Ok(())
}
}
pub fn validate_upgrade_from(entries: &[UpgradeFromEntry]) -> Result<(), UpgradeError> {
use semver::Version;
let mut seen: Vec<Version> = Vec::with_capacity(entries.len());
for entry in entries {
entry.validate()?;
let parsed = Version::parse(entry.prior_versao()).expect(
"UpgradeFromEntry::validate must accept `:from` iff Version::parse does — keep the \
two gates aligned",
);
if seen.contains(&parsed) {
return Err(UpgradeError::duplicate_from(entry));
}
seen.push(parsed);
}
Ok(())
}
pub fn validate_upgrade_from_against_versao(
entries: &[UpgradeFromEntry],
versao: &str,
) -> Result<(), UpgradeError> {
use semver::Version;
let Ok(current) = Version::parse(versao) else {
return Ok(());
};
for entry in entries {
let Ok(prior) = Version::parse(entry.prior_versao()) else {
continue;
};
if prior >= current {
return Err(UpgradeError::from_not_before_versao(
entry.prior_versao(),
versao,
));
}
}
Ok(())
}
pub fn validate_upgrade_from_against_behavior(
entries: &[UpgradeFromEntry],
behavior: Option<&crate::BehaviorSpec>,
) -> Result<(), UpgradeError> {
if behavior
.and_then(crate::BehaviorSpec::on_state_change)
.is_some()
{
return Ok(());
}
for entry in entries {
for instr in entry.instructions() {
if let Some(script) = instr.declared_path() {
return Err(UpgradeError::state_change_without_on_state_change_callback(
entry.prior_versao(),
script,
));
}
}
}
Ok(())
}
impl UpgradeInstruction {
#[must_use]
const fn lisp_form(&self) -> &'static str {
match self {
Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
}
}
pub fn validate(&self) -> Result<(), UpgradeError> {
if let Some(module) = self.declared_module() {
return validate_module(self.lisp_form(), module);
}
if let Some(script) = self.declared_path() {
crate::render::require_sandboxed_lisp_path(
script,
|| UpgradeError::EmptyScript,
|| UpgradeError::absolute_script(script),
|| UpgradeError::parent_escape_script(script),
|| UpgradeError::non_lisp_extension_script(script),
)?;
}
Ok(())
}
#[must_use]
pub const fn declared_module(&self) -> Option<&str> {
match self {
Self::LoadModule { module } | Self::SoftPurge { module } | Self::Purge { module } => {
Some(module.as_str())
}
Self::StateChange { .. } | Self::Restart => None,
}
}
#[must_use]
pub const fn declared_path(&self) -> Option<&PathBuf> {
match self {
Self::StateChange { script } => Some(script),
_ => None,
}
}
#[must_use]
pub const fn is_cleanup(&self) -> bool {
self.is_soft_purge() || self.is_purge()
}
}
fn validate_module(kind: &'static str, module: &str) -> Result<(), UpgradeError> {
crate::render::require_valid_dns_1123_label(
module,
|| UpgradeError::ModuleEmpty { kind },
|reason| UpgradeError::module_invalid(kind, module, reason),
)
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum UpgradeError {
#[error(
":upgrade-from :from {from:?} is not a valid SemVer-2 version: {reason} (the substrate \
consumes this string as `semver::Version` — three-part `MAJOR.MINOR.PATCH` with optional \
`-prerelease` and `+build`, the same shape every top-level `:versao` carries — across \
every artifact derived from `:from`: the wasm-operator's `:from`-match dispatch loads \
the running version through `semver::Version::parse` and matches it against each entry's \
`:from`, so a malformed `:from` is structurally unreachable at dispatch time; use a \
SemVer-2 literal like `\"0.1.0\"`, `\"0.2.0-rc.1\"`, or `\"1.0.0+build.42\"` — not a \
git-tag-shape like `\"v0.1.0\"`, a docker-tag-shape like `\"latest\"`, a \
requirement-shape like `\"^0.1\"`, or a four-part `\"0.1.0.0\"`)"
)]
FromInvalid { from: String, reason: String },
#[error(
"upgrade instruction `{kind}` :module is empty (every appup module reference \
must name a caixa; use a non-empty caixa name like `\"hello-rio\"` or omit \
the instruction entirely)"
)]
ModuleEmpty { kind: &'static str },
#[error(
"upgrade instruction `{kind}` :module {module:?} is not a valid DNS-1123 label: \
{reason} (every appup module reference resolves to a caixa name, which lands \
verbatim as a K8s `metadata.name` on the per-Servico ComputeUnit the operator \
creates, the `LABEL_PROGRAM` label value the wasm-engine matches at hot-upgrade \
dispatch, and every future `app-operator` rolling-load CR's per-module reference \
axis; use a lowercase alphanumeric + hyphen identifier like `\"hello-rio\"` or \
`\"cache-v2\"`)"
)]
ModuleInvalid {
kind: &'static str,
module: String,
reason: String,
},
#[error("instruction's :script is empty")]
EmptyScript,
#[error(
"instruction's :script {} is absolute — upgrade scripts must be relative to the caixa \
root (Path::join would otherwise escape the project sandbox)",
script.display()
)]
AbsoluteScript { script: PathBuf },
#[error(
"instruction's :script {} contains a `..` component — upgrade scripts must not traverse \
above the caixa root",
script.display()
)]
ParentEscapeScript { script: PathBuf },
#[error(
":upgrade-from (:state-change {}) does not terminate in the `.lisp` extension — the M2.5 \
wasm-engine instantiator reads every migration script as tatara-lisp source through \
`tatara_lisp::read` at hot-upgrade migration time (the same downstream consumer the \
peer `:behavior :on-*` axis routes through at instance-start time, c97815a), 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 \
`(:state-change …)` instruction. Pin a relative path under the caixa root whose \
terminating extension is lowercase-`.lisp` (e.g. `\"lib/migrations.lisp\"`, \
`\"lib/migrations/v01-to-v02.lisp\"`).",
script.display()
)]
NonLispExtensionScript { script: PathBuf },
#[error(
":upgrade-from carries more than one `(:from {from:?})` entry — OTP appup picks at most \
one matching block per running version (`release_handler:install_release/1` dispatches \
on the loaded `:from` against the currently-running release), so two entries with the \
same parsed semver are an ambiguous edge in the typed upgrade graph (the operator would \
pick either set non-deterministically). Author one path per prior version; if two \
distinct instruction sequences are needed, fold them into one ordered list under the \
single matching `(:from {from:?} :instructions (…))` block."
)]
DuplicateFrom { from: String },
#[error(
":upgrade-from `(:from {from:?})` is not strictly less than the caixa's current \
`:versao {versao:?}` under SemVer-2 precedence — an upgrade block whose `:from` is \
greater than or equal to the caixa's own version is structurally unreachable \
(the wasm-operator's `:from`-match dispatch loads the current `:versao` and matches \
the running version against each entry's `:from`; an entry whose `:from >= :versao` \
is never reached because the operator never runs a version greater than or equal to \
the current one that it could then upgrade *to* the current one). Bump the caixa's \
`:versao` past {from:?} (the typical fix — you added the entry intending to upgrade \
*to* a new version but forgot to bump `:versao`), drop the entry (if it's a stale \
reference left over from a reverted `:versao` bump), or correct `:from` to a prior \
version (if it's a typo). Pre-release values like `\"0.2.0-rc.1\"` are strictly less \
than the corresponding release `\"0.2.0\"` under SemVer §11 precedence; build-metadata \
values like `\"0.2.0+build.1\"` are equal to `\"0.2.0\"` under precedence and rejected \
here as a self-upgrade no-op."
)]
FromNotBeforeVersao { from: String, versao: String },
#[error(
":upgrade-from `(:from {from:?})` :instructions list violates the `(:restart)` \
exclusivity invariant — an entry containing `(:restart)` must contain exactly one \
`(:restart)` and nothing else (found {restart_count} `(:restart)` plus other \
instruction(s): {other_kinds:?}). Per the UpgradeInstruction::Restart doc comment, \
`(:restart)` is the fallback for an entry whose typed upgrade is impossible (wasm \
component-model world incompatibility, irreversible state shape change), and the \
fallback is terminal by construction (the operator restarts the pod and the new \
version comes up fresh). Mixing the fallback with the typed sequence is dead code \
in both directions: if the typed instructions would succeed, `(:restart)` is \
unreached; if they wouldn't, the typed instructions are dead because the operator \
restarts anyway. Author *either* a typed sequence (`(:load-module …) \
(:state-change …) (:soft-purge …)`) *or* a single `((:restart))` — never both, \
never repeated. If two distinct upgrade strategies are needed for the same prior \
version, that is itself a typed-graph ambiguity (the operator's `:from`-match \
dispatch picks exactly one block per running version) — keep the typed sequence; \
the fallback restart is what the operator does on any typed-sequence failure \
already."
)]
RestartNotExclusive {
from: String,
restart_count: usize,
other_kinds: Vec<&'static str>,
},
#[error(
":upgrade-from `(:from {from:?})` runs `(:state-change {})` before any \
`(:load-module …)` in its :instructions list — a state migration is the \
gen_server:code_change/3 analog and must run in the context of the newly-loaded \
code, but the operator executes instructions in declared order, so this migration \
runs while the only resident version is still the prior one (which expects the \
pre-migration state shape). Load the new module first: author the canonical \
`(:load-module …) (:state-change {}) (:soft-purge …)` order so the new code is \
resident before its state migration runs.",
script.display(),
script.display()
)]
StateChangeWithoutPriorLoad { from: String, script: PathBuf },
#[error(
":upgrade-from `(:from {from:?})` runs `({kind} {module:?})` before any \
`(:load-module …)` in its :instructions list — `:soft-purge` and `:purge` are the \
code:soft_purge/1 / code:purge/1 analogs and must run after the new code is \
resident alongside the old (OTP's two-phase code load: `code:load_module/1` \
then `code:soft_purge/1`), but the operator executes instructions in declared \
order, so this cleanup runs while the only resident version is still the same \
old code (`:soft-purge` drains it to nothing; `:purge` discards it outright \
mid-request), leaving no replacement to route in-flight or future requests \
to. Load the new module first: author the canonical `(:load-module …) \
(:state-change …) ({kind} {module:?})` order so the new code is resident \
before the old code is drained or discarded."
)]
PurgeWithoutPriorLoad {
from: String,
kind: &'static str,
module: String,
},
#[error(
":upgrade-from `(:from {from:?})` :instructions list targets module {module:?} with \
more than one cleanup instruction ({kinds:?}) — `:soft-purge` and `:purge` are the \
code:soft_purge/1 / code:purge/1 analogs (INSPIRATIONS §II.4: \"`code:soft_purge/1` — \
wait until no process is running v1, then discard. (`code:purge/1` kills v1 immediately \
if you don't care.)\"), and each module's old version is cleaned up by exactly one of \
them: either drain-then-discard (`:soft-purge`) or immediate-discard (`:purge`), never \
both, never repeated. systools-generated `.relup` files emit at most one purge per \
module for this reason. A second cleanup on the same module is at best redundant (the \
module is already gone after the first cleanup, so the second is a no-op or undefined \
depending on the operator's handling of a non-resident-module purge request) and at \
worst incoherent (mixing drain and discard semantics on one module suggests the author \
wanted a fallback, but the operator runs declared instructions unconditionally — \
fallback on cleanup failure is the operator's job, not authored into the entry). \
Author one cleanup per module: prefer `(:soft-purge {module:?})` (waits for in-flight \
callers to drain before GC); fall back to `(:purge {module:?})` only when the drain \
can't complete (cron / oneShot / stuck callers). If two distinct old versions need \
cleanup, name them distinctly (e.g. `(:soft-purge {module:?}) (:soft-purge \"…-older\")`)."
)]
DuplicateCleanup {
from: String,
module: String,
kinds: Vec<&'static str>,
},
#[error(
":upgrade-from `(:from {from:?})` :instructions list loads module {module:?} more than \
once — `:load-module` is the code:load_module/1 analog (INSPIRATIONS §II.4: \"1. \
`code:load_module/1` — load v2 alongside v1; new code is 'current', old code is \
'old'.\"), and the instruction binds the named wasm component once: the operator's \
dispatch table reads the module name and brings up the corresponding component \
alongside the running version. systools-generated `.relup` files emit at most one \
`load_module` per module per upgrade step for this reason. A second `(:load-module \
{module:?})` instruction has no observable semantic relative to the first (the \
component is already resident) — either dead code (copy-pasted load line) or a typo \
masking a distinct module the author intended to load alongside (renamed both to \
{module:?} by mistake), leaving the second module silently absent from the entry. \
Author one `(:load-module {module:?})` per old module per entry; if two distinct old \
versions need loading alongside the running one, name them distinctly (e.g. \
`(:load-module {module:?}) (:load-module \"…-v2\")`)."
)]
DuplicateLoadModule { from: String, module: String },
#[error(
":upgrade-from `(:from {from:?})` :instructions list runs state migration {} more than \
once — `:state-change` is the gen_server:code_change/3 analog (INSPIRATIONS §II.4: \
\"State migration uses gen_server:code_change/3\"), and the script folds the prior-version \
state shape into the current-version shape: a one-shot transition, not a step that \
composes with itself. systools-generated `.relup` files emit at most one `code_change` \
per gen_server per upgrade step for this reason; OTP's release_handler invokes the \
callback exactly once. A second `(:state-change {})` instruction re-runs the same fold on \
the already-migrated state — at best a no-op (idempotent script masking a typo where the \
author intended two distinct migration scripts) and at worst silent state corruption \
(non-idempotent fold double-applied: an `add column` that runs twice, an `increment \
counter` that double-bumps, a `rename field` that renames-then-fails the second time). \
Author one `(:state-change {})` per migration script per entry; if two distinct state \
transitions are needed (e.g. one module's schema *and* another module's projection), \
name them distinctly (e.g. `(:state-change {}) (:state-change \"lib/migrations/v01-to-v02-projection.lisp\")`).",
script.display(),
script.display(),
script.display(),
script.display()
)]
DuplicateStateChange { from: String, script: PathBuf },
#[error(
":upgrade-from `(:from {from:?})` runs `(:state-change {})` after `({prior_cleanup_kind} \
{prior_cleanup_module:?})` in its :instructions list — `:state-change` is the \
gen_server:code_change/3 analog and folds the prior-version state shape into the \
current shape, but the prior version's state only exists while the prior code is \
still resident; `:soft-purge` and `:purge` are the code:soft_purge/1 / code:purge/1 \
analogs and drain or discard that prior code. The operator executes instructions in \
declared order, so a cleanup ahead of a state-change has already drained the prior \
module to nothing (`:soft-purge`) or discarded it mid-request (`:purge`) by the time \
the migration script runs, leaving the script either no-op (no prior-version state \
left to fold) or crashing (`code_change/3` invoked on an unloaded version). The OTP \
canonical sequence is `code:load_module/1` → `gen_server:code_change/3` → \
`code:soft_purge/1`; the appup cookbook's recommended pattern is `[{{load_module, m}}, \
{{update, m, soft}}, {{soft_purge, m}}]` with the migration-triggering `update` \
strictly between load and cleanup. Author the canonical `(:load-module …) \
(:state-change {}) ({prior_cleanup_kind} {prior_cleanup_module:?})` order so the \
migration runs against the prior-version state before the cleanup drains it.",
script.display(),
script.display()
)]
StateChangeAfterCleanup {
from: String,
script: PathBuf,
prior_cleanup_kind: &'static str,
prior_cleanup_module: String,
},
#[error(
":upgrade-from `(:from {from:?})` declares `(:state-change {})` but the caixa does not \
declare `:behavior :on-state-change` — the per-version migration script is the \
gen_server:code_change/3 analog and the runtime hook it is delivered through during \
hot upgrade is the `:on-state-change` callback. OTP's release_handler:install_release/1 \
realizes the composition by invoking the running gen_server's code_change/3 callback \
during the appup's `code_change` / `update, m, soft` step; caixa decomposes the same \
composition into two typed slots, the per-version migration logic in this \
`(:state-change …)` instruction's `:script` and the runtime dispatch hook in the \
`:behavior :on-state-change` callback (the upgrade.rs module doc pins the composition \
verbatim: \"Composes with the `:behavior :on-state-change` callback to deliver state \
migration during hot upgrades\"). The missing callback leaves the per-version script \
with no runtime delivery path: the operator's hot-upgrade dispatch reaches for the \
callback at the migration step, finds it absent, and either fails the upgrade \
mid-flight (the transactional rollback the module doc names — \"On any failure, the \
current version stays load-bearing\") or silently skips the migration leaving the \
new code running against unmigrated prior-version state. Add the callback: \
`(:behavior ((:on-state-change \"lib/migrations.lisp\") …))` (the runtime delivery \
path) alongside the existing `(:state-change {})` instruction (the per-version \
script). If the upgrade truly carries no state migration, drop the `(:state-change \
…)` instruction from the entry (a metadata-only upgrade — load + cleanup, no \
migration — is the canonical shape).",
script.display(),
script.display()
)]
StateChangeWithoutOnStateChangeCallback { from: String, script: PathBuf },
}
macro_rules! upgrade_from_script_ctors {
($($ctor:ident => $variant:ident),* $(,)?) => {
impl UpgradeError {
$(
#[doc = concat!(
"Construct an [`UpgradeError::",
stringify!($variant),
"`] naming the offending `(:from <prior-versao>)` and ",
"`(:state-change <script>)` pair. Folds the uniform ",
"`Self::",
stringify!($variant),
" { from: from.to_string(), script: script.to_path_buf() }` ",
"two-field struct-literal onto one substrate primitive so ",
"every wire-up on this variant reads through one dispatch ",
"rather than the pre-lift three-line open-coded block. The ",
"`from` string threads verbatim from ",
"[`UpgradeFromEntry::prior_versao`] and the `script` path ",
"from [`UpgradeInstruction::declared_path`] at the call site."
)]
#[must_use]
pub fn $ctor(from: &str, script: &std::path::Path) -> Self {
Self::$variant {
from: from.to_string(),
script: script.to_path_buf(),
}
}
)*
}
};
}
upgrade_from_script_ctors! {
state_change_without_prior_load => StateChangeWithoutPriorLoad,
duplicate_state_change => DuplicateStateChange,
state_change_without_on_state_change_callback => StateChangeWithoutOnStateChangeCallback,
}
macro_rules! upgrade_script_only_ctors {
($($ctor:ident => $variant:ident),* $(,)?) => {
impl UpgradeError {
$(
#[doc = concat!(
"Construct an [`UpgradeError::",
stringify!($variant),
"`] naming the offending `(:state-change <script>)`. ",
"Folds the uniform `Self::",
stringify!($variant),
" { script: script.to_path_buf() }` one-field ",
"struct-literal onto one substrate primitive so every ",
"closure passed to ",
"[`crate::render::require_sandboxed_lisp_path`] at ",
"[`UpgradeInstruction::validate`] on this variant reads ",
"through one dispatch rather than the pre-lift three-line ",
"open-coded block. The `script` path threads verbatim ",
"from [`UpgradeInstruction::declared_path`] at the call ",
"site."
)]
#[must_use]
pub fn $ctor(script: &std::path::Path) -> Self {
Self::$variant {
script: script.to_path_buf(),
}
}
)*
}
};
}
upgrade_script_only_ctors! {
absolute_script => AbsoluteScript,
parent_escape_script => ParentEscapeScript,
non_lisp_extension_script => NonLispExtensionScript,
}
macro_rules! upgrade_from_axis_ctors {
($($ctor:ident => $variant:ident { $axis:ident }),* $(,)?) => {
impl UpgradeError {
$(
#[doc = concat!(
"Construct an [`UpgradeError::",
stringify!($variant),
"`] naming the offending `(:from <prior-versao>)` and ",
"the offending `:", stringify!($axis), "` axis value. ",
"Folds the uniform `Self::",
stringify!($variant),
" { from: from.to_string(), ",
stringify!($axis),
": ",
stringify!($axis),
".to_string() }` two-field struct-literal onto one ",
"substrate primitive so every in-crate wire-up on ",
"this variant reads through one dispatch rather than ",
"the pre-lift four-line open-coded block. Both `from: ",
"&str` and `",
stringify!($axis),
": &str` parameters accept `&str` literals and ",
"`&String` (via Deref coercion) so every existing ",
"wire-up threads through the ctor without a pre-",
"conversion."
)]
#[must_use]
pub fn $ctor(from: &str, $axis: &str) -> Self {
Self::$variant {
from: from.to_string(),
$axis: $axis.to_string(),
}
}
)*
}
};
}
upgrade_from_axis_ctors! {
from_invalid => FromInvalid { reason },
from_not_before_versao => FromNotBeforeVersao { versao },
duplicate_load_module => DuplicateLoadModule { module },
}
impl UpgradeError {
#[must_use]
pub fn duplicate_from(entry: &UpgradeFromEntry) -> Self {
Self::DuplicateFrom {
from: entry.prior_versao().to_string(),
}
}
#[must_use]
pub fn purge_without_prior_load(from: &str, kind: &'static str, module: &str) -> Self {
Self::PurgeWithoutPriorLoad {
from: from.to_string(),
kind,
module: module.to_string(),
}
}
#[must_use]
pub fn state_change_after_cleanup(
from: &str,
script: &std::path::Path,
prior_cleanup_kind: &'static str,
prior_cleanup_module: &str,
) -> Self {
Self::StateChangeAfterCleanup {
from: from.to_string(),
script: script.to_path_buf(),
prior_cleanup_kind,
prior_cleanup_module: prior_cleanup_module.to_string(),
}
}
#[must_use]
pub fn duplicate_cleanup(from: &str, module: &str, kinds: Vec<&'static str>) -> Self {
Self::DuplicateCleanup {
from: from.to_string(),
module: module.to_string(),
kinds,
}
}
#[must_use]
pub fn restart_not_exclusive(
from: &str,
restart_count: usize,
other_kinds: Vec<&'static str>,
) -> Self {
Self::RestartNotExclusive {
from: from.to_string(),
restart_count,
other_kinds,
}
}
#[must_use]
pub fn module_invalid(kind: &'static str, module: &str, reason: impl Into<String>) -> Self {
Self::ModuleInvalid {
kind,
module: module.to_string(),
reason: reason.into(),
}
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
fn entry(from: &str, instrs: Vec<UpgradeInstruction>) -> UpgradeFromEntry {
UpgradeFromEntry {
from: from.into(),
instructions: instrs,
}
}
#[test]
fn upgrade_from_entry_prior_versao_accessor_is_const_fn() {
const fn prior_versao_via_const_fn(e: &UpgradeFromEntry) -> &str {
e.prior_versao()
}
for from in ["0.1.0", "1.2.3-alpha.1", "0.0.0"] {
let e = entry(from, vec![]);
assert_eq!(prior_versao_via_const_fn(&e), e.prior_versao());
assert_eq!(e.prior_versao(), from);
}
}
#[test]
fn upgrade_from_entry_instructions_slice_return_accessor_is_const_fn() {
const fn instructions_via_const_fn(e: &UpgradeFromEntry) -> &[UpgradeInstruction] {
e.instructions()
}
let e_empty = entry("0.1.0", vec![]);
assert!(instructions_via_const_fn(&e_empty).is_empty());
assert_eq!(instructions_via_const_fn(&e_empty), e_empty.instructions());
let e_full = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "hello-rio-old".into(),
},
],
);
assert_eq!(instructions_via_const_fn(&e_full).len(), 3);
assert_eq!(instructions_via_const_fn(&e_full), e_full.instructions());
}
#[test]
fn round_trip_load_module() {
let i = UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
};
let json = serde_json::to_string(&i).unwrap();
assert!(json.contains("\"kind\":\"load-module\""));
let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
assert_eq!(i, back);
}
#[test]
fn round_trip_all_variants() {
let cases = vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/migrations.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::Purge {
module: "x-old".into(),
},
UpgradeInstruction::Restart,
];
for c in cases {
let json = serde_json::to_string(&c).unwrap();
let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
}
}
#[test]
fn validate_accepts_well_formed() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "hello-rio-old".into(),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_rejects_non_semver_from() {
let e = entry("not-a-semver", vec![]);
let err = e.validate().unwrap_err();
assert!(
matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver")
);
}
#[test]
fn from_invalid_diagnostic_carries_offending_from_and_reason() {
let e = entry("v0.1.0", vec![]);
let err = e.validate().unwrap_err();
let UpgradeError::FromInvalid { from, reason } = err else {
panic!("expected FromInvalid variant, got {err:?}");
};
assert_eq!(from, "v0.1.0");
assert!(
!reason.is_empty(),
"FromInvalid `reason` must carry the parser's wording verbatim"
);
}
#[test]
fn prior_versao_returns_from_byte_equal_across_permutations() {
let cases = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30", ""];
for from in cases {
let e = entry(from, vec![]);
assert_eq!(
e.prior_versao(),
from,
"prior_versao() must return the `:from` field byte-for-byte for {from:?}",
);
assert_eq!(
e.prior_versao().len(),
from.len(),
"prior_versao() byte-length must equal the `:from` field's for {from:?}",
);
}
}
#[test]
fn prior_versao_borrows_from_from_storage() {
let e = entry("0.1.0", vec![]);
assert!(
std::ptr::eq(e.prior_versao().as_ptr(), e.from.as_ptr()),
"prior_versao() must borrow from `self.from`'s storage, not allocate a fresh copy",
);
}
#[test]
fn validate_parses_prior_versao_through_lifted_accessor() {
let accepted = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30"];
for from in accepted {
let e = entry(from, vec![]);
e.validate().unwrap_or_else(|err| {
panic!("validate() must accept {from:?} that Version::parse accepts, got {err:?}");
});
semver::Version::parse(e.prior_versao()).unwrap_or_else(|err| {
panic!(
"Version::parse(prior_versao()) must accept {from:?} that validate() accepts, \
got {err:?}",
);
});
}
let rejected = ["", "v0.1.0", "0.1", "not-a-semver", "0.1.0.0"];
for from in rejected {
let e = entry(from, vec![]);
assert!(
matches!(e.validate(), Err(UpgradeError::FromInvalid { .. })),
"validate() must reject {from:?} that Version::parse rejects",
);
assert!(
semver::Version::parse(e.prior_versao()).is_err(),
"Version::parse(prior_versao()) must reject {from:?} that validate() rejects",
);
}
}
#[test]
fn validate_rejects_empty_module() {
let cases: &[(UpgradeInstruction, &'static str)] = &[
(
UpgradeInstruction::LoadModule {
module: String::new(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
),
(
UpgradeInstruction::SoftPurge {
module: String::new(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
),
(
UpgradeInstruction::Purge {
module: String::new(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
),
];
for (instr, expected_kind) in cases {
assert_eq!(
instr.validate().unwrap_err(),
UpgradeError::ModuleEmpty {
kind: expected_kind
},
"empty :module on {instr:?} must surface as ModuleEmpty {{ kind: {expected_kind:?} }}"
);
}
}
#[test]
fn validate_rejects_non_dns_1123_module() {
type Build = fn(String) -> UpgradeInstruction;
let footguns: &[&str] = &[
"Hello-Rio",
"hello_rio",
"hello.rio",
"-hello",
"hello-",
"hello rio",
&"x".repeat(crate::render::DNS_1123_LABEL_MAX_LEN + 1),
];
let variants: &[(Build, &'static str)] = &[
(
|m| UpgradeInstruction::LoadModule { module: m },
crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
),
(
|m| UpgradeInstruction::SoftPurge { module: m },
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
),
(
|m| UpgradeInstruction::Purge { module: m },
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
),
];
for (build, expected_kind) in variants {
for module in footguns {
let instr = build((*module).to_string());
let err = instr.validate().unwrap_err();
match err {
UpgradeError::ModuleInvalid {
kind,
module: m,
reason,
} => {
assert_eq!(
kind, *expected_kind,
":module footgun on {instr:?} must tag the lisp-form"
);
assert_eq!(
m, *module,
"ModuleInvalid must carry the offending value verbatim"
);
assert!(
!reason.is_empty(),
"ModuleInvalid reason must name the specific violation \
(the predicate's parser-shaped wording from \
`is_dns_1123_label`), got empty"
);
}
other => panic!("expected ModuleInvalid on {instr:?}, got {other:?}"),
}
}
}
}
#[test]
fn validate_accepts_canonical_module_names() {
let canonical: &[&str] = &[
"hello-rio",
"hello-rio-old",
"cache",
"cache-v2",
"x",
"a1",
"0a",
"abc-123-def",
];
for module in canonical {
UpgradeInstruction::LoadModule {
module: (*module).to_string(),
}
.validate()
.unwrap_or_else(|e| panic!("LoadModule {module:?} must pass, got {e:?}"));
UpgradeInstruction::SoftPurge {
module: (*module).to_string(),
}
.validate()
.unwrap_or_else(|e| panic!("SoftPurge {module:?} must pass, got {e:?}"));
UpgradeInstruction::Purge {
module: (*module).to_string(),
}
.validate()
.unwrap_or_else(|e| panic!("Purge {module:?} must pass, got {e:?}"));
}
}
#[test]
fn validate_empty_takes_precedence_over_invalid() {
let err = UpgradeInstruction::LoadModule {
module: String::new(),
}
.validate()
.unwrap_err();
assert_eq!(
err,
UpgradeError::ModuleEmpty {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
}
);
}
#[test]
fn validate_rejects_empty_script() {
let i = UpgradeInstruction::StateChange {
script: PathBuf::new(),
};
assert_eq!(i.validate().unwrap_err(), UpgradeError::EmptyScript);
}
#[test]
fn validate_rejects_absolute_script() {
let i = UpgradeInstruction::StateChange {
script: PathBuf::from("/etc/migrations.lisp"),
};
assert!(matches!(
i.validate().unwrap_err(),
UpgradeError::AbsoluteScript { .. }
));
}
#[test]
fn validate_rejects_parent_escape_script() {
let i = UpgradeInstruction::StateChange {
script: PathBuf::from("../sibling/migrations.lisp"),
};
assert!(matches!(
i.validate().unwrap_err(),
UpgradeError::ParentEscapeScript { .. }
));
let i2 = UpgradeInstruction::StateChange {
script: PathBuf::from("lib/../../escaped.lisp"),
};
assert!(matches!(
i2.validate().unwrap_err(),
UpgradeError::ParentEscapeScript { .. }
));
}
#[test]
fn validate_rejects_no_extension_script() {
for relpath in ["lib/migrations", "migrations", "lib/handlers/migrate"] {
let i = UpgradeInstruction::StateChange {
script: PathBuf::from(relpath),
};
let err = i.validate().unwrap_err();
assert!(
matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
if s == Path::new(relpath)),
"no-extension script {relpath:?} must surface as NonLispExtensionScript \
carrying the offending path verbatim, got {err:?}"
);
}
}
#[test]
fn validate_rejects_non_lisp_extension_script() {
let footguns: &[&str] = &[
"lib/migrations.rs",
"lib/migrations.txt",
"lib/migrations.md",
"lib/migrations.json",
"lib/migrations.yaml",
"lib/migrations.toml",
"lib/migrations.lisp.bak",
"lib/migrations.lispx",
"lib/migrations.lis",
];
for relpath in footguns {
let i = UpgradeInstruction::StateChange {
script: PathBuf::from(relpath),
};
let err = i.validate().unwrap_err();
assert!(
matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
if s == Path::new(relpath)),
"wrong-extension script {relpath:?} must surface as NonLispExtensionScript \
carrying the offending path verbatim, got {err:?}"
);
}
}
#[test]
fn validate_rejects_uppercase_lisp_extension_script() {
for relpath in [
"lib/migrations.LISP",
"lib/migrations.Lisp",
"lib/migrations.LiSp",
"lib/migrations.lISP",
] {
let i = UpgradeInstruction::StateChange {
script: PathBuf::from(relpath),
};
let err = i.validate().unwrap_err();
assert!(
matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
if s == Path::new(relpath)),
"case-folded `.lisp` extension {relpath:?} must surface as \
NonLispExtensionScript (strict lowercase, canonical-form \
round-trip pin), got {err:?}"
);
}
}
#[test]
fn validate_accepts_canonical_lisp_extension_scripts() {
let canonical: &[&str] = &[
"lib/migrations.lisp",
"lib/migrations/v01-to-v02.lisp",
"migrations.lisp",
"a.lisp",
"./lib/migrations.lisp",
"lib/./migrations.lisp",
"lib/migrations/v.0.1.lisp",
];
for relpath in canonical {
UpgradeInstruction::StateChange {
script: PathBuf::from(relpath),
}
.validate()
.unwrap_or_else(|e| {
panic!("canonical `.lisp` script {relpath:?} must pass, got {e:?}")
});
}
}
#[test]
fn validate_sandbox_shape_takes_precedence_over_lisp_extension() {
let i_empty = UpgradeInstruction::StateChange {
script: PathBuf::new(),
};
assert_eq!(i_empty.validate().unwrap_err(), UpgradeError::EmptyScript);
let i_abs = UpgradeInstruction::StateChange {
script: PathBuf::from("/etc/migrations.txt"),
};
assert!(
matches!(
i_abs.validate().unwrap_err(),
UpgradeError::AbsoluteScript { .. }
),
"absolute + non-`.lisp` must surface AbsoluteScript first"
);
let i_esc = UpgradeInstruction::StateChange {
script: PathBuf::from("../sibling/migrations.rs"),
};
assert!(
matches!(
i_esc.validate().unwrap_err(),
UpgradeError::ParentEscapeScript { .. }
),
"parent-escape + non-`.lisp` must surface ParentEscapeScript first"
);
}
#[test]
fn non_lisp_extension_script_diagnostic_carries_offending_path() {
let bad = PathBuf::from("lib/migrations.txt");
let err = UpgradeInstruction::StateChange {
script: bad.clone(),
}
.validate()
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("lib/migrations.txt"),
"diagnostic must name the offending path verbatim, got {msg:?}"
);
assert!(
msg.contains(".lisp"),
"diagnostic must name the expected `.lisp` extension, got {msg:?}"
);
assert!(
msg.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE),
"diagnostic must name the offending `:state-change` instruction, got {msg:?}"
);
match err {
UpgradeError::NonLispExtensionScript { script } => {
assert_eq!(
script, bad,
"variant must carry the offending path verbatim"
);
}
other => panic!("expected NonLispExtensionScript, got {other:?}"),
}
}
#[test]
fn declared_path_only_for_state_change() {
let load = UpgradeInstruction::LoadModule { module: "x".into() };
assert!(load.declared_path().is_none());
let mig = UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
};
assert_eq!(mig.declared_path(), Some(&PathBuf::from("lib/m.lisp")));
}
#[test]
fn upgrade_instruction_is_restart_predicate_partitions_the_arm_set() {
let cases: &[(UpgradeInstruction, bool)] = &[
(UpgradeInstruction::LoadModule { module: "a".into() }, false),
(UpgradeInstruction::SoftPurge { module: "b".into() }, false),
(UpgradeInstruction::Purge { module: "c".into() }, false),
(
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
false,
),
(UpgradeInstruction::Restart, true),
];
for (variant, expected) in cases {
assert_eq!(
variant.is_restart(),
*expected,
"UpgradeInstruction::{variant:?}.is_restart() must \
return {expected} (partition invariant on the \
IsVariant-derived arm-discriminator predicate)"
);
}
}
#[test]
fn validate_restart_exclusive_routes_through_is_restart_predicate() {
let cases: Vec<UpgradeInstruction> = vec![
UpgradeInstruction::LoadModule { module: "a".into() },
UpgradeInstruction::SoftPurge { module: "b".into() },
UpgradeInstruction::Purge { module: "c".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::Restart,
];
for instr in &cases {
let via_predicate = instr.is_restart();
let via_matches = matches!(instr, UpgradeInstruction::Restart);
assert_eq!(
via_predicate, via_matches,
"UpgradeInstruction::{instr:?}: is_restart() must \
byte-equal matches!(_, UpgradeInstruction::Restart) — \
the pre-lift open-coded pattern and the \
IsVariant-derived predicate are the same axis, \
one typed dispatch"
);
}
}
#[test]
fn upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set() {
let cases: &[(UpgradeInstruction, bool)] = &[
(UpgradeInstruction::LoadModule { module: "a".into() }, false),
(UpgradeInstruction::SoftPurge { module: "b".into() }, true),
(UpgradeInstruction::Purge { module: "c".into() }, true),
(
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
false,
),
(UpgradeInstruction::Restart, false),
];
for (variant, expected) in cases {
assert_eq!(
variant.is_cleanup(),
*expected,
"UpgradeInstruction::{variant:?}.is_cleanup() must \
return {expected} (partition invariant on the \
lifted OTP-appup two-arm cleanup-family arm-\
discriminator predicate)"
);
}
}
#[test]
fn upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge() {
let cases: Vec<UpgradeInstruction> = vec![
UpgradeInstruction::LoadModule { module: "a".into() },
UpgradeInstruction::SoftPurge { module: "b".into() },
UpgradeInstruction::Purge { module: "c".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::Restart,
];
for instr in &cases {
let via_predicate = instr.is_cleanup();
let via_composition = instr.is_soft_purge() || instr.is_purge();
assert_eq!(
via_predicate, via_composition,
"UpgradeInstruction::{instr:?}: is_cleanup() must \
byte-equal is_soft_purge() || is_purge() — the \
lifted union predicate and its per-variant \
composition are the same axis, one typed dispatch"
);
}
}
#[test]
fn upgrade_instruction_is_cleanup_implies_declared_module_is_some() {
let cases: Vec<UpgradeInstruction> = vec![
UpgradeInstruction::LoadModule { module: "a".into() },
UpgradeInstruction::SoftPurge { module: "b".into() },
UpgradeInstruction::Purge { module: "c".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::Restart,
];
for instr in &cases {
if instr.is_cleanup() {
assert!(
instr.declared_module().is_some(),
"UpgradeInstruction::{instr:?}: is_cleanup() \
must imply declared_module().is_some() — the \
three within-entry cross-instruction cleanup-\
facing gates rely on this invariant to route \
the cleanup-target :module scalar through the \
sibling declared_module accessor without a \
pattern-bound `module` binding"
);
}
}
}
#[test]
fn upgrade_instruction_is_load_module_implies_declared_module_is_some() {
let cases: Vec<UpgradeInstruction> = vec![
UpgradeInstruction::LoadModule { module: "a".into() },
UpgradeInstruction::SoftPurge { module: "b".into() },
UpgradeInstruction::Purge { module: "c".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::Restart,
];
for instr in &cases {
if instr.is_load_module() {
assert!(
instr.declared_module().is_some(),
"UpgradeInstruction::{instr:?}: is_load_module() \
must imply declared_module().is_some() — the \
within-entry load-singularity gate relies on this \
invariant to route the load-target :module scalar \
through the sibling declared_module accessor \
without a pattern-bound `module` binding"
);
}
}
}
#[test]
fn validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors()
{
let lm = UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
};
assert!(
lm.is_load_module(),
"LoadModule must satisfy is_load_module() — the gate's \
load-family arm-discriminator relies on this partition"
);
assert_eq!(
lm.declared_module(),
Some("hello-rio"),
"declared_module() must project the LoadModule :module \
byte-equal to the raw field access — accessor divergence \
would silently detach the gate from the projection every \
peer per-`UpgradeInstruction` consumer routes through"
);
let dup = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::LoadModule { module: "x".into() },
],
);
assert_eq!(
dup.validate_load_singularity(),
Err(UpgradeError::DuplicateLoadModule {
from: "0.1.0".into(),
module: "x".into(),
}),
"duplicate LoadModule modules within one entry must fire \
DuplicateLoadModule byte-identical to the pre-lift \
pattern-match shape"
);
let no_load = entry(
"0.1.0",
vec![
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::Restart,
],
);
assert_eq!(
no_load.validate_load_singularity(),
Ok(()),
"non-LoadModule-only entries must leave the load-\
singularity gate vacuous — the `!is_load_module()` \
continue fall-through pins"
);
}
#[test]
fn upgrade_instruction_is_load_module_predicate_partitions_the_arm_set() {
let cases: &[(UpgradeInstruction, bool)] = &[
(UpgradeInstruction::LoadModule { module: "a".into() }, true),
(UpgradeInstruction::SoftPurge { module: "b".into() }, false),
(UpgradeInstruction::Purge { module: "c".into() }, false),
(
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
false,
),
(UpgradeInstruction::Restart, false),
];
for (variant, expected) in cases {
assert_eq!(
variant.is_load_module(),
*expected,
"UpgradeInstruction::{variant:?}.is_load_module() must \
return {expected} (partition invariant on the \
IsVariant-derived arm-discriminator predicate)"
);
}
}
#[test]
fn validate_purge_ordering_routes_through_is_load_module_predicate() {
let cases: Vec<UpgradeInstruction> = vec![
UpgradeInstruction::LoadModule { module: "a".into() },
UpgradeInstruction::SoftPurge { module: "b".into() },
UpgradeInstruction::Purge { module: "c".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::Restart,
];
for instr in &cases {
let via_predicate = instr.is_load_module();
let via_matches = matches!(instr, UpgradeInstruction::LoadModule { .. });
assert_eq!(
via_predicate, via_matches,
"UpgradeInstruction::{instr:?}: is_load_module() must \
byte-equal matches!(_, UpgradeInstruction::LoadModule \
{{ .. }}) — the pre-lift open-coded pattern and the \
IsVariant-derived predicate are the same axis, one \
typed dispatch"
);
}
}
#[test]
fn declared_module_only_for_module_bearing_variants() {
let load = UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
};
assert_eq!(load.declared_module(), Some("hello-rio"));
let soft = UpgradeInstruction::SoftPurge {
module: "hello-rio-old".into(),
};
assert_eq!(soft.declared_module(), Some("hello-rio-old"));
let hard = UpgradeInstruction::Purge {
module: "hello-rio-ancient".into(),
};
assert_eq!(hard.declared_module(), Some("hello-rio-ancient"));
let mig = UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
};
assert!(mig.declared_module().is_none());
assert!(UpgradeInstruction::Restart.declared_module().is_none());
}
#[test]
fn declared_module_and_declared_path_partition_the_enum_variant_space() {
let cases: Vec<UpgradeInstruction> = vec![
UpgradeInstruction::LoadModule { module: "a".into() },
UpgradeInstruction::SoftPurge { module: "b".into() },
UpgradeInstruction::Purge { module: "c".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::Restart,
];
for instr in &cases {
let has_module = instr.declared_module().is_some();
let has_path = instr.declared_path().is_some();
assert!(
!(has_module && has_path),
"no variant may declare both a module and a path — offending: {instr:?}"
);
match instr {
UpgradeInstruction::LoadModule { .. }
| UpgradeInstruction::SoftPurge { .. }
| UpgradeInstruction::Purge { .. } => {
assert!(has_module && !has_path, "module axis: {instr:?}");
}
UpgradeInstruction::StateChange { .. } => {
assert!(!has_module && has_path, "script axis: {instr:?}");
}
UpgradeInstruction::Restart => {
assert!(!has_module && !has_path, "data-less axis: {instr:?}");
}
}
}
}
#[test]
fn entry_with_chain_of_versions() {
let entries = vec![
entry(
"0.1.0",
vec![UpgradeInstruction::LoadModule { module: "x".into() }],
),
entry(
"0.1.5",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
),
entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
];
for e in &entries {
e.validate().unwrap();
}
let json = serde_json::to_string(&entries).unwrap();
let back: Vec<UpgradeFromEntry> = serde_json::from_str(&json).unwrap();
assert_eq!(entries, back);
}
#[test]
fn empty_instructions_list_is_valid() {
let e = entry("0.1.0", vec![]);
e.validate().unwrap();
}
#[test]
fn json_uses_kebab_case_kind_tags() {
let i = UpgradeInstruction::SoftPurge {
module: "x-old".into(),
};
let json = serde_json::to_string(&i).unwrap();
assert!(json.contains("\"kind\":\"soft-purge\""));
let i2 = UpgradeInstruction::StateChange {
script: PathBuf::from("m.lisp"),
};
let json2 = serde_json::to_string(&i2).unwrap();
assert!(json2.contains("\"kind\":\"state-change\""));
}
#[test]
fn validate_upgrade_from_accepts_disjoint_versions() {
let entries = vec![
entry(
"0.1.0",
vec![UpgradeInstruction::LoadModule { module: "x".into() }],
),
entry(
"0.1.5",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
),
entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
];
validate_upgrade_from(&entries).unwrap();
}
#[test]
fn validate_upgrade_from_accepts_empty_list() {
validate_upgrade_from(&[]).unwrap();
}
#[test]
fn validate_upgrade_from_rejects_duplicate_from() {
let entries = vec![
entry(
"0.1.0",
vec![UpgradeInstruction::LoadModule { module: "x".into() }],
),
entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
),
];
let err = validate_upgrade_from(&entries).unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateFrom {
from: "0.1.0".into()
},
"two entries with `:from \"0.1.0\"` must surface as DuplicateFrom carrying the \
offending value verbatim"
);
}
#[test]
fn validate_upgrade_from_treats_pre_release_as_distinct() {
let entries = vec![
entry("1.0.0", vec![UpgradeInstruction::Restart]),
entry("1.0.0-rc.1", vec![UpgradeInstruction::Restart]),
];
validate_upgrade_from(&entries).unwrap();
}
#[test]
fn validate_upgrade_from_treats_build_metadata_as_distinct() {
let entries = vec![
entry("1.0.0+build1", vec![UpgradeInstruction::Restart]),
entry("1.0.0+build2", vec![UpgradeInstruction::Restart]),
];
validate_upgrade_from(&entries).unwrap();
}
#[test]
fn validate_upgrade_from_per_entry_shape_fires_before_duplicate() {
let entries = vec![
entry("0.1.0", vec![UpgradeInstruction::Restart]),
entry("not-a-semver", vec![UpgradeInstruction::Restart]),
];
let err = validate_upgrade_from(&entries).unwrap_err();
assert!(
matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver"),
"malformed `:from` on a non-duplicate entry must surface as FromInvalid, got {err:?}"
);
}
#[test]
fn validate_upgrade_from_per_entry_shape_fires_before_duplicate_on_first_entry() {
let entries = vec![
entry(
"0.1.0",
vec![UpgradeInstruction::LoadModule {
module: String::new(),
}],
),
entry("0.1.0", vec![UpgradeInstruction::Restart]),
];
let err = validate_upgrade_from(&entries).unwrap_err();
assert_eq!(
err,
UpgradeError::ModuleEmpty {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
},
"malformed instruction on the first entry of a duplicate pair must surface its \
per-entry diagnostic before the duplicate gate fires, got {err:?}"
);
}
#[test]
fn validate_upgrade_from_duplicate_diagnostic_names_second_collision() {
let entries = vec![
entry("0.1.0", vec![UpgradeInstruction::Restart]),
entry("0.1.0", vec![UpgradeInstruction::Restart]),
entry("0.1.0", vec![UpgradeInstruction::Restart]),
];
let err = validate_upgrade_from(&entries).unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateFrom {
from: "0.1.0".into()
}
);
}
#[test]
fn validate_upgrade_from_single_entry_never_duplicates() {
let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
validate_upgrade_from(&entries).unwrap();
}
#[test]
fn versao_gate_accepts_strict_upgrade() {
let entries = vec![
entry("0.1.0", vec![UpgradeInstruction::Restart]),
entry("0.1.5", vec![UpgradeInstruction::Restart]),
entry("0.1.9", vec![UpgradeInstruction::Restart]),
];
validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
}
#[test]
fn versao_gate_accepts_empty_entries() {
validate_upgrade_from_against_versao(&[], "0.1.0").unwrap();
}
#[test]
fn versao_gate_rejects_equal_from() {
let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
assert_eq!(
err,
UpgradeError::FromNotBeforeVersao {
from: "0.2.0".into(),
versao: "0.2.0".into(),
},
":from == :versao under precedence must surface as FromNotBeforeVersao naming both \
values verbatim, got {err:?}"
);
}
#[test]
fn versao_gate_rejects_downgrade_from() {
let entries = vec![entry("0.3.0", vec![UpgradeInstruction::Restart])];
let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
assert_eq!(
err,
UpgradeError::FromNotBeforeVersao {
from: "0.3.0".into(),
versao: "0.2.0".into(),
}
);
}
#[test]
fn versao_gate_accepts_prerelease_before_release() {
let entries = vec![entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart])];
validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
}
#[test]
fn versao_gate_rejects_release_after_prerelease() {
let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
let err = validate_upgrade_from_against_versao(&entries, "0.2.0-rc.1").unwrap_err();
assert_eq!(
err,
UpgradeError::FromNotBeforeVersao {
from: "0.2.0".into(),
versao: "0.2.0-rc.1".into(),
}
);
}
#[test]
fn versao_gate_rejects_build_metadata_only_difference() {
let entries = vec![entry("0.2.0+build.1", vec![UpgradeInstruction::Restart])];
let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
assert_eq!(
err,
UpgradeError::FromNotBeforeVersao {
from: "0.2.0+build.1".into(),
versao: "0.2.0".into(),
}
);
}
#[test]
fn versao_gate_silently_passes_on_unparseable_versao() {
let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
validate_upgrade_from_against_versao(&entries, "not-a-semver").unwrap();
}
#[test]
fn versao_gate_silently_passes_on_unparseable_from() {
let entries = vec![entry("not-a-semver", vec![UpgradeInstruction::Restart])];
validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
}
#[test]
fn versao_gate_reports_first_offending_entry() {
let entries = vec![
entry("0.1.0", vec![UpgradeInstruction::Restart]),
entry("0.3.0", vec![UpgradeInstruction::Restart]),
entry("0.4.0", vec![UpgradeInstruction::Restart]),
];
let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
assert_eq!(
err,
UpgradeError::FromNotBeforeVersao {
from: "0.3.0".into(),
versao: "0.2.0".into(),
},
"the first offending `:from` (0.3.0) must surface, not the later one (0.4.0)"
);
}
#[test]
fn validate_rejects_restart_mixed_with_load_module() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
UpgradeInstruction::Restart,
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::RestartNotExclusive {
from: "0.1.0".into(),
restart_count: 1,
other_kinds: vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
},
"restart + load-module mix must surface as RestartNotExclusive naming the \
offending `:from` + the non-:restart kinds verbatim, got {err:?}"
);
}
#[test]
fn validate_rejects_restart_mixed_with_full_typed_sequence() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "hello-rio-old".into(),
},
UpgradeInstruction::Purge {
module: "hello-rio-old".into(),
},
UpgradeInstruction::Restart,
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::RestartNotExclusive {
from: "0.1.0".into(),
restart_count: 1,
other_kinds: vec![
crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
],
},
);
}
#[test]
fn validate_rejects_restart_duplicated() {
let e = entry(
"0.1.0",
vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::RestartNotExclusive {
from: "0.1.0".into(),
restart_count: 2,
other_kinds: vec![],
},
);
}
#[test]
fn validate_accepts_sole_restart() {
let e = entry("0.1.0", vec![UpgradeInstruction::Restart]);
e.validate().unwrap();
}
#[test]
fn validate_accepts_typed_sequence_without_restart() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "hello-rio-old".into(),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_rejects_state_change_without_load() {
let e = entry(
"0.1.0",
vec![UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
}],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::StateChangeWithoutPriorLoad {
from: "0.1.0".into(),
script: PathBuf::from("lib/m.lisp"),
},
"a `:state-change` with no preceding `:load-module` must surface as \
StateChangeWithoutPriorLoad naming the offending entry + script verbatim"
);
}
#[test]
fn validate_rejects_state_change_before_load() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
"a `:state-change` ahead of its `:load-module` must surface as \
StateChangeWithoutPriorLoad, got {err:?}"
);
}
#[test]
fn validate_accepts_state_change_after_load() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_accepts_multiple_state_changes_after_one_load() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m1.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m2.lisp"),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors()
{
let lm = UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
};
assert!(
lm.is_load_module(),
"LoadModule must satisfy is_load_module() — the gate's \
load-family sticky-latch relies on this partition"
);
assert!(
lm.declared_path().is_none(),
"LoadModule must not carry a declared_path — the gate's \
else-if migration-family arm must not fire on load arms"
);
let sc = UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
};
assert!(
!sc.is_load_module(),
"StateChange must not satisfy is_load_module() — the gate's \
sticky-latch must not advance on migration arms"
);
assert_eq!(
sc.declared_path().map(std::path::PathBuf::as_path),
Some(PathBuf::from("lib/m.lisp").as_path()),
"declared_path() must project the StateChange :script \
byte-equal to the raw field access — accessor divergence \
would silently detach the gate from the projection every \
peer per-`UpgradeInstruction` consumer routes through"
);
let no_prior_load = entry(
"0.1.0",
vec![UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
}],
);
assert_eq!(
no_prior_load.validate_state_change_ordering(),
Err(UpgradeError::StateChangeWithoutPriorLoad {
from: "0.1.0".into(),
script: PathBuf::from("lib/m.lisp"),
}),
"a `:state-change` with no preceding `:load-module` must fire \
StateChangeWithoutPriorLoad carrying the offending script \
verbatim through the declared_path() accessor"
);
let load_before_migrate = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
assert_eq!(
load_before_migrate.validate_state_change_ordering(),
Ok(()),
"load-before-migrate entries must leave the ordering gate \
vacuous — the `loaded = true` sticky-latch on the first arm \
satisfies the `!loaded` guard negation on the else-if arm"
);
for instr in [
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::Purge {
module: "x-old".into(),
},
UpgradeInstruction::Restart,
] {
let e = entry("0.1.0", vec![instr.clone()]);
assert_eq!(
e.validate_state_change_ordering(),
Ok(()),
"non-StateChange-non-LoadModule sequence ({instr:?}) must \
leave the ordering gate vacuous — declared_path() is None \
on every non-StateChange arm, so the else-if migration-\
family arm never fires"
);
}
}
#[test]
fn validate_state_change_ordering_fires_after_restart_exclusive() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::Restart,
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(err, UpgradeError::RestartNotExclusive { .. }),
"restart-mixed must surface before the ordering gate, got {err:?}"
);
}
#[test]
fn validate_rejects_soft_purge_without_load() {
let e = entry(
"0.1.0",
vec![UpgradeInstruction::SoftPurge {
module: "x-old".into(),
}],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::PurgeWithoutPriorLoad {
from: "0.1.0".into(),
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
module: "x-old".into(),
},
"a `:soft-purge` with no preceding `:load-module` must surface as \
PurgeWithoutPriorLoad naming the offending entry + kind + module verbatim"
);
}
#[test]
fn validate_rejects_purge_without_load() {
let e = entry(
"0.1.0",
vec![UpgradeInstruction::Purge {
module: "x-old".into(),
}],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::PurgeWithoutPriorLoad {
from: "0.1.0".into(),
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
module: "x-old".into(),
},
);
}
#[test]
fn validate_rejects_soft_purge_before_load() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::LoadModule { module: "x".into() },
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(
err,
UpgradeError::PurgeWithoutPriorLoad {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
..
}
),
"a `:soft-purge` ahead of its `:load-module` must surface as \
PurgeWithoutPriorLoad, got {err:?}"
);
}
#[test]
fn validate_rejects_purge_before_load() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::Purge {
module: "x-old".into(),
},
UpgradeInstruction::LoadModule { module: "x".into() },
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(
err,
UpgradeError::PurgeWithoutPriorLoad {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
..
}
),
"a `:purge` ahead of its `:load-module` must surface as \
PurgeWithoutPriorLoad, got {err:?}"
);
}
#[test]
fn validate_accepts_soft_purge_after_load() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_accepts_multiple_purges_after_one_load() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::Purge {
module: "x-oldest".into(),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_purge_ordering_fires_after_state_change_ordering() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
"state-change-without-load must surface before purge-without-load, got {err:?}"
);
}
#[test]
fn validate_purge_ordering_fires_after_per_instr_shape() {
let e = entry(
"0.1.0",
vec![UpgradeInstruction::SoftPurge {
module: String::new(),
}],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::ModuleEmpty {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
},
"malformed instruction must surface its kind-tagged diagnostic before the \
purge-ordering gate fires, got {err:?}"
);
}
#[test]
fn validate_purge_ordering_threads_through_validate_upgrade_from() {
let entries = vec![entry(
"0.1.0",
vec![UpgradeInstruction::Purge {
module: "x-old".into(),
}],
)];
let err = validate_upgrade_from(&entries).unwrap_err();
assert!(
matches!(
err,
UpgradeError::PurgeWithoutPriorLoad {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
..
}
),
"validate_upgrade_from must thread the purge-ordering error, got {err:?}"
);
}
#[test]
fn validate_state_change_ordering_threads_through_validate_upgrade_from() {
let entries = vec![entry(
"0.1.0",
vec![UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
}],
)];
let err = validate_upgrade_from(&entries).unwrap_err();
assert!(
matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
"validate_upgrade_from must thread the ordering error, got {err:?}"
);
}
#[test]
fn validate_rejects_duplicate_soft_purge_for_same_module() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateCleanup {
from: "0.1.0".into(),
module: "x-old".into(),
kinds: vec![
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
],
},
"two `:soft-purge` of the same module must surface as DuplicateCleanup naming the \
module + both kinds in declaration order, got {err:?}"
);
}
#[test]
fn validate_rejects_duplicate_purge_for_same_module() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::Purge {
module: "x-old".into(),
},
UpgradeInstruction::Purge {
module: "x-old".into(),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateCleanup {
from: "0.1.0".into(),
module: "x-old".into(),
kinds: vec![
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
],
},
);
}
#[test]
fn validate_rejects_soft_purge_then_purge_for_same_module() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::Purge {
module: "x-old".into(),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateCleanup {
from: "0.1.0".into(),
module: "x-old".into(),
kinds: vec![
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
],
},
);
}
#[test]
fn validate_rejects_purge_then_soft_purge_for_same_module() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::Purge {
module: "x-old".into(),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateCleanup {
from: "0.1.0".into(),
module: "x-old".into(),
kinds: vec![
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
],
},
);
}
#[test]
fn validate_accepts_distinct_cleanup_modules() {
let two_soft = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::SoftPurge {
module: "x-older".into(),
},
],
);
two_soft.validate().unwrap();
let mixed = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::Purge {
module: "x-oldest".into(),
},
],
);
mixed.validate().unwrap();
}
#[test]
fn validate_accepts_single_cleanup_per_module() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::Purge {
module: "y-old".into(),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_cleanup_singularity_fires_after_purge_ordering() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(
err,
UpgradeError::PurgeWithoutPriorLoad {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
..
}
),
"purge-without-load must surface before duplicate-cleanup, got {err:?}"
);
}
#[test]
fn validate_cleanup_singularity_fires_after_per_instr_shape() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: String::new(),
},
UpgradeInstruction::SoftPurge {
module: String::new(),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::ModuleEmpty {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
},
"malformed instruction must surface its kind-tagged diagnostic before the \
cleanup-singularity gate fires, got {err:?}"
);
}
#[test]
fn validate_cleanup_singularity_reports_first_collision() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::Purge {
module: "x-old".into(),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateCleanup {
from: "0.1.0".into(),
module: "x-old".into(),
kinds: vec![
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
],
},
"the first colliding pair must surface, not the later `:purge` collision"
);
}
#[test]
fn validate_cleanup_singularity_threads_through_validate_upgrade_from() {
let entries = vec![entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::Purge {
module: "x-old".into(),
},
],
)];
let err = validate_upgrade_from(&entries).unwrap_err();
assert!(
matches!(err, UpgradeError::DuplicateCleanup { .. }),
"validate_upgrade_from must thread the cleanup-singularity error, got {err:?}"
);
}
#[test]
fn validate_rejects_duplicate_load_module_for_same_module() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::LoadModule { module: "x".into() },
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateLoadModule {
from: "0.1.0".into(),
module: "x".into(),
},
"two `:load-module` of the same module must surface as DuplicateLoadModule naming \
the module, got {err:?}"
);
}
#[test]
fn validate_accepts_distinct_load_modules() {
let two_loads = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::LoadModule { module: "y".into() },
],
);
two_loads.validate().unwrap();
let with_cleanup = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::LoadModule { module: "y".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::SoftPurge {
module: "y-old".into(),
},
],
);
with_cleanup.validate().unwrap();
}
#[test]
fn validate_accepts_single_load_per_module() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_load_singularity_fires_after_state_change_ordering() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::LoadModule { module: "x".into() },
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
"state-change-without-load must surface before duplicate-load, got {err:?}"
);
}
#[test]
fn validate_load_singularity_fires_after_purge_ordering() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::LoadModule { module: "x".into() },
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(
err,
UpgradeError::PurgeWithoutPriorLoad {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
..
}
),
"purge-without-load must surface before duplicate-load, got {err:?}"
);
}
#[test]
fn validate_load_singularity_fires_after_per_instr_shape() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule {
module: String::new(),
},
UpgradeInstruction::LoadModule {
module: String::new(),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::ModuleEmpty {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
},
"malformed instruction must surface its kind-tagged diagnostic before the \
load-singularity gate fires, got {err:?}"
);
}
#[test]
fn validate_load_singularity_fires_before_cleanup_singularity() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "y-old".into(),
},
UpgradeInstruction::SoftPurge {
module: "y-old".into(),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateLoadModule {
from: "0.1.0".into(),
module: "x".into(),
},
"duplicate-load must surface before duplicate-cleanup, got {err:?}"
);
}
#[test]
fn validate_load_singularity_reports_first_collision() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::LoadModule { module: "x".into() },
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateLoadModule {
from: "0.1.0".into(),
module: "x".into(),
},
"the first colliding occurrence must surface, not the later third-load collision"
);
}
#[test]
fn validate_load_singularity_threads_through_validate_upgrade_from() {
let entries = vec![entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::LoadModule { module: "x".into() },
],
)];
let err = validate_upgrade_from(&entries).unwrap_err();
assert!(
matches!(err, UpgradeError::DuplicateLoadModule { .. }),
"validate_upgrade_from must thread the load-singularity error, got {err:?}"
);
}
#[test]
fn validate_rejects_duplicate_state_change_for_same_script() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateStateChange {
from: "0.1.0".into(),
script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
},
"two `:state-change` of the same script must surface as DuplicateStateChange naming \
the script, got {err:?}"
);
}
#[test]
fn validate_accepts_distinct_state_change_scripts() {
let two_migrations = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m1.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m2.lisp"),
},
],
);
two_migrations.validate().unwrap();
let with_cleanup = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m1.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m2.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
with_cleanup.validate().unwrap();
}
#[test]
fn validate_accepts_single_state_change_per_script() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_state_change_singularity_fires_after_state_change_ordering() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
"state-change-without-load must surface before duplicate-state-change, got {err:?}"
);
}
#[test]
fn validate_state_change_singularity_fires_after_purge_ordering() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(
err,
UpgradeError::PurgeWithoutPriorLoad {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
..
}
),
"purge-without-load must surface before duplicate-state-change, got {err:?}"
);
}
#[test]
fn validate_state_change_singularity_fires_after_per_instr_shape() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::new(),
},
UpgradeInstruction::StateChange {
script: PathBuf::new(),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::EmptyScript,
"malformed instruction must surface its narrower diagnostic before the \
state-change-singularity gate fires, got {err:?}"
);
}
#[test]
fn validate_state_change_singularity_fires_after_load_singularity() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateLoadModule {
from: "0.1.0".into(),
module: "x".into(),
},
"duplicate-load must surface before duplicate-state-change, got {err:?}"
);
}
#[test]
fn validate_state_change_singularity_fires_before_cleanup_singularity() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "y-old".into(),
},
UpgradeInstruction::SoftPurge {
module: "y-old".into(),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateStateChange {
from: "0.1.0".into(),
script: PathBuf::from("lib/m.lisp"),
},
"duplicate-state-change must surface before duplicate-cleanup, got {err:?}"
);
}
#[test]
fn validate_state_change_singularity_reports_first_collision() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::DuplicateStateChange {
from: "0.1.0".into(),
script: PathBuf::from("lib/m.lisp"),
},
"the first colliding occurrence must surface, not the later third-migration collision"
);
}
#[test]
fn validate_state_change_singularity_threads_through_validate_upgrade_from() {
let entries = vec![entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
)];
let err = validate_upgrade_from(&entries).unwrap_err();
assert!(
matches!(err, UpgradeError::DuplicateStateChange { .. }),
"validate_upgrade_from must thread the state-change-singularity error, got {err:?}"
);
}
#[test]
fn validate_state_change_singularity_projects_scripts_through_declared_path_accessor() {
use std::path::PathBuf;
let sc = UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
};
assert_eq!(
sc.declared_path().map(std::path::PathBuf::as_path),
Some(PathBuf::from("lib/m.lisp").as_path()),
"declared_path() must project the StateChange :script byte-equal to the raw \
field access — accessor divergence would silently detach the gate from the \
projection every peer per-`UpgradeInstruction` consumer routes through"
);
let dup = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
assert_eq!(
dup.validate_state_change_singularity(),
Err(UpgradeError::DuplicateStateChange {
from: "0.1.0".into(),
script: PathBuf::from("lib/m.lisp"),
}),
"duplicate StateChange scripts must trip the gate on the second occurrence \
through the declared_path accessor's Some(script) arm"
);
for instrs in [
vec![UpgradeInstruction::LoadModule { module: "x".into() }],
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::Purge {
module: "x-old".into(),
},
],
vec![UpgradeInstruction::Restart],
] {
for instr in &instrs {
assert!(
instr.declared_path().is_none(),
"non-StateChange variants must project None through declared_path — \
accessor divergence would let this gate silently fire on a duplicate \
module reference far from any :state-change site"
);
}
let e = entry("0.1.0", instrs);
assert_eq!(
e.validate_state_change_singularity(),
Ok(()),
"the state-change-singularity gate must return Ok(()) on an entry whose \
instructions all project None through declared_path — the accessor's \
continue arm the pattern-match's `_ => continue` previously carried"
);
}
}
#[test]
fn validate_rejects_state_change_after_soft_purge() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::StateChangeAfterCleanup {
from: "0.1.0".into(),
script: PathBuf::from("lib/m.lisp"),
prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
prior_cleanup_module: "x-old".into(),
},
"a `:state-change` after a `:soft-purge` must surface as StateChangeAfterCleanup \
naming the offending entry + script + the prior cleanup's kind/module, got {err:?}"
);
}
#[test]
fn validate_rejects_state_change_after_purge() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::Purge {
module: "x-old".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::StateChangeAfterCleanup {
from: "0.1.0".into(),
script: PathBuf::from("lib/m.lisp"),
prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
prior_cleanup_module: "x-old".into(),
},
"a `:state-change` after a `:purge` must surface as StateChangeAfterCleanup with \
`prior_cleanup_kind: \":purge\"`, got {err:?}"
);
}
#[test]
fn validate_accepts_state_change_before_cleanup() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_accepts_cleanup_without_state_change() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::Purge {
module: "x-oldest".into(),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_accepts_state_change_without_cleanup() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_accepts_multiple_state_changes_before_cleanup() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m1.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m2.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
e.validate().unwrap();
}
#[test]
fn validate_rejects_state_change_sandwiched_between_cleanups() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::Purge {
module: "y-old".into(),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::StateChangeAfterCleanup {
from: "0.1.0".into(),
script: PathBuf::from("lib/m.lisp"),
prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
prior_cleanup_module: "x-old".into(),
},
"the first cleanup the state-change follows must surface (not the trailing one), \
got {err:?}"
);
}
#[test]
fn validate_state_change_before_cleanup_fires_after_purge_ordering() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(
err,
UpgradeError::PurgeWithoutPriorLoad {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
..
}
),
"purge-without-load must surface before state-change-after-cleanup, got {err:?}"
);
}
#[test]
fn validate_state_change_before_cleanup_fires_after_state_change_ordering() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
"state-change-without-load must surface before purge-without-load (the canonical \
validate_purge_ordering_fires_after_state_change_ordering pin), got {err:?}"
);
}
#[test]
fn validate_state_change_before_cleanup_fires_after_per_instr_shape() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::new(),
},
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::EmptyScript,
"malformed instruction must surface its narrower diagnostic before the \
state-change-before-cleanup gate fires, got {err:?}"
);
}
#[test]
fn validate_state_change_before_cleanup_fires_before_state_change_singularity() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
let err = e.validate().unwrap_err();
assert!(
matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
"state-change-after-cleanup must surface before duplicate-state-change, got {err:?}"
);
}
#[test]
fn validate_state_change_before_cleanup_threads_through_validate_upgrade_from() {
let entries = vec![entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
)];
let err = validate_upgrade_from(&entries).unwrap_err();
assert!(
matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
"validate_upgrade_from must thread the state-change-before-cleanup error, \
got {err:?}"
);
}
#[test]
fn validate_state_change_before_cleanup_projects_scripts_through_declared_path_accessor() {
let sc = UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
};
assert_eq!(
sc.declared_path().cloned(),
Some(PathBuf::from("lib/m.lisp")),
"declared_path() must project the StateChange :script byte-equal to the raw \
field access — accessor divergence would silently detach this within-entry \
migrate→cleanup ordering gate from the projection every peer per-`UpgradeInstruction` \
consumer routes through"
);
let after = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
);
assert_eq!(
after.validate(),
Err(UpgradeError::StateChangeAfterCleanup {
from: "0.1.0".into(),
script: PathBuf::from("lib/m.lisp"),
prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
prior_cleanup_module: "x-old".into(),
}),
"a :state-change following a cleanup must trip the gate through the declared_path \
accessor's Some(script) arm — carrying the offending script + the prior cleanup's \
kind/module verbatim byte-identical to the pattern-match shape"
);
for instrs in [
vec![UpgradeInstruction::LoadModule { module: "x".into() }],
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::Purge {
module: "x-old".into(),
},
],
vec![UpgradeInstruction::Restart],
] {
for instr in &instrs {
assert!(
instr.declared_path().is_none(),
"non-StateChange variants must project None through declared_path — \
accessor divergence would let this within-entry ordering gate silently \
fire on a cleanup-only sequence far from any :state-change site"
);
}
let e = entry("0.1.0", instrs);
assert_eq!(
e.validate(),
Ok(()),
"the state-change-before-cleanup gate must return Ok(()) on an entry whose \
instructions all project None through declared_path — the accessor's \
None arm the pattern-match's implicit fall-through previously carried"
);
}
}
#[test]
fn validate_restart_order_independent() {
let leading = entry(
"0.1.0",
vec![
UpgradeInstruction::Restart,
UpgradeInstruction::LoadModule { module: "x".into() },
],
);
let trailing = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::Restart,
],
);
let middle = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "a".into() },
UpgradeInstruction::Restart,
UpgradeInstruction::SoftPurge {
module: "a-old".into(),
},
],
);
for e in [&leading, &trailing, &middle] {
assert!(
matches!(
e.validate().unwrap_err(),
UpgradeError::RestartNotExclusive {
restart_count: 1,
..
}
),
"mixed-with-:restart entry must surface RestartNotExclusive regardless of \
instruction order, got {:?}",
e.validate()
);
}
}
#[test]
fn validate_restart_exclusive_fires_after_per_instr_shape() {
let e = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule {
module: String::new(),
},
UpgradeInstruction::Restart,
],
);
let err = e.validate().unwrap_err();
assert_eq!(
err,
UpgradeError::ModuleEmpty {
kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
},
"malformed instruction must surface its kind-tagged diagnostic before the \
restart-exclusivity gate fires, got {err:?}"
);
}
fn behavior_with_state_change_callback() -> crate::BehaviorSpec {
crate::BehaviorSpec {
on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
..Default::default()
}
}
#[test]
fn behavior_gate_rejects_state_change_without_any_behavior() {
let entries = vec![entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
)];
let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
assert_eq!(
err,
UpgradeError::StateChangeWithoutOnStateChangeCallback {
from: "0.1.0".into(),
script: PathBuf::from("lib/m.lisp"),
},
);
}
#[test]
fn behavior_gate_rejects_state_change_when_on_state_change_is_none() {
let entries = vec![entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
)];
let b = crate::BehaviorSpec {
on_init: Some(PathBuf::from("lib/init.lisp")),
on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
..Default::default()
};
let err = validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap_err();
assert_eq!(
err,
UpgradeError::StateChangeWithoutOnStateChangeCallback {
from: "0.1.0".into(),
script: PathBuf::from("lib/m.lisp"),
},
"only `:on-state-change` satisfies the composition; other callbacks must not mask \
the missing migration hook"
);
}
#[test]
fn behavior_gate_accepts_state_change_with_on_state_change_callback() {
let entries = vec![entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
)];
let b = behavior_with_state_change_callback();
validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
}
#[test]
fn behavior_gate_accepts_entries_without_any_state_change() {
let entries = vec![entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
)];
validate_upgrade_from_against_behavior(&entries, None).unwrap();
}
#[test]
fn behavior_gate_accepts_restart_only_entry() {
let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
validate_upgrade_from_against_behavior(&entries, None).unwrap();
}
#[test]
fn behavior_gate_accepts_empty_entries_list() {
let entries: Vec<UpgradeFromEntry> = vec![];
validate_upgrade_from_against_behavior(&entries, None).unwrap();
}
#[test]
fn behavior_gate_reports_first_state_change_in_first_entry() {
let entries = vec![
entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m1.lisp"),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m2.lisp"),
},
],
),
entry(
"0.1.5",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m3.lisp"),
},
],
),
];
let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
assert_eq!(
err,
UpgradeError::StateChangeWithoutOnStateChangeCallback {
from: "0.1.0".into(),
script: PathBuf::from("lib/m1.lisp"),
},
"the first :state-change in the first entry must surface, not later collisions"
);
}
#[test]
fn behavior_gate_reports_second_entry_when_first_has_no_state_change() {
let entries = vec![
entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
),
entry(
"0.1.5",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
),
];
let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
assert_eq!(
err,
UpgradeError::StateChangeWithoutOnStateChangeCallback {
from: "0.1.5".into(),
script: PathBuf::from("lib/m.lisp"),
},
"the offending entry's `:from` must surface even when an earlier entry carries no \
:state-change"
);
}
#[test]
fn behavior_gate_does_not_fire_when_callback_is_declared_across_many_entries() {
let entries = vec![
entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m1.lisp"),
},
],
),
entry(
"0.1.5",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m2.lisp"),
},
],
),
];
let b = behavior_with_state_change_callback();
validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
}
#[test]
fn behavior_gate_accepts_load_and_cleanup_only_when_behavior_carries_on_state_change() {
let entries = vec![entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
)];
let b = behavior_with_state_change_callback();
validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
}
#[test]
fn validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor() {
let sc = UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
};
assert_eq!(
sc.declared_path().cloned(),
Some(PathBuf::from("lib/m.lisp")),
"declared_path() must project the StateChange :script byte-equal to the raw \
field access — accessor divergence would silently detach this cross-slot \
composition gate from the projection every peer per-`UpgradeInstruction` \
consumer routes through"
);
let entries = vec![entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
],
)];
assert_eq!(
validate_upgrade_from_against_behavior(&entries, None),
Err(UpgradeError::StateChangeWithoutOnStateChangeCallback {
from: "0.1.0".into(),
script: PathBuf::from("lib/m.lisp"),
}),
"a :state-change-carrying entry with behavior: None must trip the gate through \
the declared_path accessor's Some(script) arm — carrying the offending script \
verbatim byte-identical to the pattern-match shape"
);
for instrs in [
vec![UpgradeInstruction::LoadModule { module: "x".into() }],
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::Purge {
module: "x-old".into(),
},
],
vec![UpgradeInstruction::Restart],
] {
for instr in &instrs {
assert!(
instr.declared_path().is_none(),
"non-StateChange variants must project None through declared_path — \
accessor divergence would let this cross-slot composition gate silently \
fire on a module reference far from any :state-change site"
);
}
let entries = vec![entry("0.1.0", instrs)];
assert_eq!(
validate_upgrade_from_against_behavior(&entries, None),
Ok(()),
"the cross-slot composition gate must return Ok(()) on an entry whose \
instructions all project None through declared_path — the accessor's \
None arm the pattern-match's implicit fall-through previously carried"
);
}
}
#[test]
fn validate_restart_exclusive_threads_through_validate_upgrade_from() {
let entries = vec![
entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::Restart,
],
),
entry("0.1.0", vec![UpgradeInstruction::Restart]),
];
let err = validate_upgrade_from(&entries).unwrap_err();
assert!(
matches!(
err,
UpgradeError::RestartNotExclusive {
restart_count: 1,
..
}
),
"within-entry restart-exclusivity diagnostic must surface before the cross-entry \
duplicate-`:from` gate fires, got {err:?}"
);
}
#[test]
fn upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts() {
let e = UpgradeFromEntry {
from: "0.1.0".into(),
instructions: vec![UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
}],
};
let json = serde_json::to_string(&e).unwrap();
for key in [
crate::render::M2_UPGRADE_FROM_KEY_FROM,
crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
] {
let quoted = format!("\"{key}\"");
assert!(
json.contains("ed),
"serialized UpgradeFromEntry must carry the lifted \
M2_UPGRADE_FROM_KEY_* byte-sequence {quoted} verbatim in \
the JSON emission (got: {json})",
);
}
}
#[test]
fn m2_upgrade_from_key_consts_are_pairwise_distinct() {
let all = [
crate::render::M2_UPGRADE_FROM_KEY_FROM,
crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
];
for (i, a) in all.iter().enumerate() {
for b in all.iter().skip(i + 1) {
assert_ne!(
a, b,
"M2_UPGRADE_FROM_KEY_* consts must be pairwise-distinct \
canonical byte-sequences — got `{a}` == `{b}`",
);
}
}
}
#[test]
fn upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const() {
let samples: [(UpgradeInstruction, &'static str); 5] = [
(
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE.trim_start_matches(':'),
),
(
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE.trim_start_matches(':'),
),
(
UpgradeInstruction::SoftPurge {
module: "hello-rio-old".into(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE.trim_start_matches(':'),
),
(
UpgradeInstruction::Purge {
module: "hello-rio-old".into(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE.trim_start_matches(':'),
),
(
UpgradeInstruction::Restart,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART.trim_start_matches(':'),
),
];
for (sample, expected_value) in &samples {
let v: serde_json::Value = serde_json::to_value(sample).unwrap();
let got = v
.get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
.and_then(|k| k.as_str());
assert_eq!(
got,
Some(*expected_value),
"serialized {sample:?} must carry the lifted \
M2_UPGRADE_INSTRUCTION_KEY_KIND byte-sequence \
({:?}) verbatim as the tag-slot JSON key, holding the \
expected kebab-case value {expected_value:?} (got: {v})",
crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND,
);
}
}
#[test]
fn m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape() {
let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
assert!(
!key.is_empty(),
"M2_UPGRADE_INSTRUCTION_KEY_KIND must be non-empty (got {key:?})"
);
let first = key.chars().next().unwrap();
assert!(
first.is_ascii_lowercase(),
"M2_UPGRADE_INSTRUCTION_KEY_KIND must lead with an ASCII-lowercase \
byte (got {key:?}, leads with {first:?})",
);
assert!(
key.chars().all(|c| c.is_ascii_alphanumeric()),
"M2_UPGRADE_INSTRUCTION_KEY_KIND must be ASCII-alphanumeric only \
— no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
);
}
#[test]
fn m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys() {
let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
for data_field in [
crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
] {
assert_ne!(
key, data_field,
"M2_UPGRADE_INSTRUCTION_KEY_KIND (the serde `tag` slot) \
must be disjoint from every UpgradeInstruction per-variant \
data-field key — got tag-key {key:?} colliding with \
data-field {data_field:?}, which would silently corrupt \
the internally-tagged serialization",
);
}
}
#[test]
fn upgrade_instruction_variant_data_field_keys_match_lifted_field_key_consts() {
let module_sample = UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
};
let v: serde_json::Value = serde_json::to_value(&module_sample).unwrap();
assert_eq!(
v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE)
.and_then(|k| k.as_str()),
Some("hello-rio"),
"serialized {module_sample:?} must carry the lifted \
M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE byte-sequence \
({:?}) verbatim as the data-field JSON key holding the \
module string (got: {v})",
crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
);
let script_sample = UpgradeInstruction::StateChange {
script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
};
let v: serde_json::Value = serde_json::to_value(&script_sample).unwrap();
assert_eq!(
v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT)
.and_then(|k| k.as_str()),
Some("lib/migrations/v01-to-v02.lisp"),
"serialized {script_sample:?} must carry the lifted \
M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT byte-sequence \
({:?}) verbatim as the data-field JSON key holding the \
script path (got: {v})",
crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
);
}
#[test]
fn m2_upgrade_instruction_field_key_consts_are_lower_camel_case_shape() {
for key in [
crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
] {
assert!(
!key.is_empty(),
"M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be non-empty (got {key:?})"
);
let first = key.chars().next().unwrap();
assert!(
first.is_ascii_lowercase(),
"M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must lead with an ASCII-lowercase \
byte (got {key:?}, leads with {first:?})",
);
assert!(
key.chars().all(|c| c.is_ascii_alphanumeric()),
"M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be ASCII-alphanumeric only \
— no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
);
}
}
#[test]
fn m2_upgrade_instruction_field_key_consts_are_pairwise_distinct() {
let all = [
crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
];
for (i, a) in all.iter().enumerate() {
for b in all.iter().skip(i + 1) {
assert_ne!(
a, b,
"M2_UPGRADE_INSTRUCTION_FIELD_KEY_* consts must be pairwise-distinct \
canonical byte-sequences — got `{a}` == `{b}`",
);
}
}
}
#[test]
fn m2_upgrade_from_key_consts_are_lower_camel_case_shape() {
for key in [
crate::render::M2_UPGRADE_FROM_KEY_FROM,
crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
] {
assert!(
!key.is_empty(),
"M2_UPGRADE_FROM_KEY_* must be non-empty (got {key:?})"
);
let first = key.chars().next().unwrap();
assert!(
first.is_ascii_lowercase(),
"M2_UPGRADE_FROM_KEY_* must lead with an ASCII-lowercase \
byte (got {key:?}, leads with {first:?})",
);
assert!(
key.chars().all(|c| c.is_ascii_alphanumeric()),
"M2_UPGRADE_FROM_KEY_* must be ASCII-alphanumeric only \
— no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
);
}
}
#[test]
fn m2_upgrade_instruction_kind_consts_pin_canonical_kebab_case_labels() {
assert_eq!(
crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
":load-module"
);
assert_eq!(
crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
":state-change"
);
assert_eq!(
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
":soft-purge"
);
assert_eq!(crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE, ":purge");
assert_eq!(
crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
":restart"
);
}
#[test]
fn m2_upgrade_instruction_kind_consts_are_pairwise_distinct() {
let all = [
crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
];
for (i, a) in all.iter().enumerate() {
for (j, b) in all.iter().enumerate() {
if i != j {
assert_ne!(
a, b,
"M2_UPGRADE_INSTRUCTION_KIND_* consts must be pairwise \
distinct — got duplicate {a:?} at indices {i} and {j}",
);
}
}
}
}
#[test]
fn upgrade_instruction_lisp_form_routes_through_lifted_kind_consts() {
let cases: &[(UpgradeInstruction, &'static str)] = &[
(
UpgradeInstruction::LoadModule { module: "x".into() },
crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
),
(
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
),
(
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
),
(
UpgradeInstruction::Purge {
module: "x-old".into(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
),
(
UpgradeInstruction::Restart,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
),
];
for (instr, expected) in cases {
assert_eq!(
instr.lisp_form(),
*expected,
"UpgradeInstruction::lisp_form on {instr:?} must route through the lifted \
const (expected {expected:?})",
);
}
}
#[test]
fn upgrade_from_entry_instructions_returns_instructions_slice_byte_equal_across_permutations() {
let fixtures: Vec<Vec<UpgradeInstruction>> = vec![
Vec::new(),
vec![UpgradeInstruction::LoadModule { module: "x".into() }],
vec![UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
}],
vec![UpgradeInstruction::SoftPurge {
module: "x-old".into(),
}],
vec![UpgradeInstruction::Purge {
module: "x-old".into(),
}],
vec![UpgradeInstruction::Restart],
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
];
for instructions in fixtures {
let e = UpgradeFromEntry {
from: "0.1.0".into(),
instructions: instructions.clone(),
};
assert_eq!(
e.instructions(),
e.instructions.as_slice(),
"UpgradeFromEntry::instructions must project the raw \
`:instructions` `Vec<UpgradeInstruction>` verbatim as a \
`&[UpgradeInstruction]` slice-view over the same backing buffer \
(fixture: {instructions:?})",
);
assert_eq!(
e.instructions().len(),
instructions.len(),
"UpgradeFromEntry::instructions length must match the raw \
`:instructions` `Vec<UpgradeInstruction>` length (fixture: {instructions:?})",
);
}
}
#[test]
fn validate_reads_through_lifted_instructions_accessor() {
let well_formed = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
assert!(
well_formed.validate().is_ok(),
"well-formed `LoadModule → StateChange → SoftPurge` triad must accept — \
the per-instruction shape-check fan-out requires the accessor to reach every entry"
);
let no_prior_load = entry(
"0.1.0",
vec![UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
}],
);
match no_prior_load.validate() {
Err(UpgradeError::StateChangeWithoutPriorLoad { .. }) => {}
other => panic!(
"expected StateChangeWithoutPriorLoad on a `((:state-change …))` singleton \
— the within-entry state-change-ordering gate must reach the single \
instruction through the lifted accessor; got: {other:?}"
),
}
let duplicate_cleanup = entry(
"0.1.0",
vec![
UpgradeInstruction::LoadModule { module: "x".into() },
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
],
);
match duplicate_cleanup.validate() {
Err(UpgradeError::DuplicateCleanup { module, .. }) => {
assert_eq!(
module, "x-old",
"DuplicateCleanup must name the colliding module `x-old` — the per-module \
cleanup-singularity gate must iterate through the lifted accessor to \
match the second SoftPurge against the first via the `seen` set"
);
}
other => panic!(
"expected DuplicateCleanup on `((:load-module x) (:soft-purge x-old) \
(:soft-purge x-old))` — the within-entry cleanup-singularity gate must \
iterate the whole list through the lifted accessor; got: {other:?}"
),
}
let _ = Path::new("lib/m.lisp");
}
#[test]
fn state_change_without_prior_load_ctor_matches_struct_literal_wrap() {
let from = "0.1.0";
let script = Path::new("lib/migrations/v01-to-v02.lisp");
assert_eq!(
UpgradeError::state_change_without_prior_load(from, script),
UpgradeError::StateChangeWithoutPriorLoad {
from: from.to_string(),
script: script.to_path_buf(),
},
"generated state_change_without_prior_load ctor must produce \
byte-equal UpgradeError to the open-coded struct-literal \
wrap on the same (&str, &Path) fixture",
);
}
#[test]
fn duplicate_state_change_ctor_matches_struct_literal_wrap() {
let from = "0.1.0";
let script = Path::new("lib/migrations/v01-to-v02.lisp");
assert_eq!(
UpgradeError::duplicate_state_change(from, script),
UpgradeError::DuplicateStateChange {
from: from.to_string(),
script: script.to_path_buf(),
},
"generated duplicate_state_change ctor must produce byte-equal \
UpgradeError to the open-coded struct-literal wrap on the \
same (&str, &Path) fixture",
);
}
#[test]
fn state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap() {
let from = "0.1.0";
let script = Path::new("lib/migrations/v01-to-v02.lisp");
assert_eq!(
UpgradeError::state_change_without_on_state_change_callback(from, script),
UpgradeError::StateChangeWithoutOnStateChangeCallback {
from: from.to_string(),
script: script.to_path_buf(),
},
"generated state_change_without_on_state_change_callback ctor \
must produce byte-equal UpgradeError to the open-coded \
struct-literal wrap on the same (&str, &Path) fixture",
);
}
#[test]
fn upgrade_from_script_ctors_route_from_and_script_verbatim() {
let from = "1.2.3-rc.1";
let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
let script_ref: &Path = script_owned.as_path();
for script in [script_ref, &script_owned as &Path] {
assert_eq!(
UpgradeError::state_change_without_prior_load(from, script),
UpgradeError::StateChangeWithoutPriorLoad {
from: from.to_string(),
script: script.to_path_buf(),
},
);
assert_eq!(
UpgradeError::duplicate_state_change(from, script),
UpgradeError::DuplicateStateChange {
from: from.to_string(),
script: script.to_path_buf(),
},
);
assert_eq!(
UpgradeError::state_change_without_on_state_change_callback(from, script),
UpgradeError::StateChangeWithoutOnStateChangeCallback {
from: from.to_string(),
script: script.to_path_buf(),
},
);
}
}
#[test]
fn absolute_script_ctor_matches_struct_literal_wrap() {
let script = Path::new("/etc/nope.lisp");
assert_eq!(
UpgradeError::absolute_script(script),
UpgradeError::AbsoluteScript {
script: script.to_path_buf(),
},
"generated absolute_script ctor must produce byte-equal \
UpgradeError to the open-coded struct-literal wrap on the \
same &Path fixture",
);
}
#[test]
fn parent_escape_script_ctor_matches_struct_literal_wrap() {
let script = Path::new("../oops.lisp");
assert_eq!(
UpgradeError::parent_escape_script(script),
UpgradeError::ParentEscapeScript {
script: script.to_path_buf(),
},
"generated parent_escape_script ctor must produce byte-equal \
UpgradeError to the open-coded struct-literal wrap on the \
same &Path fixture",
);
}
#[test]
fn non_lisp_extension_script_ctor_matches_struct_literal_wrap() {
let script = Path::new("lib/migrations.rs");
assert_eq!(
UpgradeError::non_lisp_extension_script(script),
UpgradeError::NonLispExtensionScript {
script: script.to_path_buf(),
},
"generated non_lisp_extension_script ctor must produce \
byte-equal UpgradeError to the open-coded struct-literal \
wrap on the same &Path fixture",
);
}
#[test]
fn upgrade_script_only_ctors_route_script_through_to_path_buf() {
let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
let script_ref: &Path = script_owned.as_path();
for script in [script_ref, &script_owned as &Path] {
assert_eq!(
UpgradeError::absolute_script(script),
UpgradeError::AbsoluteScript {
script: script.to_path_buf(),
},
);
assert_eq!(
UpgradeError::parent_escape_script(script),
UpgradeError::ParentEscapeScript {
script: script.to_path_buf(),
},
);
assert_eq!(
UpgradeError::non_lisp_extension_script(script),
UpgradeError::NonLispExtensionScript {
script: script.to_path_buf(),
},
);
}
}
#[test]
fn from_invalid_ctor_matches_struct_literal_wrap() {
let from = "not-a-semver";
let reason = "unexpected character '-' at position 3";
assert_eq!(
UpgradeError::from_invalid(from, reason),
UpgradeError::FromInvalid {
from: from.to_string(),
reason: reason.to_string(),
},
"generated from_invalid ctor must produce byte-equal \
UpgradeError to the open-coded struct-literal wrap on the \
same (&str, &str) fixture",
);
}
#[test]
fn from_not_before_versao_ctor_matches_struct_literal_wrap() {
let from = "0.2.0";
let versao = "0.1.0";
assert_eq!(
UpgradeError::from_not_before_versao(from, versao),
UpgradeError::FromNotBeforeVersao {
from: from.to_string(),
versao: versao.to_string(),
},
"generated from_not_before_versao ctor must produce byte-equal \
UpgradeError to the open-coded struct-literal wrap on the \
same (&str, &str) fixture",
);
}
#[test]
fn duplicate_load_module_ctor_matches_struct_literal_wrap() {
let from = "0.1.0";
let module = "hello-rio";
assert_eq!(
UpgradeError::duplicate_load_module(from, module),
UpgradeError::DuplicateLoadModule {
from: from.to_string(),
module: module.to_string(),
},
"generated duplicate_load_module ctor must produce byte-equal \
UpgradeError to the open-coded struct-literal wrap on the \
same (&str, &str) fixture",
);
}
#[test]
fn upgrade_from_axis_ctors_route_from_and_axis_through_to_string_uniformly() {
let from = "0.1.0";
let axis = "distinct-axis-value";
let from_owned: String = from.to_string();
let axis_owned: String = axis.to_string();
for (from_in, axis_in) in [(from, axis), (from_owned.as_str(), axis_owned.as_str())] {
assert_eq!(
UpgradeError::from_invalid(from_in, axis_in),
UpgradeError::FromInvalid {
from: from.to_string(),
reason: axis.to_string(),
},
"from_invalid must route `from` → `from`, `axis` → `reason` \
in declared field order",
);
assert_eq!(
UpgradeError::from_not_before_versao(from_in, axis_in),
UpgradeError::FromNotBeforeVersao {
from: from.to_string(),
versao: axis.to_string(),
},
"from_not_before_versao must route `from` → `from`, \
`axis` → `versao` in declared field order",
);
assert_eq!(
UpgradeError::duplicate_load_module(from_in, axis_in),
UpgradeError::DuplicateLoadModule {
from: from.to_string(),
module: axis.to_string(),
},
"duplicate_load_module must route `from` → `from`, \
`axis` → `module` in declared field order",
);
}
}
#[test]
fn duplicate_from_ctor_matches_struct_literal_wrap() {
let entry = entry("0.1.0", vec![UpgradeInstruction::Restart]);
let lifted = UpgradeError::duplicate_from(&entry);
let struct_literal = UpgradeError::DuplicateFrom {
from: entry.prior_versao().to_string(),
};
assert_eq!(lifted, struct_literal);
}
#[test]
fn duplicate_from_ctor_routes_prior_versao_through_verbatim() {
let entry = entry("1.2.3-rc.4+build.5", vec![UpgradeInstruction::Restart]);
let built = UpgradeError::duplicate_from(&entry);
match built {
UpgradeError::DuplicateFrom { from } => {
assert_eq!(
from, "1.2.3-rc.4+build.5",
"from slot must thread UpgradeFromEntry::prior_versao() verbatim, \
preserving pre-release + build-metadata bytes"
);
}
other => panic!("expected DuplicateFrom, got {other:?}"),
}
}
#[test]
fn duplicate_from_ctor_projects_prior_versao_scalar_accessor() {
let entry = entry(
"0.2.0-alpha.7",
vec![
UpgradeInstruction::LoadModule {
module: "distinctive-load-target".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/distinctive-migrate.lisp"),
},
UpgradeInstruction::Restart,
],
);
let built = UpgradeError::duplicate_from(&entry);
match built {
UpgradeError::DuplicateFrom { from } => {
assert_eq!(
from, "0.2.0-alpha.7",
"from slot must project UpgradeFromEntry::prior_versao() \
(not any whole-entry rendering)"
);
}
other => panic!("expected DuplicateFrom, got {other:?}"),
}
}
#[test]
fn purge_without_prior_load_ctor_matches_struct_literal_wrap() {
let from = "0.1.0";
let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
let module = "hello-rio-old";
assert_eq!(
UpgradeError::purge_without_prior_load(from, kind, module),
UpgradeError::PurgeWithoutPriorLoad {
from: from.to_string(),
kind,
module: module.to_string(),
},
"generated purge_without_prior_load ctor must produce \
byte-equal UpgradeError to the open-coded struct-literal \
wrap on the same (&str, &'static str, &str) fixture",
);
}
#[test]
fn purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim() {
let kinds: [&'static str; 2] = [
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
];
let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
let modules: [&str; 4] = ["x", "hello-rio-old", "cache-v2-ancient", "a-b-c-d-e-f"];
for kind in kinds {
for from in froms {
for module in modules {
let from_owned: String = from.to_string();
let module_owned: String = module.to_string();
for (from_in, module_in) in
[(from, module), (from_owned.as_str(), module_owned.as_str())]
{
assert_eq!(
UpgradeError::purge_without_prior_load(from_in, kind, module_in),
UpgradeError::PurgeWithoutPriorLoad {
from: from.to_string(),
kind,
module: module.to_string(),
},
"purge_without_prior_load must route from → from, \
kind → kind, module → module in declared field \
order verbatim on ({from:?}, {kind:?}, {module:?})",
);
}
}
}
}
}
#[test]
fn validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor() {
let cases: [(&str, UpgradeInstruction, &'static str, &str); 2] = [
(
"0.1.0",
UpgradeInstruction::SoftPurge {
module: "hello-rio-old".into(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
"hello-rio-old",
),
(
"1.2.3-rc.1",
UpgradeInstruction::Purge {
module: "cache-v2-ancient".into(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
"cache-v2-ancient",
),
];
for (from, instr, kind, module) in cases {
let e = entry(from, vec![instr]);
let observed = e.validate().unwrap_err();
assert_eq!(
observed,
UpgradeError::purge_without_prior_load(from, kind, module),
"validate_purge_ordering must route its refusal through \
UpgradeError::purge_without_prior_load(from, kind, \
module) on a bare-cleanup {kind:?} entry, byte-equal \
to the pre-lift open-coded struct-literal wrap on the \
same fixture",
);
}
}
#[test]
fn state_change_after_cleanup_ctor_matches_struct_literal_wrap() {
let from = "0.1.0";
let script = Path::new("lib/m.lisp");
let prior_cleanup_kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
let prior_cleanup_module = "x-old";
assert_eq!(
UpgradeError::state_change_after_cleanup(
from,
script,
prior_cleanup_kind,
prior_cleanup_module,
),
UpgradeError::StateChangeAfterCleanup {
from: from.to_string(),
script: script.to_path_buf(),
prior_cleanup_kind,
prior_cleanup_module: prior_cleanup_module.to_string(),
},
"generated state_change_after_cleanup ctor must produce \
byte-equal UpgradeError to the open-coded struct-literal \
wrap on the same (&str, &Path, &'static str, &str) fixture",
);
}
#[test]
fn state_change_after_cleanup_ctor_routes_from_script_kind_and_module_through_verbatim() {
let kinds: [&'static str; 2] = [
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
];
let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
let scripts: [&str; 3] = [
"m.lisp",
"lib/migrations.lisp",
"lib/migrations/v01/step-1.lisp",
];
let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
for kind in kinds {
for from in froms {
for script_str in scripts {
for module in modules {
let from_owned: String = from.to_string();
let module_owned: String = module.to_string();
let script_path = Path::new(script_str);
let script_pathbuf = PathBuf::from(script_str);
for (from_in, module_in, script_in) in [
(from, module, script_path),
(
from_owned.as_str(),
module_owned.as_str(),
script_pathbuf.as_path(),
),
] {
assert_eq!(
UpgradeError::state_change_after_cleanup(
from_in, script_in, kind, module_in,
),
UpgradeError::StateChangeAfterCleanup {
from: from.to_string(),
script: PathBuf::from(script_str),
prior_cleanup_kind: kind,
prior_cleanup_module: module.to_string(),
},
"state_change_after_cleanup must route from → from, \
script → script, prior_cleanup_kind → prior_cleanup_kind, \
prior_cleanup_module → prior_cleanup_module in declared \
field order verbatim on ({from:?}, {script_str:?}, \
{kind:?}, {module:?})",
);
}
}
}
}
}
}
#[test]
fn validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor() {
let cases: [(&str, UpgradeInstruction, &'static str, &str, &str); 2] = [
(
"0.1.0",
UpgradeInstruction::SoftPurge {
module: "hello-rio-old".into(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
"hello-rio-old",
"lib/migrations/v01.lisp",
),
(
"1.2.3-rc.1",
UpgradeInstruction::Purge {
module: "cache-v2-ancient".into(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
"cache-v2-ancient",
"lib/migrations/v02.lisp",
),
];
for (from, cleanup, kind, module, script_str) in cases {
let script = PathBuf::from(script_str);
let e = entry(
from,
vec![
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
cleanup,
UpgradeInstruction::StateChange {
script: script.clone(),
},
],
);
let observed = e.validate().unwrap_err();
assert_eq!(
observed,
UpgradeError::state_change_after_cleanup(from, &script, kind, module),
"validate_state_change_before_cleanup must route its \
refusal through \
UpgradeError::state_change_after_cleanup(from, script, \
prior_cleanup_kind, prior_cleanup_module) on a \
`:state-change` after a bare-cleanup {kind:?} entry, \
byte-equal to the pre-lift open-coded struct-literal \
wrap on the same fixture",
);
}
}
#[test]
fn duplicate_cleanup_ctor_matches_struct_literal_wrap() {
let from = "0.1.0";
let module = "x-old";
let kinds: Vec<&'static str> = vec![
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
];
assert_eq!(
UpgradeError::duplicate_cleanup(from, module, kinds.clone()),
UpgradeError::DuplicateCleanup {
from: from.to_string(),
module: module.to_string(),
kinds,
},
"generated duplicate_cleanup ctor must produce byte-equal \
UpgradeError to the open-coded struct-literal wrap on the \
same (&str, &str, Vec<&'static str>) fixture",
);
}
#[test]
fn duplicate_cleanup_ctor_routes_from_module_and_kinds_through_verbatim() {
let all_kinds: [&'static str; 2] = [
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
];
let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
for prior_kind in all_kinds {
for kind in all_kinds {
for from in froms {
for module in modules {
let from_owned: String = from.to_string();
let module_owned: String = module.to_string();
for (from_in, module_in) in
[(from, module), (from_owned.as_str(), module_owned.as_str())]
{
let kinds: Vec<&'static str> = vec![prior_kind, kind];
assert_eq!(
UpgradeError::duplicate_cleanup(from_in, module_in, kinds.clone(),),
UpgradeError::DuplicateCleanup {
from: from.to_string(),
module: module.to_string(),
kinds,
},
"duplicate_cleanup must route from → from, \
module → module, kinds → kinds in declared \
field order verbatim on ({from:?}, \
{module:?}, [{prior_kind:?}, {kind:?}])",
);
}
}
}
}
}
}
#[test]
fn validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor() {
let cases: [(
&str,
UpgradeInstruction,
UpgradeInstruction,
&str,
[&'static str; 2],
); 4] = [
(
"0.1.0",
UpgradeInstruction::SoftPurge {
module: "hello-rio-old".into(),
},
UpgradeInstruction::SoftPurge {
module: "hello-rio-old".into(),
},
"hello-rio-old",
[
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
],
),
(
"1.2.3-rc.1",
UpgradeInstruction::Purge {
module: "cache-v2-ancient".into(),
},
UpgradeInstruction::Purge {
module: "cache-v2-ancient".into(),
},
"cache-v2-ancient",
[
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
],
),
(
"0.2.0-alpha.7+build.5",
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
UpgradeInstruction::Purge {
module: "x-old".into(),
},
"x-old",
[
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
],
),
(
"0.0.0",
UpgradeInstruction::Purge {
module: "x-old".into(),
},
UpgradeInstruction::SoftPurge {
module: "x-old".into(),
},
"x-old",
[
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
],
),
];
for (from, first, second, module, kinds) in cases {
let e = entry(
from,
vec![
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
first,
second,
],
);
let observed = e.validate().unwrap_err();
assert_eq!(
observed,
UpgradeError::duplicate_cleanup(from, module, kinds.to_vec()),
"validate_cleanup_singularity must route its refusal \
through UpgradeError::duplicate_cleanup(from, module, \
kinds) on a two-cleanup {kinds:?} entry targeting the \
same module, byte-equal to the pre-lift open-coded \
struct-literal wrap on the same fixture",
);
}
}
#[test]
fn restart_not_exclusive_ctor_matches_struct_literal_wrap() {
let from = "0.1.0";
let restart_count: usize = 1;
let other_kinds: Vec<&'static str> =
vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE];
assert_eq!(
UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
UpgradeError::RestartNotExclusive {
from: from.to_string(),
restart_count,
other_kinds,
},
"generated restart_not_exclusive ctor must produce byte-equal \
UpgradeError to the open-coded struct-literal wrap on the \
same (&str, usize, Vec<&'static str>) fixture",
);
}
#[test]
fn restart_not_exclusive_ctor_routes_from_restart_count_and_other_kinds_through_verbatim() {
let all_typed_kinds: [&'static str; 4] = [
crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
];
let other_kinds_matrix: [Vec<&'static str>; 3] =
[vec![], vec![all_typed_kinds[0]], all_typed_kinds.to_vec()];
let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
let restart_counts: [usize; 3] = [1, 2, 3];
for other_kinds in &other_kinds_matrix {
for restart_count in restart_counts {
for from in froms {
let from_owned: String = from.to_string();
for from_in in [from, from_owned.as_str()] {
assert_eq!(
UpgradeError::restart_not_exclusive(
from_in,
restart_count,
other_kinds.clone(),
),
UpgradeError::RestartNotExclusive {
from: from.to_string(),
restart_count,
other_kinds: other_kinds.clone(),
},
"restart_not_exclusive must route from → from, \
restart_count → restart_count, other_kinds → \
other_kinds in declared field order verbatim \
on ({from:?}, {restart_count:?}, \
{other_kinds:?})",
);
}
}
}
}
}
#[test]
fn validate_restart_exclusive_arm_routes_through_restart_not_exclusive_ctor() {
let cases: [(&str, Vec<UpgradeInstruction>, usize, Vec<&'static str>); 3] = [
(
"0.1.0",
vec![
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
UpgradeInstruction::Restart,
],
1,
vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
),
(
"1.2.3-rc.1",
vec![
UpgradeInstruction::LoadModule {
module: "hello-rio".into(),
},
UpgradeInstruction::StateChange {
script: PathBuf::from("lib/m.lisp"),
},
UpgradeInstruction::SoftPurge {
module: "hello-rio-old".into(),
},
UpgradeInstruction::Purge {
module: "hello-rio-old".into(),
},
UpgradeInstruction::Restart,
],
1,
vec![
crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
],
),
(
"0.0.0",
vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
2,
vec![],
),
];
for (from, instructions, restart_count, other_kinds) in cases {
let e = entry(from, instructions);
let observed = e.validate().unwrap_err();
assert_eq!(
observed,
UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
"validate_restart_exclusive must route its refusal \
through UpgradeError::restart_not_exclusive(from, \
restart_count, other_kinds) on a mixed-`(:restart)` \
entry, byte-equal to the pre-lift open-coded struct-\
literal wrap on the same fixture",
);
}
}
#[test]
fn module_invalid_ctor_matches_struct_literal_wrap() {
let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
let module = "Hello-Rio";
let reason = "must be lowercase alphanumeric or `-`";
assert_eq!(
UpgradeError::module_invalid(kind, module, reason),
UpgradeError::ModuleInvalid {
kind,
module: module.to_string(),
reason: reason.to_string(),
},
"generated module_invalid ctor must produce byte-equal \
UpgradeError to the open-coded struct-literal wrap on the \
same (kind, module, reason) fixture",
);
}
#[test]
fn module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
let module = "Hello-Rio";
let reason = "must be lowercase alphanumeric or `-`";
for kind in [
crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
":phantom",
] {
assert_eq!(
UpgradeError::module_invalid(kind, module, reason),
UpgradeError::ModuleInvalid {
kind,
module: module.to_string(),
reason: reason.to_string(),
},
"module_invalid ctor must thread kind={kind:?} verbatim",
);
}
}
#[test]
fn validate_module_wire_up_routes_invalid_through_module_invalid_ctor() {
let module = "Hello-Rio";
let cases: &[(UpgradeInstruction, &'static str)] = &[
(
UpgradeInstruction::LoadModule {
module: module.to_string(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
),
(
UpgradeInstruction::SoftPurge {
module: module.to_string(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
),
(
UpgradeInstruction::Purge {
module: module.to_string(),
},
crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
),
];
for (instr, expected_kind) in cases {
let observed = instr.validate().unwrap_err();
let UpgradeError::ModuleInvalid {
reason: observed_reason,
..
} = &observed
else {
panic!("expected ModuleInvalid on {instr:?}, got {observed:?}");
};
assert_eq!(
observed,
UpgradeError::module_invalid(expected_kind, module, observed_reason.clone()),
"validate_module must route its invalid-arm refusal \
through UpgradeError::module_invalid(kind, module, \
reason) on {instr:?}, byte-equal to the pre-lift open-\
coded struct-literal wrap on the same fixture",
);
}
}
}