brut 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/Gemfile.lock +66 -1
  4. data/README.md +36 -0
  5. data/Rakefile +22 -0
  6. data/brut.gemspec +7 -0
  7. data/doc-src/architecture.md +102 -0
  8. data/doc-src/assets.md +98 -0
  9. data/doc-src/forms.md +214 -0
  10. data/doc-src/handlers.md +83 -0
  11. data/doc-src/javascript.md +265 -0
  12. data/doc-src/keyword-injection.md +183 -0
  13. data/doc-src/pages.md +210 -0
  14. data/doc-src/route-hooks.md +59 -0
  15. data/docs-todo.md +32 -0
  16. data/lib/brut/back_end/seed_data.rb +5 -1
  17. data/lib/brut/back_end/validator.rb +1 -1
  18. data/lib/brut/back_end/validators/form_validator.rb +31 -6
  19. data/lib/brut/cli/app.rb +100 -4
  20. data/lib/brut/cli/app_runner.rb +38 -5
  21. data/lib/brut/cli/apps/build_assets.rb +4 -6
  22. data/lib/brut/cli/apps/db.rb +2 -7
  23. data/lib/brut/cli/apps/scaffold.rb +413 -7
  24. data/lib/brut/cli/apps/test.rb +14 -1
  25. data/lib/brut/cli/command.rb +141 -6
  26. data/lib/brut/cli/error.rb +30 -3
  27. data/lib/brut/cli/execution_results.rb +44 -6
  28. data/lib/brut/cli/executor.rb +21 -1
  29. data/lib/brut/cli/options.rb +29 -2
  30. data/lib/brut/cli/output.rb +47 -0
  31. data/lib/brut/cli.rb +26 -0
  32. data/lib/brut/factory_bot.rb +2 -0
  33. data/lib/brut/framework/app.rb +38 -5
  34. data/lib/brut/framework/config.rb +97 -54
  35. data/lib/brut/framework/container.rb +97 -33
  36. data/lib/brut/framework/errors/abstract_method.rb +3 -7
  37. data/lib/brut/framework/errors/missing_parameter.rb +12 -0
  38. data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
  39. data/lib/brut/framework/errors/not_found.rb +12 -2
  40. data/lib/brut/framework/errors/not_implemented.rb +14 -0
  41. data/lib/brut/framework/errors.rb +18 -0
  42. data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
  43. data/lib/brut/framework/mcp.rb +106 -49
  44. data/lib/brut/framework/project_environment.rb +9 -0
  45. data/lib/brut/framework.rb +1 -0
  46. data/lib/brut/front_end/asset_metadata.rb +7 -1
  47. data/lib/brut/front_end/component.rb +129 -38
  48. data/lib/brut/front_end/components/constraint_violations.rb +57 -0
  49. data/lib/brut/front_end/components/form_tag.rb +23 -32
  50. data/lib/brut/front_end/components/i18n_translations.rb +34 -1
  51. data/lib/brut/front_end/components/input.rb +3 -0
  52. data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
  53. data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
  54. data/lib/brut/front_end/components/inputs/select.rb +26 -2
  55. data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
  56. data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
  57. data/lib/brut/front_end/components/locale_detection.rb +8 -1
  58. data/lib/brut/front_end/components/page_identifier.rb +2 -0
  59. data/lib/brut/front_end/components/time.rb +95 -0
  60. data/lib/brut/front_end/components/traceparent.rb +22 -0
  61. data/lib/brut/front_end/download.rb +11 -0
  62. data/lib/brut/front_end/flash.rb +32 -0
  63. data/lib/brut/front_end/form.rb +109 -106
  64. data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
  65. data/lib/brut/front_end/forms/input.rb +30 -42
  66. data/lib/brut/front_end/forms/input_declarations.rb +90 -0
  67. data/lib/brut/front_end/forms/input_definition.rb +45 -30
  68. data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
  69. data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
  70. data/lib/brut/front_end/forms/select_input.rb +47 -0
  71. data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +23 -9
  73. data/lib/brut/front_end/handler.rb +27 -8
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
  75. data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
  76. data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
  77. data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
  78. data/lib/brut/front_end/handling_results.rb +14 -4
  79. data/lib/brut/front_end/http_method.rb +13 -1
  80. data/lib/brut/front_end/http_status.rb +10 -0
  81. data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
  82. data/lib/brut/front_end/middleware.rb +5 -0
  83. data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
  84. data/lib/brut/front_end/middlewares/favicon.rb +16 -0
  85. data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
  86. data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
  87. data/lib/brut/front_end/page.rb +50 -11
  88. data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
  89. data/lib/brut/front_end/pages/missing_page.rb +36 -0
  90. data/lib/brut/front_end/request_context.rb +117 -8
  91. data/lib/brut/front_end/route_hook.rb +43 -1
  92. data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
  93. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
  94. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
  95. data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
  96. data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
  97. data/lib/brut/front_end/routing.rb +138 -31
  98. data/lib/brut/front_end/session.rb +86 -7
  99. data/lib/brut/front_end/template.rb +17 -2
  100. data/lib/brut/front_end/templates/block_filter.rb +4 -3
  101. data/lib/brut/front_end/templates/erb_parser.rb +1 -1
  102. data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
  103. data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
  104. data/lib/brut/front_end/templates/locator.rb +60 -0
  105. data/lib/brut/i18n/base_methods.rb +4 -0
  106. data/lib/brut/i18n.rb +1 -0
  107. data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
  108. data/lib/brut/instrumentation/open_telemetry.rb +107 -0
  109. data/lib/brut/instrumentation.rb +4 -6
  110. data/lib/brut/junk_drawer.rb +54 -4
  111. data/lib/brut/sinatra_helpers.rb +42 -38
  112. data/lib/brut/spec_support/clock_support.rb +6 -0
  113. data/lib/brut/spec_support/component_support.rb +53 -26
  114. data/lib/brut/spec_support/e2e_test_server.rb +82 -0
  115. data/lib/brut/spec_support/enhanced_node.rb +45 -0
  116. data/lib/brut/spec_support/general_support.rb +14 -3
  117. data/lib/brut/spec_support/handler_support.rb +2 -0
  118. data/lib/brut/spec_support/matcher.rb +6 -3
  119. data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
  120. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
  121. data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
  122. data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
  123. data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
  124. data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
  125. data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
  126. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
  127. data/lib/brut/spec_support/rspec_setup.rb +182 -0
  128. data/lib/brut/spec_support.rb +8 -3
  129. data/lib/brut/version.rb +2 -1
  130. data/lib/brut.rb +28 -5
  131. data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
  132. data/lib/sequel/extensions/brut_migrations.rb +18 -8
  133. data/lib/sequel/plugins/created_at.rb +2 -0
  134. data/lib/sequel/plugins/external_id.rb +39 -1
  135. data/lib/sequel/plugins/find_bang.rb +4 -1
  136. metadata +140 -13
  137. data/lib/brut/back_end/action.rb +0 -3
  138. data/lib/brut/back_end/result.rb +0 -46
  139. data/lib/brut/front_end/components/timestamp.rb +0 -33
  140. data/lib/brut/instrumentation/basic.rb +0 -66
  141. data/lib/brut/instrumentation/event.rb +0 -19
  142. data/lib/brut/instrumentation/http_event.rb +0 -5
  143. data/lib/brut/instrumentation/subscriber.rb +0 -41
@@ -1,8 +1,14 @@
1
1
  require "optparse"
2
+ # Base class for subcommands of a {Brut::CLI::App}. You must implement {#execute} to perform whatever action this command must perform.
2
3
  class Brut::CLI::Command
3
4
  include Brut::CLI::ExecutionResults
4
5
  include Brut::I18n::ForCLI
6
+ include Brut::Framework::Errors
5
7
 
8
+ # Call this to set the one-line description of this command
9
+ #
10
+ # @param new_description [String] When present, sets the description of this command. When omitted, returns the current description.
11
+ # @return [String] the current description (if called with no parameters)
6
12
  def self.description(new_description=nil)
7
13
  if new_description.nil?
8
14
  return @description.to_s
@@ -10,6 +16,12 @@ class Brut::CLI::Command
10
16
  @description = new_description
11
17
  end
12
18
  end
19
+
20
+
21
+ # Call this to set a an additional detailed description of this command. Currently, this should not be formatted and will be shown all on one line. This is shown after the `description`, so this text can follow from that, without having to restate it.
22
+ #
23
+ # @param new_description [String] When present, sets the detailed description of this command. When omitted, returns the current detailed description.
24
+ # @return [String] the current detailed description (if called with no parameters)
13
25
  def self.detailed_description(new_description=nil)
14
26
  if new_description.nil?
15
27
  if @detailed_description.nil?
@@ -20,6 +32,17 @@ class Brut::CLI::Command
20
32
  @detailed_description = new_description
21
33
  end
22
34
  end
35
+
36
+ # Set a description of the command line args this command accepts. Args are any part of the command line that is not a switch or
37
+ # flag. The string you give will be used only for documentation. Typically, you would format it in one a few ways:
38
+ #
39
+ # * `args "some_arg"` indicates that exactly one arg called `some_arg` is required
40
+ # * `args "[some_arg]"` indicates that exactly one arg called `some_arg` is optional
41
+ # * `args "args..."` indicates that
42
+ # * `args "[args...]"` indicates that zero or more args called `args` are accepted
43
+ #
44
+ # @param [String] new_args documentation for this command's args. If omitted, returns the current value.
45
+ # @return [String the current value (if called with no parameters)
23
46
  def self.args(new_args=nil)
24
47
  if new_args.nil?
25
48
  return @args.to_s
@@ -27,19 +50,67 @@ class Brut::CLI::Command
27
50
  @args = new_args
28
51
  end
29
52
  end
53
+
54
+ # Call this for each environment variable this *command* responds to. These would be variables that affect only this command. For
55
+ # app-wide environment variables, see {Brut::CLI::App.env_var}.
56
+ #
57
+ # @param var_name [String] Declares that this command recognizes this environment variable.
58
+ # @param purpose [String] An explanation for how this environment variable affects the command. Used in documentation.
59
+ def self.env_var(var_name,purpose:)
60
+ env_vars[var_name] = purpose
61
+ end
62
+
63
+ # Access all configured environment variables.
64
+ # @!visibility private
65
+ def self.env_vars
66
+ @env_vars ||= {
67
+ }
68
+ end
69
+
70
+ # Returns the name of this command, for use on the command line. By default, returns the "underscorized" name of this class
71
+ # (excluding any module namespaces). For exaple, if your command's class is `MyApp::CLI::Commands::ClearFiles::DryRun`, the command
72
+ # name would be `"dry_run"`. You can override this if you want something different. It is recommended that it not include spaces
73
+ # or other characters meaningful to the shell, but if you like quotes, cool.
30
74
  def self.command_name = RichString.new(self.name.split(/::/).last).underscorized
75
+
76
+ # Checks if a given string matches this command name. This exists to allow the use of underscores or dashes as delimiters. Ruby
77
+ # likes underscores, but the shell vibe is often dashes.
78
+ #
79
+ # @param [String] string the command given on the command line
80
+ # @return [true|false] true if `string` is considered an exact match for this command's name.
31
81
  def self.name_matches?(string)
32
82
  self.command_name == string || self.command_name.to_s.gsub(/_/,"-") == string
33
83
  end
84
+
85
+ # Provides access to an `OptionParser` you can use to declare flags and switches that should be accepted only by this command.
86
+ # The way to use this is to call `.on` and provide a description for an option as you would to `OptionParser`.
87
+ # The only difference is that you should not pass a block to this. When the command line is parsed, the results will be placed
88
+ # into a {Brut::CLI::Options} instance made available to your command.
89
+ #
90
+ # @return [OptionParser]
34
91
  def self.opts
35
92
  self.option_parser
36
93
  end
94
+
95
+ # Returns the configured `OptionParser` used to parse the command portion of the command line.
96
+ # If you don't want to call {.opts}, you can create and return
97
+ # a fully-formed `OptionParser` by overriding this method. By default, it will create one with a conventional banner.
98
+ #
99
+ # @return [OptionParser]
37
100
  def self.option_parser
38
101
  @option_parser ||= OptionParser.new do |opts|
39
102
  opts.banner = "%{app} %{global_options} #{command_name} %{command_options} %{args}"
40
103
  end
41
104
  end
42
105
 
106
+ # Call this if this command requires a project environment as context for what it does. When called, this will do a few things:
107
+ #
108
+ # * Your command (not app) will recognize `--env=ENVIRONMENT` as a global option
109
+ # * Your command will document that it recognizes the `RACK_ENV` environment variable.
110
+ #
111
+ # @see Brut::CLI::App.requires_project_env
112
+ #
113
+ # @param default [String] name of the environment to use if none was specified. `nil` should be used to require the environment to be specified explicitly.
43
114
  def self.requires_project_env(default: "development")
44
115
  default_message = if default.nil?
45
116
  ""
@@ -49,11 +120,22 @@ class Brut::CLI::Command
49
120
  opts.on("--env=ENVIRONMENT","Project environment#{default_message}")
50
121
  @default_env = default
51
122
  @requires_project_env = true
123
+ self.env_var("RACK_ENV",purpose: "default project environment when --env is omitted")
52
124
  end
53
125
 
126
+ # Returns the default project env, based on the logic described in {.requires_project_env}
54
127
  def self.default_env = @default_env
128
+ # Returns true if this app requires a project env
55
129
  def self.requires_project_env? = @requires_project_env
56
130
 
131
+ # Creates the command before executing it. Generally you would not call this directly.
132
+ #
133
+ # @param [Brut::CLI::Options] command_options the command options parsed from the command line.
134
+ # @param [Brut::CLI::Options] global_options the global options parsed from the command line.
135
+ # @param [Array] args Any unparsed arguments
136
+ # @param [Brut::CLI::Output] out an IO used to send messages to the standard output
137
+ # @param [Brut::CLI::Output] err an IO used to send messages to the standard error
138
+ # @param [Brut::CLI::Executor] executor used to execute child processes instead of e.g. `system`
57
139
  def initialize(command_options:,global_options:, args:,out:,err:,executor:)
58
140
  @command_options = command_options
59
141
  @global_options = global_options
@@ -66,8 +148,20 @@ class Brut::CLI::Command
66
148
  end
67
149
  end
68
150
 
151
+ # Convienince method to call {Brut::CLI::Executor#system!} on the executor given in the constructor.
152
+ # @param (see Brut::CLI::Executor#system!)
153
+ # @return (see Brut::CLI::Executor#system!)
69
154
  def system!(*args) = @executor.system!(*args)
70
155
 
156
+ # Use this inside {#execute} to createa compound command that executes other commands that are a part of your CLI app.
157
+ # Note that each command will be given the same global and commmand options and the same arguments, so these commands must be able
158
+ # to complete as desired in that way.
159
+ #
160
+ # Note that since commands are just classes, you can certianly create them however you like and call `execute` yourself.
161
+ #
162
+ # @param [Enumerable<Class>] command_klasses one or more classes that subclass {Brut::CLI::Command} to delegate to.
163
+ # @return [Brut::CLI::ExecutionResults::Result] the first non successful result is returned and processing is stopped, otherwise returns the
164
+ # result of the last class executed.
71
165
  def delegate_to_commands(*command_klasses)
72
166
  result = nil
73
167
  command_klasses.each do |command_klass|
@@ -80,28 +174,52 @@ class Brut::CLI::Command
80
174
  result
81
175
  end
82
176
 
83
- def delegate_to_command(command_klass)
84
- command = command_klass.new(command_options: options, global_options:, args:, out:, err:, executor: @executor)
85
- as_execution_result(command.execute)
86
- end
87
-
177
+ # You must implement this to perform whatever action your command needs to perform. In order to do this, you will have access to:
178
+ #
179
+ # * {#options} - the command options passed on the command line
180
+ # * {#global_options} - the global options passed on the command line
181
+ # * {#args} - the args passed on the command line
182
+ # * {#out} - an IO you should use to print messages to the standard out.
183
+ # * {#err} - on IO you should use to print messages to the standard error.
184
+ # * {#system!} - the method you should use to spawn child processes.
185
+ #
186
+ # @return [Brut::CLI::ExecutionResults::Result] a description of what happened during processing. It is preferable to try to return
187
+ # something instead of raising an exception.
188
+ # @raise [Brurt::CLI::Error] if thrown, this will be caught and handled by {Brut::CLI::AppRunner}.
189
+ # @raise [StandardError] if thrown, this will bubble up and show your user a very sad stack trace that will make them cry. Don't.
88
190
  def execute
89
- raise Brut::Framework::Errors::AbstractMethod
191
+ abstract_method!
90
192
  end
91
193
 
194
+ # Called before any execution or bootstrapping happens but after {Brut::CLI::App#before_execute} is called. This will have access
195
+ # to everything {#execute} can access.
196
+ # @raise [Brurt::CLI::Error] if thrown, this will be caught and handled by {Brut::CLI::AppRunner} and execution will be aborted.
197
+ # @raise [StandardError] if thrown, this will bubble up and show your user a very sad stack trace that will make them cry. Don't.
92
198
  def before_execute
93
199
  end
94
200
 
201
+ # @!visibility private
95
202
  def set_env_if_needed
96
203
  if self.class.requires_project_env?
97
204
  ENV["RACK_ENV"] = options.env
98
205
  end
99
206
  end
100
207
 
208
+ # Called if there is an exception during bootstrapping. By default, it re-raises the exception, which cases the command to abort.
209
+ # The reason you may want to overrid this is that your command line app may exist to handle a bootstrapping exception. For example,
210
+ # the built-in {Brut::CLI::Apps::DB} app will catch various database connection errors and then create or migrate the database.
211
+ #
212
+ # Yes, I realize this means we are using exceptions for control flow. It's fine.
213
+ #
214
+ # @param [StandardError] ex whichever exception was caught during bootstrapping
215
+ # @return [void] ignored
216
+ # @raise [Brurt::CLI::Error] if thrown, this will be caught and handled by {Brut::CLI::AppRunner} and execution will be aborted.
217
+ # @raise [StandardError] if thrown, this will bubble up and show your user a very sad stack trace that will make them cry. Don't.
101
218
  def handle_bootstrap_exception(ex)
102
219
  raise ex
103
220
  end
104
221
 
222
+ # @!visibility private
105
223
  def bootstrap!(project_root:, configure_only:)
106
224
  require "bundler"
107
225
  Bundler.require(:default, ENV["RACK_ENV"].to_sym)
@@ -116,15 +234,32 @@ class Brut::CLI::Command
116
234
 
117
235
  private
118
236
 
237
+ # @!visibility public
238
+ # @return [Brut::CLI::Options] the command options parsed on the command line
119
239
  def options = @command_options
240
+ # @!visibility public
241
+ # @return [Brut::CLI::Options] the global options parsed on the command line
120
242
  def global_options = @global_options
243
+ # @!visibility public
244
+ # @return [Array<String>] the arguments parsed from the command line
121
245
  def args = @args
246
+ # @!visibility public
247
+ # @return [Brut::CLI::Output] IO to use for sending messages to the standard output
122
248
  def out = @out
249
+ # @!visibility public
250
+ # @return [Brut::CLI::Output] IO to use for sending messages to the standard error
123
251
  def err = @err
124
252
 
253
+ # Exists to warn users not to use `puts`
125
254
  def puts(...)
126
255
  warn("Your CLI apps should use out and err to produce terminal output, not puts", uplevel: 1)
127
256
  Kernel.puts(...)
128
257
  end
129
258
 
259
+ def delegate_to_command(command_klass)
260
+ command = command_klass.new(command_options: options, global_options:, args:, out:, err:, executor: @executor)
261
+ as_execution_result(command.execute)
262
+ end
263
+
264
+
130
265
  end
@@ -1,12 +1,39 @@
1
- # Marker that means an expected error happens and
2
- # we don't need to show the stack trace
1
+ # All errors from a Brut CLI should extend this. Any error that does will be caught and handled without showing the user a stack
2
+ # trace.
3
3
  class Brut::CLI::Error < StandardError
4
4
  end
5
+ # Raised when a child process executed by {Brut::CLI::Executor} returns a nonzero exit status.
6
+ #
7
+ # @see Brut::CLI::Executor#system!
5
8
  class Brut::CLI::SystemExecError < Brut::CLI::Error
6
- attr_reader :command,:exit_status
9
+ # @return [String|Array] command line invocation that caused the error
10
+ attr_reader :command
11
+ # @return [Integer] exit status of the command
12
+ attr_reader :exit_status
13
+ # @param [String|Array] command or args passed to {Brut::CLI::Executor#system!}.
14
+ # @param [Integer] exit_status the exit status of the command.
7
15
  def initialize(command,exit_status)
8
16
  super("#{command} failed - exited #{exit_status}")
9
17
  @command = command
10
18
  @exit_status = exit_status
11
19
  end
12
20
  end
21
+
22
+ # Raised when an `OptionParser::ParseError` is caught to provide a more useful
23
+ # error message given the global/command optiona dichotomy.
24
+ class Brut::CLI::InvalidOption < Brut::CLI::Error
25
+ def initialize(option_parser_parse_error, context:)
26
+ args = option_parser_parse_error.args
27
+ count_message = if option_parser_parse_error.args.length == 1
28
+ "isn't a valid option"
29
+ else
30
+ "aren't valid options"
31
+ end
32
+ type_message = if context.kind_of?(Class)
33
+ "for the '#{context.command_name}' command"
34
+ else
35
+ "for the app globally"
36
+ end
37
+ super("#{args.join(", ")} #{count_message} #{type_message}")
38
+ end
39
+ end
@@ -1,30 +1,43 @@
1
1
  module Brut
2
2
  module CLI
3
+ # Included in commands to allow convienient ways to return structured information about what happened during command
4
+ # execution.
3
5
  module ExecutionResults
6
+ # Base class for all results.
4
7
  class Result
8
+ # @return [Strting] any message provided in the results. Generally `nil` for successful results.
5
9
  attr_reader :message
10
+
11
+ # Create a new result
12
+ #
13
+ # @param exit_status [Integer] exit status desired for this command. 0 is treated as success by any command that called your
14
+ # command. All other values have command-defined meanings.
15
+ # @param message [String|nil] a message to explain the results, if relevant.
6
16
  def initialize(exit_status:,message:nil)
7
17
  @exit_status = exit_status
8
18
  @message = message
9
19
  end
10
20
 
11
- # Returns true if execution internal to the command should stop
21
+ # @return [true|false] true if execution internal to the command should stop
12
22
  def stop? = @exit_status != 0
13
- # Returns true if the execution of the command succeeded or didn't error
23
+ # @return [true|false] true if the execution of the command succeeded or didn't error
14
24
  def ok? = @exit_status == 0
15
- # Returns the exit status to use for the CLI
25
+ # @return [Integer] the exit status
16
26
  def to_i = @exit_status
27
+ # @return [true|false] true if the result means that the user should be show CLI usage in addition to any messaging.
17
28
  def show_usage? = false
18
29
  end
19
30
 
20
- # Stop execution, even though nothing is wrong
31
+ # @!visibility private
21
32
  class Stop < Result
22
33
  def initialize
23
34
  super(exit_status: 0)
24
35
  end
36
+ # @return [true] true
25
37
  def stop? = true
26
38
  end
27
39
 
40
+ # @!visibility private
28
41
  class ShowCLIUsage < Stop
29
42
  attr_reader :command_klass
30
43
  def initialize(command_klass:)
@@ -34,14 +47,14 @@ module Brut
34
47
  def show_usage? = true
35
48
  end
36
49
 
37
- # Continue execution
50
+ # @!visibility private
38
51
  class Continue < Result
39
52
  def initialize
40
53
  super(exit_status: 0)
41
54
  end
42
55
  end
43
56
 
44
- # Abort execution immediately
57
+ # @!visibility private
45
58
  class Abort < Result
46
59
  def initialize(exit_status:1,message:nil)
47
60
  if exit_status == 0
@@ -50,6 +63,7 @@ module Brut
50
63
  super(exit_status:,message:)
51
64
  end
52
65
  end
66
+ # @!visibility private
53
67
  class CLIUsageError < Abort
54
68
  def initialize(message:)
55
69
  super(message:,exit_status:65)
@@ -57,12 +71,36 @@ module Brut
57
71
  def show_usage? = true
58
72
  end
59
73
 
74
+ # Return this from {Brut::CLI::Command#execute} to stop execution, without signaling an error
60
75
  def stop_execution = Stop.new
76
+ # Return this from {Brut::CLI::Command#execute} to indicate any other execution may continue. This is mostly useful when one
77
+ # command calls another e.g. with {Brut::CLI::Command#delegate_to_commands}.
61
78
  def continue_execution = Continue.new
79
+ # Return this from {Brut::CLI::Command#execute} to stop execution and signal an error
80
+ #
81
+ # @param [String] message the error message
82
+ # @param [Integer] exit_status the exit status (should ideally not be 0)
62
83
  def abort_execution(message,exit_status:1) = Abort.new(message:,exit_status:)
84
+ # Return this from {Brut::CLI::Command#execute} to stop execution because the user messed up the CLI invocation.
85
+ #
86
+ # @param [String] message the error message
63
87
  def cli_usage_error(message) = CLIUsageError.new(message:)
88
+ # Return this from {Brut::CLI::Command#execute} to stop execution, and show the user the CLI help, optionally for a specific
89
+ # command.
90
+ #
91
+ # @param [Class] command_klass if given, this it he class whose help will be shown.
64
92
  def show_cli_usage(command_klass=nil) = ShowCLIUsage.new(command_klass:)
65
93
 
94
+ # Coerce a value to the appropriate {Brut::CLI::ExecutionResults::Result}. Currently works as follows:
95
+ #
96
+ # 1. If `exit_status_or_execution_result` is nil or true, returns a successful result
97
+ # 2. If `exit_status_or_execution_result` is an integer, returns a result with no message and that value as the exit status
98
+ # 3. If `exit_status_or_execution_result` is false, returns {#abort_execution}.
99
+ # 4. If `exit_status_or_execution_result` is a {Brut::CLI::ExecutionResults::Result}, returns that
100
+ # 5. Otherwise, raises `ArgumentError`
101
+ #
102
+ # @param [nil|true|false|Brut::CLI::ExecutionResults::Result|Integer] exit_status_or_execution_result the value to coerce
103
+ # @return [Brut::CLI::ExecutionResults::Result] appropriate for the parameter
66
104
  def as_execution_result(exit_status_or_execution_result)
67
105
  if exit_status_or_execution_result.kind_of?(Numeric) || exit_status_or_execution_result.nil?
68
106
  Result.new(exit_status: exit_status_or_execution_result.to_i)
@@ -1,9 +1,29 @@
1
1
  require "open3"
2
+ # Abstracts the invocation of child processes, but includes useful output and exception handling that you'd need to be transparent to
3
+ # the user. In particular:
4
+ #
5
+ # * Outputs the command being executed to the standard error (via {Brut::CLI::Output}).
6
+ # * Outputs the command's standard error and standard output in real time. So, this should be usable for spawned commands that
7
+ # produce real time output like test runners.
8
+ # * Assumes spawned commands should succeed, raising an error if they do not.
2
9
  class Brut::CLI::Executor
10
+ # Create the executor
11
+ #
12
+ # @param [Brut::CLI::Output] out an IO used to send messages to the standard output
13
+ # @param [Brut::CLI::Output] err an IO used to send messages to the standard error
3
14
  def initialize(out:,err:)
4
15
  @out = out
5
16
  @err = err
6
17
  end
18
+
19
+ # Execute a command, logging it to the standard output and outputing the commands output and error to the standard output and error,
20
+ # respecitively
21
+ #
22
+ # @see https://docs.ruby-lang.org/en/3.3/Open3.html#method-c-popen3
23
+ #
24
+ # @param [String|Array] args Whatever you would give to `Kernel#system` or `Open3.popen3`.
25
+ # @raise Brut::CLI::Error::SystemExecError if the spawed command exits nonzer
26
+ # @return [true]
7
27
  def system!(*args)
8
28
  @out.puts "Executing #{args}"
9
29
  wait_thread = Open3.popen3(*args) do |_stdin,stdout,stderr,wait_thread|
@@ -30,7 +50,7 @@ class Brut::CLI::Executor
30
50
  if wait_thread.value.success?
31
51
  @out.puts "#{args} succeeded"
32
52
  else
33
- raise Brut::CLI::SystemExecError.new(*args,wait_thread.value.exitstatus)
53
+ raise Brut::CLI::SystemExecError.new(args,wait_thread.value.exitstatus)
34
54
  end
35
55
  true
36
56
  end
@@ -1,19 +1,46 @@
1
- # Convienience module to put into Hash to allow options
2
- # parsed to be a bit more accessible
1
+ # Wraps parsed command line options to provide a method-like interface instead of simple `Hash` acceess. Also allows specifying default values when an option wasn't given on the command line, as well as boolean coercion for switches. Allows accessing options via snake_case in code, even if specified via kebab-case on the command line.
3
2
  class Brut::CLI::Options
4
3
  def initialize(parsed_options)
5
4
  @parsed_options = parsed_options
6
5
  @defaults = {}
7
6
  end
8
7
 
8
+ # Returns the parsed options as a `Hash`
9
+ # @return [Hash]
9
10
  def to_h = @parsed_options
10
11
 
12
+ # Access an options value directly.
13
+ # @param [String] key the key to use. This must be the exact name you used when calling `opts.on` or when creating the `OptionParser` for your app or command. Generally, use the {#method_missing}-provided version instead of this.
11
14
  def [](key) = @parsed_options[key]
15
+ # Check if `key` was provided on the command line.
16
+ # @param [String] key the key to use. This must be the exact name you used when calling `opts.on` or when creating the `OptionParser` for your app or command.
12
17
  def key?(key) = @parsed_options.key?(key)
18
+
19
+ # Set a default value for an option when {#method_missing} is used to access it and that flag or switch was not used on the command
20
+ # line.
21
+ # @param [Symbol] sym the symbol of the option, either in kebab-case or snake_case.
22
+ # @param [Object] default_value the value to return if that option was not used on the command line
13
23
  def set_default(sym,default_value)
14
24
  @defaults[sym] = default_value
15
25
  end
16
26
 
27
+ # Dynamically creates methods for each command-line option. Note that this doesn't know what options could've been provided, so it
28
+ # will respond to any method that either takes no arguments or where the only argument is `default:`.
29
+ #
30
+ # @param [Symbol] sym kebab or snake case value of the option. A question mark should be used if the option is to be coerced to a booealn.
31
+ # @param [Array|Hash] args if the first arg is a Hash with the key `default:`, this value will be used if `sym` has no value
32
+ #
33
+ # @return [true|false|nil|Object] the value of the option used on the command line, or the default used in `default:`, or the
34
+ # preconfigured default, or `nil`. If `sym` ended in a question mark, true or false will be returned (never nil).
35
+ #
36
+ # @example
37
+ #
38
+ # # Suppose the user provided `--log-level=debug --dry-run` on the command line
39
+ # options.log_level # => "debug"
40
+ # options.verbose # => nil
41
+ # options.verbose(default: "yes") # => "yes"
42
+ # options.dry_run? # => true
43
+ # options.slow? # => false
17
44
  def method_missing(sym,*args,&block)
18
45
  boolean = false
19
46
  if sym.to_s =~ /\?$/
@@ -1,14 +1,41 @@
1
+ # An `IO`-like class that provides user-helpful output, to be used in place of `puts`. This is not strictly an `IO` and is intended to provide only a few options for sending output to a human.
2
+ #
3
+ # Command line apps that a human runs are often executed in the context of other apps or themselves spawn child processes. Thus, it
4
+ # can be hard to know what output is coming from where. This class addresses that by prefixing every line with the name of the
5
+ # command line app:
6
+ #
7
+ # ```
8
+ # > bin/my_app doit
9
+ # [ bin/my_app ] About to do sometohing
10
+ # Cannot connect to house
11
+ # [ bin/my_app ] Problem connecting
12
+ # ```
13
+ #
14
+ # The prefix allows the human to know what output was generated by the app they ran and what not. This can be extremely helpful for
15
+ # debugging or just understanding what is going on.
1
16
  class Brut::CLI::Output
17
+ # Create a wrapper for the given IO that will use the given prefix
18
+ #
19
+ # @param [IO] io an IO where output should be sent, for example `$stdout`.
20
+ # @param [String] prefix a prefix to be placed in front of all messages (unless a `_no_prefix` version is called)
2
21
  def initialize(io:, prefix:)
3
22
  @io = io
4
23
  @prefix = prefix
5
24
  @sync_status = @io.sync
6
25
  end
7
26
 
27
+ # Calls `puts` without the prefix. This is useful if the output is going to be obvious to the human user (e.g. CLI help), or if
28
+ # it's intended to be piped into another app.
29
+ #
30
+ # @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-puts
8
31
  def puts_no_prefix(*objects)
9
32
  @io.puts(*objects)
10
33
  end
11
34
 
35
+ # Calls `puts`, adding a prefix to each of the objects in `*objects`. This is useful for sending messages that a human may want to
36
+ # read.
37
+ #
38
+ # @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-puts
12
39
  def puts(*objects)
13
40
  if objects.empty?
14
41
  objects << ""
@@ -19,10 +46,30 @@ class Brut::CLI::Output
19
46
  nil
20
47
  end
21
48
 
49
+ # Calls `print` without any prefix. In theory, this is the escape hatch to sending arbitrary data to the underlying `IO` without
50
+ # expose said `IO`
51
+ #
52
+ # @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-print
22
53
  def print(*objects)
23
54
  @io.print(*objects)
24
55
  end
25
56
 
57
+ # Prints a string via `printf`, using the prefix. This is useful for communciating to a human, but you need more power for
58
+ # formatting than is afforded by {#puts}.
59
+ #
60
+ # @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-printf
61
+ def printf(format_string,*objects)
62
+ @io.printf(@prefix + format_string,*objects)
63
+ end
64
+
65
+ # Prints a string via `printf`, without the prefix.
66
+ #
67
+ # @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-printf
68
+ def printf_no_prefix(format_string,*objects)
69
+ @io.printf(format_string,*objects)
70
+ end
71
+
72
+ # Flush the underlying `IO`.
26
73
  def flush
27
74
  @io.flush
28
75
  self
data/lib/brut/cli.rb CHANGED
@@ -1,6 +1,30 @@
1
1
  module Brut
2
+ # Brut provides a basic CLI framework for building CLIs that have access to your Brut app's innerworkings.
3
+ # This is an alternative to Rake tasks which suffer from poor usability and testability.
4
+ #
5
+ # To create a CLI, you will subclass {Brut::CLI::App}. That class will define your UI as well as any subcommands
6
+ # that your CLI will respond to. See {Brut::CLI::app}.
2
7
  module CLI
3
8
 
9
+ # Execute your CLI based on its command line invocation. You would call this method inside the executable file placed in `bin/`
10
+ # in your project. For example, if you have `YourApp::CLI::CleanOldFiles` and you wish to execute it via `bin/clean-files`, you'd
11
+ # create `bin/clean-files` like so:
12
+ #
13
+ # ```
14
+ # #!/usr/bin/env ruby
15
+ #
16
+ # require "bundler"
17
+ # Bundler.require
18
+ # require "pathname"
19
+ # require "brut/cli/apps/db"
20
+ #
21
+ # exit Brut::CLI.app(YourApp::CLI::CleanOldFiles,
22
+ # project_root: Pathname($0).dirname / "..")
23
+ # ```
24
+ #
25
+ # @param app_klass [Class] your CLI app's class.
26
+ # @param project_root [Pathname] the path to the root of your project. This is needed before the Brut framework is initialized so
27
+ # it must be specified explicitly.
4
28
  def self.app(app_klass, project_root:)
5
29
  Brut::CLI::AppRunner.new(app_klass:,project_root:).run!
6
30
  end
@@ -8,11 +32,13 @@ module Brut
8
32
  autoload(:Command, "brut/cli/command")
9
33
  autoload(:Error, "brut/cli/error")
10
34
  autoload(:SystemExecError, "brut/cli/error")
35
+ autoload(:InvalidOption, "brut/cli/error")
11
36
  autoload(:ExecutionResults, "brut/cli/execution_results")
12
37
  autoload(:Options, "brut/cli/options")
13
38
  autoload(:Output, "brut/cli/output")
14
39
  autoload(:Executor, "brut/cli/executor")
15
40
  autoload(:AppRunner, "brut/cli/app_runner")
41
+ # Holds Brut-provided CLI apps that are set up in your project.
16
42
  module Apps
17
43
  autoload(:DB,"brut/cli/apps/db")
18
44
  autoload(:DB,"brut/cli/apps/test")
@@ -5,7 +5,9 @@ require "active_support"
5
5
  require "factory_bot"
6
6
  require "faker"
7
7
 
8
+ # Encompasses a Brut app's FactoryBot configuration. This allows it to be used outside of tests.
8
9
  class Brut::FactoryBot
10
+ # Configures FactoryBot and finds all definitions. After this, calls like `FactoryBot.create(...)` should work.
9
11
  def setup!
10
12
  Faker::Config.locale = :en
11
13
  FactoryBot.definition_file_paths = [