cuprum-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +34 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE +21 -0
  5. data/README.md +163 -0
  6. data/lib/cuprum/cli/argument.rb +172 -0
  7. data/lib/cuprum/cli/arguments/class_methods.rb +283 -0
  8. data/lib/cuprum/cli/arguments.rb +16 -0
  9. data/lib/cuprum/cli/coercion.rb +131 -0
  10. data/lib/cuprum/cli/command.rb +102 -0
  11. data/lib/cuprum/cli/commands/ci/report.rb +121 -0
  12. data/lib/cuprum/cli/commands/ci/rspec_command.rb +108 -0
  13. data/lib/cuprum/cli/commands/ci/rspec_each_command.rb +185 -0
  14. data/lib/cuprum/cli/commands/ci.rb +12 -0
  15. data/lib/cuprum/cli/commands/echo_command.rb +76 -0
  16. data/lib/cuprum/cli/commands/file/generate_file.rb +141 -0
  17. data/lib/cuprum/cli/commands/file/new_command.rb +86 -0
  18. data/lib/cuprum/cli/commands/file/render_erb.rb +88 -0
  19. data/lib/cuprum/cli/commands/file/resolve_template.rb +136 -0
  20. data/lib/cuprum/cli/commands/file/templates/rspec.rb.erb +14 -0
  21. data/lib/cuprum/cli/commands/file/templates/ruby.rb.erb +29 -0
  22. data/lib/cuprum/cli/commands/file/templates.rb +71 -0
  23. data/lib/cuprum/cli/commands/file.rb +14 -0
  24. data/lib/cuprum/cli/commands.rb +12 -0
  25. data/lib/cuprum/cli/dependencies/file_system/mock.rb +297 -0
  26. data/lib/cuprum/cli/dependencies/file_system.rb +247 -0
  27. data/lib/cuprum/cli/dependencies/standard_io/helpers.rb +138 -0
  28. data/lib/cuprum/cli/dependencies/standard_io/mock.rb +85 -0
  29. data/lib/cuprum/cli/dependencies/standard_io.rb +110 -0
  30. data/lib/cuprum/cli/dependencies/system_command/mock.rb +57 -0
  31. data/lib/cuprum/cli/dependencies/system_command.rb +147 -0
  32. data/lib/cuprum/cli/dependencies.rb +25 -0
  33. data/lib/cuprum/cli/errors/files/file_not_writeable.rb +42 -0
  34. data/lib/cuprum/cli/errors/files/missing_parameter.rb +71 -0
  35. data/lib/cuprum/cli/errors/files/missing_template.rb +36 -0
  36. data/lib/cuprum/cli/errors/files/template_error.rb +37 -0
  37. data/lib/cuprum/cli/errors/files/template_not_resolved.rb +54 -0
  38. data/lib/cuprum/cli/errors/files.rb +19 -0
  39. data/lib/cuprum/cli/errors/system_command_failure.rb +44 -0
  40. data/lib/cuprum/cli/errors.rb +11 -0
  41. data/lib/cuprum/cli/integrations/thor/arguments_parser.rb +99 -0
  42. data/lib/cuprum/cli/integrations/thor/registry.rb +42 -0
  43. data/lib/cuprum/cli/integrations/thor/task.rb +211 -0
  44. data/lib/cuprum/cli/integrations/thor.rb +14 -0
  45. data/lib/cuprum/cli/integrations.rb +8 -0
  46. data/lib/cuprum/cli/metadata.rb +215 -0
  47. data/lib/cuprum/cli/option.rb +165 -0
  48. data/lib/cuprum/cli/options/class_methods.rb +232 -0
  49. data/lib/cuprum/cli/options/quiet.rb +32 -0
  50. data/lib/cuprum/cli/options/verbose.rb +32 -0
  51. data/lib/cuprum/cli/options.rb +18 -0
  52. data/lib/cuprum/cli/registry.rb +141 -0
  53. data/lib/cuprum/cli/rspec/deferred/arguments_examples.rb +203 -0
  54. data/lib/cuprum/cli/rspec/deferred/ci/report_examples.rb +450 -0
  55. data/lib/cuprum/cli/rspec/deferred/ci.rb +8 -0
  56. data/lib/cuprum/cli/rspec/deferred/dependencies/file_system_examples.rb +1469 -0
  57. data/lib/cuprum/cli/rspec/deferred/dependencies.rb +8 -0
  58. data/lib/cuprum/cli/rspec/deferred/metadata_examples.rb +856 -0
  59. data/lib/cuprum/cli/rspec/deferred/options_examples.rb +234 -0
  60. data/lib/cuprum/cli/rspec/deferred/registry_examples.rb +451 -0
  61. data/lib/cuprum/cli/rspec/deferred.rb +8 -0
  62. data/lib/cuprum/cli/rspec.rb +8 -0
  63. data/lib/cuprum/cli/version.rb +59 -0
  64. data/lib/cuprum/cli.rb +47 -0
  65. metadata +173 -0
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/cli/dependencies/file_system'
4
+
5
+ module Cuprum::Cli::Dependencies
6
+ # Mock implementation of FileSystem for testing purposes.
7
+ class FileSystem::Mock < Cuprum::Cli::Dependencies::FileSystem # rubocop:disable Metrics/ClassLength
8
+ SINGLE_GLOB_PATTERN = /\*+/
9
+ private_constant :SINGLE_GLOB_PATTERN
10
+
11
+ # Exception raised when trying to read from or write to a non-mocked path.
12
+ class InvalidPathError < StandardError; end
13
+
14
+ # Utility class used to simulate tempfile behavior.
15
+ class MockTempfile < SimpleDelegator
16
+ # @param path [String] the qualified path to the tempfile.
17
+ def initialize(path)
18
+ super(StringIO.new)
19
+
20
+ @path = path
21
+ end
22
+
23
+ # @return [String] the qualified path to the tempfile.
24
+ attr_reader :path
25
+ end
26
+
27
+ # @param files [Hash{String => Hash, IO}] the mocked directories and files.
28
+ # Must be a Hash with String keys representing file path segments; Hash
29
+ # values represent directories, while IO values represent files.
30
+ # @param root_path [String] the path to the root directory. Defaults to the
31
+ # value of `Dir.pwd`.
32
+ def initialize(files: {}, root_path: Dir.pwd)
33
+ super(root_path:)
34
+
35
+ @files = files
36
+ @tempfiles = []
37
+ end
38
+
39
+ # @return [Hash{String => Hash, IO}] the mocked directories and files.
40
+ attr_reader :files
41
+
42
+ # @return [Array<String>] the contents of each generated tempfile.
43
+ attr_reader :tempfiles
44
+
45
+ # (see Cuprum::Cli::Dependencies::FileSystem#create_directory)
46
+ def create_directory(path, recursive: false) # rubocop:disable Metrics/MethodLength
47
+ tools.assertions.validate_name(path, as: 'path')
48
+
49
+ *dir_names, dir_name = split_path(resolve_path(path))
50
+
51
+ directory = write_directory(
52
+ *dir_names,
53
+ action: 'create directory',
54
+ file_or_path: path,
55
+ recursive:
56
+ )
57
+
58
+ if io_stream?(directory[dir_name])
59
+ raise DirectoryIsAFileError,
60
+ "unable to create directory #{path} - directory is a file"
61
+ end
62
+
63
+ directory[dir_name] = {}
64
+
65
+ path
66
+ end
67
+ alias make_directory create_directory
68
+
69
+ # (see Cuprum::Cli::Dependencies::FileSystem#directory?)
70
+ def directory?(path)
71
+ tools.assertions.validate_name(path, as: 'path')
72
+
73
+ path = resolve_path(path)
74
+ mock = resolve_mock(path)
75
+
76
+ mock.is_a?(Hash)
77
+ end
78
+ alias directory_exists? directory?
79
+
80
+ # (see Cuprum::Cli::Dependencies::FileSystem#each_file)
81
+ def each_file(pattern, &)
82
+ return enum_for(:each_file, pattern) unless block_given?
83
+
84
+ flattened_files.each do |file_path|
85
+ next unless matches_pattern?(file_path:, pattern:)
86
+
87
+ yield File.join(root_path, file_path)
88
+ end
89
+
90
+ nil
91
+ end
92
+
93
+ # (see Cuprum::Cli::Dependencies::FileSystem#directory?)
94
+ def file?(path)
95
+ tools.assertions.validate_name(path, as: 'path')
96
+
97
+ path = resolve_path(path)
98
+ mock = resolve_mock(path)
99
+
100
+ io_stream?(mock)
101
+ end
102
+ alias file_exists? file?
103
+
104
+ # (see Cuprum::Cli::Dependencies::FileSystem#read_file)
105
+ def read_file(file_or_path) # rubocop:disable Metrics/MethodLength
106
+ validate_file(file_or_path, as: 'file')
107
+
108
+ return file_or_path.read if io_stream?(file_or_path)
109
+
110
+ path = resolve_path(file_or_path)
111
+
112
+ unless path.start_with?(root_path)
113
+ raise FileNotFoundError,
114
+ "unable to read file #{file_or_path} - file not found"
115
+ end
116
+
117
+ mock = resolve_mock(path)
118
+
119
+ return mock.tap(&:rewind).read if io_stream?(mock)
120
+
121
+ if mock
122
+ raise FileIsADirectoryError,
123
+ "unable to read file #{file_or_path} - file is a directory"
124
+ end
125
+
126
+ raise FileNotFoundError,
127
+ "unable to read file #{file_or_path} - file not found"
128
+ end
129
+ alias read read_file
130
+
131
+ # (see Cuprum::Cli::Dependencies::FileSystem#with_tempfile)
132
+ def with_tempfile(&block)
133
+ file_name = SecureRandom.uuid
134
+ file_path = File.join(root_path, 'tempfiles', file_name)
135
+
136
+ tempfile = MockTempfile.new(file_path)
137
+
138
+ (files['tempfiles'] ||= {})[file_name] = tempfile
139
+
140
+ block.call(tempfile).tap do
141
+ tempfiles << read_file(tempfile.tap(&:rewind))
142
+ end
143
+ ensure
144
+ files['tempfiles'].delete(file_name)
145
+ end
146
+
147
+ # (see Cuprum::Cli::Dependencies::FileSystem#write_file)
148
+ def write_file(file_or_path, data) # rubocop:disable Metrics/MethodLength
149
+ validate_file(file_or_path, as: 'file')
150
+
151
+ return file_or_path.write(data) if io_stream?(file_or_path)
152
+
153
+ path = resolve_path(file_or_path)
154
+
155
+ unless path.start_with?(root_path)
156
+ raise DirectoryNotFoundError,
157
+ "unable to write file #{file_or_path} - directory not found"
158
+ end
159
+
160
+ mock = resolve_mock(path)
161
+
162
+ if mock.is_a?(MockTempfile)
163
+ mock.write(data)
164
+ mock.rewind
165
+
166
+ return
167
+ end
168
+
169
+ if mock.nil? || io_stream?(mock)
170
+ return write_mock_file(path, data, file_or_path)
171
+ end
172
+
173
+ raise FileIsADirectoryError,
174
+ "unable to write file #{file_or_path} - file is a directory"
175
+ end
176
+ alias write write_file
177
+
178
+ private
179
+
180
+ def flatten_files(files:, flat: [], path: '')
181
+ files.each do |name, value|
182
+ qualified_path = path.empty? ? name : File.join(path, name)
183
+
184
+ next flat << qualified_path unless value.is_a?(Hash)
185
+
186
+ flatten_files(files: value, flat:, path: qualified_path)
187
+ end
188
+
189
+ flat
190
+ end
191
+
192
+ def flattened_files
193
+ @flattened_files || flatten_files(files:)
194
+ end
195
+
196
+ def io_stream?(file_or_path)
197
+ super || file_or_path.is_a?(MockTempfile)
198
+ end
199
+
200
+ def matches_globbed_pattern?(entry_names:, pattern_strings:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
201
+ prefix_index = pattern_strings.index { |str| str.include?('**') }
202
+ prefix_strings = pattern_strings[...prefix_index]
203
+ prefix_count = prefix_strings.length
204
+ suffix_index = pattern_strings.rindex { |str| str.include?('**') }
205
+ suffix_strings = pattern_strings[(1 + suffix_index)..]
206
+ suffix_count = suffix_strings.length
207
+
208
+ return false unless entry_names.length >= prefix_count + suffix_count
209
+
210
+ entry_names[...prefix_count]
211
+ .zip(prefix_strings)
212
+ .concat(entry_names[-suffix_count...].zip(suffix_strings))
213
+ .all? do |entry_name, pattern_string|
214
+ matches_pattern_string?(entry_name:, pattern_string:)
215
+ end
216
+ end
217
+
218
+ def matches_pattern?(file_path:, pattern:)
219
+ entry_names = file_path.split(File::SEPARATOR)
220
+ pattern_strings = pattern.split(File::SEPARATOR)
221
+
222
+ if pattern_strings.any? { |str| str.include?('**') }
223
+ return matches_globbed_pattern?(entry_names:, pattern_strings:)
224
+ end
225
+
226
+ return false unless entry_names.length == pattern_strings.length
227
+
228
+ entry_names.zip(pattern_strings).all? do |entry_name, pattern_string|
229
+ matches_pattern_string?(entry_name:, pattern_string:)
230
+ end
231
+ end
232
+
233
+ def matches_pattern_string?(entry_name:, pattern_string:)
234
+ if pattern_string.include?('*')
235
+ pattern_string
236
+ .gsub('.', '\.')
237
+ .gsub(SINGLE_GLOB_PATTERN, '.*')
238
+ .then { |str| Regexp.new(str) }
239
+ .match?(entry_name)
240
+ else
241
+ entry_name == pattern_string
242
+ end
243
+ end
244
+
245
+ def resolve_mock(path)
246
+ return unless path.start_with?(root_path)
247
+
248
+ *rest, last = split_path(path)
249
+
250
+ dir = rest.reduce(files) do |dir, segment|
251
+ break if dir[segment].nil? || io_stream?(dir[segment])
252
+
253
+ dir[segment]
254
+ end
255
+
256
+ dir&.[](last)
257
+ end
258
+
259
+ def split_path(path)
260
+ path[(1 + root_path.length)..]
261
+ &.split(File::SEPARATOR) || []
262
+ end
263
+
264
+ def write_directory(*dir_names, action:, file_or_path:, recursive: false)
265
+ dir_names.reduce(files) do |dir, dir_name|
266
+ if io_stream?(dir[dir_name])
267
+ raise DirectoryIsAFileError,
268
+ "unable to #{action} #{file_or_path} - directory is a file"
269
+ elsif dir[dir_name].nil? && !recursive
270
+ raise DirectoryNotFoundError,
271
+ "unable to #{action} #{file_or_path} - directory not found"
272
+ end
273
+
274
+ dir[dir_name] ||= {}
275
+ end
276
+ end
277
+
278
+ def write_mock_file(path, data, file_or_path)
279
+ *dir_names, file_name = split_path(path)
280
+
281
+ directory = write_directory(
282
+ *dir_names,
283
+ action: 'write file',
284
+ file_or_path:,
285
+ recursive: false
286
+ )
287
+
288
+ directory[file_name] = StringIO.new(data.to_s)
289
+ end
290
+
291
+ def validate_file(file_or_path, as:)
292
+ return if file_or_path.is_a?(MockTempfile)
293
+
294
+ super
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'stringio'
5
+ require 'tempfile'
6
+
7
+ require 'cuprum/cli/dependencies'
8
+
9
+ module Cuprum::Cli::Dependencies
10
+ # Utility wrapping filesystem operations.
11
+ class FileSystem # rubocop:disable Metrics/ClassLength
12
+ autoload :Mock, 'cuprum/cli/dependencies/file_system/mock'
13
+
14
+ # Abstract class for exceptions raised by failing file system operations.
15
+ class FileError < StandardError; end
16
+
17
+ # Exception raised when attempting to access a file as a directory.
18
+ class DirectoryIsAFileError < FileError; end
19
+
20
+ # Exception raised when attempting to access a non-existent directory.
21
+ class DirectoryNotFoundError < FileError; end
22
+
23
+ # Exception raised when attempting to access a directory as a file.
24
+ class FileIsADirectoryError < FileError; end
25
+
26
+ # Exception raised when attempting to access a non-existent file.
27
+ class FileNotFoundError < FileError; end
28
+
29
+ # @param root_path [String] the path to the root directory. Defaults to the
30
+ # value of `Dir.pwd`.
31
+ def initialize(root_path: Dir.pwd)
32
+ @root_path = root_path
33
+ end
34
+
35
+ # @return [String] the path to the root directory.
36
+ attr_reader :root_path
37
+
38
+ # Creates a directory at the requested path.
39
+ #
40
+ # @param path [String] the path to the directory.
41
+ # @param recursive [true, false] if true, creates any required intermediate
42
+ # directories, equivalent to the -p flag on `mkdir`. Defaults to false.
43
+ #
44
+ # @return [String] the path to the created directory.
45
+ def create_directory(path, recursive: false)
46
+ tools.assertions.validate_name(path, as: 'path')
47
+
48
+ resolved = resolve_path(path)
49
+
50
+ return path if directory?(resolved)
51
+
52
+ handle_create_errors(path) do
53
+ recursive ? FileUtils.mkdir_p(resolved) : FileUtils.mkdir(resolved)
54
+ end
55
+
56
+ path
57
+ end
58
+ alias make_directory create_directory
59
+
60
+ # Checks if the requested directory exists.
61
+ #
62
+ # @param path [String] the path to the requested directory.
63
+ #
64
+ # @return [true, false] true if the directory exists and is a directory,
65
+ # otherwise false.
66
+ def directory?(path)
67
+ tools.assertions.validate_name(path, as: 'path')
68
+
69
+ path = resolve_path(path)
70
+
71
+ File.exist?(path) && File.directory?(path)
72
+ end
73
+ alias directory_exists? directory?
74
+
75
+ # @overload each_file
76
+ # Iterates over file names matching the given pattern.
77
+ #
78
+ # @param pattern [String] the file pattern to match.
79
+ #
80
+ # @return [Enumerator<String>] an enumerator over the matching file names.
81
+ #
82
+ # @overload each_file { |file| }
83
+ # Yields each file name matching the given pattern.
84
+ #
85
+ # @param pattern [String] the file pattern to match.
86
+ #
87
+ # @return [nil]
88
+ #
89
+ # @yieldparam [String] the matching file name.
90
+ def each_file(pattern, &)
91
+ return enum_for(:each_file, pattern) unless block_given?
92
+
93
+ path = resolve_path(pattern)
94
+
95
+ Dir[path].each(&)
96
+
97
+ nil
98
+ end
99
+
100
+ # Checks if the requested file exists.
101
+ #
102
+ # @param path [String] the path to the requested file.
103
+ #
104
+ # @return [true, false] true if the file exists and is a file,
105
+ # otherwise false.
106
+ def file?(path)
107
+ tools.assertions.validate_name(path, as: 'path')
108
+
109
+ path = resolve_path(path)
110
+
111
+ File.exist?(path) && File.file?(path)
112
+ end
113
+ alias file_exists? file?
114
+
115
+ # @overload read_file(file)
116
+ # Reads the contents of the given file or IO stream.
117
+ #
118
+ # @param file [IO] the file to read.
119
+ #
120
+ # @return [String] the file contents.
121
+ #
122
+ # @overload read_file(path)
123
+ # Reads the contents of the file at the given path.
124
+ #
125
+ # @param path [String] the file path to read.
126
+ #
127
+ # @return [String] the file contents.
128
+ #
129
+ # @raise [FileIsADirectoryError] if the requested file is a directory.
130
+ # @raise [FileNotFoundError] if the requested file is not found.
131
+ def read_file(file_or_path)
132
+ validate_file(file_or_path, as: 'file')
133
+
134
+ return file_or_path.read if io_stream?(file_or_path)
135
+
136
+ path = resolve_path(file_or_path)
137
+
138
+ File.read(path)
139
+ rescue Errno::EISDIR
140
+ raise FileIsADirectoryError,
141
+ "unable to read file #{file_or_path} - file is a directory"
142
+ rescue Errno::ENOENT
143
+ raise FileNotFoundError,
144
+ "unable to read file #{file_or_path} - file not found"
145
+ end
146
+ alias read read_file
147
+
148
+ # Creates a tempfile and passes it to the block.
149
+ #
150
+ # @yieldparam [File] the generated tempfile.
151
+ #
152
+ # @return [Object] the value returned by the block.
153
+ def with_tempfile(&) = Tempfile.create(&)
154
+
155
+ # @overload write_file(file, data)
156
+ # Writes the data to the given file or IO stream.
157
+ #
158
+ # @param file [IO] the file to write.
159
+ # @param data [String] the data to write.
160
+ #
161
+ # @return [Integer] the number of bytes written.
162
+ #
163
+ # @overload write_file(path, data)
164
+ # Writes the data to the file at the given path.
165
+ #
166
+ # @param path [String] the file path to write.
167
+ # @param data [String] the data to write.
168
+ #
169
+ # @return [Integer] the number of bytes written.
170
+ #
171
+ # @raise [DirectoryIsAFileError] if the path to the requested file
172
+ # includes an existing file.
173
+ # @raise [DirectoryNotFoundError] if the directory for the requested file
174
+ # is not found.
175
+ # @raise [FileIsADirectoryError] if the requested file is a directory.
176
+ def write_file(file_or_path, data)
177
+ validate_file(file_or_path, as: 'file')
178
+
179
+ return file_or_path.write(data) if io_stream?(file_or_path)
180
+
181
+ path = resolve_path(file_or_path)
182
+
183
+ handle_write_errors(file_or_path) { File.write(path, data) }
184
+ end
185
+ alias write write_file
186
+
187
+ private
188
+
189
+ def empty_file_message(as:)
190
+ tools.assertions.error_message_for('presence', as:)
191
+ end
192
+
193
+ def handle_create_errors(path)
194
+ yield
195
+ rescue Errno::EEXIST
196
+ raise Cuprum::Cli::Dependencies::FileSystem::DirectoryIsAFileError,
197
+ "unable to create directory #{path} - directory is a file"
198
+ rescue Errno::ENOENT
199
+ raise Cuprum::Cli::Dependencies::FileSystem::DirectoryNotFoundError,
200
+ "unable to create directory #{path} - directory not found"
201
+ end
202
+
203
+ def handle_write_errors(file_or_path)
204
+ yield
205
+ rescue Errno::EISDIR
206
+ raise FileIsADirectoryError,
207
+ "unable to write file #{file_or_path} - file is a directory"
208
+ rescue Errno::ENOENT
209
+ raise DirectoryNotFoundError,
210
+ "unable to write file #{file_or_path} - directory not found"
211
+ rescue Errno::ENOTDIR
212
+ raise DirectoryIsAFileError,
213
+ "unable to write file #{file_or_path} - directory is a file"
214
+ end
215
+
216
+ def invalid_file_message(as:)
217
+ "#{as} is not a String or IO stream"
218
+ end
219
+
220
+ def io_stream?(file_or_path)
221
+ file_or_path.is_a?(IO) || file_or_path.is_a?(StringIO)
222
+ end
223
+
224
+ def resolve_path(path)
225
+ return path if File.absolute_path?(path)
226
+
227
+ File.expand_path(File.join(root_path, path))
228
+ end
229
+
230
+ def tools
231
+ SleepingKingStudios::Tools::Toolbelt.instance
232
+ end
233
+
234
+ def validate_file(file_or_path, as:)
235
+ case file_or_path
236
+ in IO | StringIO
237
+ nil
238
+ in nil | ''
239
+ raise ArgumentError, empty_file_message(as:)
240
+ in String # rubocop:disable Lint/DuplicateBranch
241
+ nil
242
+ else
243
+ raise ArgumentError, invalid_file_message(as:)
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/cli/dependencies/standard_io'
4
+
5
+ module Cuprum::Cli::Dependencies
6
+ # Helper methods for reading from and writing to standard inputs and outputs.
7
+ module StandardIo::Helpers
8
+ # String input values that will be mapped to a boolean false.
9
+ FALSY_VALUES = Set.new(%w[f false n no]).freeze
10
+
11
+ # Pattern matching a valid integer input.
12
+ INTEGER_PATTERN = /\A-?\d+([\d_,]+\d)?\z/
13
+
14
+ # String input values that will be mapped to a boolean true.
15
+ TRUTHY_VALUES = Set.new(%w[t true y yes]).freeze
16
+
17
+ # @overload ask(prompt = nil, caret: true, format: nil, strip: true, **options)
18
+ # Requests an input from the input stream.
19
+ #
20
+ # @param prompt [String, nil] the prompt to display to the user, if any.
21
+ # @param options [Hash] options for requesting the input.
22
+ #
23
+ # @option options caret [true, false] if true, prints a caret "> " to the
24
+ # output stream after the prompt. Defaults to true when the newline
25
+ # option is true, otherwise false.
26
+ # @option options format [String, Symbol] the expected format of the
27
+ # input. Valid values are :string (the default), :boolean, and :integer.
28
+ # The input string will be transformed into the given format, or an
29
+ # exception raised if the value cannot be transformed.
30
+ # @option options newline [true, false] if true, a newline will be printed
31
+ # after the prompt if a prompt is given.
32
+ # @option options strip [true, false] if true, strips the trailing newline
33
+ # from the input. Defaults to true.
34
+ #
35
+ # @return [String, Integer, true, false, nil] the received and formatted
36
+ # input value, or nil if the input value was empty.
37
+ def ask( # rubocop:disable Metrics/ParameterLists
38
+ prompt = nil,
39
+ caret: nil,
40
+ format: nil,
41
+ newline: true,
42
+ strip: true,
43
+ **
44
+ )
45
+ validate_prompt(prompt)
46
+ display_prompt(caret:, newline:, prompt:)
47
+
48
+ value = standard_io.read_input&.then { |str| strip ? str.strip : str }
49
+
50
+ return if value.nil? || value.empty?
51
+ return value if format.nil?
52
+
53
+ send(:"format_#{format}", value)
54
+ end
55
+
56
+ # @overload say(message, newline: true, quiet: false, verbose: false, **options)
57
+ # Prints a message to the output stream.
58
+ #
59
+ # @param message [String] the message to print.
60
+ # @param options [Hash] options for printing the message.
61
+ #
62
+ # @option options newline [true, false] if true, appends a newline to the
63
+ # message if the message does not end with a newline. Defaults to true.
64
+ # @option options quiet [true, false] if true, prints the message even if
65
+ # the command has the :quiet option enabled. Defaults to false. Ignored
66
+ # if
67
+ # the command does not support the :quiet option.
68
+ # @option options verbose [true, false] if true, prints the message only
69
+ # if the command has the :verbose option enabled. Defaults to false.
70
+ # Ignored if the command does not support the :verbose option.
71
+ #
72
+ # @return [nil]
73
+ def say(message, newline: true, **)
74
+ validate_message(message)
75
+
76
+ standard_io.write_output(message, newline:)
77
+ end
78
+
79
+ # @overload warn(message, **options)
80
+ # Prints a message to the error stream.
81
+ #
82
+ # @param message [String] the message to print.
83
+ # @param options [Hash] options for printing the message.
84
+ #
85
+ # @option options newline [true, false] if true, appends a newline to the
86
+ # message if the message does not end with a newline. Defaults to true.
87
+ #
88
+ # @return [nil]
89
+ def warn(message, newline: true, **)
90
+ validate_message(message)
91
+
92
+ standard_io.write_error(message, newline:)
93
+ end
94
+
95
+ private
96
+
97
+ def display_prompt(caret:, newline:, prompt:)
98
+ standard_io.write_output(prompt, newline:) if prompt
99
+
100
+ return unless caret.nil? ? newline : caret
101
+
102
+ standard_io.write_output('> ', newline: false)
103
+ end
104
+
105
+ def format_boolean(value)
106
+ Cuprum::Cli::Coercion.coerce_boolean(value.strip)
107
+ rescue Cuprum::Cli::Coercion::CoercionError
108
+ nil
109
+ end
110
+
111
+ def format_integer(value)
112
+ Cuprum::Cli::Coercion.coerce_integer(value.strip)
113
+ rescue Cuprum::Cli::Coercion::CoercionError
114
+ nil
115
+ end
116
+
117
+ def tools = SleepingKingStudios::Tools::Toolbelt.instance
118
+
119
+ def validate_message(message)
120
+ tools.assertions.validate_instance_of(
121
+ message,
122
+ as: 'message',
123
+ expected: String
124
+ )
125
+ end
126
+
127
+ def validate_prompt(prompt)
128
+ return if prompt.nil?
129
+
130
+ tools.assertions.validate_instance_of(
131
+ prompt,
132
+ as: 'prompt',
133
+ expected: String
134
+ )
135
+ tools.assertions.validate_presence(prompt, as: 'prompt')
136
+ end
137
+ end
138
+ end