opinionated_rails 0.1.4

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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +59 -0
  5. data/exe/opinionated_rails +7 -0
  6. data/lib/opinionated_rails/answer_shell.rb +131 -0
  7. data/lib/opinionated_rails/cli.rb +291 -0
  8. data/lib/opinionated_rails/context.rb +39 -0
  9. data/lib/opinionated_rails/error.rb +6 -0
  10. data/lib/opinionated_rails/generators/file_writer.rb +14 -0
  11. data/lib/opinionated_rails/generators/rails_new_invoker.rb +26 -0
  12. data/lib/opinionated_rails/kamal_config.rb +94 -0
  13. data/lib/opinionated_rails/manifest.rb +42 -0
  14. data/lib/opinionated_rails/pins.rb +83 -0
  15. data/lib/opinionated_rails/prerequisites.rb +39 -0
  16. data/lib/opinionated_rails/rails_version_guard.rb +49 -0
  17. data/lib/opinionated_rails/step.rb +41 -0
  18. data/lib/opinionated_rails/step_graph.rb +53 -0
  19. data/lib/opinionated_rails/steps/addons/auth_step.rb +170 -0
  20. data/lib/opinionated_rails/steps/addons/ci_step.rb +226 -0
  21. data/lib/opinionated_rails/steps/addons/docker_kamal_step.rb +133 -0
  22. data/lib/opinionated_rails/steps/addons/docs_step.rb +35 -0
  23. data/lib/opinionated_rails/steps/addons/factory_or_fixtures_step.rb +128 -0
  24. data/lib/opinionated_rails/steps/addons/faker_step.rb +55 -0
  25. data/lib/opinionated_rails/steps/addons/js_lint_step.rb +132 -0
  26. data/lib/opinionated_rails/steps/addons/mailer_step.rb +183 -0
  27. data/lib/opinionated_rails/steps/addons/pagination_step.rb +116 -0
  28. data/lib/opinionated_rails/steps/addons/pr_issue_templates_step.rb +43 -0
  29. data/lib/opinionated_rails/steps/addons/rubocop_step.rb +111 -0
  30. data/lib/opinionated_rails/steps/addons/secrets_handling_step.rb +138 -0
  31. data/lib/opinionated_rails/steps/addons/services_step.rb +64 -0
  32. data/lib/opinionated_rails/steps/addons/solid_stack_step.rb +252 -0
  33. data/lib/opinionated_rails/steps/addons/spa_controller_step.rb +87 -0
  34. data/lib/opinionated_rails/steps/addons/staging_env_step.rb +108 -0
  35. data/lib/opinionated_rails/steps/addons/tailwind_step.rb +148 -0
  36. data/lib/opinionated_rails/steps/addons/vcr_step.rb +170 -0
  37. data/lib/opinionated_rails/steps/core/app_name_step.rb +85 -0
  38. data/lib/opinionated_rails/steps/core/app_type_step.rb +51 -0
  39. data/lib/opinionated_rails/steps/core/database_step.rb +53 -0
  40. data/lib/opinionated_rails/steps/core/frontend_step.rb +282 -0
  41. data/lib/opinionated_rails/steps/core/testing_step.rb +82 -0
  42. data/lib/opinionated_rails/templates/docs/STYLE.md +39 -0
  43. data/lib/opinionated_rails/templates/github/ISSUE_TEMPLATE/bug_report.md +25 -0
  44. data/lib/opinionated_rails/templates/github/ISSUE_TEMPLATE/feature_request.md +17 -0
  45. data/lib/opinionated_rails/templates/github/PULL_REQUEST_TEMPLATE.md +9 -0
  46. data/lib/opinionated_rails/templates/rubocop/custom_overrides.yml +5 -0
  47. data/lib/opinionated_rails/templates/services/application_service.rb +50 -0
  48. data/lib/opinionated_rails/templates/services/result.rb +44 -0
  49. data/lib/opinionated_rails/version.rb +5 -0
  50. data/lib/opinionated_rails.rb +41 -0
  51. metadata +141 -0
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class JsLintStep < Step
7
+ def self.id = :js_lint
8
+ def self.tier = :addon
9
+ def self.needs = [ :frontend ]
10
+ def self.summary = "Configure JavaScript linting"
11
+ def self.description = "Installs ESLint and writes an ESLint 9 flat config (eslint.config.js) for the chosen frontend stack."
12
+ def self.prompts = "Enable JS linting? (default: yes)"
13
+
14
+ def if_condition(context)
15
+ context.output(:frontend, :frontend) != :none
16
+ end
17
+
18
+ def prompt(context)
19
+ shell = context.shell
20
+ choice = shell.ask("Enable JS linting? (default: yes):").to_s.strip
21
+ choice = "yes" if choice.empty?
22
+
23
+ loop do
24
+ case choice.downcase
25
+ when "yes", "y"
26
+ context.answer(:js_lint, true)
27
+ break
28
+ when "no", "n"
29
+ context.answer(:js_lint, false)
30
+ break
31
+ else
32
+ shell.say("Invalid. Enter 'yes' or 'no'.", :red)
33
+ choice = shell.ask("Enable JS linting? (default: yes):").to_s.strip
34
+ choice = "yes" if choice.empty?
35
+ end
36
+ end
37
+ end
38
+
39
+ def generate(context)
40
+ enabled = if if_condition(context)
41
+ context.get(:js_lint) != false
42
+ else
43
+ false
44
+ end
45
+
46
+ data = { js_lint: enabled }
47
+ data[:js_lint_tool] = :eslint if enabled
48
+
49
+ context.set_output(:js_lint, data)
50
+
51
+ return unless enabled
52
+
53
+ write_eslint_config(context)
54
+ update_package_json(context)
55
+ end
56
+
57
+ def outputs(context)
58
+ context.output(:js_lint)
59
+ end
60
+
61
+ private
62
+
63
+ # ESLint 9 flat config. `files` names .jsx explicitly: ESLint lints only .js unless told otherwise,
64
+ # which previously left every React component unlinted.
65
+ PLAIN_ESLINT_CONFIG = <<~JS
66
+ const js = require("@eslint/js")
67
+ const globals = require("globals")
68
+
69
+ module.exports = [
70
+ { ignores: ["app/assets/builds/**", "node_modules/**"] },
71
+ js.configs.recommended,
72
+ {
73
+ files: ["**/*.js"],
74
+ languageOptions: {
75
+ ecmaVersion: "latest",
76
+ sourceType: "module",
77
+ globals: { ...globals.browser }
78
+ }
79
+ }
80
+ ]
81
+ JS
82
+
83
+ REACT_ESLINT_CONFIG = <<~JS
84
+ const js = require("@eslint/js")
85
+ const globals = require("globals")
86
+ const react = require("eslint-plugin-react")
87
+ const reactHooks = require("eslint-plugin-react-hooks")
88
+
89
+ module.exports = [
90
+ { ignores: ["app/assets/builds/**", "node_modules/**"] },
91
+ js.configs.recommended,
92
+ { ...react.configs.flat.recommended, files: ["**/*.{js,jsx}"] },
93
+ reactHooks.configs.flat.recommended,
94
+ {
95
+ files: ["**/*.{js,jsx}"],
96
+ languageOptions: {
97
+ ecmaVersion: "latest",
98
+ sourceType: "module",
99
+ globals: { ...globals.browser }
100
+ },
101
+ settings: { react: { version: "detect" } }
102
+ }
103
+ ]
104
+ JS
105
+
106
+ def write_eslint_config(context)
107
+ react = context.output(:frontend, :frontend) == :react
108
+ File.write(File.join(context.app_path, "eslint.config.js"), react ? REACT_ESLINT_CONFIG : PLAIN_ESLINT_CONFIG)
109
+ FileUtils.rm_f(File.join(context.app_path, ".eslintrc.json")) # ESLint 8 format, ignored by ESLint 9
110
+ end
111
+
112
+ def update_package_json(context)
113
+ package_json_path = File.join(context.app_path, "package.json")
114
+ package = File.exist?(package_json_path) ? JSON.parse(File.read(package_json_path)) : {}
115
+ package["scripts"] ||= {}
116
+ package["scripts"]["lint:js"] = "eslint app/javascript"
117
+ package["devDependencies"] ||= {}
118
+ package["devDependencies"]["eslint"] = "9.39.5"
119
+ package["devDependencies"]["@eslint/js"] = "9.39.5"
120
+ package["devDependencies"]["globals"] = "17.13.0"
121
+
122
+ if context.output(:frontend, :frontend) == :react
123
+ package["devDependencies"]["eslint-plugin-react"] = "7.37.5"
124
+ package["devDependencies"]["eslint-plugin-react-hooks"] = "7.1.1"
125
+ end
126
+
127
+ File.write(package_json_path, JSON.pretty_generate(package))
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class MailerStep < Step
7
+ def self.id = :mailer
8
+ def self.tier = :addon
9
+ def self.needs = [ :secrets_handling ]
10
+ def self.summary = "Configure mailers"
11
+ def self.description = "Sets up Letter Opener in development and Postmark in production."
12
+ def self.prompts = "Enable mailer setup (Letter Opener dev / Postmark production)? (default: yes)"
13
+
14
+ LETTER_OPENER_VERSION = "1.10.0".freeze
15
+ POSTMARK_RAILS_VERSION = "0.22.1".freeze
16
+
17
+ DEVELOPMENT_ANCHOR = 'config.action_mailer.default_url_options = { host: "localhost", port: 3000 }'
18
+ DEVELOPMENT_LINE = "config.action_mailer.delivery_method = :letter_opener"
19
+
20
+ PRODUCTION_ANCHOR = 'config.action_mailer.default_url_options = { host: "example.com" }'
21
+
22
+ POSTMARK_ENV_VAR_LINE = "POSTMARK_API_TOKEN="
23
+
24
+ def if_condition(_context) = true
25
+
26
+ def prompt(context)
27
+ shell = context.shell
28
+ choice = shell.ask("Enable mailer setup (Letter Opener dev / Postmark production)? (default: yes):").to_s.strip
29
+ choice = "yes" if choice.empty?
30
+
31
+ loop do
32
+ case choice.downcase
33
+ when "yes", "y"
34
+ context.answer(:mailer, true)
35
+ break
36
+ when "no", "n"
37
+ context.answer(:mailer, false)
38
+ break
39
+ else
40
+ shell.say("Invalid. Enter 'yes' or 'no'.", :red)
41
+ choice = shell.ask("Enable mailer setup (Letter Opener dev / Postmark production)? (default: yes):").to_s.strip
42
+ choice = "yes" if choice.empty?
43
+ end
44
+ end
45
+ end
46
+
47
+ def generate(context)
48
+ enabled = context.get(:mailer) != false
49
+
50
+ if enabled
51
+ add_development_group_gem(context, "gem \"letter_opener\", \"#{LETTER_OPENER_VERSION}\"")
52
+ add_top_level_gem(context, "gem \"postmark-rails\", \"#{POSTMARK_RAILS_VERSION}\"")
53
+ configure_development(context)
54
+ configure_production(context)
55
+ ensure_application_mailer(context)
56
+ end
57
+
58
+ context.set_output(:mailer, { mailer: enabled })
59
+ end
60
+
61
+ def outputs(context)
62
+ context.output(:mailer)
63
+ end
64
+
65
+ private
66
+
67
+ def configure_development(context)
68
+ insert_after_anchor(
69
+ context,
70
+ File.join(context.app_path, "config/environments/development.rb"),
71
+ DEVELOPMENT_ANCHOR,
72
+ [ DEVELOPMENT_LINE ]
73
+ )
74
+ end
75
+
76
+ def configure_production(context)
77
+ insert_after_anchor(
78
+ context,
79
+ File.join(context.app_path, "config/environments/production.rb"),
80
+ PRODUCTION_ANCHOR,
81
+ production_lines(context)
82
+ )
83
+ end
84
+
85
+ def production_lines(context)
86
+ secrets_handling = context.output(:secrets_handling, :secrets_handling) || :rails_credentials
87
+
88
+ api_token_line = case secrets_handling
89
+ when :dotenv
90
+ append_env_placeholder(context)
91
+ 'config.action_mailer.postmark_settings = { api_token: ENV["POSTMARK_API_TOKEN"] }'
92
+ else
93
+ "config.action_mailer.postmark_settings = { api_token: Rails.application.credentials.dig(:postmark, :api_token) }"
94
+ end
95
+
96
+ [ "config.action_mailer.delivery_method = :postmark", api_token_line ]
97
+ end
98
+
99
+ def append_env_placeholder(context)
100
+ path = File.join(context.app_path, ".env.example")
101
+ return unless File.exist?(path)
102
+
103
+ content = File.read(path)
104
+ return if content.include?(POSTMARK_ENV_VAR_LINE)
105
+
106
+ File.open(path, "a") { |f| f.puts(POSTMARK_ENV_VAR_LINE) }
107
+ end
108
+
109
+ # A Rails release that rewords the anchor must not silently leave mail unconfigured.
110
+ def insert_after_anchor(context, path, anchor, new_lines)
111
+ relative = path.delete_prefix("#{context.app_path}/")
112
+ unless File.exist?(path)
113
+ context.shell.say("Warning: #{relative} not found; mailer settings were not added.", :yellow)
114
+ return
115
+ end
116
+
117
+ content = File.read(path)
118
+ return if new_lines.all? { |line| content.include?(line) }
119
+
120
+ lines = content.lines
121
+ anchor_index = lines.index { |line| line.include?(anchor) }
122
+ unless anchor_index
123
+ context.shell.say(
124
+ "Warning: could not find `#{anchor}` in #{relative}; add these lines yourself:\n #{new_lines.join("\n ")}",
125
+ :yellow
126
+ )
127
+ return
128
+ end
129
+
130
+ indent = lines[anchor_index][/\A\s*/]
131
+ insertion = new_lines.map { |line| "#{indent}#{line}\n" }
132
+ lines.insert(anchor_index + 1, *insertion)
133
+
134
+ File.write(path, lines.join)
135
+ end
136
+
137
+ def ensure_application_mailer(context)
138
+ path = File.join(context.app_path, "app/mailers/application_mailer.rb")
139
+ return if File.exist?(path)
140
+
141
+ FileUtils.mkdir_p(File.dirname(path))
142
+ File.write(path, <<~RUBY)
143
+ class ApplicationMailer < ActionMailer::Base
144
+ default from: "from@example.com"
145
+ layout "mailer"
146
+ end
147
+ RUBY
148
+ end
149
+
150
+ def add_top_level_gem(context, gem_line)
151
+ gemfile_path = File.join(context.app_path, "Gemfile")
152
+ return unless File.exist?(gemfile_path)
153
+
154
+ content = File.read(gemfile_path)
155
+ return if content.include?(gem_line)
156
+
157
+ File.open(gemfile_path, "a") { |f| f.puts(gem_line) }
158
+ end
159
+
160
+ def add_development_group_gem(context, gem_line)
161
+ gemfile_path = File.join(context.app_path, "Gemfile")
162
+ return unless File.exist?(gemfile_path)
163
+
164
+ content = File.read(gemfile_path)
165
+ return if content.include?(gem_line)
166
+
167
+ lines = content.lines
168
+ group_start = lines.index { |line| line.strip == "group :development do" }
169
+
170
+ if group_start
171
+ end_index = (group_start + 1...lines.size).find { |i| lines[i].strip == "end" }
172
+ lines.insert(end_index, " #{gem_line}\n")
173
+ else
174
+ lines << "\n" unless lines.last.nil? || lines.last.end_with?("\n")
175
+ lines << "\ngroup :development do\n #{gem_line}\nend\n"
176
+ end
177
+
178
+ File.write(gemfile_path, lines.join)
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class PaginationStep < Step
7
+ def self.id = :pagination
8
+ def self.tier = :addon
9
+ def self.needs = []
10
+ def self.summary = "Configure pagination"
11
+ def self.description = "Installs geared_pagination or pagy for paginated collections."
12
+ def self.prompts = "Pagination: geared_pagination or pagy (default: pagy)"
13
+
14
+ GEARED_PAGINATION_VERSION = "1.2.0".freeze
15
+ PAGY_VERSION = "43.6.1".freeze
16
+
17
+ PAGY_INITIALIZER = <<~RUBY
18
+ # frozen_string_literal: true
19
+
20
+ # Pagy initializer — see https://ddnexus.github.io/pagy/toolbox/configuration/initializer/
21
+ # for the full list of available options.
22
+
23
+ Pagy::OPTIONS.freeze
24
+ RUBY
25
+
26
+ def if_condition(_context) = true
27
+
28
+ def prompt(context)
29
+ shell = context.shell
30
+ shell.say("Choose a pagination library:")
31
+ shell.say(" 1. geared_pagination")
32
+ shell.say(" 2. pagy (default)")
33
+
34
+ choice = shell.ask("Pagination (default: pagy):").to_s.strip
35
+ choice = "2" if choice.empty?
36
+
37
+ loop do
38
+ case choice.downcase
39
+ when "geared_pagination", "1"
40
+ context.answer(:pagination, :geared_pagination)
41
+ break
42
+ when "pagy", "2"
43
+ context.answer(:pagination, :pagy)
44
+ break
45
+ else
46
+ shell.say("Invalid. Enter 'geared_pagination', 'pagy', 1, or 2.", :red)
47
+ choice = shell.ask("Pagination (default: pagy):").to_s.strip
48
+ choice = "2" if choice.empty?
49
+ end
50
+ end
51
+ end
52
+
53
+ def generate(context)
54
+ strategy = context.get(:pagination) || :pagy
55
+
56
+ if strategy == :pagy
57
+ install_pagy(context)
58
+ else
59
+ install_geared_pagination(context)
60
+ end
61
+
62
+ context.set_output(:pagination, { pagination: strategy })
63
+ end
64
+
65
+ def outputs(context)
66
+ context.output(:pagination)
67
+ end
68
+
69
+ private
70
+
71
+ def install_geared_pagination(context)
72
+ add_top_level_gem(context, "gem \"geared_pagination\", \"#{GEARED_PAGINATION_VERSION}\"")
73
+ end
74
+
75
+ def install_pagy(context)
76
+ add_top_level_gem(context, "gem \"pagy\", \"#{PAGY_VERSION}\"")
77
+ write_pagy_initializer(context)
78
+ # Pagy 43 replaced Backend/Frontend with Pagy::Method (controller only); view helpers live on @pagy.
79
+ include_module(context, "app/controllers/application_controller.rb", "ApplicationController", "Pagy::Method")
80
+ end
81
+
82
+ def write_pagy_initializer(context)
83
+ path = File.join(context.app_path, "config/initializers/pagy.rb")
84
+ FileUtils.mkdir_p(File.dirname(path))
85
+ File.write(path, PAGY_INITIALIZER)
86
+ end
87
+
88
+ def include_module(context, relative_path, class_or_module_name, module_to_include)
89
+ path = File.join(context.app_path, relative_path)
90
+ return unless File.exist?(path)
91
+
92
+ content = File.read(path)
93
+ include_line = "include #{module_to_include}"
94
+ return if content.include?(include_line)
95
+
96
+ lines = content.lines
97
+ open_index = lines.index { |line| line.match?(/\A(class|module)\s+#{Regexp.escape(class_or_module_name)}\b/) }
98
+ return unless open_index
99
+
100
+ lines.insert(open_index + 1, " #{include_line}\n")
101
+ File.write(path, lines.join)
102
+ end
103
+
104
+ def add_top_level_gem(context, gem_line)
105
+ gemfile_path = File.join(context.app_path, "Gemfile")
106
+ return unless File.exist?(gemfile_path)
107
+
108
+ content = File.read(gemfile_path)
109
+ return if content.include?(gem_line)
110
+
111
+ File.open(gemfile_path, "a") { |f| f.puts(gem_line) }
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class PrIssueTemplatesStep < Step
7
+ def self.id = :pr_issue_templates
8
+ def self.tier = :addon
9
+ def self.needs = []
10
+ def self.summary = "Generate GitHub templates"
11
+ def self.description = "Adds default pull request and issue templates."
12
+ def self.prompts = "None"
13
+
14
+ TEMPLATES_DIR = File.expand_path("../../templates/github", __dir__)
15
+
16
+ FILES = {
17
+ "PULL_REQUEST_TEMPLATE.md" => ".github/PULL_REQUEST_TEMPLATE.md",
18
+ "ISSUE_TEMPLATE/bug_report.md" => ".github/ISSUE_TEMPLATE/bug_report.md",
19
+ "ISSUE_TEMPLATE/feature_request.md" => ".github/ISSUE_TEMPLATE/feature_request.md"
20
+ }.freeze
21
+
22
+ def if_condition(_context) = true
23
+
24
+ def prompt(_context); end
25
+
26
+ def generate(context)
27
+ writer = OpinionatedRails::Generators::FileWriter.new
28
+
29
+ FILES.each do |source, destination|
30
+ content = File.read(File.join(TEMPLATES_DIR, source))
31
+ writer.write(File.join(context.app_path, destination), content)
32
+ end
33
+
34
+ context.set_output(:pr_issue_templates, { pr_issue_templates: true })
35
+ end
36
+
37
+ def outputs(context)
38
+ context.output(:pr_issue_templates)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class RubocopStep < Step
7
+ def self.id = :rubocop
8
+ def self.tier = :addon
9
+ def self.needs = []
10
+ def self.summary = "Configure RuboCop"
11
+ def self.description = "Installs and configures RuboCop with Omakase defaults or a custom setup."
12
+ def self.prompts = "RuboCop style: Omakase or Custom (default: Omakase)"
13
+
14
+ RUBOCOP_OMAKASE_VERSION = "1.1.0".freeze
15
+ UNPINNED_GEM_LINE = 'gem "rubocop-rails-omakase", require: false'
16
+ CUSTOM_OVERRIDES_TEMPLATE = File.expand_path("../../templates/rubocop/custom_overrides.yml", __dir__)
17
+
18
+ def if_condition(_context) = true
19
+
20
+ def prompt(context)
21
+ shell = context.shell
22
+ shell.say("Choose a RuboCop style:")
23
+ shell.say(" 1. Omakase (default)")
24
+ shell.say(" 2. Custom")
25
+
26
+ choice = shell.ask("RuboCop style (default: Omakase):").to_s.strip
27
+ choice = "1" if choice.empty?
28
+
29
+ loop do
30
+ case choice.downcase
31
+ when "omakase", "1"
32
+ context.answer(:rubocop, :omakase)
33
+ break
34
+ when "custom", "2"
35
+ context.answer(:rubocop, :custom)
36
+ break
37
+ else
38
+ shell.say("Invalid. Enter 'Omakase', 'Custom', 1, or 2.", :red)
39
+ choice = shell.ask("RuboCop style (default: Omakase):").to_s.strip
40
+ choice = "1" if choice.empty?
41
+ end
42
+ end
43
+ end
44
+
45
+ def generate(context)
46
+ style = context.get(:rubocop) || :omakase
47
+
48
+ pin_rubocop_gem(context)
49
+ write_custom_overrides(context) if style == :custom
50
+
51
+ context.set_output(:rubocop, { rubocop: style })
52
+ end
53
+
54
+ def outputs(context)
55
+ context.output(:rubocop)
56
+ end
57
+
58
+ private
59
+
60
+ def pin_rubocop_gem(context)
61
+ gemfile_path = File.join(context.app_path, "Gemfile")
62
+ return unless File.exist?(gemfile_path)
63
+
64
+ pinned_line = "gem \"rubocop-rails-omakase\", \"#{RUBOCOP_OMAKASE_VERSION}\", require: false"
65
+ content = File.read(gemfile_path)
66
+ return if content.include?(pinned_line)
67
+
68
+ if content.include?(UNPINNED_GEM_LINE)
69
+ File.write(gemfile_path, content.sub(UNPINNED_GEM_LINE, pinned_line))
70
+ else
71
+ add_dev_test_group_gem(context, pinned_line)
72
+ end
73
+ end
74
+
75
+ def add_dev_test_group_gem(context, gem_line)
76
+ gemfile_path = File.join(context.app_path, "Gemfile")
77
+ content = File.read(gemfile_path)
78
+ return if content.include?(gem_line)
79
+
80
+ lines = content.lines
81
+ group_start = lines.index { |line| line.strip == "group :development, :test do" }
82
+
83
+ if group_start
84
+ end_index = (group_start + 1...lines.size).find { |i| lines[i].strip == "end" }
85
+ lines.insert(end_index, " #{gem_line}\n")
86
+ else
87
+ lines << "\n" unless lines.last.nil? || lines.last.end_with?("\n")
88
+ lines << "\ngroup :development, :test do\n #{gem_line}\nend\n"
89
+ end
90
+
91
+ File.write(gemfile_path, lines.join)
92
+ end
93
+
94
+ def write_custom_overrides(context)
95
+ rubocop_yml_path = File.join(context.app_path, ".rubocop.yml")
96
+ overrides = File.read(CUSTOM_OVERRIDES_TEMPLATE)
97
+
98
+ content = <<~YAML
99
+ # Omakase Ruby styling for Rails
100
+ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
101
+
102
+ # --- user overrides below ---
103
+ #{overrides}
104
+ YAML
105
+
106
+ File.write(rubocop_yml_path, content)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end