monkrb 0.19.0 → 0.21.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +7 -6
  4. data/exe/monk +3 -181
  5. data/lib/monk/assets.rb +39 -9
  6. data/lib/monk/auth/errors.rb +3 -0
  7. data/lib/monk/auth/helpers.rb +11 -0
  8. data/lib/monk/auth/send_login_link.rb +36 -0
  9. data/lib/monk/auth.rb +34 -5
  10. data/lib/monk/base.rb +20 -0
  11. data/lib/monk/bin.rb +107 -0
  12. data/lib/monk/cli.rb +277 -0
  13. data/lib/monk/context.rb +17 -0
  14. data/lib/monk/errors.rb +0 -6
  15. data/lib/monk/generator/definition.rb +153 -0
  16. data/lib/monk/generator/result.rb +279 -0
  17. data/lib/monk/generator/run.rb +449 -0
  18. data/lib/monk/generator.rb +48 -0
  19. data/lib/monk/generators/auth.rb +29 -0
  20. data/lib/monk/generators/base.rb +19 -0
  21. data/lib/monk/generators/jobs.rb +21 -0
  22. data/lib/monk/generators/live.rb +26 -0
  23. data/lib/monk/generators/mail.rb +24 -0
  24. data/lib/monk/generators/postgres.rb +23 -0
  25. data/lib/monk/generators/redis.rb +20 -0
  26. data/lib/monk/generators/websocket.rb +24 -0
  27. data/lib/monk/generators.rb +7 -0
  28. data/lib/monk/jobs/adapters/pg.rb +3 -1
  29. data/lib/monk/jobs.rb +7 -0
  30. data/lib/monk/live/helpers.rb +13 -0
  31. data/lib/monk/live/policy.rb +39 -4
  32. data/lib/monk/live/session.rb +7 -0
  33. data/lib/monk/live.rb +17 -4
  34. data/lib/monk/log.rb +10 -1
  35. data/lib/monk/mail/deliver_later.rb +54 -0
  36. data/lib/monk/mail/later.rb +3 -51
  37. data/lib/monk/mail.rb +4 -0
  38. data/lib/monk/persistence/errors.rb +32 -0
  39. data/lib/monk/persistence/pg.rb +92 -1
  40. data/lib/monk/persistence/pool.rb +513 -0
  41. data/lib/monk/persistence.rb +196 -7
  42. data/lib/monk/templates/auth/agents.md +29 -0
  43. data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
  44. data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
  45. data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
  46. data/lib/monk/templates/auth/config/auth.rb +14 -8
  47. data/lib/monk/templates/auth/setup.md +37 -0
  48. data/lib/monk/templates/auth/test/auth_test.rb +17 -0
  49. data/lib/monk/templates/base/CLAUDE.md +1 -0
  50. data/lib/monk/templates/base/Dockerfile +4 -3
  51. data/lib/monk/templates/base/Gemfile +7 -2
  52. data/lib/monk/templates/base/Rakefile +8 -0
  53. data/lib/monk/templates/base/agents.md +41 -0
  54. data/lib/monk/templates/base/app/app.rb +5 -0
  55. data/lib/monk/templates/base/app/routes/.keep +0 -0
  56. data/lib/monk/templates/base/app/views/index.erb +1 -1
  57. data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
  58. data/lib/monk/templates/base/config/load.rb +8 -0
  59. data/lib/monk/templates/base/config/settings.rb +4 -5
  60. data/lib/monk/templates/base/setup.md +22 -0
  61. data/lib/monk/templates/base/test/app_test.rb +12 -0
  62. data/lib/monk/templates/base/test/test_helper.rb +18 -0
  63. data/lib/monk/templates/jobs/agents.md +25 -0
  64. data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
  65. data/lib/monk/templates/jobs/bin/jobs +19 -13
  66. data/lib/monk/templates/jobs/setup.md +30 -0
  67. data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
  68. data/lib/monk/templates/live/agents.md +25 -0
  69. data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
  70. data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
  71. data/lib/monk/templates/live/app/routes/live.rb +10 -0
  72. data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
  73. data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
  74. data/lib/monk/templates/live/config/live.rb +30 -32
  75. data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
  76. data/lib/monk/templates/live/setup.md +39 -0
  77. data/lib/monk/templates/live/test/live_test.rb +22 -0
  78. data/lib/monk/templates/mail/agents.md +22 -0
  79. data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
  80. data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
  81. data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
  82. data/lib/monk/templates/mail/config/mail.rb +4 -4
  83. data/lib/monk/templates/mail/setup.md +32 -0
  84. data/lib/monk/templates/mail/test/mail_test.rb +13 -0
  85. data/lib/monk/templates/postgres/agents.md +25 -0
  86. data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
  87. data/lib/monk/templates/postgres/bin/console +3 -1
  88. data/lib/monk/templates/postgres/bin/migrate +23 -17
  89. data/lib/monk/templates/postgres/bin/setup_db +11 -5
  90. data/lib/monk/templates/postgres/config/persistence.rb +1 -0
  91. data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
  92. data/lib/monk/templates/postgres/setup.md +38 -0
  93. data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
  94. data/lib/monk/templates/redis/agents.md +18 -0
  95. data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
  96. data/lib/monk/templates/redis/config/redis.rb +13 -0
  97. data/lib/monk/templates/redis/setup.md +20 -0
  98. data/lib/monk/templates/redis/test/redis_test.rb +12 -0
  99. data/lib/monk/templates/websocket/agents.md +25 -0
  100. data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
  101. data/lib/monk/templates/websocket/bin/websocket_server +58 -0
  102. data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
  103. data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
  104. data/lib/monk/templates/websocket/setup.md +51 -0
  105. data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
  106. data/lib/monk/version.rb +1 -1
  107. data/lib/monk/websocket/connection.rb +25 -6
  108. data/lib/monk/websocket/listeners.rb +24 -0
  109. data/lib/monk/websocket/pg_fanout.rb +143 -20
  110. data/lib/monk/websocket/redis_fanout.rb +78 -29
  111. data/lib/monk/websocket/registry.rb +24 -0
  112. data/lib/monk/websocket/server.rb +57 -16
  113. data/lib/monk/websocket.rb +2 -0
  114. data/lib/monk.rb +10 -0
  115. metadata +83 -14
  116. data/lib/monk/scaffold.rb +0 -1061
  117. data/lib/monk/templates/base/bin/websocket_server +0 -68
  118. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
  119. data/lib/monk/templates/live/app/app.rb +0 -22
  120. data/lib/monk/templates/live/app/views/index.erb +0 -18
  121. data/lib/monk/templates/live/bin/websocket_server +0 -42
  122. data/lib/monk/templates/live/config/live_pg.rb +0 -60
  123. data/lib/monk/templates/mail/Gemfile.extra +0 -1
  124. data/lib/monk/templates/postgres/Dockerfile +0 -32
  125. data/lib/monk/templates/postgres/Gemfile.extra +0 -2
  126. data/lib/monk/templates/redis/Gemfile.extra +0 -1
  127. /data/lib/monk/templates/live/app/views/{live → demo}/_hits.erb +0 -0
@@ -0,0 +1,153 @@
1
+ module Monk
2
+ module Generator
3
+ # One module's generator, as `Monk::Generator.define(:jobs) { ... }`
4
+ # declares it. Only declarations: Run decides what they mean for a given
5
+ # app, and writes nothing until it has checked every file.
6
+ class Definition
7
+ Copy = Data.define(:path, :from, :role, :executable, :condition)
8
+ Migration = Data.define(:name)
9
+ Env = Data.define(:file, :key, :value, :commented)
10
+ Option = Data.define(:name, :values, :infer_from, :describe, :default)
11
+ Service = Data.define(:name, :check, :setup)
12
+ Step = Data.define(:text, :note, :needs_service, :condition)
13
+ Example = Data.define(:tag, :todo)
14
+ Production = Data.define(:key, :note, :placeholder)
15
+
16
+ # What a role means when the file already exists with other content:
17
+ # a wiring file stops the whole command, anything else is skipped
18
+ # (docs/history/plan-scaffold.md decision 26).
19
+ ROLES = %i[wiring example test view demo].freeze
20
+
21
+ # Which .env file each env group appends to.
22
+ ENV_FILES = { development: ".env", test: ".env.test", example: ".env.example" }.freeze
23
+
24
+ attr_reader :name, :copies, :migrations, :gems, :envs, :ignores, :options, :services, :steps,
25
+ :examples, :productions, :sections
26
+
27
+ def initialize(name)
28
+ @name = name.to_sym
29
+ @summary = ""
30
+ @dependencies = []
31
+ @option_dependencies = []
32
+ @installed_if = "config/#{name}.rb"
33
+ @copies = []
34
+ @migrations = []
35
+ @gems = []
36
+ @envs = []
37
+ @ignores = []
38
+ @options = []
39
+ @services = []
40
+ @steps = []
41
+ @examples = []
42
+ @productions = []
43
+ @sections = {}
44
+ @closing = nil
45
+ end
46
+
47
+ def summary(text = nil)
48
+ return @summary if text.nil?
49
+
50
+ @summary = text
51
+ end
52
+
53
+ # Modules this one needs, added first when they're missing.
54
+ def depends_on(*names)
55
+ @dependencies.concat(names.map(&:to_sym))
56
+ end
57
+
58
+ # A choice the user makes (websocket's transport). It's inferred when
59
+ # exactly one of `infer_from` is installed or being added, and the
60
+ # chosen value is a dependency when `dependency: true`. An option with
61
+ # a `default:` (live's demo) is never inferred.
62
+ def option(name, values:, infer_from: values, dependency: false, describe: {}, default: nil)
63
+ @options << Option.new(name: name.to_sym, values: values.map(&:to_s), infer_from: infer_from.map(&:to_s),
64
+ describe: describe.transform_keys(&:to_s), default: default&.to_s,)
65
+ @option_dependencies << name.to_sym if dependency
66
+ end
67
+
68
+ # The file whose existence means this module is installed.
69
+ def installed_if(path = nil)
70
+ return @installed_if if path.nil?
71
+
72
+ @installed_if = path
73
+ end
74
+
75
+ # A file copied verbatim from templates/<module>/<path>, or from
76
+ # `from:`, a template path or a block taking the options. `if:`, a
77
+ # block taking the options, decides whether it's copied at all.
78
+ def copy(*paths, role:, from: nil, executable: false, if: nil)
79
+ raise ArgumentError, "unknown role #{role.inspect}, one of #{ROLES.inspect}" unless ROLES.include?(role)
80
+ raise ArgumentError, "from: needs a single path" if from && paths.size > 1
81
+
82
+ condition = binding.local_variable_get(:if)
83
+ paths.each do |path|
84
+ @copies << Copy.new(path: path, from: from, role: role, executable: executable, condition: condition)
85
+ end
86
+ end
87
+
88
+ # db/migrate/<timestamp>_<name>.{up,down}.sql, from the template
89
+ # migration named *_<name>.{up,down}.sql.
90
+ def migration(name)
91
+ @migrations << Migration.new(name: name)
92
+ end
93
+
94
+ # A line appended to the Gemfile unless that gem is already there.
95
+ def gem(line)
96
+ @gems << line
97
+ end
98
+
99
+ # A KEY=value line appended to one of the .env files unless KEY is
100
+ # already there (the app's own value wins). The value may be a block
101
+ # taking the app's name.
102
+ def env(file, values, commented: false)
103
+ raise ArgumentError, "unknown env file #{file.inspect}" unless ENV_FILES.key?(file)
104
+
105
+ values.each { |key, value| @envs << Env.new(file: file, key: key.to_s, value: value, commented: commented) }
106
+ end
107
+
108
+ # A line appended to .gitignore and .dockerignore unless it's there.
109
+ def ignore(line)
110
+ @ignores << line
111
+ end
112
+
113
+ def service(name, check:, setup:)
114
+ @services << Service.new(name: name.to_s, check: check, setup: setup)
115
+ end
116
+
117
+ # This module's section of SETUP.md or AGENTS.md: a template path.
118
+ def setup_section(path) = @sections[:setup] = path
119
+ def agents_section(path) = @sections[:agents] = path
120
+
121
+ def set_before_production(key, note, placeholder: false)
122
+ @productions << Production.new(key: key, note: note, placeholder: placeholder)
123
+ end
124
+
125
+ # A command (`bin/jobs`) or something to do; `note:` says what for.
126
+ def next_step(text, note: nil, needs_service: nil, if: nil)
127
+ @steps << Step.new(text: text, note: note, needs_service: needs_service&.to_s,
128
+ condition: binding.local_variable_get(:if),)
129
+ end
130
+
131
+ # What to do with an example block (`# monk:example <tag>`). `todo`
132
+ # may be a block taking the names of the installed modules.
133
+ def example(tag, todo:)
134
+ @examples << Example.new(tag: tag, todo: todo)
135
+ end
136
+
137
+ # The one thing that matters most after adding this module: a String,
138
+ # or a block taking the options.
139
+ def closing(text = nil)
140
+ return @closing if text.nil?
141
+
142
+ @closing = text
143
+ end
144
+
145
+ # The options whose value is a module to depend on (websocket's transport).
146
+ def dependency_options = @option_dependencies
147
+
148
+ def dependencies(options = {})
149
+ @dependencies + @option_dependencies.filter_map { |option| options[option]&.to_sym }
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,279 @@
1
+ require "json"
2
+
3
+ module Monk
4
+ module Generator
5
+ # What one `monk add` did, or would do: printed as text for a person
6
+ # (decision 25), as one JSON object with --json (decision 27), and
7
+ # turned into the exit code (decision 28) -- all from the same data.
8
+ class Result
9
+ EXIT_CODES = { ok: 0, usage_error: 1, missing_choice: 2, conflict: 3 }.freeze
10
+
11
+ # The text form's widest path column.
12
+ COLUMN = 30
13
+ # The text form's numbered steps; JSON keeps them all.
14
+ MAX_STEPS = 3
15
+
16
+ attr_reader :status, :written, :dry_run, :requested, :modules, :files, :skipped, :env, :examples, :services,
17
+ :next_steps, :docs, :closing, :message, :suggestion, :choice, :conflicts, :command
18
+
19
+ def initialize(status:, written:, dry_run: false, requested: [], modules: [], files: [], skipped: [], env: [],
20
+ examples: [], services: [], next_steps: [], docs: [], closing: nil, message: nil, suggestion: nil,
21
+ choice: nil, conflicts: [], command: nil)
22
+ @status = status
23
+ @written = written
24
+ @dry_run = dry_run
25
+ @requested = requested
26
+ @modules = modules
27
+ @files = files
28
+ @skipped = skipped
29
+ @env = env
30
+ @examples = examples
31
+ @services = services
32
+ @next_steps = next_steps
33
+ @docs = docs
34
+ @closing = closing
35
+ @message = message
36
+ @suggestion = suggestion
37
+ @choice = choice
38
+ @conflicts = conflicts
39
+ @command = command
40
+ end
41
+
42
+ def ok? = status == :ok
43
+ def exit_code = EXIT_CODES.fetch(status)
44
+
45
+ def to_h
46
+ hash = { status: status.to_s, written: written, requested: requested.map(&:to_s) }
47
+ if ok?
48
+ hash.merge!(
49
+ modules: modules.map { |entry| stringify(entry) },
50
+ files: files.map { |entry| file_json(entry) },
51
+ skipped: skipped.map { |entry| stringify(entry) },
52
+ env: env.map { |entry| stringify(entry.except(:module)) },
53
+ examples: examples.map { |entry| stringify(entry.except(:module)) },
54
+ services: services.map { |entry| stringify(entry) },
55
+ next: next_steps.map { |entry| stringify(entry) },
56
+ docs: docs,
57
+ )
58
+ hash[:dry_run] = true if dry_run
59
+ else
60
+ hash[:message] = message
61
+ hash[:suggestion] = suggestion if suggestion
62
+ hash[:choice] = stringify(choice.except(:describe, :command)) if choice
63
+ hash[:conflicts] = conflicts.map { |entry| stringify(entry) } unless conflicts.empty?
64
+ end
65
+ stringify(hash)
66
+ end
67
+
68
+ def to_json(*) = JSON.pretty_generate(to_h)
69
+
70
+ def to_text
71
+ case status
72
+ when :ok then ok_text
73
+ when :missing_choice then missing_choice_text
74
+ when :conflict then conflict_text
75
+ else "#{message}#{"\n #{suggestion}" if suggestion}\n\nNothing was written.\n"
76
+ end
77
+ end
78
+
79
+ # `monk new`'s text: the app created, its base files by directory, then
80
+ # each module added on top of it, as `monk add` shows them.
81
+ def new_text(app)
82
+ return to_text unless ok?
83
+
84
+ added = modules.select { |entry| entry[:action] == :added && entry[:name] != :base }
85
+ lines = [new_header(app, added), "", *base_summary]
86
+ added.each { |entry| lines.push("", entry[:name].to_s, *module_lines(entry[:name])) }
87
+ lines.concat(production_text)
88
+ lines.push("", "Next:", " cd #{app}", *step_lines)
89
+ lines << " then: monk add --list the modules you can add" if added.empty?
90
+ lines.push("", closing) if closing && !dry_run
91
+ "#{lines.join("\n")}\n"
92
+ end
93
+
94
+ private
95
+
96
+ def new_header(app, added)
97
+ verb = dry_run ? "Would create" : "Created"
98
+ return "#{verb} #{app}/ (bare Monk app)#{" — nothing written" if dry_run}." if added.empty?
99
+
100
+ requested_added = added.reject { |entry| entry[:reason] }.map { |entry| entry[:name] }
101
+ dependencies = added.select { |entry| entry[:reason] }.map { |entry| entry[:name] }
102
+ line = "#{verb} #{app}/ with #{names(requested_added)}"
103
+ if dependencies.any?
104
+ line += ", and #{names(dependencies)}, which #{requested_added.size > 1 ? "they need" : "it needs"}"
105
+ end
106
+ "#{line}#{" — nothing written" if dry_run}."
107
+ end
108
+
109
+ # The base app's files, one line per top-level directory.
110
+ def base_summary
111
+ base = files.select { |entry| entry[:module] == :base && entry[:action] == :created }
112
+ paths = base.map { |entry| entry[:path] }
113
+ top, nested = paths.partition { |path| !path.include?("/") }
114
+ lines = top.empty? ? [] : [" + #{top.join(", ")}"]
115
+ nested.group_by { |path| path.split("/").first }.each do |dir, inside|
116
+ children = inside.map { |path| path.split("/")[1] + (path.count("/") > 1 ? "/" : "") }.uniq
117
+ lines << " + #{"#{dir}/".ljust(9)}#{children.join(", ")}"
118
+ end
119
+ sections = files.select { |entry| entry[:sections] }.map { |entry| entry[:path] }
120
+ lines << " + #{sections.join(", ")}" unless sections.empty?
121
+ lines
122
+ end
123
+
124
+ def ok_text
125
+ added = modules.select { |entry| entry[:action] == :added }
126
+ if added.empty?
127
+ return "#{names(requested)} #{requested.size > 1 ? "are" : "is"} already installed — nothing to do.\n"
128
+ end
129
+
130
+ lines = [header(added), ""]
131
+ added.each do |entry|
132
+ body = module_lines(entry[:name])
133
+ lines << entry[:name].to_s if added.size > 1
134
+ lines.concat(body)
135
+ lines << "" if added.size > 1
136
+ end
137
+ lines.pop if lines.last == "" && added.size == 1
138
+ sections = files.select { |entry| entry[:sections] }
139
+ if sections.any?
140
+ names = sections.flat_map { |entry| entry[:items] }.uniq
141
+ label = names.size > 1 ? "sections" : "section"
142
+ lines << row("~", sections.map { |entry| entry[:path] }.join(", "), "#{label}: #{names.join(", ")}")
143
+ elsif lines.last == ""
144
+ lines.pop
145
+ end
146
+ lines.concat(production_text, next_text)
147
+ lines.push("", closing) if closing && !dry_run
148
+ "#{lines.join("\n")}\n"
149
+ end
150
+
151
+ def header(added)
152
+ to_add = added.reject { |entry| entry[:reason] }.map { |entry| entry[:name] }
153
+ dependencies = added.select { |entry| entry[:reason] }.map { |entry| entry[:name] }
154
+ installed = modules.select { |entry| entry[:action] == :already_installed }.map { |entry| entry[:name] }
155
+ verb = dry_run ? "Would add" : "Adding"
156
+ line = "#{verb} #{names(to_add)}"
157
+ if dependencies.any?
158
+ line += ", which #{to_add.size > 1 ? "need" : "needs"} #{names(dependencies)} — adding " \
159
+ "#{dependencies.size > 1 ? "those" : "it"} first"
160
+ end
161
+ already = "#{names(installed)} already installed" if installed.any? && dependencies.empty?
162
+ return "#{line} (#{["nothing written", already].compact.join("; ")}):" if dry_run
163
+
164
+ already ? "#{line} (#{already})." : "#{line}."
165
+ end
166
+
167
+ def module_lines(name)
168
+ own = files.select { |entry| entry[:module] == name }
169
+ created = own.select { |entry| entry[:action] == :created }
170
+ lines = migrations_grouped(created).map { |entry| row("+", entry[:path], created_note(entry)) }
171
+ lines += skipped.select { |entry| entry[:module] == name }.map do |entry|
172
+ row("!", entry[:path], "exists and differs — left as is")
173
+ end
174
+ lines + appended_grouped(own.select { |entry| entry[:action] == :appended && !entry[:sections] })
175
+ end
176
+
177
+ def created_note(entry)
178
+ return "example: #{entry[:examples].map(&:first).uniq.join(", ")} (commented)" if entry[:examples]&.any?
179
+
180
+ "demo, development only" if entry[:role] == :demo
181
+ end
182
+
183
+ # db/migrate/..._name.up.sql and .down.sql as one line.
184
+ def migrations_grouped(created)
185
+ created.each_with_object([]) do |entry, rows|
186
+ if entry[:path].end_with?(".down.sql") && rows.last && rows.last[:path] == entry[:path].sub(/\.down\.sql\z/,
187
+ ".up.sql",)
188
+ rows[-1] = rows.last.merge(path: entry[:path].sub(/\.down\.sql\z/, ".{up,down}.sql"))
189
+ else
190
+ rows << entry
191
+ end
192
+ end
193
+ end
194
+
195
+ # The files that got the same lines (the .env files, say), as one line.
196
+ def appended_grouped(appended)
197
+ appended.group_by { |entry| compact_keys(entry[:items]) }.map do |items, entries|
198
+ row("~", entries.map { |entry| entry[:path] }.join(", "), items)
199
+ end
200
+ end
201
+
202
+ # DB_HOST, DB_NAME... as DB_*.
203
+ def compact_keys(items)
204
+ prefix = items.first.to_s[/\A[A-Z]+_/]
205
+ return "#{prefix}*" if prefix && items.size > 1 && items.all? { |item| item.start_with?(prefix) }
206
+
207
+ items.join(", ")
208
+ end
209
+
210
+ def production_text
211
+ return [] if env.empty?
212
+
213
+ width = env.map { |entry| entry[:key].size }.max
214
+ ["", "Set before production:", *env.map { |entry| " #{entry[:key].ljust(width)} #{entry[:note]}" }]
215
+ end
216
+
217
+ def next_text = ["", "Next:", *step_lines]
218
+
219
+ # At most three numbered steps, in the order they're done. Past three,
220
+ # bin/setup_db joins the tests' line, the first two and that line are
221
+ # numbered, and what's left is named on one more line each, with the
222
+ # SETUP.md section that explains it.
223
+ def step_lines
224
+ steps = next_steps.dup
225
+ if steps.size > MAX_STEPS && (migrate = steps.find { |step| step[:run] == "bin/setup_db" })
226
+ steps.delete(migrate)
227
+ steps[-1] = steps.last.merge(run: "bin/setup_db && #{steps.last[:run]}")
228
+ end
229
+ rest = []
230
+ if steps.size > MAX_STEPS
231
+ rest = steps[(MAX_STEPS - 1)...-1]
232
+ steps = steps.first(MAX_STEPS - 1) + [steps.last]
233
+ end
234
+ lines = steps.each_with_index.map { |step, index| " #{index + 1}. #{step_text(step)}" }
235
+ lines + rest.map { |step| " … also: #{step_text(step)}#{" (SETUP.md › #{step[:module]})" if step[:module]}" }
236
+ end
237
+
238
+ def step_text(step)
239
+ [step[:run] || step[:do], step[:note]].compact.join(" ")
240
+ end
241
+
242
+ def missing_choice_text
243
+ lines = ["#{command}: #{message} Pass one:", ""]
244
+ width = choice[:values].map(&:size).max
245
+ choice[:values].each do |value|
246
+ described = choice[:describe]&.fetch(value, nil)
247
+ lines << " #{command} --#{choice[:option]}=#{value.ljust(width)}#{" #{described}" if described}"
248
+ end
249
+ "#{lines.join("\n")}\n\nNothing was written.\n"
250
+ end
251
+
252
+ def conflict_text
253
+ lines = [message]
254
+ conflicts.each { |entry| lines << " Monk's version of #{entry[:path]}: #{entry[:show]}" }
255
+ "#{lines.join("\n")}\n\nNothing was written.\n"
256
+ end
257
+
258
+ def row(mark, path, note = nil)
259
+ note ? " #{mark} #{path.ljust(COLUMN)} #{note}" : " #{mark} #{path}"
260
+ end
261
+
262
+ def names(list)
263
+ list = list.map(&:to_s)
264
+ list.size <= 1 ? list.join : "#{list[0..-2].join(", ")} and #{list.last}"
265
+ end
266
+
267
+ def file_json(entry)
268
+ json = entry.slice(:path, :module, :role, :action, :items)
269
+ json[:examples] = entry[:examples].map(&:first).uniq if entry[:examples]&.any?
270
+ json[:module] = nil if entry[:sections]
271
+ stringify(json.compact)
272
+ end
273
+
274
+ def stringify(hash)
275
+ hash.to_h { |key, value| [key.to_s, value.is_a?(Symbol) ? value.to_s : value] }
276
+ end
277
+ end
278
+ end
279
+ end