circulator 2.0.0 → 2.0.1
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 +4 -4
- data/CHANGELOG.md +7 -3
- data/README.md +12 -0
- data/Rakefile +9 -2
- data/exe/circulator-diagram +118 -0
- data/lib/circulator/dot.rb +92 -0
- data/lib/circulator/plantuml.rb +88 -0
- data/lib/circulator/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 42195ffd537b18be888f0bbab239d61d9ff19a2ff91c50adf0d6e51bea4119b0
|
4
|
+
data.tar.gz: ec684979c91e8a5bba4538e6a0c0546d1b23c2746d83b815cc9f43f5cb01fd83
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: eb5064f15bb2d4756139ae66b8a229d35afd9e3df5dd09ae0f8875c522661ffa82c7d0773d6dd088becca25c42f17da350fee347723357d21bd6b67037aa256d
|
7
|
+
data.tar.gz: 1d800dc29d73a0e5f8e88821abba4ec9b3e4d48e109362b5bbd15550a0af4a7e40e2c7a00346c64ac8eebe6d8caf327c57129ea1ae3d09de2129922c235adc0a
|
data/CHANGELOG.md
CHANGED
@@ -5,8 +5,12 @@ All notable changes to this project will be documented in this file.
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
7
|
|
8
|
-
## [2.0.
|
8
|
+
## [2.0.1] - 2025-10-01
|
9
9
|
|
10
|
-
###
|
10
|
+
### Changed
|
11
11
|
|
12
|
-
-
|
12
|
+
- Switched to a rake test task to set the test order
|
13
|
+
|
14
|
+
### Added
|
15
|
+
|
16
|
+
- Ability to generate diagrams from declared flows in Dot or PlantUML
|
data/README.md
CHANGED
@@ -168,6 +168,18 @@ class Payment
|
|
168
168
|
end
|
169
169
|
```
|
170
170
|
|
171
|
+
### Generating Diagrams
|
172
|
+
|
173
|
+
You can generate diagrams for your Circulator models using the `circulator-diagram` executable. By default, it will generate a DOT file. You can also generate a PlantUML file by passing the `-f plantuml` option.
|
174
|
+
|
175
|
+
```bash
|
176
|
+
bundle exec circulator-diagram MODEL_NAME
|
177
|
+
```
|
178
|
+
|
179
|
+
```bash
|
180
|
+
bundle exec circulator-diagram MODEL_NAME -f plantuml
|
181
|
+
```
|
182
|
+
|
171
183
|
## Why Circulator?
|
172
184
|
|
173
185
|
Circulator distinguishes itself from other Ruby state machine libraries through its simplicity and flexibility:
|
data/Rakefile
CHANGED
@@ -1,9 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "bundler/gem_tasks"
|
4
|
-
require "
|
4
|
+
require "rake/testtask"
|
5
5
|
|
6
|
-
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
7
|
+
t.libs << "test"
|
8
|
+
t.libs << "lib"
|
9
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
10
|
+
t.verbose = true
|
11
|
+
t.warning = true
|
12
|
+
end
|
7
13
|
|
8
14
|
require "standard/rake"
|
9
15
|
|
@@ -15,4 +21,5 @@ Reissue::Task.create :reissue do |task|
|
|
15
21
|
task.version_file = "lib/circulator/version.rb"
|
16
22
|
task.changelog_file = "CHANGELOG.md"
|
17
23
|
task.version_limit = 1
|
24
|
+
task.fragment = :git
|
18
25
|
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "optparse"
|
5
|
+
require "fileutils"
|
6
|
+
require_relative "../lib/circulator"
|
7
|
+
require_relative "../lib/circulator/dot"
|
8
|
+
require_relative "../lib/circulator/plantuml"
|
9
|
+
|
10
|
+
# Parse command-line options
|
11
|
+
options = {format: "dot"}
|
12
|
+
parser = OptionParser.new do |opts|
|
13
|
+
opts.banner = "Usage: circulator-diagram MODEL_NAME [options]"
|
14
|
+
opts.separator ""
|
15
|
+
opts.separator "Generate diagram files for Circulator state machine flows"
|
16
|
+
opts.separator ""
|
17
|
+
opts.separator "Arguments:"
|
18
|
+
opts.separator " MODEL_NAME Name of the model class with Circulator flows"
|
19
|
+
opts.separator ""
|
20
|
+
opts.separator "Options:"
|
21
|
+
|
22
|
+
opts.on("-f", "--format FORMAT", ["dot", "plantuml"], "Output format (dot, plantuml). Default: dot") do |format|
|
23
|
+
options[:format] = format
|
24
|
+
end
|
25
|
+
|
26
|
+
opts.on("-h", "--help", "Show this help message") do
|
27
|
+
puts opts
|
28
|
+
exit 0
|
29
|
+
end
|
30
|
+
|
31
|
+
opts.on("-v", "--version", "Show version") do
|
32
|
+
puts "circulator-diagram #{Circulator::VERSION}"
|
33
|
+
exit 0
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
begin
|
38
|
+
parser.parse!
|
39
|
+
rescue OptionParser::InvalidOption => e
|
40
|
+
warn "Error: #{e.message}"
|
41
|
+
warn parser
|
42
|
+
exit 1
|
43
|
+
end
|
44
|
+
|
45
|
+
# Check for required model name argument
|
46
|
+
if ARGV.empty?
|
47
|
+
warn "Error: MODEL_NAME is required"
|
48
|
+
warn ""
|
49
|
+
warn parser
|
50
|
+
exit 1
|
51
|
+
end
|
52
|
+
|
53
|
+
model_name = ARGV[0]
|
54
|
+
|
55
|
+
# Try to constantize the model name
|
56
|
+
begin
|
57
|
+
model_class = Object.const_get(model_name)
|
58
|
+
rescue NameError
|
59
|
+
warn "Error: Model '#{model_name}' not found"
|
60
|
+
warn "Make sure the model is loaded in your environment"
|
61
|
+
exit 1
|
62
|
+
end
|
63
|
+
|
64
|
+
# Generate diagram file
|
65
|
+
begin
|
66
|
+
generator = case options[:format]
|
67
|
+
when "plantuml"
|
68
|
+
Circulator::PlantUml.new(model_class)
|
69
|
+
else
|
70
|
+
Circulator::Dot.new(model_class)
|
71
|
+
end
|
72
|
+
|
73
|
+
content = generator.generate
|
74
|
+
|
75
|
+
# Determine output filename and extension
|
76
|
+
# Convert namespaced class names to directory paths
|
77
|
+
# Something::Other becomes something/other
|
78
|
+
class_name = model_class.name || "diagram"
|
79
|
+
path_parts = class_name.split("::").map { |part|
|
80
|
+
part.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
81
|
+
}
|
82
|
+
base_name = path_parts.join("/")
|
83
|
+
|
84
|
+
if options[:format] == "plantuml"
|
85
|
+
# Use model class name for PlantUML files
|
86
|
+
output_file = "#{base_name}.puml"
|
87
|
+
|
88
|
+
# Create directory if needed
|
89
|
+
dir = File.dirname(output_file)
|
90
|
+
FileUtils.mkdir_p(dir) unless dir == "." || File.exist?(dir)
|
91
|
+
|
92
|
+
File.write(output_file, content)
|
93
|
+
puts "Generated PlantUML file: #{output_file}"
|
94
|
+
puts "To create an image, run:"
|
95
|
+
puts " plantuml #{output_file}"
|
96
|
+
else
|
97
|
+
# Use model class name for DOT files
|
98
|
+
output_file = "#{base_name}.dot"
|
99
|
+
|
100
|
+
# Create directory if needed
|
101
|
+
dir = File.dirname(output_file)
|
102
|
+
FileUtils.mkdir_p(dir) unless dir == "." || File.exist?(dir)
|
103
|
+
|
104
|
+
File.write(output_file, content)
|
105
|
+
puts "Generated DOT file: #{output_file}"
|
106
|
+
puts "To create an image, run:"
|
107
|
+
puts " dot -Tpng #{output_file} -o #{base_name}.png"
|
108
|
+
end
|
109
|
+
|
110
|
+
exit 0
|
111
|
+
rescue ArgumentError => e
|
112
|
+
warn "Error: #{e.message}"
|
113
|
+
exit 1
|
114
|
+
rescue => e
|
115
|
+
warn "Error: #{e.class} - #{e.message}"
|
116
|
+
warn e.backtrace.first(5).join("\n") if ENV["DEBUG"]
|
117
|
+
exit 1
|
118
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Circulator
|
4
|
+
class Dot
|
5
|
+
def initialize(model_class)
|
6
|
+
unless model_class.respond_to?(:flows)
|
7
|
+
raise ArgumentError, "Model class must extend Circulator"
|
8
|
+
end
|
9
|
+
|
10
|
+
flows = model_class.flows
|
11
|
+
if flows.nil? || flows.empty?
|
12
|
+
raise ArgumentError, "Model class has no flows defined"
|
13
|
+
end
|
14
|
+
|
15
|
+
@model_class = model_class
|
16
|
+
@flows = flows
|
17
|
+
end
|
18
|
+
|
19
|
+
def generate
|
20
|
+
output = []
|
21
|
+
output << "digraph #{graph_name} {"
|
22
|
+
output << " rankdir=LR;"
|
23
|
+
output << ""
|
24
|
+
|
25
|
+
# Collect all states and transitions
|
26
|
+
states = Set.new
|
27
|
+
transitions = []
|
28
|
+
|
29
|
+
@flows.each do |model_key, attribute_flows|
|
30
|
+
attribute_flows.each do |attribute_name, flow|
|
31
|
+
# Extract states and transitions from the flow
|
32
|
+
flow.transition_map.each do |action, state_transitions|
|
33
|
+
state_transitions.each do |from_state, transition_info|
|
34
|
+
states.add(from_state)
|
35
|
+
|
36
|
+
to_state = transition_info[:to]
|
37
|
+
if to_state.respond_to?(:call)
|
38
|
+
# Dynamic state - use ? as placeholder
|
39
|
+
states.add(:"?")
|
40
|
+
label = "#{action} (dynamic)"
|
41
|
+
transitions << {from: from_state, to: :"?", label: label}
|
42
|
+
else
|
43
|
+
states.add(to_state)
|
44
|
+
label = action.to_s
|
45
|
+
label += " (conditional)" if transition_info[:allow_if]
|
46
|
+
transitions << {from: from_state, to: to_state, label: label}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Output state nodes
|
54
|
+
output << " // States"
|
55
|
+
states.sort_by { |s| s.to_s }.each do |state|
|
56
|
+
state_label = state.nil? ? "nil" : state.to_s
|
57
|
+
output << " #{state_label} [shape=circle];"
|
58
|
+
end
|
59
|
+
|
60
|
+
output << ""
|
61
|
+
output << " // Transitions"
|
62
|
+
|
63
|
+
# Output transition edges
|
64
|
+
transitions.sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
|
65
|
+
from_label = transition[:from].nil? ? "nil" : transition[:from].to_s
|
66
|
+
to_label = transition[:to].nil? ? "nil" : transition[:to].to_s
|
67
|
+
output << " #{from_label} -> #{to_label} [label=\"#{transition[:label]}\"];"
|
68
|
+
end
|
69
|
+
|
70
|
+
output << "}"
|
71
|
+
output.join("\n") + "\n"
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def graph_name
|
77
|
+
# Use the model class name if available, otherwise use the model key
|
78
|
+
class_name = @model_class.name
|
79
|
+
model_key = @flows.keys.first
|
80
|
+
|
81
|
+
# If class has no name, use the model_key which will be like "anonymous_XXX"
|
82
|
+
# If model_key differs from class_name, it's a model-based flow, use model_key
|
83
|
+
if class_name.nil?
|
84
|
+
"#{model_key}_flows"
|
85
|
+
elsif model_key && model_key != class_name
|
86
|
+
"#{model_key}_flows"
|
87
|
+
else
|
88
|
+
"#{class_name}_flows"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Circulator
|
4
|
+
class PlantUml
|
5
|
+
def initialize(model_class)
|
6
|
+
unless model_class.respond_to?(:flows)
|
7
|
+
raise ArgumentError, "Model class must extend Circulator"
|
8
|
+
end
|
9
|
+
|
10
|
+
flows = model_class.flows
|
11
|
+
if flows.nil? || flows.empty?
|
12
|
+
raise ArgumentError, "Model class has no flows defined"
|
13
|
+
end
|
14
|
+
|
15
|
+
@model_class = model_class
|
16
|
+
@flows = flows
|
17
|
+
end
|
18
|
+
|
19
|
+
def generate
|
20
|
+
output = []
|
21
|
+
output << "@startuml"
|
22
|
+
output << ""
|
23
|
+
|
24
|
+
# Collect all states and transitions
|
25
|
+
states = Set.new
|
26
|
+
transitions = []
|
27
|
+
|
28
|
+
@flows.each do |model_key, attribute_flows|
|
29
|
+
attribute_flows.each do |attribute_name, flow|
|
30
|
+
# Extract states and transitions from the flow
|
31
|
+
flow.transition_map.each do |action, state_transitions|
|
32
|
+
state_transitions.each do |from_state, transition_info|
|
33
|
+
states.add(from_state)
|
34
|
+
|
35
|
+
to_state = transition_info[:to]
|
36
|
+
if to_state.respond_to?(:call)
|
37
|
+
# Dynamic state - use [*] as placeholder (end state)
|
38
|
+
label = action.to_s
|
39
|
+
transitions << {
|
40
|
+
from: from_state,
|
41
|
+
to: nil,
|
42
|
+
label: label,
|
43
|
+
note: "dynamic target state"
|
44
|
+
}
|
45
|
+
else
|
46
|
+
states.add(to_state)
|
47
|
+
label = action.to_s
|
48
|
+
note = nil
|
49
|
+
note = "conditional transition" if transition_info[:allow_if]
|
50
|
+
transitions << {
|
51
|
+
from: from_state,
|
52
|
+
to: to_state,
|
53
|
+
label: label,
|
54
|
+
note: note
|
55
|
+
}
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Output state declarations (except nil which is represented as [*])
|
63
|
+
states.reject(&:nil?).sort_by(&:to_s).each do |state|
|
64
|
+
output << "state #{state}"
|
65
|
+
end
|
66
|
+
|
67
|
+
output << ""
|
68
|
+
|
69
|
+
# Output transitions
|
70
|
+
transitions.sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
|
71
|
+
from_label = transition[:from].nil? ? "[*]" : transition[:from].to_s
|
72
|
+
to_label = transition[:to].nil? ? "[*]" : transition[:to].to_s
|
73
|
+
output << "#{from_label} --> #{to_label} : #{transition[:label]}"
|
74
|
+
|
75
|
+
# Add note if present
|
76
|
+
if transition[:note]
|
77
|
+
output << "note on link"
|
78
|
+
output << " #{transition[:note]}"
|
79
|
+
output << "end note"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
output << ""
|
84
|
+
output << "@enduml"
|
85
|
+
output.join("\n") + "\n"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
data/lib/circulator/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: circulator
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.0.
|
4
|
+
version: 2.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jim Gay
|
@@ -12,15 +12,19 @@ dependencies: []
|
|
12
12
|
description: Simple declarative state machine
|
13
13
|
email:
|
14
14
|
- jim@saturnflyer.com
|
15
|
-
executables:
|
15
|
+
executables:
|
16
|
+
- circulator-diagram
|
16
17
|
extensions: []
|
17
18
|
extra_rdoc_files: []
|
18
19
|
files:
|
19
20
|
- CHANGELOG.md
|
20
21
|
- README.md
|
21
22
|
- Rakefile
|
23
|
+
- exe/circulator-diagram
|
22
24
|
- lib/circulator.rb
|
25
|
+
- lib/circulator/dot.rb
|
23
26
|
- lib/circulator/flow.rb
|
27
|
+
- lib/circulator/plantuml.rb
|
24
28
|
- lib/circulator/version.rb
|
25
29
|
homepage: https://github.com/SOFware/circulator
|
26
30
|
licenses: []
|