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
data/lib/insta/cli.rb ADDED
@@ -0,0 +1,714 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../insta"
4
+ require_relative "ansi"
5
+ require_relative "syntax_highlight"
6
+
7
+ module Insta
8
+ class CLI
9
+ include ANSI
10
+
11
+ COMMANDS = ["review", "accept", "reject", "pending", "status", "clean", "help"].freeze
12
+ MIN_GAP = 8 #: Integer
13
+
14
+ #: (Array[String]) -> void
15
+ def initialize(args)
16
+ @command = args.first || "help"
17
+ @args = args.drop(1)
18
+ end
19
+
20
+ #: () -> void
21
+ def run
22
+ ConfigManifest.apply!
23
+
24
+ case @command
25
+ when "review" then review
26
+ when "accept" then accept
27
+ when "reject" then reject
28
+ when "pending" then pending_list
29
+ when "status" then status
30
+ when "clean" then clean
31
+ when "help", "--help", "-h" then help
32
+ when "--version", "-v" then version
33
+ else
34
+ warn header
35
+ warn
36
+ warn "#{dim("Invalid command:")} #{red(@command)}"
37
+ warn
38
+ warn "Run #{cyan("insta help")} for usage information."
39
+ exit 1
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def pluralize(count, singular, plural = "#{singular}s") = count == 1 ? singular : plural
46
+
47
+ #: () -> void
48
+ def review
49
+ pending_files = find_pending_files
50
+ inline_entries = Inline::PendingStore.load
51
+
52
+ if pending_files.empty? && inline_entries.empty?
53
+ puts "#{green("✓")} No pending snapshots to review."
54
+ return
55
+ end
56
+
57
+ @pending_locations = PendingLocations.load
58
+ @accepted_count = 0
59
+ @rejected_count = 0
60
+
61
+ total = pending_files.length + inline_entries.length
62
+ puts "#{yellow("●")} Found #{bold(total.to_s)} pending #{pluralize(total, "snapshot")} to review.\n\n"
63
+
64
+ @quit = false
65
+ review_files(pending_files) unless pending_files.empty?
66
+ review_inline_entries(inline_entries) unless @quit || inline_entries.empty?
67
+
68
+ print_review_summary
69
+ end
70
+
71
+ #: (Array[String]) -> void
72
+ def review_files(files)
73
+ accept_all = false
74
+ reject_all = false
75
+
76
+ files.each do |pending_file|
77
+ if accept_all
78
+ accept_file(pending_file)
79
+ next
80
+ end
81
+
82
+ if reject_all
83
+ reject_file(pending_file)
84
+ next
85
+ end
86
+
87
+ result = review_single_file(pending_file)
88
+
89
+ case result
90
+ when :accept_all
91
+ accept_all = true
92
+ when :reject_all
93
+ reject_all = true
94
+ when :quit
95
+ puts "Quit."
96
+ @quit = true
97
+ break
98
+ end
99
+ end
100
+ end
101
+
102
+ #: (String) -> Symbol
103
+ def review_single_file(pending_file)
104
+ original = pending_file.sub(/\.new\z/, "")
105
+ snap_name = File.basename(original)
106
+
107
+ puts bold("─── #{snap_name} ───")
108
+ display_source_context(pending_file)
109
+ display_snapshot_paths(original, pending_file)
110
+ puts
111
+ display_diff(original, pending_file)
112
+
113
+ response = prompt_review_action
114
+ process_review_response(response, pending_file)
115
+ end
116
+
117
+ #: () -> String
118
+ def prompt_review_action
119
+ puts
120
+ print review_prompt_line
121
+
122
+ response = $stdin.gets&.strip || "s"
123
+
124
+ if response.downcase == "h"
125
+ print_review_help
126
+ print review_prompt_line
127
+ response = $stdin.gets&.strip || "s"
128
+ end
129
+
130
+ response.downcase
131
+ end
132
+
133
+ #: () -> String
134
+ def review_prompt_line
135
+ " #{bold(green("[a]ccept"))} #{bold(red("[r]eject"))} #{dim("[s]kip")} " \
136
+ "#{bold(green("[A]ccept all"))} #{bold(red("[R]eject all"))} " \
137
+ "#{dim("[q]uit")} #{dim("[h]elp")}: "
138
+ end
139
+
140
+ #: (String) -> void
141
+ def display_source_context(pending_file)
142
+ locations = @pending_locations || {}
143
+ caller_location = locations[pending_file]
144
+
145
+ if caller_location
146
+ display_caller_context(caller_location)
147
+ else
148
+ snapshot = Snapshot.parse(File.read(pending_file))
149
+ source = snapshot.source
150
+ puts " #{dim("Source:")} #{cyan(source)}" if source
151
+
152
+ test_file = find_test_file(pending_file)
153
+ return unless test_file
154
+
155
+ method_name = source&.split("#")&.last
156
+ lineno = method_name ? find_test_line(test_file, method_name) : nil
157
+ location = lineno ? "#{test_file}:#{lineno}" : test_file
158
+
159
+ puts " #{dim("Test:")} #{cyan(location)}"
160
+
161
+ print_source_lines(test_file, lineno) if lineno
162
+ end
163
+ end
164
+
165
+ #: (String) -> String?
166
+ def find_test_file(pending_file)
167
+ snapshot_path = Insta.configuration.snapshot_path
168
+ relative = pending_file.sub(%r{\A#{Regexp.escape(snapshot_path)}/}, "")
169
+ test_dir = File.dirname(relative)
170
+
171
+ test_file = File.join("test", "#{test_dir}.rb")
172
+ return test_file if File.exist?(test_file)
173
+
174
+ nil
175
+ end
176
+
177
+ #: (String, String) -> Integer?
178
+ def find_test_line(test_file, method_name)
179
+ lines = File.readlines(test_file)
180
+
181
+ lines.each_with_index do |line, i|
182
+ return i + 1 if line.match?(/\bdef\s+#{Regexp.escape(method_name)}\b/)
183
+ end
184
+
185
+ description = method_name.sub(/\Atest_\d+_/, "").tr("_", " ")
186
+
187
+ lines.each_with_index do |line, i|
188
+ return i + 1 if line.include?("test \"#{description}\"") || line.include?("test '#{description}'")
189
+ end
190
+
191
+ nil
192
+ end
193
+
194
+ #: (String) -> void
195
+ def display_caller_context(caller_location)
196
+ file, lineno_string = caller_location.split(":", 3).first(2)
197
+ return unless file && lineno_string
198
+
199
+ lineno = lineno_string.to_i
200
+ return unless lineno.positive? && File.exist?(file)
201
+
202
+ absolute_path = File.expand_path(file)
203
+ puts " #{dim("Source:")} #{cyan("#{absolute_path}:#{lineno}")}"
204
+
205
+ print_source_lines(file, lineno)
206
+ end
207
+
208
+ #: (String, String) -> void
209
+ def display_snapshot_paths(original, pending_file)
210
+ puts " #{dim("Old:")} #{dim(original)}"
211
+ puts " #{dim("New:")} #{dim(pending_file)}"
212
+ end
213
+
214
+ #: () -> void
215
+ def print_review_help
216
+ extension = Insta.configuration.snapshot_extension
217
+
218
+ puts
219
+ puts " #{bold(green("a"))} #{bold(green("accept"))} " \
220
+ "#{dim("Apply the new snapshot as the current #{extension} file")}"
221
+ puts " #{bold(red("r"))} #{bold(red("reject"))} " \
222
+ "#{dim("Discard the #{extension}.new file and keep the current snapshot")}"
223
+ puts " #{dim("s")} #{dim("skip")} " \
224
+ "#{dim("Skip this snapshot and continue reviewing")}"
225
+ puts " #{bold(green("A"))} #{bold(green("accept all"))} " \
226
+ "#{dim("Accept this and all remaining pending snapshots")}"
227
+ puts " #{bold(red("R"))} #{bold(red("reject all"))} " \
228
+ "#{dim("Reject this and all remaining pending snapshots")}"
229
+ puts " #{dim("q")} #{dim("quit")} " \
230
+ "#{dim("Stop reviewing and exit")}"
231
+ puts
232
+ end
233
+
234
+ #: (String, Integer) -> void
235
+ def print_source_lines(file, lineno)
236
+ lines = File.readlines(file)
237
+ start_line = [lineno - 3, 0].max
238
+ end_line = [lineno + 1, lines.length - 1].min
239
+
240
+ highlighted = highlight_lines(lines, start_line, end_line)
241
+ puts
242
+
243
+ (start_line..end_line).each do |i|
244
+ line_num = i + 1
245
+ prefix = line_num == lineno ? cyan("→ ") : " "
246
+
247
+ puts " #{prefix}#{dim(format("%4d", line_num))} #{highlighted[i - start_line]}"
248
+ end
249
+
250
+ puts
251
+ end
252
+
253
+ #: (Array[String], Integer, Integer) -> Array[String]
254
+ def highlight_lines(lines, start_line, end_line)
255
+ snippet = lines[start_line..end_line] || []
256
+ code = snippet.join
257
+
258
+ SyntaxHighlight.highlight(code, colorable: color?).lines
259
+ end
260
+
261
+ #: (String, String) -> void
262
+ def display_diff(original, pending_file)
263
+ if File.exist?(original)
264
+ old_content = File.read(original)
265
+ new_content = File.read(pending_file)
266
+ extension = File.extname(original).delete_prefix(".")
267
+
268
+ puts Diff.diff_with_language(old_content, new_content, file_extension: extension)
269
+ else
270
+ puts " #{dim("(new snapshot)")}"
271
+ puts File.read(pending_file)
272
+ end
273
+ end
274
+
275
+ #: (String, String) -> Symbol
276
+ def process_review_response(response, pending_file)
277
+ case response
278
+ when "a"
279
+ accept_file(pending_file)
280
+ :continue
281
+ when "r"
282
+ reject_file(pending_file)
283
+ :continue
284
+ when "accept all"
285
+ accept_file(pending_file)
286
+ :accept_all
287
+ when "reject all"
288
+ reject_file(pending_file)
289
+ :reject_all
290
+ when "q"
291
+ :quit
292
+ else
293
+ puts " #{dim("Skipped.")}\n\n"
294
+ :continue
295
+ end
296
+ end
297
+
298
+ #: (Array[Inline::pending_store_entry]) -> void
299
+ def review_inline_entries(entries)
300
+ accept_all = false
301
+ reject_all = false
302
+
303
+ entries.each do |entry|
304
+ if accept_all
305
+ accept_inline_entry(entry)
306
+ next
307
+ end
308
+
309
+ if reject_all
310
+ reject_inline_entry(entry)
311
+ next
312
+ end
313
+
314
+ result = review_single_inline_entry(entry)
315
+
316
+ case result
317
+ when :accept_all
318
+ accept_all = true
319
+ when :reject_all
320
+ reject_all = true
321
+ when :quit
322
+ puts "Quit."
323
+ break
324
+ end
325
+ end
326
+ end
327
+
328
+ #: (Inline::pending_store_entry) -> Symbol
329
+ def review_single_inline_entry(entry)
330
+ file = entry[:file]
331
+ line = entry[:line]
332
+
333
+ puts bold("─── inline snapshot ───")
334
+ puts " #{dim("Source:")} #{cyan("#{File.expand_path(file)}:#{line}")}"
335
+
336
+ print_source_lines(file, line) if File.exist?(file)
337
+
338
+ old_content = entry[:old_content] || ""
339
+ new_content = entry[:content] || ""
340
+
341
+ puts Diff.diff(old_content, new_content)
342
+
343
+ response = prompt_review_action
344
+ process_inline_review_response(response, entry)
345
+ end
346
+
347
+ #: (String, Inline::pending_store_entry) -> Symbol
348
+ def process_inline_review_response(response, entry)
349
+ case response
350
+ when "a"
351
+ accept_inline_entry(entry)
352
+ :continue
353
+ when "r"
354
+ reject_inline_entry(entry)
355
+ :continue
356
+ when "accept all"
357
+ accept_inline_entry(entry)
358
+ :accept_all
359
+ when "reject all"
360
+ reject_inline_entry(entry)
361
+ :reject_all
362
+ when "q"
363
+ :quit
364
+ else
365
+ puts " #{dim("Skipped.")}\n\n"
366
+ :continue
367
+ end
368
+ end
369
+
370
+ #: (Inline::pending_store_entry) -> void
371
+ def accept_inline_entry(entry)
372
+ file = entry[:file]
373
+
374
+ Inline::PendingStore.apply!([entry])
375
+ Inline::PendingStore.remove!([entry])
376
+
377
+ @accepted_count = (@accepted_count || 0) + 1
378
+
379
+ puts "\n #{green("✓")} #{File.basename(file)}:#{entry[:line]}"
380
+ end
381
+
382
+ #: (Inline::pending_store_entry) -> void
383
+ def reject_inline_entry(entry)
384
+ file = entry[:file]
385
+
386
+ Inline::PendingStore.remove!([entry])
387
+
388
+ @rejected_count = (@rejected_count || 0) + 1
389
+
390
+ puts "\n #{red("✗")} #{File.basename(file)}:#{entry[:line]}"
391
+ end
392
+
393
+ #: () -> void
394
+ def accept
395
+ pending_files = find_pending_files
396
+ inline_entries = Inline::PendingStore.load
397
+ count = pending_files.length + inline_entries.length
398
+
399
+ if count.zero?
400
+ puts "#{green("✓")} No pending snapshots to accept."
401
+ return
402
+ end
403
+
404
+ pending_files.each { |file| accept_file(file) }
405
+
406
+ unless inline_entries.empty?
407
+ Inline::PendingStore.apply!(inline_entries)
408
+ Inline::PendingStore.clean!
409
+ end
410
+
411
+ puts "\n#{green("✓")} Accepted #{bold(count.to_s)} #{pluralize(count, "snapshot")}."
412
+ end
413
+
414
+ #: () -> void
415
+ def reject
416
+ pending_files = find_pending_files
417
+ inline_entries = Inline::PendingStore.load
418
+ count = pending_files.length + inline_entries.length
419
+
420
+ if count.zero?
421
+ puts "#{green("✓")} No pending snapshots to reject."
422
+ return
423
+ end
424
+
425
+ pending_files.each { |file| reject_file(file) }
426
+ Inline::PendingStore.clean! unless inline_entries.empty?
427
+
428
+ puts "\n#{green("✓")} Rejected #{bold(count.to_s)} #{pluralize(count, "snapshot")}."
429
+ end
430
+
431
+ #: () -> void
432
+ def pending_list
433
+ pending_files = find_pending_files
434
+ inline_entries = Inline::PendingStore.load
435
+
436
+ if pending_files.empty? && inline_entries.empty?
437
+ puts "#{green("✓")} No pending snapshots."
438
+ return
439
+ end
440
+
441
+ total = pending_files.length + inline_entries.length
442
+ puts "#{yellow("●")} Pending snapshots #{dim("(#{total})")}:\n\n"
443
+
444
+ print_pending_files(pending_files) unless pending_files.empty?
445
+
446
+ return if inline_entries.empty?
447
+
448
+ puts unless pending_files.empty?
449
+ print_pending_inline(inline_entries)
450
+ end
451
+
452
+ #: (Array[String]) -> void
453
+ def print_pending_files(pending_files)
454
+ locations = PendingLocations.load
455
+
456
+ pending_files.each do |pending_file|
457
+ caller_location = locations[pending_file]
458
+
459
+ if caller_location
460
+ file, lineno = caller_location.split(":", 3).first(2)
461
+ absolute_path = file ? File.expand_path(file) : nil
462
+ location = absolute_path ? "#{absolute_path}:#{lineno}" : caller_location
463
+
464
+ puts " #{yellow("›")} #{pending_file}"
465
+ puts " #{dim("at")} #{cyan(location)}"
466
+ else
467
+ puts " #{yellow("›")} #{pending_file}"
468
+ end
469
+ end
470
+ end
471
+
472
+ #: (Array[Inline::pending_store_entry]) -> void
473
+ def print_pending_inline(inline_entries)
474
+ inline_entries.each do |entry|
475
+ file = entry[:file]
476
+ line = entry[:line]
477
+ absolute_path = File.expand_path(file)
478
+
479
+ puts " #{yellow("›")} #{dim("inline")} #{File.basename(file)}:#{line}"
480
+ puts " #{dim("at")} #{cyan("#{absolute_path}:#{line}")}"
481
+ end
482
+ end
483
+
484
+ #: () -> void
485
+ def status
486
+ snapshot_path = Insta.configuration.snapshot_path
487
+ extension = Insta.configuration.snapshot_extension
488
+
489
+ puts header
490
+ puts
491
+
492
+ all_entries = Dir.exist?(snapshot_path) ? Dir.glob(File.join(snapshot_path, "**", "*")) : [] #: Array[String]
493
+ pending_extension = "#{extension}.new"
494
+ snap_files = all_entries.select { |file|
495
+ File.file?(file) && file.end_with?(extension) && !file.end_with?(pending_extension)
496
+ }
497
+ pending_files = all_entries.select { |file| File.file?(file) && file.end_with?(pending_extension) }
498
+ inline_entries = Inline::PendingStore.load
499
+ directories = all_entries.select { |file| File.directory?(file) }
500
+
501
+ print_status_config(snapshot_path, extension)
502
+ print_status_counts(snap_files, pending_files, inline_entries, directories)
503
+ print_status_pending(pending_files, inline_entries)
504
+ end
505
+
506
+ #: (String, String) -> void
507
+ def print_status_config(snapshot_path, extension)
508
+ config_entries = [["Snapshot path", snapshot_path], ["Extension", extension]] #: Array[[String, String]]
509
+ print_status_entries(config_entries)
510
+ puts
511
+ end
512
+
513
+ #: (Array[String], Array[String], Array[Inline::pending_store_entry], Array[String]) -> void
514
+ def print_status_counts(snap_files, pending_files, inline_entries, directories)
515
+ print_status_entries(
516
+ [
517
+ ["Snapshots", snap_files.length.to_s],
518
+ ["Pending files", pending_files.empty? ? "0" : yellow(pending_files.length.to_s)],
519
+ ["Pending inline", inline_entries.empty? ? "0" : yellow(inline_entries.length.to_s)],
520
+ ["Directories", directories.length.to_s]
521
+ ]
522
+ )
523
+ puts
524
+ end
525
+
526
+ #: (Array[String], Array[Inline::pending_store_entry]) -> void
527
+ def print_status_pending(pending_files, inline_entries)
528
+ if pending_files.empty? && inline_entries.empty?
529
+ puts "#{green("✓")} No pending snapshots."
530
+ return
531
+ end
532
+
533
+ unless pending_files.empty?
534
+ puts "Pending file snapshots:\n\n"
535
+ pending_files.each { |file| puts " #{yellow("›")} #{file}" }
536
+ puts
537
+ end
538
+
539
+ unless inline_entries.empty?
540
+ puts "Pending inline snapshots:\n\n"
541
+ inline_entries.each { |entry| puts " #{yellow("›")} #{entry[:file]}:#{entry[:line]}" }
542
+ puts
543
+ end
544
+
545
+ puts " #{cyan("bundle exec insta review")}\n"
546
+ end
547
+
548
+ #: (Array[[String, String]]) -> void
549
+ def print_status_entries(entries)
550
+ max_label = entries.map { |label, _| label.length }.max || 0
551
+
552
+ entries.each do |label, value|
553
+ dot_count = MIN_GAP + (max_label - label.length)
554
+ dots = dim("·" * dot_count)
555
+ puts " #{cyan(label)} #{dots} #{value}"
556
+ end
557
+ end
558
+
559
+ #: () -> void
560
+ def clean
561
+ snapshot_path = Insta.configuration.snapshot_path
562
+
563
+ unless Dir.exist?(snapshot_path)
564
+ puts "#{red("✗")} Snapshot directory does not exist: #{bold(snapshot_path)}"
565
+ return
566
+ end
567
+
568
+ all_files = Dir.glob(File.join(snapshot_path, "**", "*"))
569
+ pending_files = all_files.select { |file| file.end_with?(".new") }
570
+ snap_files = all_files - pending_files
571
+ removed = 0
572
+
573
+ pending_files.each do |file|
574
+ File.delete(file)
575
+ removed += 1
576
+ end
577
+
578
+ inline_entries = Inline::PendingStore.load
579
+ removed += inline_entries.length
580
+
581
+ PendingLocations.clean!
582
+ Inline::PendingStore.clean!
583
+
584
+ puts "#{green("✓")} Removed #{bold(removed.to_s)} pending #{pluralize(removed, "snapshot")}." if removed.positive?
585
+ puts " #{dim("#{snap_files.length} snapshot file(s) in #{snapshot_path}")}"
586
+ end
587
+
588
+ #: () -> void
589
+ def help
590
+ puts header
591
+ puts
592
+ puts dim("Description:")
593
+ puts " Snapshot tests assert values against a reference value. Think of it as"
594
+ puts " a supercharged version of #{cyan("assert_equal")} where the reference value is"
595
+ puts " managed by #{bold("insta")} for you."
596
+ puts
597
+
598
+ print_usage
599
+ print_section("Commands:", help_commands)
600
+ print_section("Options:", help_options)
601
+ print_section("Environment:", help_environment)
602
+ end
603
+
604
+ #: () -> void
605
+ def print_usage
606
+ puts dim("Usage:")
607
+ puts " insta #{dim("<command>")} #{dim("[options]")}"
608
+ puts
609
+ end
610
+
611
+ #: (String, Array[[String, String]]) -> void
612
+ def print_section(title, entries)
613
+ puts dim(title)
614
+
615
+ max_label = entries.map { |label, _| label.length }.max || 0
616
+
617
+ entries.each do |label, description|
618
+ dot_count = MIN_GAP + (max_label - label.length)
619
+ dots = dim("·" * dot_count)
620
+
621
+ puts " #{cyan(label)} #{dots} #{description}"
622
+ end
623
+
624
+ puts
625
+ end
626
+
627
+ #: () -> String
628
+ def header
629
+ "#{bold("insta")} v#{Insta::VERSION} #{dim("📸 Snapshot Testing for Ruby")}"
630
+ end
631
+
632
+ #: () -> void
633
+ def version
634
+ puts header
635
+ end
636
+
637
+ #: () -> Array[[String, String]]
638
+ def help_commands
639
+ [
640
+ ["review", "Interactive review of pending snapshots"],
641
+ ["accept", "Accept all pending snapshots"],
642
+ ["reject", "Reject all pending snapshots"],
643
+ ["pending", "List pending snapshots"],
644
+ ["status", "Show snapshot overview and pending files"],
645
+ ["clean", "Remove pending snapshot files"],
646
+ ["help", "Display usage information"]
647
+ ]
648
+ end
649
+
650
+ #: () -> Array[[String, String]]
651
+ def help_options
652
+ [
653
+ ["-h, --help", "Display usage information"],
654
+ ["-v, --version", "Display version information"]
655
+ ]
656
+ end
657
+
658
+ #: () -> Array[[String, String]]
659
+ def help_environment
660
+ [
661
+ ["INSTA_UPDATE=<mode>", "Set update mode #{dim("[auto|always|new|no]")}"],
662
+ ["INSTA_FORCE_PASS=1", "Create .snap.new without failing tests"]
663
+ ]
664
+ end
665
+
666
+ #: () -> Array[String]
667
+ def find_pending_files
668
+ snapshot_path = Insta.configuration.snapshot_path
669
+
670
+ Dir.glob(File.join(snapshot_path, "**", "*.new"))
671
+ end
672
+
673
+ #: (String) -> void
674
+ def accept_file(pending_file)
675
+ original = pending_file.sub(/\.new\z/, "")
676
+ File.rename(pending_file, original)
677
+
678
+ @accepted_count = (@accepted_count || 0) + 1
679
+
680
+ puts "\n #{green("✓")} #{File.basename(original)}"
681
+ end
682
+
683
+ #: (String) -> void
684
+ def reject_file(pending_file)
685
+ File.delete(pending_file)
686
+
687
+ @rejected_count = (@rejected_count || 0) + 1
688
+
689
+ puts "\n #{red("✗")} #{File.basename(pending_file)}"
690
+ end
691
+
692
+ #: () -> void
693
+ def print_review_summary
694
+ parts = [] #: Array[String]
695
+ parts << green("#{@accepted_count} accepted") if @accepted_count.positive?
696
+ parts << red("#{@rejected_count} rejected") if @rejected_count.positive?
697
+
698
+ remaining_files = find_pending_files
699
+ remaining_inline = Inline::PendingStore.load
700
+ remaining_count = remaining_files.length + remaining_inline.length
701
+ parts << yellow("#{remaining_count} skipped") if remaining_count.positive?
702
+
703
+ summary = parts.any? ? " #{dim("(")}#{parts.join(dim(", "))}#{dim(")")}" : ""
704
+ puts
705
+
706
+ if remaining_count.zero?
707
+ puts "#{green("✓")} Review complete.#{summary}"
708
+ else
709
+ puts "#{green("✓")} Review complete.#{summary}"
710
+ puts "\n #{cyan("bundle exec insta review")}\n\n"
711
+ end
712
+ end
713
+ end
714
+ end