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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0a33536db417f338113b2ef4a5656a38994652f4454f8583ea8e22d626a0e9e
4
- data.tar.gz: 394085a68c1a7444192264fa69152be2827663284f4221b844f4fbfcb45cf64b
3
+ metadata.gz: 20f38e7ccf237cac5499afb5723d5d8f8f4ffe26b3ecbe7e05ee71ad05366438
4
+ data.tar.gz: e49a9f22259fea102bc6e99d5545590ae4fa40689cd2679318bababba8a5a075
5
5
  SHA512:
6
- metadata.gz: 4489ca3bf464fe0b37ad3b2e41096fcce05a2d5bcc5348d25e7750f278c666fd10c266e397635846b0093d1ace1064da754d50a0d6973b07f2a4086b5983926d
7
- data.tar.gz: 4da4067d6b07dccc9207e17c6095b236dab9f9e7c1d6399f5ff0c475b761b84da34490ec5d8d61ab71678e18fd014b327f6f9358b5aa571b8c70e19306698da6
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.0] - 2025-09-12
8
+ ## [2.0.2] - 2025-10-02
9
9
 
10
- ### Removed
10
+ ### Added
11
11
 
12
- - The `Circulator::Diverter` module has been and features added to `Circulator` itself.
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 "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,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
@@ -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.2"
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.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: []