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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0a33536db417f338113b2ef4a5656a38994652f4454f8583ea8e22d626a0e9e
4
- data.tar.gz: 394085a68c1a7444192264fa69152be2827663284f4221b844f4fbfcb45cf64b
3
+ metadata.gz: 42195ffd537b18be888f0bbab239d61d9ff19a2ff91c50adf0d6e51bea4119b0
4
+ data.tar.gz: ec684979c91e8a5bba4538e6a0c0546d1b23c2746d83b815cc9f43f5cb01fd83
5
5
  SHA512:
6
- metadata.gz: 4489ca3bf464fe0b37ad3b2e41096fcce05a2d5bcc5348d25e7750f278c666fd10c266e397635846b0093d1ace1064da754d50a0d6973b07f2a4086b5983926d
7
- data.tar.gz: 4da4067d6b07dccc9207e17c6095b236dab9f9e7c1d6399f5ff0c475b761b84da34490ec5d8d61ab71678e18fd014b327f6f9358b5aa571b8c70e19306698da6
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.0] - 2025-09-12
8
+ ## [2.0.1] - 2025-10-01
9
9
 
10
- ### Removed
10
+ ### Changed
11
11
 
12
- - The `Circulator::Diverter` module has been and features added to `Circulator` itself.
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 "minitest/test_task"
4
+ require "rake/testtask"
5
5
 
6
- Minitest::TestTask.create
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.0.0"
4
+ VERSION = "2.0.1"
5
5
  end
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.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: []