circulator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 34d38ad852dfc0dea3071103c368fa9d77224c7a390b7fc10c6a39fde2e2b507
4
+ data.tar.gz: c67068423ece22963cdf64391f0424251a8c23caebf08280a6bb977b1a18bd4b
5
+ SHA512:
6
+ metadata.gz: dc4264165cdb3fdf72ec246dcabc02c0bacff9d20f24a167984e6521517550cc131fd0711f289892c8263fea6ac6c07dcc1d39c0ed77165dbf74d2a3d044f331
7
+ data.tar.gz: d85440b2e40cc2aa2ff1f7848f5b4dc63b94c2217ebdd7918488967489badb439edcb1c5f39c0b762634f1739ce38405ad19e841a7f4b84c2bb8671262418ec3
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2025-09-11
9
+
10
+ ###
11
+
12
+ - Initial release.
data/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # Circulator
2
+
3
+ A lightweight and flexible state machine implementation for Ruby that allows you to define and manage state transitions with an simple DSL. Circulator provides a simple yet powerful way to add state machine functionality to your Ruby classes without the complexity of larger frameworks.
4
+
5
+ ## Key Features
6
+
7
+ - **Lightweight**: Minimal dependencies and simple implementation
8
+ - **Flexible DSL**: Intuitive syntax for defining states and transitions
9
+ - **Dynamic Method Generation**: Automatically creates helper methods for state transitions
10
+ - **Conditional Transitions**: Support for guards and conditional logic
11
+ - **Transition Callbacks**: Execute code before, during, or after transitions
12
+ - **Multiple State Machines**: Define multiple independent state machines per class
13
+ - **Framework Agnostic**: Works with plain Ruby objects, no Rails or ActiveRecord required
14
+ - **100% Test Coverage**: Thoroughly tested with comprehensive test suite
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem 'circulator'
22
+ ```
23
+
24
+ And then execute:
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ Or install it yourself as:
31
+
32
+ ```bash
33
+ gem install circulator
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ### Basic Example
39
+
40
+ ```ruby
41
+ class Order
42
+ extend Circulator::Diverter
43
+
44
+ attr_accessor :status
45
+
46
+ circulator :status do
47
+ state :pending do
48
+ action :process, to: :processing
49
+ action :cancel, to: :cancelled
50
+ end
51
+
52
+ state :processing do
53
+ action :ship, to: :shipped
54
+ action :cancel, to: :cancelled
55
+ end
56
+
57
+ state :shipped do
58
+ action :deliver, to: :delivered
59
+ end
60
+
61
+ state :delivered
62
+ state :cancelled
63
+ end
64
+ end
65
+
66
+ order = Order.new
67
+ order.status = :pending
68
+
69
+ order.status_process # => :processing
70
+ order.status_ship # => :shipped
71
+ order.status_deliver # => :delivered
72
+ ```
73
+
74
+ ### Advanced Features
75
+
76
+ #### Conditional Transitions with Guards
77
+
78
+ ```ruby
79
+ class Document
80
+ extend Circulator::Diverter
81
+
82
+ attr_accessor :state, :reviewed_by
83
+
84
+ circulator :state do
85
+ state :draft do
86
+ action :submit, to: :review
87
+
88
+ action :publish, to: :published, allow_if: -> { reviewed_by.present? } do
89
+ puts "Publishing document reviewed by #{reviewed_by}"
90
+ end
91
+ end
92
+ end
93
+ end
94
+ ```
95
+
96
+ #### Dynamic Destination States
97
+
98
+ ```ruby
99
+ class Task
100
+ extend Circulator::Diverter
101
+
102
+ attr_accessor :priority, :urgency_level
103
+
104
+ circulator :priority do
105
+ state :normal do
106
+ # Destination determined at runtime
107
+ action :escalate, to: -> { urgency_level > 5 ? :critical : :high }
108
+ end
109
+ end
110
+ end
111
+ ```
112
+
113
+ #### Multiple State Machines
114
+
115
+ ```ruby
116
+ class Server
117
+ extend Circulator::Diverter
118
+
119
+ attr_accessor :power_state, :network_state
120
+
121
+ # First state machine for power management
122
+ circulator :power_state do
123
+ state :off do
124
+ action :boot, to: :booting
125
+ end
126
+ state :booting do
127
+ action :ready, to: :on
128
+ end
129
+ state :on do
130
+ action :shutdown, to: :off
131
+ end
132
+ end
133
+
134
+ # Second state machine for network status
135
+ circulator :network_state do
136
+ state :disconnected do
137
+ action :connect, to: :connected
138
+ end
139
+ state :connected do
140
+ action :disconnect, to: :disconnected
141
+ end
142
+ end
143
+ end
144
+ ```
145
+
146
+ #### Transition Callbacks
147
+
148
+ ```ruby
149
+ class Payment
150
+ extend Circulator::Diverter
151
+
152
+ attr_accessor :status, :processed_at
153
+
154
+ circulator :status do
155
+ state :pending do
156
+ action :process, to: :completed do
157
+ self.processed_at = Time.now
158
+ send_confirmation_email
159
+ end
160
+ end
161
+ end
162
+
163
+ private
164
+
165
+ def send_confirmation_email
166
+ # Send email logic here
167
+ end
168
+ end
169
+ ```
170
+
171
+ ## Why Circulator?
172
+
173
+ Circulator distinguishes itself from other Ruby state machine libraries through its simplicity and flexibility:
174
+
175
+ ### Advantages Over Other Libraries
176
+
177
+ - **Minimal Magic**: Unlike AASM and state_machines, Circulator uses straightforward Ruby metaprogramming without complex DSL magic
178
+ - **No Dependencies**: Works with plain Ruby objects without requiring Rails, ActiveRecord, or other frameworks
179
+ - **Lightweight**: Smaller footprint compared to feature-heavy alternatives
180
+ - **Clear Method Names**: Generated methods follow predictable naming patterns (`status_approve`, `priority_escalate`)
181
+ - **Flexible Architecture**: Easy to extend and customize for specific needs
182
+
183
+ ### When to Use Circulator
184
+
185
+ Choose Circulator when you need:
186
+ - A simple, lightweight state machine without framework dependencies
187
+ - Clear, predictable method naming conventions
188
+ - Multiple independent state machines on the same object
189
+ - Easy-to-understand code without DSL complexity
190
+ - Full control over state transition logic
191
+
192
+ ## Related Projects
193
+
194
+ If Circulator doesn't meet your needs, consider these alternatives:
195
+
196
+ - **[AASM](https://github.com/aasm/aasm)** - Full-featured state machine with ActiveRecord integration and extensive callbacks
197
+ - **[state_machines](https://github.com/state-machines/state_machines)** - Comprehensive state machine library with GraphViz visualization support
198
+ - **[workflow](https://github.com/geekq/workflow)** - Workflow-focused state machine with emphasis on business processes
199
+ - **[statesman](https://github.com/gocardless/statesman)** - Database-backed state machines with transition history
200
+ - **[finite_machine](https://github.com/piotrmurach/finite_machine)** - Minimal finite state machine with a simple DSL
201
+
202
+ Each library has its strengths:
203
+ - Use **AASM** for Rails applications needing ActiveRecord integration
204
+ - Use **state_machines** for complex state logic with visualization needs
205
+ - Use **workflow** for business process modeling
206
+ - Use **statesman** when audit trails and transition history are critical
207
+ - Use **finite_machine** for thread-safe state machines
208
+ - Use **Circulator** for lightweight, flexible state management without dependencies
209
+
210
+ ## Development
211
+
212
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
213
+
214
+ To install this gem onto your local machine, run bundle exec rake install.
215
+
216
+ This project is managed with [Reissue](https://github.com/SOFware/reissue).
217
+
218
+ To release a new version, make your changes and be sure to update the CHANGELOG.md.
219
+
220
+ To release a new version:
221
+
222
+ bundle exec rake build:checksum
223
+ bundle exec rake release
224
+
225
+ ## Contributing
226
+
227
+ Bug reports and pull requests are welcome on GitHub at https://github.com/SOFware/circulator.
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[test standard]
11
+
12
+ require "reissue/gem"
13
+
14
+ Reissue::Task.create :reissue do |task|
15
+ task.version_file = "lib/circulator/version.rb"
16
+ task.changelog_file = "CHANGELOG.md"
17
+ task.version_limit = 1
18
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Circulator
4
+ module Diverter
5
+ # Declare a flow for an attribute.
6
+ #
7
+ # Specify the attribute to be used for states and actions.
8
+ #
9
+ # Example:
10
+ #
11
+ # flow(:status) do
12
+ # state :pending do
13
+ # action :approve, to: :approved
14
+ # end
15
+ # end
16
+ #
17
+ # The above declares a flow for the `status` attribute. When in the `pending`
18
+ # state, the `approve` action will transition the `status` to `approved`.
19
+ #
20
+ # This creates a `status_approve` method which will change the state in memory.
21
+ #
22
+ # You will also have a instance method `flow` which will allow you to specify
23
+ # the action to take on the attribute.
24
+ #
25
+ # Example:
26
+ #
27
+ # test_object.status_approve
28
+ # # OR
29
+ # test_object.flow(:approve, :status)
30
+ #
31
+ # You can also provide a block to receive arguments
32
+ #
33
+ # Example:
34
+ #
35
+ # flow(:status) do
36
+ # state :pending do
37
+ # action :approve, to: :approved do |*args, **kwargs|
38
+ # @args_received = {args: args, kwargs: kwargs}
39
+ # end
40
+ # action_allowed(:approve) { true } # Optional. Check some value on self
41
+ # end
42
+ # end
43
+ #
44
+ # The block will be evalutaed on the instance of the class declaring the flow.
45
+ # So `self` inside that action block will be the instance of the class.
46
+ #
47
+ # Example:
48
+ #
49
+ # test_object.status_approve("arg1", "arg2", key: "value")
50
+ # # @args_received will be {args: ["arg1", "arg2"], kwargs: {key: "value"}}
51
+ #
52
+ # If the action is not allowed, the transition will not be executed.
53
+ #
54
+ # Example:
55
+ #
56
+ # flow(:status) do
57
+ # state :pending do
58
+ # action :approve, to: :approved do
59
+ # @args_received = {args: args, kwargs: kwargs}
60
+ # end
61
+ # action_allowed(:approve) { false }
62
+ # end
63
+ # end
64
+ #
65
+ # test_object.status_approve
66
+ # # Will not transition because the action is not allowed
67
+ #
68
+ # You may also specify the `allow_if` option to check a condition before
69
+ # the action is allowed. The callable will be evaluated on the instance of
70
+ # the class declaring the flow. So `self` inside that block will be the
71
+ # instance of the class.
72
+ #
73
+ # Example:
74
+ #
75
+ # flow(:status) do
76
+ # state :pending do
77
+ # action :approve, to: :approved, allow_if: -> { true }
78
+ # end
79
+ # end
80
+ #
81
+ # test_object.status_approve
82
+ # # Will transition to :approved if the condition is true
83
+ #
84
+ # If you declare states separately, for example in an enum, you can use the
85
+ # `action` method to declare the action on the attribute.
86
+ #
87
+ # Example:
88
+ #
89
+ # enum :status, {pending: 0, approved: 1, rejected: 2}
90
+ # flow(:status) do
91
+ # action :approve, to: :approved, from: :pending
92
+ # action :reject, to: :rejected, from: :approved do |rejected_at|
93
+ # self.rejected_at = rejected_at
94
+ # end
95
+ # end
96
+ #
97
+ # test_object.status_approve
98
+ # # Will transition to :approved
99
+ # test_object.status_reject
100
+ # # Will transition to :rejected and set the rejected_at attribute
101
+ #
102
+ # By default, if there is no transition for the current state, the flow will
103
+ # raise an error. You can specify a no_action block to handle this case.
104
+ #
105
+ # Example:
106
+ #
107
+ # flow(:status) do
108
+ # no_action { |attribute_name, action| raise "Nope!" }
109
+ # end
110
+ #
111
+ # test_object.status_approve
112
+ # # Will raise an error
113
+ #
114
+ # You can also provide a custom action for other behavior for a set of states and
115
+ # use the `to` option as a callable to set the attribute.
116
+ #
117
+ # Example:
118
+ #
119
+ # flow(:status) do
120
+ # action :unknown, to: -> { status }, from: [:enforcing, :monitoring, :ignoring] do |signal|
121
+ # raise UnhandledSignalError, signal
122
+ # end
123
+ # end
124
+ #
125
+ # test_object.flow(:unknown, :status, "signal")
126
+ # # Will raise an UnhandledSignalError
127
+ #
128
+ def flow(attribute_name, model: to_s, &block)
129
+ @flows ||= {}
130
+ model_key = Circulator::Diverter.model_key(model)
131
+ @flows[model_key] ||= {}
132
+ @flows[model_key][attribute_name] = Circulator::Flow.new(model, attribute_name, &block)
133
+
134
+ flow_module = ancestors.find { |ancestor|
135
+ ancestor.name.to_s =~ /FlowMethods/
136
+ } || Module.new.tap do |mod|
137
+ include mod
138
+
139
+ const_set(:FlowMethods, mod)
140
+ end
141
+
142
+ object = if model == to_s
143
+ nil
144
+ else
145
+ Circulator::Diverter.methodize_name(model)
146
+ end
147
+
148
+ @flows.dig(model_key, attribute_name).transition_map.each do |action, transitions|
149
+ define_flow_method(attribute_name:, action:, transitions:, object:, owner: flow_module)
150
+ end
151
+ end
152
+ alias_method :circulator, :flow
153
+
154
+ def define_flow_method(attribute_name:, action:, transitions:, object:, owner:)
155
+ object_attribute_method = [object, attribute_name, action].compact.join("_")
156
+ raise ArgumentError, "Method already defined: #{object_attribute_method}" if owner.method_defined?(object_attribute_method)
157
+
158
+ owner.define_method(object_attribute_method) do |*args, flow_target: self, **kwargs, &block|
159
+ current_value = flow_target.send(attribute_name)
160
+
161
+ transition = if current_value.respond_to?(:to_sym)
162
+ transitions[current_value.to_sym]
163
+ else
164
+ transitions[current_value]
165
+ end
166
+
167
+ unless transition
168
+ flow_target.instance_exec(attribute_name, action, &flows.dig(Circulator::Diverter.model_key(flow_target), attribute_name).no_action)
169
+ return
170
+ end
171
+
172
+ if transition[:allow_if]
173
+ return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
174
+ end
175
+
176
+ if transition[:block]
177
+ flow_target.instance_exec(*args, **kwargs, &transition[:block])
178
+ end
179
+
180
+ if transition[:to].respond_to?(:call)
181
+ flow_target.send("#{attribute_name}=", flow_target.instance_exec(*args, **kwargs, &transition[:to]))
182
+ else
183
+ flow_target.send("#{attribute_name}=", transition[:to])
184
+ end.tap do
185
+ if block
186
+ flow_target.instance_exec(*args, **kwargs, &block)
187
+ end
188
+ end
189
+ end
190
+ end
191
+
192
+ module_function def model_key(object)
193
+ if object.is_a?(String)
194
+ if object.start_with?("#<Class:")
195
+ "anonymous_#{object.split("0x")[1]}".sub(">", "")
196
+ else
197
+ object
198
+ end
199
+ else
200
+ model_key(object.class.name || object.class.to_s)
201
+ end
202
+ end
203
+
204
+ module_function def methodize_name(name)
205
+ name.split("::").map { |part| part.gsub(/([a-z])([A-Z])/, '\1_\2') }.join("_").downcase
206
+ end
207
+
208
+ def self.extended(base)
209
+ base.include(InstanceMethods)
210
+ base.singleton_class.attr_reader :flows
211
+ end
212
+
213
+ module InstanceMethods
214
+ # Use this method to call an action on the attribute.
215
+ #
216
+ # Example:
217
+ #
218
+ # test_object.flow(:approve, :status)
219
+ # test_object.flow(:approve, :status, "arg1", "arg2", key: "value")
220
+ def flow(action, attribute, *args, flow_target: self, **kwargs, &block)
221
+ target_name = if flow_target != self
222
+ Circulator::Diverter.methodize_name(Circulator::Diverter.model_key(flow_target))
223
+ end
224
+ external_attribute_name = [target_name, attribute].compact.join("_")
225
+ method_name = "#{external_attribute_name}_#{action}"
226
+ if respond_to?(method_name)
227
+ send(method_name, *args, flow_target:, **kwargs, &block)
228
+ elsif flow_target.respond_to?(method_name)
229
+ flow_target.send(method_name, *args, **kwargs, &block)
230
+ else
231
+ raise "Invalid action for the current state of #{attribute} (#{flow_target.send(attribute).inspect}): #{action}"
232
+ end
233
+ end
234
+
235
+ private
236
+
237
+ def flows
238
+ self.class.flows
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diverter"
4
+
5
+ module Circulator
6
+ class Flow
7
+ def initialize(klass, attribute_name, states = Set.new, &block)
8
+ @klass = klass
9
+ @attribute_name = attribute_name
10
+ @states = states
11
+ @no_action = ->(attribute_name, action) { raise "No action found for the current state of #{attribute_name} (#{send(attribute_name)}): #{action}" }
12
+ @transition_map = {}
13
+ instance_eval(&block)
14
+ end
15
+ attr_reader :transition_map
16
+
17
+ def state(name, &block)
18
+ name = name.to_sym if name.respond_to?(:to_sym)
19
+ @states.add(name)
20
+ @current_state = name
21
+ instance_eval(&block) if block
22
+ remove_instance_variable(:@current_state)
23
+ end
24
+
25
+ def action(name, to:, from: :__not_specified__, allow_if: nil, &block)
26
+ raise "You must be in a state block or have a `from` option to declare an action" unless defined?(@current_state) || from != :__not_specified__
27
+
28
+ @transition_map[name] ||= {}
29
+ selected_state = (from == :__not_specified__) ? @current_state : from
30
+
31
+ # Handle nil case specially - convert to [nil] instead of []
32
+ states_to_process = if selected_state.nil?
33
+ [nil]
34
+ else
35
+ Array(selected_state)
36
+ end
37
+
38
+ states_to_process.each do |from_state|
39
+ from_state = from_state.to_sym if from_state.respond_to?(:to_sym)
40
+ @states.add(from_state)
41
+ @transition_map[name][from_state] = {to:, block:}
42
+ @transition_map[name][from_state][:allow_if] = allow_if if allow_if
43
+ end
44
+ end
45
+
46
+ def action_allowed(name, from: :__not_specified__, &block)
47
+ raise "You must be in a state block or have a `from` option to declare an action" unless defined?(@current_state) || from != :__not_specified__
48
+
49
+ selected_state = (from == :__not_specified__) ? @current_state : from
50
+
51
+ # Handle nil case specially - convert to [nil] instead of []
52
+ states_to_process = if selected_state.nil?
53
+ [nil]
54
+ else
55
+ Array(selected_state)
56
+ end
57
+
58
+ states_to_process.each do |from_state|
59
+ from_state = from_state.to_sym if from_state.respond_to?(:to_sym)
60
+ @states.add(from_state)
61
+ @transition_map[name][from_state][:allow_if] = block
62
+ end
63
+ end
64
+
65
+ def no_action(&block)
66
+ if block_given?
67
+ @no_action = block
68
+ else
69
+ @no_action
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Circulator
4
+ VERSION = "1.0.0"
5
+ end
data/lib/circulator.rb ADDED
@@ -0,0 +1,3 @@
1
+ require "circulator/version"
2
+ require "circulator/diverter"
3
+ require "circulator/flow"
metadata ADDED
@@ -0,0 +1,48 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: circulator
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jim Gay
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Simple declarative state machine
13
+ email:
14
+ - jim@saturnflyer.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - README.md
21
+ - Rakefile
22
+ - lib/circulator.rb
23
+ - lib/circulator/diverter.rb
24
+ - lib/circulator/flow.rb
25
+ - lib/circulator/version.rb
26
+ homepage: https://github.com/SOFware/circulator
27
+ licenses: []
28
+ metadata:
29
+ homepage_uri: https://github.com/SOFware/circulator
30
+ source_code_uri: https://github.com/SOFware/circulator
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 3.2.0
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubygems_version: 3.6.7
46
+ specification_version: 4
47
+ summary: Simple state machine
48
+ test_files: []