circulator 2.0.1 → 2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42195ffd537b18be888f0bbab239d61d9ff19a2ff91c50adf0d6e51bea4119b0
4
- data.tar.gz: ec684979c91e8a5bba4538e6a0c0546d1b23c2746d83b815cc9f43f5cb01fd83
3
+ metadata.gz: a33499a72d0661bf32e27e39213e4b0193f292eef8537c1946f0eb139e751a91
4
+ data.tar.gz: 6c3fa67b66bef192886a17cc9d756cb491bce6509127b43b87e7f5ca74347ef4
5
5
  SHA512:
6
- metadata.gz: eb5064f15bb2d4756139ae66b8a229d35afd9e3df5dd09ae0f8875c522661ffa82c7d0773d6dd088becca25c42f17da350fee347723357d21bd6b67037aa256d
7
- data.tar.gz: 1d800dc29d73a0e5f8e88821abba4ec9b3e4d48e109362b5bbd15550a0af4a7e40e2c7a00346c64ac8eebe6d8caf327c57129ea1ae3d09de2129922c235adc0a
6
+ metadata.gz: 3755e25bcb3a1b34ba3768e8e3b501489dd7827ad9c1695b7365c2ffee9cbf9160181e2bd7226ba49e5c76d1763811e425beef4a0250a28811f22e57f970df98
7
+ data.tar.gz: 0c970bbeca86d6835911ae4ec7369585a14a9815ff6fc4fcdf28886ae556f0fc3b3d1be97fc301a45799bb5cc3b702681b3160f0072489a5eb96cfdb222fd225
data/CHANGELOG.md CHANGED
@@ -5,12 +5,8 @@ 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.1] - 2025-10-01
9
-
10
- ### Changed
11
-
12
- - Switched to a rake test task to set the test order
8
+ ## [2.1.0] - 2025-10-02
13
9
 
14
10
  ### Added
15
11
 
16
- - Ability to generate diagrams from declared flows in Dot or PlantUML
12
+ - Configure directory for generating diagrams, defaulting to "docs"
@@ -8,7 +8,7 @@ require_relative "../lib/circulator/dot"
8
8
  require_relative "../lib/circulator/plantuml"
9
9
 
10
10
  # Parse command-line options
11
- options = {format: "dot"}
11
+ options = {format: "dot", require: nil, directory: "docs"}
12
12
  parser = OptionParser.new do |opts|
13
13
  opts.banner = "Usage: circulator-diagram MODEL_NAME [options]"
14
14
  opts.separator ""
@@ -23,6 +23,14 @@ parser = OptionParser.new do |opts|
23
23
  options[:format] = format
24
24
  end
25
25
 
26
+ opts.on("-d", "--directory DIRECTORY", "Output directory. Default: docs") do |directory|
27
+ options[:directory] = directory
28
+ end
29
+
30
+ opts.on("-r", "--require FILE", "Require a file before loading the model (e.g., config/environment)") do |file|
31
+ options[:require] = file
32
+ end
33
+
26
34
  opts.on("-h", "--help", "Show this help message") do
27
35
  puts opts
28
36
  exit 0
@@ -52,6 +60,21 @@ end
52
60
 
53
61
  model_name = ARGV[0]
54
62
 
63
+ # Load the application environment
64
+ # Priority: -r option > config/environment.rb > nothing
65
+ if options[:require]
66
+ # User specified a file to require
67
+ require_file = options[:require]
68
+ unless File.exist?(require_file)
69
+ warn "Error: Required file '#{require_file}' not found"
70
+ exit 1
71
+ end
72
+ require require_file
73
+ elsif File.exist?("config/environment.rb")
74
+ # Rails application detected, load the environment
75
+ require File.expand_path("config/environment.rb")
76
+ end
77
+
55
78
  # Try to constantize the model name
56
79
  begin
57
80
  model_class = Object.const_get(model_name)
@@ -83,11 +106,11 @@ begin
83
106
 
84
107
  if options[:format] == "plantuml"
85
108
  # Use model class name for PlantUML files
86
- output_file = "#{base_name}.puml"
109
+ output_file = File.join(options[:directory], "#{base_name}.puml")
87
110
 
88
111
  # Create directory if needed
89
112
  dir = File.dirname(output_file)
90
- FileUtils.mkdir_p(dir) unless dir == "." || File.exist?(dir)
113
+ FileUtils.mkdir_p(dir) unless File.exist?(dir)
91
114
 
92
115
  File.write(output_file, content)
93
116
  puts "Generated PlantUML file: #{output_file}"
@@ -95,16 +118,16 @@ begin
95
118
  puts " plantuml #{output_file}"
96
119
  else
97
120
  # Use model class name for DOT files
98
- output_file = "#{base_name}.dot"
121
+ output_file = File.join(options[:directory], "#{base_name}.dot")
99
122
 
100
123
  # Create directory if needed
101
124
  dir = File.dirname(output_file)
102
- FileUtils.mkdir_p(dir) unless dir == "." || File.exist?(dir)
125
+ FileUtils.mkdir_p(dir) unless File.exist?(dir)
103
126
 
104
127
  File.write(output_file, content)
105
128
  puts "Generated DOT file: #{output_file}"
106
129
  puts "To create an image, run:"
107
- puts " dot -Tpng #{output_file} -o #{base_name}.png"
130
+ puts " dot -Tpng #{output_file} -o #{File.join(options[:directory], base_name)}.png"
108
131
  end
109
132
 
110
133
  exit 0
@@ -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
@@ -1,92 +1,71 @@
1
1
  # frozen_string_literal: true
2
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
3
+ require_relative "diagram"
18
4
 
19
- def generate
20
- output = []
21
- output << "digraph #{graph_name} {"
22
- output << " rankdir=LR;"
23
- output << ""
5
+ module Circulator
6
+ class Dot < Diagram
7
+ private
24
8
 
25
- # Collect all states and transitions
26
- states = Set.new
27
- transitions = []
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
28
13
 
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)
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
35
22
 
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
23
+ def states_output(states, output)
54
24
  output << " // States"
55
25
  states.sort_by { |s| s.to_s }.each do |state|
56
26
  state_label = state.nil? ? "nil" : state.to_s
57
27
  output << " #{state_label} [shape=circle];"
58
28
  end
29
+ end
59
30
 
31
+ def transitions_output(transitions, output)
60
32
  output << ""
61
33
  output << " // Transitions"
62
-
63
- # Output transition edges
64
34
  transitions.sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
65
35
  from_label = transition[:from].nil? ? "nil" : transition[:from].to_s
66
36
  to_label = transition[:to].nil? ? "nil" : transition[:to].to_s
67
37
  output << " #{from_label} -> #{to_label} [label=\"#{transition[:label]}\"];"
68
38
  end
39
+ end
69
40
 
70
- output << "}"
71
- output.join("\n") + "\n"
41
+ def header
42
+ <<~DOT
43
+ digraph "#{graph_name}" {
44
+ rankdir=LR;
45
+ DOT
72
46
  end
73
47
 
74
- private
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
75
59
 
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
60
+ def standard_transition(action, from_state, to_state, conditional: nil)
61
+ label = action.to_s
62
+ label += " (conditional)" if conditional
80
63
 
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
64
+ {
65
+ from: from_state,
66
+ to: to_state,
67
+ label: label
68
+ }
90
69
  end
91
70
  end
92
71
  end
@@ -1,72 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "diagram"
4
+
3
5
  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
6
+ class PlantUml < Diagram
7
+ private
9
8
 
10
- flows = model_class.flows
11
- if flows.nil? || flows.empty?
12
- raise ArgumentError, "Model class has no flows defined"
13
- end
9
+ # def graph_name
10
+ # @model_class.name || "diagram"
11
+ # end
14
12
 
15
- @model_class = model_class
16
- @flows = flows
13
+ def header
14
+ <<~PLANTUML
15
+ @startuml #{graph_name}
16
+ title #{graph_name}
17
+ PLANTUML
17
18
  end
18
19
 
19
- def generate
20
- output = []
21
- output << "@startuml"
22
- output << ""
20
+ def footer
21
+ <<~PLANTUML
23
22
 
24
- # Collect all states and transitions
25
- states = Set.new
26
- transitions = []
23
+ @enduml
24
+ PLANTUML
25
+ end
27
26
 
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)
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
34
35
 
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
36
+ def standard_transition(action, from_state, to_state, conditional: nil)
37
+ note = if conditional
38
+ "conditional transition"
60
39
  end
61
40
 
62
- # Output state declarations (except nil which is represented as [*])
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)
63
50
  states.reject(&:nil?).sort_by(&:to_s).each do |state|
64
51
  output << "state #{state}"
65
52
  end
53
+ end
66
54
 
67
- output << ""
68
-
69
- # Output transitions
55
+ def transitions_output(transitions, output)
70
56
  transitions.sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
71
57
  from_label = transition[:from].nil? ? "[*]" : transition[:from].to_s
72
58
  to_label = transition[:to].nil? ? "[*]" : transition[:to].to_s
@@ -79,10 +65,6 @@ module Circulator
79
65
  output << "end note"
80
66
  end
81
67
  end
82
-
83
- output << ""
84
- output << "@enduml"
85
- output.join("\n") + "\n"
86
68
  end
87
69
  end
88
70
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.0.1"
4
+ VERSION = "2.1.0"
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.1
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
@@ -22,6 +22,7 @@ files:
22
22
  - Rakefile
23
23
  - exe/circulator-diagram
24
24
  - lib/circulator.rb
25
+ - lib/circulator/diagram.rb
25
26
  - lib/circulator/dot.rb
26
27
  - lib/circulator/flow.rb
27
28
  - lib/circulator/plantuml.rb