rails_engine_toolkit 0.6.3

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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +54 -0
  3. data/.github/workflows/release.yml +22 -0
  4. data/Gemfile +11 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +83 -0
  7. data/Rakefile +7 -0
  8. data/docs/ARCHITECTURE.md +18 -0
  9. data/docs/COMPATIBILITY.md +18 -0
  10. data/docs/CONTRIBUTING.md +26 -0
  11. data/docs/END_TO_END.md +26 -0
  12. data/docs/PUBLISHING.md +24 -0
  13. data/docs/RELEASE.md +32 -0
  14. data/docs/RELEASE_CHECKLIST.md +49 -0
  15. data/docs/TESTING.md +21 -0
  16. data/exe/engine-toolkit +6 -0
  17. data/lib/rails_engine_toolkit/actions/delete_engine_migration.rb +46 -0
  18. data/lib/rails_engine_toolkit/actions/init.rb +54 -0
  19. data/lib/rails_engine_toolkit/actions/install_engine_migrations.rb +83 -0
  20. data/lib/rails_engine_toolkit/actions/new_engine.rb +127 -0
  21. data/lib/rails_engine_toolkit/actions/new_engine_migration.rb +30 -0
  22. data/lib/rails_engine_toolkit/actions/new_engine_model.rb +30 -0
  23. data/lib/rails_engine_toolkit/actions/remove_engine.rb +78 -0
  24. data/lib/rails_engine_toolkit/actions/uninstall_engine_migrations.rb +79 -0
  25. data/lib/rails_engine_toolkit/actions/update_engine_readme.rb +60 -0
  26. data/lib/rails_engine_toolkit/cli.rb +110 -0
  27. data/lib/rails_engine_toolkit/config.rb +98 -0
  28. data/lib/rails_engine_toolkit/errors.rb +7 -0
  29. data/lib/rails_engine_toolkit/file_editor.rb +40 -0
  30. data/lib/rails_engine_toolkit/generators/install/install_generator.rb +67 -0
  31. data/lib/rails_engine_toolkit/project.rb +71 -0
  32. data/lib/rails_engine_toolkit/railtie.rb +9 -0
  33. data/lib/rails_engine_toolkit/route_inspector.rb +44 -0
  34. data/lib/rails_engine_toolkit/routes_rewriter.rb +63 -0
  35. data/lib/rails_engine_toolkit/templates/engine_readme.erb +37 -0
  36. data/lib/rails_engine_toolkit/templates/engine_toolkit_yml.erb +35 -0
  37. data/lib/rails_engine_toolkit/templates/gemspec.erb +25 -0
  38. data/lib/rails_engine_toolkit/templates/license.erb +3 -0
  39. data/lib/rails_engine_toolkit/templates.rb +12 -0
  40. data/lib/rails_engine_toolkit/utils.rb +56 -0
  41. data/lib/rails_engine_toolkit/version.rb +5 -0
  42. data/lib/rails_engine_toolkit.rb +33 -0
  43. data/rails_engine_toolkit.gemspec +31 -0
  44. data/spec/rails_engine_toolkit/cli_spec.rb +11 -0
  45. data/spec/rails_engine_toolkit/config_spec.rb +52 -0
  46. data/spec/rails_engine_toolkit/file_editor_spec.rb +26 -0
  47. data/spec/rails_engine_toolkit/install_engine_migrations_spec.rb +36 -0
  48. data/spec/rails_engine_toolkit/install_generator_spec.rb +26 -0
  49. data/spec/rails_engine_toolkit/new_engine_integration_spec.rb +59 -0
  50. data/spec/rails_engine_toolkit/new_engine_spec.rb +54 -0
  51. data/spec/rails_engine_toolkit/project_spec.rb +19 -0
  52. data/spec/rails_engine_toolkit/remove_engine_integration_spec.rb +40 -0
  53. data/spec/rails_engine_toolkit/remove_engine_spec.rb +72 -0
  54. data/spec/rails_engine_toolkit/route_inspector_spec.rb +20 -0
  55. data/spec/rails_engine_toolkit/routes_rewriter_spec.rb +36 -0
  56. data/spec/rails_engine_toolkit/uninstall_engine_migrations_spec.rb +35 -0
  57. data/spec/rails_engine_toolkit/update_engine_readme_spec.rb +32 -0
  58. data/spec/spec_helper.rb +30 -0
  59. data/test/fixtures/host_app/Gemfile +5 -0
  60. data/test/fixtures/host_app/config/application.rb +11 -0
  61. data/test/fixtures/host_app/config/boot.rb +3 -0
  62. data/test/fixtures/host_app/config/environment.rb +3 -0
  63. data/test/fixtures/host_app/config/routes.rb +2 -0
  64. metadata +140 -0
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsEngineToolkit
4
+ module Actions
5
+ class NewEngine
6
+ def initialize(argv, stdin:, stdout:, root:, stderr: nil)
7
+ @engine_name = argv[0]
8
+ @stdin = stdin
9
+ @stdout = stdout
10
+ @stderr = stderr
11
+ @root = Pathname(root)
12
+ @project = Project.new(@root)
13
+ end
14
+
15
+ def call
16
+ @project.validate_root!
17
+ validate_engine_name!
18
+
19
+ config = @project.config
20
+ handle_broken_references!
21
+ ensure_engine_does_not_exist!
22
+
23
+ description = prompt_description(config.project_name)
24
+ engine_class = Utils.classify(@engine_name)
25
+
26
+ generate_engine!(config)
27
+ rewrite_engine_files!(config, description, engine_class)
28
+ create_ddd_structure!(config)
29
+ mount_engine_route(engine_class) if config.mount_routes?
30
+ Utils.safe_system('bundle', 'install', chdir: @root)
31
+
32
+ @stdout.puts("Engine created: engines/#{@engine_name}")
33
+ end
34
+
35
+ private
36
+
37
+ def validate_engine_name!
38
+ raise ValidationError, 'Engine name is required.' if @engine_name.to_s.empty?
39
+ raise ValidationError, 'Engine name must be snake_case.' unless Utils.snake_case?(@engine_name)
40
+ end
41
+
42
+ def handle_broken_references!
43
+ broken = @project.broken_engine_references
44
+ return if broken.empty?
45
+
46
+ message = "Broken engine references found in Gemfile (#{broken.join(', ')}). Remove them automatically?"
47
+ remove = Utils.ask_yes_no(
48
+ message,
49
+ default: false,
50
+ input: @stdin,
51
+ output: @stdout
52
+ )
53
+ raise ValidationError, 'Please fix the Gemfile before creating a new engine.' unless remove
54
+
55
+ @project.remove_broken_engine_references!(broken)
56
+ end
57
+
58
+ def ensure_engine_does_not_exist!
59
+ engine_path = @project.engine_path(@engine_name)
60
+ raise ValidationError, "Engine already exists: #{engine_path}" if engine_path.exist?
61
+ end
62
+
63
+ def prompt_description(project_name)
64
+ default = "Provides #{@engine_name} domain for #{project_name}"
65
+ Utils.ask('Engine description', default: default, input: @stdin, output: @stdout)
66
+ end
67
+
68
+ def generate_engine!(config)
69
+ args = ['bundle', 'exec', 'rails', 'plugin', 'new', "engines/#{@engine_name}", '--mountable', '-d',
70
+ config.default_database]
71
+ args << '--api' if config.api_only?
72
+ args.concat(config.skip_flags)
73
+ Utils.safe_system(*args, chdir: @root)
74
+ end
75
+
76
+ def rewrite_engine_files!(config, description, engine_class)
77
+ engine_path = @project.engine_path(@engine_name)
78
+ engine_path.join('Gemfile').write("source \"https://rubygems.org\"\n\ngemspec\n")
79
+ engine_path.join("#{@engine_name}.gemspec").write(
80
+ Templates.render(
81
+ 'gemspec',
82
+ engine_name: @engine_name,
83
+ engine_class: engine_class,
84
+ author_name: config.author_name,
85
+ author_email: config.author_email,
86
+ summary: "#{engine_class} domain engine",
87
+ description: description,
88
+ project_url: config.project_url,
89
+ ruby_version: config.ruby_version,
90
+ rails_version: config.rails_version,
91
+ license: config.license
92
+ )
93
+ )
94
+ engine_path.join('MIT-LICENSE').write(
95
+ Templates.render(
96
+ 'license',
97
+ year: Time.now.year,
98
+ author_name: config.author_name,
99
+ license: config.license
100
+ )
101
+ )
102
+ engine_path.join('README.md').write(
103
+ Templates.render(
104
+ 'engine_readme',
105
+ engine_name: @engine_name,
106
+ engine_class: engine_class,
107
+ description: description
108
+ )
109
+ )
110
+ end
111
+
112
+ def create_ddd_structure!(config)
113
+ return unless config.create_ddd_structure?
114
+
115
+ engine_path = @project.engine_path(@engine_name)
116
+ config.ddd_folders.each { |folder| FileUtils.mkdir_p(engine_path.join(folder)) }
117
+ end
118
+
119
+ def mount_engine_route(engine_class)
120
+ return unless @project.routes_file.file?
121
+
122
+ rewriter = RoutesRewriter.new(@project.routes_file)
123
+ rewriter.add_mount(engine_class: engine_class, mount_path: "/#{@engine_name}")
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsEngineToolkit
4
+ module Actions
5
+ class NewEngineMigration
6
+ def initialize(argv, stdout:, root:, stdin: nil, stderr: nil)
7
+ @engine_name = argv.shift
8
+ @generator_args = argv
9
+ @stdout = stdout
10
+ @stdin = stdin
11
+ @stderr = stderr
12
+ @root = Pathname(root)
13
+ @project = Project.new(@root)
14
+ end
15
+
16
+ def call
17
+ @project.validate_root!
18
+ raise ValidationError, 'Engine name is required.' if @engine_name.to_s.empty?
19
+ raise ValidationError, 'Migration name is required.' if @generator_args.empty?
20
+
21
+ engine_path = @project.engine_path(@engine_name)
22
+ raise ValidationError, "Engine does not exist: #{engine_path}" unless engine_path.directory?
23
+
24
+ Utils.safe_system('bin/rails', 'g', 'migration', *@generator_args, chdir: engine_path)
25
+ UpdateEngineReadme.new([@engine_name], stdin: nil, stdout: @stdout, stderr: nil, root: @root).call
26
+ @stdout.puts("Migration created inside #{engine_path}")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsEngineToolkit
4
+ module Actions
5
+ class NewEngineModel
6
+ def initialize(argv, stdout:, root:, stdin: nil, stderr: nil)
7
+ @engine_name = argv.shift
8
+ @generator_args = argv
9
+ @stdout = stdout
10
+ @stdin = stdin
11
+ @stderr = stderr
12
+ @root = Pathname(root)
13
+ @project = Project.new(@root)
14
+ end
15
+
16
+ def call
17
+ @project.validate_root!
18
+ raise ValidationError, 'Engine name is required.' if @engine_name.to_s.empty?
19
+ raise ValidationError, 'Model name is required.' if @generator_args.empty?
20
+
21
+ engine_path = @project.engine_path(@engine_name)
22
+ raise ValidationError, "Engine does not exist: #{engine_path}" unless engine_path.directory?
23
+
24
+ Utils.safe_system('bin/rails', 'g', 'model', *@generator_args, chdir: engine_path)
25
+ UpdateEngineReadme.new([@engine_name], stdin: nil, stdout: @stdout, stderr: nil, root: @root).call
26
+ @stdout.puts("Model created inside #{engine_path}")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsEngineToolkit
4
+ module Actions
5
+ class RemoveEngine
6
+ def initialize(argv, stdin:, stdout:, root:, stderr: nil)
7
+ @engine_name = argv[0]
8
+ @stdin = stdin
9
+ @stdout = stdout
10
+ @stderr = stderr
11
+ @root = Pathname(root)
12
+ @project = Project.new(@root)
13
+ end
14
+
15
+ def call
16
+ @project.validate_root!
17
+ validate_engine!
18
+
19
+ warn_if_root_migrations_exist!
20
+ confirm_deletion!
21
+
22
+ remove_mounts
23
+ remove_gemfile_entry
24
+ FileUtils.rm_rf(@project.engine_path(@engine_name))
25
+ Utils.safe_system('bundle', 'install', chdir: @root)
26
+ @stdout.puts("Removed engine: #{@engine_name}")
27
+ end
28
+
29
+ private
30
+
31
+ def validate_engine!
32
+ raise ValidationError, 'Engine name is required.' if @engine_name.to_s.empty?
33
+ raise ValidationError, 'Engine name must be snake_case.' unless Utils.snake_case?(@engine_name)
34
+
35
+ engine_path = @project.engine_path(@engine_name)
36
+ raise ValidationError, "Engine does not exist: #{engine_path}" unless engine_path.directory?
37
+ end
38
+
39
+ def warn_if_root_migrations_exist!
40
+ installed = @project.matching_root_migrations_for_engine(@engine_name)
41
+ return if installed.empty?
42
+
43
+ proceed = Utils.ask_yes_no(
44
+ 'Root migrations may belong to this engine. Continue anyway?',
45
+ default: false,
46
+ input: @stdin,
47
+ output: @stdout
48
+ )
49
+ raise ValidationError, 'Operation aborted.' unless proceed
50
+ end
51
+
52
+ def confirm_deletion!
53
+ confirmed = Utils.ask(
54
+ "Type '#{@engine_name}' to confirm deletion",
55
+ input: @stdin,
56
+ output: @stdout
57
+ )
58
+ raise ValidationError, 'Operation aborted.' unless confirmed == @engine_name
59
+ end
60
+
61
+ def remove_mounts
62
+ return unless @project.routes_file.file?
63
+
64
+ RoutesRewriter.new(@project.routes_file).remove_mount(
65
+ engine_class: Utils.classify(@engine_name),
66
+ mount_path: "/#{@engine_name}"
67
+ )
68
+ end
69
+
70
+ def remove_gemfile_entry
71
+ FileEditor.remove_lines_matching(
72
+ @project.gemfile,
73
+ %r{path:\s*["']engines/#{Regexp.escape(@engine_name)}["']}
74
+ )
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsEngineToolkit
4
+ module Actions
5
+ class UninstallEngineMigrations
6
+ def initialize(argv, stdin:, stdout:, root:, stderr: nil)
7
+ @engine_name = argv[0]
8
+ @stdin = stdin
9
+ @stdout = stdout
10
+ @stderr = stderr
11
+ @root = Pathname(root)
12
+ @project = Project.new(@root)
13
+ end
14
+
15
+ def call
16
+ @project.validate_root!
17
+ validate_engine!
18
+
19
+ root_matches = matching_root_migrations
20
+ if root_matches.empty?
21
+ @stdout.puts("No installed root migrations found for engine '#{@engine_name}'.")
22
+ return
23
+ end
24
+
25
+ print_matches(root_matches)
26
+ confirm_uninstall!
27
+ root_matches.each(&:delete)
28
+
29
+ @stdout.puts("Removed #{root_matches.size} root migration file(s) for engine '#{@engine_name}'.")
30
+ end
31
+
32
+ private
33
+
34
+ def validate_engine!
35
+ raise ValidationError, 'Engine name is required.' if @engine_name.to_s.empty?
36
+ raise ValidationError, 'Engine name must be snake_case.' unless Utils.snake_case?(@engine_name)
37
+
38
+ engine_path = @project.engine_path(@engine_name)
39
+ migrations_dir = engine_path.join('db/migrate')
40
+ raise ValidationError, "Engine does not exist: #{engine_path}" unless engine_path.directory?
41
+
42
+ return if migrations_dir.directory?
43
+
44
+ raise ValidationError,
45
+ "Engine migrations directory not found: #{migrations_dir}"
46
+ end
47
+
48
+ def matching_root_migrations
49
+ return [] unless @project.root_migrations_dir.directory?
50
+
51
+ engine_basenames = @project.engine_path(@engine_name).join('db/migrate').glob('*.rb').map do |file|
52
+ file.basename.to_s.sub(/^\d+_/, '')
53
+ end.sort
54
+
55
+ @project.root_migrations_dir.glob('*.rb').select do |file|
56
+ engine_basenames.include?(file.basename.to_s.sub(/^\d+_/, ''))
57
+ end.sort
58
+ end
59
+
60
+ def print_matches(root_matches)
61
+ @stdout.puts("The following root migrations match engine '#{@engine_name}':")
62
+ root_matches.each { |file| @stdout.puts(" #{file.relative_path_from(@root)}") }
63
+ @stdout.puts('')
64
+ @stdout.puts('This only removes copied migration files from db/migrate.')
65
+ @stdout.puts('It does NOT rollback the database automatically.')
66
+ @stdout.puts('Run your rollback or down migrations manually if needed.')
67
+ end
68
+
69
+ def confirm_uninstall!
70
+ confirmed = Utils.ask(
71
+ "Type '#{@engine_name}' to confirm uninstall",
72
+ input: @stdin,
73
+ output: @stdout
74
+ )
75
+ raise ValidationError, 'Operation aborted.' unless confirmed == @engine_name
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsEngineToolkit
4
+ module Actions
5
+ class UpdateEngineReadme
6
+ def initialize(argv, stdout:, root:, stdin: nil, stderr: nil)
7
+ @engine_name = argv[0]
8
+ @stdout = stdout
9
+ @stdin = stdin
10
+ @stderr = stderr
11
+ @root = Pathname(root)
12
+ @project = Project.new(@root)
13
+ end
14
+
15
+ def call
16
+ @project.validate_root!
17
+ validate_args!
18
+
19
+ engine_path = @project.engine_path(@engine_name)
20
+ readme = engine_path.join('README.md')
21
+ migrations_dir = engine_path.join('db/migrate')
22
+
23
+ raise ValidationError, "README not found: #{readme}" unless readme.file?
24
+
25
+ owned_tables_block = tables_from(migrations_dir).then do |tables|
26
+ tables.empty? ? '- None defined yet.' : tables.map { |table| "- #{table}" }.join("\n")
27
+ end
28
+
29
+ content = readme.read
30
+ unless content.match?(/^## Owned tables$/)
31
+ raise ValidationError,
32
+ "Could not find '## Owned tables' section in #{readme}"
33
+ end
34
+
35
+ updated = content.sub(/^## Owned tables$.*?(?=^## |\z)/m, "## Owned tables\n#{owned_tables_block}\n\n")
36
+ readme.write(updated)
37
+
38
+ @stdout.puts("Updated README: #{readme}")
39
+ end
40
+
41
+ private
42
+
43
+ def validate_args!
44
+ raise ValidationError, 'Engine name is required.' if @engine_name.to_s.empty?
45
+ raise ValidationError, 'Engine name must be snake_case.' unless Utils.snake_case?(@engine_name)
46
+
47
+ engine_path = @project.engine_path(@engine_name)
48
+ raise ValidationError, "Engine does not exist: #{engine_path}" unless engine_path.directory?
49
+ end
50
+
51
+ def tables_from(migrations_dir)
52
+ return [] unless migrations_dir.directory?
53
+
54
+ migrations_dir.glob('*.rb').flat_map do |file|
55
+ file.read.scan(/create_table\s+:?"?([a-zA-Z0-9_]+)"?/).flatten
56
+ end.uniq.sort
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsEngineToolkit
4
+ class CLI < Thor
5
+ class_option :root, type: :string
6
+
7
+ KNOWN_COMMANDS = %w[
8
+ help
9
+ init
10
+ new_engine
11
+ update_engine_readme
12
+ new_engine_migration
13
+ delete_engine_migration
14
+ new_engine_model
15
+ install_engine_migrations
16
+ uninstall_engine_migrations
17
+ remove_engine
18
+ ].freeze
19
+
20
+ def self.exit_on_failure?
21
+ true
22
+ end
23
+
24
+ desc 'init', 'Create config/engine_toolkit.yml'
25
+ def init
26
+ run_action(Actions::Init, [])
27
+ end
28
+
29
+ desc 'new_engine ENGINE_NAME', 'Create a new internal Rails engine'
30
+ def new_engine(engine_name)
31
+ run_action(Actions::NewEngine, [engine_name])
32
+ end
33
+
34
+ desc 'update_engine_readme ENGINE_NAME', 'Refresh the README owned tables section'
35
+ def update_engine_readme(engine_name)
36
+ run_action(Actions::UpdateEngineReadme, [engine_name])
37
+ end
38
+
39
+ desc 'new_engine_migration ENGINE_NAME MIGRATION_NAME [ARGS...]', 'Create a migration inside an engine'
40
+ def new_engine_migration(engine_name, migration_name, *args)
41
+ run_action(Actions::NewEngineMigration, [engine_name, migration_name, *args])
42
+ end
43
+
44
+ desc 'delete_engine_migration ENGINE_NAME PATTERN', 'Delete matching engine migration files'
45
+ def delete_engine_migration(engine_name, pattern)
46
+ run_action(Actions::DeleteEngineMigration, [engine_name, pattern])
47
+ end
48
+
49
+ desc 'new_engine_model ENGINE_NAME MODEL_NAME [ATTRS...]', 'Create a model inside an engine'
50
+ def new_engine_model(engine_name, model_name, *attrs)
51
+ run_action(Actions::NewEngineModel, [engine_name, model_name, *attrs])
52
+ end
53
+
54
+ desc 'install_engine_migrations ENGINE_NAME', "Copy only one engine's migrations into the host app"
55
+ def install_engine_migrations(engine_name)
56
+ run_action(Actions::InstallEngineMigrations, [engine_name])
57
+ end
58
+
59
+ desc 'uninstall_engine_migrations ENGINE_NAME', 'Remove root migrations that were installed from one engine'
60
+ def uninstall_engine_migrations(engine_name)
61
+ run_action(Actions::UninstallEngineMigrations, [engine_name])
62
+ end
63
+
64
+ desc 'remove_engine ENGINE_NAME', 'Remove an internal engine'
65
+ def remove_engine(engine_name)
66
+ run_action(Actions::RemoveEngine, [engine_name])
67
+ end
68
+
69
+ def self.start(argv)
70
+ first = argv.first
71
+ if first && !first.start_with?('-') && !KNOWN_COMMANDS.include?(first)
72
+ puts(command_help_text)
73
+ return 1
74
+ end
75
+
76
+ super
77
+ 0
78
+ rescue Error => e
79
+ warn("Error: #{e.message}")
80
+ 1
81
+ end
82
+
83
+ def self.command_help_text
84
+ <<~TEXT
85
+ Commands:
86
+ init
87
+ new_engine ENGINE_NAME
88
+ update_engine_readme ENGINE_NAME
89
+ new_engine_migration ENGINE_NAME MIGRATION_NAME [ARGS...]
90
+ delete_engine_migration ENGINE_NAME PATTERN
91
+ new_engine_model ENGINE_NAME MODEL_NAME [ATTRS...]
92
+ install_engine_migrations ENGINE_NAME
93
+ uninstall_engine_migrations ENGINE_NAME
94
+ remove_engine ENGINE_NAME
95
+ TEXT
96
+ end
97
+
98
+ private
99
+
100
+ def run_action(klass, argv)
101
+ klass.new(
102
+ argv,
103
+ stdin: $stdin,
104
+ stdout: $stdout,
105
+ stderr: $stderr,
106
+ root: options[:root] || Pathname.pwd
107
+ ).call
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsEngineToolkit
4
+ class Config
5
+ DEFAULT_PATH = 'config/engine_toolkit.yml'
6
+
7
+ REQUIRED_TOP_LEVEL_KEYS = %w[project author defaults metadata].freeze
8
+ VALID_DATABASES = %w[postgresql mysql2 sqlite3].freeze
9
+ DEFAULT_DDD_FOLDERS = [
10
+ 'app/use_cases',
11
+ 'app/services',
12
+ 'app/policies',
13
+ 'app/serializers',
14
+ 'app/repositories',
15
+ 'app/contracts'
16
+ ].freeze
17
+ SKIP_FLAG_MAPPING = {
18
+ 'skip_asset_pipeline' => '--skip-asset-pipeline',
19
+ 'skip_action_mailbox' => '--skip-action-mailbox',
20
+ 'skip_action_text' => '--skip-action-text',
21
+ 'skip_active_storage' => '--skip-active-storage',
22
+ 'skip_hotwire' => '--skip-hotwire',
23
+ 'skip_jbuilder' => '--skip-jbuilder',
24
+ 'skip_system_test' => '--skip-system-test'
25
+ }.freeze
26
+
27
+ attr_reader :data
28
+
29
+ def self.load(project_root, path: DEFAULT_PATH)
30
+ full_path = Pathname(project_root).join(path)
31
+ raise ValidationError, "Configuration file not found: #{full_path}" unless full_path.exist?
32
+
33
+ new(YAML.safe_load(full_path.read, aliases: true) || {}, path: full_path).tap(&:validate!)
34
+ end
35
+
36
+ def initialize(data, path:)
37
+ @data = data
38
+ @path = path
39
+ end
40
+
41
+ def validate!
42
+ REQUIRED_TOP_LEVEL_KEYS.each do |key|
43
+ raise ValidationError, "Missing config section: #{key} in #{@path}" unless data.key?(key)
44
+ end
45
+
46
+ validate_string('project', 'name')
47
+ validate_string('project', 'slug')
48
+ validate_string('author', 'name')
49
+ validate_string('author', 'email')
50
+
51
+ raise ValidationError, 'project.slug must be snake_case' unless Utils.snake_case?(project_slug)
52
+ return true if VALID_DATABASES.include?(default_database)
53
+
54
+ raise ValidationError, "defaults.database must be one of: #{VALID_DATABASES.join(', ')}"
55
+ end
56
+
57
+ def project_name = fetch('project', 'name')
58
+ def project_slug = fetch('project', 'slug')
59
+ def project_url = fetch('project', 'url', default: '')
60
+ def author_name = fetch('author', 'name')
61
+ def author_email = fetch('author', 'email')
62
+ def license = fetch('metadata', 'license', default: 'MIT')
63
+ def ruby_version = fetch('metadata', 'ruby_version', default: '>= 3.2')
64
+ def rails_version = fetch('metadata', 'rails_version', default: '>= 8.1.2')
65
+ def default_database = fetch('defaults', 'database', default: 'postgresql')
66
+ def api_only? = !!fetch('defaults', 'api_only', default: true)
67
+ def mount_routes? = !!fetch('defaults', 'mount_routes', default: true)
68
+ def create_ddd_structure? = !!fetch('defaults', 'create_ddd_structure', default: true)
69
+
70
+ def skip_flags
71
+ defaults = data.fetch('defaults', {})
72
+ SKIP_FLAG_MAPPING.filter_map do |key, flag|
73
+ flag if defaults.fetch(key, true)
74
+ end
75
+ end
76
+
77
+ def ddd_folders
78
+ fetch('ddd', 'folders', default: DEFAULT_DDD_FOLDERS)
79
+ end
80
+
81
+ private
82
+
83
+ def validate_string(*keys)
84
+ value = fetch(*keys)
85
+ raise ValidationError, "Missing config value: #{keys.join('.')}" if value.to_s.strip.empty?
86
+ end
87
+
88
+ def fetch(*keys, default: nil)
89
+ cursor = data
90
+ keys.each do |key|
91
+ return default unless cursor.is_a?(Hash) && cursor.key?(key)
92
+
93
+ cursor = cursor[key]
94
+ end
95
+ cursor.nil? ? default : cursor
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsEngineToolkit
4
+ class Error < StandardError; end
5
+ class ValidationError < Error; end
6
+ class CommandError < Error; end
7
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsEngineToolkit
4
+ class FileEditor
5
+ def self.remove_lines_matching(path, regex)
6
+ return false unless path.exist?
7
+
8
+ original = path.read
9
+ updated = original.lines.grep_v(regex).join
10
+ changed = original != updated
11
+ path.write(updated) if changed
12
+ changed
13
+ end
14
+
15
+ def self.insert_after_first_match(path, match_regex, text)
16
+ content = path.read
17
+ updated = content.sub(match_regex) { |m| "#{m}#{text}" }
18
+ changed = updated != content
19
+ path.write(updated) if changed
20
+ changed
21
+ end
22
+
23
+ def self.ensure_line_after_draw_block(path, line)
24
+ return false unless path.exist?
25
+
26
+ content = path.read
27
+ return false if content.include?(line.strip)
28
+
29
+ updated = content.sub(/Rails\.application\.routes\.draw do\s*\n/, "Rails.application.routes.draw do\n#{line}")
30
+ changed = updated != content
31
+ path.write(updated) if changed
32
+ changed
33
+ end
34
+
35
+ def self.copy_file(path_from, path_to)
36
+ FileUtils.mkdir_p(path_to.dirname)
37
+ FileUtils.cp(path_from, path_to)
38
+ end
39
+ end
40
+ end