machine
packageAPI reference for the machine
package.
Imports
(11)orderFSM
type orderFSM struct
Methods
func (*orderFSM) OnEnterPaid()
{}
Fields
| Name | Type | Description |
|---|---|---|
| State | string | fsm:"initial:draft; draft->confirmed; confirmed->paid; paid->shipped; *->cancelled" |
guardFSM
type guardFSM struct
Methods
Returns
func (*guardFSM) CanPaid() error
{
g.canPaidCalled = true
if g.guardShouldErr {
return errGuard
}
return nil
}
Fields
| Name | Type | Description |
|---|---|---|
| State | string | fsm:"initial:draft; draft->paid; draft->archived" |
| canPaidCalled | bool | |
| guardShouldErr | bool |
hookFSM
type hookFSM struct
Methods
func (*hookFSM) OnExitIdle()
{ h.onExitIdle = true }
func (*hookFSM) OnEnterActive()
{ h.onEnterActive = true }
func (*hookFSM) OnExitActive()
{ h.onExitActive = true }
func (*hookFSM) OnEnterDone()
{ h.onEnterDone = true }
Fields
| Name | Type | Description |
|---|---|---|
| State | string | fsm:"initial:idle; idle->active; active->done" |
| onExitIdle | bool | |
| onEnterActive | bool | |
| onExitActive | bool | |
| onEnterDone | bool |
timeoutFSM
type timeoutFSM struct
Fields
| Name | Type | Description |
|---|---|---|
| State | string | fsm:"initial:pending; pending->expired [50ms]" |
errGuardType
type errGuardType string
TestNew_ValidFSM
Parameters
func TestNew_ValidFSM(t *testing.T)
{
o := &orderFSM{}
m, err := New(o)
if err != nil {
t.Fatalf("New failed: %v", err)
}
if m.CurrentState() != "draft" {
t.Errorf("state = %q, want %q", m.CurrentState(), "draft")
}
}
TestNew_NonPointer
Parameters
func TestNew_NonPointer(t *testing.T)
{
_, err := New(orderFSM{})
if err == nil {
t.Error("expected error for non-pointer")
}
}
TestNew_NilPointer
Parameters
func TestNew_NilPointer(t *testing.T)
{
_, err := New((*orderFSM)(nil))
if err == nil {
t.Error("expected error for nil pointer")
}
}
TestNew_NoFSMTag
Parameters
func TestNew_NoFSMTag(t *testing.T)
{
type noTag struct {
Name string
}
_, err := New(&noTag{})
if err == nil {
t.Error("expected error when no fsm tag found")
}
}
TestNew_NonStringField
Parameters
func TestNew_NonStringField(t *testing.T)
{
type badField struct {
State int `fsm:"initial:0"`
}
_, err := New(&badField{})
if err == nil {
t.Error("expected error for non-string fsm field")
}
}
TestTransition_Valid
Parameters
func TestTransition_Valid(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
if err := m.Transition("confirmed"); err != nil {
t.Fatalf("Transition failed: %v", err)
}
if m.CurrentState() != "confirmed" {
t.Errorf("state = %q, want %q", m.CurrentState(), "confirmed")
}
}
TestTransition_Chained
Parameters
func TestTransition_Chained(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
states := []string{"confirmed", "paid", "shipped"}
for _, s := range states {
if err := m.Transition(s); err != nil {
t.Fatalf("Transition to %s failed: %v", s, err)
}
}
if m.CurrentState() != "shipped" {
t.Errorf("state = %q, want %q", m.CurrentState(), "shipped")
}
}
TestTransition_Invalid
Parameters
func TestTransition_Invalid(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
if err := m.Transition("shipped"); err == nil {
t.Error("expected error for invalid transition draft->shipped")
}
}
TestTransition_Wildcard
Parameters
func TestTransition_Wildcard(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
if err := m.Transition("cancelled"); err != nil {
t.Fatalf("Transition through wildcard failed: %v", err)
}
if m.CurrentState() != "cancelled" {
t.Errorf("state = %q, want %q", m.CurrentState(), "cancelled")
}
}
TestTransition_GuardAllowed
Parameters
func TestTransition_GuardAllowed(t *testing.T)
{
g := &guardFSM{}
m, _ := New(g)
if err := m.Transition("paid"); err != nil {
t.Fatalf("Transition with guard failed: %v", err)
}
if !g.canPaidCalled {
t.Error("guard CanPaid was not called")
}
}
TestTransition_GuardRejected
Parameters
func TestTransition_GuardRejected(t *testing.T)
{
g := &guardFSM{guardShouldErr: true}
m, _ := New(g)
if err := m.Transition("paid"); err == nil {
t.Fatal("expected error from guard")
}
if m.CurrentState() != "draft" {
t.Errorf("state should remain 'draft', got %q", m.CurrentState())
}
}
TestTransition_HookOrder
Parameters
func TestTransition_HookOrder(t *testing.T)
{
h := &hookFSM{}
m, _ := New(h)
m.Transition("active")
if !h.onExitIdle {
t.Error("OnExitIdle not called")
}
if !h.onEnterActive {
t.Error("OnEnterActive not called")
}
m.Transition("done")
if !h.onExitActive {
t.Error("OnExitActive not called")
}
if !h.onEnterDone {
t.Error("OnEnterDone not called")
}
}
TestCurrentState_Concurrent
Parameters
func TestCurrentState_Concurrent(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
m.CurrentState()
}()
}
wg.Wait()
}
TestSubscribe_ListenerCalled
Parameters
func TestSubscribe_ListenerCalled(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
var events []EventType
var mu sync.Mutex
m.Subscribe(func(e Event) {
mu.Lock()
events = append(events, e.Type)
mu.Unlock()
})
m.Transition("confirmed")
mu.Lock()
if len(events) != 4 {
t.Fatalf("expected 4 events, got %d", len(events))
}
if events[0] != BeforeTransition {
t.Errorf("event[0] = %v, want BeforeTransition", events[0])
}
if events[3] != AfterTransition {
t.Errorf("event[3] = %v, want AfterTransition", events[3])
}
mu.Unlock()
}
TestHistory_RecordsTransitions
Parameters
func TestHistory_RecordsTransitions(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
m.Transition("confirmed")
m.Transition("paid")
history := m.History()
if len(history) != 2 {
t.Fatalf("expected 2 history records, got %d", len(history))
}
if history[0].From != "draft" || history[0].To != "confirmed" {
t.Errorf("record[0] = %+v, want {draft -> confirmed}", history[0])
}
if history[1].From != "confirmed" || history[1].To != "paid" {
t.Errorf("record[1] = %+v, want {confirmed -> paid}", history[1])
}
}
TestHistory_Trigger
Parameters
func TestHistory_Trigger(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
m.Transition("confirmed")
history := m.History()
if history[0].Trigger != "manual" {
t.Errorf("trigger = %q, want %q", history[0].Trigger, "manual")
}
}
TestHistory_Immutable
Parameters
func TestHistory_Immutable(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
h := m.History()
h = append(h, TransitionRecord{})
if len(m.History()) != 0 {
t.Error("History should not be modifiable by caller")
}
}
TestCheckTimeouts_Expired
Parameters
func TestCheckTimeouts_Expired(t *testing.T)
{
tm := &timeoutFSM{}
m, _ := New(tm)
time.Sleep(60 * time.Millisecond)
if err := m.CheckTimeouts(); err != nil {
t.Fatalf("CheckTimeouts failed: %v", err)
}
if m.CurrentState() != "expired" {
t.Errorf("state = %q, want %q", m.CurrentState(), "expired")
}
}
TestCheckTimeouts_NotExpired
Parameters
func TestCheckTimeouts_NotExpired(t *testing.T)
{
tm := &timeoutFSM{}
m, _ := New(tm)
if err := m.CheckTimeouts(); err != nil {
t.Fatalf("CheckTimeouts should not error immediately: %v", err)
}
if m.CurrentState() != "pending" {
t.Errorf("state should remain 'pending', got %q", m.CurrentState())
}
}
TestCheckTimeouts_NoTimeoutRule
Parameters
func TestCheckTimeouts_NoTimeoutRule(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
if err := m.CheckTimeouts(); err != nil {
t.Fatalf("CheckTimeouts with no rule: %v", err)
}
}
TestGetStructure
Parameters
func TestGetStructure(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
initial, transitions, wildcards := m.GetStructure()
if initial != "draft" {
t.Errorf("initial = %q, want %q", initial, "draft")
}
if _, ok := transitions["draft"]; !ok {
t.Error("expected transitions from draft")
}
if len(wildcards) == 0 {
t.Error("expected wildcards")
}
}
TestConcurrentTransitions
Parameters
func TestConcurrentTransitions(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
m.CurrentState()
}()
}
wg.Add(1)
go func() {
defer wg.Done()
m.Transition("confirmed")
}()
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
m.History()
}()
}
wg.Wait()
}
TestSubscribe_Concurrent
Parameters
func TestSubscribe_Concurrent(t *testing.T)
{
o := &orderFSM{}
m, _ := New(o)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
m.Subscribe(func(e Event) {})
}()
}
wg.Wait()
}
TestDoubleWildcard
Parameters
func TestDoubleWildcard(t *testing.T)
{
type doubleWild struct {
State string `fsm:"*->cancelled; *->archived"`
}
d := &doubleWild{}
m, err := New(d)
if err != nil {
t.Fatalf("New failed: %v", err)
}
if err := m.Transition("cancelled"); err != nil {
t.Fatalf("Transition to cancelled failed: %v", err)
}
}
EventType
EventType categorises state machine lifecycle events.
type EventType int
TransitionRecord
TransitionRecord stores a single state transition.
type TransitionRecord struct
Fields
| Name | Type | Description |
|---|---|---|
| From | string | |
| To | string | |
| Timestamp | time.Time | |
| Trigger | string | |
| Metadata | map[string]any |
Listener
Listener is called when a state machine event occurs.
type Listener func(e Event)
Event
Event represents a state machine lifecycle event.
type Event struct
Fields
Uses
stateHooks
type stateHooks struct
Fields
| Name | Type | Description |
|---|---|---|
| can | func() error | |
| enter | func() | |
| exit | func() |
Machine
Machine manages an object’s state machine with transitions and hooks.
type Machine struct
Methods
func (*Machine) initHooks()
{
states := make(map[string]struct{})
if m.initialState != "" {
states[m.initialState] = struct{}{}
}
for src, dsts := range m.transitions {
states[src] = struct{}{}
for _, dst := range dsts {
states[dst] = struct{}{}
}
}
for _, dst := range m.wildcards {
states[dst] = struct{}{}
}
objVal := reflect.ValueOf(m.obj)
getMethod := func(name string) reflect.Value { return objVal.MethodByName(name) }
for state := range states {
normalized := normalizeStateName(state)
h := stateHooks{}
if mVal := getMethod("Can" + normalized); mVal.IsValid() {
if fn, ok := mVal.Interface().(func() error); ok {
h.can = fn
}
}
if mVal := getMethod("OnEnter" + normalized); mVal.IsValid() {
if fn, ok := mVal.Interface().(func()); ok {
h.enter = fn
}
}
if mVal := getMethod("OnExit" + normalized); mVal.IsValid() {
if fn, ok := mVal.Interface().(func()); ok {
h.exit = fn
}
}
m.hooks[state] = h
}
}
CurrentState returns the current state value.
Returns
func (*Machine) CurrentState() string
{
m.mu.Lock()
defer m.mu.Unlock()
return m.stateField.String()
}
History returns a copy of all transition records.
Returns
func (*Machine) History() []TransitionRecord
{
m.mu.Lock()
defer m.mu.Unlock()
return append([]TransitionRecord(nil), m.history...)
}
Subscribe registers a listener for state change events.
Parameters
func (*Machine) Subscribe(l Listener)
{
m.mu.Lock()
defer m.mu.Unlock()
m.listeners = append(m.listeners, l)
}
Parameters
func (*Machine) emitEvent(eventType EventType, from, to string)
{
evt := Event{
Type: eventType,
From: from,
To: to,
Timestamp: time.Now(),
Machine: m,
}
m.mu.Lock()
listeners := make([]Listener, len(m.listeners))
copy(listeners, m.listeners)
m.mu.Unlock()
for _, l := range listeners {
l(evt)
}
}
CanTransition checks whether a transition to target is allowed.
Parameters
Returns
func (*Machine) CanTransition(target string) error
{
m.mu.Lock()
defer m.mu.Unlock()
current := m.stateField.String()
return m.checkTransitionLocked(current, target)
}
Parameters
Returns
func (*Machine) checkTransitionLocked(current, target string) error
{
allowed := m.wildcardSet[target]
if !allowed {
if dests, ok := m.transitionSet[current]; ok {
allowed = dests[target]
}
}
if !allowed {
return fmt.Errorf("transition from '%s' to '%s' not allowed", current, target)
}
if h, ok := m.hooks[target]; ok && h.can != nil {
if err := h.can(); err != nil {
return err
}
}
return nil
}
Transition moves the machine to the target state if allowed.
Parameters
Returns
func (*Machine) Transition(target string) error
{
return m.transitionInternal(target, "manual")
}
Parameters
Returns
func (*Machine) transitionInternal(target string, trigger string) error
{
m.mu.Lock()
current := m.stateField.String()
if err := m.checkTransitionLocked(current, target); err != nil {
m.mu.Unlock()
return err
}
exitHook := m.hooks[current].exit
enterHook := m.hooks[target].enter
m.stateField.SetString(target)
m.lastStateTime = time.Now()
m.history = append(m.history, TransitionRecord{
From: current,
To: target,
Timestamp: time.Now(),
Trigger: trigger,
})
m.mu.Unlock()
m.emitEvent(BeforeTransition, current, target)
if current != "" {
if exitHook != nil {
exitHook()
}
m.emitEvent(ExitState, current, target)
}
if enterHook != nil {
enterHook()
}
m.emitEvent(EnterState, current, target)
m.emitEvent(AfterTransition, current, target)
return nil
}
CheckTimeouts transitions on timeout if a rule has expired.
Returns
func (*Machine) CheckTimeouts() error
{
m.mu.Lock()
current := m.stateField.String()
elapsed := time.Since(m.lastStateTime)
rule, exists := m.timeouts[current]
m.mu.Unlock()
if exists && elapsed > rule.Duration {
return m.transitionInternal(rule.ToState, "timeout")
}
return nil
}
GetStructure returns the initial state, transitions, and wildcards.
Returns
func (*Machine) GetStructure() (string, map[string][]string, []string)
{
return m.initialState, m.transitions, m.wildcards
}
ToMermaid returns a Mermaid diagram of the state machine.
Returns
func (*Machine) ToMermaid() string
{
return visualizer.ToMermaid(m)
}
ToGraphviz returns a Graphviz DOT diagram of the state machine.
Returns
func (*Machine) ToGraphviz() string
{
return visualizer.ToGraphviz(m)
}
Fields
| Name | Type | Description |
|---|---|---|
| obj | any | |
| val | reflect.Value | |
| stateField | reflect.Value | |
| stateType | reflect.StructField | |
| transitions | map[string][]string | |
| transitionSet | map[string]map[string]bool | |
| wildcards | []string | |
| wildcardSet | map[string]bool | |
| initialState | string | |
| hooks | map[string]stateHooks | |
| mu | sync.Mutex | |
| history | []TransitionRecord | |
| listeners | []Listener | |
| timeouts | map[string]parser.TimeoutRule | |
| lastStateTime | time.Time |
New
New creates a Machine from a struct pointer with an “fsm” tag field.
Parameters
Returns
func New(obj any) (*Machine, error)
{
val := reflect.ValueOf(obj)
if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Struct {
return nil, errors.New("obj must be a pointer to a struct")
}
elem := val.Elem()
fields := tags.NewParser("fsm").ParseStruct(obj)
for _, meta := range fields {
if meta.Type.Kind() != reflect.String {
return nil, fmt.Errorf("field '%s' must be a string", meta.Name)
}
cfg, err := parser.Parse(meta.RawTag)
if err != nil {
return nil, err
}
m := &Machine{
obj: obj,
val: elem,
stateField: elem.Field(meta.Index),
stateType: elem.Type().Field(meta.Index),
transitions: cfg.Transitions,
wildcards: cfg.Wildcards,
initialState: cfg.InitialState,
timeouts: cfg.Timeouts,
hooks: make(map[string]stateHooks),
history: make([]TransitionRecord, 0),
listeners: make([]Listener, 0),
}
m.transitionSet = make(map[string]map[string]bool, len(cfg.Transitions))
for src, dsts := range cfg.Transitions {
s := make(map[string]bool, len(dsts))
for _, d := range dsts {
s[d] = true
}
m.transitionSet[src] = s
}
m.wildcardSet = make(map[string]bool, len(cfg.Wildcards))
for _, w := range cfg.Wildcards {
m.wildcardSet[w] = true
}
m.initHooks()
current := m.stateField.String()
if current == "" && m.initialState != "" {
m.stateField.SetString(m.initialState)
current = m.initialState
}
m.lastStateTime = time.Now()
return m, nil
}
return nil, errors.New("no field with 'fsm' tag found")
}
normalizeStateName
Parameters
Returns
func normalizeStateName(s string) string
{
parts := strings.Split(s, "_")
for i, p := range parts {
r := []rune(p)
if len(r) > 0 {
r[0] = unicode.ToUpper(r[0])
}
parts[i] = string(r)
}
return strings.Join(parts, "")
}