circulator 2.1.0 → 2.1.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 +2 -2
- data/README.md +38 -0
- data/exe/circulator-diagram +62 -28
- data/lib/circulator/diagram.rb +62 -11
- data/lib/circulator/dot.rb +51 -0
- data/lib/circulator/flow.rb +58 -0
- data/lib/circulator/plantuml.rb +66 -5
- data/lib/circulator/version.rb +1 -1
- data/lib/circulator.rb +18 -2
- metadata +1 -1
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 1a3d83bc194cf2440d13e088b1b23e72cf3ec89071ae9d046d575a7e5d1014c7
         | 
| 4 | 
            +
              data.tar.gz: 4c1afec2234a2edf3675048519aac13e4b64f2c8bbb08b98387603fc5f354ea8
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: a5bf023b56fe2f8352fbb2fadce2c8a29cb9a47cfd5b011c80c098646696b9dcd5735c2b027c8f4a126c285005f11c8b46e9c009ac8ad243df3e1bc8a5979523
         | 
| 7 | 
            +
              data.tar.gz: e4f99d8cd3e1cdbf87a15eb0b588ffc778f0fdf412c897fb5bf6796ff1295351fcf542c6ea80d1f883b1c3fa6afd817925ecddbda39ac1e16ffc9a59665754cb
         | 
    
        data/CHANGELOG.md
    CHANGED
    
    | @@ -5,8 +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.1. | 
| 8 | 
            +
            ## [2.1.2] - 2025-10-17
         | 
| 9 9 |  | 
| 10 10 | 
             
            ### Added
         | 
| 11 11 |  | 
| 12 | 
            -
            -  | 
| 12 | 
            +
            - Hash-based allow_if for nested state dependencies (411a6b5)
         | 
    
        data/README.md
    CHANGED
    
    | @@ -8,6 +8,7 @@ A lightweight and flexible state machine implementation for Ruby that allows you | |
| 8 8 | 
             
            - **Flexible DSL**: Intuitive syntax for defining states and transitions
         | 
| 9 9 | 
             
            - **Dynamic Method Generation**: Automatically creates helper methods for state transitions
         | 
| 10 10 | 
             
            - **Conditional Transitions**: Support for guards and conditional logic
         | 
| 11 | 
            +
            - **Nested State Dependencies**: State machines can depend on the state of other attributes
         | 
| 11 12 | 
             
            - **Transition Callbacks**: Execute code before, during, or after transitions
         | 
| 12 13 | 
             
            - **Multiple State Machines**: Define multiple independent state machines per class
         | 
| 13 14 | 
             
            - **Framework Agnostic**: Works with plain Ruby objects, no Rails or ActiveRecord required
         | 
| @@ -93,6 +94,43 @@ class Document | |
| 93 94 | 
             
            end
         | 
| 94 95 | 
             
            ```
         | 
| 95 96 |  | 
| 97 | 
            +
            #### Nested State Dependencies
         | 
| 98 | 
            +
             | 
| 99 | 
            +
            You can make one state machine depend on another using hash-based `allow_if`:
         | 
| 100 | 
            +
             | 
| 101 | 
            +
            ```ruby
         | 
| 102 | 
            +
            class Document
         | 
| 103 | 
            +
              extend Circulator
         | 
| 104 | 
            +
             | 
| 105 | 
            +
              attr_accessor :status, :review_status
         | 
| 106 | 
            +
             | 
| 107 | 
            +
              # Review must be completed first
         | 
| 108 | 
            +
              flow :review_status do
         | 
| 109 | 
            +
                state :pending do
         | 
| 110 | 
            +
                  action :approve, to: :approved
         | 
| 111 | 
            +
                end
         | 
| 112 | 
            +
                state :approved
         | 
| 113 | 
            +
              end
         | 
| 114 | 
            +
             | 
| 115 | 
            +
              # Document status depends on review status
         | 
| 116 | 
            +
              flow :status do
         | 
| 117 | 
            +
                state :draft do
         | 
| 118 | 
            +
                  # Can only publish if review is approved
         | 
| 119 | 
            +
                  action :publish, to: :published, allow_if: {review_status: [:approved]}
         | 
| 120 | 
            +
                end
         | 
| 121 | 
            +
              end
         | 
| 122 | 
            +
            end
         | 
| 123 | 
            +
             | 
| 124 | 
            +
            doc = Document.new
         | 
| 125 | 
            +
            doc.status = :draft
         | 
| 126 | 
            +
            doc.review_status = :pending
         | 
| 127 | 
            +
             | 
| 128 | 
            +
            doc.status_publish  # => blocked, status remains :draft
         | 
| 129 | 
            +
             | 
| 130 | 
            +
            doc.review_status_approve  # => :approved
         | 
| 131 | 
            +
            doc.status_publish         # => :published ✓
         | 
| 132 | 
            +
            ```
         | 
| 133 | 
            +
             | 
| 96 134 | 
             
            #### Dynamic Destination States
         | 
| 97 135 |  | 
| 98 136 | 
             
            ```ruby
         | 
    
        data/exe/circulator-diagram
    CHANGED
    
    | @@ -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, directory: "docs"}
         | 
| 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 ""
         | 
| @@ -27,6 +27,10 @@ parser = OptionParser.new do |opts| | |
| 27 27 | 
             
                options[:directory] = directory
         | 
| 28 28 | 
             
              end
         | 
| 29 29 |  | 
| 30 | 
            +
              opts.on("-s", "--separate", "Generate separate diagram files for each flow attribute") do
         | 
| 31 | 
            +
                options[:separate] = true
         | 
| 32 | 
            +
              end
         | 
| 33 | 
            +
             | 
| 30 34 | 
             
              opts.on("-r", "--require FILE", "Require a file before loading the model (e.g., config/environment)") do |file|
         | 
| 31 35 | 
             
                options[:require] = file
         | 
| 32 36 | 
             
              end
         | 
| @@ -84,7 +88,7 @@ rescue NameError | |
| 84 88 | 
             
              exit 1
         | 
| 85 89 | 
             
            end
         | 
| 86 90 |  | 
| 87 | 
            -
            # Generate diagram file
         | 
| 91 | 
            +
            # Generate diagram file(s)
         | 
| 88 92 | 
             
            begin
         | 
| 89 93 | 
             
              generator = case options[:format]
         | 
| 90 94 | 
             
              when "plantuml"
         | 
| @@ -93,9 +97,7 @@ begin | |
| 93 97 | 
             
                Circulator::Dot.new(model_class)
         | 
| 94 98 | 
             
              end
         | 
| 95 99 |  | 
| 96 | 
            -
               | 
| 97 | 
            -
             | 
| 98 | 
            -
              # Determine output filename and extension
         | 
| 100 | 
            +
              # Determine base output filename and extension
         | 
| 99 101 | 
             
              # Convert namespaced class names to directory paths
         | 
| 100 102 | 
             
              # Something::Other becomes something/other
         | 
| 101 103 | 
             
              class_name = model_class.name || "diagram"
         | 
| @@ -104,30 +106,62 @@ begin | |
| 104 106 | 
             
              }
         | 
| 105 107 | 
             
              base_name = path_parts.join("/")
         | 
| 106 108 |  | 
| 107 | 
            -
              if options[: | 
| 108 | 
            -
                #  | 
| 109 | 
            -
                 | 
| 110 | 
            -
             | 
| 111 | 
            -
             | 
| 112 | 
            -
                 | 
| 113 | 
            -
             | 
| 114 | 
            -
             | 
| 115 | 
            -
             | 
| 116 | 
            -
             | 
| 117 | 
            -
             | 
| 118 | 
            -
             | 
| 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
         | 
| 119 136 | 
             
              else
         | 
| 120 | 
            -
                #  | 
| 121 | 
            -
                 | 
| 122 | 
            -
             | 
| 123 | 
            -
                 | 
| 124 | 
            -
             | 
| 125 | 
            -
             | 
| 126 | 
            -
             | 
| 127 | 
            -
             | 
| 128 | 
            -
             | 
| 129 | 
            -
             | 
| 130 | 
            -
             | 
| 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
         | 
| 131 165 | 
             
              end
         | 
| 132 166 |  | 
| 133 167 | 
             
              exit 0
         | 
    
        data/lib/circulator/diagram.rb
    CHANGED
    
    | @@ -20,12 +20,14 @@ module Circulator | |
| 20 20 | 
             
                  output = []
         | 
| 21 21 | 
             
                  output << header
         | 
| 22 22 |  | 
| 23 | 
            -
                  # Collect  | 
| 24 | 
            -
                   | 
| 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  | 
| 48 | 
            -
                   | 
| 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  | 
| 128 | 
            +
                def header_for_attribute(attribute_name)
         | 
| 78 129 | 
             
                  raise NotImplementedError, "Subclasses must implement #{__method__}"
         | 
| 79 130 | 
             
                end
         | 
| 80 131 |  | 
| 81 | 
            -
                def  | 
| 132 | 
            +
                def footer
         | 
| 82 133 | 
             
                  raise NotImplementedError, "Subclasses must implement #{__method__}"
         | 
| 83 134 | 
             
                end
         | 
| 84 135 |  | 
| 85 | 
            -
                def  | 
| 136 | 
            +
                def flows_output(flows_data, output)
         | 
| 86 137 | 
             
                  raise NotImplementedError, "Subclasses must implement #{__method__}"
         | 
| 87 138 | 
             
                end
         | 
| 88 139 |  | 
    
        data/lib/circulator/dot.rb
    CHANGED
    
    | @@ -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
         | 
    
        data/lib/circulator/flow.rb
    CHANGED
    
    | @@ -23,6 +23,11 @@ module Circulator | |
| 23 23 | 
             
                def action(name, to:, from: :__not_specified__, allow_if: nil, &block)
         | 
| 24 24 | 
             
                  raise "You must be in a state block or have a `from` option to declare an action" unless defined?(@current_state) || from != :__not_specified__
         | 
| 25 25 |  | 
| 26 | 
            +
                  # Validate allow_if parameter
         | 
| 27 | 
            +
                  if allow_if
         | 
| 28 | 
            +
                    validate_allow_if(allow_if)
         | 
| 29 | 
            +
                  end
         | 
| 30 | 
            +
             | 
| 26 31 | 
             
                  @transition_map[name] ||= {}
         | 
| 27 32 | 
             
                  selected_state = (from == :__not_specified__) ? @current_state : from
         | 
| 28 33 |  | 
| @@ -36,6 +41,13 @@ module Circulator | |
| 36 41 | 
             
                  states_to_process.each do |from_state|
         | 
| 37 42 | 
             
                    from_state = from_state.to_sym if from_state.respond_to?(:to_sym)
         | 
| 38 43 | 
             
                    @states.add(from_state)
         | 
| 44 | 
            +
             | 
| 45 | 
            +
                    # Add the target state to @states if it's not a callable
         | 
| 46 | 
            +
                    unless to.respond_to?(:call)
         | 
| 47 | 
            +
                      to_state = to.respond_to?(:to_sym) ? to.to_sym : to
         | 
| 48 | 
            +
                      @states.add(to_state)
         | 
| 49 | 
            +
                    end
         | 
| 50 | 
            +
             | 
| 39 51 | 
             
                    @transition_map[name][from_state] = {to:, block:}
         | 
| 40 52 | 
             
                    @transition_map[name][from_state][:allow_if] = allow_if if allow_if
         | 
| 41 53 | 
             
                  end
         | 
| @@ -67,5 +79,51 @@ module Circulator | |
| 67 79 | 
             
                    @no_action
         | 
| 68 80 | 
             
                  end
         | 
| 69 81 | 
             
                end
         | 
| 82 | 
            +
             | 
| 83 | 
            +
                private
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                def validate_allow_if(allow_if)
         | 
| 86 | 
            +
                  # Must be either a Proc or a Hash
         | 
| 87 | 
            +
                  unless allow_if.is_a?(Proc) || allow_if.is_a?(Hash)
         | 
| 88 | 
            +
                    raise ArgumentError, "allow_if must be a Proc or Hash, got: #{allow_if.class}"
         | 
| 89 | 
            +
                  end
         | 
| 90 | 
            +
             | 
| 91 | 
            +
                  # If it's a Hash, validate the structure
         | 
| 92 | 
            +
                  if allow_if.is_a?(Hash)
         | 
| 93 | 
            +
                    validate_hash_allow_if(allow_if)
         | 
| 94 | 
            +
                  end
         | 
| 95 | 
            +
                end
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                def validate_hash_allow_if(allow_if_hash)
         | 
| 98 | 
            +
                  # Must have exactly one key
         | 
| 99 | 
            +
                  if allow_if_hash.size != 1
         | 
| 100 | 
            +
                    raise ArgumentError, "allow_if hash must contain exactly one attribute, got: #{allow_if_hash.keys.inspect}"
         | 
| 101 | 
            +
                  end
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                  attribute_name, valid_states = allow_if_hash.first
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                  # Convert attribute name to symbol
         | 
| 106 | 
            +
                  attribute_name = attribute_name.to_sym if attribute_name.respond_to?(:to_sym)
         | 
| 107 | 
            +
             | 
| 108 | 
            +
                  # Get model_key from the class name string, not the Class object
         | 
| 109 | 
            +
                  model_key = Circulator.model_key(@klass.to_s)
         | 
| 110 | 
            +
                  unless @klass.flows&.dig(model_key, attribute_name)
         | 
| 111 | 
            +
                    available_flows = @klass.flows&.dig(model_key)&.keys || []
         | 
| 112 | 
            +
                    raise ArgumentError, "allow_if references undefined flow attribute :#{attribute_name}. Available flows: #{available_flows.inspect}"
         | 
| 113 | 
            +
                  end
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                  # Get the states from the referenced flow
         | 
| 116 | 
            +
                  referenced_flow = @klass.flows.dig(model_key, attribute_name)
         | 
| 117 | 
            +
                  referenced_states = referenced_flow.instance_variable_get(:@states)
         | 
| 118 | 
            +
             | 
| 119 | 
            +
                  # Convert valid_states to array of symbols
         | 
| 120 | 
            +
                  valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
         | 
| 121 | 
            +
             | 
| 122 | 
            +
                  # Check if all specified states exist in the referenced flow
         | 
| 123 | 
            +
                  invalid_states = valid_states_array - referenced_states.to_a
         | 
| 124 | 
            +
                  if invalid_states.any?
         | 
| 125 | 
            +
                    raise ArgumentError, "allow_if references invalid states #{invalid_states.inspect} for :#{attribute_name}. Valid states: #{referenced_states.to_a.inspect}"
         | 
| 126 | 
            +
                  end
         | 
| 127 | 
            +
                end
         | 
| 70 128 | 
             
              end
         | 
| 71 129 | 
             
            end
         | 
    
        data/lib/circulator/plantuml.rb
    CHANGED
    
    | @@ -6,9 +6,53 @@ module Circulator | |
| 6 6 | 
             
              class PlantUml < Diagram
         | 
| 7 7 | 
             
                private
         | 
| 8 8 |  | 
| 9 | 
            -
                 | 
| 10 | 
            -
             | 
| 11 | 
            -
             | 
| 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 | 
            -
                     | 
| 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 | 
            -
                     | 
| 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]
         | 
    
        data/lib/circulator/version.rb
    CHANGED
    
    
    
        data/lib/circulator.rb
    CHANGED
    
    | @@ -129,7 +129,7 @@ module Circulator | |
| 129 129 | 
             
                @flows ||= {}
         | 
| 130 130 | 
             
                model_key = Circulator.model_key(model)
         | 
| 131 131 | 
             
                @flows[model_key] ||= {}
         | 
| 132 | 
            -
                @flows[model_key][attribute_name] = Flow.new( | 
| 132 | 
            +
                @flows[model_key][attribute_name] = Flow.new(self, attribute_name, &block)
         | 
| 133 133 |  | 
| 134 134 | 
             
                flow_module = ancestors.find { |ancestor|
         | 
| 135 135 | 
             
                  ancestor.name.to_s =~ /FlowMethods/
         | 
| @@ -170,7 +170,23 @@ module Circulator | |
| 170 170 | 
             
                  end
         | 
| 171 171 |  | 
| 172 172 | 
             
                  if transition[:allow_if]
         | 
| 173 | 
            -
                     | 
| 173 | 
            +
                    # Handle hash-based allow_if (checking other attribute states)
         | 
| 174 | 
            +
                    if transition[:allow_if].is_a?(Hash)
         | 
| 175 | 
            +
                      attribute_name_to_check, valid_states = transition[:allow_if].first
         | 
| 176 | 
            +
                      current_state = flow_target.send(attribute_name_to_check)
         | 
| 177 | 
            +
             | 
| 178 | 
            +
                      # Convert current state to symbol if possible
         | 
| 179 | 
            +
                      current_state = current_state.to_sym if current_state.respond_to?(:to_sym)
         | 
| 180 | 
            +
             | 
| 181 | 
            +
                      # Convert valid_states to array of symbols
         | 
| 182 | 
            +
                      valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
         | 
| 183 | 
            +
             | 
| 184 | 
            +
                      # Return early if current state is not in the valid states
         | 
| 185 | 
            +
                      return unless valid_states_array.include?(current_state)
         | 
| 186 | 
            +
                    else
         | 
| 187 | 
            +
                      # Handle proc-based allow_if (original behavior)
         | 
| 188 | 
            +
                      return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
         | 
| 189 | 
            +
                    end
         | 
| 174 190 | 
             
                  end
         | 
| 175 191 |  | 
| 176 192 | 
             
                  if transition[:block]
         |