machine API

machine

package

API reference for the machine package.

S
struct

orderFSM

pkg/fsm/machine/machine_test.go:9-11
type orderFSM struct

Methods

CanPaid
Method

Returns

error
func (*orderFSM) CanPaid() error
{ return nil }
OnEnterPaid
Method
func (*orderFSM) OnEnterPaid()
{}

Fields

Name Type Description
State string fsm:"initial:draft; draft->confirmed; confirmed->paid; paid->shipped; *->cancelled"
S
struct

guardFSM

pkg/fsm/machine/machine_test.go:16-20
type guardFSM struct

Methods

CanPaid
Method

Returns

error
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
S
struct

hookFSM

pkg/fsm/machine/machine_test.go:30-36
type hookFSM struct

Methods

OnExitIdle
Method
func (*hookFSM) OnExitIdle()
{ h.onExitIdle = true }
OnEnterActive
Method
func (*hookFSM) OnEnterActive()
{ h.onEnterActive = true }
OnExitActive
Method
func (*hookFSM) OnExitActive()
{ h.onExitActive = true }
OnEnterDone
Method
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
S
struct

timeoutFSM

pkg/fsm/machine/machine_test.go:43-45
type timeoutFSM struct

Fields

Name Type Description
State string fsm:"initial:pending; pending->expired [50ms]"
T
type

errGuardType

pkg/fsm/machine/machine_test.go:49-49
type errGuardType string
F
function

TestNew_ValidFSM

Parameters

pkg/fsm/machine/machine_test.go:53-62
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")
	}
}
F
function

TestNew_NonPointer

Parameters

pkg/fsm/machine/machine_test.go:64-69
func TestNew_NonPointer(t *testing.T)

{
	_, err := New(orderFSM{})
	if err == nil {
		t.Error("expected error for non-pointer")
	}
}
F
function

TestNew_NilPointer

Parameters

pkg/fsm/machine/machine_test.go:71-76
func TestNew_NilPointer(t *testing.T)

{
	_, err := New((*orderFSM)(nil))
	if err == nil {
		t.Error("expected error for nil pointer")
	}
}
F
function

TestNew_NoFSMTag

Parameters

pkg/fsm/machine/machine_test.go:78-86
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")
	}
}
F
function

TestNew_NonStringField

Parameters

pkg/fsm/machine/machine_test.go:88-96
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")
	}
}
F
function

TestTransition_Valid

Parameters

pkg/fsm/machine/machine_test.go:98-107
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")
	}
}
F
function

TestTransition_Chained

Parameters

pkg/fsm/machine/machine_test.go:109-121
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")
	}
}
F
function

TestTransition_Invalid

Parameters

pkg/fsm/machine/machine_test.go:123-129
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")
	}
}
F
function

TestTransition_Wildcard

Parameters

pkg/fsm/machine/machine_test.go:131-140
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")
	}
}
F
function

TestTransition_GuardAllowed

Parameters

pkg/fsm/machine/machine_test.go:142-151
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")
	}
}
F
function

TestTransition_GuardRejected

Parameters

pkg/fsm/machine/machine_test.go:153-162
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())
	}
}
F
function

TestTransition_HookOrder

Parameters

pkg/fsm/machine/machine_test.go:164-182
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")
	}
}
F
function

TestCurrentState_Concurrent

Parameters

pkg/fsm/machine/machine_test.go:184-196
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()
}
F
function

TestSubscribe_ListenerCalled

Parameters

pkg/fsm/machine/machine_test.go:198-223
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()
}
F
function

TestHistory_RecordsTransitions

Parameters

pkg/fsm/machine/machine_test.go:225-241
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])
	}
}
F
function

TestHistory_Trigger

Parameters

pkg/fsm/machine/machine_test.go:243-251
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")
	}
}
F
function

TestHistory_Immutable

Parameters

pkg/fsm/machine/machine_test.go:253-261
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")
	}
}
F
function

TestCheckTimeouts_Expired

Parameters

pkg/fsm/machine/machine_test.go:263-273
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")
	}
}
F
function

TestCheckTimeouts_NotExpired

Parameters

pkg/fsm/machine/machine_test.go:275-284
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())
	}
}
F
function

TestCheckTimeouts_NoTimeoutRule

Parameters

pkg/fsm/machine/machine_test.go:286-292
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)
	}
}
F
function

TestGetStructure

Parameters

pkg/fsm/machine/machine_test.go:294-307
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")
	}
}
F
function

TestConcurrentTransitions

Parameters

pkg/fsm/machine/machine_test.go:309-337
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()
}
F
function

TestSubscribe_Concurrent

Parameters

pkg/fsm/machine/machine_test.go:339-352
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()
}
F
function

TestDoubleWildcard

Parameters

pkg/fsm/machine/machine_test.go:354-366
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)
	}
}
T
type

EventType

EventType categorises state machine lifecycle events.

pkg/fsm/machine/types.go:6-6
type EventType int
S
struct

TransitionRecord

TransitionRecord stores a single state transition.

pkg/fsm/machine/types.go:20-26
type TransitionRecord struct

Fields

Name Type Description
From string
To string
Timestamp time.Time
Trigger string
Metadata map[string]any
T
type

Listener

Listener is called when a state machine event occurs.

pkg/fsm/machine/types.go:29-29
type Listener func(e Event)
S
struct

Event

Event represents a state machine lifecycle event.

pkg/fsm/machine/types.go:32-38
type Event struct

Fields

Name Type Description
Type EventType
From string
To string
Timestamp time.Time
Machine *Machine
S
struct

stateHooks

pkg/fsm/machine/machine.go:18-22
type stateHooks struct

Fields

Name Type Description
can func() error
enter func()
exit func()
S
struct

Machine

Machine manages an object’s state machine with transitions and hooks.

pkg/fsm/machine/machine.go:25-41
type Machine struct

Methods

initHooks
Method
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
Method

CurrentState returns the current state value.

Returns

string
func (*Machine) CurrentState() string
{
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.stateField.String()
}
History
Method

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
Method

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)
}
emitEvent
Method

Parameters

eventType EventType
from string
to string
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
Method

CanTransition checks whether a transition to target is allowed.

Parameters

target string

Returns

error
func (*Machine) CanTransition(target string) error
{
	m.mu.Lock()
	defer m.mu.Unlock()

	current := m.stateField.String()
	return m.checkTransitionLocked(current, target)
}

Parameters

current string
target string

Returns

error
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
Method

Transition moves the machine to the target state if allowed.

Parameters

target string

Returns

error
func (*Machine) Transition(target string) error
{
	return m.transitionInternal(target, "manual")
}

Parameters

target string
trigger string

Returns

error
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
Method

CheckTimeouts transitions on timeout if a rule has expired.

Returns

error
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
Method

GetStructure returns the initial state, transitions, and wildcards.

Returns

string
map[string][]string
[]string
func (*Machine) GetStructure() (string, map[string][]string, []string)
{
	return m.initialState, m.transitions, m.wildcards
}
ToMermaid
Method

ToMermaid returns a Mermaid diagram of the state machine.

Returns

string
func (*Machine) ToMermaid() string
{
	return visualizer.ToMermaid(m)
}
ToGraphviz
Method

ToGraphviz returns a Graphviz DOT diagram of the state machine.

Returns

string
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
F
function

New

New creates a Machine from a struct pointer with an “fsm” tag field.

Parameters

obj
any

Returns

error
pkg/fsm/machine/machine.go:44-103
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")
}
F
function

normalizeStateName

Parameters

s
string

Returns

string
pkg/fsm/machine/machine.go:287-297
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, "")
}