rspec-path_matchers 0.1.0 → 0.2.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.rubocop.yml +5 -0
  4. data/CHANGELOG.md +33 -0
  5. data/README.md +161 -260
  6. data/design.rb +12 -12
  7. data/lib/rspec/path_matchers/matchers/base.rb +210 -56
  8. data/lib/rspec/path_matchers/matchers/directory_matcher.rb +172 -0
  9. data/lib/rspec/path_matchers/matchers/{have_file.rb → file_matcher.rb} +8 -5
  10. data/lib/rspec/path_matchers/matchers/no_entry_matcher.rb +64 -0
  11. data/lib/rspec/path_matchers/matchers/{have_symlink.rb → symlink_matcher.rb} +9 -6
  12. data/lib/rspec/path_matchers/options/atime.rb +6 -40
  13. data/lib/rspec/path_matchers/options/base.rb +296 -0
  14. data/lib/rspec/path_matchers/options/birthtime.rb +6 -49
  15. data/lib/rspec/path_matchers/options/content.rb +13 -42
  16. data/lib/rspec/path_matchers/options/ctime.rb +6 -40
  17. data/lib/rspec/path_matchers/options/etc_base.rb +42 -0
  18. data/lib/rspec/path_matchers/options/file_stat_base.rb +47 -0
  19. data/lib/rspec/path_matchers/options/group.rb +5 -52
  20. data/lib/rspec/path_matchers/options/json_content.rb +6 -30
  21. data/lib/rspec/path_matchers/options/mode.rb +6 -40
  22. data/lib/rspec/path_matchers/options/mtime.rb +7 -41
  23. data/lib/rspec/path_matchers/options/owner.rb +6 -53
  24. data/lib/rspec/path_matchers/options/parsed_content_base.rb +67 -0
  25. data/lib/rspec/path_matchers/options/size.rb +5 -40
  26. data/lib/rspec/path_matchers/options/symlink_atime.rb +7 -40
  27. data/lib/rspec/path_matchers/options/symlink_birthtime.rb +7 -49
  28. data/lib/rspec/path_matchers/options/symlink_ctime.rb +7 -40
  29. data/lib/rspec/path_matchers/options/symlink_group.rb +6 -52
  30. data/lib/rspec/path_matchers/options/symlink_mtime.rb +7 -40
  31. data/lib/rspec/path_matchers/options/symlink_owner.rb +7 -53
  32. data/lib/rspec/path_matchers/options/symlink_target.rb +6 -43
  33. data/lib/rspec/path_matchers/options/symlink_target_exist.rb +7 -42
  34. data/lib/rspec/path_matchers/options/symlink_target_type.rb +20 -43
  35. data/lib/rspec/path_matchers/options/yaml_content.rb +6 -31
  36. data/lib/rspec/path_matchers/options.rb +1 -0
  37. data/lib/rspec/path_matchers/refinements.rb +79 -0
  38. data/lib/rspec/path_matchers/version.rb +1 -1
  39. data/lib/rspec/path_matchers.rb +185 -16
  40. metadata +12 -8
  41. data/lib/rspec/path_matchers/matchers/directory_contents_inspector.rb +0 -57
  42. data/lib/rspec/path_matchers/matchers/have_directory.rb +0 -126
  43. data/lib/rspec/path_matchers/matchers/have_no_entry.rb +0 -49
@@ -4,41 +4,134 @@ module RSpec
4
4
  module PathMatchers
5
5
  module Matchers
6
6
  # The base class for matchers
7
+ #
8
+ # Implements the [RSpec matcher
9
+ # protocol](https://rspec.info/documentation/3.13/rspec-expectations/RSpec/Matchers/MatcherProtocol.html)
10
+ # for value expectations including:
11
+ #
12
+ # - `#matches?(base_path)` - checks if the matcher matches the given base_path
13
+ # - `#failure_message` - returns a human-readable failure message
14
+ # - `#actual` - returns the actual value that was matched against
15
+ # - `#expected` - returns the expected value that was matched against
16
+ # - `#description` - returns a human-readable description of the matcher
17
+ # - `#does_not_match?(base_path)` - checks if the matcher does not match the given base_path
18
+ # - `#failure_message_when_negated` - returns a human-readable failure message for negative matches
19
+ #
7
20
  class Base # rubocop:disable Metrics/ClassLength
8
- def initialize(name, **options_hash)
21
+ # Create a new matcher instance
22
+ #
23
+ # Subclasses may override this to provide additional arguments or options.
24
+ # They should call `super` to ensure the base class is initialized correctly.
25
+ #
26
+ # @param entry_name [String] The name of the entry to match against
27
+ #
28
+ # - If entry_name is empty, the matcher will match against the base_path
29
+ # directly (this is the path passed to `matches?` or `does_not_match?`).
30
+ # - If entry_name is NOT empty, the matcher will match against
31
+ # File.join(base_path, entry_name)
32
+ #
33
+ # @param matcher_name [Symbol] The matcher name (e.g. :have_dir, :have_file,
34
+ # etc.) to use in descriptions and messages
35
+ #
36
+ # @param options_hash [Hash] Options for the matcher passed to the options
37
+ # factory to get an options object
38
+ #
39
+ def initialize(entry_name, matcher_name:, **options_hash)
9
40
  super()
10
- @name = name.to_s
41
+ @entry_name = entry_name.to_s
42
+ @matcher_name = matcher_name
11
43
  @options = options_factory(*option_keys, **options_hash)
44
+ @failures = []
12
45
  end
13
46
 
14
- attr_reader :name, :options, :base_path, :path
47
+ # @attribute [r] options
48
+ #
49
+ # The matcher options loaded from the options_hash passed to the initializer
50
+ #
51
+ # @return [Object] A Data object containing the options
52
+ #
53
+ attr_reader :options
54
+
55
+ # @attribute [r] base_path
56
+ #
57
+ # The base path against which the matcher is applied
58
+ #
59
+ # @return [String]
60
+ #
61
+ attr_reader :base_path
62
+
63
+ # @attribute [r] path
64
+ #
65
+ # The full path to the entry being matched against
66
+ #
67
+ # @return [String]
68
+ #
69
+ attr_reader :path
70
+
71
+ # @attribute [r] matcher_name
72
+ #
73
+ # The name of this matcher, used for descriptions and messages
74
+ #
75
+ # @return [Symbol] The matcher name (e.g. :have_dir, :have_file, etc.)
76
+ #
77
+ attr_reader :matcher_name
78
+
79
+ # @attribute [r] failures
80
+ #
81
+ # An array of failures that describe why the matcher did not match the actual
82
+ # value
83
+ #
84
+ # Only populated after `matches?` or `execute_match` is called
85
+ #
86
+ # @return [Array<RSpec::PathMatchers::Failure>]
87
+ #
88
+ attr_reader :failures
89
+
90
+ # @attribute [r] entry_name
91
+ #
92
+ # The name of the entry being matched against or an empty String
93
+ #
94
+ # If `entry_name` is empty, the matcher will match against the base_path
95
+ # directly. If `entry_name` is not empty, the matcher will match against
96
+ # `File.join(base_path, entry_name)`.
97
+ #
98
+ # @return [String]
99
+ #
100
+ def entry_name
101
+ @entry_name || base_path.basename
102
+ end
15
103
 
16
104
  # A human-readable description of the matcher's expectation
17
105
  #
18
- # This is used by RSpec to build the failure message when an `expect(...).to`
19
- # expectation is not met. For example, if a test asserts `expect(path).to
20
- # have_file("foo")` and the file does not exist, the failure message will
21
- # include the output of this method: "expected to have file \"foo\"".
106
+ # This description is used by RSpec formatters (e.g., `--format
107
+ # documentation`) to provide output about the specification itself. It is NOT
108
+ # used to build the detailed failure message when an expectation fails.
109
+ #
110
+ # Subclasses can override this method to add to or provide a more specific
111
+ # description based on the entry type, options, or other factors.
22
112
  #
23
- # @return [String] A description of the matcher
113
+ # @return [String]
24
114
  #
25
115
  def description
26
- desc = "have #{entry_type} #{name.inspect}"
116
+ desc = (@entry_name.empty? ? "be a #{entry_type}" : "have #{entry_type} #{entry_name.inspect}")
27
117
  options_description = build_options_description
28
118
  desc += " with #{options_description}" unless options_description.empty?
29
119
  desc
30
120
  end
31
121
 
32
- def failure_messages
33
- @failure_messages ||= []
34
- end
35
-
122
+ # Returns `true` if the matcher matches the actual value
123
+ #
124
+ # If `false` is returned, the `failures` array will be populated with
125
+ # human-readable error messages that describe why the match failed.
126
+ #
127
+ # @param base_path [Object] The base_path together with entry_name determine the actual value
128
+ #
129
+ # @return [Boolean] `true` if the matcher matches, `false` otherwise
130
+ #
131
+ # @raise [ArgumentError] if there are errors in the matcher or its options
132
+ #
36
133
  def matches?(base_path)
37
- # It is important to reset failure_messages in case this matcher instance
38
- # is reused
39
- @failure_messages = []
40
-
41
- # Phase 1: Validate all options recursively for syntax errors
134
+ # Phase 1: Validate all options for syntax errors
42
135
  validation_errors = []
43
136
  collect_validation_errors(validation_errors)
44
137
  raise ArgumentError, validation_errors.join(', ') if validation_errors.any?
@@ -47,24 +140,31 @@ module RSpec
47
140
  execute_match(base_path)
48
141
  end
49
142
 
50
- def collect_negative_validation_errors(errors)
51
- errors << "The matcher `not_to #{matcher_name}(...)` cannot be given options" if options.any_given?
143
+ def failure_message
144
+ header = "#{path} was not as expected:"
145
+ grouped_failures = failures.group_by(&:relative_path)
146
+
147
+ messages = grouped_failures.map do |relative_path, failures_for_path|
148
+ format_failure_group(relative_path, failures_for_path)
149
+ end
150
+
151
+ "#{header}\n#{messages.join("\n")}"
52
152
  end
53
153
 
54
154
  # This method is called by RSpec for `expect(...).not_to have_...`
55
155
  def does_not_match?(base_path) # rubocop:disable Naming/PredicatePrefix
56
- # 1. Validate that no options were passed to the negative matcher.
156
+ # Phase 1: Validate all options for syntax errors (in this case options are not allowed)
57
157
  validation_errors = []
58
158
  collect_negative_validation_errors(validation_errors)
59
159
  raise ArgumentError, validation_errors.join(', ') if validation_errors.any?
60
160
 
61
161
  @base_path = base_path.to_s
62
- @path = File.join(base_path, name)
162
+ @path = @entry_name.empty? ? base_path : File.join(base_path, entry_name)
63
163
 
64
- # 2. A negative match SUCCEEDS if the entry of the specified type does NOT exist.
65
- # We delegate the type-specific check to the subclass.
66
- # The method returns `true` if the entry is NOT of the correct type (pass),
67
- # and `false` if it IS of the correct type (fail).
164
+ # Phase 2: Execute the actual match logic
165
+ #
166
+ # A negative match SUCCEEDS if the entry of the specified type does NOT
167
+ # exist. We delegate the type-specific check to the subclass.
68
168
  !correct_type?
69
169
  end
70
170
 
@@ -73,12 +173,19 @@ module RSpec
73
173
  "expected it not to be a #{entry_type}"
74
174
  end
75
175
 
76
- # Recursively gathers all syntax/validation errors
176
+ protected
177
+
178
+ # Add to `errors` if the matcher is not defined correctly
179
+ #
180
+ # Subclasses may override this method to provide additional checking. For
181
+ # instance, BeDirectory extends this to add validation for nested matchers.
77
182
  #
78
- # Subclasses (like HaveDirectory) may extend this to recurse into nested
79
- # matchers.
183
+ # A nesting matcher (such as BeDirectory) may call this method on the
184
+ # nested matchers to collect all validation errors before raising an
185
+ # ArgumentError.
80
186
  #
81
- # @param errors [Array<String>] An array to append validation error messages to.
187
+ # @param errors [Array<String>] An array to populate with validation error
188
+ # messages
82
189
  #
83
190
  # @return [void]
84
191
  #
@@ -88,52 +195,93 @@ module RSpec
88
195
  validate_option_values(errors)
89
196
  end
90
197
 
91
- def failure_message
92
- header = "the entry '#{name}' at '#{base_path}' was expected to satisfy the following but did not:"
93
- # Format single- and multi-line nested messages with proper indentation.
94
- messages = failure_messages.map do |msg|
95
- msg.lines.map.with_index do |line, i|
96
- i.zero? ? " - #{line.chomp}" : " #{line.chomp}"
97
- end.join("\n")
98
- end.join("\n")
99
- "#{header}\n#{messages}"
100
- end
101
-
198
+ # Returns `true` if `path` exists and is of the correct type for this matcher
199
+ #
200
+ # Subclasses must implement this method to check the type of the entry. For
201
+ # example, BeFile checks if the path is a regular file; BeDirectory, a
202
+ # directory; and BeSymlink, a symlink.
203
+ #
204
+ # @return [Boolean]
205
+ #
206
+ # @abstract
207
+ #
102
208
  def correct_type?
103
- raise NotImplementedError, 'This method should be implemented in a subclass'
209
+ raise NotImplementedError, 'Subclasses must implement Base#correct_type?'
104
210
  end
105
211
 
106
- def matcher_name
107
- raise NotImplementedError, 'This method should be implemented in a subclass'
212
+ # Add to errors if options were passed to the negative matcher
213
+ #
214
+ # This method is called by RSpec for `expect(...).not_to <matcher>...`
215
+ #
216
+ # Subclasses can override this to add additional validation.
217
+ #
218
+ # @param errors [Array<String>] An array to append validation error messages to
219
+ #
220
+ # @return [void]
221
+ #
222
+ def collect_negative_validation_errors(errors)
223
+ errors << "The matcher `not_to #{matcher_name}(...)` cannot be given options" if options.any_given?
108
224
  end
109
225
 
110
- protected
226
+ # Subclasses should define their own option definitions
227
+ #
228
+ # @return [Array<RSpec::PathMatchers::Options::Base>] An array of option definitions
229
+ #
230
+ def option_definitions = []
111
231
 
232
+ # The type of entry this matcher is checking for
233
+ #
234
+ # @return [Symbol] The entry type (e.g. :file, :directory, :symlink, ...)
235
+ #
112
236
  def entry_type
113
- self.class.name.split('::').last.sub(/^Have/, '').downcase
237
+ raise NotImplementedError, 'Subclasses must implement Base#entry_type'
114
238
  end
115
239
 
116
240
  # Performs the actual matching against the directory entry
117
241
  #
118
242
  # This method assumes that collect_validation_errors has already been called
119
- # and passed. This method is protected so that container matchers (like
120
- # HaveDirectory) can call it on nested matchers without using .send.
243
+ # and passed.
244
+ #
245
+ # This method is protected so that container matchers (like BeDirectory)
246
+ # can call it on nested matchers without using .send.
121
247
  #
122
248
  def execute_match(base_path) # rubocop:disable Naming/PredicateMethod
249
+ # It is important to reset failures in case this matcher is reused
250
+ @failures = []
251
+
123
252
  @base_path = base_path.to_s
124
- @path = File.join(base_path, name)
253
+ @path = @entry_name.empty? ? base_path : File.join(base_path, entry_name)
125
254
 
126
255
  # Validate existence and type.
127
- validate_existance(failure_messages)
128
- return false if failure_messages.any?
256
+ validate_existance
257
+ return false if failures.any?
129
258
 
130
259
  # Validate specific options and nested expectations.
131
260
  validate_options
132
- failure_messages.empty?
261
+ failures.empty?
133
262
  end
134
263
 
135
264
  private
136
265
 
266
+ # Formats a group of failures for a single relative path.
267
+ #
268
+ # @param relative_path [String] The path of the failure relative to the subject.
269
+ # @param failures_for_path [Array<Failure>] A list of failures for that path.
270
+ # @return [String] A formatted string block for this group of failures.
271
+ #
272
+ def format_failure_group(relative_path, failures_for_path)
273
+ indented_errors = failures_for_path.map { |f| " #{f.message}" }.join("\n")
274
+
275
+ if relative_path == '.'
276
+ # For failures on the subject itself, just show the indented errors.
277
+ indented_errors
278
+ else
279
+ # For failures on a child, show the path and then the indented errors.
280
+ path_line = " - #{relative_path}"
281
+ "#{path_line}\n#{indented_errors}"
282
+ end
283
+ end
284
+
137
285
  def build_options_description
138
286
  descriptions = options.to_h.filter_map do |key, value|
139
287
  next if value == RSpec::PathMatchers::Options::NOT_GIVEN
@@ -176,21 +324,27 @@ module RSpec
176
324
  end
177
325
  end
178
326
 
179
- def validate_existance(_failure_messages)
180
- raise NotImplementedError, 'This method should be implemented in a subclass'
327
+ def validate_existance
328
+ raise NotImplementedError, 'Subclasses must implement Base#validate_existance'
181
329
  end
182
330
 
183
331
  # Validate the options for the current matcher
184
332
  #
185
- # Subclasses will override this to add nested execution.
333
+ # Subclasses may override this to add additional validation logic. For
334
+ # instance, BeDirectory extends this to check nested matchers.
335
+ #
186
336
  def validate_options
187
337
  options.members.each do |key|
188
338
  expected = options.send(key)
189
339
  next if expected == RSpec::PathMatchers::Options::NOT_GIVEN
190
340
 
191
- option_definition(key).match(path, expected, failure_messages)
341
+ option_definition(key).match(path, expected, failures)
192
342
  end
193
343
  end
344
+
345
+ def add_failure(message, failures)
346
+ failures << RSpec::PathMatchers::Failure.new('.', message)
347
+ end
194
348
  end
195
349
  end
196
350
  end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require 'pathname'
5
+
6
+ module RSpec
7
+ module PathMatchers
8
+ module Matchers
9
+ # An RSpec matcher that checks for the existence and properties of a directory
10
+ #
11
+ class DirectoryMatcher < Base
12
+ OPTIONS = [
13
+ RSpec::PathMatchers::Options::Atime,
14
+ RSpec::PathMatchers::Options::Birthtime,
15
+ RSpec::PathMatchers::Options::Ctime,
16
+ RSpec::PathMatchers::Options::Group,
17
+ RSpec::PathMatchers::Options::Mode,
18
+ RSpec::PathMatchers::Options::Mtime,
19
+ RSpec::PathMatchers::Options::Owner
20
+ ].freeze
21
+
22
+ # @attribute [r] exact
23
+ #
24
+ # If true, the dir must contain only entries given in `containing_exactly`
25
+ #
26
+ # The default is false, meaning the directory can contain additional entries.
27
+ #
28
+ # @return [Boolean]
29
+ #
30
+ attr_reader :exact
31
+
32
+ # Initializes the matcher with the directory name and options
33
+ #
34
+ # @param entry_name [String] The name of the directory relative to the subject or empty
35
+ #
36
+ # @param matcher_name [Symbol] The name of the DSL method used to create this matcher
37
+ #
38
+ # @param options_hash [Hash] A hash of attribute matchers (e.g., mode:, owner:)
39
+ #
40
+ def initialize(entry_name, matcher_name:, **options_hash)
41
+ super
42
+
43
+ @exact = false
44
+ @nested_matchers = []
45
+ end
46
+
47
+ def containing(*matchers)
48
+ @nested_matchers << matchers
49
+ @exact = false
50
+ self
51
+ end
52
+
53
+ def containing_exactly(*matchers)
54
+ @nested_matchers << matchers
55
+ @exact = true
56
+ self
57
+ end
58
+
59
+ def description
60
+ desc = super
61
+ return desc if nested_matchers.empty?
62
+
63
+ nested_descriptions = nested_matchers.map do |matcher|
64
+ matcher.description.lines.map.with_index do |line, i|
65
+ i.zero? ? "- #{line.chomp}" : " #{line.chomp}"
66
+ end.join("\n")
67
+ end
68
+
69
+ "#{desc} containing#{' exactly' if exact}:\n #{nested_descriptions.join("\n ")}"
70
+ end
71
+
72
+ def entry_type = :directory
73
+ def option_definitions = OPTIONS
74
+ def correct_type? = File.directory?(path)
75
+
76
+ def collect_negative_validation_errors(errors)
77
+ super
78
+ return unless nested_matchers.any?
79
+
80
+ errors << "The matcher `not_to #{matcher_name}(...)` cannot have expectations on its contents"
81
+ end
82
+
83
+ def collect_validation_errors(errors)
84
+ super
85
+
86
+ if @nested_matchers.size > 1
87
+ errors << 'Collectively, `#containing` and `#containing_exactly` may be called only once'
88
+ end
89
+
90
+ # Recursively validate nested matchers.
91
+ nested_matchers.each { |matcher| matcher.collect_validation_errors(errors) }
92
+ end
93
+
94
+ protected
95
+
96
+ # Overrides Base to add the exactness check after other validations.
97
+ def validate_options
98
+ # Validate this directory's own options (mode, owner, etc.)
99
+ super
100
+
101
+ # Validate the nested entries described in `containing`
102
+ validate_nested_matchers
103
+
104
+ return unless failures.empty?
105
+
106
+ # If using `containing_exactly`, check for unexpected entries
107
+ check_for_unexpected_entries if exact
108
+ end
109
+
110
+ def validate_existance
111
+ return if File.directory?(path)
112
+
113
+ message = (File.exist?(path) ? 'expected it to be a directory' : 'expected it to exist')
114
+ add_failure(message, failures)
115
+ end
116
+
117
+ private
118
+
119
+ # This new private method encapsulates the logic for checking children.
120
+ def validate_nested_matchers
121
+ nested_matchers.each do |matcher|
122
+ next if matcher.execute_match(path)
123
+
124
+ matcher.failures.each do |failure|
125
+ new_relative_path = (Pathname.new(matcher.entry_name) + Pathname.new(failure.relative_path)).to_s
126
+ failures << RSpec::PathMatchers::Failure.new(new_relative_path, failure.message)
127
+ end
128
+ end
129
+ end
130
+
131
+ # An array of nested matchers that define the expected contents of the
132
+ # directory
133
+ #
134
+ # One element is added to the @nested_matchers array for each call to
135
+ # `containing` or `containing_exactly`. Since `containing` or
136
+ # `containing_exactly` are allowed only once per matcher, an error will be
137
+ # logged during validation if the size of this array is greater than one.
138
+ #
139
+ # Each element in @nested_matchers is itself an array of the matchers given
140
+ # in each `containing` or `containing_exactly` call.
141
+ #
142
+ # This method returns the first element of @nested_matchers (or an empty
143
+ # array of @nested_matchers is itself empty).
144
+ #
145
+ # @return [Array<RSpec::PathMatchers::Matchers::Base>]
146
+ #
147
+ def nested_matchers
148
+ @nested_matchers.first || []
149
+ end
150
+
151
+ # Checks for any files/directories on disk that were not declared in the block.
152
+ def check_for_unexpected_entries
153
+ positively_declared_entries = nested_matchers.reject do |m|
154
+ m.is_a?(RSpec::PathMatchers::Matchers::NoEntryMatcher)
155
+ end.map(&:entry_name)
156
+
157
+ actual_entries = Dir.children(path)
158
+ unexpected_entries = actual_entries - positively_declared_entries
159
+
160
+ return if unexpected_entries.empty?
161
+
162
+ message = build_unexpected_entries_message(unexpected_entries)
163
+ add_failure(message, failures)
164
+ end
165
+
166
+ def build_unexpected_entries_message(unexpected_entries)
167
+ "contained unexpected entries #{unexpected_entries.sort.inspect}"
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -6,7 +6,8 @@ module RSpec
6
6
  module PathMatchers
7
7
  module Matchers
8
8
  # An RSpec matcher checks for the existence and properties of a file
9
- class HaveFile < Base
9
+ #
10
+ class FileMatcher < Base
10
11
  OPTIONS = [
11
12
  RSpec::PathMatchers::Options::Atime,
12
13
  RSpec::PathMatchers::Options::Birthtime,
@@ -21,23 +22,25 @@ module RSpec
21
22
  RSpec::PathMatchers::Options::YamlContent
22
23
  ].freeze
23
24
 
25
+ def entry_type = :file
26
+
24
27
  def option_definitions = OPTIONS
25
28
 
26
29
  def correct_type? = File.file?(path)
27
30
 
28
- def matcher_name = 'have_file'
29
-
30
31
  protected
31
32
 
32
- def validate_existance(failure_messages)
33
+ def validate_existance
33
34
  return nil if File.file?(path)
34
35
 
35
- failure_messages <<
36
+ message =
36
37
  if File.exist?(path)
37
38
  'expected it to be a regular file'
38
39
  else
39
40
  'expected it to exist'
40
41
  end
42
+
43
+ add_failure(message, failures)
41
44
  end
42
45
  end
43
46
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module PathMatchers
5
+ module Matchers
6
+ # Asserts that a directory entry of a specific entry_type does NOT exist.
7
+ # This is a simple, internal matcher-like object.
8
+ #
9
+ # @api private
10
+ class NoEntryMatcher
11
+ attr_reader :entry_name, :entry_type, :base_path, :path
12
+
13
+ # Initializes the matcher with the entry name and type
14
+ #
15
+ # @param entry_name [String] The name of the entry to check
16
+ #
17
+ # @param matcher_name [Symbol] The name of the DSL method used to create this matcher
18
+ #
19
+ # @param entry_type [Symbol] The type of the entry (:file, :directory, or :symlink)
20
+ #
21
+ def initialize(entry_name, matcher_name:, entry_type:)
22
+ @entry_name = entry_name
23
+ @matcher_name = matcher_name
24
+ @entry_type = entry_type
25
+ @base_path = nil
26
+ @path = nil
27
+ end
28
+
29
+ # The core logic. Returns `true` if the expectation is met (the entry is absent).
30
+ def execute_match(base_path) # rubocop:disable Naming/PredicateMethod
31
+ @base_path = base_path
32
+ @path = File.join(base_path, entry_name)
33
+
34
+ case entry_type
35
+ when :file then !File.file?(path)
36
+ when :directory then !File.directory?(path)
37
+ else !File.symlink?(path)
38
+ end
39
+ end
40
+
41
+ # The failure message if `execute_match` returns `false`.
42
+ def failure_message
43
+ "expected #{entry_type} '#{entry_name}' not to be found at '#{base_path}', but it exists"
44
+ end
45
+
46
+ # Returns the failure message in an array as expected by the DirectoryMatcher
47
+ #
48
+ # @return [Array<String>]
49
+ #
50
+ def failures
51
+ [RSpec::PathMatchers::Failure.new('.', failure_message)]
52
+ end
53
+
54
+ # Provides a human-readable description for use in test output.
55
+ def description
56
+ "not have #{entry_type} #{entry_name.inspect}"
57
+ end
58
+
59
+ # Called by DirectoryMatcher but is not needed for this simple matcher
60
+ def collect_validation_errors(_errors); end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -5,8 +5,9 @@ require_relative 'base'
5
5
  module RSpec
6
6
  module PathMatchers
7
7
  module Matchers
8
- # An RSpec matcher checks for the existence and properties of a file
9
- class HaveSymlink < Base
8
+ # An RSpec matcher that checks for the existence and properties of a symlink
9
+ #
10
+ class SymlinkMatcher < Base
10
11
  OPTIONS = [
11
12
  RSpec::PathMatchers::Options::SymlinkAtime,
12
13
  RSpec::PathMatchers::Options::SymlinkBirthtime,
@@ -19,23 +20,25 @@ module RSpec
19
20
  RSpec::PathMatchers::Options::SymlinkTargetType
20
21
  ].freeze
21
22
 
23
+ def entry_type = :symlink
24
+
22
25
  def option_definitions = OPTIONS
23
26
 
24
27
  def correct_type? = File.symlink?(path)
25
28
 
26
- def matcher_name = 'have_symlink'
27
-
28
29
  protected
29
30
 
30
- def validate_existance(failure_messages)
31
+ def validate_existance
31
32
  return nil if File.symlink?(path)
32
33
 
33
- failure_messages <<
34
+ message =
34
35
  if File.exist?(path)
35
36
  'expected it to be a symlink'
36
37
  else
37
38
  'expected it to exist'
38
39
  end
40
+
41
+ add_failure(message, failures)
39
42
  end
40
43
  end
41
44
  end