simplecov-mcp 0.3.0 → 1.0.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +173 -356
  4. data/docs/ADVANCED_USAGE.md +967 -0
  5. data/docs/ARCHITECTURE.md +79 -0
  6. data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
  7. data/docs/CLI_USAGE.md +637 -0
  8. data/docs/DEVELOPMENT.md +82 -0
  9. data/docs/ERROR_HANDLING.md +93 -0
  10. data/docs/EXAMPLES.md +430 -0
  11. data/docs/INSTALLATION.md +352 -0
  12. data/docs/LIBRARY_API.md +635 -0
  13. data/docs/MCP_INTEGRATION.md +488 -0
  14. data/docs/TROUBLESHOOTING.md +276 -0
  15. data/docs/arch-decisions/001-x-arch-decision.md +93 -0
  16. data/docs/arch-decisions/002-x-arch-decision.md +157 -0
  17. data/docs/arch-decisions/003-x-arch-decision.md +163 -0
  18. data/docs/arch-decisions/004-x-arch-decision.md +199 -0
  19. data/docs/arch-decisions/005-x-arch-decision.md +187 -0
  20. data/docs/arch-decisions/README.md +60 -0
  21. data/docs/presentations/simplecov-mcp-presentation.md +249 -0
  22. data/exe/simplecov-mcp +4 -4
  23. data/lib/simplecov_mcp/app_context.rb +26 -0
  24. data/lib/simplecov_mcp/base_tool.rb +74 -0
  25. data/lib/simplecov_mcp/cli.rb +234 -0
  26. data/lib/simplecov_mcp/cli_config.rb +56 -0
  27. data/lib/simplecov_mcp/commands/base_command.rb +78 -0
  28. data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
  29. data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
  30. data/lib/simplecov_mcp/commands/list_command.rb +13 -0
  31. data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
  32. data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
  33. data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
  34. data/lib/simplecov_mcp/commands/version_command.rb +18 -0
  35. data/lib/simplecov_mcp/constants.rb +22 -0
  36. data/lib/simplecov_mcp/error_handler.rb +124 -0
  37. data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
  38. data/lib/simplecov_mcp/errors.rb +179 -0
  39. data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
  40. data/lib/simplecov_mcp/mcp_server.rb +40 -0
  41. data/lib/simplecov_mcp/mode_detector.rb +55 -0
  42. data/lib/simplecov_mcp/model.rb +300 -0
  43. data/lib/simplecov_mcp/option_normalizers.rb +92 -0
  44. data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
  45. data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
  46. data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
  47. data/lib/simplecov_mcp/path_relativizer.rb +61 -0
  48. data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
  49. data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
  50. data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
  51. data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
  52. data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
  53. data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
  54. data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
  55. data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
  56. data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
  57. data/lib/simplecov_mcp/resultset_loader.rb +136 -0
  58. data/lib/simplecov_mcp/staleness_checker.rb +243 -0
  59. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
  60. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
  61. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
  62. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
  63. data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
  64. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
  65. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
  66. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
  67. data/lib/simplecov_mcp/util.rb +82 -0
  68. data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
  69. data/lib/simplecov_mcp.rb +144 -2
  70. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  71. data/spec/TIMESTAMPS.md +48 -0
  72. data/spec/all_files_coverage_tool_spec.rb +29 -25
  73. data/spec/base_tool_spec.rb +11 -10
  74. data/spec/cli/show_default_report_spec.rb +33 -0
  75. data/spec/cli_config_spec.rb +137 -0
  76. data/spec/cli_enumerated_options_spec.rb +68 -0
  77. data/spec/cli_error_spec.rb +105 -47
  78. data/spec/cli_source_spec.rb +82 -23
  79. data/spec/cli_spec.rb +140 -5
  80. data/spec/cli_success_predicate_spec.rb +141 -0
  81. data/spec/cli_table_spec.rb +1 -1
  82. data/spec/cli_usage_spec.rb +10 -26
  83. data/spec/commands/base_command_spec.rb +187 -0
  84. data/spec/commands/command_factory_spec.rb +72 -0
  85. data/spec/commands/detailed_command_spec.rb +48 -0
  86. data/spec/commands/raw_command_spec.rb +46 -0
  87. data/spec/commands/summary_command_spec.rb +47 -0
  88. data/spec/commands/uncovered_command_spec.rb +49 -0
  89. data/spec/constants_spec.rb +61 -0
  90. data/spec/coverage_table_tool_spec.rb +17 -33
  91. data/spec/error_handler_spec.rb +22 -13
  92. data/spec/error_mode_spec.rb +143 -0
  93. data/spec/errors_edge_cases_spec.rb +239 -0
  94. data/spec/errors_stale_spec.rb +2 -2
  95. data/spec/file_based_mcp_tools_spec.rb +99 -0
  96. data/spec/fixtures/project1/lib/bar.rb +0 -1
  97. data/spec/fixtures/project1/lib/foo.rb +0 -1
  98. data/spec/help_tool_spec.rb +11 -17
  99. data/spec/integration_spec.rb +845 -0
  100. data/spec/logging_fallback_spec.rb +128 -0
  101. data/spec/mcp_logging_spec.rb +44 -0
  102. data/spec/mcp_server_integration_spec.rb +23 -0
  103. data/spec/mcp_server_spec.rb +15 -4
  104. data/spec/mode_detector_spec.rb +148 -0
  105. data/spec/model_error_handling_spec.rb +210 -0
  106. data/spec/model_staleness_spec.rb +40 -10
  107. data/spec/option_normalizers_spec.rb +204 -0
  108. data/spec/option_parsers/env_options_parser_spec.rb +233 -0
  109. data/spec/option_parsers/error_helper_spec.rb +222 -0
  110. data/spec/path_relativizer_spec.rb +83 -0
  111. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  112. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  113. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  114. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  115. data/spec/presenters/project_coverage_presenter_spec.rb +86 -0
  116. data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
  117. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  118. data/spec/resolvers/resultset_path_resolver_spec.rb +55 -0
  119. data/spec/resultset_loader_spec.rb +167 -0
  120. data/spec/shared_examples/README.md +115 -0
  121. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  122. data/spec/shared_examples/file_based_mcp_tools.rb +174 -0
  123. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  124. data/spec/simple_cov_mcp_module_spec.rb +16 -0
  125. data/spec/simplecov_mcp_model_spec.rb +340 -9
  126. data/spec/simplecov_mcp_opts_spec.rb +182 -0
  127. data/spec/spec_helper.rb +147 -4
  128. data/spec/staleness_checker_spec.rb +373 -0
  129. data/spec/staleness_more_spec.rb +16 -13
  130. data/spec/support/mcp_runner.rb +64 -0
  131. data/spec/tools_error_handling_spec.rb +144 -0
  132. data/spec/util_spec.rb +109 -34
  133. data/spec/version_spec.rb +117 -9
  134. data/spec/version_tool_spec.rb +131 -10
  135. metadata +120 -63
  136. data/lib/simple_cov/mcp.rb +0 -9
  137. data/lib/simple_cov_mcp/base_tool.rb +0 -70
  138. data/lib/simple_cov_mcp/cli.rb +0 -390
  139. data/lib/simple_cov_mcp/error_handler.rb +0 -131
  140. data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
  141. data/lib/simple_cov_mcp/errors.rb +0 -176
  142. data/lib/simple_cov_mcp/mcp_server.rb +0 -30
  143. data/lib/simple_cov_mcp/model.rb +0 -104
  144. data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
  145. data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
  146. data/lib/simple_cov_mcp/util.rb +0 -122
  147. data/lib/simple_cov_mcp.rb +0 -102
  148. data/spec/coverage_detailed_tool_spec.rb +0 -36
  149. data/spec/coverage_raw_tool_spec.rb +0 -32
  150. data/spec/coverage_summary_tool_spec.rb +0 -39
  151. data/spec/legacy_shim_spec.rb +0 -13
  152. data/spec/uncovered_lines_tool_spec.rb +0 -33
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'json'
5
+
6
+ require_relative 'util'
7
+ require_relative 'errors'
8
+ require_relative 'staleness_checker'
9
+ require_relative 'path_relativizer'
10
+ require_relative 'resultset_loader'
11
+
12
+ module SimpleCovMcp
13
+ class CoverageModel
14
+ RELATIVIZER_SCALAR_KEYS = %w[file file_path].freeze
15
+ RELATIVIZER_ARRAY_KEYS = %w[newer_files missing_files deleted_files].freeze
16
+
17
+ attr_reader :relativizer
18
+
19
+ # Create a CoverageModel
20
+ #
21
+ # Params:
22
+ # - root: project root directory (default '.')
23
+ # - resultset: path or directory to .resultset.json
24
+ # - staleness: 'off' or 'error' (default 'off'). When 'error', raises
25
+ # stale errors if sources are newer than coverage or line counts mismatch.
26
+ # - tracked_globs: only used for all_files project-level staleness.
27
+ def initialize(root: '.', resultset: nil, staleness: 'off', tracked_globs: nil)
28
+ @root = File.absolute_path(root || '.')
29
+ @resultset = resultset
30
+ @relativizer = PathRelativizer.new(
31
+ root: @root,
32
+ scalar_keys: RELATIVIZER_SCALAR_KEYS,
33
+ array_keys: RELATIVIZER_ARRAY_KEYS
34
+ )
35
+
36
+ begin
37
+ rs = CovUtil.find_resultset(@root, resultset: resultset)
38
+ loaded = ResultsetLoader.load(resultset_path: rs)
39
+ coverage_map = loaded.coverage_map or raise CoverageDataError.new("No 'coverage' key found in resultset file: #{rs}")
40
+
41
+ @cov = coverage_map.transform_keys { |k| File.absolute_path(k, @root) }
42
+ @cov_timestamp = loaded.timestamp
43
+
44
+ @checker = StalenessChecker.new(
45
+ root: @root,
46
+ resultset: @resultset,
47
+ mode: staleness,
48
+ tracked_globs: tracked_globs,
49
+ timestamp: @cov_timestamp
50
+ )
51
+ rescue Errno::ENOENT => e
52
+ raise FileError.new("Coverage data not found at #{resultset || @root}")
53
+ rescue JSON::ParserError => e
54
+ raise CoverageDataError.new("Invalid coverage data format: #{e.message}")
55
+ rescue Errno::EACCES => e
56
+ raise FilePermissionError.new("Permission denied reading coverage data: #{e.message}")
57
+ rescue TypeError, NoMethodError => e
58
+ # These typically indicate the resultset has an unexpected structure
59
+ raise CoverageDataError.new("Invalid coverage data structure: #{e.message}")
60
+ rescue ArgumentError => e
61
+ # ArgumentError can occur from File.absolute_path or other path operations
62
+ raise CoverageDataError.new("Invalid path in coverage data: #{e.message}")
63
+ rescue RuntimeError => e
64
+ # RuntimeError from find_resultset or other operations
65
+ raise CoverageDataError.new("Failed to load coverage data: #{e.message}")
66
+ end
67
+ end
68
+
69
+ # Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
70
+ def raw_for(path)
71
+ file_abs, coverage_lines = resolve(path)
72
+ { 'file' => file_abs, 'lines' => coverage_lines }
73
+ end
74
+
75
+ def relativize(payload)
76
+ relativizer.relativize(payload)
77
+ end
78
+
79
+ # Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'pct'=>} }
80
+ def summary_for(path)
81
+ file_abs, coverage_lines = resolve(path)
82
+ { 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
83
+ end
84
+
85
+ # Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
86
+ def uncovered_for(path)
87
+ file_abs, coverage_lines = resolve(path)
88
+ {
89
+ 'file' => file_abs,
90
+ 'uncovered' => CovUtil.uncovered(coverage_lines),
91
+ 'summary' => CovUtil.summary(coverage_lines)
92
+ }
93
+ end
94
+
95
+ # Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
96
+ def detailed_for(path)
97
+ file_abs, coverage_lines = resolve(path)
98
+ {
99
+ 'file' => file_abs,
100
+ 'lines' => CovUtil.detailed(coverage_lines),
101
+ 'summary' => CovUtil.summary(coverage_lines)
102
+ }
103
+ end
104
+
105
+ # Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
106
+ def all_files(sort_order: :ascending, check_stale: !@checker.off?, tracked_globs: nil)
107
+ stale_checker = build_staleness_checker(mode: 'off', tracked_globs: tracked_globs)
108
+
109
+ rows = @cov.map do |abs_path, _data|
110
+ begin
111
+ coverage_lines = CovUtil.lookup_lines(@cov, abs_path)
112
+ rescue FileError
113
+ next
114
+ end
115
+
116
+ s = CovUtil.summary(coverage_lines)
117
+ stale = stale_checker.stale_for_file?(abs_path, coverage_lines)
118
+ {
119
+ 'file' => abs_path,
120
+ 'covered' => s['covered'],
121
+ 'total' => s['total'],
122
+ 'percentage' => s['pct'],
123
+ 'stale' => stale
124
+ }
125
+ end.compact
126
+
127
+ rows = filter_rows_by_globs(rows, tracked_globs)
128
+
129
+ if check_stale
130
+ build_staleness_checker(mode: 'error', tracked_globs: tracked_globs).check_project!(@cov)
131
+ end
132
+
133
+ sort_rows(rows, sort_order: sort_order)
134
+ end
135
+
136
+ def staleness_for(path)
137
+ file_abs = File.absolute_path(path, @root)
138
+ coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
139
+ @checker.stale_for_file?(file_abs, coverage_lines)
140
+ rescue StandardError => e
141
+ # Log the error if possible
142
+ CovUtil.log("Failed to check staleness for #{path}: #{e.message}") rescue nil
143
+ false
144
+ end
145
+
146
+ # Returns formatted table string for all files coverage data
147
+ def format_table(rows = nil, sort_order: :ascending, check_stale: !@checker.off?,
148
+ tracked_globs: nil)
149
+ rows = prepare_rows(rows, sort_order: sort_order, check_stale: check_stale,
150
+ tracked_globs: tracked_globs)
151
+ return 'No coverage data found' if rows.empty?
152
+
153
+ widths = compute_table_widths(rows)
154
+ lines = []
155
+ lines << border_line(widths, '┌', '┬', '┐')
156
+ lines << header_row(widths)
157
+ lines << border_line(widths, '├', '┼', '┤')
158
+ rows.each { |file_data| lines << data_row(file_data, widths) }
159
+ lines << border_line(widths, '└', '┴', '┘')
160
+ lines << summary_counts(rows)
161
+ if rows.any? { |f| f['stale'] }
162
+ lines <<
163
+ 'Staleness: M = Missing file, T = Timestamp (source newer), L = Line count mismatch'
164
+ end
165
+ lines.join("\n")
166
+ end
167
+
168
+ private
169
+
170
+ def build_staleness_checker(mode:, tracked_globs:)
171
+ StalenessChecker.new(
172
+ root: @root,
173
+ resultset: @resultset,
174
+ mode: mode,
175
+ tracked_globs: tracked_globs,
176
+ timestamp: @cov_timestamp
177
+ )
178
+ end
179
+
180
+ def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
181
+ if rows.nil?
182
+ all_files(sort_order: sort_order, check_stale: check_stale, tracked_globs: tracked_globs)
183
+ else
184
+ rows = sort_rows(rows.dup, sort_order: sort_order)
185
+ filter_rows_by_globs(rows, tracked_globs)
186
+ end
187
+ end
188
+
189
+ def sort_rows(rows, sort_order: :ascending)
190
+ rows.sort do |a, b|
191
+ pct_cmp = (sort_order == :descending) \
192
+ ? (b['percentage'] <=> a['percentage'])
193
+ : (a['percentage'] <=> b['percentage'])
194
+ pct_cmp == 0 ? (a['file'] <=> b['file']) : pct_cmp
195
+ end
196
+ end
197
+
198
+ def compute_table_widths(rows)
199
+ max_file_length = rows.map { |f| f['file'].length }.max.to_i
200
+ file_width = [max_file_length, 'File'.length].max + 2
201
+ pct_width = 8
202
+ max_covered = rows.map { |f| f['covered'].to_s.length }.max
203
+ max_total = rows.map { |f| f['total'].to_s.length }.max
204
+ covered_width = [max_covered, 'Covered'.length].max + 2
205
+ total_width = [max_total, 'Total'.length].max + 2
206
+ stale_width = 'Stale'.length
207
+ {
208
+ file: file_width,
209
+ pct: pct_width,
210
+ covered: covered_width,
211
+ total: total_width,
212
+ stale: stale_width
213
+ }
214
+ end
215
+
216
+ def border_line(widths, left, middle, right)
217
+ h_line = ->(col_width) { '─' * (col_width + 2) }
218
+ left +
219
+ h_line.call(widths[:file]) +
220
+ middle + h_line.call(widths[:pct]) +
221
+ middle + h_line.call(widths[:covered]) +
222
+ middle + h_line.call(widths[:total]) +
223
+ middle + h_line.call(widths[:stale]) +
224
+ right
225
+ end
226
+
227
+ def header_row(widths)
228
+ sprintf(
229
+ "│ %-#{widths[:file]}s │ %#{widths[:pct]}s │ %#{widths[:covered]}s │ %#{widths[:total]}s │ %#{widths[:stale]}s │",
230
+ 'File', ' %', 'Covered', 'Total', 'Stale'.center(widths[:stale])
231
+ )
232
+ end
233
+
234
+ def data_row(file_data, widths)
235
+ stale_text_str = file_data['stale'] ? file_data['stale'].to_s : ''
236
+ sprintf(
237
+ "│ %-#{widths[:file]}s │ %#{widths[:pct] - 1}.2f%% │ %#{widths[:covered]}d │ %#{widths[:total]}d │ %#{widths[:stale]}s │",
238
+ file_data['file'],
239
+ file_data['percentage'],
240
+ file_data['covered'],
241
+ file_data['total'],
242
+ stale_text_str.center(widths[:stale])
243
+ )
244
+ end
245
+
246
+ def summary_counts(rows)
247
+ total = rows.length
248
+ stale_count = rows.count { |f| f['stale'] }
249
+ ok_count = total - stale_count
250
+ "Files: total #{total}, ok #{ok_count}, stale #{stale_count}"
251
+ end
252
+
253
+ def filter_rows_by_globs(rows, tracked_globs)
254
+ patterns = Array(tracked_globs).compact.map(&:to_s).reject(&:empty?)
255
+ return rows if patterns.empty?
256
+
257
+ root_pathname = Pathname.new(@root)
258
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
259
+
260
+ rows.select do |row|
261
+ abs_path = row['file']
262
+ rel_path = begin
263
+ Pathname.new(abs_path).relative_path_from(root_pathname).to_s
264
+ rescue ArgumentError
265
+ abs_path
266
+ end
267
+
268
+ patterns.any? do |pattern|
269
+ target = Pathname.new(pattern).absolute? ? abs_path : rel_path
270
+ File.fnmatch?(pattern, target, flags)
271
+ end
272
+ end
273
+ end
274
+
275
+ def resolve(path)
276
+ file_abs = File.absolute_path(path, @root)
277
+ begin
278
+ coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
279
+ rescue RuntimeError => e
280
+ raise FileError.new("No coverage data found for file: #{path}")
281
+ end
282
+ @checker.check_file!(file_abs, coverage_lines) unless @checker.off?
283
+ if coverage_lines.nil?
284
+ raise FileError.new("No coverage data found for file: #{path}")
285
+ end
286
+
287
+ [file_abs, coverage_lines]
288
+ rescue Errno::ENOENT => e
289
+ raise FileNotFoundError.new("File not found: #{path}")
290
+ end
291
+
292
+ # staleness handled by StalenessChecker
293
+
294
+ def check_all_files_staleness!(cov_timestamp, tracked_globs: nil)
295
+ # handled by StalenessChecker
296
+ end
297
+
298
+ # Detailed stale message construction moved to CoverageDataStaleError
299
+ end
300
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ # Shared normalization logic for CLI options.
5
+ # Provides both strict (raise on invalid) and lenient (default on invalid) modes.
6
+ module OptionNormalizers
7
+ SORT_ORDER_MAP = {
8
+ 'a' => :ascending,
9
+ 'ascending' => :ascending,
10
+ 'd' => :descending,
11
+ 'descending' => :descending
12
+ }.freeze
13
+
14
+ SOURCE_MODE_MAP = {
15
+ 'f' => :full,
16
+ 'full' => :full,
17
+ 'u' => :uncovered,
18
+ 'uncovered' => :uncovered
19
+ }.freeze
20
+
21
+ STALE_MODE_MAP = {
22
+ 'o' => :off,
23
+ 'off' => :off,
24
+ 'e' => :error,
25
+ 'error' => :error
26
+ }.freeze
27
+
28
+ ERROR_MODE_MAP = {
29
+ 'off' => :off,
30
+ 'on' => :on,
31
+ 't' => :trace,
32
+ 'trace' => :trace
33
+ }.freeze
34
+
35
+ module_function
36
+
37
+ # Normalize sort order value.
38
+ # @param value [String, Symbol] The value to normalize
39
+ # @param strict [Boolean] If true, raises on invalid value; if false, returns nil
40
+ # @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
41
+ # @raise [OptionParser::InvalidArgument] If strict and value is invalid
42
+ def normalize_sort_order(value, strict: true)
43
+ normalized = SORT_ORDER_MAP[value.to_s.downcase]
44
+ return normalized if normalized
45
+ raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
46
+
47
+ nil
48
+ end
49
+
50
+ # Normalize source mode value.
51
+ # @param value [String, Symbol, nil] The value to normalize
52
+ # @param strict [Boolean] If true, raises on invalid value; if false, returns nil
53
+ # @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
54
+ # @raise [OptionParser::InvalidArgument] If strict and value is invalid
55
+ def normalize_source_mode(value, strict: true)
56
+ return :full if value.nil? || value == ''
57
+
58
+ normalized = SOURCE_MODE_MAP[value.to_s.downcase]
59
+ return normalized if normalized
60
+ raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
61
+
62
+ nil
63
+ end
64
+
65
+ # Normalize stale mode value.
66
+ # @param value [String, Symbol] The value to normalize
67
+ # @param strict [Boolean] If true, raises on invalid value; if false, returns nil
68
+ # @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
69
+ # @raise [OptionParser::InvalidArgument] If strict and value is invalid
70
+ def normalize_stale_mode(value, strict: true)
71
+ normalized = STALE_MODE_MAP[value.to_s.downcase]
72
+ return normalized if normalized
73
+ raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
74
+
75
+ nil
76
+ end
77
+
78
+ # Normalize error mode value.
79
+ # @param value [String, Symbol, nil] The value to normalize
80
+ # @param strict [Boolean] If true, raises on invalid value; if false, returns default
81
+ # @param default [Symbol] The default value to return if invalid and not strict
82
+ # @return [Symbol] The normalized symbol or default if invalid and not strict
83
+ # @raise [OptionParser::InvalidArgument] If strict and value is invalid
84
+ def normalize_error_mode(value, strict: true, default: :on)
85
+ normalized = ERROR_MODE_MAP[value&.downcase]
86
+ return normalized if normalized
87
+ raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
88
+
89
+ default
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'option_normalizers'
4
+
5
+ module SimpleCovMcp
6
+ class OptionParserBuilder
7
+ HORIZONTAL_RULE = '-' * 79
8
+ SUBCOMMANDS = %w[list summary raw uncovered detailed version].freeze
9
+
10
+ attr_reader :config
11
+
12
+ def initialize(config)
13
+ @config = config
14
+ end
15
+
16
+ def build_option_parser
17
+ require 'optparse'
18
+ OptionParser.new do |o|
19
+ configure_banner(o)
20
+ define_subcommands_help(o)
21
+ define_options(o)
22
+ define_examples(o)
23
+ add_help_handler(o)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def configure_banner(o)
30
+ o.banner = <<~BANNER
31
+ #{HORIZONTAL_RULE}
32
+ Usage: simplecov-mcp [subcommand] [options] [args]
33
+ Repository: https://github.com/keithrbennett/simplecov-mcp
34
+ Version: #{SimpleCovMcp::VERSION}
35
+ #{HORIZONTAL_RULE}
36
+
37
+ BANNER
38
+ end
39
+
40
+ def define_subcommands_help(o)
41
+ o.separator <<~SUBCOMMANDS
42
+ Subcommands:
43
+ list Show files coverage (table or --json)
44
+ summary <path> Show covered/total/% for a file
45
+ raw <path> Show the SimpleCov 'lines' array
46
+ uncovered <path> Show uncovered lines and a summary
47
+ detailed <path> Show per-line rows with hits/covered
48
+ version Show version information
49
+
50
+ SUBCOMMANDS
51
+ end
52
+
53
+ def define_options(o)
54
+ o.separator 'Options:'
55
+ o.on('-r', '--resultset PATH', String,
56
+ 'Path or directory that contains .resultset.json (default: coverage/.resultset.json)') \
57
+ do |v|
58
+ config.resultset = v
59
+ end
60
+ o.on('-R', '--root PATH', String, 'Project root (default: .)') { |v| config.root = v }
61
+ o.on('-j', '--json', 'Output JSON for machine consumption') { config.json = true }
62
+ o.on('-o', '--sort-order ORDER', String,
63
+ 'Sort order for list: a[scending]|d[escending] (default ascending)') do |v|
64
+ config.sort_order = normalize_sort_order(v)
65
+ end
66
+ o.on('-s', '--source[=MODE]', String,
67
+ 'Include source (MODE: f[ull]|u[ncovered]; default full)') do |v|
68
+ config.source_mode = normalize_source_mode(v)
69
+ end
70
+ o.on('-c', '--source-context N', Integer,
71
+ 'For --source=uncovered, show N context lines (default: 2)') do |v|
72
+ config.source_context = v
73
+ end
74
+ o.on('--color', 'Enable ANSI colors for source output') { config.color = true }
75
+ o.on('--no-color', 'Disable ANSI colors') { config.color = false }
76
+ o.on('-S', '--stale MODE', String,
77
+ 'Staleness mode: o[ff]|e[rror] (default off)') do |v|
78
+ config.stale_mode = normalize_stale_mode(v)
79
+ end
80
+ o.on('-g', '--tracked-globs x,y,z', Array,
81
+ 'Globs for filtering files (list subcommand)') do |v|
82
+ config.tracked_globs = v
83
+ end
84
+ o.on('-l', '--log-file PATH', String,
85
+ 'Log file path (default ./simplecov_mcp.log, use stdout/stderr for streams)') do |v|
86
+ config.log_file = v
87
+ end
88
+ o.on('--error-mode MODE', String,
89
+ 'Error handling mode: off|on|t[trace] (default on)') do |v|
90
+ config.error_mode = normalize_error_mode(v)
91
+ end
92
+ o.on('--force-cli', 'Force CLI mode (useful in scripts where auto-detection fails)') do
93
+ # This flag is mainly for mode detection - no action needed here
94
+ end
95
+ o.on('--success-predicate FILE', String,
96
+ 'Ruby file returning callable; exits 0 if truthy, 1 if falsy') do |v|
97
+ config.success_predicate = v
98
+ end
99
+ end
100
+
101
+ def define_examples(o)
102
+ o.separator <<~EXAMPLES
103
+
104
+ Examples:
105
+ simplecov-mcp list --resultset coverage
106
+ simplecov-mcp summary lib/foo.rb --json --resultset coverage
107
+ simplecov-mcp uncovered lib/foo.rb --source=uncovered --source-context 2
108
+ EXAMPLES
109
+ end
110
+
111
+ def add_help_handler(o)
112
+ o.on('-h', '--help', 'Show help') do
113
+ puts o
114
+ exit 0
115
+ end
116
+ end
117
+
118
+ def normalize_sort_order(v)
119
+ OptionNormalizers.normalize_sort_order(v, strict: true)
120
+ end
121
+
122
+ def normalize_source_mode(v)
123
+ OptionNormalizers.normalize_source_mode(v, strict: true)
124
+ end
125
+
126
+ def normalize_stale_mode(v)
127
+ OptionNormalizers.normalize_stale_mode(v, strict: true)
128
+ end
129
+
130
+ def normalize_error_mode(v)
131
+ OptionNormalizers.normalize_error_mode(v, strict: true)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require_relative '../option_normalizers'
5
+
6
+ module SimpleCovMcp
7
+ module OptionParsers
8
+ class EnvOptionsParser
9
+ ENV_VAR = 'SIMPLECOV_MCP_OPTS'
10
+
11
+ def initialize(env_var: ENV_VAR)
12
+ @env_var = env_var
13
+ end
14
+
15
+ def parse_env_opts
16
+ opts_string = ENV[@env_var]
17
+ return [] unless opts_string && !opts_string.empty?
18
+
19
+ begin
20
+ Shellwords.split(opts_string)
21
+ rescue ArgumentError => e
22
+ raise SimpleCovMcp::ConfigurationError, "Invalid #{@env_var} format: #{e.message}"
23
+ end
24
+ end
25
+
26
+ def pre_scan_error_mode(argv, error_mode_normalizer: method(:normalize_error_mode))
27
+ # Quick scan for --error-mode to ensure early errors are logged correctly
28
+ argv.each_with_index do |arg, i|
29
+ if arg == '--error-mode' && argv[i + 1]
30
+ return error_mode_normalizer.call(argv[i + 1])
31
+ elsif arg.start_with?('--error-mode=')
32
+ value = arg.split('=', 2)[1]
33
+ return nil if value.to_s.empty?
34
+ return error_mode_normalizer.call(value) if value
35
+ end
36
+ end
37
+ nil
38
+ rescue StandardError
39
+ # Ignore errors during pre-scan; they'll be caught during actual parsing
40
+ nil
41
+ end
42
+
43
+ private
44
+
45
+ def normalize_error_mode(value)
46
+ OptionNormalizers.normalize_error_mode(value, strict: false, default: :on)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ module OptionParsers
5
+ class ErrorHelper
6
+ SUBCOMMANDS = %w[list summary raw uncovered detailed version].freeze
7
+
8
+ def initialize(subcommands = SUBCOMMANDS)
9
+ @subcommands = subcommands
10
+ end
11
+
12
+ def handle_option_parser_error(error, argv: [], usage_hint: "Run '#{program_name} --help' for usage information.")
13
+ message = error.message.to_s
14
+ # Suggest a subcommand when an invalid option matches a known subcommand
15
+ option = extract_invalid_option(message)
16
+
17
+ if option && option.start_with?('--') && @subcommands.include?(option[2..-1])
18
+ suggest_subcommand(option)
19
+ else
20
+ # Generic message from OptionParser
21
+ warn "Error: #{message}"
22
+ # Attempt to derive a helpful hint for enumerated options
23
+ if (hint = build_enum_value_hint(argv))
24
+ warn hint
25
+ end
26
+ end
27
+ warn usage_hint
28
+ exit 1
29
+ end
30
+
31
+ private
32
+
33
+ def extract_invalid_option(message)
34
+ message.match(/invalid option: (.+)/)[1] rescue nil
35
+ end
36
+
37
+ def suggest_subcommand(option)
38
+ subcommand = option[2..-1]
39
+ warn "Error: '#{option}' is not a valid option. Did you mean the '#{subcommand}' subcommand?"
40
+ warn "Try: #{program_name} #{subcommand} [args]"
41
+ end
42
+
43
+ def build_enum_value_hint(argv)
44
+ rules = enumerated_option_rules
45
+ tokens = Array(argv)
46
+ rules.each do |rule|
47
+ hint = build_hint_for_rule(rule, tokens)
48
+ return hint if hint
49
+ end
50
+ nil
51
+ end
52
+
53
+ def build_hint_for_rule(rule, tokens)
54
+ switches = rule[:switches]
55
+ allowed = rule[:values]
56
+ display = rule[:display] || allowed.join(', ')
57
+ preferred = switches.find { |s| s.start_with?('--') } || switches.first
58
+
59
+ tokens.each_with_index do |tok, i|
60
+ # --opt=value form
61
+ if equal_form_match?(tok, switches, preferred)
62
+ hint = handle_equal_form(tok, switches, preferred, display, allowed)
63
+ return hint if hint
64
+ end
65
+
66
+ # --opt value or -o value form
67
+ if switches.include?(tok)
68
+ hint = handle_space_form(tokens, i, preferred, display, allowed)
69
+ return hint if hint
70
+ end
71
+ end
72
+ nil
73
+ end
74
+
75
+ def equal_form_match?(token, switches, preferred)
76
+ token.start_with?(preferred + '=') || switches.any? { |s| token.start_with?(s + '=') }
77
+ end
78
+
79
+ def handle_equal_form(token, switches, preferred, display, allowed)
80
+ sw = switches.find { |s| token.start_with?(s + '=') } || preferred
81
+ val = token.split('=', 2)[1]
82
+ "Valid values for #{sw}: #{display}" if val && !allowed.include?(val)
83
+ end
84
+
85
+ def handle_space_form(tokens, index, preferred, display, allowed)
86
+ val = tokens[index + 1]
87
+ # If missing value, provide hint; if present and invalid, also hint
88
+ if val.nil? || val.start_with?('-') || !allowed.include?(val)
89
+ "Valid values for #{preferred}: #{display}"
90
+ end
91
+ end
92
+
93
+ def enumerated_option_rules
94
+ [
95
+ { switches: ['-S', '--stale'], values: %w[off o error e], display: 'o[ff]|e[rror]' },
96
+ { switches: ['-s', '--source'], values: %w[full f uncovered u],
97
+ display: 'f[ull]|u[ncovered]' },
98
+ { switches: ['--error-mode'], values: %w[off on trace t], display: 'off|on|t[race]' },
99
+ { switches: ['-o', '--sort-order'], values: %w[a d ascending descending],
100
+ display: 'a[scending]|d[escending]' }
101
+ ]
102
+ end
103
+
104
+ def program_name
105
+ 'simplecov-mcp'
106
+ end
107
+ end
108
+ end
109
+ end