rspec-tracer 1.2.3 → 2.0.0.pre.2

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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +384 -67
  3. data/README.md +454 -429
  4. data/bin/rspec-tracer +15 -0
  5. data/lib/rspec_tracer/cache/Rakefile +43 -0
  6. data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +104 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +284 -0
  9. data/lib/rspec_tracer/cli/explain.rb +158 -0
  10. data/lib/rspec_tracer/cli/report_open.rb +82 -0
  11. data/lib/rspec_tracer/cli.rb +116 -0
  12. data/lib/rspec_tracer/configuration.rb +1196 -3
  13. data/lib/rspec_tracer/engine.rb +1168 -0
  14. data/lib/rspec_tracer/example.rb +141 -11
  15. data/lib/rspec_tracer/filter.rb +35 -0
  16. data/lib/rspec_tracer/line_stub.rb +61 -0
  17. data/lib/rspec_tracer/load_config.rb +2 -2
  18. data/lib/rspec_tracer/logger.rb +15 -0
  19. data/lib/rspec_tracer/rails/README.md +78 -0
  20. data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
  21. data/lib/rspec_tracer/rails/notifications.rb +263 -0
  22. data/lib/rspec_tracer/rails/preset.rb +94 -0
  23. data/lib/rspec_tracer/rails/railtie.rb +22 -0
  24. data/lib/rspec_tracer/rails.rb +15 -0
  25. data/lib/rspec_tracer/remote_cache/README.md +140 -0
  26. data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
  27. data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
  28. data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
  29. data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
  30. data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
  31. data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
  32. data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
  33. data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
  34. data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
  35. data/lib/rspec_tracer/remote_cache.rb +22 -0
  36. data/lib/rspec_tracer/reporters/README.md +103 -0
  37. data/lib/rspec_tracer/reporters/base.rb +87 -0
  38. data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
  39. data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
  40. data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
  41. data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
  42. data/lib/rspec_tracer/reporters/html/README.md +80 -0
  43. data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
  44. data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
  45. data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
  46. data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
  47. data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
  48. data/lib/rspec_tracer/reporters/html/package.json +29 -0
  49. data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
  50. data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
  51. data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
  52. data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
  53. data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
  54. data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
  55. data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
  56. data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
  57. data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
  58. data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
  59. data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
  60. data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
  61. data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
  62. data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
  63. data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
  64. data/lib/rspec_tracer/reporters/registry.rb +120 -0
  65. data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
  66. data/lib/rspec_tracer/rspec/README.md +73 -0
  67. data/lib/rspec_tracer/rspec/installation.rb +97 -0
  68. data/lib/rspec_tracer/rspec/metadata.rb +96 -0
  69. data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
  70. data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
  71. data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
  72. data/lib/rspec_tracer/source_file.rb +24 -7
  73. data/lib/rspec_tracer/storage/README.md +35 -0
  74. data/lib/rspec_tracer/storage/backend.rb +130 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +884 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +50 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +141 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
  82. data/lib/rspec_tracer/time_formatter.rb +37 -18
  83. data/lib/rspec_tracer/tracker/README.md +36 -0
  84. data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
  85. data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
  86. data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
  87. data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
  88. data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
  89. data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
  90. data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
  91. data/lib/rspec_tracer/tracker/filter.rb +127 -0
  92. data/lib/rspec_tracer/tracker/input.rb +99 -0
  93. data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
  94. data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
  95. data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
  96. data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
  97. data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
  98. data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
  99. data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
  100. data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
  101. data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
  102. data/lib/rspec_tracer/version.rb +4 -1
  103. data/lib/rspec_tracer.rb +231 -491
  104. metadata +94 -43
  105. data/lib/rspec_tracer/cache.rb +0 -207
  106. data/lib/rspec_tracer/coverage_merger.rb +0 -42
  107. data/lib/rspec_tracer/coverage_reporter.rb +0 -187
  108. data/lib/rspec_tracer/coverage_writer.rb +0 -58
  109. data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
  110. data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
  111. data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
  112. data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
  113. data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
  114. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
  115. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
  116. data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
  117. data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
  118. data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
  119. data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
  120. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
  121. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
  122. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
  123. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
  124. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
  125. data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
  126. data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
  127. data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
  128. data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
  129. data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
  130. data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
  131. data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
  132. data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
  133. data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
  134. data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
  135. data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
  136. data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
  137. data/lib/rspec_tracer/report_generator.rb +0 -158
  138. data/lib/rspec_tracer/report_merger.rb +0 -68
  139. data/lib/rspec_tracer/report_writer.rb +0 -141
  140. data/lib/rspec_tracer/reporter.rb +0 -204
  141. data/lib/rspec_tracer/rspec_reporter.rb +0 -41
  142. data/lib/rspec_tracer/rspec_runner.rb +0 -56
  143. data/lib/rspec_tracer/ruby_coverage.rb +0 -9
  144. data/lib/rspec_tracer/runner.rb +0 -278
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec_tracer/storage/backend'
4
+ require 'rspec_tracer/storage/json_backend'
5
+ require 'rspec_tracer/storage/schema'
6
+ require 'rspec_tracer/storage/sqlite_backend' if RUBY_ENGINE == 'ruby'
7
+
8
+ module RSpecTracer
9
+ # Internal CLI — see {RSpecTracer} for the user-facing surface.
10
+ # @api private
11
+ module CLI
12
+ # `rspec-tracer explain <example>` — show why a given example is
13
+ # scheduled to run or skip on the next rspec invocation. Backend-
14
+ # agnostic: dispatches through {RSpecTracer::Storage::Backend.build}
15
+ # so `storage_backend :sqlite` resolves the latest run from the
16
+ # meta table instead of the JsonBackend-only `last_run.json` file.
17
+ module Explain
18
+ # @param args [Array<String>] sub-command args. First positional
19
+ # arg is the example_id (or substring) to explain.
20
+ # @param stdout [IO]
21
+ # @param stderr [IO]
22
+ # @return [Integer] exit status (0 = explanation printed,
23
+ # 1 = example not found / cache missing).
24
+ def self.run(args, stdout: $stdout, stderr: $stderr)
25
+ return print_help(stdout) if args.empty? || args.include?('-h') || args.include?('--help')
26
+
27
+ require 'rspec_tracer/load_config'
28
+ cache_path = RSpecTracer.cache_path
29
+
30
+ snapshot = load_snapshot(cache_path, stderr)
31
+ return 1 if snapshot.nil?
32
+
33
+ match = find_example(snapshot.all_examples, args.first)
34
+ return no_match(args.first, snapshot.all_examples, stderr) if match.nil?
35
+
36
+ print_explanation(stdout, match, snapshot)
37
+ 0
38
+ rescue StandardError => e
39
+ stderr.puts "explain: #{e.class}: #{e.message}"
40
+ 1
41
+ end
42
+
43
+ # Internal helper for the tracer pipeline.
44
+ # @api private
45
+ def self.load_snapshot(cache_path, stderr)
46
+ backend = Storage::Backend.build(cache_path: cache_path, configuration: RSpecTracer)
47
+ run_id = backend.last_run_id
48
+ if run_id.nil? || run_id.to_s.empty?
49
+ stderr.puts "explain: no cache yet at #{cache_path} — run rspec first"
50
+ return nil
51
+ end
52
+
53
+ snapshot = backend.load_graph(schema_version: Storage::Schema::CURRENT)
54
+ if snapshot.nil?
55
+ stderr.puts "explain: cache at #{cache_path} is incompatible with this rspec-tracer; next rspec run is cold"
56
+ return nil
57
+ end
58
+
59
+ snapshot
60
+ end
61
+
62
+ # Internal helper for the tracer pipeline.
63
+ # @api private
64
+ def self.no_match(query, all_examples, stderr)
65
+ stderr.puts "explain: no example matching #{query.inspect}"
66
+ stderr.puts " cache has #{all_examples.size} examples; pass an example_id or substring of description"
67
+ 1
68
+ end
69
+
70
+ # Internal helper for the tracer pipeline.
71
+ # @api private
72
+ def self.print_help(stdout)
73
+ stdout.puts <<~HELP
74
+ Usage: rspec-tracer explain <example_id_or_substring>
75
+
76
+ Show why an example is scheduled to run or skip. Matches against
77
+ example_id exactly first, then falls back to a substring match
78
+ on the example's full_description. Backend-aware: works under
79
+ `storage_backend :json` (default) and `storage_backend :sqlite`.
80
+ Requires a prior rspec run.
81
+ HELP
82
+ 0
83
+ end
84
+
85
+ # Internal helper for the tracer pipeline.
86
+ # @api private
87
+ def self.find_example(all_examples, query)
88
+ return all_examples[query] if all_examples.key?(query)
89
+
90
+ all_examples.find do |id, meta|
91
+ meta = {} unless meta.is_a?(::Hash)
92
+ desc = fetch_meta(meta, 'full_description') || fetch_meta(meta, 'description') || ''
93
+ id.include?(query) || desc.include?(query)
94
+ end&.last
95
+ end
96
+
97
+ # Internal helper for the tracer pipeline.
98
+ # @api private
99
+ def self.print_explanation(stdout, meta, snapshot)
100
+ meta = {} unless meta.is_a?(::Hash)
101
+ format_lines(meta).each { |line| stdout.puts line }
102
+ print_dependency_summary(stdout, meta, snapshot)
103
+ end
104
+
105
+ # Internal helper for the tracer pipeline.
106
+ # @api private
107
+ def self.format_lines(meta)
108
+ id = fetch_meta(meta, 'example_id', 'id') || '<unknown>'
109
+ file = fetch_meta(meta, 'rerun_file_name', 'file_name')
110
+ line = fetch_meta(meta, 'rerun_line_number', 'line_number')
111
+ status = dig_meta(meta, 'execution_result', 'status') || fetch_meta(meta, 'status') || 'unknown'
112
+ [
113
+ "id: #{id}",
114
+ "description: #{fetch_meta(meta, 'full_description', 'description')}",
115
+ "location: #{file}:#{line}",
116
+ "last status: #{status}",
117
+ "run reason: #{fetch_meta(meta, 'run_reason') || '<not recorded>'}"
118
+ ]
119
+ end
120
+
121
+ # Look up a key from a Hash, tolerating both String and Symbol
122
+ # storage. Snapshot Hashes round-tripped through JSON yield
123
+ # String keys; the post-#182 msgpack serializer preserves
124
+ # Symbol keys end-to-end, so callers can't assume either shape.
125
+ def self.fetch_meta(meta, *keys)
126
+ keys.each do |k|
127
+ v = meta[k]
128
+ return v unless v.nil?
129
+
130
+ sym_value = meta[k.to_sym]
131
+ return sym_value unless sym_value.nil?
132
+ end
133
+ nil
134
+ end
135
+
136
+ # Look up a nested key from a Hash, tolerating both String and
137
+ # Symbol storage at each level. See {.fetch_meta} for rationale.
138
+ def self.dig_meta(meta, *keys)
139
+ keys.reduce(meta) do |acc, k|
140
+ break nil if acc.nil? || !acc.is_a?(::Hash)
141
+
142
+ acc[k] || acc[k.to_sym]
143
+ end
144
+ end
145
+
146
+ # Internal helper for the tracer pipeline.
147
+ # @api private
148
+ def self.print_dependency_summary(stdout, meta, snapshot)
149
+ id = fetch_meta(meta, 'example_id', 'id')
150
+ deps = snapshot.dependency || {}
151
+ files = Array(deps[id])
152
+ stdout.puts "dependencies: #{files.size} files tracked"
153
+ files.first(10).each { |f| stdout.puts " - #{f}" }
154
+ stdout.puts " ... (#{files.size - 10} more)" if files.size > 10
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecTracer
4
+ # Internal CLI — see {RSpecTracer} for the user-facing surface.
5
+ # @api private
6
+ module CLI
7
+ # `rspec-tracer report:open` — open the HTML report in the default
8
+ # browser. Resolves `report_path/index.html` and dispatches via
9
+ # `open` (macOS) / `xdg-open` (Linux). Falls back to printing the
10
+ # path when no opener is available.
11
+ module ReportOpen
12
+ # @param args [Array<String>] sub-command args (`-h` / `--help`).
13
+ # @param stdout [IO]
14
+ # @param stderr [IO]
15
+ # @return [Integer] exit status (0 = opened or path printed,
16
+ # 1 = report missing).
17
+ def self.run(args, stdout: $stdout, stderr: $stderr)
18
+ return print_help(stdout) if args.include?('-h') || args.include?('--help')
19
+
20
+ require 'rspec_tracer/load_config'
21
+
22
+ report_path = RSpecTracer.report_path
23
+ index_path = File.join(report_path, 'index.html')
24
+
25
+ unless File.file?(index_path)
26
+ stderr.puts "report:open: no report at #{index_path}"
27
+ stderr.puts ' run rspec first to generate the HTML report'
28
+ return 1
29
+ end
30
+
31
+ opener = detect_opener
32
+ if opener.nil?
33
+ stdout.puts "report:open: report at #{index_path}"
34
+ stdout.puts ' no opener detected (open / xdg-open); open the path manually'
35
+ return 0
36
+ end
37
+
38
+ if system(opener, index_path, out: File::NULL, err: File::NULL)
39
+ stdout.puts "report:open: opened #{index_path} via #{opener}"
40
+ 0
41
+ else
42
+ stderr.puts "report:open: failed to launch #{opener} #{index_path}"
43
+ 1
44
+ end
45
+ rescue StandardError => e
46
+ stderr.puts "report:open: #{e.class}: #{e.message}"
47
+ 1
48
+ end
49
+
50
+ # Internal helper for the tracer pipeline.
51
+ # @api private
52
+ def self.print_help(stdout)
53
+ stdout.puts <<~HELP
54
+ Usage: rspec-tracer report:open
55
+
56
+ Open the HTML report (`report_path/index.html`) in the default
57
+ browser. Uses `open` on macOS and `xdg-open` on Linux. Prints
58
+ the path and exits 0 when no opener is available.
59
+ HELP
60
+ 0
61
+ end
62
+
63
+ # Returns the opener binary name on PATH, or nil. Checked in
64
+ # priority order: macOS `open`, then Linux `xdg-open`. Windows
65
+ # is unsupported per `COMPATIBILITY_MATRIX.md`'s explicit drop;
66
+ # users on Windows see the print-the-path fallback.
67
+ def self.detect_opener
68
+ %w[open xdg-open].find { |bin| which(bin) }
69
+ end
70
+
71
+ # Internal helper for the tracer pipeline.
72
+ # @api private
73
+ def self.which(binary)
74
+ found = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
75
+ path = File.join(dir, binary)
76
+ File.file?(path) && File.executable?(path)
77
+ end
78
+ found ? binary : nil
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ # Require the top-level entry to pull in `docile` (used by
6
+ # `Configuration#configure`) plus the autoloads sub-commands rely on.
7
+ # Loading `rspec_tracer` does NOT call `RSpecTracer.start` - the engine
8
+ # stays inert until a user invokes it explicitly.
9
+ require 'rspec_tracer'
10
+
11
+ module RSpecTracer
12
+ # Command-line entry for the `rspec-tracer` binary. Five sub-commands
13
+ # per USER_FACING_SURFACE.md §10: `doctor`, `cache:info`, `cache:clear`,
14
+ # `report:open`, `explain <example>`.
15
+ #
16
+ # The CLI is opt-in — the canonical CI flow continues to go through
17
+ # `rake rspec_tracer:remote_cache:*` tasks per USER_FACING_SURFACE.md
18
+ # §5. Sub-commands operate against the local cache + report
19
+ # directories resolved from the project's `.rspec-tracer` config
20
+ # (loaded lazily on first sub-command dispatch, not at CLI load time —
21
+ # `doctor` deliberately runs without requiring a configured project).
22
+ #
23
+ # @api private
24
+ module CLI
25
+ # Internal constant.
26
+ # @api private
27
+ SUB_COMMANDS = {
28
+ 'doctor' => 'Doctor',
29
+ 'cache:info' => 'CacheInfo',
30
+ 'cache:clear' => 'CacheClear',
31
+ 'report:open' => 'ReportOpen',
32
+ 'explain' => 'Explain'
33
+ }.freeze
34
+
35
+ # CLI entry. Called by `bin/rspec-tracer` with `ARGV`. Wraps every
36
+ # sub-command in a top-level rescue so the binary always exits
37
+ # with a meaningful integer status (0 / 1) instead of a backtrace.
38
+ #
39
+ # @param argv [Array<String>] command-line arguments (excluding
40
+ # the program name)
41
+ # @param stdout [IO] stream for normal output (default `$stdout`)
42
+ # @param stderr [IO] stream for errors / diagnostics (default `$stderr`)
43
+ # @return [Integer] exit status (0 = success, 1 = failure)
44
+ def self.run(argv, stdout: $stdout, stderr: $stderr)
45
+ args = argv.dup
46
+ return print_top_level_help(stdout) if args.empty? || %w[-h --help help].include?(args.first)
47
+ return print_version(stdout) if %w[-v --version].include?(args.first)
48
+
49
+ dispatch(args, stdout: stdout, stderr: stderr)
50
+ rescue StandardError => e
51
+ stderr.puts "rspec-tracer: #{e.class}: #{e.message}"
52
+ 1
53
+ end
54
+
55
+ # Internal helper for the tracer pipeline.
56
+ # @api private
57
+ def self.dispatch(args, stdout:, stderr:)
58
+ sub = args.shift
59
+ klass_name = SUB_COMMANDS[sub]
60
+ return unknown_sub_command(sub, stderr) if klass_name.nil?
61
+
62
+ load_sub_command(klass_name)
63
+ RSpecTracer::CLI.const_get(klass_name).run(args, stdout: stdout, stderr: stderr)
64
+ end
65
+
66
+ # Internal helper for the tracer pipeline.
67
+ # @api private
68
+ def self.unknown_sub_command(sub, stderr)
69
+ stderr.puts "rspec-tracer: unknown sub-command #{sub.inspect}"
70
+ stderr.puts " available: #{SUB_COMMANDS.keys.join(', ')}"
71
+ 1
72
+ end
73
+
74
+ # Internal helper for the tracer pipeline.
75
+ # @api private
76
+ def self.print_top_level_help(stdout)
77
+ stdout.puts <<~HELP
78
+ Usage: rspec-tracer <sub-command> [options]
79
+
80
+ Sub-commands:
81
+ doctor Diagnose rspec-tracer config + environment.
82
+ cache:info Show cache size, last run timestamp, and example counts.
83
+ cache:clear Remove cache, coverage, and report directories.
84
+ report:open Open the HTML report in the default browser.
85
+ explain <id> Show why an example is scheduled to run or skip.
86
+
87
+ Options:
88
+ -h, --help Print this help message.
89
+ -v, --version Print rspec-tracer version.
90
+
91
+ Run `rspec-tracer <sub-command> --help` for sub-command options.
92
+ HELP
93
+ 0
94
+ end
95
+
96
+ # Internal helper for the tracer pipeline.
97
+ # @api private
98
+ def self.print_version(stdout)
99
+ stdout.puts "rspec-tracer #{RSpecTracer::VERSION}"
100
+ 0
101
+ end
102
+
103
+ # Internal helper for the tracer pipeline.
104
+ # @api private
105
+ def self.load_sub_command(klass_name)
106
+ filename = klass_name.gsub(/([A-Z])/) { |m| "_#{m.downcase}" }.sub(/^_/, '')
107
+ require_relative "cli/#{filename}"
108
+ rescue LoadError => e
109
+ # LoadError isn't a StandardError, so the outer `rescue StandardError`
110
+ # in `.run` wouldn't catch it. Re-raise as StandardError so the dispatch
111
+ # path stays uniform: any sub-command resolution failure prints
112
+ # `rspec-tracer: <class>: <message>` and exits 1.
113
+ raise StandardError, "could not load sub-command #{klass_name.inspect}: #{e.message}"
114
+ end
115
+ end
116
+ end