fix!: up-transitions no longer exit and re-enter the target ancestor
A transition whose target is already active (an ancestor of the innermost active state) now unwinds only the subtree strictly below the target. The target's entry/exit actions do not fire and its default() does not re-descend — you cannot re-enter a state you never left. The old behavior could loop infinitely when a child transitioned to its own parent. Self-transitions (target == innermost) and lateral transitions are unchanged. See spec §2.6 and tests §T2.8 / T2.8b / T2.8c. Also in this release: * Full workspace justfile (check, test, coverage, mutants, pre-release) * Unit tests covering all __private internals; `cargo mutants` now exits clean (38 caught, 178 unviable, 0 missed, 0 timeouts). * .config/nextest.toml with slow-timeout/terminate-after so hung tests fail fast rather than wedging cargo-mutants runs. BREAKING CHANGE: transition-to-ancestor semantics. Machines that relied on the old re-entry behavior of up-transitions will observe different exit/entry action sequences.
This commit is contained in:
12 files changed
+774
-32
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
# Kill any single test that runs longer than 15s. The machine tests use
|
||||||
|
# `run()` loops that rely on the timer subsystem progressing — when a
|
||||||
|
# mutation breaks timer semantics a test can spin forever. Without a
|
||||||
|
# terminate-after budget the whole `cargo mutants` run is scored TIMEOUT
|
||||||
|
# instead of CAUGHT even when unit tests have already flagged the bug.
|
||||||
|
[profile.default.slow-timeout]
|
||||||
|
period = "5s"
|
||||||
|
terminate-after = 3
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
/target
|
/target
|
||||||
**/target/
|
**/target/
|
||||||
|
|
||||||
|
# Tooling output (coverage, mutation testing, profiling, mise cache)
|
||||||
|
/coverage/
|
||||||
|
/mutants.out/
|
||||||
|
/mutants.out.old/
|
||||||
|
mutation_history.json
|
||||||
|
*.profraw
|
||||||
|
|
||||||
# Local Claude Code config (per-host overrides)
|
# Local Claude Code config (per-host overrides)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,26 @@ All notable changes to this workspace are documented here. The format
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||||
this project adheres to [Semantic Versioning](https://semver.org/).
|
this project adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## 0.2.0 — 2026-04-22
|
||||||
|
|
||||||
|
### Fixed — behavior-visible (breaking)
|
||||||
|
|
||||||
|
- **Up-transitions no longer exit and re-enter the target ancestor.**
|
||||||
|
A transition whose target is already active (i.e., the target is on
|
||||||
|
the path from root to the innermost active state, and the target is
|
||||||
|
not itself the innermost) now unwinds only the subtree strictly
|
||||||
|
below the target. The target's `exit:` / `entry:` actions do NOT
|
||||||
|
fire, and its `default(...)` does NOT re-descend. You cannot
|
||||||
|
re-enter a state you never left. The old semantics (exit target,
|
||||||
|
re-enter, re-descend to default) was incorrect and could produce
|
||||||
|
infinite re-entry when a child transitioned to its own parent.
|
||||||
|
See spec §2.6, §T2.8, §T2.8b, §T2.8c.
|
||||||
|
- Self-transitions (`target == innermost`) are unchanged: the target
|
||||||
|
is still exited and re-entered, timers still restart.
|
||||||
|
- Lateral transitions (target not on the active path) are unchanged.
|
||||||
|
- `current_state()` may now return a composite state when it became
|
||||||
|
innermost via an up-transition.
|
||||||
|
|
||||||
## 0.1.0 — 2026-04-22
|
## 0.1.0 — 2026-04-22
|
||||||
|
|
||||||
Initial release.
|
Initial release.
|
||||||
|
|||||||
Generated
+2
-2
@@ -391,7 +391,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hsmc"
|
name = "hsmc"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"embassy-futures",
|
"embassy-futures",
|
||||||
@@ -407,7 +407,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hsmc-macros"
|
name = "hsmc-macros"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hsmc",
|
"hsmc",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["hsmc", "hsmc-macros"]
|
members = ["hsmc", "hsmc-macros"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
authors = ["Stephen Waits <steve@waits.net>"]
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ A state is a named node in the state hierarchy. States may be nested arbitrarily
|
|||||||
|
|
||||||
### 2.3 State Configuration
|
### 2.3 State Configuration
|
||||||
|
|
||||||
At any point in time, the machine is in exactly one "state configuration," which is a path from the root state down through the hierarchy to a leaf (or to a state with no children). If state `A` contains state `B` which contains state `C`, and `C` is the active leaf, then the machine is simultaneously "in" states `A`, `B`, and `C`.
|
At any point in time, the machine is in exactly one "state configuration," which is a path from the root state down through the hierarchy to the **innermost active state**. The innermost active state may be a state with no declared children, or a composite state that has become the innermost active state via a transition to an already-active ancestor (§2.6) and therefore has no active child. If state `A` contains state `B` which contains state `C`, and `C` is the innermost active state, then the machine is simultaneously "in" states `A`, `B`, and `C`.
|
||||||
|
|
||||||
When entering a state that has children, the machine automatically descends into the `default` child, recursively, until it reaches a state with no children or no default.
|
When a state becomes newly active — that is, it was not in the previous configuration — and it has children, the machine automatically descends into the `default` child, recursively, until it reaches a state with no children or no default. A transition that targets a state which is already active (see §2.6) does NOT trigger default descent, because the target is not newly entered.
|
||||||
|
|
||||||
### 2.4 Events
|
### 2.4 Events
|
||||||
|
|
||||||
@@ -60,12 +60,23 @@ A trigger is either:
|
|||||||
|
|
||||||
A transition specifies: when trigger `T` occurs in state `S`, move to state `Target`.
|
A transition specifies: when trigger `T` occurs in state `S`, move to state `Target`.
|
||||||
|
|
||||||
|
Transitions are classified by the relationship between `Target` and the **innermost active state** `I` at the time the transition fires:
|
||||||
|
|
||||||
|
- **Up-transition**: `Target` is already active — that is, `Target` lies on the path from the root down to `I`, and `Target ≠ I`. You cannot enter a state you never left. An up-transition therefore only unwinds the subtree strictly below `Target`.
|
||||||
|
- **Self-transition**: `Target = I`. The innermost active state is exited and re-entered (timers restart; entry/exit actions fire).
|
||||||
|
- **Lateral transition**: `Target` is not on the active path. Standard LCA semantics apply.
|
||||||
|
|
||||||
The transition algorithm:
|
The transition algorithm:
|
||||||
|
|
||||||
1. Find the least common ancestor (LCA) of the source state and the target state.
|
1. Classify the transition as up, self, or lateral (see above).
|
||||||
2. Exit states from the current active leaf up to (but not including) the LCA, calling exit actions in inner-to-outer order.
|
2. Compute the effective LCA:
|
||||||
3. Enter states from the LCA down to the target state, calling entry actions in outer-to-inner order.
|
- **Up**: effective LCA = `Target`.
|
||||||
4. If the target state has children, descend into defaults recursively, calling entry actions along the way.
|
- **Self**: effective LCA = parent of `Target` (so `Target` is exited and re-entered).
|
||||||
|
- **Lateral**: effective LCA = LCA(`I`, `Target`).
|
||||||
|
3. Exit states from `I` upward, calling exit actions inner-to-outer, stopping **before** the effective LCA. (The effective LCA is never exited.)
|
||||||
|
4. Entry behavior:
|
||||||
|
- **Up**: no entry actions fire. `Target` was already active and remains so; it is now the innermost active state, with no active child.
|
||||||
|
- **Self** and **Lateral**: enter states from the effective LCA down to `Target` (outer-to-inner), then if `Target` has children, descend into defaults recursively, calling entry actions along the way.
|
||||||
|
|
||||||
Transitions may target any state in the entire machine, not just siblings.
|
Transitions may target any state in the entire machine, not just siblings.
|
||||||
|
|
||||||
@@ -79,7 +90,7 @@ If both actions and a transition are registered on the same trigger in the same
|
|||||||
|
|
||||||
### 2.8 Event Dispatch
|
### 2.8 Event Dispatch
|
||||||
|
|
||||||
When an event is received (or a timer fires), the machine searches for a matching transition or action starting from the current active leaf state and walking up toward the root. The first state that has a handler for the trigger processes it. If no state handles the trigger, the event is silently discarded.
|
When an event is received (or a timer fires), the machine searches for a matching transition or action starting from the innermost active state and walking up toward the root. The first state that has a handler for the trigger processes it. If no state handles the trigger, the event is silently discarded.
|
||||||
|
|
||||||
Timer triggers do NOT bubble. A timer belongs to the state that declared it and only fires in that state's scope. Event triggers DO bubble up the hierarchy.
|
Timer triggers do NOT bubble. A timer belongs to the state that declared it and only fires in that state's scope. Event triggers DO bubble up the hierarchy.
|
||||||
|
|
||||||
@@ -91,13 +102,15 @@ Timer triggers do NOT bubble. A timer belongs to the state that declared it and
|
|||||||
|
|
||||||
### 2.10 Default State
|
### 2.10 Default State
|
||||||
|
|
||||||
`default(ChildState)` specifies the initial child state. It is required in any state that has children. It is required at the root level. The referenced state must be a direct child of the containing state. This is enforced at compile time.
|
`default(ChildState)` specifies the child state to descend into when the containing state is newly entered (either at initial machine startup, or via a lateral/self-transition that enters it fresh). It is required in any state that has children. It is required at the root level. The referenced state must be a direct child of the containing state. This is enforced at compile time.
|
||||||
|
|
||||||
|
`default` does NOT fire when a state becomes the innermost active state via an up-transition (§2.6): the parent was already active, so there is no "new entry" to descend from.
|
||||||
|
|
||||||
### 2.11 Terminate
|
### 2.11 Terminate
|
||||||
|
|
||||||
`terminate(EventVariant)` may only appear at the root level of the machine. It specifies that when the given event is received, the machine shuts down. Shutdown means:
|
`terminate(EventVariant)` may only appear at the root level of the machine. It specifies that when the given event is received, the machine shuts down. Shutdown means:
|
||||||
|
|
||||||
1. Exit the current active leaf state, calling its exit actions.
|
1. Exit the innermost active state, calling its exit actions.
|
||||||
2. Exit each ancestor state up to and including the root state, calling exit actions in inner-to-outer order.
|
2. Exit each ancestor state up to and including the root state, calling exit actions in inner-to-outer order.
|
||||||
3. The machine is now terminated. `run()` returns. `is_terminated()` returns true.
|
3. The machine is now terminated. `run()` returns. `is_terminated()` returns true.
|
||||||
|
|
||||||
@@ -246,7 +259,9 @@ impl<Name> {
|
|||||||
/// Returns true if the machine has terminated.
|
/// Returns true if the machine has terminated.
|
||||||
fn is_terminated(&self) -> bool;
|
fn is_terminated(&self) -> bool;
|
||||||
|
|
||||||
/// Returns the current active state configuration (leaf state).
|
/// Returns the innermost active state (see §2.3). This is typically a
|
||||||
|
/// leaf, but may be a composite state that became innermost via an
|
||||||
|
/// up-transition (§2.6).
|
||||||
fn current_state(&self) -> <Name>State;
|
fn current_state(&self) -> <Name>State;
|
||||||
|
|
||||||
/// Consumes the machine, returns the user's context.
|
/// Consumes the machine, returns the user's context.
|
||||||
@@ -633,15 +648,47 @@ Then: Exit order: [B_exit, A_exit]
|
|||||||
current_state() == C
|
current_state() == C
|
||||||
```
|
```
|
||||||
|
|
||||||
#### T2.8: Transition to an ancestor state
|
#### T2.8: Up-transition to an ancestor state
|
||||||
|
|
||||||
|
A transition whose target is already active (an ancestor of the innermost active state) only unwinds the subtree strictly below the target. The target itself is not exited and not re-entered. Its `default` is not followed — it was already active, so there is no "new entry."
|
||||||
|
|
||||||
```
|
```
|
||||||
Given: Machine with:
|
Given: Machine with:
|
||||||
state A { default(B); state B { transition(Up) => A } }
|
state A { default(B); state B { transition(Up) => A } }
|
||||||
Machine is in B.
|
Machine is in B.
|
||||||
When: Event Up is delivered
|
When: Event Up is delivered
|
||||||
Then: Exit order: [B_exit, A_exit]
|
Then: Exit order: [B_exit]
|
||||||
Entry order: [A_entry, B_entry] (re-enters A, descends to default B)
|
Entry order: []
|
||||||
|
current_state() == A
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T2.8b: Up-transition across multiple levels
|
||||||
|
|
||||||
|
```
|
||||||
|
Given: Machine with:
|
||||||
|
state A { default(B); state B { default(C); state C { transition(Up) => A } } }
|
||||||
|
Machine is in C.
|
||||||
|
When: Event Up is delivered
|
||||||
|
Then: Exit order: [C_exit, B_exit]
|
||||||
|
Entry order: []
|
||||||
|
current_state() == A
|
||||||
|
```
|
||||||
|
|
||||||
|
#### T2.8c: Handler on ancestor, target is a mid-ancestor
|
||||||
|
|
||||||
|
When the handler bubbles up to an ancestor, classification is based on the innermost active state at the time the transition fires — not on where the handler was found. If the target is on the active path and not the innermost, it is still an up-transition.
|
||||||
|
|
||||||
|
```
|
||||||
|
Given: Machine with:
|
||||||
|
state A {
|
||||||
|
default(B);
|
||||||
|
transition(Up) => B; // handler on A, target B
|
||||||
|
state B { default(C); state C {} }
|
||||||
|
}
|
||||||
|
Machine is in C.
|
||||||
|
When: Event Up is delivered
|
||||||
|
Then: Exit order: [C_exit]
|
||||||
|
Entry order: []
|
||||||
current_state() == B
|
current_state() == B
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+54
-10
@@ -1819,13 +1819,45 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
|||||||
__dispatch(&mut actx, aid, event).await;
|
__dispatch(&mut actx, aid, event).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §2.6: classify transitions by the relationship between the
|
||||||
|
// innermost active state `I` and the target `T`:
|
||||||
|
// - Up-transition: T is already active (T is a strict ancestor
|
||||||
|
// of I). Unwind the subtree strictly below T; do NOT exit or
|
||||||
|
// re-enter T; do NOT descend into defaults. You cannot enter
|
||||||
|
// a state you never left.
|
||||||
|
// - Self-transition (T == I): external semantics — exit T and
|
||||||
|
// re-enter it, then descend into defaults if T has children.
|
||||||
|
// - Lateral: standard LCA semantics.
|
||||||
|
fn is_up_transition(current: Option<u16>, target: u16) -> bool {
|
||||||
|
let Some(mut node) = current else { return false; };
|
||||||
|
if node == target { return false; }
|
||||||
|
while let Some(p) = __PARENT[node as usize] {
|
||||||
|
if p == target { return true; }
|
||||||
|
node = p;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||||
fn transition(&mut self, source_matched_state: u16, target: u16) {
|
fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||||
let mut lca = Self::lca(source_matched_state, target);
|
if Self::is_up_transition(self.current, target) {
|
||||||
if let Some(l) = lca {
|
if let Some(mut cur) = self.current {
|
||||||
if l == target {
|
while cur != target {
|
||||||
lca = __PARENT[l as usize];
|
self.exit_state(cur);
|
||||||
|
match __PARENT[cur as usize] {
|
||||||
|
Some(p) => cur = p,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
self.current = Some(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lca = Self::lca(self.current.unwrap_or(target), target);
|
||||||
|
if self.current == Some(target) {
|
||||||
|
// Self-transition: bump LCA so target is exited and re-entered.
|
||||||
|
lca = __PARENT[target as usize];
|
||||||
}
|
}
|
||||||
if let Some(mut cur) = self.current {
|
if let Some(mut cur) = self.current {
|
||||||
loop {
|
loop {
|
||||||
@@ -1853,12 +1885,24 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||||
async fn transition(&mut self, source_matched_state: u16, target: u16) {
|
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||||
let mut lca = Self::lca(source_matched_state, target);
|
if Self::is_up_transition(self.current, target) {
|
||||||
if let Some(l) = lca {
|
if let Some(mut cur) = self.current {
|
||||||
if l == target {
|
while cur != target {
|
||||||
lca = __PARENT[l as usize];
|
self.exit_state(cur).await;
|
||||||
|
match __PARENT[cur as usize] {
|
||||||
|
Some(p) => cur = p,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
self.current = Some(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lca = Self::lca(self.current.unwrap_or(target), target);
|
||||||
|
if self.current == Some(target) {
|
||||||
|
lca = __PARENT[target as usize];
|
||||||
}
|
}
|
||||||
if let Some(mut cur) = self.current {
|
if let Some(mut cur) = self.current {
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ tokio = ["dep:tokio"]
|
|||||||
embassy = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"]
|
embassy = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hsmc-macros = { path = "../hsmc-macros", version = "0.1.0" }
|
hsmc-macros = { path = "../hsmc-macros", version = "0.2.0" }
|
||||||
heapless = "0.8"
|
heapless = "0.8"
|
||||||
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
|
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
|
||||||
embassy-sync = { version = "0.8", optional = true }
|
embassy-sync = { version = "0.8", optional = true }
|
||||||
|
|||||||
@@ -18,3 +18,20 @@ impl core::fmt::Display for HsmcError {
|
|||||||
|
|
||||||
#[cfg(feature = "tokio")]
|
#[cfg(feature = "tokio")]
|
||||||
impl std::error::Error for HsmcError {}
|
impl std::error::Error for HsmcError {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
extern crate alloc;
|
||||||
|
use alloc::format;
|
||||||
|
|
||||||
|
// Kills: <impl Display for HsmcError>::fmt -> Ok(Default::default())
|
||||||
|
#[test]
|
||||||
|
fn display_messages_are_specific() {
|
||||||
|
assert_eq!(format!("{}", HsmcError::QueueFull), "event queue is full");
|
||||||
|
assert_eq!(
|
||||||
|
format!("{}", HsmcError::AlreadyTerminated),
|
||||||
|
"machine has already terminated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+282
@@ -271,3 +271,285 @@ pub mod __private {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod private_internals {
|
||||||
|
//! Direct unit tests on `__private` internals. The behavior suite covers
|
||||||
|
//! these through generated code, but mutation testing needs tight, fast
|
||||||
|
//! tests that pin the exact semantics each helper is supposed to uphold.
|
||||||
|
use super::__private::*;
|
||||||
|
use crate::HsmcError;
|
||||||
|
use core::time::Duration;
|
||||||
|
|
||||||
|
// ───── EventQueue ─────
|
||||||
|
|
||||||
|
// Kills: push -> Ok(()) (must actually enqueue, not swallow)
|
||||||
|
#[test]
|
||||||
|
fn event_queue_push_and_pop() {
|
||||||
|
let mut q: EventQueue<u32, 2> = EventQueue::new();
|
||||||
|
assert!(q.push(10).is_ok());
|
||||||
|
assert!(q.push(20).is_ok());
|
||||||
|
assert_eq!(q.pop(), Some(10));
|
||||||
|
assert_eq!(q.pop(), Some(20));
|
||||||
|
assert_eq!(q.pop(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kills: push -> Ok(()) for the overflow branch (QueueFull must be surfaced)
|
||||||
|
#[test]
|
||||||
|
fn event_queue_push_overflow_returns_queue_full() {
|
||||||
|
let mut q: EventQueue<u32, 2> = EventQueue::new();
|
||||||
|
assert!(q.push(1).is_ok());
|
||||||
|
assert!(q.push(2).is_ok());
|
||||||
|
assert_eq!(q.push(3), Err(HsmcError::QueueFull));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kills: pop -> None (must drain in FIFO order)
|
||||||
|
#[test]
|
||||||
|
fn event_queue_pop_is_fifo() {
|
||||||
|
let mut q: EventQueue<u32, 4> = EventQueue::new();
|
||||||
|
q.push(1).unwrap();
|
||||||
|
q.push(2).unwrap();
|
||||||
|
q.push(3).unwrap();
|
||||||
|
assert_eq!(q.pop(), Some(1));
|
||||||
|
assert_eq!(q.pop(), Some(2));
|
||||||
|
assert_eq!(q.pop(), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kills: is_empty -> true | is_empty -> false
|
||||||
|
#[test]
|
||||||
|
fn event_queue_is_empty_tracks_contents() {
|
||||||
|
let mut q: EventQueue<u32, 2> = EventQueue::new();
|
||||||
|
assert!(q.is_empty()); // kills is_empty -> false
|
||||||
|
q.push(1).unwrap();
|
||||||
|
assert!(!q.is_empty()); // kills is_empty -> true
|
||||||
|
q.pop();
|
||||||
|
assert!(q.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kills: clear with ()
|
||||||
|
#[test]
|
||||||
|
fn event_queue_clear_drains() {
|
||||||
|
let mut q: EventQueue<u32, 4> = EventQueue::new();
|
||||||
|
q.push(1).unwrap();
|
||||||
|
q.push(2).unwrap();
|
||||||
|
q.push(3).unwrap();
|
||||||
|
q.clear();
|
||||||
|
assert!(q.is_empty());
|
||||||
|
assert_eq!(q.pop(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── QueuePush for heapless::Deque ─────
|
||||||
|
|
||||||
|
// Kills: <impl QueuePush for heapless::Deque>::push -> Ok(())
|
||||||
|
#[test]
|
||||||
|
fn deque_queue_push_surfaces_overflow() {
|
||||||
|
let mut d: heapless::Deque<u32, 2> = heapless::Deque::new();
|
||||||
|
let q: &mut dyn QueuePush<u32> = &mut d;
|
||||||
|
assert!(q.push(1).is_ok());
|
||||||
|
assert!(q.push(2).is_ok());
|
||||||
|
assert_eq!(q.push(3), Err(HsmcError::QueueFull));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── TimerTable::start ─────
|
||||||
|
|
||||||
|
// Kills L173 && -> ||, L173 first == -> !=, L173 second == -> !=
|
||||||
|
//
|
||||||
|
// Three timers chosen so each mutation lands on a different outcome:
|
||||||
|
// (1,10,100) (1,20,200) (2,10,300); then re-start (1,10,500) to reset.
|
||||||
|
// Normal: three distinct entries; (1,10) shows 500ns.
|
||||||
|
// && -> ||: re-start (1,10,500) matches (1,20) on state-1, rewrites it.
|
||||||
|
// state == -> !=: the second call (1,20) overwrites the first (1,10).
|
||||||
|
// trigger == -> !=: the re-start (1,10,500) doesn't find (1,10); pushes a 4th.
|
||||||
|
#[test]
|
||||||
|
fn timer_start_matches_state_and_trigger_exactly() {
|
||||||
|
let mut t: TimerTable<8> = TimerTable::new();
|
||||||
|
t.start(1, 10, Duration::from_nanos(100));
|
||||||
|
t.start(1, 20, Duration::from_nanos(200));
|
||||||
|
t.start(2, 10, Duration::from_nanos(300));
|
||||||
|
assert_eq!(t.entries.len(), 3);
|
||||||
|
|
||||||
|
t.start(1, 10, Duration::from_nanos(500));
|
||||||
|
assert_eq!(t.entries.len(), 3); // reset, not appended
|
||||||
|
|
||||||
|
let find = |s, trg| {
|
||||||
|
t.entries
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.state == s && e.trigger == trg)
|
||||||
|
.map(|e| e.remaining_ns)
|
||||||
|
};
|
||||||
|
assert_eq!(find(1, 10), Some(500));
|
||||||
|
assert_eq!(find(1, 20), Some(200));
|
||||||
|
assert_eq!(find(2, 10), Some(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kills: start with () (must actually push an entry)
|
||||||
|
#[test]
|
||||||
|
fn timer_start_pushes_new_entry() {
|
||||||
|
let mut t: TimerTable<4> = TimerTable::new();
|
||||||
|
t.start(7, 42, Duration::from_nanos(999));
|
||||||
|
assert_eq!(t.entries.len(), 1);
|
||||||
|
assert_eq!(t.entries[0].state, 7);
|
||||||
|
assert_eq!(t.entries[0].trigger, 42);
|
||||||
|
assert_eq!(t.entries[0].remaining_ns, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── TimerTable::cancel_state ─────
|
||||||
|
|
||||||
|
// Kills: cancel_state with (), != -> ==
|
||||||
|
//
|
||||||
|
// Start three timers, cancel state=1; expect only state=2 survives.
|
||||||
|
// cancel_state with (): all three survive.
|
||||||
|
// != -> ==: retains only state=1 entries, drops state=2. Inverted.
|
||||||
|
#[test]
|
||||||
|
fn timer_cancel_state_removes_matching_only() {
|
||||||
|
let mut t: TimerTable<8> = TimerTable::new();
|
||||||
|
t.start(1, 10, Duration::from_nanos(100));
|
||||||
|
t.start(1, 20, Duration::from_nanos(200));
|
||||||
|
t.start(2, 10, Duration::from_nanos(300));
|
||||||
|
t.cancel_state(1);
|
||||||
|
assert_eq!(t.entries.len(), 1);
|
||||||
|
assert_eq!(t.entries[0].state, 2);
|
||||||
|
assert_eq!(t.entries[0].trigger, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── TimerTable::cancel_one ─────
|
||||||
|
|
||||||
|
// Kills: cancel_one with (), delete !, && -> ||, both == -> !=
|
||||||
|
//
|
||||||
|
// Start (1,10), (1,20), (2,10). Cancel (1,10). Expect {(1,20),(2,10)}.
|
||||||
|
// body (): nothing removed.
|
||||||
|
// delete !: keeps only (1,10).
|
||||||
|
// && -> ||: retains `!(state==s || trig==t)`, drops everything.
|
||||||
|
// state == -> !=: (1,10) survives, (1,20) dropped.
|
||||||
|
// trigger == -> !=: (1,10) survives, (1,20) dropped.
|
||||||
|
#[test]
|
||||||
|
fn timer_cancel_one_removes_exact_pair() {
|
||||||
|
let mut t: TimerTable<8> = TimerTable::new();
|
||||||
|
t.start(1, 10, Duration::from_nanos(100));
|
||||||
|
t.start(1, 20, Duration::from_nanos(200));
|
||||||
|
t.start(2, 10, Duration::from_nanos(300));
|
||||||
|
t.cancel_one(1, 10);
|
||||||
|
|
||||||
|
let has = |s, trg| t.entries.iter().any(|e| e.state == s && e.trigger == trg);
|
||||||
|
assert_eq!(t.entries.len(), 2);
|
||||||
|
assert!(!has(1, 10), "(1,10) must be cancelled");
|
||||||
|
assert!(has(1, 20), "(1,20) must remain");
|
||||||
|
assert!(has(2, 10), "(2,10) must remain");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── TimerTable::decrement ─────
|
||||||
|
|
||||||
|
// Kills: decrement with ()
|
||||||
|
#[test]
|
||||||
|
fn timer_decrement_reduces_remaining() {
|
||||||
|
let mut t: TimerTable<4> = TimerTable::new();
|
||||||
|
t.start(1, 1, Duration::from_nanos(500));
|
||||||
|
t.decrement(Duration::from_nanos(200));
|
||||||
|
assert_eq!(t.entries[0].remaining_ns, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── TimerTable::pop_expired ─────
|
||||||
|
|
||||||
|
// Kills body mutations: None, Some((0,0)/(0,1)/(1,0)/(1,1))
|
||||||
|
// Uses state/trigger distinct from 0/1 so no fixed return matches.
|
||||||
|
// Also kills the "entry is removed" invariant.
|
||||||
|
#[test]
|
||||||
|
fn timer_pop_expired_returns_exact_pair_and_removes() {
|
||||||
|
let mut t: TimerTable<4> = TimerTable::new();
|
||||||
|
t.start(5, 7, Duration::from_nanos(0));
|
||||||
|
let depth = [0u8, 0, 0, 0, 0, 1, 0, 1];
|
||||||
|
assert_eq!(t.pop_expired(&depth), Some((5, 7)));
|
||||||
|
assert_eq!(t.entries.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kills L202: `remaining_ns == 0` -> `!= 0`
|
||||||
|
// A still-running timer must not be returned.
|
||||||
|
#[test]
|
||||||
|
fn timer_pop_expired_ignores_nonzero_remaining() {
|
||||||
|
let mut t: TimerTable<4> = TimerTable::new();
|
||||||
|
t.start(5, 7, Duration::from_nanos(100));
|
||||||
|
let depth = [0u8, 0, 0, 0, 0, 1, 0, 1];
|
||||||
|
assert_eq!(t.pop_expired(&depth), None);
|
||||||
|
assert_eq!(t.entries.len(), 1); // not removed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kills L208: depth `>` -> `==` or `<`
|
||||||
|
// Two expired entries at different depths; deepest must win.
|
||||||
|
#[test]
|
||||||
|
fn timer_pop_expired_picks_deepest() {
|
||||||
|
let mut t: TimerTable<4> = TimerTable::new();
|
||||||
|
t.start(1, 100, Duration::from_nanos(0)); // shallow
|
||||||
|
t.start(2, 200, Duration::from_nanos(0)); // deeper
|
||||||
|
let depth = [0u8, 1, 3, 0];
|
||||||
|
assert_eq!(t.pop_expired(&depth), Some((2, 200)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kills L208: `>` -> `>=`
|
||||||
|
// Two expired entries at *equal* depth; first-declared must win.
|
||||||
|
// With `>=`, the later entry replaces the earlier on tie.
|
||||||
|
#[test]
|
||||||
|
fn timer_pop_expired_breaks_ties_by_declaration_order() {
|
||||||
|
let mut t: TimerTable<4> = TimerTable::new();
|
||||||
|
t.start(1, 100, Duration::from_nanos(0));
|
||||||
|
t.start(2, 200, Duration::from_nanos(0));
|
||||||
|
let depth = [0u8, 2, 2, 0]; // both at depth 2
|
||||||
|
assert_eq!(t.pop_expired(&depth), Some((1, 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── TimerTable::min_remaining ─────
|
||||||
|
|
||||||
|
// Kills L223 body None + body Some(default)
|
||||||
|
// Kills L224 / -> * and / -> %
|
||||||
|
// Kills L225 % -> + and % -> /
|
||||||
|
//
|
||||||
|
// 1_500_000_000 ns = 1.5s. Every mutation yields a clearly wrong duration:
|
||||||
|
// None: returns None, not Some(1.5s).
|
||||||
|
// Some(default): returns Duration::ZERO.
|
||||||
|
// / -> *: secs = ns * 1e9 overflows (or caught by `as u64`); not 1.
|
||||||
|
// / -> %: secs = ns % 1e9 = 5e8; not 1.
|
||||||
|
// % -> +: rem = ns + 1e9 overflows u32; not 5e8.
|
||||||
|
// % -> /: rem = ns / 1e9 = 1; not 5e8.
|
||||||
|
#[test]
|
||||||
|
fn timer_min_remaining_exact_duration() {
|
||||||
|
let mut t: TimerTable<4> = TimerTable::new();
|
||||||
|
t.start(1, 1, Duration::from_nanos(1_500_000_000));
|
||||||
|
assert_eq!(t.min_remaining(), Some(Duration::new(1, 500_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kills: min_remaining -> None (always). Explicit contrast to the empty case.
|
||||||
|
#[test]
|
||||||
|
fn timer_min_remaining_none_when_empty() {
|
||||||
|
let t: TimerTable<4> = TimerTable::new();
|
||||||
|
assert_eq!(t.min_remaining(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picks the smallest across multiple entries.
|
||||||
|
#[test]
|
||||||
|
fn timer_min_remaining_picks_smallest() {
|
||||||
|
let mut t: TimerTable<4> = TimerTable::new();
|
||||||
|
t.start(1, 1, Duration::from_nanos(9_999));
|
||||||
|
t.start(2, 2, Duration::from_nanos(100));
|
||||||
|
t.start(3, 3, Duration::from_nanos(5_000));
|
||||||
|
assert_eq!(t.min_remaining(), Some(Duration::from_nanos(100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── duration_from_secs_f64 ─────
|
||||||
|
|
||||||
|
// Kills body -> Default, * -> +, * -> /
|
||||||
|
//
|
||||||
|
// 1.5s -> 1_500_000_000ns exactly.
|
||||||
|
// body -> Default: returns ZERO.
|
||||||
|
// * -> +: secs + 1e9 = 1_000_000_001.5 -> 1_000_000_001 ns.
|
||||||
|
// * -> /: secs / 1e9 = 1.5e-9 -> 0 ns.
|
||||||
|
#[test]
|
||||||
|
fn duration_from_secs_f64_multiplies_correctly() {
|
||||||
|
assert_eq!(
|
||||||
|
duration_from_secs_f64(1.5),
|
||||||
|
Duration::from_nanos(1_500_000_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
duration_from_secs_f64(0.25),
|
||||||
|
Duration::from_nanos(250_000_000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
-5
@@ -717,17 +717,150 @@ mod t2_8 {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn t2_8_transition_to_ancestor() {
|
fn t2_8_transition_to_ancestor() {
|
||||||
|
// §2.6: up-transition. Target A is already active; we unwind only
|
||||||
|
// the subtree strictly below A. A itself is not exited or
|
||||||
|
// re-entered, and its default(B) does not re-fire.
|
||||||
let mut m = T28::new(TestCtx::default());
|
let mut m = T28::new(TestCtx::default());
|
||||||
m.step(Duration::ZERO);
|
m.step(Duration::ZERO);
|
||||||
assert_eq!(m.context().logs(), ["a_entry", "b_entry"]);
|
assert_eq!(m.context().logs(), ["a_entry", "b_entry"]);
|
||||||
m.context_mut().log.clear();
|
m.context_mut().log.clear();
|
||||||
m.send(Ev::Up).unwrap();
|
m.send(Ev::Up).unwrap();
|
||||||
m.step(Duration::ZERO);
|
m.step(Duration::ZERO);
|
||||||
assert_eq!(m.current_state(), T28State::B);
|
assert_eq!(m.current_state(), T28State::A);
|
||||||
assert_eq!(
|
assert_eq!(m.context().logs(), ["b_exit"]);
|
||||||
m.context().logs(),
|
}
|
||||||
["b_exit", "a_exit", "a_entry", "b_entry"]
|
}
|
||||||
);
|
|
||||||
|
// ---- T2.8b: up-transition across multiple levels ----
|
||||||
|
mod t2_8b {
|
||||||
|
use super::*;
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Ev {
|
||||||
|
Up,
|
||||||
|
}
|
||||||
|
|
||||||
|
statechart! {
|
||||||
|
T28b {
|
||||||
|
context: TestCtx;
|
||||||
|
events: Ev;
|
||||||
|
default(A);
|
||||||
|
state A {
|
||||||
|
default(B);
|
||||||
|
entry: a_entry;
|
||||||
|
exit: a_exit;
|
||||||
|
state B {
|
||||||
|
default(C);
|
||||||
|
entry: b_entry;
|
||||||
|
exit: b_exit;
|
||||||
|
state C {
|
||||||
|
entry: c_entry;
|
||||||
|
exit: c_exit;
|
||||||
|
on(Up) => A;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl T28bActions for T28bActionContext<'_> {
|
||||||
|
fn a_entry(&mut self) {
|
||||||
|
self.log.push("a_entry".into());
|
||||||
|
}
|
||||||
|
fn a_exit(&mut self) {
|
||||||
|
self.log.push("a_exit".into());
|
||||||
|
}
|
||||||
|
fn b_entry(&mut self) {
|
||||||
|
self.log.push("b_entry".into());
|
||||||
|
}
|
||||||
|
fn b_exit(&mut self) {
|
||||||
|
self.log.push("b_exit".into());
|
||||||
|
}
|
||||||
|
fn c_entry(&mut self) {
|
||||||
|
self.log.push("c_entry".into());
|
||||||
|
}
|
||||||
|
fn c_exit(&mut self) {
|
||||||
|
self.log.push("c_exit".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t2_8b_up_transition_across_levels() {
|
||||||
|
let mut m = T28b::new(TestCtx::default());
|
||||||
|
m.step(Duration::ZERO);
|
||||||
|
assert_eq!(m.context().logs(), ["a_entry", "b_entry", "c_entry"]);
|
||||||
|
m.context_mut().log.clear();
|
||||||
|
m.send(Ev::Up).unwrap();
|
||||||
|
m.step(Duration::ZERO);
|
||||||
|
assert_eq!(m.current_state(), T28bState::A);
|
||||||
|
assert_eq!(m.context().logs(), ["c_exit", "b_exit"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- T2.8c: handler on ancestor, target is a mid-ancestor ----
|
||||||
|
mod t2_8c {
|
||||||
|
use super::*;
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Ev {
|
||||||
|
Up,
|
||||||
|
}
|
||||||
|
|
||||||
|
statechart! {
|
||||||
|
T28c {
|
||||||
|
context: TestCtx;
|
||||||
|
events: Ev;
|
||||||
|
default(A);
|
||||||
|
state A {
|
||||||
|
default(B);
|
||||||
|
entry: a_entry;
|
||||||
|
exit: a_exit;
|
||||||
|
on(Up) => B; // handler on A, target B
|
||||||
|
state B {
|
||||||
|
default(C);
|
||||||
|
entry: b_entry;
|
||||||
|
exit: b_exit;
|
||||||
|
state C {
|
||||||
|
entry: c_entry;
|
||||||
|
exit: c_exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl T28cActions for T28cActionContext<'_> {
|
||||||
|
fn a_entry(&mut self) {
|
||||||
|
self.log.push("a_entry".into());
|
||||||
|
}
|
||||||
|
fn a_exit(&mut self) {
|
||||||
|
self.log.push("a_exit".into());
|
||||||
|
}
|
||||||
|
fn b_entry(&mut self) {
|
||||||
|
self.log.push("b_entry".into());
|
||||||
|
}
|
||||||
|
fn b_exit(&mut self) {
|
||||||
|
self.log.push("b_exit".into());
|
||||||
|
}
|
||||||
|
fn c_entry(&mut self) {
|
||||||
|
self.log.push("c_entry".into());
|
||||||
|
}
|
||||||
|
fn c_exit(&mut self) {
|
||||||
|
self.log.push("c_exit".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t2_8c_up_transition_classified_by_innermost_not_handler() {
|
||||||
|
// Handler bubbles from C to A. Target B is on the active path and
|
||||||
|
// is not the innermost, so this is an up-transition — even though
|
||||||
|
// the handler was found on A (not on C or B).
|
||||||
|
let mut m = T28c::new(TestCtx::default());
|
||||||
|
m.step(Duration::ZERO);
|
||||||
|
assert_eq!(m.context().logs(), ["a_entry", "b_entry", "c_entry"]);
|
||||||
|
m.context_mut().log.clear();
|
||||||
|
m.send(Ev::Up).unwrap();
|
||||||
|
m.step(Duration::ZERO);
|
||||||
|
assert_eq!(m.current_state(), T28cState::B);
|
||||||
|
assert_eq!(m.context().logs(), ["c_exit"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# justfile for the hsmc workspace (hsmc + hsmc-macros)
|
||||||
|
# High-level development and CI workflows
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Treat all warnings as errors
|
||||||
|
rustflags := "-D warnings"
|
||||||
|
|
||||||
|
# Code coverage target percentage (fail if below this threshold)
|
||||||
|
coverage_target := "80"
|
||||||
|
|
||||||
|
# Timeout for coverage analysis in seconds
|
||||||
|
coverage_timeout := "600"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Default recipe - show available recipes
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Primary Development Workflows
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Quick development loop: format + lint + fast tests (default features only)
|
||||||
|
dev:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
echo "🔄 Running development checks..."
|
||||||
|
cargo fmt
|
||||||
|
RUSTFLAGS="{{rustflags}}" cargo clippy --workspace --all-targets &
|
||||||
|
RUSTFLAGS="{{rustflags}}" cargo nextest run --workspace &
|
||||||
|
wait
|
||||||
|
echo "✅ Development checks passed"
|
||||||
|
|
||||||
|
# Pre-commit checks across all feature combinations (tokio/embassy are mutually exclusive)
|
||||||
|
check:
|
||||||
|
@echo "Running format check..."
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
@echo ""
|
||||||
|
@echo "Running clippy on all feature combinations..."
|
||||||
|
RUSTFLAGS="{{rustflags}}" cargo clippy --workspace --all-targets --no-default-features
|
||||||
|
RUSTFLAGS="{{rustflags}}" cargo clippy --workspace --all-targets --features tokio
|
||||||
|
RUSTFLAGS="{{rustflags}}" cargo clippy --workspace --all-targets --features embassy
|
||||||
|
@echo ""
|
||||||
|
@echo "Verifying compilation on all feature combinations..."
|
||||||
|
RUSTFLAGS="{{rustflags}}" cargo test --workspace --no-default-features --no-run
|
||||||
|
RUSTFLAGS="{{rustflags}}" cargo test --workspace --features tokio --no-run
|
||||||
|
RUSTFLAGS="{{rustflags}}" cargo test --workspace --features embassy --no-run
|
||||||
|
@echo ""
|
||||||
|
@echo "✅ All checks passed"
|
||||||
|
|
||||||
|
# Run all tests with comprehensive feature coverage
|
||||||
|
test:
|
||||||
|
@echo "Running tests with all feature combinations..."
|
||||||
|
RUSTFLAGS="{{rustflags}}" cargo nextest run --workspace --no-default-features
|
||||||
|
RUSTFLAGS="{{rustflags}}" cargo nextest run --workspace --features tokio
|
||||||
|
RUSTFLAGS="{{rustflags}}" cargo nextest run --workspace --features embassy
|
||||||
|
@echo ""
|
||||||
|
@echo "Running doctests..."
|
||||||
|
cargo test --doc --workspace
|
||||||
|
@echo "✅ All feature combinations tested"
|
||||||
|
|
||||||
|
# Before pushing: check + test + coverage
|
||||||
|
pre-push: check test coverage
|
||||||
|
@echo ""
|
||||||
|
@echo "✅ Pre-push checks passed - safe to push!"
|
||||||
|
|
||||||
|
# Comprehensive pre-release validation
|
||||||
|
pre-release: check test coverage audit mutants examples
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Verify documentation version matches crate version
|
||||||
|
echo ""
|
||||||
|
echo "📋 Verifying documentation version..."
|
||||||
|
VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||||
|
MAJOR_MINOR=$(echo $VERSION | cut -d. -f1-2)
|
||||||
|
|
||||||
|
# README uses the braced form: `hsmc = { version = "X.Y", ... }`
|
||||||
|
if ! grep -Eq "hsmc = \{[^}]*version = \"$MAJOR_MINOR(\.[0-9]+)?\"" README.md; then
|
||||||
|
echo "❌ ERROR: README.md should reference version $MAJOR_MINOR (crate is $VERSION)"
|
||||||
|
echo " Update the install snippet in README.md"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Documentation version correct: $MAJOR_MINOR"
|
||||||
|
|
||||||
|
# Workspace publish dry-run: hsmc-macros must publish before hsmc
|
||||||
|
echo ""
|
||||||
|
echo "📦 Verifying packages..."
|
||||||
|
cargo publish --dry-run -p hsmc-macros
|
||||||
|
cargo publish --dry-run -p hsmc
|
||||||
|
cargo package --list -p hsmc-macros > /tmp/hsmc-macros-files.txt
|
||||||
|
cargo package --list -p hsmc > /tmp/hsmc-files.txt
|
||||||
|
echo "✅ Packages verified"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ All pre-release checks passed!"
|
||||||
|
echo " - Code formatted and linted"
|
||||||
|
echo " - All tests passing on {default, tokio, embassy}"
|
||||||
|
echo " - Code coverage ≥ {{coverage_target}}%"
|
||||||
|
echo " - No security vulnerabilities"
|
||||||
|
echo " - Mutation testing complete"
|
||||||
|
echo " - All examples run / build successfully"
|
||||||
|
echo " - Documentation version verified"
|
||||||
|
echo " - Packages ready for publication"
|
||||||
|
echo ""
|
||||||
|
echo "📦 Ready to publish! Run (in order):"
|
||||||
|
echo " cargo publish -p hsmc-macros"
|
||||||
|
echo " cargo publish -p hsmc"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Quality & Security
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Run code coverage analysis (tokio feature covers the widest surface area)
|
||||||
|
coverage:
|
||||||
|
@echo "Running code coverage analysis (target: {{coverage_target}}%)..."
|
||||||
|
cargo llvm-cov nextest --workspace --features tokio --html --output-dir coverage --fail-under-lines {{coverage_target}}
|
||||||
|
@echo "Coverage report: coverage/html/index.html"
|
||||||
|
|
||||||
|
# Mutation testing with cargo-mutants
|
||||||
|
mutants:
|
||||||
|
@echo "Running mutation testing..."
|
||||||
|
@echo "⚠️ This will use significant CPU and memory resources!"
|
||||||
|
cargo mutants \
|
||||||
|
--no-shuffle \
|
||||||
|
--test-tool nextest \
|
||||||
|
--jobs 3 \
|
||||||
|
--features tokio \
|
||||||
|
-vV
|
||||||
|
|
||||||
|
# Security audit + dependency checks
|
||||||
|
audit:
|
||||||
|
@echo "🔒 Running security checks..."
|
||||||
|
cargo audit
|
||||||
|
@echo "✅ Security checks passed"
|
||||||
|
|
||||||
|
# Run Miri for UB detection (default features only; tokio/embassy rely on runtime primitives Miri does not support)
|
||||||
|
miri:
|
||||||
|
@echo "Running Miri tests for UB detection (no-default-features)..."
|
||||||
|
cargo +nightly miri test --workspace --no-default-features
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Documentation & Utilities
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Build documentation and open in browser
|
||||||
|
doc:
|
||||||
|
cargo doc --no-deps --workspace --features tokio --open
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
fmt:
|
||||||
|
cargo fmt --all
|
||||||
|
|
||||||
|
# Show project information
|
||||||
|
info:
|
||||||
|
@echo "hsmc workspace"
|
||||||
|
@echo " hsmc - Hierarchical state machines (statecharts) with a declarative proc macro"
|
||||||
|
@echo " hsmc-macros - Procedural macro implementation for the hsmc crate"
|
||||||
|
@echo ""
|
||||||
|
@echo "Rust: $(rustc --version)"
|
||||||
|
@echo "Cargo: $(cargo --version)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Dependencies:"
|
||||||
|
@cargo tree --depth 1 -p hsmc
|
||||||
|
|
||||||
|
# Run all examples (tokio examples execute; embassy_full is a lib crate, build-only)
|
||||||
|
examples:
|
||||||
|
@echo "Running tokio examples..."
|
||||||
|
cargo run --example microwave --features tokio
|
||||||
|
cargo run --example during_radio --features tokio
|
||||||
|
@echo ""
|
||||||
|
@echo "Building embassy_full example (lib crate)..."
|
||||||
|
cargo build --example embassy_full --features embassy
|
||||||
|
@echo "✅ All examples completed successfully"
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
|
rm -rf mutants.out/ mutants.out.old/ mutation_history.json
|
||||||
|
rm -rf coverage/
|
||||||
Reference in new issue
Block a user