circulator 2.0.2 → 2.1.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: 20f38e7ccf237cac5499afb5723d5d8f8f4ffe26b3ecbe7e05ee71ad05366438
4
- data.tar.gz: e49a9f22259fea102bc6e99d5545590ae4fa40689cd2679318bababba8a5a075
3
+ metadata.gz: 31b604ea699b2cbe133905f3078e81aa7860bc4e70e52cfd928611b92140b3f8
4
+ data.tar.gz: e264bd9c50f93fb1ab9f606a0d6b670ce4708304ccdf4a04a67a3a2e588b5bf5
5
5
  SHA512:
6
- metadata.gz: 6e59f461501d8bd236755dcc30eaa277372e14aca6a61236545ab5df4334f47668fe4878ee4a418bedc539c16891f1fc6cec3f54d88e19ecd9c766146e4b796e
7
- data.tar.gz: 4e98a06987e4d08f44cc3cf81ea5544c219bff24a5f08990bac6b2c5c93193a47ce2d9a20aaecb4cdb28fa24cce06420093966b0be49c60f19fe8302198097ec
6
+ metadata.gz: 80262bad01b9861f1f20459a67c80fbbd77c1f4cf4bc661d5b5918bd727582e8e099871cfd63b29a973fc5998a0c942a0f63aa2ced5894d41de91b5722517b19
7
+ data.tar.gz: fcbd512e14e020ee4ede8cb34b0432b46c38fbfee246c43a14906f0711302825e633376e8bb1b98c00eee55a24396c5e5814e1fd394dbc8f5a106737a615cb39
data/CHANGELOG.md CHANGED
@@ -5,12 +5,9 @@ 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.2] - 2025-10-02
8
+ ## [2.1.1] - 2025-10-06
9
9
 
10
10
  ### Added
11
11
 
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.
12
+ - Visual organization for separate flows in generated diagrams
13
+ - Ability to generate separate diagrams for each flow in a class.
@@ -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", require: nil}
11
+ options = {format: "dot", require: nil, directory: "docs", separate: false}
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("-s", "--separate", "Generate separate diagram files for each flow attribute") do
31
+ options[:separate] = true
32
+ end
33
+
26
34
  opts.on("-r", "--require FILE", "Require a file before loading the model (e.g., config/environment)") do |file|
27
35
  options[:require] = file
28
36
  end
@@ -80,7 +88,7 @@ rescue NameError
80
88
  exit 1
81
89
  end
82
90
 
83
- # Generate diagram file
91
+ # Generate diagram file(s)
84
92
  begin
85
93
  generator = case options[:format]
86
94
  when "plantuml"
@@ -89,9 +97,7 @@ begin
89
97
  Circulator::Dot.new(model_class)
90
98
  end
91
99
 
92
- content = generator.generate
93
-
94
- # Determine output filename and extension
100
+ # Determine base output filename and extension
95
101
  # Convert namespaced class names to directory paths
96
102
  # Something::Other becomes something/other
97
103
  class_name = model_class.name || "diagram"
@@ -100,30 +106,62 @@ begin
100
106
  }
101
107
  base_name = path_parts.join("/")
102
108
 
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}"
109
+ if options[:separate]
110
+ # Generate separate diagram file for each flow attribute
111
+ diagrams = generator.generate_separate
112
+ extension = (options[:format] == "plantuml") ? ".puml" : ".dot"
113
+
114
+ diagrams.each do |attribute_name, content|
115
+ # Create filename with attribute name
116
+ output_file = File.join(options[:directory], "#{base_name}_#{attribute_name}#{extension}")
117
+
118
+ # Create directory if needed
119
+ dir = File.dirname(output_file)
120
+ FileUtils.mkdir_p(dir) unless File.exist?(dir)
121
+
122
+ File.write(output_file, content)
123
+ puts "Generated #{options[:format]} file: #{output_file}"
124
+ end
125
+
126
+ puts ""
127
+ puts "To create images, run:"
128
+ if options[:format] == "plantuml"
129
+ puts " plantuml #{File.join(options[:directory], "#{base_name}_*#{extension}")}"
130
+ else
131
+ diagrams.keys.each do |attribute_name|
132
+ output_file = File.join(options[:directory], "#{base_name}_#{attribute_name}#{extension}")
133
+ puts " dot -Tpng #{output_file} -o #{File.join(options[:directory], "#{base_name}_#{attribute_name}")}.png"
134
+ end
135
+ end
115
136
  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"
137
+ # Generate single combined diagram file
138
+ content = generator.generate
139
+
140
+ if options[:format] == "plantuml"
141
+ # Use model class name for PlantUML files
142
+ output_file = File.join(options[:directory], "#{base_name}.puml")
143
+
144
+ # Create directory if needed
145
+ dir = File.dirname(output_file)
146
+ FileUtils.mkdir_p(dir) unless File.exist?(dir)
147
+
148
+ File.write(output_file, content)
149
+ puts "Generated PlantUML file: #{output_file}"
150
+ puts "To create an image, run:"
151
+ puts " plantuml #{output_file}"
152
+ else
153
+ # Use model class name for DOT files
154
+ output_file = File.join(options[:directory], "#{base_name}.dot")
155
+
156
+ # Create directory if needed
157
+ dir = File.dirname(output_file)
158
+ FileUtils.mkdir_p(dir) unless File.exist?(dir)
159
+
160
+ File.write(output_file, content)
161
+ puts "Generated DOT file: #{output_file}"
162
+ puts "To create an image, run:"
163
+ puts " dot -Tpng #{output_file} -o #{File.join(options[:directory], base_name)}.png"
164
+ end
127
165
  end
128
166
 
129
167
  exit 0
@@ -20,12 +20,14 @@ module Circulator
20
20
  output = []
21
21
  output << header
22
22
 
23
- # Collect all states and transitions
24
- states = Set.new
25
- transitions = []
23
+ # Collect states and transitions grouped by attribute
24
+ flows_data = []
26
25
 
27
26
  @flows.each do |model_key, attribute_flows|
28
27
  attribute_flows.each do |attribute_name, flow|
28
+ states = Set.new
29
+ transitions = []
30
+
29
31
  # Extract states and transitions from the flow
30
32
  flow.transition_map.each do |action, state_transitions|
31
33
  state_transitions.each do |from_state, transition_info|
@@ -41,19 +43,68 @@ module Circulator
41
43
  end
42
44
  end
43
45
  end
46
+
47
+ flows_data << {
48
+ attribute_name: attribute_name,
49
+ states: states,
50
+ transitions: transitions
51
+ }
44
52
  end
45
53
  end
46
54
 
47
- # Output state nodes
48
- states_output(states, output)
49
-
50
- # Output transition edges
51
- transitions_output(transitions, output)
55
+ # Output flows (grouped or combined based on subclass implementation)
56
+ flows_output(flows_data, output)
52
57
 
53
58
  output << footer
54
59
  output.join("\n") + "\n"
55
60
  end
56
61
 
62
+ # Generate separate diagrams for each flow attribute
63
+ # Returns a hash mapping attribute_name => diagram_content
64
+ def generate_separate
65
+ result = {}
66
+
67
+ @flows.each do |model_key, attribute_flows|
68
+ attribute_flows.each do |attribute_name, flow|
69
+ states = Set.new
70
+ transitions = []
71
+
72
+ # Extract states and transitions from the flow
73
+ flow.transition_map.each do |action, state_transitions|
74
+ state_transitions.each do |from_state, transition_info|
75
+ states.add(from_state)
76
+
77
+ to_state = transition_info[:to]
78
+ if to_state.respond_to?(:call)
79
+ states.add(:"?")
80
+ transitions << dynamic_transition(action, from_state, :"?")
81
+ else
82
+ states.add(to_state)
83
+ transitions << standard_transition(action, from_state, to_state, conditional: transition_info[:allow_if])
84
+ end
85
+ end
86
+ end
87
+
88
+ # Generate diagram for this flow only
89
+ output = []
90
+ output << header_for_attribute(attribute_name)
91
+
92
+ flows_data = [{
93
+ attribute_name: attribute_name,
94
+ states: states,
95
+ transitions: transitions
96
+ }]
97
+
98
+ flows_output(flows_data, output)
99
+ output << footer
100
+
101
+ result[attribute_name] = output.join("\n") + "\n"
102
+ end
103
+ end
104
+
105
+ result
106
+ end
107
+
57
108
  private
58
109
 
59
110
  def graph_name
@@ -74,15 +125,15 @@ module Circulator
74
125
  raise NotImplementedError, "Subclasses must implement #{__method__}"
75
126
  end
76
127
 
77
- def footer
128
+ def header_for_attribute(attribute_name)
78
129
  raise NotImplementedError, "Subclasses must implement #{__method__}"
79
130
  end
80
131
 
81
- def transitions_output(transitions, output)
132
+ def footer
82
133
  raise NotImplementedError, "Subclasses must implement #{__method__}"
83
134
  end
84
135
 
85
- def states_output(states, output)
136
+ def flows_output(flows_data, output)
86
137
  raise NotImplementedError, "Subclasses must implement #{__method__}"
87
138
  end
88
139
 
@@ -6,6 +6,49 @@ module Circulator
6
6
  class Dot < Diagram
7
7
  private
8
8
 
9
+ def flows_output(flows_data, output)
10
+ if flows_data.size == 1
11
+ # Single flow: no grouping needed
12
+ flow = flows_data.first
13
+ states_output(flow[:states], output)
14
+ transitions_output(flow[:transitions], output)
15
+ else
16
+ # Multiple flows: use subgraph clusters
17
+ flows_data.each_with_index do |flow, index|
18
+ output << ""
19
+ output << " subgraph cluster_#{index} {"
20
+ output << " label=\":#{flow[:attribute_name]}\";"
21
+ output << " style=dashed;"
22
+ output << " color=blue;"
23
+ output << ""
24
+
25
+ # Output states within this cluster
26
+ flow[:states].sort_by(&:to_s).each do |state|
27
+ state_label = state.nil? ? "nil" : state.to_s
28
+ # Prefix state names with attribute to avoid conflicts
29
+ prefixed_name = "#{flow[:attribute_name]}_#{state_label}"
30
+ output << " #{prefixed_name} [label=\"#{state_label}\", shape=circle];"
31
+ end
32
+
33
+ output << " }"
34
+ end
35
+
36
+ # Output all transitions after clusters
37
+ output << ""
38
+ output << " // Transitions"
39
+ flows_data.each do |flow|
40
+ flow[:transitions].sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
41
+ from_label = transition[:from].nil? ? "nil" : transition[:from].to_s
42
+ to_label = transition[:to].nil? ? "nil" : transition[:to].to_s
43
+ # Use prefixed names
44
+ prefixed_from = "#{flow[:attribute_name]}_#{from_label}"
45
+ prefixed_to = "#{flow[:attribute_name]}_#{to_label}"
46
+ output << " #{prefixed_from} -> #{prefixed_to} [label=\"#{transition[:label]}\"];"
47
+ end
48
+ end
49
+ end
50
+ end
51
+
9
52
  # def graph_name
10
53
  # # Use the model class name if available, otherwise use the model key
11
54
  # class_name = @model_class.name
@@ -45,6 +88,14 @@ module Circulator
45
88
  DOT
46
89
  end
47
90
 
91
+ def header_for_attribute(attribute_name)
92
+ class_name = @model_class.name || "diagram"
93
+ <<~DOT
94
+ digraph "#{class_name} :#{attribute_name} flow" {
95
+ rankdir=LR;
96
+ DOT
97
+ end
98
+
48
99
  def footer
49
100
  "}"
50
101
  end
@@ -6,9 +6,53 @@ module Circulator
6
6
  class PlantUml < Diagram
7
7
  private
8
8
 
9
- # def graph_name
10
- # @model_class.name || "diagram"
11
- # end
9
+ def flows_output(flows_data, output)
10
+ if flows_data.size == 1
11
+ # Single flow: no grouping needed
12
+ flow = flows_data.first
13
+ states_output(flow[:states], output)
14
+ transitions_output(flow[:transitions], output)
15
+ else
16
+ # Multiple flows: use composite states (state containers) with visible labels
17
+ flows_data.each do |flow|
18
+ output << ""
19
+ output << "state \":#{flow[:attribute_name]}\" as #{flow[:attribute_name]}_group {"
20
+
21
+ # Output states for this flow
22
+ flow[:states].reject(&:nil?).sort_by(&:to_s).each do |state|
23
+ # Replace characters that PlantUML doesn't like in identifiers
24
+ safe_state = state.to_s.gsub("?", "unknown")
25
+ prefixed_name = "#{flow[:attribute_name]}_#{safe_state}"
26
+ output << " state \"#{state}\" as #{prefixed_name}"
27
+ end
28
+
29
+ output << "}"
30
+ end
31
+
32
+ # Output all transitions after composite states
33
+ output << ""
34
+ flows_data.each do |flow|
35
+ flow[:transitions].sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
36
+ from_label = transition[:from].nil? ? "[*]" : transition[:from].to_s
37
+ to_label = transition[:to].nil? ? "[*]" : transition[:to].to_s
38
+ # Use prefixed names for non-nil states
39
+ # Replace characters that PlantUML doesn't like in identifiers
40
+ safe_from = from_label.gsub("?", "unknown")
41
+ safe_to = to_label.gsub("?", "unknown")
42
+ prefixed_from = transition[:from].nil? ? "[*]" : "#{flow[:attribute_name]}_#{safe_from}"
43
+ prefixed_to = transition[:to].nil? ? "[*]" : "#{flow[:attribute_name]}_#{safe_to}"
44
+ output << "#{prefixed_from} --> #{prefixed_to} : #{transition[:label]}"
45
+
46
+ # Add note if present
47
+ if transition[:note]
48
+ output << "note on link"
49
+ output << " #{transition[:note]}"
50
+ output << "end note"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
12
56
 
13
57
  def header
14
58
  <<~PLANTUML
@@ -17,6 +61,14 @@ module Circulator
17
61
  PLANTUML
18
62
  end
19
63
 
64
+ def header_for_attribute(attribute_name)
65
+ class_name = @model_class.name || "diagram"
66
+ <<~PLANTUML
67
+ @startuml #{class_name}_#{attribute_name}
68
+ title #{class_name} :#{attribute_name} flow
69
+ PLANTUML
70
+ end
71
+
20
72
  def footer
21
73
  <<~PLANTUML
22
74
 
@@ -48,7 +100,13 @@ module Circulator
48
100
 
49
101
  def states_output(states, output)
50
102
  states.reject(&:nil?).sort_by(&:to_s).each do |state|
51
- output << "state #{state}"
103
+ # Replace characters that PlantUML doesn't like in identifiers
104
+ safe_state = state.to_s.gsub("?", "unknown")
105
+ output << if safe_state != state.to_s
106
+ "state \"#{state}\" as #{safe_state}"
107
+ else
108
+ "state #{state}"
109
+ end
52
110
  end
53
111
  end
54
112
 
@@ -56,7 +114,10 @@ module Circulator
56
114
  transitions.sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
57
115
  from_label = transition[:from].nil? ? "[*]" : transition[:from].to_s
58
116
  to_label = transition[:to].nil? ? "[*]" : transition[:to].to_s
59
- output << "#{from_label} --> #{to_label} : #{transition[:label]}"
117
+ # Replace characters that PlantUML doesn't like in identifiers
118
+ safe_from = (from_label == "[*]") ? from_label : from_label.gsub("?", "unknown")
119
+ safe_to = (to_label == "[*]") ? to_label : to_label.gsub("?", "unknown")
120
+ output << "#{safe_from} --> #{safe_to} : #{transition[:label]}"
60
121
 
61
122
  # Add note if present
62
123
  if transition[:note]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.0.2"
4
+ VERSION = "2.1.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.2
4
+ version: 2.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay