statesman_scaffold 0.1.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: 6cbb9daaf63b955b29f50bf88b2bd3289356982558e7fb850c1fb1d0848104a8
4
+ data.tar.gz: 403cf58478d56041e182c84b1d8cbd78d64ea86ca3543b36efb89ebe5f691f23
5
+ SHA512:
6
+ metadata.gz: 1d574af74d46523405ce37bc80a8c1ba1b9dbc71a8e1025f1b29d9586f98fbb141c4d9ca14517298a91ac090eb8b634d0cb28fec186d28db0ac0e44abcad0326
7
+ data.tar.gz: 80e5402b96703129bf738d6b86fcfba25593d9f85048b9a6ba518f4426974973c8b0449768421ea227d08aa8c6ded8ed52e3af8b8c71a72f23630928b43d3a43
data/AGENTS.md ADDED
@@ -0,0 +1,180 @@
1
+ # AGENTS.md — statesman_scaffold
2
+
3
+ > Concise reference for AI coding agents working **on** the `statesman_scaffold`
4
+ > gem or **with** it inside a host Rails application.
5
+ >
6
+ > For end-user documentation see `README.md`.
7
+ > For LLM-optimised usage patterns see `llms/usage.md`.
8
+ > For architecture details see `llms/overview.md`.
9
+
10
+ ---
11
+
12
+ ## What this gem does
13
+
14
+ `statesman_scaffold` scaffolds [Statesman](https://github.com/gocardless/statesman)
15
+ state machines for ActiveRecord models and provides a `with_state_machine`
16
+ concern that wires up delegation, query scopes, and transition associations.
17
+
18
+ ```ruby
19
+ # 1. Generate files
20
+ # rails "statesman_scaffold:generate[Project]"
21
+
22
+ # 2. Add to model
23
+ class Project < ApplicationRecord
24
+ with_state_machine
25
+ end
26
+
27
+ # 3. Use
28
+ project = Project.create!(name: "Demo")
29
+ project.current_state # => "pending"
30
+ project.transition_to!(:active)
31
+ project.in_state?(:active) # => true
32
+ ```
33
+
34
+ It is a **Rails-only** gem (depends on `activesupport >= 7`, `activerecord >= 7`,
35
+ `railties >= 7`, `statesman >= 10`). Works only with
36
+ `Statesman::Adapters::ActiveRecord`.
37
+
38
+ ---
39
+
40
+ ## Quick integration checklist (host app)
41
+
42
+ ```bash
43
+ bundle add statesman_scaffold
44
+ rails statesman_scaffold:install # creates config/initializers/statesman_scaffold.rb
45
+ rails "statesman_scaffold:generate[ModelName]" # generates files
46
+ ```
47
+
48
+ After `install` every model inherits the `with_state_machine` macro. No
49
+ `include` needed.
50
+
51
+ ---
52
+
53
+ ## `with_state_machine` macro signature
54
+
55
+ ```ruby
56
+ with_state_machine(
57
+ transition_suffix: "Transition",
58
+ state_machine_suffix: "StateMachine"
59
+ )
60
+ ```
61
+
62
+ | Parameter | Type | Default | Notes |
63
+ |-----------|------|---------|-------|
64
+ | `transition_suffix` | `String` | `"Transition"` | Suffix for the transition class |
65
+ | `state_machine_suffix` | `String` | `"StateMachine"` | Suffix for the state machine class |
66
+
67
+ The macro expects `Model::StateMachine` and `Model::Transition` to exist.
68
+ Use the `generate` rake task to create them.
69
+
70
+ ---
71
+
72
+ ## Instance methods added by the concern
73
+
74
+ | Method | Delegates to | Description |
75
+ |--------|-------------|-------------|
76
+ | `current_state` | `state_machine` | Returns the current state as a string |
77
+ | `transition_to!(state)` | `state_machine` | Transitions to state; raises on failure |
78
+ | `transition_to(state)` | `state_machine` | Transitions to state; returns `true`/`false` |
79
+ | `can_transition_to?(state)` | `state_machine` | Checks if transition is valid |
80
+ | `in_state?(*states)` | — | Returns `true` if current state matches any given state |
81
+ | `not_in_state?(state)` | — | Returns `true` if current state does not match |
82
+
83
+ Class-level scopes from `Statesman::Adapters::ActiveRecordQueries`:
84
+ - `Model.in_state(:active, :pending)`
85
+ - `Model.not_in_state(:cancelled)`
86
+
87
+ ---
88
+
89
+ ## Rules agents must follow
90
+
91
+ ### Always
92
+ - Run `rails statesman_scaffold:install` before first use in a host app.
93
+ - Call `with_state_machine` **after** any custom `has_many` declarations.
94
+ - Define states, transitions, and callbacks in the generated `StateMachine` class.
95
+ - Add an `after_transition` callback to sync the status column if you have one.
96
+ - Run `rails db:migrate` after generating files.
97
+ - The `statesman` gem must be in the host app's Gemfile.
98
+
99
+ ### Never
100
+ - Manually create StateMachine/Transition classes without the generator (or follow the exact template patterns).
101
+ - Call `with_state_machine` more than once on the same model.
102
+ - Define a `has_many :transitions` on the model — `with_state_machine` does it.
103
+ - Stub `Rails.root` in tests — use `Installer.install!(tmpdir)` or `Generator.call(...)` with real paths.
104
+
105
+ ---
106
+
107
+ ## Gem internals (working on the gem itself)
108
+
109
+ ### Module map
110
+
111
+ | File | Responsibility |
112
+ |------|----------------|
113
+ | `lib/statesman_scaffold/concern.rb` | `with_state_machine` class macro + `in_state?`/`not_in_state?` |
114
+ | `lib/statesman_scaffold/generator.rb` | ERB template rendering for StateMachine, Transition, migration |
115
+ | `lib/statesman_scaffold/installer.rb` | Creates/removes `config/initializers/statesman_scaffold.rb` |
116
+ | `lib/statesman_scaffold/railtie.rb` | Registers all `statesman_scaffold:*` rake tasks |
117
+ | `lib/tasks/statesman_scaffold.rake` | Rake task implementations (install/uninstall/generate) |
118
+ | `lib/templates/statesman/*.erb` | ERB templates for generated files |
119
+
120
+ ### Generated files (per model)
121
+
122
+ | Template | Output | Description |
123
+ |----------|--------|-------------|
124
+ | `state_machine.rb.erb` | `app/models/<model>/state_machine.rb` | Statesman::Machine class with example states |
125
+ | `transition.rb.erb` | `app/models/<model>/transition.rb` | Transition model with validations |
126
+ | `migration.rb.erb` | `db/migrate/TIMESTAMP_create_<model>_transitions.rb` | Migration with indexes |
127
+
128
+ ### Test suite
129
+
130
+ ```bash
131
+ bundle exec rake test # Minitest with SQLite in-memory
132
+ bundle exec rake # default task
133
+ ```
134
+
135
+ Tests live in `test/statesman_scaffold/`. Each test class uses `setup`/`teardown`
136
+ to create and drop SQLite tables so tests are fully isolated.
137
+
138
+ ### Adding a new test
139
+
140
+ 1. Extend `Minitest::Test` inside the `StatesmanScaffold` module namespace.
141
+ 2. Name the file `test/statesman_scaffold/<feature>_test.rb`.
142
+ 3. Create tables in `setup`, drop them in `teardown`.
143
+ 4. Generator/Installer tests use `Dir.mktmpdir` for filesystem isolation.
144
+
145
+ ---
146
+
147
+ ## Common pitfalls
148
+
149
+ | Symptom | Cause | Fix |
150
+ |---------|-------|-----|
151
+ | `NameError: uninitialized constant Statesman` | Initializer runs before `statesman` gem is loaded | Ensure `gem "statesman"` is in Gemfile; re-run `rails statesman_scaffold:install` (initializer now includes `require "statesman"`) |
152
+ | `NameError: uninitialized constant Model::StateMachine` | StateMachine class not generated or not loaded | Run `rails "statesman_scaffold:generate[Model]"` and ensure file is in autoload path |
153
+ | `Statesman::TransitionFailedError` | Transition not allowed from current state | Check `StateMachine` class transition rules |
154
+ | `ActiveRecord::StatementInvalid` (missing table) | Migration not run | Run `rails db:migrate` |
155
+ | Transitions not persisted | Statesman not configured for AR adapter | Run `rails statesman_scaffold:install` |
156
+ | Status column not updated after transition | No `after_transition` callback | Add `after_transition { |m, t| m.update!(status: t.to_state) }` to StateMachine |
157
+
158
+ ---
159
+
160
+ ## Dependencies
161
+
162
+ | Gem | Version | Why |
163
+ |-----|---------|-----|
164
+ | `statesman` | `>= 10.0, < 13` | State machine engine |
165
+ | `activerecord` | `>= 7.0, < 9` | AR integration (has_many, migrations) |
166
+ | `activesupport` | `>= 7.0, < 9` | `Concern`, `camelize`, `underscore`, `on_load` |
167
+ | `railties` | `>= 7.0, < 9` | `Rails::Railtie` for exposing rake tasks |
168
+ | `sqlite3` | dev only | In-memory DB for tests |
169
+ | `minitest` | dev only | Test framework |
170
+
171
+ ---
172
+
173
+ ## Test conventions
174
+
175
+ - Use `Minitest::Test` (not `ActiveSupport::TestCase`)
176
+ - AR schema is created in `setup` with `force: true` and dropped in `teardown`
177
+ - `InstallerTest` and `GeneratorTest` use `Dir.mktmpdir` — always clean up in `teardown`
178
+ - `assert` / `refute` preferred over `assert_equal true/false`
179
+ - Group related tests with comment banners: `# --- install! ---`
180
+ - Test method names describe the exact behaviour: `test_install_creates_the_initializer_file`
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
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.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-03-07
11
+
12
+ ### Added
13
+
14
+ - `StatesmanScaffold::Concern` with the `with_state_machine` class macro
15
+ - `in_state?` and `not_in_state?` instance methods for state checking
16
+ - Delegation of `current_state`, `transition_to!`, `transition_to`, `can_transition_to?`
17
+ - `Statesman::Adapters::ActiveRecordQueries` integration for query scopes
18
+ - `StatesmanScaffold::Generator` for creating StateMachine, Transition, and migration files from ERB templates
19
+ - `StatesmanScaffold::Installer` for creating/removing the Rails initializer
20
+ - `StatesmanScaffold::Railtie` with `statesman_scaffold:install`, `statesman_scaffold:uninstall`, and `statesman_scaffold:generate` rake tasks
21
+ - Support for namespaced models (e.g. `Admin::Order`)
data/CLAUDE.md ADDED
@@ -0,0 +1,126 @@
1
+ # StatesmanScaffold -- Claude Project Context
2
+
3
+ > Project-level instructions and context for Claude Code when working inside
4
+ > the `statesman_scaffold` gem repository.
5
+
6
+ ## Project overview
7
+
8
+ `statesman_scaffold` is a Ruby gem that scaffolds [Statesman](https://github.com/gocardless/statesman)
9
+ state machines for ActiveRecord models. It generates StateMachine, Transition classes,
10
+ and migrations via rake tasks, and provides a `with_state_machine` concern for wiring
11
+ everything up with a single macro call.
12
+
13
+ ## Repository layout
14
+
15
+ ```
16
+ lib/
17
+ statesman_scaffold.rb # Entry point -- require this in host apps
18
+ statesman_scaffold/
19
+ concern.rb # Core: with_state_machine macro via ActiveSupport::Concern
20
+ generator.rb # ERB template renderer for state machine files
21
+ installer.rb # Manages config/initializers/statesman_scaffold.rb
22
+ railtie.rb # Rails integration, exposes rake tasks
23
+ version.rb # Version constant
24
+ templates/
25
+ statesman/
26
+ state_machine.rb.erb # Template for StateMachine class
27
+ transition.rb.erb # Template for Transition model
28
+ migration.rb.erb # Template for migration file
29
+ tasks/
30
+ statesman_scaffold.rake # install / uninstall / generate rake tasks
31
+ test/
32
+ test_helper.rb # SQLite in-memory, Statesman AR adapter config
33
+ test_statesman_scaffold.rb # Version number test
34
+ statesman_scaffold/
35
+ concern_test.rb # Tests for with_state_machine macro
36
+ generator_test.rb # Tests for ERB template generation
37
+ installer_test.rb # Tests for Installer class
38
+ llms/
39
+ overview.md # Architecture deep-dive for LLMs
40
+ usage.md # Common patterns and recipes
41
+ AGENTS.md # Concise guide for AI coding agents
42
+ CLAUDE.md # This file
43
+ ```
44
+
45
+ ## Development workflow
46
+
47
+ ```bash
48
+ bundle install
49
+ bundle exec rake test # run Minitest suite (SQLite in-memory)
50
+ bundle exec rake # tests (default)
51
+ bin/console # IRB with gem loaded
52
+ ```
53
+
54
+ ## Rake tasks (in host Rails apps)
55
+
56
+ | Task | Description |
57
+ |------|-------------|
58
+ | `rails statesman_scaffold:install` | Create `config/initializers/statesman_scaffold.rb` |
59
+ | `rails statesman_scaffold:uninstall` | Remove `config/initializers/statesman_scaffold.rb` |
60
+ | `rails "statesman_scaffold:generate[ModelName]"` | Generate StateMachine, Transition, and migration |
61
+
62
+ ## Code style
63
+
64
+ - **Double quotes** throughout (enforced by RuboCop).
65
+ - Frozen string literals on every file.
66
+ - Nested module/class syntax (`module StatesmanScaffold; class Concern`) preferred.
67
+ - Test files use `module StatesmanScaffold; class XxxTest < Minitest::Test` nesting.
68
+ - Metrics limits: `MethodLength: Max: 15`; test files are excluded from metrics.
69
+
70
+ ## Key design decisions
71
+
72
+ 1. **`with_state_machine` resolves class names dynamically** at call time using
73
+ the model's `name`. It expects `Model::StateMachine` and `Model::Transition`
74
+ to exist (generated via the rake task). Accepts optional `transition_suffix:`
75
+ and `state_machine_suffix:` keyword arguments.
76
+
77
+ 2. **Concern delegates** `current_state`, `transition_to!`, `transition_to`, and
78
+ `can_transition_to?` to a lazily-initialized state machine instance.
79
+
80
+ 3. **`in_state?` accepts multiple states** and compares via `.to_sym` for
81
+ symbol/string interoperability. `not_in_state?` accepts a single state.
82
+
83
+ 4. **Statesman::Adapters::ActiveRecordQueries** is auto-included by
84
+ `with_state_machine`, giving the model class-level `.in_state` and
85
+ `.not_in_state` query scopes.
86
+
87
+ 5. **Generator is decoupled from Rails**: `StatesmanScaffold::Generator` takes
88
+ `Pathname` arguments and never references `Rails.root`, making it unit-testable
89
+ with a `Dir.mktmpdir`. Returns a `Result` struct with all output paths.
90
+
91
+ 6. **Installer is decoupled from Rails**: `StatesmanScaffold::Installer` takes a
92
+ `Pathname` root and never references `Rails.root`, making it unit-testable
93
+ with a `Dir.mktmpdir`.
94
+
95
+ 7. **The generated initializer** does three things:
96
+ - `require "statesman"` (ensures the gem is loaded before configuring)
97
+ - `Statesman.configure { storage_adapter(Statesman::Adapters::ActiveRecord) }`
98
+ - `ActiveSupport.on_load(:active_record) { include StatesmanScaffold::Concern }`
99
+
100
+ 8. **Templates use ERB** with `trim_mode: "-"` and `result_with_hash` for clean
101
+ rendering without binding objects.
102
+
103
+ ## When adding features
104
+
105
+ - State machine wiring logic belongs in `concern.rb`.
106
+ - New public class-level DSL methods belong inside `class_methods do`.
107
+ - Template changes go in `lib/templates/statesman/`.
108
+ - Update `sig/statesman_scaffold.rbs` when adding or changing public method signatures.
109
+ - Write Minitest tests in `test/statesman_scaffold/` before implementing (TDD).
110
+ - Run `bundle exec rake` before committing -- must be fully green.
111
+
112
+ ## Dependency notes
113
+
114
+ - `statesman` provides `Statesman::Machine`, `Statesman::Adapters::ActiveRecord`,
115
+ and `Statesman::Adapters::ActiveRecordQueries`.
116
+ - `activerecord` provides `has_many`, `ActiveRecord::Base`, and migrations.
117
+ - `activesupport` provides `ActiveSupport::Concern`, `String#camelize`,
118
+ `String#underscore`, `String#demodulize`, `String#deconstantize`.
119
+ - `railties` provides `Rails::Railtie` for rake task integration.
120
+
121
+ ## Out of scope (do not add unless explicitly requested)
122
+
123
+ - Custom transition metadata helpers.
124
+ - State machine visualization or diagram generation.
125
+ - Support for non-ActiveRecord ORMs.
126
+ - Automatic state column management (users define their own status columns).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Pawel Niemczyk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # StatesmanScaffold
2
+
3
+ Scaffold [Statesman](https://github.com/gocardless/statesman) state machines for ActiveRecord models. Generates StateMachine, Transition classes, and migrations with a single rake task, plus a `with_state_machine` concern that wires up delegation, query scopes, and transition associations.
4
+
5
+ ## Features
6
+
7
+ * One rake task generates StateMachine class, Transition model, and migration
8
+ * `with_state_machine` class macro wires up Statesman with a single line
9
+ * Delegates `current_state`, `transition_to!`, `transition_to`, `can_transition_to?` to the state machine
10
+ * Adds `in_state?` and `not_in_state?` convenience predicates
11
+ * Includes `Statesman::Adapters::ActiveRecordQueries` for query scopes
12
+ * Supports namespaced models (e.g. `Admin::Order`)
13
+ * Rails installer that configures Statesman adapter and auto-includes the concern
14
+
15
+ ## Installation
16
+
17
+ Add to your application's `Gemfile`:
18
+
19
+ ```ruby
20
+ gem "statesman_scaffold"
21
+ ```
22
+
23
+ Then run:
24
+
25
+ ```bash
26
+ bundle install
27
+ rails statesman_scaffold:install
28
+ ```
29
+
30
+ The installer creates `config/initializers/statesman_scaffold.rb` which configures Statesman to use the ActiveRecord adapter and auto-includes `StatesmanScaffold::Concern` into every ActiveRecord model.
31
+
32
+ ## Usage
33
+
34
+ ### Generate state machine files
35
+
36
+ ```bash
37
+ rails "statesman_scaffold:generate[Project]"
38
+ ```
39
+
40
+ This creates three files:
41
+
42
+ * `app/models/project/state_machine.rb` – the Statesman state machine class
43
+ * `app/models/project/transition.rb` – the transition model
44
+ * `db/migrate/TIMESTAMP_create_project_transitions.rb` – the migration
45
+
46
+ ### Configure your model
47
+
48
+ ```ruby
49
+ class Project < ApplicationRecord
50
+ STATUSES = Project::StateMachine.states
51
+
52
+ with_state_machine
53
+
54
+ attribute :status, :string, default: Project::StateMachine.initial_state
55
+ validates :status, inclusion: { in: STATUSES }, allow_nil: true
56
+ end
57
+ ```
58
+
59
+ ### Define states and transitions
60
+
61
+ Edit the generated `app/models/project/state_machine.rb`:
62
+
63
+ ```ruby
64
+ class Project::StateMachine
65
+ include Statesman::Machine
66
+
67
+ state :active, initial: true
68
+ state :pending
69
+ state :skipped
70
+ state :cancelled
71
+ state :done
72
+
73
+ transition from: :active, to: [:pending, :skipped, :cancelled, :done]
74
+ transition from: :pending, to: [:skipped, :cancelled, :done]
75
+ transition from: :skipped, to: [:pending]
76
+
77
+ after_transition do |model, transition|
78
+ model.update!(status: transition.to_state)
79
+ end
80
+ end
81
+ ```
82
+
83
+ ### Use the state machine
84
+
85
+ ```ruby
86
+ project = Project.create!(name: "Demo")
87
+
88
+ project.current_state # => "active"
89
+ project.in_state?(:active) # => true
90
+ project.not_in_state?(:done) # => true
91
+
92
+ project.can_transition_to?(:pending) # => true
93
+ project.transition_to!(:pending)
94
+ project.current_state # => "pending"
95
+ project.status # => "pending"
96
+
97
+ # Non-bang version returns true/false instead of raising
98
+ project.transition_to(:done) # => true
99
+
100
+ # Query scopes (via Statesman::Adapters::ActiveRecordQueries)
101
+ Project.in_state(:active)
102
+ Project.not_in_state(:cancelled)
103
+ ```
104
+
105
+ ### Namespaced models
106
+
107
+ ```bash
108
+ rails "statesman_scaffold:generate[Admin::Order]"
109
+ ```
110
+
111
+ This generates `Admin::Order::StateMachine`, `Admin::Order::Transition`, and the corresponding migration with proper table names and foreign keys.
112
+
113
+ ## Rake tasks
114
+
115
+ ```bash
116
+ rails statesman_scaffold:install # create config/initializers/statesman_scaffold.rb
117
+ rails statesman_scaffold:uninstall # remove config/initializers/statesman_scaffold.rb
118
+ rails "statesman_scaffold:generate[ModelName]" # generate state machine files
119
+ ```
120
+
121
+ ## Manual include (without the Rails initializer)
122
+
123
+ ```ruby
124
+ class Project < ApplicationRecord
125
+ include StatesmanScaffold::Concern
126
+ with_state_machine
127
+ end
128
+ ```
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ bin/setup # install dependencies
134
+ bundle exec rake # run tests
135
+ bin/console # interactive prompt
136
+ ```
137
+
138
+ ## Contributing
139
+
140
+ Bug reports and pull requests are welcome on GitHub at https://github.com/pniemczyk/statesman_scaffold.
141
+
142
+ ## License
143
+
144
+ MIT -- see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module StatesmanScaffold
6
+ # ActiveSupport::Concern that provides the +with_state_machine+ class macro
7
+ # for wiring up Statesman state machines to ActiveRecord models.
8
+ #
9
+ # @example
10
+ # class Project < ApplicationRecord
11
+ # include StatesmanScaffold::Concern
12
+ # with_state_machine
13
+ # end
14
+ #
15
+ # project = Project.create!(name: "Demo")
16
+ # project.current_state # => "pending"
17
+ # project.can_transition_to?(:active) # => true/false
18
+ # project.transition_to!(:active)
19
+ # project.in_state?(:active) # => true
20
+ # project.not_in_state?(:pending) # => true
21
+ module Concern
22
+ extend ActiveSupport::Concern
23
+
24
+ included do
25
+ # Check if the model is currently in any of the given states.
26
+ #
27
+ # @param states [Array<String, Symbol>] one or more state names
28
+ # @return [Boolean]
29
+ def in_state?(*states)
30
+ states.any? { |s| current_state.to_sym == s.to_sym }
31
+ end
32
+
33
+ # Check if the model is NOT in the given state.
34
+ #
35
+ # @param state [String, Symbol] a state name
36
+ # @return [Boolean]
37
+ def not_in_state?(state)
38
+ !in_state?(state)
39
+ end
40
+
41
+ delegate :can_transition_to?, :transition_to!, :transition_to, :current_state, to: :state_machine
42
+ end
43
+
44
+ class_methods do
45
+ # Wires up the Statesman state machine for this model.
46
+ #
47
+ # Expects nested +StateMachine+ and +Transition+ classes to exist under
48
+ # the model's namespace (e.g. +Project::StateMachine+, +Project::Transition+).
49
+ #
50
+ # Use the +statesman_scaffold:generate+ rake task to create these classes
51
+ # and the corresponding migration.
52
+ #
53
+ # @param transition_suffix [String] suffix for the transition class (default: "Transition")
54
+ # @param state_machine_suffix [String] suffix for the state machine class (default: "StateMachine")
55
+ def with_state_machine(transition_suffix: "Transition", state_machine_suffix: "StateMachine")
56
+ model_name = name
57
+ model_namespace = model_name.deconstantize
58
+ model_base = model_name.demodulize
59
+
60
+ transition_class_name = [model_namespace.presence, model_base, transition_suffix].compact.join("::")
61
+ state_machine_class_name = [model_namespace.presence, model_base, state_machine_suffix].compact.join("::")
62
+
63
+ has_many :transitions,
64
+ autosave: false,
65
+ class_name: transition_class_name,
66
+ dependent: :destroy
67
+
68
+ include ::Statesman::Adapters::ActiveRecordQueries[
69
+ transition_class: transition_class_name.constantize,
70
+ initial_state: state_machine_class_name.constantize.initial_state
71
+ ]
72
+
73
+ define_method(:state_machine) do
74
+ instance_variable_get(:@state_machine) ||
75
+ instance_variable_set(
76
+ :@state_machine,
77
+ state_machine_class_name.constantize.new(
78
+ self,
79
+ transition_class: transition_class_name.constantize,
80
+ association_name: :transitions
81
+ )
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "pathname"
5
+
6
+ module StatesmanScaffold
7
+ # Generates StateMachine, Transition class files, and a migration from ERB
8
+ # templates. This class is intentionally decoupled from Rails so it can be
9
+ # tested without booting a full Rails application.
10
+ #
11
+ # @example
12
+ # StatesmanScaffold::Generator.call(
13
+ # model: "Project",
14
+ # output_path: Pathname.new("app/models/project"),
15
+ # migration_path: Pathname.new("db/migrate"),
16
+ # migration_version: "8.0"
17
+ # )
18
+ class Generator
19
+ TEMPLATE_DIR = Pathname.new(__dir__).join("..", "templates", "statesman").expand_path
20
+
21
+ Result = Struct.new(:state_machine_path, :transition_path, :migration_path, keyword_init: true) # rubocop:disable Style/RedundantStructKeywordInit
22
+
23
+ class << self
24
+ # Generate all three files for the given model.
25
+ #
26
+ # @param model [String] PascalCase model name (e.g. "Project", "Admin::Order")
27
+ # @param output_path [Pathname, String] directory for model files
28
+ # @param migration_path [Pathname, String] directory for migration file
29
+ # @param migration_version [String] ActiveRecord migration version (e.g. "8.0")
30
+ # @param timestamp [String, nil] migration timestamp (default: current UTC)
31
+ # @return [Result] paths of created files
32
+ def call(model:, output_path:, migration_path:, migration_version:, timestamp: nil)
33
+ model = model.to_s.camelize
34
+ class_name = model
35
+ output_path = Pathname(output_path)
36
+ migration_path = Pathname(migration_path)
37
+ timestamp ||= Time.now.utc.strftime("%Y%m%d%H%M%S")
38
+
39
+ output_path.mkpath
40
+ migration_path.mkpath
41
+
42
+ sm_path = render_template(
43
+ "state_machine.rb.erb",
44
+ output_path.join("state_machine.rb"),
45
+ model: model, class_name: class_name
46
+ )
47
+
48
+ tr_path = render_template(
49
+ "transition.rb.erb",
50
+ output_path.join("transition.rb"),
51
+ model: model, class_name: class_name
52
+ )
53
+
54
+ file_path = model.underscore.tr("/", "_")
55
+ mig_file = "#{timestamp}_create_#{file_path}_transitions.rb"
56
+ mig_path = render_template(
57
+ "migration.rb.erb",
58
+ migration_path.join(mig_file),
59
+ model: model, class_name: class_name, version: migration_version
60
+ )
61
+
62
+ Result.new(
63
+ state_machine_path: sm_path,
64
+ transition_path: tr_path,
65
+ migration_path: mig_path
66
+ )
67
+ end
68
+
69
+ private
70
+
71
+ def render_template(template_name, output_file, **locals)
72
+ template_path = TEMPLATE_DIR.join(template_name)
73
+ erb = ERB.new(template_path.read, trim_mode: "-")
74
+ content = erb.result_with_hash(locals)
75
+ output_file.write(content)
76
+ output_file
77
+ end
78
+ end
79
+ end
80
+ end