rage_arch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +190 -0
  4. data/lib/generators/rage_arch/ar_dep_generator.rb +74 -0
  5. data/lib/generators/rage_arch/dep_generator.rb +120 -0
  6. data/lib/generators/rage_arch/dep_switch_generator.rb +224 -0
  7. data/lib/generators/rage_arch/install_generator.rb +64 -0
  8. data/lib/generators/rage_arch/scaffold_generator.rb +133 -0
  9. data/lib/generators/rage_arch/templates/ar_dep.rb.tt +46 -0
  10. data/lib/generators/rage_arch/templates/dep.rb.tt +16 -0
  11. data/lib/generators/rage_arch/templates/rage_arch.rb.tt +15 -0
  12. data/lib/generators/rage_arch/templates/scaffold/api_controller.rb.tt +39 -0
  13. data/lib/generators/rage_arch/templates/scaffold/controller.rb.tt +56 -0
  14. data/lib/generators/rage_arch/templates/scaffold/create.rb.tt +14 -0
  15. data/lib/generators/rage_arch/templates/scaffold/destroy.rb.tt +15 -0
  16. data/lib/generators/rage_arch/templates/scaffold/list.rb.tt +13 -0
  17. data/lib/generators/rage_arch/templates/scaffold/new.rb.tt +13 -0
  18. data/lib/generators/rage_arch/templates/scaffold/post_repo.rb.tt +35 -0
  19. data/lib/generators/rage_arch/templates/scaffold/show.rb.tt +14 -0
  20. data/lib/generators/rage_arch/templates/scaffold/update.rb.tt +15 -0
  21. data/lib/generators/rage_arch/templates/use_case.rb.tt +18 -0
  22. data/lib/generators/rage_arch/use_case_generator.rb +33 -0
  23. data/lib/rage_arch/container.rb +38 -0
  24. data/lib/rage_arch/controller.rb +22 -0
  25. data/lib/rage_arch/dep.rb +9 -0
  26. data/lib/rage_arch/dep_scanner.rb +95 -0
  27. data/lib/rage_arch/deps/active_record.rb +45 -0
  28. data/lib/rage_arch/event_publisher.rb +59 -0
  29. data/lib/rage_arch/fake_event_publisher.rb +37 -0
  30. data/lib/rage_arch/railtie.rb +23 -0
  31. data/lib/rage_arch/result.rb +31 -0
  32. data/lib/rage_arch/rspec_matchers.rb +94 -0
  33. data/lib/rage_arch/use_case.rb +252 -0
  34. data/lib/rage_arch/version.rb +5 -0
  35. data/lib/rage_arch.rb +97 -0
  36. metadata +133 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a0f57a7080e54d4fd38967f95f88a3e3af2a1a3716665acc79e3b2383fd3b6a4
4
+ data.tar.gz: '052118de065d6e08bf9c61eacce620d964b8d13f35c745c33d01e5df15515d54'
5
+ SHA512:
6
+ metadata.gz: 0ed85c8a86183eee47cd39bdbcb9685832a73f2d731a4a7e7ba2f4a6b88c47718418b0fae81d1059ef9be628715817a3747999e61fdba971dd8fc3a4f1cabd14
7
+ data.tar.gz: c3692202b97ad9e8c2ba501a1b033b5b2e6fa2cad0de41b6bdbc9f607131be2d43b11694d2f7c8fd85103a93ae2c0dff6c136d6948967e8177fcd91fc57dfbcd
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Rage Corp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # Rage
2
+
3
+ **Clean Architecture Light for Rails.** Business logic in testable use cases, thin controllers, and models free of callbacks.
4
+
5
+ ## Core concepts
6
+
7
+ - **Controllers** only orchestrate — no business logic
8
+ - **Models** stay clean — no business callbacks
9
+ - **Use cases** hold all logic, with injected dependencies and typed results
10
+
11
+ ## Setup
12
+
13
+ ```bash
14
+ bundle install
15
+ rails g rage:install
16
+ ```
17
+
18
+ Creates `config/initializers/rage.rb`, `app/use_cases/`, `app/deps/`, and injects `include Rage::Controller` into `ApplicationController`.
19
+
20
+ ---
21
+
22
+ ## Components
23
+
24
+ ### `Rage::Result` — typed result object
25
+
26
+ ```ruby
27
+ result = Rage::Result.success(order)
28
+ result.success? # => true
29
+ result.value # => order
30
+
31
+ result = Rage::Result.failure(["Validation error"])
32
+ result.failure? # => true
33
+ result.errors # => ["Validation error"]
34
+ ```
35
+
36
+ ---
37
+
38
+ ### `Rage::UseCase::Base` — use cases
39
+
40
+ ```ruby
41
+ class CreateOrder < Rage::UseCase::Base
42
+ use_case_symbol :create_order
43
+ deps :order_store, :notifications # injected by symbol
44
+
45
+ def call(params = {})
46
+ order = order_store.build(params)
47
+ return failure(order.errors) unless order_store.save(order)
48
+ notifications.notify(:order_created, order)
49
+ success(order)
50
+ end
51
+ end
52
+ ```
53
+
54
+ Build and run manually:
55
+
56
+ ```ruby
57
+ use_case = Rage::UseCase::Base.build(:create_order)
58
+ result = use_case.call(reference: "REF-1", total_cents: 1000)
59
+ ```
60
+
61
+ ---
62
+
63
+ ### `Rage::Container` — dependency registration
64
+
65
+ ```ruby
66
+ Rage.register(:order_store, MyApp::Deps::OrderStore.new)
67
+ Rage.register_ar(:user_store, User) # automatic ActiveRecord wrapper
68
+ Rage.resolve(:order_store)
69
+ ```
70
+
71
+ ---
72
+
73
+ ### `Rage::Controller` — thin controller mixin
74
+
75
+ ```ruby
76
+ def create
77
+ run :users_register, register_params,
78
+ success: ->(r) { session[:user_id] = r.value[:user].id; redirect_to root_path, notice: "Created." },
79
+ failure: ->(r) { flash_errors(r); render :new, status: :unprocessable_entity }
80
+ end
81
+ ```
82
+
83
+ - `run(symbol, params, success:, failure:)` — runs the use case and calls the matching block
84
+ - `run_result(symbol, params)` — runs and returns the `Result` directly
85
+ - `flash_errors(result)` — sets `flash.now[:alert]` from `result.errors`
86
+
87
+ ---
88
+
89
+ ### `Rage::EventPublisher` — domain events
90
+
91
+ Every use case automatically publishes an event when it finishes. Other use cases subscribe to react:
92
+
93
+ ```ruby
94
+ class Notifications::SendPostCreatedEmail < Rage::UseCase::Base
95
+ use_case_symbol :send_post_created_email
96
+ deps :mailer
97
+ subscribe :posts_create # runs when :posts_create event is published
98
+
99
+ def call(payload = {})
100
+ return success unless payload[:success]
101
+ mailer.send_post_created(payload[:value][:post])
102
+ success
103
+ end
104
+ end
105
+ ```
106
+
107
+ Subscribe to multiple events or everything:
108
+
109
+ ```ruby
110
+ subscribe :post_created, :post_updated
111
+ subscribe :all # payload includes :event with the event name
112
+ ```
113
+
114
+ Opt out of auto-publish for a specific use case:
115
+
116
+ ```ruby
117
+ skip_auto_publish
118
+ ```
119
+
120
+ ---
121
+
122
+ ### Orchestration — use cases calling use cases
123
+
124
+ ```ruby
125
+ class CreateOrderWithNotification < Rage::UseCase::Base
126
+ use_case_symbol :create_order_with_notification
127
+ deps :order_store
128
+ use_cases :orders_create, :notifications_send
129
+
130
+ def call(params = {})
131
+ result = orders_create.call(params)
132
+ return result unless result.success?
133
+ notifications_send.call(order_id: result.value[:order].id, type: :order_created)
134
+ result
135
+ end
136
+ end
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Generators
142
+
143
+ | Command | What it does |
144
+ |---|---|
145
+ | `rails g rage:install` | Initial setup (initializer, directories, controller mixin) |
146
+ | `rails g rage:scaffold Post title:string` | Full CRUD: model, use cases, dep, controller, views, routes |
147
+ | `rails g rage:scaffold Post title:string --api` | Same but API-only (JSON responses) |
148
+ | `rails g rage:scaffold Post title:string --skip-model` | Skip model/migration if it already exists |
149
+ | `rails g rage:use_case CreateOrder` | Generates a base use case file |
150
+ | `rails g rage:dep post_store` | Generates a dep class by scanning method calls in use cases |
151
+ | `rails g rage:ar_dep post_store Post` | Generates a dep that wraps an ActiveRecord model |
152
+
153
+ ---
154
+
155
+ ## Testing
156
+
157
+ ```ruby
158
+ # spec/rails_helper.rb
159
+ require "rage/rspec_matchers"
160
+ require "rage/fake_event_publisher"
161
+ ```
162
+
163
+ **Result matchers:**
164
+
165
+ ```ruby
166
+ expect(result).to succeed_with(post: a_kind_of(Post))
167
+ expect(result).to fail_with_errors(["Not found"])
168
+ ```
169
+
170
+ **Fake event publisher:**
171
+
172
+ ```ruby
173
+ publisher = Rage::FakeEventPublisher.new
174
+ Rage.register(:event_publisher, publisher)
175
+ # ... run use case ...
176
+ expect(publisher.published).to include(hash_including(event: :post_created))
177
+ publisher.clear
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Documentation
183
+
184
+ - [`doc/REFERENCE.md`](doc/REFERENCE.md) — Full API reference with all options and examples
185
+ - [`doc/DOCUMENTATION.md`](doc/DOCUMENTATION.md) — Detailed behaviour (use cases, deps, events, config)
186
+ - [`doc/DAY_OF_WORK.md`](doc/DAY_OF_WORK.md) — Quick reference for common tasks
187
+
188
+ ## License
189
+
190
+ MIT
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rage_arch/dep_scanner"
5
+
6
+ module RageArch
7
+ module Generators
8
+ class ArDepGenerator < ::Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ argument :symbol_arg, type: :string, required: true, banner: "SYMBOL"
12
+ argument :model_arg, type: :string, required: true, banner: "MODEL"
13
+
14
+ desc "Create a dep class that wraps an Active Record model (build, find, save, update, destroy, list). Scans use cases for extra method calls and adds stubs for them. Example: rails g rage_arch:ar_dep post_store Post"
15
+ def create_ar_dep
16
+ @extra_methods = extra_methods
17
+ template "ar_dep.rb.tt", File.join("app/deps", module_dir, "#{dep_file_name}.rb")
18
+ say "Register in config/initializers/rage_arch.rb: Rage.register(:#{symbol_name}, #{full_class_name}.new)", :green
19
+ end
20
+
21
+ STANDARD_AR_METHODS = %i[build find save update destroy list].freeze
22
+
23
+ # Methods that use cases call on this dep but are not in the standard AR adapter
24
+ def extra_methods
25
+ detected = scanner.methods_for(symbol_name).to_a
26
+ (detected - STANDARD_AR_METHODS).sort
27
+ end
28
+
29
+ def symbol_name
30
+ symbol_arg.to_s.underscore
31
+ end
32
+
33
+ def model_name
34
+ model_arg.camelize
35
+ end
36
+
37
+ def module_dir
38
+ inferred_module_dir || use_case_folder
39
+ end
40
+
41
+ def module_name
42
+ module_dir.camelize
43
+ end
44
+
45
+ def use_case_folder
46
+ scanner.folder_for(symbol_name)
47
+ end
48
+
49
+ def inferred_module_dir
50
+ entity = symbol_name.split("_").first
51
+ entity.pluralize
52
+ end
53
+
54
+ def dep_file_name
55
+ symbol_name
56
+ end
57
+
58
+ def class_name
59
+ symbol_name.camelize
60
+ end
61
+
62
+ def full_class_name
63
+ "#{module_name}::#{class_name}"
64
+ end
65
+
66
+ def scanner
67
+ @scanner ||= begin
68
+ root = destination_root
69
+ RageArch::DepScanner.new(File.join(root, "app", "use_cases")).tap(&:scan)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rage_arch/dep_scanner"
5
+
6
+ module RageArch
7
+ module Generators
8
+ class DepGenerator < ::Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ argument :symbol_arg, type: :string, required: true, banner: "SYMBOL"
12
+ argument :class_name_arg, type: :string, optional: true, banner: "[CLASS_NAME]",
13
+ default: nil
14
+
15
+ desc "Create a dep class in app/deps/, grouped by folder. The folder is inferred from the symbol first (post_store → posts, like_store → likes). If the symbol has no clear entity, the folder is taken from use cases that reference it. Optional CLASS_NAME is the generated class (e.g. CsvPostStore); default is SYMBOL.camelize. If the file already exists, only adds stub methods that are missing (detected from use cases but not yet in the class)."
16
+ def create_dep
17
+ @methods = detected_methods
18
+ if @methods.empty?
19
+ say "No method calls found for dep :#{symbol_name} in app/use_cases/. Creating with a single stub.", :yellow
20
+ @methods = [:call]
21
+ end
22
+ target_path = File.join("app/deps", module_dir, "#{dep_file_name}.rb")
23
+ if File.exist?(File.join(destination_root, target_path))
24
+ add_missing_methods_only(target_path)
25
+ else
26
+ template "dep.rb.tt", target_path
27
+ end
28
+ end
29
+
30
+ # When the dep file already exists, parse it for existing method names and insert only stubs for missing ones.
31
+ def add_missing_methods_only(relative_path)
32
+ full_path = File.join(destination_root, relative_path)
33
+ content = File.read(full_path)
34
+ existing = content.scan(/^\s+def\s+(\w+)\s*[\(\s]/m).flatten.uniq.map(&:to_sym)
35
+ missing = @methods - existing
36
+ if missing.empty?
37
+ say "All detected methods already present in #{relative_path}.", :green
38
+ return
39
+ end
40
+ indent = content[/^(\s+)def\s+\w+/m, 1] || " "
41
+ stubs = missing.map { |m| method_stub(m, indent) }.join("\n")
42
+ # Insert before the class's closing end (last indented "end" before the file's final "end").
43
+ lines = content.lines
44
+ insert_at = lines.length - 1
45
+ insert_at -= 1 while insert_at >= 0 && lines[insert_at] =~ /^\s*$/
46
+ insert_at -= 1 while insert_at > 0 && lines[insert_at] !~ /^\s+end\s*$/
47
+ new_content = lines[0...insert_at].join + "\n#{stubs}\n" + lines[insert_at..].join
48
+ File.write(full_path, new_content)
49
+ say "Added #{missing.size} method(s) to #{relative_path}: #{missing.map(&:to_s).join(', ')}", :green
50
+ end
51
+
52
+ def method_stub(method_name, indent = " ")
53
+ body_indent = indent + " "
54
+ <<~RUBY.strip
55
+ #{indent}def #{method_name}(*args, **kwargs)
56
+ #{body_indent}# TODO: implement
57
+ #{body_indent}raise NotImplementedError, "#{full_class_name}##{method_name}"
58
+ #{indent}end
59
+ RUBY
60
+ end
61
+
62
+ def symbol_name
63
+ symbol_arg.to_s.underscore
64
+ end
65
+
66
+ # Folder from use cases that reference this symbol (e.g. likes/create.rb → "likes"), or inferred from symbol.
67
+ # Prefer inferred from symbol (post_store → posts) so the dep lives with its domain, not only where it's referenced.
68
+ def module_dir
69
+ inferred_module_dir || use_case_folder
70
+ end
71
+
72
+ # Module name for the class (e.g. Likes, Posts). Matches Zeitwerk: app/deps/likes/ → Likes::
73
+ def module_name
74
+ module_dir.camelize
75
+ end
76
+
77
+ # When use cases in app/use_cases/likes/*.rb reference this symbol, returns "likes"
78
+ def use_case_folder
79
+ scanner.folder_for(symbol_name)
80
+ end
81
+
82
+ # Fallback when no use case references the symbol: like_store → "likes", post_store → "posts"
83
+ def inferred_module_dir
84
+ entity = symbol_name.split("_").first
85
+ entity.pluralize
86
+ end
87
+
88
+ def class_name
89
+ if class_name_arg.present?
90
+ class_name_arg.camelize
91
+ else
92
+ symbol_name.camelize
93
+ end
94
+ end
95
+
96
+ def dep_file_name
97
+ if class_name_arg.present?
98
+ class_name_arg.underscore
99
+ else
100
+ symbol_name
101
+ end
102
+ end
103
+
104
+ def full_class_name
105
+ "#{module_name}::#{class_name}"
106
+ end
107
+
108
+ def detected_methods
109
+ scanner.methods_for(symbol_name).to_a.sort
110
+ end
111
+
112
+ def scanner
113
+ @scanner ||= begin
114
+ root = destination_root
115
+ RageArch::DepScanner.new(File.join(root, "app", "use_cases")).tap(&:scan)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module RageArch
6
+ module Generators
7
+ class DepSwitchGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ argument :symbol_arg, type: :string, required: true, banner: "SYMBOL"
11
+ argument :class_name_arg, type: :string, optional: true, banner: "[CLASS_NAME]",
12
+ default: nil
13
+
14
+ desc "List implementations for a dep symbol and set which one is active in config/initializers/rage_arch.rb"
15
+ def switch_dep
16
+ symbol = symbol_name
17
+ options = build_options(symbol)
18
+ if options.empty?
19
+ say "No implementations found for :#{symbol}.", :red
20
+ say "Create a dep with: rails g rage_arch:dep #{symbol} [ClassName]"
21
+ say "Or add Rage.register_ar(:#{symbol}, Model) in config/initializers/rage_arch.rb", :red
22
+ return
23
+ end
24
+ chosen = if class_name_arg.present?
25
+ resolve_requested(symbol, class_name_arg, options)
26
+ elsif options.size == 1
27
+ options.first
28
+ else
29
+ ask_choice(symbol, options)
30
+ end
31
+ return unless chosen
32
+
33
+ update_initializer(symbol, chosen)
34
+ msg = chosen[:type] == :ar ?
35
+ "Registered :#{symbol} -> Active Record (#{chosen[:model]}) in config/initializers/rage_arch.rb" :
36
+ "Registered :#{symbol} -> #{chosen[:name]}.new in config/initializers/rage_arch.rb"
37
+ say msg, :green
38
+ end
39
+
40
+ def symbol_name
41
+ symbol_arg.to_s.underscore
42
+ end
43
+
44
+ private
45
+
46
+ def deps_path
47
+ File.join(destination_root, "app", "deps")
48
+ end
49
+
50
+ def initializer_path
51
+ File.join(destination_root, "config", "initializers", "rage.rb")
52
+ end
53
+
54
+ def find_ar_registration(symbol)
55
+ path = initializer_path
56
+ return nil unless File.exist?(path)
57
+ content = File.read(path)
58
+ # Match active or commented: Rage.register_ar(:post_store, Post) or Rage.register_ar :post_store, Post
59
+ content.each_line do |line|
60
+ stripped = line.strip
61
+ next if stripped.start_with?("#")
62
+ if stripped =~ /Rage\.register_ar\s*\(\s*:\s*#{Regexp.escape(symbol)}\s*,\s*(\w+)\s*\)/ ||
63
+ stripped =~ /Rage\.register_ar\s+:\s*#{Regexp.escape(symbol)}\s*,\s*(\w+)/
64
+ return $1
65
+ end
66
+ end
67
+ # Also check commented lines so "default" stays as option after switching away
68
+ content.each_line do |line|
69
+ stripped = line.strip.delete_prefix("#").strip
70
+ next if stripped.empty?
71
+ if stripped =~ /Rage\.register_ar\s*\(\s*:\s*#{Regexp.escape(symbol)}\s*,\s*(\w+)\s*\)/ ||
72
+ stripped =~ /Rage\.register_ar\s+:\s*#{Regexp.escape(symbol)}\s*,\s*(\w+)/
73
+ return $1
74
+ end
75
+ end
76
+ nil
77
+ end
78
+
79
+ def find_dep_classes(symbol)
80
+ return [] unless File.directory?(deps_path)
81
+
82
+ Dir[File.join(deps_path, "**", "*.rb")].filter_map do |path|
83
+ base = File.basename(path, ".rb")
84
+ # symbol.rb, symbol_suffix.rb (e.g. post_store_mysql), or prefix_symbol.rb (e.g. mysql_post_store)
85
+ next unless base == symbol || base.start_with?("#{symbol}_") || base.end_with?("_#{symbol}")
86
+ path_to_class_name(path)
87
+ end.sort
88
+ end
89
+
90
+ # Derives full constant from path (Zeitwerk convention):
91
+ # app/deps/posts/post_store.rb → Posts::PostStore
92
+ # app/deps/post_store.rb (flat, legacy) → PostStore
93
+ def path_to_class_name(path)
94
+ base = File.join(destination_root, "app", "deps")
95
+ relative = path.sub(/\A#{Regexp.escape(base + File::SEPARATOR)}/, "").sub(/\.rb\z/, "")
96
+ parts = relative.split(File::SEPARATOR).map(&:camelize)
97
+ if parts.size > 1
98
+ parts.join("::")
99
+ else
100
+ parts.first
101
+ end
102
+ end
103
+
104
+ def build_options(symbol)
105
+ options = []
106
+ ar_model = find_ar_registration(symbol)
107
+ options << { type: :ar, model: ar_model } if ar_model
108
+ find_dep_classes(symbol).each { |name| options << { type: :class, name: name } }
109
+ options
110
+ end
111
+
112
+ def display_option(opt)
113
+ opt[:type] == :ar ? "Active Record (#{opt[:model]})" : opt[:name]
114
+ end
115
+
116
+ def resolve_requested(symbol, requested, options)
117
+ raw = requested.to_s.strip
118
+ return nil if raw.empty?
119
+ normalized = raw.camelize
120
+ # Allow "ar" or "activerecord" (any case) to select Active Record default
121
+ if raw.downcase == "ar" || raw.downcase == "activerecord"
122
+ opt = options.find { |o| o[:type] == :ar }
123
+ unless opt
124
+ say "No Active Record registration found for :#{symbol}. Available: #{options.map { |o| display_option(o) }.join(', ')}", :red
125
+ return nil
126
+ end
127
+ return opt
128
+ end
129
+ opt = options.find { |o| o[:type] == :class && (o[:name] == normalized || o[:name].end_with?("::#{normalized}")) }
130
+ unless opt
131
+ say "Unknown implementation '#{requested}' for :#{symbol}. Available: #{options.map { |o| display_option(o) }.join(', ')}", :red
132
+ return nil
133
+ end
134
+ opt
135
+ end
136
+
137
+ def ask_choice(symbol, options)
138
+ say "Implementations for :#{symbol}:", :green
139
+ options.each_with_index do |opt, i|
140
+ say " #{i + 1}. #{display_option(opt)}"
141
+ end
142
+ choice = ask("\nWhich one to activate? [1-#{options.size}]: ", :green)
143
+ idx = choice.to_i
144
+ unless idx.between?(1, options.size)
145
+ say "Invalid choice.", :red
146
+ return nil
147
+ end
148
+ options[idx - 1]
149
+ end
150
+
151
+ def update_initializer(symbol, option)
152
+ path = initializer_path
153
+ unless File.exist?(path)
154
+ say "config/initializers/rage_arch.rb not found.", :red
155
+ return
156
+ end
157
+ content = File.read(path)
158
+ # Comment out every active registration for this symbol (do not remove)
159
+ content = comment_line_matching(content, symbol, :register)
160
+ content = comment_line_matching(content, symbol, :register_ar)
161
+ # Uncomment or add the chosen registration
162
+ chosen_line = option[:type] == :ar ?
163
+ "Rage.register_ar(:#{symbol}, #{option[:model]})" :
164
+ "Rage.register(:#{symbol}, #{option[:name]}.new)"
165
+ content = uncomment_or_add(content, symbol, option, chosen_line)
166
+ File.write(path, content)
167
+ end
168
+
169
+ def comment_line_matching(content, symbol, form)
170
+ if form == :register
171
+ content.gsub(/^(\s*)(Rage\.register\(:#{Regexp.escape(symbol)},\s*\S+\.new\))\s*$/, '\1# \2')
172
+ else
173
+ # register_ar with parens or with space
174
+ content
175
+ .gsub(/^(\s*)(Rage\.register_ar\s*\(\s*:\s*#{Regexp.escape(symbol)}\s*,\s*\S+\s*\))\s*$/, '\1# \2')
176
+ .gsub(/^(\s*)(Rage\.register_ar\s+:\s*#{Regexp.escape(symbol)}\s*,\s*\S+)\s*$/, '\1# \2')
177
+ end
178
+ end
179
+
180
+ def uncomment_or_add(content, symbol, option, chosen_line)
181
+ if option[:type] == :ar
182
+ model = option[:model]
183
+ # Uncomment if there is a commented register_ar line for this symbol and model
184
+ content = content.gsub(
185
+ /^(\s*)#\s*(Rage\.register_ar\s*\(\s*:\s*#{Regexp.escape(symbol)}\s*,\s*#{Regexp.escape(model)}\s*\))\s*$/,
186
+ '\1\2'
187
+ )
188
+ content = content.gsub(
189
+ /^(\s*)#\s*(Rage\.register_ar\s+:\s*#{Regexp.escape(symbol)}\s*,\s*#{Regexp.escape(model)})\s*$/,
190
+ '\1\2'
191
+ )
192
+ else
193
+ class_name = option[:name]
194
+ # Uncomment if there is a commented Rage.register line for this symbol and class
195
+ content = content.gsub(
196
+ /^(\s*)#\s*(Rage\.register\(:#{Regexp.escape(symbol)},\s*#{Regexp.escape(class_name)}\.new\))\s*$/,
197
+ '\1\2'
198
+ )
199
+ end
200
+ # If chosen line is still not present as active, add it inside after_initialize
201
+ return content if chosen_already_active?(content, symbol, option, chosen_line)
202
+
203
+ if content =~ /Rails\.application\.config\.after_initialize\s+do/
204
+ content = content.sub(/(Rails\.application\.config\.after_initialize\s+do)\n/, "\\1\n #{chosen_line}\n")
205
+ else
206
+ content = content.rstrip + "\n\nRails.application.config.after_initialize do\n #{chosen_line}\nend\n"
207
+ end
208
+ content
209
+ end
210
+
211
+ def chosen_already_active?(content, symbol, option, chosen_line)
212
+ content.lines.any? do |line|
213
+ next false if line.lstrip.start_with?("#")
214
+ if option[:type] == :ar
215
+ model = option[:model]
216
+ line.include?("register_ar") && line.include?(":#{symbol}") && line.include?(model.to_s)
217
+ else
218
+ line.include?(chosen_line)
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end