insta 0.2.9 → 0.3.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/Rakefile +30 -3
  4. data/exe/insta +6 -0
  5. data/lib/insta/ansi.rb +30 -0
  6. data/lib/insta/ci.rb +21 -0
  7. data/lib/insta/cli.rb +714 -0
  8. data/lib/insta/config_manifest.rb +56 -0
  9. data/lib/insta/configuration.rb +86 -2
  10. data/lib/insta/diff.rb +134 -0
  11. data/lib/insta/inline/call_finder.rb +37 -0
  12. data/lib/insta/inline/edit.rb +21 -0
  13. data/lib/insta/inline/file_patcher.rb +269 -0
  14. data/lib/insta/inline/pending_registry.rb +57 -0
  15. data/lib/insta/inline/pending_store.rb +145 -0
  16. data/lib/insta/minitest/assertions.rb +254 -0
  17. data/lib/insta/minitest/lifecycle.rb +12 -0
  18. data/lib/insta/minitest.rb +8 -0
  19. data/lib/insta/pending_locations.rb +64 -0
  20. data/lib/insta/pending_reporter.rb +47 -0
  21. data/lib/insta/redaction/applicator.rb +89 -0
  22. data/lib/insta/redaction/selector.rb +193 -0
  23. data/lib/insta/rspec/hooks.rb +18 -0
  24. data/lib/insta/rspec/matchers.rb +248 -0
  25. data/lib/insta/rspec.rb +7 -0
  26. data/lib/insta/serializers/base.rb +31 -0
  27. data/lib/insta/serializers/inspect.rb +14 -0
  28. data/lib/insta/serializers/json.rb +16 -0
  29. data/lib/insta/serializers/string.rb +14 -0
  30. data/lib/insta/serializers/yaml.rb +16 -0
  31. data/lib/insta/snapshot.rb +74 -0
  32. data/lib/insta/snapshot_content.rb +44 -0
  33. data/lib/insta/snapshot_file.rb +64 -0
  34. data/lib/insta/snapshot_mismatch_handler.rb +34 -0
  35. data/lib/insta/snapshot_name.rb +51 -0
  36. data/lib/insta/syntax_highlight.rb +20 -0
  37. data/lib/insta/update_coordinator.rb +22 -0
  38. data/lib/insta/version.rb +3 -1
  39. data/lib/insta.rb +42 -8
  40. data/lib/minitest/insta_plugin.rb +17 -0
  41. data/sig/insta/ansi.rbs +28 -0
  42. data/sig/insta/ci.rbs +10 -0
  43. data/sig/insta/cli.rbs +150 -0
  44. data/sig/insta/config_manifest.rbs +27 -0
  45. data/sig/insta/configuration.rbs +48 -0
  46. data/sig/insta/diff.rbs +38 -0
  47. data/sig/insta/inline/call_finder.rbs +22 -0
  48. data/sig/insta/inline/edit.rbs +18 -0
  49. data/sig/insta/inline/file_patcher.rbs +55 -0
  50. data/sig/insta/inline/pending_registry.rbs +22 -0
  51. data/sig/insta/inline/pending_store.rbs +41 -0
  52. data/sig/insta/minitest/assertions.rbs +52 -0
  53. data/sig/insta/minitest/lifecycle.rbs +10 -0
  54. data/sig/insta/minitest.rbs +2 -0
  55. data/sig/insta/pending_locations.rbs +22 -0
  56. data/sig/insta/pending_reporter.rbs +14 -0
  57. data/sig/insta/redaction/applicator.rbs +22 -0
  58. data/sig/insta/redaction/selector.rbs +58 -0
  59. data/sig/insta/rspec/hooks.rbs +10 -0
  60. data/sig/insta/rspec/matchers.rbs +71 -0
  61. data/sig/insta/rspec.rbs +2 -0
  62. data/sig/insta/serializers/base.rbs +21 -0
  63. data/sig/insta/serializers/inspect.rbs +10 -0
  64. data/sig/insta/serializers/json.rbs +10 -0
  65. data/sig/insta/serializers/string.rbs +10 -0
  66. data/sig/insta/serializers/yaml.rbs +10 -0
  67. data/sig/insta/snapshot.rbs +37 -0
  68. data/sig/insta/snapshot_content.rbs +14 -0
  69. data/sig/insta/snapshot_file.rbs +27 -0
  70. data/sig/insta/snapshot_mismatch_handler.rbs +12 -0
  71. data/sig/insta/snapshot_name.rbs +17 -0
  72. data/sig/insta/syntax_highlight.rbs +8 -0
  73. data/sig/insta/update_coordinator.rbs +11 -0
  74. data/sig/insta/version.rbs +5 -0
  75. data/sig/insta.rbs +12 -0
  76. data/sig/minitest/insta_plugin.rbs +6 -0
  77. data/sig/vendor/difftastic.rbs +19 -0
  78. data/sig/vendor/irb.rbs +5 -0
  79. data/sig/vendor/psych.rbs +7 -0
  80. data/sig/vendor/rspec.rbs +9 -0
  81. data/sig/vendor/rubygems.rbs +3 -0
  82. metadata +109 -54
  83. data/.DS_Store +0 -0
  84. data/.gitignore +0 -11
  85. data/.rspec +0 -3
  86. data/.travis.yml +0 -5
  87. data/CODE_OF_CONDUCT.md +0 -74
  88. data/Gemfile +0 -6
  89. data/Gemfile.lock +0 -35
  90. data/README.md +0 -41
  91. data/bin/console +0 -14
  92. data/bin/setup +0 -8
  93. data/insta.gemspec +0 -36
  94. data/lib/.DS_Store +0 -0
  95. data/lib/insta/account.rb +0 -127
  96. data/lib/insta/api.rb +0 -88
  97. data/lib/insta/constants.rb +0 -21
  98. data/lib/insta/device.rb +0 -11852
  99. data/lib/insta/feed.rb +0 -89
  100. data/lib/insta/location.rb +0 -18
  101. data/lib/insta/proxy_manager.rb +0 -23
  102. data/lib/insta/tag.rb +0 -18
  103. data/lib/insta/user.rb +0 -103
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Insta
7
+ module Inline
8
+ # @rbs!
9
+ # type pending_store_entry = { file: String, line: Integer, content: String, old_content: String, type: String }
10
+
11
+ module PendingStore
12
+ FILENAME = ".insta-pending-inline"
13
+
14
+ @pending = [] #: Array[Inline::pending_store_entry]
15
+ @mutex = Mutex.new
16
+
17
+ #: (file: String, line: Integer, content: String, old_content: String, type: Symbol) -> void
18
+ def self.add(file:, line:, content:, old_content:, type:)
19
+ @mutex.synchronize do
20
+ @pending << {
21
+ file: file,
22
+ line: line,
23
+ content: content,
24
+ old_content: old_content,
25
+ type: type.to_s,
26
+ }
27
+ end
28
+ end
29
+
30
+ #: () -> void
31
+ def self.flush!
32
+ entries = @mutex.synchronize do
33
+ result = @pending.dup
34
+ @pending.clear
35
+
36
+ result
37
+ end
38
+
39
+ return if entries.empty?
40
+
41
+ manifest_path = File.join(Insta.configuration.snapshot_path, FILENAME)
42
+ existing = if File.exist?(manifest_path)
43
+ JSON.parse(File.read(manifest_path), symbolize_names: true)
44
+ else
45
+ [] #: Array[Inline::pending_store_entry]
46
+ end
47
+
48
+ merged = merge_entries(existing, entries)
49
+
50
+ FileUtils.mkdir_p(File.dirname(manifest_path))
51
+ File.write(manifest_path, JSON.pretty_generate(merged))
52
+ end
53
+
54
+ #: () -> Array[Inline::pending_store_entry]
55
+ def self.load
56
+ manifest_path = File.join(Insta.configuration.snapshot_path, FILENAME)
57
+ return [] unless File.exist?(manifest_path)
58
+
59
+ JSON.parse(File.read(manifest_path), symbolize_names: true)
60
+ rescue JSON::ParserError
61
+ []
62
+ end
63
+
64
+ #: () -> void
65
+ def self.clean!
66
+ manifest_path = File.join(Insta.configuration.snapshot_path, FILENAME)
67
+
68
+ FileUtils.rm_f(manifest_path)
69
+ end
70
+
71
+ #: (Array[Inline::pending_store_entry]) -> void
72
+ def self.apply!(entries)
73
+ grouped = entries.group_by { |entry| entry[:file] }
74
+
75
+ grouped.each do |file, file_entries|
76
+ next unless File.exist?(file)
77
+
78
+ pending = file_entries.map { |entry|
79
+ { line: entry[:line], content: entry[:content], type: entry[:type].to_sym }
80
+ } #: Array[Inline::pending_entry]
81
+
82
+ patched = FilePatcher.patch(file, pending)
83
+
84
+ FilePatcher.atomic_write(file, patched)
85
+ end
86
+ end
87
+
88
+ #: (Array[Inline::pending_store_entry]) -> void
89
+ def self.remove!(entries)
90
+ current = self.load
91
+ return if current.empty?
92
+
93
+ remaining = current.reject { |existing|
94
+ entries.any? { |entry|
95
+ existing[:file] == entry[:file] && existing[:line] == entry[:line]
96
+ }
97
+ }
98
+
99
+ manifest_path = File.join(Insta.configuration.snapshot_path, FILENAME)
100
+
101
+ if remaining.empty?
102
+ FileUtils.rm_f(manifest_path)
103
+ else
104
+ File.write(manifest_path, JSON.pretty_generate(remaining))
105
+ end
106
+ end
107
+
108
+ #: () -> bool
109
+ def self.any?
110
+ @mutex.synchronize { !@pending.empty? }
111
+ end
112
+
113
+ #: () -> Integer
114
+ def self.size
115
+ @mutex.synchronize { @pending.length }
116
+ end
117
+
118
+ #: () -> void
119
+ def self.clear!
120
+ @mutex.synchronize { @pending.clear }
121
+ end
122
+
123
+ #: (Array[Inline::pending_store_entry], Array[Inline::pending_store_entry]) -> Array[Inline::pending_store_entry]
124
+ def self.merge_entries(existing, new_entries)
125
+ result = existing.dup
126
+
127
+ new_entries.each do |entry|
128
+ index = result.index { |e|
129
+ e[:file] == entry[:file] && e[:line] == entry[:line]
130
+ }
131
+
132
+ if index
133
+ result[index] = entry
134
+ else
135
+ result << entry
136
+ end
137
+ end
138
+
139
+ result
140
+ end
141
+
142
+ private_class_method :merge_entries
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insta
4
+ module Minitest
5
+ # @rbs module-self: Minitest::Test
6
+ module Assertions
7
+ #: (
8
+ #| untyped,
9
+ #| ?name: String?,
10
+ #| ?serializer: Symbol?,
11
+ #| ?redact: Hash[String, untyped]?,
12
+ #| ?input: String?,
13
+ #| ?description: String?,
14
+ #| ?info: Hash[String, untyped]?,
15
+ #| ?options: Hash[Symbol, untyped]?
16
+ #| ) -> void
17
+ def assert_snapshot(actual, name: nil, serializer: nil, redact: nil, input: nil, description: nil, info: nil, options: nil)
18
+ expression = actual.class.name
19
+ serializer_name = serializer || Insta.configuration.default_serializer
20
+ actual = Redaction::Applicator.apply(actual, redact) if redact
21
+ serialized = Serializers.serialize(serializer_name, actual)
22
+ content = SnapshotContent.normalize(serialized)
23
+
24
+ test_class = self.class.name || "Anonymous"
25
+ test_method = self.name || "unknown"
26
+
27
+ path = if name
28
+ snapshot_file = SnapshotFile.new(
29
+ Insta.configuration.snapshot_path,
30
+ test_class,
31
+ test_method
32
+ )
33
+ snapshot_file.named_path(name)
34
+ else
35
+ snapshot_file = _insta_snapshot_file
36
+ snapshot_file.path_for(options || {})
37
+ end
38
+
39
+ metadata = build_metadata(
40
+ test_class,
41
+ test_method,
42
+ expression: expression,
43
+ input: input,
44
+ description: description,
45
+ info: info,
46
+ options: options
47
+ )
48
+
49
+ existing = snapshot_file.read(path)
50
+
51
+ unless existing
52
+ handle_missing_snapshot(snapshot_file, path, content, metadata)
53
+ return
54
+ end
55
+
56
+ if existing.expression && existing.expression != expression
57
+ caller_line = find_test_caller
58
+ handle_type_mismatch(snapshot_file, path, content, metadata, existing, expression, caller_line)
59
+ return
60
+ end
61
+
62
+ expected = SnapshotContent.normalize(existing.content)
63
+
64
+ if content == expected
65
+ pass
66
+ return
67
+ end
68
+
69
+ caller_line = find_test_caller
70
+ handle_snapshot_mismatch(snapshot_file, path, content, metadata, expected, test_class, test_method, caller_line)
71
+ end
72
+
73
+ #: (
74
+ #| untyped,
75
+ #| ?String?,
76
+ #| ?serializer: Symbol?,
77
+ #| ?redact: Hash[String, untyped]?
78
+ #| ) -> void
79
+ def assert_inline_snapshot(actual, expected = nil, serializer: nil, redact: nil)
80
+ serializer_name = serializer || Insta.configuration.default_serializer
81
+ actual = Redaction::Applicator.apply(actual, redact) if redact
82
+ serialized = Serializers.serialize(serializer_name, actual)
83
+ content = SnapshotContent.normalize(serialized)
84
+
85
+ location = caller_locations(1, 1)&.first
86
+ return flunk("Could not determine caller location") unless location
87
+
88
+ file = location.path
89
+ line = location.lineno
90
+
91
+ return flunk("Could not determine source file") unless file
92
+
93
+ if expected.nil?
94
+ if Insta.configuration.new_snapshot == :review
95
+ Inline::PendingStore.add(file: file, line: line, content: content, old_content: "", type: :insert)
96
+
97
+ if ENV["INSTA_FORCE_PASS"]
98
+ pass
99
+ else
100
+ flunk "New inline snapshot pending review at #{file}:#{line}\nRun `bundle exec insta review` to accept."
101
+ end
102
+ else
103
+ Inline::PendingRegistry.add(file: file, line: line, content: content, type: :insert)
104
+ pass
105
+ end
106
+
107
+ return
108
+ end
109
+
110
+ expected_normalized = SnapshotContent.normalize(expected.to_s)
111
+
112
+ if content == expected_normalized
113
+ pass
114
+ return
115
+ end
116
+
117
+ handle_inline_mismatch(file, line, content, expected_normalized)
118
+ end
119
+
120
+ private
121
+
122
+ #: () -> SnapshotFile
123
+ def _insta_snapshot_file
124
+ @_insta_snapshot_file ||= SnapshotFile.new(
125
+ Insta.configuration.snapshot_path,
126
+ self.class.name || "Anonymous",
127
+ name || "unknown"
128
+ )
129
+ end
130
+
131
+ #: () -> String
132
+ def find_test_caller
133
+ locations = caller_locations
134
+ return locations&.first.to_s unless locations
135
+
136
+ test_location = locations.find { |location|
137
+ path = location.path
138
+
139
+ path && !path.include?("lib/insta") && path.match?(/_test\.rb\z/)
140
+ }
141
+
142
+ (test_location || locations.first).to_s
143
+ end
144
+
145
+ #: (String, String, ?expression: String?, ?input: String?, ?description: String?,
146
+ #| ?info: Hash[String, untyped]?, ?options: Hash[Symbol, untyped]?) -> Snapshot::snapshot_metadata
147
+ def build_metadata(test_class, test_method, expression: nil, input: nil, description: nil, info: nil, options: nil)
148
+ metadata = { "source" => "#{test_class}##{test_method}" }
149
+
150
+ metadata["expression"] = expression if expression
151
+ metadata["input"] = input if input
152
+ metadata["description"] = description if description
153
+ metadata["options"] = options if options && !options.empty?
154
+ metadata["info"] = info if info
155
+
156
+ metadata
157
+ end
158
+
159
+ #: (SnapshotFile, Pathname, String, Snapshot::snapshot_metadata) -> void
160
+ def handle_missing_snapshot(snapshot_file, path, content, metadata)
161
+ mode = Insta.configuration.resolved_update_mode
162
+
163
+ if mode == :no
164
+ flunk "Snapshot file does not exist: #{path}\nRun with INSTA_UPDATE=force to create it."
165
+ return
166
+ end
167
+
168
+ if Insta.configuration.new_snapshot == :review
169
+ pending_path = snapshot_file.pending_path(path)
170
+ snapshot_file.write(pending_path, content, metadata)
171
+ PendingLocations.add(pending_path.to_s, find_test_caller)
172
+
173
+ if ENV["INSTA_FORCE_PASS"]
174
+ pass
175
+ else
176
+ flunk "New snapshot pending review: #{path}\nRun `bundle exec insta review` to accept."
177
+ end
178
+
179
+ return
180
+ end
181
+
182
+ snapshot_file.write(path, content, metadata)
183
+
184
+ pass
185
+ end
186
+
187
+ #: (SnapshotFile, Pathname, String, Snapshot::snapshot_metadata, Snapshot, String?, String) -> void
188
+ def handle_type_mismatch(snapshot_file, path, content, metadata, existing, expression, caller_line)
189
+ expected = SnapshotContent.normalize(existing.content)
190
+ result = SnapshotMismatchHandler.handle(snapshot_file, path, content, metadata, expected, caller_line: caller_line)
191
+
192
+ case result
193
+ when :updated, :force_passed
194
+ pass
195
+ else
196
+ flunk "Snapshot type mismatch: snapshot was #{existing.expression} but got #{expression}\n" \
197
+ "Run `bundle exec insta review` or delete the snapshot file and re-run the test to update it."
198
+ end
199
+ end
200
+
201
+ #: (SnapshotFile, Pathname, String, Snapshot::snapshot_metadata, String, String, String, String) -> void
202
+ def handle_snapshot_mismatch(snapshot_file, path, content, metadata, expected, test_class, test_method, caller_line)
203
+ result = SnapshotMismatchHandler.handle(snapshot_file, path, content, metadata, expected, caller_line: caller_line)
204
+
205
+ case result
206
+ when :updated, :force_passed
207
+ pass
208
+ else
209
+ label = "#{test_class}##{test_method}"
210
+ flunk SnapshotMismatchHandler.failure_message(expected, content, label, path, caller_line: caller_line)
211
+ end
212
+ end
213
+
214
+ #: (String, Integer, String, String) -> void
215
+ def handle_inline_mismatch(file, line, content, expected_normalized)
216
+ mode = Insta.configuration.resolved_update_mode
217
+
218
+ case mode
219
+ when :force
220
+ Inline::PendingRegistry.add(file: file, line: line, content: content, type: :replace)
221
+ pass
222
+ when :no
223
+ flunk Diff.failure_message(
224
+ expected_normalized,
225
+ content,
226
+ "inline snapshot at #{file}:#{line}",
227
+ nil,
228
+ "#{file}:#{line}"
229
+ )
230
+ else # :auto, :pending
231
+ Inline::PendingStore.add(
232
+ file: file,
233
+ line: line,
234
+ content: content,
235
+ old_content: expected_normalized,
236
+ type: :replace
237
+ )
238
+
239
+ if ENV["INSTA_FORCE_PASS"]
240
+ pass
241
+ else
242
+ flunk Diff.failure_message(
243
+ expected_normalized,
244
+ content,
245
+ "inline snapshot at #{file}:#{line}",
246
+ nil,
247
+ "#{file}:#{line}"
248
+ )
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insta
4
+ module Minitest
5
+ module Lifecycle
6
+ #: (Module) -> void
7
+ def self.included(base)
8
+ base.include(Assertions)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require "insta"
5
+ require "minitest/insta_plugin"
6
+
7
+ Minitest::Test.include(Insta::Minitest::Assertions)
8
+ Minitest::Test.include(Insta::Minitest::Lifecycle)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insta
6
+ module PendingLocations
7
+ FILENAME = ".insta-pending-locations"
8
+
9
+ @locations = {} #: Hash[String, String]
10
+ @mutex = Mutex.new
11
+
12
+ #: (String, String) -> void
13
+ def self.add(path, caller_line)
14
+ @mutex.synchronize do
15
+ @locations[path] = caller_line
16
+ end
17
+ end
18
+
19
+ #: () -> void
20
+ def self.flush!
21
+ entries = @mutex.synchronize do
22
+ result = @locations.dup
23
+ @locations.clear
24
+ result
25
+ end
26
+
27
+ return if entries.empty?
28
+
29
+ manifest_path = File.join(Insta.configuration.snapshot_path, FILENAME)
30
+ existing = if File.exist?(manifest_path)
31
+ JSON.parse(File.read(manifest_path))
32
+ else
33
+ {} #: Hash[String, String]
34
+ end
35
+
36
+ existing.merge!(entries)
37
+
38
+ FileUtils.mkdir_p(File.dirname(manifest_path))
39
+ File.write(manifest_path, JSON.pretty_generate(existing))
40
+ end
41
+
42
+ #: () -> Hash[String, String]
43
+ def self.load
44
+ manifest_path = File.join(Insta.configuration.snapshot_path, FILENAME)
45
+ return {} unless File.exist?(manifest_path)
46
+
47
+ JSON.parse(File.read(manifest_path))
48
+ rescue JSON::ParserError
49
+ {}
50
+ end
51
+
52
+ #: () -> void
53
+ def self.clean!
54
+ manifest_path = File.join(Insta.configuration.snapshot_path, FILENAME)
55
+
56
+ FileUtils.rm_f(manifest_path)
57
+ end
58
+
59
+ #: () -> void
60
+ def self.clear!
61
+ @mutex.synchronize { @locations.clear }
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insta
4
+ module PendingReporter
5
+ #: () -> void
6
+ def self.flush_and_report!
7
+ Inline::PendingRegistry.flush!
8
+ Inline::PendingStore.flush!
9
+ PendingLocations.flush!
10
+ ConfigManifest.write!
11
+
12
+ report_pending_files!
13
+ report_pending_inline!
14
+ end
15
+
16
+ #: () -> void
17
+ def self.report_pending_files!
18
+ snapshot_path = Insta.configuration.snapshot_path
19
+ extension = Insta.configuration.snapshot_extension
20
+ pending_files = Dir.glob(File.join(snapshot_path, "**", "*#{extension}.new"))
21
+
22
+ return unless pending_files.any?
23
+
24
+ count = pending_files.length
25
+ noun = count == 1 ? "snapshot" : "snapshots"
26
+ warn "\n\e[33m●\e[0m #{count} pending file #{noun}:\n\n"
27
+ pending_files.each { |f| warn " \e[33m›\e[0m #{f}" }
28
+ warn "\n \e[36mbundle exec insta review\e[0m\n\n"
29
+ end
30
+
31
+ #: () -> void
32
+ def self.report_pending_inline!
33
+ entries = Inline::PendingStore.load
34
+ return unless entries.any?
35
+
36
+ count = entries.length
37
+ noun = count == 1 ? "snapshot" : "snapshots"
38
+ warn "\n\e[33m●\e[0m #{count} pending inline #{noun}:\n\n"
39
+
40
+ entries.each do |entry|
41
+ warn " \e[33m›\e[0m #{entry[:file]}:#{entry[:line]}"
42
+ end
43
+
44
+ warn "\n \e[36mbundle exec insta review\e[0m\n\n"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insta
4
+ module Redaction
5
+ class Applicator
6
+ #: (untyped, Hash[String, untyped]?) -> untyped
7
+ def self.apply(value, redactions)
8
+ return value if redactions.nil? || redactions.empty?
9
+
10
+ unless value.is_a?(Hash) || value.is_a?(Array)
11
+ raise ArgumentError,
12
+ "Redactions require structured data (Hash or Array), got #{value.class}. " \
13
+ "Use a structured serializer like `serializer: :json` or `serializer: :yaml` with Hash/Array values."
14
+ end
15
+
16
+ duped = deep_dup(value)
17
+ parsed = redactions.transform_keys { |key| Selector.new(key) }
18
+
19
+ walk(duped, [], parsed)
20
+
21
+ duped
22
+ end
23
+
24
+ #: (untyped) -> untyped
25
+ def self.deep_dup(object)
26
+ case object
27
+ when Hash
28
+ hash = {} #: Hash[untyped, untyped]
29
+ object.each_with_object(hash) { |(key, value), result| result[key] = deep_dup(value) }
30
+ when Array
31
+ object.map { |value| deep_dup(value) }
32
+ else
33
+ object
34
+ end
35
+ end
36
+
37
+ #: (untyped, untyped, Hash[Selector, untyped]) -> void
38
+ def self.walk(node, path, parsed_redactions)
39
+ case node
40
+ when Hash
41
+ node.each_key do |key|
42
+ child_path = path + [{ type: :key, value: key }]
43
+ replacement = find_match(child_path, parsed_redactions)
44
+
45
+ if replacement
46
+ node[key] = apply_replacement(node[key], replacement)
47
+ else
48
+ walk(node[key], child_path, parsed_redactions)
49
+ end
50
+ end
51
+ when Array
52
+ node.each_with_index do |_item, index|
53
+ child_path = path + [{ type: :index, value: index }]
54
+ replacement = find_match(child_path, parsed_redactions)
55
+
56
+ if replacement
57
+ node[index] = apply_replacement(node[index], replacement)
58
+ else
59
+ walk(node[index], child_path, parsed_redactions)
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ #: (untyped, Hash[Selector, untyped]) -> untyped
66
+ def self.find_match(path, parsed_redactions)
67
+ parsed_redactions.each do |selector, replacement|
68
+ return replacement if selector.matches?(path)
69
+ end
70
+
71
+ nil
72
+ end
73
+
74
+ #: (untyped, untyped) -> untyped
75
+ def self.apply_replacement(value, replacement)
76
+ case replacement
77
+ when Proc
78
+ replacement.call(value)
79
+ when :sorted
80
+ value.is_a?(Array) ? value.sort_by(&:to_s) : value
81
+ else
82
+ replacement
83
+ end
84
+ end
85
+
86
+ private_class_method :deep_dup, :walk, :find_match, :apply_replacement
87
+ end
88
+ end
89
+ end