cmdx 0.4.0 → 1.0.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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/.DS_Store +0 -0
  3. data/.cursor/rules/cursor-instructions.mdc +6 -0
  4. data/.rubocop.yml +16 -1
  5. data/.ruby-version +1 -1
  6. data/CHANGELOG.md +42 -1
  7. data/README.md +72 -25
  8. data/docs/ai_prompts.md +309 -0
  9. data/docs/basics/call.md +225 -14
  10. data/docs/basics/chain.md +271 -0
  11. data/docs/basics/context.md +232 -33
  12. data/docs/basics/setup.md +76 -12
  13. data/docs/callbacks.md +273 -0
  14. data/docs/configuration.md +158 -28
  15. data/docs/getting_started.md +134 -22
  16. data/docs/interruptions/exceptions.md +189 -11
  17. data/docs/interruptions/faults.md +187 -44
  18. data/docs/interruptions/halt.md +179 -35
  19. data/docs/logging.md +194 -53
  20. data/docs/middlewares.md +735 -0
  21. data/docs/outcomes/result.md +296 -10
  22. data/docs/outcomes/states.md +212 -19
  23. data/docs/outcomes/statuses.md +284 -18
  24. data/docs/parameters/coercions.md +402 -29
  25. data/docs/parameters/defaults.md +249 -25
  26. data/docs/parameters/definitions.md +238 -72
  27. data/docs/parameters/namespacing.md +250 -27
  28. data/docs/parameters/validations.md +193 -168
  29. data/docs/testing.md +550 -0
  30. data/docs/tips_and_tricks.md +95 -43
  31. data/docs/workflows.md +319 -0
  32. data/lib/cmdx/.DS_Store +0 -0
  33. data/lib/cmdx/callback.rb +69 -0
  34. data/lib/cmdx/callback_registry.rb +106 -0
  35. data/lib/cmdx/chain.rb +190 -0
  36. data/lib/cmdx/chain_inspector.rb +149 -0
  37. data/lib/cmdx/chain_serializer.rb +175 -0
  38. data/lib/cmdx/coercions/array.rb +37 -0
  39. data/lib/cmdx/coercions/big_decimal.rb +33 -0
  40. data/lib/cmdx/coercions/boolean.rb +41 -1
  41. data/lib/cmdx/coercions/complex.rb +31 -0
  42. data/lib/cmdx/coercions/date.rb +39 -0
  43. data/lib/cmdx/coercions/date_time.rb +39 -0
  44. data/lib/cmdx/coercions/float.rb +31 -0
  45. data/lib/cmdx/coercions/hash.rb +42 -0
  46. data/lib/cmdx/coercions/integer.rb +32 -0
  47. data/lib/cmdx/coercions/rational.rb +31 -0
  48. data/lib/cmdx/coercions/string.rb +31 -0
  49. data/lib/cmdx/coercions/time.rb +39 -0
  50. data/lib/cmdx/coercions/virtual.rb +31 -0
  51. data/lib/cmdx/configuration.rb +217 -9
  52. data/lib/cmdx/context.rb +173 -2
  53. data/lib/cmdx/core_ext/hash.rb +72 -0
  54. data/lib/cmdx/core_ext/module.rb +94 -0
  55. data/lib/cmdx/core_ext/object.rb +105 -0
  56. data/lib/cmdx/correlator.rb +217 -0
  57. data/lib/cmdx/error.rb +210 -8
  58. data/lib/cmdx/errors.rb +256 -1
  59. data/lib/cmdx/fault.rb +177 -2
  60. data/lib/cmdx/faults.rb +158 -2
  61. data/lib/cmdx/immutator.rb +121 -2
  62. data/lib/cmdx/lazy_struct.rb +261 -18
  63. data/lib/cmdx/log_formatters/json.rb +46 -0
  64. data/lib/cmdx/log_formatters/key_value.rb +46 -0
  65. data/lib/cmdx/log_formatters/line.rb +54 -0
  66. data/lib/cmdx/log_formatters/logstash.rb +64 -0
  67. data/lib/cmdx/log_formatters/pretty_json.rb +57 -0
  68. data/lib/cmdx/log_formatters/pretty_key_value.rb +51 -0
  69. data/lib/cmdx/log_formatters/pretty_line.rb +60 -0
  70. data/lib/cmdx/log_formatters/raw.rb +54 -0
  71. data/lib/cmdx/logger.rb +85 -0
  72. data/lib/cmdx/logger_ansi.rb +93 -7
  73. data/lib/cmdx/logger_serializer.rb +116 -0
  74. data/lib/cmdx/middleware.rb +74 -0
  75. data/lib/cmdx/middleware_registry.rb +106 -0
  76. data/lib/cmdx/middlewares/correlate.rb +266 -0
  77. data/lib/cmdx/middlewares/timeout.rb +232 -0
  78. data/lib/cmdx/parameter.rb +228 -1
  79. data/lib/cmdx/parameter_inspector.rb +61 -0
  80. data/lib/cmdx/parameter_registry.rb +125 -0
  81. data/lib/cmdx/parameter_serializer.rb +83 -0
  82. data/lib/cmdx/parameter_validator.rb +62 -0
  83. data/lib/cmdx/parameter_value.rb +109 -1
  84. data/lib/cmdx/parameters_inspector.rb +59 -0
  85. data/lib/cmdx/parameters_serializer.rb +102 -0
  86. data/lib/cmdx/railtie.rb +123 -3
  87. data/lib/cmdx/result.rb +399 -20
  88. data/lib/cmdx/result_ansi.rb +105 -9
  89. data/lib/cmdx/result_inspector.rb +76 -0
  90. data/lib/cmdx/result_logger.rb +90 -3
  91. data/lib/cmdx/result_serializer.rb +137 -0
  92. data/lib/cmdx/rspec/result_matchers.rb +917 -0
  93. data/lib/cmdx/rspec/task_matchers.rb +570 -0
  94. data/lib/cmdx/task.rb +409 -34
  95. data/lib/cmdx/task_serializer.rb +74 -2
  96. data/lib/cmdx/utils/ansi_color.rb +95 -0
  97. data/lib/cmdx/utils/log_timestamp.rb +48 -0
  98. data/lib/cmdx/utils/monotonic_runtime.rb +71 -4
  99. data/lib/cmdx/utils/name_affix.rb +78 -0
  100. data/lib/cmdx/validators/custom.rb +82 -0
  101. data/lib/cmdx/validators/exclusion.rb +94 -0
  102. data/lib/cmdx/validators/format.rb +102 -8
  103. data/lib/cmdx/validators/inclusion.rb +104 -0
  104. data/lib/cmdx/validators/length.rb +128 -0
  105. data/lib/cmdx/validators/numeric.rb +128 -0
  106. data/lib/cmdx/validators/presence.rb +93 -7
  107. data/lib/cmdx/version.rb +7 -1
  108. data/lib/cmdx/workflow.rb +394 -0
  109. data/lib/cmdx.rb +25 -64
  110. data/lib/generators/cmdx/install_generator.rb +37 -1
  111. data/lib/generators/cmdx/task_generator.rb +69 -1
  112. data/lib/generators/cmdx/templates/install.rb +8 -12
  113. data/lib/generators/cmdx/workflow_generator.rb +109 -0
  114. metadata +54 -15
  115. data/docs/basics/run.md +0 -34
  116. data/docs/batch.md +0 -53
  117. data/docs/example.md +0 -82
  118. data/docs/hooks.md +0 -59
  119. data/lib/cmdx/batch.rb +0 -43
  120. data/lib/cmdx/parameters.rb +0 -34
  121. data/lib/cmdx/run.rb +0 -38
  122. data/lib/cmdx/run_inspector.rb +0 -26
  123. data/lib/cmdx/run_serializer.rb +0 -16
  124. data/lib/cmdx/task_hook.rb +0 -18
  125. data/lib/generators/cmdx/batch_generator.rb +0 -30
  126. /data/lib/generators/cmdx/templates/{batch.rb.tt → workflow.rb.tt} +0 -0
data/lib/cmdx/faults.rb CHANGED
@@ -2,10 +2,166 @@
2
2
 
3
3
  module CMDx
4
4
 
5
- # Raised when halting task processing with skipped context
5
+ ##
6
+ # Skipped is a specific fault type raised when a task execution is intentionally
7
+ # skipped via the `skip!` method. This represents a controlled interruption where
8
+ # the task determines that execution is not necessary or appropriate under the
9
+ # current conditions.
10
+ #
11
+ # Skipped faults are typically used for:
12
+ # - Conditional logic where certain conditions make execution unnecessary
13
+ # - Early returns when prerequisites are not met
14
+ # - Business logic that determines the operation is redundant
15
+ # - Graceful handling of edge cases that don't constitute errors
16
+ #
17
+ # @example Basic skip usage
18
+ # class ProcessOrderTask < CMDx::Task
19
+ # required :order_id, type: :integer
20
+ #
21
+ # def call
22
+ # context.order = Order.find(order_id)
23
+ # skip!(reason: "Order already processed") if context.order.processed?
24
+ #
25
+ # context.order.process!
26
+ # end
27
+ # end
28
+ #
29
+ # # Non-bang call returns result
30
+ # result = ProcessOrderTask.call(order_id: 123)
31
+ # result.skipped? #=> true
32
+ # result.metadata[:reason] #=> "Order already processed"
33
+ #
34
+ # # Bang call raises exception
35
+ # begin
36
+ # ProcessOrderTask.call!(order_id: 123)
37
+ # rescue CMDx::Skipped => e
38
+ # puts "Skipped: #{e.message}"
39
+ # end
40
+ #
41
+ # @example Conditional skip logic
42
+ # class SendNotificationTask < CMDx::Task
43
+ # required :user_id, type: :integer
44
+ # optional :force, type: :boolean, default: false
45
+ #
46
+ # def call
47
+ # context.user = User.find(user_id)
48
+ #
49
+ # unless force || context.user.notifications_enabled?
50
+ # skip!(reason: "User has notifications disabled")
51
+ # end
52
+ #
53
+ # NotificationService.send(context.user)
54
+ # end
55
+ # end
56
+ #
57
+ # @example Handling skipped tasks in workflows
58
+ # begin
59
+ # OrderProcessingWorkflow.call!(orders: orders)
60
+ # rescue CMDx::Skipped => e
61
+ # # Log skipped operations but continue processing
62
+ # logger.info "Skipped processing: #{e.message}"
63
+ # end
64
+ #
65
+ # @see Fault Base fault class with advanced matching capabilities
66
+ # @see Failed Failed fault type for error conditions
67
+ # @see Result#skip! Method for triggering skipped faults
68
+ # @since 1.0.0
6
69
  Skipped = Class.new(Fault)
7
70
 
8
- # Raised when halting task processing with failed context
71
+ ##
72
+ # Failed is a specific fault type raised when a task execution encounters an
73
+ # error condition via the `fail!` method. This represents a controlled failure
74
+ # where the task explicitly determines that execution cannot continue successfully.
75
+ #
76
+ # Failed faults are typically used for:
77
+ # - Validation errors that prevent successful execution
78
+ # - Business rule violations that constitute failures
79
+ # - Resource unavailability or constraint violations
80
+ # - Explicit error conditions that require attention
81
+ #
82
+ # @example Basic failure usage
83
+ # class ProcessPaymentTask < CMDx::Task
84
+ # required :payment_amount, type: :float
85
+ # required :payment_method, type: :string
86
+ #
87
+ # def call
88
+ # unless payment_amount > 0
89
+ # fail!(reason: "Payment amount must be positive", code: "INVALID_AMOUNT")
90
+ # end
91
+ #
92
+ # unless valid_payment_method?
93
+ # fail!(reason: "Invalid payment method", code: "INVALID_METHOD")
94
+ # end
95
+ #
96
+ # process_payment
97
+ # end
98
+ # end
99
+ #
100
+ # # Non-bang call returns result
101
+ # result = ProcessPaymentTask.call(payment_amount: -10, payment_method: "card")
102
+ # result.failed? #=> true
103
+ # result.metadata[:reason] #=> "Payment amount must be positive"
104
+ # result.metadata[:code] #=> "INVALID_AMOUNT"
105
+ #
106
+ # # Bang call raises exception
107
+ # begin
108
+ # ProcessPaymentTask.call!(payment_amount: -10, payment_method: "card")
109
+ # rescue CMDx::Failed => e
110
+ # puts "Failed: #{e.message}"
111
+ # puts "Error code: #{e.result.metadata[:code]}"
112
+ # end
113
+ #
114
+ # @example Validation failure with detailed metadata
115
+ # class CreateUserTask < CMDx::Task
116
+ # required :email, type: :string
117
+ # required :password, type: :string
118
+ #
119
+ # def call
120
+ # if User.exists?(email: email)
121
+ # fail!(
122
+ # "Email already exists",
123
+ # code: "EMAIL_EXISTS",
124
+ # field: "email",
125
+ # suggested_action: "Use different email or login instead"
126
+ # )
127
+ # end
128
+ #
129
+ # context.user = User.create!(email: email, password: password)
130
+ # end
131
+ # end
132
+ #
133
+ # @example Handling specific failure types
134
+ # begin
135
+ # ProcessOrderTask.call!(order_id: 123)
136
+ # rescue CMDx::Failed.matches? { |f| f.result.metadata[:code] == "PAYMENT_DECLINED" } => e
137
+ # # Handle payment failures specifically
138
+ # retry_with_backup_payment_method(e.context)
139
+ # rescue CMDx::Failed => e
140
+ # # Handle all other failures
141
+ # log_failure_and_notify_support(e)
142
+ # end
143
+ #
144
+ # @example Failure propagation in complex workflows
145
+ # class OrderFulfillmentTask < CMDx::Task
146
+ # def call
147
+ # payment_result = ProcessPaymentTask.call(context)
148
+ #
149
+ # if payment_result.failed?
150
+ # fail!(
151
+ # "Cannot fulfill order due to payment failure",
152
+ # code: "PAYMENT_REQUIRED",
153
+ # original_error: payment_result.metadata
154
+ # )
155
+ # end
156
+ #
157
+ # fulfill_order
158
+ # end
159
+ # end
160
+ #
161
+ # @see Fault Base fault class with advanced matching capabilities
162
+ # @see Skipped Skipped fault type for conditional interruptions
163
+ # @see Result#fail! Method for triggering failed faults
164
+ # @since 1.0.0
9
165
  Failed = Class.new(Fault)
10
166
 
11
167
  end
@@ -1,20 +1,139 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
+ ##
5
+ # Immutator provides task finalization by freezing objects to prevent mutation
6
+ # after task execution is complete. This ensures task immutability and prevents
7
+ # accidental side effects or modifications to completed task instances.
8
+ #
9
+ # The Immutator is automatically called during the task termination phase as part
10
+ # of the execution lifecycle. It freezes the task instance, its result, and
11
+ # associated objects to maintain data integrity and enforce the single-use pattern
12
+ # of CMDx tasks.
13
+ #
14
+ # ## Freezing Strategy
15
+ #
16
+ # The Immutator employs a selective freezing strategy:
17
+ # 1. **Task Instance**: Always frozen to prevent method calls and modifications
18
+ # 2. **Result Object**: Always frozen to preserve execution outcome
19
+ # 3. **Context Object**: Frozen only for the first task in a chain (index 0)
20
+ # 4. **Chain Object**: Frozen only for the first task in a chain (index 0)
21
+ #
22
+ # This selective approach allows subsequent tasks in a workflow or chain to continue
23
+ # using the shared context and chain objects while ensuring completed tasks remain
24
+ # immutable.
25
+ #
26
+ # ## Test Environment Handling
27
+ #
28
+ # In test environments (Rails or Rack), freezing is automatically disabled to
29
+ # prevent conflicts with test frameworks that may need to stub or mock frozen
30
+ # objects. This ensures smooth testing without compromising the immutability
31
+ # guarantees in production environments.
32
+ #
33
+ # @example Task execution with automatic freezing
34
+ # class ProcessOrderTask < CMDx::Task
35
+ # required :order_id, type: :integer
36
+ #
37
+ # def call
38
+ # context.order = Order.find(order_id)
39
+ # context.order.process!
40
+ # end
41
+ # end
42
+ #
43
+ # result = ProcessOrderTask.call(order_id: 123)
44
+ # result.frozen? #=> true
45
+ # result.task.frozen? #=> true
46
+ # result.context.frozen? #=> true (if first task in chain)
47
+ #
48
+ # @example Attempting to modify frozen task (will raise error)
49
+ # result = ProcessOrderTask.call(order_id: 123)
50
+ # result.context.new_field = "value" #=> FrozenError
51
+ # result.task.call #=> FrozenError
52
+ #
53
+ # @example Workflow execution with selective freezing
54
+ # class OrderWorkflow < CMDx::Workflow
55
+ # def call
56
+ # ProcessOrderTask.call(context)
57
+ # SendEmailTask.call(context)
58
+ # end
59
+ # end
60
+ #
61
+ # result = OrderWorkflow.call(order_id: 123)
62
+ # # First task freezes context and chain
63
+ # # Second task can still use unfrozen context for execution
64
+ # # But both task instances are individually frozen
65
+ #
66
+ # @example Test environment behavior
67
+ # # In test environment (SKIP_CMDX_FREEZING=1)
68
+ # result = ProcessOrderTask.call(order_id: 123)
69
+ # result.frozen? #=> false (freezing disabled)
70
+ # result.task.frozen? #=> false (allows stubbing/mocking)
71
+ #
72
+ # @see Task Task execution lifecycle
73
+ # @see Result Result object that gets frozen
74
+ # @see Context Context object that may get frozen
75
+ # @see Chain Chain object that may get frozen
76
+ # @since 1.0.0
4
77
  module Immutator
5
78
 
6
79
  module_function
7
80
 
81
+ ##
82
+ # Freezes task-related objects to ensure immutability after execution.
83
+ # This method is called automatically during task termination and implements
84
+ # a selective freezing strategy based on task position within a chain.
85
+ #
86
+ # The freezing process:
87
+ # 1. Checks if running in test environment and skips freezing if so
88
+ # 2. Always freezes the task instance and its result
89
+ # 3. Freezes context and chain only for the first task (index 0) in a chain
90
+ #
91
+ # This selective approach ensures that:
92
+ # - Completed tasks cannot be modified or re-executed
93
+ # - Results remain immutable and trustworthy
94
+ # - Shared objects (context/chain) remain available for subsequent tasks
95
+ # - Test environments can continue to function with mocking/stubbing
96
+ #
97
+ # @param task [Task] the task instance to freeze along with its associated objects
98
+ # @return [void]
99
+ #
100
+ # @example First task in chain (freezes everything)
101
+ # task = ProcessOrderTask.call(order_id: 123)
102
+ # # task.result.index == 0
103
+ # Immutator.call(task)
104
+ # # Freezes: task, result, context, chain
105
+ #
106
+ # @example Subsequent task in chain (selective freezing)
107
+ # # After first task has run
108
+ # task = SendEmailTask.call(context)
109
+ # # task.result.index == 1
110
+ # Immutator.call(task)
111
+ # # Freezes: task, result (context and chain remain unfrozen)
112
+ #
113
+ # @example Test environment (no freezing)
114
+ # ENV["RAILS_ENV"] = "test"
115
+ # task = ProcessOrderTask.call(order_id: 123)
116
+ # Immutator.call(task)
117
+ # # No objects are frozen, allows test stubbing
118
+ #
119
+ # @note This method is automatically called by the task execution framework
120
+ # and should not typically be called directly by user code.
121
+ #
122
+ # @note Freezing is skipped entirely in test environments to prevent conflicts
123
+ # with test frameworks that need to stub or mock objects.
8
124
  def call(task)
9
125
  # Stubbing on frozen objects is not allowed
10
- return if (ENV.fetch("RAILS_ENV", nil) || ENV.fetch("RACK_ENV", nil)) == "test"
126
+ skip_freezing = ENV.fetch("SKIP_CMDX_FREEZING", false)
127
+ return if Coercions::Boolean.call(skip_freezing)
11
128
 
12
129
  task.freeze
13
130
  task.result.freeze
14
131
  return unless task.result.index.zero?
15
132
 
16
133
  task.context.freeze
17
- task.run.freeze
134
+ task.chain.freeze
135
+
136
+ Chain.clear
18
137
  end
19
138
 
20
139
  end
@@ -1,78 +1,321 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
+ ##
5
+ # LazyStruct provides a flexible, hash-like data structure with dynamic method access
6
+ # and lazy attribute definition. It serves as the foundation for CMDx's Context system,
7
+ # allowing for dynamic parameter access and manipulation with both hash-style and
8
+ # method-style syntax.
9
+ #
10
+ # LazyStruct combines the flexibility of a Hash with the convenience of method access,
11
+ # automatically creating getter and setter methods for any key-value pairs stored within it.
12
+ # All keys are normalized to symbols for consistent access patterns.
13
+ #
14
+ #
15
+ # @example Basic usage
16
+ # struct = LazyStruct.new(name: "John", age: 30)
17
+ # struct.name #=> "John"
18
+ # struct.age #=> 30
19
+ # struct[:name] #=> "John"
20
+ # struct["age"] #=> 30
21
+ #
22
+ # @example Dynamic attribute assignment
23
+ # struct = LazyStruct.new
24
+ # struct.email = "john@example.com"
25
+ # struct[:phone] = "555-1234"
26
+ # struct["address"] = "123 Main St"
27
+ #
28
+ # struct.email #=> "john@example.com"
29
+ # struct.phone #=> "555-1234"
30
+ # struct.address #=> "123 Main St"
31
+ #
32
+ # @example Hash-like operations
33
+ # struct = LazyStruct.new(name: "John")
34
+ # struct.merge!(age: 30, city: "NYC")
35
+ # struct.delete!(:city)
36
+ # struct.to_h #=> {:name => "John", :age => 30}
37
+ #
38
+ # @example Nested data access
39
+ # struct = LazyStruct.new(user: {profile: {name: "John"}})
40
+ # struct.dig(:user, :profile, :name) #=> "John"
41
+ #
42
+ # @example Usage in CMDx Context
43
+ # class ProcessUserTask < CMDx::Task
44
+ # required :user_id, type: :integer
45
+ #
46
+ # def call
47
+ # context.user = User.find(user_id)
48
+ # context.processed_at = Time.now
49
+ # context.result_data = {status: "complete"}
50
+ # end
51
+ # end
52
+ #
53
+ # result = ProcessUserTask.call(user_id: 123)
54
+ # result.context.user #=> <User id: 123>
55
+ # result.context.processed_at #=> 2023-01-01 12:00:00 UTC
56
+ #
57
+ # @see Context Context class that inherits from LazyStruct
58
+ # @see Configuration Configuration class that uses LazyStruct
59
+ # @since 1.0.0
4
60
  class LazyStruct
5
61
 
62
+ ##
63
+ # Initializes a new LazyStruct with the given data.
64
+ # The input must respond to `to_h` for hash conversion.
65
+ #
66
+ # @param args [Hash, #to_h] initial data for the struct
67
+ # @raise [ArgumentError] if args doesn't respond to `to_h`
68
+ #
69
+ # @example With hash
70
+ # struct = LazyStruct.new(name: "John", age: 30)
71
+ #
72
+ # @example With hash-like object
73
+ # params = ActionController::Parameters.new(name: "John")
74
+ # struct = LazyStruct.new(params)
75
+ #
76
+ # @example Empty initialization
77
+ # struct = LazyStruct.new
78
+ # struct.name = "John" # Dynamic assignment
6
79
  def initialize(args = {})
7
80
  unless args.respond_to?(:to_h)
8
81
  raise ArgumentError,
9
82
  "must be respond to `to_h`"
10
83
  end
11
84
 
12
- @table = args.transform_keys(&:to_sym)
85
+ @table = args.to_h.transform_keys { |k| symbolized_key(k) }
13
86
  end
14
87
 
88
+ ##
89
+ # Retrieves a value by key using hash-style access.
90
+ # Keys are automatically converted to symbols.
91
+ #
92
+ # @param key [Symbol, String] the key to retrieve
93
+ # @return [Object, nil] the stored value or nil if not found
94
+ #
95
+ # @example
96
+ # struct[:name] #=> "John"
97
+ # struct["name"] #=> "John"
98
+ # struct[:missing] #=> nil
15
99
  def [](key)
16
- @table[key.to_sym]
100
+ table[symbolized_key(key)]
17
101
  end
18
102
 
103
+ ##
104
+ # Retrieves a value by key with error handling and default support.
105
+ # Similar to Hash#fetch, raises KeyError if key not found and no default given.
106
+ #
107
+ # @param key [Symbol, String] the key to retrieve
108
+ # @param args [Array] default value if key not found
109
+ # @return [Object] the stored value or default
110
+ # @raise [KeyError] if key not found and no default provided
111
+ #
112
+ # @example With existing key
113
+ # struct.fetch!(:name) #=> "John"
114
+ #
115
+ # @example With default value
116
+ # struct.fetch!(:missing, "default") #=> "default"
117
+ #
118
+ # @example With block default
119
+ # struct.fetch!(:missing) { "computed default" } #=> "computed default"
120
+ #
121
+ # @example Key not found
122
+ # struct.fetch!(:missing) #=> raises KeyError
19
123
  def fetch!(key, ...)
20
- @table.fetch(key.to_sym, ...)
124
+ table.fetch(symbolized_key(key), ...)
21
125
  end
22
126
 
127
+ ##
128
+ # Stores a value by key, converting the key to a symbol.
129
+ #
130
+ # @param key [Symbol, String] the key to store under
131
+ # @param value [Object] the value to store
132
+ # @return [Object] the stored value
133
+ #
134
+ # @example
135
+ # struct.store!(:name, "John")
136
+ # struct.store!("age", 30)
137
+ # struct.name #=> "John"
138
+ # struct.age #=> 30
23
139
  def store!(key, value)
24
- @table[key.to_sym] = value
140
+ table[symbolized_key(key)] = value
25
141
  end
26
142
  alias []= store!
27
143
 
144
+ ##
145
+ # Merges another hash-like object into this struct.
146
+ # All keys from the source are converted to symbols.
147
+ #
148
+ # @param args [Hash, #to_h] data to merge into this struct
149
+ # @return [LazyStruct] self for method chaining
150
+ #
151
+ # @example
152
+ # struct = LazyStruct.new(name: "John")
153
+ # struct.merge!(age: 30, city: "NYC")
154
+ # struct.to_h #=> {:name => "John", :age => 30, :city => "NYC"}
28
155
  def merge!(args = {})
29
- args.to_h.each { |key, value| store!(key, value) }
156
+ args.to_h.each { |key, value| store!(symbolized_key(key), value) }
30
157
  self
31
158
  end
32
159
 
160
+ ##
161
+ # Deletes a key-value pair from the struct.
162
+ #
163
+ # @param key [Symbol, String] the key to delete
164
+ # @param block [Proc] optional block to execute if key not found
165
+ # @return [Object, nil] the deleted value or result of block
166
+ #
167
+ # @example
168
+ # struct.delete!(:name) #=> "John"
169
+ # struct.delete!(:missing) #=> nil
170
+ # struct.delete!(:missing) { "not found" } #=> "not found"
33
171
  def delete!(key, &)
34
- @table.delete(key.to_sym, &)
172
+ table.delete(symbolized_key(key), &)
35
173
  end
36
174
  alias delete_field! delete!
37
175
 
176
+ ##
177
+ # Compares this struct with another for equality.
178
+ # Two LazyStructs are equal if they have the same class and hash representation.
179
+ #
180
+ # @param other [Object] object to compare with
181
+ # @return [Boolean] true if structs are equal
182
+ #
183
+ # @example
184
+ # struct1 = LazyStruct.new(name: "John")
185
+ # struct2 = LazyStruct.new(name: "John")
186
+ # struct1 == struct2 #=> true
187
+ # struct1.eql?(struct2) #=> true
38
188
  def eql?(other)
39
189
  other.is_a?(self.class) && (to_h == other.to_h)
40
190
  end
41
191
  alias == eql?
42
192
 
193
+ ##
194
+ # Retrieves nested values using a sequence of keys.
195
+ # Similar to Hash#dig, safely navigates nested structures.
196
+ #
197
+ # @param key [Symbol, String] the first key to access
198
+ # @param keys [Array<Symbol, String>] additional keys for nested access
199
+ # @return [Object, nil] the nested value or nil if path doesn't exist
200
+ # @raise [TypeError] if key cannot be converted to symbol
201
+ #
202
+ # @example
203
+ # struct = LazyStruct.new(user: {profile: {name: "John"}})
204
+ # struct.dig(:user, :profile, :name) #=> "John"
205
+ # struct.dig(:user, :missing, :name) #=> nil
43
206
  def dig(key, *keys)
44
- begin
45
- key = key.to_sym
46
- rescue NoMethodError
47
- raise TypeError, "#{key} is not a symbol nor a string"
48
- end
49
-
50
- @table.dig(key, *keys)
207
+ table.dig(symbolized_key(key), *keys)
51
208
  end
52
209
 
210
+ ##
211
+ # Iterates over each key-value pair in the struct.
212
+ #
213
+ # @yieldparam key [Symbol] the key
214
+ # @yieldparam value [Object] the value
215
+ # @return [LazyStruct] self if block given, Enumerator otherwise
216
+ #
217
+ # @example
218
+ # struct.each_pair { |key, value| puts "#{key}: #{value}" }
53
219
  def each_pair(&)
54
- @table.each_pair(&)
220
+ table.each_pair(&)
55
221
  end
56
222
 
223
+ ##
224
+ # Converts the struct to a hash representation.
225
+ #
226
+ # @param block [Proc] optional block for hash transformation
227
+ # @return [Hash] hash representation with symbol keys
228
+ #
229
+ # @example
230
+ # struct = LazyStruct.new(name: "John", age: 30)
231
+ # struct.to_h #=> {:name => "John", :age => 30}
57
232
  def to_h(&)
58
- @table.to_h(&)
233
+ table.to_h(&)
59
234
  end
60
235
 
236
+ ##
237
+ # Returns a string representation of the struct showing all key-value pairs.
238
+ #
239
+ # @return [String] formatted string representation
240
+ #
241
+ # @example
242
+ # struct = LazyStruct.new(name: "John", age: 30)
243
+ # struct.inspect #=> '#<CMDx::LazyStruct:name="John" :age=30>'
61
244
  def inspect
62
- "#<#{self.class}#{@table.map { |key, value| ":#{key}=#{value.inspect}" }.join(' ')}>"
245
+ "#<#{self.class.name}#{table.map { |key, value| ":#{key}=#{value.inspect}" }.join(' ')}>"
63
246
  end
64
247
  alias to_s inspect
65
248
 
66
249
  private
67
250
 
251
+ def table
252
+ @table ||= {}
253
+ end
254
+
255
+ ##
256
+ # Handles dynamic method calls for attribute access and assignment.
257
+ # Getter methods return the stored value, setter methods (ending with =) store values.
258
+ #
259
+ # @param method_name [Symbol] the method name being called
260
+ # @param args [Array] arguments passed to the method
261
+ # @return [Object] the stored value for getters, the assigned value for setters
262
+ #
263
+ # @example Getter methods
264
+ # struct.name # Calls method_missing(:name)
265
+ # struct.undefined # Calls method_missing(:undefined) => nil
266
+ #
267
+ # @example Setter methods
268
+ # struct.name = "John" # Calls method_missing(:name=, "John")
269
+ #
270
+ # @api private
68
271
  def method_missing(method_name, *args, **_kwargs, &)
69
- @table.fetch(method_name.to_sym) do
272
+ table.fetch(symbolized_key(method_name)) do
70
273
  store!(method_name[0..-2], args.first) if method_name.end_with?("=")
71
274
  end
72
275
  end
73
276
 
277
+ ##
278
+ # Determines if the struct responds to a given method name.
279
+ # Returns true for any key in the internal table or standard methods.
280
+ #
281
+ # @param method_name [Symbol] the method name to check
282
+ # @param include_private [Boolean] whether to include private methods
283
+ # @return [Boolean] true if the struct responds to the method
284
+ #
285
+ # @example
286
+ # struct = LazyStruct.new(name: "John")
287
+ # struct.respond_to?(:name) #=> true
288
+ # struct.respond_to?(:missing) #=> false
289
+ # struct.respond_to?(:to_h) #=> true
290
+ #
291
+ # @api private
74
292
  def respond_to_missing?(method_name, include_private = false)
75
- @table.key?(method_name.to_sym) || super
293
+ table.key?(symbolized_key(method_name)) || super
294
+ end
295
+
296
+ ##
297
+ # Converts a key to a symbol for consistent internal storage.
298
+ # This method normalizes all keys to symbols regardless of their input type,
299
+ # ensuring consistent access patterns throughout the LazyStruct.
300
+ #
301
+ # @param key [Object] the key to convert to a symbol
302
+ # @return [Symbol] the key converted to a symbol
303
+ # @raise [TypeError] if the key cannot be converted to a symbol (doesn't respond to `to_sym`)
304
+ #
305
+ # @example Valid key conversion
306
+ # symbolized_key("name") #=> :name
307
+ # symbolized_key(:name) #=> :name
308
+ # symbolized_key("123") #=> :"123"
309
+ #
310
+ # @example Invalid key conversion
311
+ # symbolized_key(Object.new) #=> raises TypeError
312
+ # symbolized_key(123) #=> raises TypeError
313
+ #
314
+ # @api private
315
+ def symbolized_key(key)
316
+ key.to_sym
317
+ rescue NoMethodError
318
+ raise TypeError, "#{key} is not a symbol nor a string"
76
319
  end
77
320
 
78
321
  end