constable-rails 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 (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +515 -0
  5. data/exe/constable +7 -0
  6. data/lib/constable/case.rb +336 -0
  7. data/lib/constable/cli.rb +475 -0
  8. data/lib/constable/cold_case/minitest.rb +342 -0
  9. data/lib/constable/cold_case/rspec.rb +334 -0
  10. data/lib/constable/cold_case.rb +280 -0
  11. data/lib/constable/config.rb +125 -0
  12. data/lib/constable/coverage.rb +951 -0
  13. data/lib/constable/diff.rb +212 -0
  14. data/lib/constable/dsl.rb +833 -0
  15. data/lib/constable/identity.rb +121 -0
  16. data/lib/constable/importer/modernizer.rb +860 -0
  17. data/lib/constable/importer/reopener.rb +468 -0
  18. data/lib/constable/importer.rb +51 -0
  19. data/lib/constable/investigation.rb +67 -0
  20. data/lib/constable/isolation.rb +171 -0
  21. data/lib/constable/jail.rb +399 -0
  22. data/lib/constable/log_router.rb +197 -0
  23. data/lib/constable/matchers.rb +834 -0
  24. data/lib/constable/order_audit.rb +130 -0
  25. data/lib/constable/rails_support.rb +213 -0
  26. data/lib/constable/railtie.rb +36 -0
  27. data/lib/constable/registry.rb +57 -0
  28. data/lib/constable/reporter.rb +625 -0
  29. data/lib/constable/result.rb +149 -0
  30. data/lib/constable/runner.rb +697 -0
  31. data/lib/constable/selection.rb +205 -0
  32. data/lib/constable/storage/adapter.rb +91 -0
  33. data/lib/constable/storage/mysql_adapter.rb +125 -0
  34. data/lib/constable/storage/postgres_adapter.rb +125 -0
  35. data/lib/constable/storage/sqlite_adapter.rb +84 -0
  36. data/lib/constable/storage.rb +847 -0
  37. data/lib/constable/version.rb +5 -0
  38. data/lib/constable/warrants.rb +290 -0
  39. data/lib/constable-rails.rb +16 -0
  40. data/lib/constable.rb +151 -0
  41. data/lib/generators/constable/base.rb +99 -0
  42. data/lib/generators/constable/channel/channel_generator.rb +20 -0
  43. data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
  44. data/lib/generators/constable/controller/controller_generator.rb +25 -0
  45. data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
  46. data/lib/generators/constable/generator/generator_generator.rb +31 -0
  47. data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
  48. data/lib/generators/constable/helper/helper_generator.rb +23 -0
  49. data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
  50. data/lib/generators/constable/import_generator.rb +137 -0
  51. data/lib/generators/constable/install_generator.rb +188 -0
  52. data/lib/generators/constable/integration/integration_generator.rb +27 -0
  53. data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
  54. data/lib/generators/constable/job/job_generator.rb +20 -0
  55. data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
  56. data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
  57. data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
  58. data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
  59. data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
  60. data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
  61. data/lib/generators/constable/model/model_generator.rb +31 -0
  62. data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
  63. data/lib/generators/constable/resource/resource_generator.rb +27 -0
  64. data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
  65. data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
  66. data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
  67. data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
  68. data/lib/generators/constable/system/system_generator.rb +20 -0
  69. data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
  70. data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
  71. data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
  72. data/lib/generators/constable/templates/config.yml.tt +67 -0
  73. data/lib/generators/constable/templates/example_case.rb.tt +56 -0
  74. data/lib/generators/constable/templates/matchers.rb.tt +36 -0
  75. data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
  76. metadata +209 -0
@@ -0,0 +1,625 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "constable/result"
4
+ require "constable/log_router"
5
+
6
+ module Constable
7
+ # The face of the product. Two jobs, and nothing else on stdout:
8
+ #
9
+ # 1. While the suite runs, one compact glyph per finished test, grouped by case.
10
+ # 2. When it finishes, the summary -- which is the actual deliverable. Everything a
11
+ # developer needs to act on is in it: what broke, where, why, and the exact command
12
+ # to rerun it. Sections print worst-to-least-urgent, and nothing that bends the
13
+ # rules (a jailed test, a warrant, an unsafe block) is ever silently dropped.
14
+ #
15
+ # Logs do not belong here -- Rails.logger, SQL and request logging go to log/test.log
16
+ # via Constable::LogRouter. stdout is results only.
17
+ #
18
+ # The Runner drives it:
19
+ #
20
+ # reporter = Constable::Reporter.new(io: $stdout, config: Constable.config)
21
+ # reporter.start(total: 482, seed: 8841)
22
+ # reporter.record(result) # one glyph, live
23
+ # reporter.finish(results: all, duration: 12.4, seed: 8841, coverage: cov)
24
+ # exit reporter.exit_status
25
+ class Reporter
26
+ # The summary's frame. 60 columns wide, as published in the spec.
27
+ RULE_WIDTH = 60
28
+ HEAVY_RULE = ("━" * RULE_WIDTH).freeze
29
+
30
+ INDENT = " "
31
+ ENTRY_INDENT = " "
32
+ DETAIL_INDENT = " "
33
+
34
+ # " CONSTABLE" padded so the run stats always start in the same column.
35
+ LABEL = "CONSTABLE"
36
+ LABEL_WIDTH = 21
37
+
38
+ # The live stream pads case names into a column so the glyph runs line up.
39
+ STREAM_NAME_WIDTH = 34
40
+ STREAM_GAP = 2
41
+
42
+ # How many glyphs may pile up behind the currently-streaming case before we give
43
+ # up waiting for it and start that case's own line. Parallel workers interleave;
44
+ # without a limit a quiet case could hold the buffer forever.
45
+ STREAM_FLUSH_THRESHOLD = 12
46
+
47
+ DEFAULT_SLOWEST = 5
48
+
49
+ # Result::GLYPHS covers statuses. These four are summary vocabulary, not statuses:
50
+ # supervision and coverage are properties of a test, not outcomes of one.
51
+ GLYPHS = Result::GLYPHS.merge(
52
+ parole: "◑",
53
+ warrant: "⚖",
54
+ warning: "⚠",
55
+ coverage: "◐"
56
+ ).freeze
57
+
58
+ STYLES = {
59
+ reset: 0,
60
+ bold: 1,
61
+ dim: 2,
62
+ red: 31,
63
+ green: 32,
64
+ yellow: 33,
65
+ blue: 34,
66
+ magenta: 35,
67
+ cyan: 36
68
+ }.freeze
69
+
70
+ COLORS = {
71
+ passed: :green,
72
+ failed: :red,
73
+ errored: :red,
74
+ parole_violation: :red,
75
+ jailed: :yellow,
76
+ warranted: :yellow,
77
+ warning: :yellow,
78
+ skipped: :dim,
79
+ parole: :cyan,
80
+ coverage: :cyan
81
+ }.freeze
82
+
83
+ # The spec prints SLOWEST's rule one character longer than its title. Reproduced
84
+ # verbatim so our output matches the published example character for character;
85
+ # drop the entry to make every underline title-width.
86
+ UNDERLINE_WIDTHS = { "SLOWEST" => 8 }.freeze
87
+
88
+ # Wide (East Asian / emoji) code points occupy two columns; combining marks occupy
89
+ # none. Padding by byte length or even by character count would misalign the glyph
90
+ # column the moment a name or a glyph stops being plain ASCII.
91
+ WIDE_RANGES = [
92
+ 0x1100..0x115F, 0x2E80..0x303E, 0x3041..0x33FF, 0x3400..0x4DBF, 0x4E00..0x9FFF,
93
+ 0xA000..0xA4CF, 0xAC00..0xD7A3, 0xF900..0xFAFF, 0xFE10..0xFE19, 0xFE30..0xFE6F,
94
+ 0xFF00..0xFF60, 0xFFE0..0xFFE6, 0x1F300..0x1F64F, 0x1F680..0x1F6FF,
95
+ 0x1F900..0x1F9FF, 0x20000..0x3FFFD
96
+ ].freeze
97
+
98
+ attr_reader :io, :config, :seed, :total
99
+
100
+ def initialize(io: $stdout, config: nil, color: nil, slowest: DEFAULT_SLOWEST)
101
+ @io = io
102
+ @config = config || Constable.config
103
+ @color = resolve_color(color)
104
+ @slowest = slowest.to_i
105
+ @io.set_encoding(Encoding::UTF_8) if @io.respond_to?(:set_encoding)
106
+
107
+ reset_stream!
108
+ @failed_live = 0
109
+ @warning_count = 0
110
+ @finished = false
111
+ end
112
+
113
+ # --- lifecycle -----------------------------------------------------------------
114
+
115
+ # Announces the run. The seed is always printed: every summary that mentions a
116
+ # failure hands back a rerun command, and the command is only replayable with it.
117
+ def start(total: nil, seed: nil)
118
+ @total = total
119
+ @seed = seed
120
+ return self if total.nil? && seed.nil?
121
+
122
+ bits = []
123
+ bits << "#{total} #{pluralize(total, "test")}" if total
124
+ bits << "seed #{seed}" if seed
125
+ writeln(paint("#{LABEL.downcase} · #{bits.join(" · ")}", :dim))
126
+ writeln
127
+ self
128
+ end
129
+
130
+ # One glyph, live, as each test completes. Safe to call from the parent process
131
+ # only -- workers ship results home and the parent reports them.
132
+ def record(result)
133
+ return self if result.nil?
134
+
135
+ @failed_live += 1 if result.failed?
136
+ stream(result)
137
+ self
138
+ end
139
+
140
+ # Ends the live stream, emitting any case whose glyphs are still buffered.
141
+ def flush!
142
+ return self unless streaming?
143
+
144
+ close_stream_line
145
+ pending_case_names.each { |name| open_stream_line(name) && close_stream_line }
146
+ writeln
147
+ reset_stream!
148
+ self
149
+ end
150
+
151
+ # Prints the summary. Returns the process exit status so a caller can
152
+ # `exit reporter.finish(...)` in one line.
153
+ def finish(results:, duration: 0.0, seed: nil, coverage: nil, suggestions: [], warnings: nil)
154
+ results = Array(results)
155
+ @seed = seed if seed
156
+ flush!
157
+
158
+ counts = tally(results)
159
+ @failed_live = counts[:failed]
160
+ warnings = normalize_warnings(warnings)
161
+ @warning_count = warnings.size
162
+ coverage = normalize_coverage(coverage)
163
+ @finished = true
164
+
165
+ writeln(paint(HEAVY_RULE, :dim))
166
+ writeln(header_line(results, duration))
167
+ writeln(paint(HEAVY_RULE, :dim))
168
+ writeln(headline(counts, warnings.size, coverage))
169
+
170
+ section_parole_violations(results)
171
+ section_failures(results)
172
+ section_warnings(warnings)
173
+ section_slowest(results)
174
+ rename_suggestions(suggestions)
175
+
176
+ writeln(paint(HEAVY_RULE, :dim))
177
+ exit_status
178
+ end
179
+
180
+ # --- queries -------------------------------------------------------------------
181
+
182
+ # 0 clean, 1 something the build should care about. Jailed tests, warrants and
183
+ # warnings are deliberately non-blocking -- unless a CI run opted into
184
+ # fail_on_warnings, which is the whole point of that setting.
185
+ def exit_status
186
+ return 1 if @failed_live.positive?
187
+ return 1 if @config.fail_on_warnings? && @warning_count.positive?
188
+
189
+ 0
190
+ end
191
+
192
+ def success? = exit_status.zero?
193
+ def failed? = !success?
194
+ def color? = @color
195
+ def finished? = @finished
196
+
197
+ private
198
+
199
+ # --- live glyph stream ---------------------------------------------------------
200
+
201
+ def reset_stream!
202
+ @stream_case = nil
203
+ @stream_pending = {}
204
+ @stream_open = false
205
+ end
206
+
207
+ def streaming? = @stream_open || @stream_pending.any?
208
+
209
+ def pending_case_names
210
+ @stream_pending.reject { |_name, glyphs| glyphs.empty? }.keys
211
+ end
212
+
213
+ def stream(result)
214
+ name = result.case_name.to_s
215
+ name = "(anonymous)" if name.empty?
216
+ glyph = paint(result.glyph, COLORS[result.status])
217
+
218
+ if @stream_open && @stream_case == name
219
+ write(glyph)
220
+ return
221
+ end
222
+
223
+ (@stream_pending[name] ||= +"") << glyph
224
+ return open_stream_line(name) unless @stream_open
225
+
226
+ promote_stream_line if buffered_glyph_count >= STREAM_FLUSH_THRESHOLD
227
+ end
228
+
229
+ # Glyphs waiting behind the case that currently owns the line.
230
+ def buffered_glyph_count
231
+ @stream_pending.sum { |_name, glyphs| count_glyphs(glyphs) }
232
+ end
233
+
234
+ # Ends the current line and hands it to whichever case has waited longest with
235
+ # the most to say. A case that has already had a line simply gets another one --
236
+ # repeating the name is honest, silently appending to a stale line is not.
237
+ def promote_stream_line
238
+ close_stream_line
239
+ busiest = @stream_pending.max_by { |_name, glyphs| count_glyphs(glyphs) }
240
+ open_stream_line(busiest.first) if busiest
241
+ end
242
+
243
+ def open_stream_line(name)
244
+ glyphs = @stream_pending.delete(name).to_s
245
+ return false if glyphs.empty?
246
+
247
+ write(paint(pad(name, STREAM_NAME_WIDTH - STREAM_GAP), :dim))
248
+ write(" " * STREAM_GAP)
249
+ write(glyphs)
250
+ @stream_case = name
251
+ @stream_open = true
252
+ end
253
+
254
+ def close_stream_line
255
+ return false unless @stream_open
256
+
257
+ writeln
258
+ @stream_case = nil
259
+ @stream_open = false
260
+ true
261
+ end
262
+
263
+ # Glyphs may carry escape codes; count only the visible ones.
264
+ def count_glyphs(string) = strip_ansi(string).length
265
+
266
+ # --- header and headline -------------------------------------------------------
267
+
268
+ def header_line(results, duration)
269
+ stats = [
270
+ "#{results.size} #{pluralize(results.size, "test")}",
271
+ "#{case_count(results)} #{pluralize(case_count(results), "case")}",
272
+ format_duration(duration)
273
+ ].join(" · ")
274
+
275
+ INDENT + paint(pad(LABEL, LABEL_WIDTH), :bold) + stats
276
+ end
277
+
278
+ def case_count(results) = results.map(&:case_name).uniq.size
279
+
280
+ # Zero-valued categories are omitted rather than printed as 0 -- except passed and
281
+ # failed, which are the two numbers a reader looks for first and so always show.
282
+ def headline(counts, warning_count, coverage)
283
+ parts = []
284
+ parts << count_part(:passed, counts[:passed], "passed", always: true)
285
+ parts << count_part(:failed, counts[:failed], "failed", always: true)
286
+ parts << jailed_part(counts)
287
+ parts << count_part(:skipped, counts[:skipped], "skipped")
288
+ parts << count_part(:parole, counts[:on_parole], "on parole")
289
+ parts << warrant_part(counts[:warranted])
290
+ parts << count_part(:warning, warning_count, "warning", plural: true)
291
+ parts << coverage_part(coverage)
292
+
293
+ INDENT + parts.compact.join(" ")
294
+ end
295
+
296
+ def count_part(key, count, label, always: false, plural: false)
297
+ count = count.to_i
298
+ return nil if count.zero? && !always
299
+
300
+ label = pluralize(count, label) if plural
301
+ paint("#{GLYPHS[key]} #{count} #{label}", COLORS[key])
302
+ end
303
+
304
+ # Parole violations are more urgent news than a plain jailing -- somebody deliberately
305
+ # trusted this test again -- so the headline splits them back out of the total.
306
+ def jailed_part(counts)
307
+ jailed = counts[:jailed].to_i
308
+ return nil if jailed.zero?
309
+
310
+ violations = counts[:parole_violations].to_i
311
+ text = "#{GLYPHS[:jailed]} #{jailed} jailed"
312
+ text += " (#{violations} #{pluralize(violations, "parole violation")})" if violations.positive?
313
+ paint(text, COLORS[:jailed])
314
+ end
315
+
316
+ def warrant_part(count)
317
+ count = count.to_i
318
+ return nil if count.zero?
319
+
320
+ paint("#{GLYPHS[:warrant]} #{count} #{pluralize(count, "warrant")} issued", COLORS[:warranted])
321
+ end
322
+
323
+ def coverage_part(coverage)
324
+ return nil if coverage.nil?
325
+
326
+ text = "#{GLYPHS[:coverage]} #{format_percent(coverage[:percent])}% covered"
327
+ # A 0% file is usually a missed file, not a thin one, so it gets named out loud.
328
+ if coverage[:unpatrolled].to_i.positive?
329
+ text += " (#{coverage[:unpatrolled]} #{pluralize(coverage[:unpatrolled], "file")} unpatrolled)"
330
+ end
331
+ paint(text, COLORS[:coverage])
332
+ end
333
+
334
+ def tally(results)
335
+ {
336
+ passed: results.count(&:passed?),
337
+ failed: results.count(&:failed?),
338
+ jailed: results.count(&:jailed?),
339
+ parole_violations: results.count(&:parole_violation?),
340
+ on_parole: results.count { |r| r.parole_day && !r.parole_violation? },
341
+ warranted: results.count(&:warranted?),
342
+ skipped: results.count(&:skipped?)
343
+ }
344
+ end
345
+
346
+ # --- sections ------------------------------------------------------------------
347
+
348
+ def section(title)
349
+ writeln
350
+ writeln(INDENT + paint(title, :bold))
351
+ writeln(INDENT + paint("─" * (UNDERLINE_WIDTHS[title] || title.length), :dim))
352
+ end
353
+
354
+ def section_parole_violations(results)
355
+ violations = results.select(&:parole_violation?)
356
+ return if violations.empty?
357
+
358
+ section("PAROLE VIOLATED")
359
+ each_entry(violations) do |result|
360
+ writeln(INDENT + paint("#{GLYPHS[:parole_violation]} #{result.case_name}", COLORS[:parole_violation]))
361
+ writeln(ENTRY_INDENT + paint(%("#{result.description}"), :dim))
362
+ writeln(ENTRY_INDENT + parole_violation_sentence(result))
363
+ end
364
+ end
365
+
366
+ def parole_violation_sentence(result)
367
+ sentence = "Failed on day #{result.parole_day} of a #{@config.parole_period}-run parole — back to jail."
368
+ sentence << " This is its #{ordinalize(result.times_jailed)} time in jail." if result.times_jailed
369
+ sentence
370
+ end
371
+
372
+ def section_failures(results)
373
+ failures = results.select(&:failed?)
374
+ return if failures.empty?
375
+
376
+ section("FAILURES")
377
+ each_entry(failures) { |result| failure_entry(result) }
378
+ end
379
+
380
+ def failure_entry(result)
381
+ writeln(INDENT + paint("#{GLYPHS[:failed]} #{result.case_name}", COLORS[:failed]))
382
+ writeln(ENTRY_INDENT + paint(%("#{result.description}"), :dim))
383
+ writeln(ENTRY_INDENT + paint(result.location, :dim))
384
+ writeln
385
+ failure_message(result).each_line { |line| writeln(ENTRY_INDENT + line.chomp) }
386
+ failure_context(result)
387
+ writeln
388
+ writeln(ENTRY_INDENT + paint("Rerun just this test:", :dim))
389
+ writeln(DETAIL_INDENT + result.rerun_command)
390
+ end
391
+
392
+ def failure_message(result)
393
+ failure = result.failure
394
+ return "(no failure message recorded)" if failure.nil?
395
+
396
+ message = failure.message.to_s
397
+ message = "#{failure.exception_class}: #{message}" if result.status == :errored && failure.exception_class
398
+ message.empty? ? "(no failure message recorded)" : message
399
+ end
400
+
401
+ # The matcher's own context -- a response body, a record's attributes. Printed
402
+ # verbatim, keeping whatever relative indentation the matcher chose.
403
+ def failure_context(result)
404
+ context = result.failure&.context
405
+ return if context.nil?
406
+
407
+ lines = context_lines(context)
408
+ return if lines.empty?
409
+
410
+ writeln
411
+ lines.each { |line| writeln(line.empty? ? "" : ENTRY_INDENT + line) }
412
+ end
413
+
414
+ def context_lines(context)
415
+ lines = case context
416
+ when String then context.rstrip.lines.map { |line| line.chomp.rstrip }
417
+ when Hash then context.map { |key, value| "#{key}: #{value}" }
418
+ when Array then context.map(&:to_s)
419
+ else [context.to_s]
420
+ end
421
+ lines.all?(&:empty?) ? [] : lines
422
+ end
423
+
424
+ def section_warnings(warnings)
425
+ return if warnings.empty?
426
+
427
+ section("WARNINGS")
428
+ each_entry(warnings) do |warning|
429
+ location = warning[:location].to_s
430
+ message = warning[:message].to_s
431
+ if location.empty?
432
+ writeln(INDENT + paint("#{GLYPHS[:warning]} #{message}", COLORS[:warning]))
433
+ else
434
+ writeln(INDENT + paint("#{GLYPHS[:warning]} #{location}", COLORS[:warning]))
435
+ message.each_line { |line| writeln(ENTRY_INDENT + line.chomp) }
436
+ end
437
+ end
438
+ end
439
+
440
+ def section_slowest(results)
441
+ # A jailed test never ran its body, so it has no honest duration. A parole
442
+ # violation did run -- and failing slowly is still worth seeing.
443
+ timed = results.reject { |r| r.skipped? || r.status == :jailed }
444
+ .select { |r| r.duration.to_f.positive? }
445
+ .sort_by { |r| -r.duration.to_f }
446
+ .first(@slowest)
447
+ return if timed.empty?
448
+
449
+ section("SLOWEST")
450
+ width = timed.map { |r| format_duration(r.duration).length }.max
451
+ timed.each do |result|
452
+ stamp = format_duration(result.duration).rjust(width)
453
+ writeln("#{INDENT}#{paint(stamp, :dim)} #{result.case_name} #{paint(%("#{result.description}"), :dim)}")
454
+ end
455
+ end
456
+
457
+ # Rename detection is the Runner's job; the reporter only says it out loud. Never
458
+ # its own section -- it is a suggestion, not a finding.
459
+ def rename_suggestions(suggestions)
460
+ suggestions = Array(suggestions).map { |s| suggestion_line(s) }.compact
461
+ return if suggestions.empty?
462
+
463
+ writeln
464
+ suggestions.each { |line| writeln(INDENT + paint(line, :dim)) }
465
+ end
466
+
467
+ def suggestion_line(suggestion)
468
+ return suggestion.to_s if suggestion.is_a?(String)
469
+ return nil unless suggestion.respond_to?(:to_h)
470
+
471
+ s = suggestion.to_h.transform_keys(&:to_sym)
472
+ from = s[:old_label] || s[:from] || label_for(s[:old_case], s[:old_description])
473
+ to = s[:new_label] || s[:to] || label_for(s[:new_case], s[:new_description])
474
+ old_hash = s[:old_hash] || s[:old_identity]
475
+ new_hash = s[:new_hash] || s[:new_identity]
476
+ return nil if from.nil? || to.nil?
477
+
478
+ "possible rename: #{from} → #{to}, " \
479
+ "run constable history relink #{old_hash} #{new_hash} to confirm"
480
+ end
481
+
482
+ def label_for(case_name, description)
483
+ return nil if case_name.nil? && description.nil?
484
+
485
+ "#{case_name}##{description}"
486
+ end
487
+
488
+ def each_entry(entries)
489
+ entries.each_with_index do |entry, index|
490
+ writeln if index.positive?
491
+ yield entry
492
+ end
493
+ end
494
+
495
+ # --- warnings and coverage input -----------------------------------------------
496
+
497
+ # Warnings arrive from two directions: Constable.warn! in this process, and results
498
+ # shipped back by workers that warned in theirs. Same warning from four workers is
499
+ # still one warning, so they collapse on message+location.
500
+ def normalize_warnings(explicit)
501
+ raw = explicit || Constable.warnings
502
+ Array(raw).filter_map { |w| normalize_warning(w) }
503
+ .uniq { |w| [w[:message], w[:location]] }
504
+ end
505
+
506
+ def normalize_warning(warning)
507
+ return { message: warning.to_s, location: nil, kind: :unsafe } if warning.is_a?(String)
508
+ return nil unless warning.respond_to?(:to_h)
509
+
510
+ w = warning.to_h.transform_keys(&:to_sym)
511
+ return nil if w[:message].nil?
512
+
513
+ { message: w[:message].to_s, location: w[:location], kind: (w[:kind] || :unsafe).to_sym }
514
+ end
515
+
516
+ def normalize_coverage(coverage)
517
+ return nil if coverage.nil?
518
+ return { percent: coverage.to_f, unpatrolled: 0 } if coverage.is_a?(Numeric)
519
+
520
+ c = coverage.respond_to?(:to_h) ? coverage.to_h.transform_keys(&:to_sym) : {}
521
+ c = coverage_from_object(coverage) if c.empty?
522
+ percent = c[:percent] || c[:percentage] || c[:covered]
523
+ return nil if percent.nil?
524
+
525
+ { percent: percent.to_f, unpatrolled: unpatrolled_count(c) }
526
+ end
527
+
528
+ def coverage_from_object(coverage)
529
+ {
530
+ percent: (coverage.percent if coverage.respond_to?(:percent)),
531
+ unpatrolled: (coverage.unpatrolled if coverage.respond_to?(:unpatrolled))
532
+ }
533
+ end
534
+
535
+ def unpatrolled_count(hash)
536
+ value = hash[:unpatrolled] || hash[:unpatrolled_files] || hash[:unpatrolled_count] || 0
537
+ value.is_a?(Integer) ? value : Array(value).size
538
+ end
539
+
540
+ # --- formatting ----------------------------------------------------------------
541
+
542
+ # 1st, 2nd, 3rd, 4th -- and 11th/12th/13th, which is where naive versions break.
543
+ def ordinalize(number)
544
+ n = number.to_i
545
+ suffix = if (11..13).cover?(n.abs % 100)
546
+ "th"
547
+ else
548
+ { 1 => "st", 2 => "nd", 3 => "rd" }.fetch(n.abs % 10, "th")
549
+ end
550
+ "#{n}#{suffix}"
551
+ end
552
+
553
+ def pluralize(count, word)
554
+ count.to_i == 1 ? word : "#{word}s"
555
+ end
556
+
557
+ def format_duration(seconds)
558
+ seconds = seconds.to_f
559
+ return format("%.1fs", seconds) if seconds < 60
560
+
561
+ minutes, rest = seconds.divmod(60)
562
+ format("%dm %.1fs", minutes, rest)
563
+ end
564
+
565
+ def format_percent(percent)
566
+ rounded = percent.round(1)
567
+ rounded == rounded.round ? rounded.round.to_s : format("%.1f", rounded)
568
+ end
569
+
570
+ # --- colour --------------------------------------------------------------------
571
+
572
+ # Colour is a nicety; correctness is not. NO_COLOR, a pipe, a dumb terminal or an
573
+ # explicit --no-color all fall back to plain text with identical layout.
574
+ def resolve_color(color)
575
+ return !!color unless color.nil?
576
+ return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
577
+ return false if ENV["TERM"].to_s == "dumb"
578
+ return false unless @io.respond_to?(:tty?) && @io.tty?
579
+
580
+ true
581
+ end
582
+
583
+ def paint(text, *styles)
584
+ styles = styles.flatten.compact
585
+ return text if !@color || styles.empty?
586
+
587
+ codes = styles.filter_map { |style| STYLES[style] }
588
+ return text if codes.empty?
589
+
590
+ "\e[#{codes.join(";")}m#{text}\e[0m"
591
+ end
592
+
593
+ def strip_ansi(text) = text.to_s.gsub(/\e\[[0-9;]*m/, "")
594
+
595
+ # --- width-aware padding -------------------------------------------------------
596
+
597
+ def display_width(string)
598
+ strip_ansi(string).each_char.sum { |char| char_width(char) }
599
+ end
600
+
601
+ def char_width(char)
602
+ return 0 if char.match?(/\p{Mn}|\p{Me}|\p{Cf}/)
603
+
604
+ code = char.ord
605
+ WIDE_RANGES.any? { |range| range.cover?(code) } ? 2 : 1
606
+ end
607
+
608
+ def pad(string, width)
609
+ padding = width - display_width(string)
610
+ padding.positive? ? string + (" " * padding) : string
611
+ end
612
+
613
+ # --- output --------------------------------------------------------------------
614
+
615
+ def write(text)
616
+ @io.write(text)
617
+ @io.flush if @io.respond_to?(:flush)
618
+ text
619
+ end
620
+
621
+ def writeln(text = "")
622
+ write("#{text}\n")
623
+ end
624
+ end
625
+ end