rooibos 0.6.2 → 0.7.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 (217) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSES/BSD-2-Clause.txt +9 -0
  3. data/REUSE.toml +5 -0
  4. data/exe/.gitkeep +0 -0
  5. data/lib/rooibos/cli/commands/new.rb +24 -0
  6. data/lib/rooibos/command/batch.rb +10 -0
  7. data/lib/rooibos/command/bubble.rb +34 -0
  8. data/lib/rooibos/command/custom.rb +3 -2
  9. data/lib/rooibos/command/deliver.rb +50 -0
  10. data/lib/rooibos/command/http.rb +1 -1
  11. data/lib/rooibos/command/lifecycle.rb +3 -1
  12. data/lib/rooibos/command/outlet.rb +19 -9
  13. data/lib/rooibos/command.rb +107 -3
  14. data/lib/rooibos/configuration.rb +29 -0
  15. data/lib/rooibos/message/bubbled.rb +29 -0
  16. data/lib/rooibos/message.rb +24 -6
  17. data/lib/rooibos/router/action.rb +36 -0
  18. data/lib/rooibos/router/flow/dispatch.rb +39 -0
  19. data/lib/rooibos/router/flow/inward.rb +41 -0
  20. data/lib/rooibos/router/flow/outward.rb +44 -0
  21. data/lib/rooibos/router/guard.rb +56 -0
  22. data/lib/rooibos/router/predicate.rb +65 -0
  23. data/lib/rooibos/router/registry/actions.rb +41 -0
  24. data/lib/rooibos/router/registry/forwards.rb +58 -0
  25. data/lib/rooibos/router/registry/observes.rb +57 -0
  26. data/lib/rooibos/router/registry/otherwises.rb +29 -0
  27. data/lib/rooibos/router/registry/receives.rb +57 -0
  28. data/lib/rooibos/router/registry/routes.rb +59 -0
  29. data/lib/rooibos/router/registry.rb +26 -0
  30. data/lib/rooibos/router/route.rb +42 -0
  31. data/lib/rooibos/router/router_update.rb +53 -0
  32. data/lib/rooibos/router/rule/forward.rb +39 -0
  33. data/lib/rooibos/router/rule/observe.rb +22 -0
  34. data/lib/rooibos/router/rule/otherwise.rb +26 -0
  35. data/lib/rooibos/router/rule/receive.rb +22 -0
  36. data/lib/rooibos/router/rule.rb +40 -0
  37. data/lib/rooibos/router.rb +424 -438
  38. data/lib/rooibos/runtime.rb +37 -52
  39. data/lib/rooibos/test_helper.rb +22 -0
  40. data/lib/rooibos/transition.rb +92 -0
  41. data/lib/rooibos/version.rb +1 -1
  42. data/lib/rooibos.rb +2 -57
  43. data/sig/rooibos/cli.rbs +1 -0
  44. data/sig/rooibos/command.rbs +44 -0
  45. data/sig/rooibos/configuration.rbs +20 -0
  46. data/sig/rooibos/message.rbs +12 -0
  47. data/sig/rooibos/router/action.rbs +33 -0
  48. data/sig/rooibos/router/actions.rbs +27 -0
  49. data/sig/rooibos/router/flow/dispatch.rbs +29 -0
  50. data/sig/rooibos/router/flow/inward.rbs +37 -0
  51. data/sig/rooibos/router/flow/outward.rbs +36 -0
  52. data/sig/rooibos/router/forward.rbs +35 -0
  53. data/sig/rooibos/router/forwards.rbs +34 -0
  54. data/sig/rooibos/router/guard.rbs +21 -0
  55. data/sig/rooibos/router/observe.rbs +20 -0
  56. data/sig/rooibos/router/observes.rbs +38 -0
  57. data/sig/rooibos/router/otherwise.rbs +22 -0
  58. data/sig/rooibos/router/otherwises.rbs +20 -0
  59. data/sig/rooibos/router/predicate.rbs +51 -0
  60. data/sig/rooibos/router/receive.rbs +20 -0
  61. data/sig/rooibos/router/receives.rbs +38 -0
  62. data/sig/rooibos/router/registry.rbs +24 -0
  63. data/sig/rooibos/router/route.rbs +46 -0
  64. data/sig/rooibos/router/router_update.rbs +33 -0
  65. data/sig/rooibos/router/routes.rbs +41 -0
  66. data/sig/rooibos/router/rule.rbs +36 -0
  67. data/sig/rooibos/router.rbs +216 -161
  68. data/sig/rooibos/runtime.rbs +0 -1
  69. data/sig/rooibos/test_helper.rbs +6 -0
  70. data/sig/rooibos/transition.rbs +33 -0
  71. data/sig/rooibos.rbs +0 -10
  72. metadata +144 -198
  73. data/.builds/ruby-3.2.yml +0 -55
  74. data/.builds/ruby-3.3.yml +0 -55
  75. data/.builds/ruby-3.4.yml +0 -55
  76. data/.builds/ruby-4.0.0.yml +0 -55
  77. data/.pre-commit-config.yaml +0 -16
  78. data/.rubocop.yml +0 -8
  79. data/AGENTS.md +0 -108
  80. data/CHANGELOG.md +0 -308
  81. data/README.md +0 -183
  82. data/README.rdoc +0 -374
  83. data/Rakefile +0 -16
  84. data/Steepfile +0 -13
  85. data/doc/best_practices/forms_and_validation.md +0 -20
  86. data/doc/best_practices/http_workflows.md +0 -20
  87. data/doc/best_practices/index.md +0 -26
  88. data/doc/best_practices/lists_and_tables.md +0 -20
  89. data/doc/best_practices/modal_dialogs.md +0 -20
  90. data/doc/best_practices/no_stateful_widgets.md +0 -184
  91. data/doc/best_practices/orchestration.md +0 -20
  92. data/doc/best_practices/streaming_data.md +0 -20
  93. data/doc/contributors/design/commands_and_outlets.md +0 -214
  94. data/doc/contributors/design/mvu_tea_implementations_research.md +0 -373
  95. data/doc/contributors/documentation_plan.md +0 -616
  96. data/doc/contributors/documentation_stub_audit.md +0 -112
  97. data/doc/contributors/documentation_style.md +0 -275
  98. data/doc/contributors/e2e_pty.md +0 -168
  99. data/doc/contributors/maybe_stateful_router.md +0 -56
  100. data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +0 -70
  101. data/doc/contributors/specs/file_browser.md +0 -789
  102. data/doc/contributors/specs/file_browser_stories.md +0 -784
  103. data/doc/contributors/specs/tutorials_to_stories.rb +0 -167
  104. data/doc/contributors/todo/scrollbar.md +0 -118
  105. data/doc/contributors/tutorial_old/01_project_setup.md +0 -20
  106. data/doc/contributors/tutorial_old/02_hello_world.md +0 -24
  107. data/doc/contributors/tutorial_old/03_adding_state.md +0 -26
  108. data/doc/contributors/tutorial_old/06_organizing_your_code.md +0 -20
  109. data/doc/contributors/tutorial_old/07_your_first_command.md +0 -21
  110. data/doc/contributors/tutorial_old/08_the_preview_pane.md +0 -20
  111. data/doc/contributors/tutorial_old/09_loading_states.md +0 -20
  112. data/doc/contributors/tutorial_old/10_testing_your_app.md +0 -20
  113. data/doc/contributors/tutorial_old/11_polish_and_refine.md +0 -20
  114. data/doc/contributors/tutorial_old/12_going_further.md +0 -20
  115. data/doc/contributors/tutorial_old/index.md +0 -20
  116. data/doc/custom.css +0 -22
  117. data/doc/essentials/commands.md +0 -20
  118. data/doc/essentials/index.md +0 -31
  119. data/doc/essentials/messages.md +0 -21
  120. data/doc/essentials/models.md +0 -21
  121. data/doc/essentials/shortcuts.md +0 -19
  122. data/doc/essentials/the_elm_architecture.md +0 -24
  123. data/doc/essentials/the_runtime.md +0 -21
  124. data/doc/essentials/update_functions.md +0 -20
  125. data/doc/essentials/views.md +0 -22
  126. data/doc/getting_started/for_go_developers.md +0 -16
  127. data/doc/getting_started/for_python_developers.md +0 -16
  128. data/doc/getting_started/for_rails_developers.md +0 -17
  129. data/doc/getting_started/for_ratatui_ruby_developers.md +0 -17
  130. data/doc/getting_started/for_react_developers.md +0 -17
  131. data/doc/getting_started/index.md +0 -52
  132. data/doc/getting_started/install.md +0 -20
  133. data/doc/getting_started/quickstart.md +0 -20
  134. data/doc/getting_started/ruby_primer.md +0 -19
  135. data/doc/getting_started/why_rooibos.md +0 -20
  136. data/doc/images/verify_readme_usage.png +0 -0
  137. data/doc/images/widget_cmd_exec.png +0 -0
  138. data/doc/index.md +0 -93
  139. data/doc/scaling_up/async_patterns.md +0 -20
  140. data/doc/scaling_up/command_composition.md +0 -20
  141. data/doc/scaling_up/custom_commands.md +0 -21
  142. data/doc/scaling_up/fractal_architecture.md +0 -20
  143. data/doc/scaling_up/index.md +0 -30
  144. data/doc/scaling_up/message_routing.md +0 -20
  145. data/doc/scaling_up/ractor_safety.md +0 -20
  146. data/doc/scaling_up/testing.md +0 -21
  147. data/doc/troubleshooting/common_errors.md +0 -20
  148. data/doc/troubleshooting/debugging.md +0 -21
  149. data/doc/troubleshooting/index.md +0 -23
  150. data/doc/troubleshooting/performance.md +0 -20
  151. data/doc/tutorial/01_project_setup.md +0 -44
  152. data/doc/tutorial/02_hello_world.md +0 -45
  153. data/doc/tutorial/03_static_file_list.md +0 -44
  154. data/doc/tutorial/04_arrow_navigation.md +0 -47
  155. data/doc/tutorial/05_real_files.md +0 -45
  156. data/doc/tutorial/06_safe_refactoring.md +0 -21
  157. data/doc/tutorial/07_red_first_tdd.md +0 -26
  158. data/doc/tutorial/08_file_metadata.md +0 -42
  159. data/doc/tutorial/09_text_preview.md +0 -44
  160. data/doc/tutorial/10_directory_tree.md +0 -42
  161. data/doc/tutorial/11_pane_focus.md +0 -40
  162. data/doc/tutorial/12_sorting.md +0 -41
  163. data/doc/tutorial/13_filtering.md +0 -43
  164. data/doc/tutorial/14_toggle_hidden.md +0 -41
  165. data/doc/tutorial/15_text_input_widget.md +0 -43
  166. data/doc/tutorial/16_rename_files.md +0 -42
  167. data/doc/tutorial/17_confirmation_dialogs.md +0 -43
  168. data/doc/tutorial/18_progress_indicators.md +0 -43
  169. data/doc/tutorial/19_atomic_operations.md +0 -42
  170. data/doc/tutorial/20_external_editor.md +0 -42
  171. data/doc/tutorial/21_modal_overlays.md +0 -41
  172. data/doc/tutorial/22_error_handling.md +0 -43
  173. data/doc/tutorial/23_terminal_capabilities.md +0 -53
  174. data/doc/tutorial/24_mouse_events.md +0 -43
  175. data/doc/tutorial/25_resize_events.md +0 -43
  176. data/doc/tutorial/26_loading_states.md +0 -42
  177. data/doc/tutorial/27_performance.md +0 -43
  178. data/doc/tutorial/28_color_schemes.md +0 -47
  179. data/doc/tutorial/29_configuration.md +0 -124
  180. data/doc/tutorial/30_going_further.md +0 -17
  181. data/doc/tutorial/index.md +0 -17
  182. data/examples/app_fractal_dashboard/README.md +0 -60
  183. data/examples/app_fractal_dashboard/app.rb +0 -63
  184. data/examples/app_fractal_dashboard/dashboard/base.rb +0 -73
  185. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +0 -86
  186. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +0 -87
  187. data/examples/app_fractal_dashboard/dashboard/update_router.rb +0 -43
  188. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +0 -81
  189. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +0 -82
  190. data/examples/app_fractal_dashboard/fragments/custom_shell_output.rb +0 -90
  191. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +0 -47
  192. data/examples/app_fractal_dashboard/fragments/network_panel.rb +0 -45
  193. data/examples/app_fractal_dashboard/fragments/ping.rb +0 -47
  194. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +0 -45
  195. data/examples/app_fractal_dashboard/fragments/system_info.rb +0 -47
  196. data/examples/app_fractal_dashboard/fragments/uptime.rb +0 -47
  197. data/examples/tutorial/01/app.rb +0 -50
  198. data/examples/tutorial/02/app.rb +0 -64
  199. data/examples/tutorial/03/app.rb +0 -91
  200. data/examples/tutorial/06_safe_refactoring/app.rb +0 -124
  201. data/examples/verify_readme_usage/README.md +0 -54
  202. data/examples/verify_readme_usage/app.rb +0 -47
  203. data/examples/verify_website_first_app/app.rb +0 -85
  204. data/examples/verify_website_hello_mvu/app.rb +0 -31
  205. data/examples/widget_command_system/README.md +0 -70
  206. data/examples/widget_command_system/app.rb +0 -134
  207. data/generate_tutorial_stubs.rb +0 -126
  208. data/mise.toml +0 -8
  209. data/rbs_collection.lock.yaml +0 -108
  210. data/rbs_collection.yaml +0 -15
  211. data/tasks/example_viewer.html.erb +0 -172
  212. data/tasks/install.rake +0 -29
  213. data/tasks/resources/build.yml.erb +0 -55
  214. data/tasks/resources/index.html.erb +0 -44
  215. data/tasks/resources/rubies.yml +0 -7
  216. data/tasks/steep.rake +0 -11
  217. /data/{vendor/goodcop/base.yml → lib/rooibos/rubocop.yml} +0 -0
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ module Flow
13
+ # Outward flow: messages traveling toward the root.
14
+ #
15
+ # Observe → intercept.
16
+ class Outward < Data.define(:observes, :receives, :routes)
17
+ include Dispatch
18
+
19
+ # Sentinel: intercept consumed the bubble. Non-nil so
20
+ # Transition#with_command replaces the original Command::Bubble
21
+ # instead of preserving it via the nil no-op.
22
+ INTERCEPTED = Object.new.freeze
23
+
24
+ def call(message, model)
25
+ transition = run_all(observes, message, model)
26
+ config = Configuration.new(message:, model: transition.model)
27
+ intercept_first_matching(receives, config, transition) ||
28
+ transition
29
+ end
30
+
31
+ private def intercept_first_matching(rules, config, transition)
32
+ rules.each do |rule|
33
+ new_transition = rule.apply_if_matches(config, routes) or next
34
+ return transition
35
+ .with_model(new_transition.model)
36
+ .with_command(new_transition.command || INTERCEPTED)
37
+ end
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ private_constant :Flow
43
+ end
44
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ # Normalizes guard options into a callable.
13
+ class Guard < Data.define(:callable)
14
+ @scope = []
15
+
16
+ def self.scoped(callable)
17
+ @scope.push(callable)
18
+ yield
19
+ ensure
20
+ @scope.pop
21
+ end
22
+
23
+ def self.from(guard: nil, when: nil, unless: nil, if: nil, only: nil, except: nil, skip: nil)
24
+ when_guard = binding.local_variable_get(:when) ||
25
+ binding.local_variable_get(:if) ||
26
+ binding.local_variable_get(:only)
27
+ unless_guard = binding.local_variable_get(:unless) ||
28
+ binding.local_variable_get(:except) ||
29
+ binding.local_variable_get(:skip)
30
+ normalized = guard || when_guard
31
+ normalized = -> (msg, model) { !unless_guard.call(msg, model) } if unless_guard
32
+ new(callable: apply_scope(normalized))
33
+ end
34
+
35
+ private_class_method def self.apply_scope(callable)
36
+ @scope.inject(callable) do |inner, outer|
37
+ if inner
38
+ -> (msg, model) { outer.call(msg, model) && inner.call(msg, model) }
39
+ else
40
+ outer
41
+ end
42
+ end
43
+ end
44
+
45
+ def arity
46
+ callable&.arity || 2
47
+ end
48
+
49
+ def call(message, model)
50
+ return true unless callable
51
+ callable.arity.zero? ? callable.call : callable.call(message, model)
52
+ end
53
+ end
54
+ private_constant :Guard
55
+ end
56
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ module Predicate
13
+ # Matches key events by symbol.
14
+ class Events < Data.define(:keys)
15
+ def initialize(keys:)
16
+ super(keys: Array(keys))
17
+ end
18
+
19
+ def arity = 2
20
+
21
+ def call(message, _model)
22
+ message.respond_to?(:to_sym) && keys.include?(message.to_sym)
23
+ end
24
+ end
25
+
26
+ # Matches routed messages by envelope.
27
+ class Routed < Data.define(:envelope)
28
+ def arity = 2
29
+
30
+ def call(message, _model)
31
+ message.is_a?(Message::Routed) && message.envelope == envelope
32
+ end
33
+ end
34
+
35
+ # Matches routed messages by any of the given envelopes.
36
+ class RoutedEnvelopes < Data.define(:envelopes)
37
+ def initialize(envelopes:)
38
+ super(envelopes: Array(envelopes))
39
+ end
40
+
41
+ def arity = 2
42
+
43
+ def call(message, _model)
44
+ message.routed? && envelopes.include?(message.envelope)
45
+ end
46
+ end
47
+
48
+ # Matches messages by class.
49
+ class InstancesOf < Data.define(:klass)
50
+ def arity = 2
51
+
52
+ def call(message, _model)
53
+ message.is_a?(klass)
54
+ end
55
+ end
56
+
57
+ # Matches all messages.
58
+ class Always < Data.define
59
+ def arity = 2
60
+ def call(_message, _model) = true
61
+ end
62
+ end
63
+ private_constant :Predicate
64
+ end
65
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ # Collection of named actions.
13
+ class Actions < Data.define(:rules)
14
+ include Enumerable
15
+
16
+ def initialize(rules: [])
17
+ super
18
+ end
19
+
20
+ def add(name, handler)
21
+ action = if handler.is_a?(Module)
22
+ RoutedAction.new(name: name.to_sym, fragment: handler)
23
+ else
24
+ LambdaAction.new(name: name.to_sym, handler:)
25
+ end
26
+ rules << action
27
+ end
28
+
29
+ def [](name)
30
+ find { |action| action.name == name.to_sym } or
31
+ raise ArgumentError, "Unknown action: #{name}"
32
+ end
33
+
34
+ def each(&block)
35
+ return rules.each unless block
36
+ rules.each { |action| block.call(action) }
37
+ end
38
+ end
39
+ private_constant :Actions
40
+ end
41
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ # Collection of forwards
13
+ class Forwards < Data.define(:rules)
14
+ include Registry
15
+
16
+ def initialize(rules: [])
17
+ super
18
+ end
19
+
20
+ def add_instances_of(klass, to: nil, as: nil, broadcast: false, broadcast_to: nil) = add(Forward.new(
21
+ predicate: Predicate::InstancesOf.new(klass:),
22
+ targets: resolve_targets(to:, broadcast:, broadcast_to:),
23
+ envelope: as
24
+ ))
25
+
26
+ def add_events(keys, to:, as: nil, guard: nil, when: nil, unless: nil) = add(Forward.new(
27
+ predicate: Predicate::Events.new(keys:),
28
+ targets: to,
29
+ envelope: as,
30
+ guard: Guard.from(guard:, when: binding.local_variable_get(:when), unless: binding.local_variable_get(:unless))
31
+ ))
32
+
33
+ def add_routed(envelopes, to:, as: nil) = add(Forward.new(
34
+ predicate: Predicate::RoutedEnvelopes.new(envelopes:),
35
+ targets: to,
36
+ envelope: as
37
+ ))
38
+
39
+ def add_custom(predicate, to:, as: nil, **guard_opts) = add(Forward.new(
40
+ predicate:,
41
+ targets: to,
42
+ envelope: as,
43
+ guard: Guard.from(**guard_opts)
44
+ ))
45
+
46
+ private def resolve_targets(to:, broadcast:, broadcast_to:)
47
+ if broadcast
48
+ ALL_ROUTES
49
+ elsif broadcast_to
50
+ broadcast_to
51
+ else
52
+ to
53
+ end
54
+ end
55
+ end
56
+ private_constant :Forwards
57
+ end
58
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ # Collection of observe rules.
13
+ class Observes < Data.define(:rules, :actions)
14
+ include Registry
15
+
16
+ def initialize(rules: [], actions:)
17
+ super
18
+ end
19
+
20
+ def add_events(keys, handler, **guard_opts) = add(Observe.new(
21
+ predicate: Predicate::Events.new(keys:),
22
+ action: resolve(handler),
23
+ guard: Guard.from(**guard_opts)
24
+ ))
25
+
26
+ def add_routed(envelope, handler, guard: nil) = add(Observe.new(
27
+ predicate: Predicate::Routed.new(envelope:),
28
+ action: resolve(handler),
29
+ guard: Guard.from(guard:)
30
+ ))
31
+
32
+ def add_instances_of(klass, handler, guard: nil) = add(Observe.new(
33
+ predicate: Predicate::InstancesOf.new(klass:),
34
+ action: resolve(handler),
35
+ guard: Guard.from(guard:)
36
+ ))
37
+
38
+ def add_all(handler, **guard_opts) = add(Observe.new(
39
+ predicate: Predicate::Always.new,
40
+ action: resolve(handler),
41
+ guard: Guard.from(**guard_opts)
42
+ ))
43
+
44
+ def add_custom(predicate, handler, **guard_opts) = add(Observe.new(
45
+ predicate:,
46
+ action: resolve(handler),
47
+ guard: Guard.from(**guard_opts)
48
+ ))
49
+
50
+ private def resolve(handler)
51
+ return actions[handler] if handler.is_a?(Symbol)
52
+ LambdaAction.new(name: nil, handler:)
53
+ end
54
+ end
55
+ private_constant :Observes
56
+ end
57
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ # Collection of otherwise rules.
13
+ class Otherwises < Data.define(:rules)
14
+ include Registry
15
+
16
+ def initialize(rules: [])
17
+ super
18
+ end
19
+
20
+ def add(route_to:, **guard_opts)
21
+ super(Otherwise.new(
22
+ target: route_to,
23
+ guard: Guard.from(**guard_opts)
24
+ ))
25
+ end
26
+ end
27
+ private_constant :Otherwises
28
+ end
29
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ # Collection of receive rules.
13
+ class Receives < Data.define(:rules, :actions)
14
+ include Registry
15
+
16
+ def initialize(rules: [], actions:)
17
+ super
18
+ end
19
+
20
+ def add_events(keys, handler, **guard_opts) = add(Receive.new(
21
+ predicate: Predicate::Events.new(keys:),
22
+ action: resolve(handler),
23
+ guard: Guard.from(**guard_opts)
24
+ ))
25
+
26
+ def add_routed(envelope, handler, guard: nil) = add(Receive.new(
27
+ predicate: Predicate::Routed.new(envelope:),
28
+ action: resolve(handler),
29
+ guard: Guard.from(guard:)
30
+ ))
31
+
32
+ def add_instances_of(klass, handler, guard: nil) = add(Receive.new(
33
+ predicate: Predicate::InstancesOf.new(klass:),
34
+ action: resolve(handler),
35
+ guard: Guard.from(guard:)
36
+ ))
37
+
38
+ def add_all(handler, **guard_opts) = add(Receive.new(
39
+ predicate: Predicate::Always.new,
40
+ action: resolve(handler),
41
+ guard: Guard.from(**guard_opts)
42
+ ))
43
+
44
+ def add_custom(predicate, handler, guard: nil) = add(Receive.new(
45
+ predicate:,
46
+ action: resolve(handler),
47
+ guard: Guard.from(guard:)
48
+ ))
49
+
50
+ private def resolve(handler)
51
+ return actions[handler] if handler.is_a?(Symbol)
52
+ LambdaAction.new(name: nil, handler:)
53
+ end
54
+ end
55
+ private_constant :Receives
56
+ end
57
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ class Routes
13
+ include Enumerable
14
+
15
+ def initialize(routes = Set.new) = @routes = routes
16
+
17
+ def add(route)
18
+ @routes << route
19
+ route
20
+ end
21
+
22
+ def for(identifier)
23
+ case identifier
24
+ when Module then unique(identifier) { |r| r.fragment == identifier }
25
+ when Symbol then unique(identifier) { |r| r.prefix == identifier }
26
+ else identifier
27
+ end
28
+ end
29
+
30
+ def each(&block)
31
+ return @routes.each unless block
32
+ @routes.each { |route| block.call(route) }
33
+ end
34
+
35
+ def subset(targets)
36
+ resolved = targets.filter_map { |t| self.for(t) }
37
+ Routes.new(resolved.to_set)
38
+ end
39
+
40
+ def broadcast(message, model)
41
+ reduce(Transition.initial(model)) do |transition, route|
42
+ new_model, command = route.delegate(message, transition.model)
43
+ transition.with_model(new_model).with_added_command(command)
44
+ end
45
+ end
46
+
47
+ def broadcast_to(prefixes, message, model)
48
+ subset(prefixes).broadcast(message, model)
49
+ end
50
+
51
+ private def unique(identifier, &)
52
+ matches = @routes.select(&)
53
+ raise Rooibos::Error::Invariant, "Ambiguous route: #{identifier} matches #{matches.size} routes. Capture the Route returned by `route` and pass it to `to:` to disambiguate." if matches.size > 1
54
+ matches.first
55
+ end
56
+ end
57
+ private_constant :Routes
58
+ end
59
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ # Common behavior for Rule collections.
13
+ # Classes including this must define :rules as a Data member.
14
+ module Registry
15
+ include Enumerable
16
+
17
+ def add(rule) = rules << rule
18
+
19
+ def each(&block)
20
+ return rules.each unless block
21
+ rules.each { |rule| block.call(rule) }
22
+ end
23
+ end
24
+ private_constant :Registry
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ Route = Data.define(:prefix, :fragment, :read, :write) do
13
+ def initialize(prefix:, fragment:, read: nil, write: nil)
14
+ super
15
+ end
16
+
17
+ def delegate(message, model)
18
+ nested_model = extract(model)
19
+ result = fragment::Update.call(message, nested_model)
20
+ transition = Transition.from(result, nested_model)
21
+ Transition.new(model: merge(model, transition.model), command: transition.command)
22
+ end
23
+
24
+ private def extract(model)
25
+ if read
26
+ read.call(model)
27
+ else
28
+ model.public_send(prefix)
29
+ end
30
+ end
31
+
32
+ private def merge(model, new_nested_model)
33
+ if write
34
+ write.call(model, new_nested_model)
35
+ else
36
+ model.with(prefix => new_nested_model)
37
+ end
38
+ end
39
+ end
40
+ private_constant :Route
41
+ end
42
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ # Encapsulates dispatch logic - given frozen rule sets, processes messages.
13
+ class RouterUpdate < Data.define(:inward, :outward)
14
+ def call(message, model)
15
+ case message
16
+ when Message::Bubbled then dispatch_outward(message.message, model)
17
+ else dispatch_inward(message, model)
18
+ end
19
+ end
20
+
21
+ private def dispatch_inward(message, model)
22
+ transition = inward.call(message, model)
23
+ case transition.command
24
+ when Command::Bubble # Sentinel; not a real Command to be handled by the runtime
25
+ bubble_model, bubble_cmd = dispatch_outward(transition.command.message, transition.model)
26
+ transition = transition.with_model(bubble_model).with_command(bubble_cmd)
27
+ when Command::Batch
28
+ transition = extract_bubbles_from_batch(transition)
29
+ end
30
+ transition = transition.with(command: nil) if transition.command.equal?(Flow::Outward::INTERCEPTED)
31
+ transition.to_a
32
+ end
33
+
34
+ private def extract_bubbles_from_batch(transition)
35
+ bubbles, remaining = transition.command.extract_bubbles
36
+ return transition if bubbles.empty?
37
+
38
+ result = Transition.new(model: transition.model, command: remaining)
39
+ bubbles.each do |bubble|
40
+ model, cmd = dispatch_outward(bubble.message, result.model)
41
+ result = Transition.new(model:, command: result.command)
42
+ result = result.with_added_command(cmd) unless cmd.nil? || cmd.equal?(Flow::Outward::INTERCEPTED)
43
+ end
44
+ result
45
+ end
46
+
47
+ private def dispatch_outward(message, model)
48
+ outward.call(message, model).to_a
49
+ end
50
+ end
51
+ private_constant :RouterUpdate
52
+ end
53
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ # Forward rule - matches messages and delegates to route(s)
13
+ class Forward < Data.define(:predicate, :targets, :envelope, :guard)
14
+ include Rule
15
+
16
+ def initialize(predicate:, targets: ALL_ROUTES, envelope: nil, guard: nil)
17
+ targets = Array(targets) unless targets == ALL_ROUTES
18
+ super
19
+ end
20
+
21
+ def apply(message, model, routes)
22
+ if targets == ALL_ROUTES
23
+ routes
24
+ else
25
+ routes.subset(targets)
26
+ end.broadcast(envelop(message), model)
27
+ end
28
+
29
+ private def envelop(message)
30
+ if envelope
31
+ Message::Routed.new(envelope:, event: message)
32
+ else
33
+ message
34
+ end
35
+ end
36
+ end
37
+ private_constant :Forward
38
+ end
39
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ # Observe rule - matches messages, handles, and continues processing.
13
+ class Observe < Data.define(:predicate, :action, :guard)
14
+ include Rule
15
+
16
+ def apply(message, model, routes)
17
+ action.apply(message, model, routes)
18
+ end
19
+ end
20
+ private_constant :Observe
21
+ end
22
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ module Rooibos
10
+ module Router
11
+ # :stopdoc:
12
+ # Rule for otherwise routing - routes unhandled messages to a fragment.
13
+ Otherwise = Data.define(:target, :guard, :predicate) do
14
+ include Rule
15
+
16
+ def initialize(target:, guard: nil, predicate: Predicate::Always.new)
17
+ super
18
+ end
19
+
20
+ def apply(message, model, routes)
21
+ routes.subset([target]).broadcast(message, model)
22
+ end
23
+ end
24
+ private_constant :Otherwise
25
+ end
26
+ end