circulator 2.0.0 → 2.0.2
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 +137 -0
- data/lib/circulator/diagram.rb +97 -0
- data/lib/circulator/dot.rb +71 -0
- data/lib/circulator/plantuml.rb +70 -0
- data/lib/circulator/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 20f38e7ccf237cac5499afb5723d5d8f8f4ffe26b3ecbe7e05ee71ad05366438
|
4
|
+
data.tar.gz: e49a9f22259fea102bc6e99d5545590ae4fa40689cd2679318bababba8a5a075
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6e59f461501d8bd236755dcc30eaa277372e14aca6a61236545ab5df4334f47668fe4878ee4a418bedc539c16891f1fc6cec3f54d88e19ecd9c766146e4b796e
|
7
|
+
data.tar.gz: 4e98a06987e4d08f44cc3cf81ea5544c219bff24a5f08990bac6b2c5c93193a47ce2d9a20aaecb4cdb28fa24cce06420093966b0be49c60f19fe8302198097ec
|
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.2] - 2025-10-02
|
9
9
|
|
10
|
-
###
|
10
|
+
### Added
|
11
11
|
|
12
|
-
-
|
12
|
+
- Loading of required files with -r command option for circulator-diagram.
|
13
|
+
|
14
|
+
### Changed
|
15
|
+
|
16
|
+
- Implement diagrams through a standard base class to coordinate implementation.
|
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,137 @@
|
|
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", require: nil}
|
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("-r", "--require FILE", "Require a file before loading the model (e.g., config/environment)") do |file|
|
27
|
+
options[:require] = file
|
28
|
+
end
|
29
|
+
|
30
|
+
opts.on("-h", "--help", "Show this help message") do
|
31
|
+
puts opts
|
32
|
+
exit 0
|
33
|
+
end
|
34
|
+
|
35
|
+
opts.on("-v", "--version", "Show version") do
|
36
|
+
puts "circulator-diagram #{Circulator::VERSION}"
|
37
|
+
exit 0
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
begin
|
42
|
+
parser.parse!
|
43
|
+
rescue OptionParser::InvalidOption => e
|
44
|
+
warn "Error: #{e.message}"
|
45
|
+
warn parser
|
46
|
+
exit 1
|
47
|
+
end
|
48
|
+
|
49
|
+
# Check for required model name argument
|
50
|
+
if ARGV.empty?
|
51
|
+
warn "Error: MODEL_NAME is required"
|
52
|
+
warn ""
|
53
|
+
warn parser
|
54
|
+
exit 1
|
55
|
+
end
|
56
|
+
|
57
|
+
model_name = ARGV[0]
|
58
|
+
|
59
|
+
# Load the application environment
|
60
|
+
# Priority: -r option > config/environment.rb > nothing
|
61
|
+
if options[:require]
|
62
|
+
# User specified a file to require
|
63
|
+
require_file = options[:require]
|
64
|
+
unless File.exist?(require_file)
|
65
|
+
warn "Error: Required file '#{require_file}' not found"
|
66
|
+
exit 1
|
67
|
+
end
|
68
|
+
require require_file
|
69
|
+
elsif File.exist?("config/environment.rb")
|
70
|
+
# Rails application detected, load the environment
|
71
|
+
require File.expand_path("config/environment.rb")
|
72
|
+
end
|
73
|
+
|
74
|
+
# Try to constantize the model name
|
75
|
+
begin
|
76
|
+
model_class = Object.const_get(model_name)
|
77
|
+
rescue NameError
|
78
|
+
warn "Error: Model '#{model_name}' not found"
|
79
|
+
warn "Make sure the model is loaded in your environment"
|
80
|
+
exit 1
|
81
|
+
end
|
82
|
+
|
83
|
+
# Generate diagram file
|
84
|
+
begin
|
85
|
+
generator = case options[:format]
|
86
|
+
when "plantuml"
|
87
|
+
Circulator::PlantUml.new(model_class)
|
88
|
+
else
|
89
|
+
Circulator::Dot.new(model_class)
|
90
|
+
end
|
91
|
+
|
92
|
+
content = generator.generate
|
93
|
+
|
94
|
+
# Determine output filename and extension
|
95
|
+
# Convert namespaced class names to directory paths
|
96
|
+
# Something::Other becomes something/other
|
97
|
+
class_name = model_class.name || "diagram"
|
98
|
+
path_parts = class_name.split("::").map { |part|
|
99
|
+
part.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
100
|
+
}
|
101
|
+
base_name = path_parts.join("/")
|
102
|
+
|
103
|
+
if options[:format] == "plantuml"
|
104
|
+
# Use model class name for PlantUML files
|
105
|
+
output_file = "#{base_name}.puml"
|
106
|
+
|
107
|
+
# Create directory if needed
|
108
|
+
dir = File.dirname(output_file)
|
109
|
+
FileUtils.mkdir_p(dir) unless dir == "." || File.exist?(dir)
|
110
|
+
|
111
|
+
File.write(output_file, content)
|
112
|
+
puts "Generated PlantUML file: #{output_file}"
|
113
|
+
puts "To create an image, run:"
|
114
|
+
puts " plantuml #{output_file}"
|
115
|
+
else
|
116
|
+
# Use model class name for DOT files
|
117
|
+
output_file = "#{base_name}.dot"
|
118
|
+
|
119
|
+
# Create directory if needed
|
120
|
+
dir = File.dirname(output_file)
|
121
|
+
FileUtils.mkdir_p(dir) unless dir == "." || File.exist?(dir)
|
122
|
+
|
123
|
+
File.write(output_file, content)
|
124
|
+
puts "Generated DOT file: #{output_file}"
|
125
|
+
puts "To create an image, run:"
|
126
|
+
puts " dot -Tpng #{output_file} -o #{base_name}.png"
|
127
|
+
end
|
128
|
+
|
129
|
+
exit 0
|
130
|
+
rescue ArgumentError => e
|
131
|
+
warn "Error: #{e.message}"
|
132
|
+
exit 1
|
133
|
+
rescue => e
|
134
|
+
warn "Error: #{e.class} - #{e.message}"
|
135
|
+
warn e.backtrace.first(5).join("\n") if ENV["DEBUG"]
|
136
|
+
exit 1
|
137
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Circulator
|
4
|
+
class Diagram
|
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 << header
|
22
|
+
|
23
|
+
# Collect all states and transitions
|
24
|
+
states = Set.new
|
25
|
+
transitions = []
|
26
|
+
|
27
|
+
@flows.each do |model_key, attribute_flows|
|
28
|
+
attribute_flows.each do |attribute_name, flow|
|
29
|
+
# Extract states and transitions from the flow
|
30
|
+
flow.transition_map.each do |action, state_transitions|
|
31
|
+
state_transitions.each do |from_state, transition_info|
|
32
|
+
states.add(from_state)
|
33
|
+
|
34
|
+
to_state = transition_info[:to]
|
35
|
+
if to_state.respond_to?(:call)
|
36
|
+
states.add(:"?")
|
37
|
+
transitions << dynamic_transition(action, from_state, :"?")
|
38
|
+
else
|
39
|
+
states.add(to_state)
|
40
|
+
transitions << standard_transition(action, from_state, to_state, conditional: transition_info[:allow_if])
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Output state nodes
|
48
|
+
states_output(states, output)
|
49
|
+
|
50
|
+
# Output transition edges
|
51
|
+
transitions_output(transitions, output)
|
52
|
+
|
53
|
+
output << footer
|
54
|
+
output.join("\n") + "\n"
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def graph_name
|
60
|
+
# Use the model class name if available, otherwise use the model key
|
61
|
+
class_name = @model_class.name
|
62
|
+
model_key = @flows.keys.first
|
63
|
+
|
64
|
+
# If class has no name or model_key differs from class_name (model-based flow),
|
65
|
+
# use model_key, otherwise use class_name
|
66
|
+
if class_name.nil? || (model_key && model_key != class_name)
|
67
|
+
"#{model_key} flows"
|
68
|
+
else
|
69
|
+
"#{class_name} flows"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def header
|
74
|
+
raise NotImplementedError, "Subclasses must implement #{__method__}"
|
75
|
+
end
|
76
|
+
|
77
|
+
def footer
|
78
|
+
raise NotImplementedError, "Subclasses must implement #{__method__}"
|
79
|
+
end
|
80
|
+
|
81
|
+
def transitions_output(transitions, output)
|
82
|
+
raise NotImplementedError, "Subclasses must implement #{__method__}"
|
83
|
+
end
|
84
|
+
|
85
|
+
def states_output(states, output)
|
86
|
+
raise NotImplementedError, "Subclasses must implement #{__method__}"
|
87
|
+
end
|
88
|
+
|
89
|
+
def standard_transition(action, from_state, to_state, conditional: nil)
|
90
|
+
raise NotImplementedError, "Subclasses must implement #{__method__}"
|
91
|
+
end
|
92
|
+
|
93
|
+
def dynamic_transition(action, from_state, to_state = nil)
|
94
|
+
raise NotImplementedError, "Subclasses must implement #{__method__}"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "diagram"
|
4
|
+
|
5
|
+
module Circulator
|
6
|
+
class Dot < Diagram
|
7
|
+
private
|
8
|
+
|
9
|
+
# def graph_name
|
10
|
+
# # Use the model class name if available, otherwise use the model key
|
11
|
+
# class_name = @model_class.name
|
12
|
+
# model_key = @flows.keys.first
|
13
|
+
|
14
|
+
# # If class has no name or model_key differs from class_name (model-based flow),
|
15
|
+
# # use model_key, otherwise use class_name
|
16
|
+
# if class_name.nil? || (model_key && model_key != class_name)
|
17
|
+
# "#{model_key} flows"
|
18
|
+
# else
|
19
|
+
# "#{class_name} flows"
|
20
|
+
# end
|
21
|
+
# end
|
22
|
+
|
23
|
+
def states_output(states, output)
|
24
|
+
output << " // States"
|
25
|
+
states.sort_by { |s| s.to_s }.each do |state|
|
26
|
+
state_label = state.nil? ? "nil" : state.to_s
|
27
|
+
output << " #{state_label} [shape=circle];"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def transitions_output(transitions, output)
|
32
|
+
output << ""
|
33
|
+
output << " // Transitions"
|
34
|
+
transitions.sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
|
35
|
+
from_label = transition[:from].nil? ? "nil" : transition[:from].to_s
|
36
|
+
to_label = transition[:to].nil? ? "nil" : transition[:to].to_s
|
37
|
+
output << " #{from_label} -> #{to_label} [label=\"#{transition[:label]}\"];"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def header
|
42
|
+
<<~DOT
|
43
|
+
digraph "#{graph_name}" {
|
44
|
+
rankdir=LR;
|
45
|
+
DOT
|
46
|
+
end
|
47
|
+
|
48
|
+
def footer
|
49
|
+
"}"
|
50
|
+
end
|
51
|
+
|
52
|
+
def dynamic_transition(action, from_state, to_state = nil)
|
53
|
+
{
|
54
|
+
from: from_state,
|
55
|
+
to: to_state,
|
56
|
+
label: " #{action} (dynamic)"
|
57
|
+
}
|
58
|
+
end
|
59
|
+
|
60
|
+
def standard_transition(action, from_state, to_state, conditional: nil)
|
61
|
+
label = action.to_s
|
62
|
+
label += " (conditional)" if conditional
|
63
|
+
|
64
|
+
{
|
65
|
+
from: from_state,
|
66
|
+
to: to_state,
|
67
|
+
label: label
|
68
|
+
}
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "diagram"
|
4
|
+
|
5
|
+
module Circulator
|
6
|
+
class PlantUml < Diagram
|
7
|
+
private
|
8
|
+
|
9
|
+
# def graph_name
|
10
|
+
# @model_class.name || "diagram"
|
11
|
+
# end
|
12
|
+
|
13
|
+
def header
|
14
|
+
<<~PLANTUML
|
15
|
+
@startuml #{graph_name}
|
16
|
+
title #{graph_name}
|
17
|
+
PLANTUML
|
18
|
+
end
|
19
|
+
|
20
|
+
def footer
|
21
|
+
<<~PLANTUML
|
22
|
+
|
23
|
+
@enduml
|
24
|
+
PLANTUML
|
25
|
+
end
|
26
|
+
|
27
|
+
def dynamic_transition(action, from_state, to_state = nil)
|
28
|
+
{
|
29
|
+
from: from_state,
|
30
|
+
to: to_state,
|
31
|
+
label: action.to_s,
|
32
|
+
note: "dynamic target state"
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
def standard_transition(action, from_state, to_state, conditional: nil)
|
37
|
+
note = if conditional
|
38
|
+
"conditional transition"
|
39
|
+
end
|
40
|
+
|
41
|
+
{
|
42
|
+
from: from_state,
|
43
|
+
to: to_state,
|
44
|
+
label: action.to_s,
|
45
|
+
note:
|
46
|
+
}
|
47
|
+
end
|
48
|
+
|
49
|
+
def states_output(states, output)
|
50
|
+
states.reject(&:nil?).sort_by(&:to_s).each do |state|
|
51
|
+
output << "state #{state}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def transitions_output(transitions, output)
|
56
|
+
transitions.sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
|
57
|
+
from_label = transition[:from].nil? ? "[*]" : transition[:from].to_s
|
58
|
+
to_label = transition[:to].nil? ? "[*]" : transition[:to].to_s
|
59
|
+
output << "#{from_label} --> #{to_label} : #{transition[:label]}"
|
60
|
+
|
61
|
+
# Add note if present
|
62
|
+
if transition[:note]
|
63
|
+
output << "note on link"
|
64
|
+
output << " #{transition[:note]}"
|
65
|
+
output << "end note"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
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.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jim Gay
|
@@ -12,15 +12,20 @@ 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/diagram.rb
|
26
|
+
- lib/circulator/dot.rb
|
23
27
|
- lib/circulator/flow.rb
|
28
|
+
- lib/circulator/plantuml.rb
|
24
29
|
- lib/circulator/version.rb
|
25
30
|
homepage: https://github.com/SOFware/circulator
|
26
31
|
licenses: []
|