simplecov-mcp 0.1.0 → 0.2.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +379 -32
  3. data/exe/simplecov-mcp +19 -2
  4. data/lib/simple_cov/mcp.rb +9 -0
  5. data/lib/simple_cov_mcp/base_tool.rb +54 -0
  6. data/lib/simple_cov_mcp/cli.rb +390 -0
  7. data/lib/simple_cov_mcp/error_handler.rb +131 -0
  8. data/lib/simple_cov_mcp/error_handler_factory.rb +38 -0
  9. data/lib/simple_cov_mcp/errors.rb +176 -0
  10. data/lib/simple_cov_mcp/mcp_server.rb +30 -0
  11. data/lib/simple_cov_mcp/model.rb +104 -0
  12. data/lib/simple_cov_mcp/staleness_checker.rb +125 -0
  13. data/lib/simple_cov_mcp/tools/all_files_coverage_tool.rb +63 -0
  14. data/lib/simple_cov_mcp/tools/coverage_detailed_tool.rb +29 -0
  15. data/lib/simple_cov_mcp/tools/coverage_raw_tool.rb +29 -0
  16. data/lib/simple_cov_mcp/tools/coverage_summary_tool.rb +29 -0
  17. data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +61 -0
  18. data/lib/simple_cov_mcp/tools/help_tool.rb +133 -0
  19. data/lib/simple_cov_mcp/tools/uncovered_lines_tool.rb +29 -0
  20. data/lib/simple_cov_mcp/tools/version_tool.rb +31 -0
  21. data/lib/simple_cov_mcp/util.rb +122 -0
  22. data/lib/simple_cov_mcp/version.rb +5 -0
  23. data/lib/simple_cov_mcp.rb +102 -0
  24. data/lib/simplecov_mcp.rb +2 -3
  25. data/spec/all_files_coverage_tool_spec.rb +46 -0
  26. data/spec/base_tool_spec.rb +58 -0
  27. data/spec/cli_error_spec.rb +103 -0
  28. data/spec/cli_json_source_spec.rb +92 -0
  29. data/spec/cli_source_spec.rb +37 -0
  30. data/spec/cli_spec.rb +72 -0
  31. data/spec/cli_table_spec.rb +28 -0
  32. data/spec/cli_usage_spec.rb +58 -0
  33. data/spec/coverage_table_tool_spec.rb +64 -0
  34. data/spec/error_handler_spec.rb +72 -0
  35. data/spec/errors_stale_spec.rb +49 -0
  36. data/spec/fixtures/project1/lib/bar.rb +4 -0
  37. data/spec/fixtures/project1/lib/foo.rb +5 -0
  38. data/spec/help_tool_spec.rb +47 -0
  39. data/spec/legacy_shim_spec.rb +13 -0
  40. data/spec/mcp_server_spec.rb +78 -0
  41. data/spec/model_staleness_spec.rb +49 -0
  42. data/spec/simplecov_mcp_model_spec.rb +51 -32
  43. data/spec/spec_helper.rb +37 -7
  44. data/spec/staleness_more_spec.rb +39 -0
  45. data/spec/util_spec.rb +78 -0
  46. data/spec/version_spec.rb +10 -0
  47. metadata +56 -13
  48. data/lib/simplecov/mcp/base_tool.rb +0 -18
  49. data/lib/simplecov/mcp/cli.rb +0 -98
  50. data/lib/simplecov/mcp/model.rb +0 -59
  51. data/lib/simplecov/mcp/tools/all_files_coverage.rb +0 -28
  52. data/lib/simplecov/mcp/tools/coverage_detailed.rb +0 -22
  53. data/lib/simplecov/mcp/tools/coverage_raw.rb +0 -22
  54. data/lib/simplecov/mcp/tools/coverage_summary.rb +0 -22
  55. data/lib/simplecov/mcp/tools/uncovered_lines.rb +0 -22
  56. data/lib/simplecov/mcp/util.rb +0 -94
  57. data/lib/simplecov/mcp/version.rb +0 -8
  58. data/lib/simplecov/mcp.rb +0 -28
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ class CoverageCLI
5
+ SUBCOMMANDS = %w[list summary raw uncovered detailed version].freeze
6
+
7
+ # Initialize CLI for pure CLI usage only.
8
+ # Always runs as CLI, no mode detection needed.
9
+ def initialize(error_handler: nil)
10
+ @root = '.'
11
+ @resultset = nil
12
+ @json = false
13
+ @sort_order = 'ascending'
14
+ @cmd = nil
15
+ @cmd_args = []
16
+ @source_mode = nil # nil, 'full', or 'uncovered'
17
+ @source_context = 2 # lines of context for uncovered mode
18
+ @color = STDOUT.tty?
19
+ @error_handler = error_handler || ErrorHandlerFactory.for_cli
20
+ @stale_mode = 'off'
21
+ @tracked_globs = nil
22
+ end
23
+
24
+ def run(argv)
25
+ parse_options!(argv)
26
+ if @cmd
27
+ run_subcommand(@cmd, @cmd_args)
28
+ else
29
+ show_default_report(sort_order: @sort_order)
30
+ end
31
+ rescue SimpleCovMcp::Error => e
32
+ handle_user_facing_error(e)
33
+ rescue => e
34
+ @error_handler.handle_error(e, context: 'CLI execution')
35
+ end
36
+
37
+ def show_default_report(sort_order: :ascending, output: $stdout)
38
+ model = CoverageModel.new(root: @root, resultset: @resultset, staleness: @stale_mode, tracked_globs: @tracked_globs)
39
+ rows = model.all_files(sort_order: sort_order, check_stale: (@stale_mode == 'error'), tracked_globs: @tracked_globs)
40
+ if @json
41
+ files = rows.map { |row| row.merge('file' => rel_to_root(row['file'])) }
42
+ total = files.length
43
+ stale_count = files.count { |f| f['stale'] }
44
+ ok_count = total - stale_count
45
+ output.puts JSON.pretty_generate({ files: files, counts: { total: total, ok: ok_count, stale: stale_count } })
46
+ return
47
+ end
48
+
49
+ file_summaries = rows.map do |row|
50
+ row.dup.tap do |h|
51
+ h['file'] = rel_to_root(h['file'])
52
+ end
53
+ end
54
+
55
+ # Format as table with box-style borders
56
+ max_file_length = file_summaries.map { |f| f['file'].length }.max.to_i
57
+ max_file_length = [max_file_length, 'File'.length].max
58
+
59
+ # Calculate maximum numeric values for proper column widths
60
+ max_covered = file_summaries.map { |f| f['covered'].to_s.length }.max
61
+ max_total = file_summaries.map { |f| f['total'].to_s.length }.max
62
+
63
+ # Define column widths
64
+ file_width = max_file_length + 2 # Extra padding
65
+ pct_width = 8
66
+ covered_width = [max_covered, 'Covered'.length].max + 2
67
+ total_width = [max_total, 'Total'.length].max + 2
68
+
69
+ stale_header = 'Stale'
70
+ stale_width = stale_header.length
71
+ # Use String#center for concise centering
72
+
73
+ # Horizontal line for each column span
74
+ h_line = ->(col_width) { '─' * (col_width + 2) }
75
+
76
+ # Border line lambda
77
+ border_line = ->(left, middle, right) {
78
+ left + h_line.(file_width) +
79
+ middle + h_line.(pct_width) +
80
+ middle + h_line.(covered_width) +
81
+ middle + h_line.(total_width) +
82
+ middle + h_line.(stale_width) +
83
+ right
84
+ }
85
+
86
+ # Top border
87
+ output.puts border_line.call('┌', '┬', '┐')
88
+
89
+ # Header row
90
+ output.printf "│ %-#{file_width}s │ %#{pct_width}s │ %#{covered_width}s │ %#{total_width}s │ %#{stale_width}s │\n",
91
+ 'File', ' %', 'Covered', 'Total', stale_header.to_s.center(stale_width)
92
+
93
+ # Header separator
94
+ output.puts border_line.call('├', '┼', '┤')
95
+
96
+ # Data rows
97
+ file_summaries.each do |file_data|
98
+ stale_text_str = file_data['stale'] ? '!' : ''
99
+ output.printf "│ %-#{file_width}s │ %#{pct_width - 1}.2f%% │ %#{covered_width}d │ %#{total_width}d │ %#{stale_width}s │\n",
100
+ file_data['file'],
101
+ file_data['percentage'],
102
+ file_data['covered'],
103
+ file_data['total'],
104
+ stale_text_str.center(stale_width)
105
+ end
106
+
107
+ # Bottom border
108
+ output.puts border_line.call('└', '┴', '┘')
109
+
110
+ # Summary counts line
111
+ total = file_summaries.length
112
+ stale_count = file_summaries.count { |f| f['stale'] }
113
+ ok_count = total - stale_count
114
+ output.puts "Files: total #{total}, ok #{ok_count}, stale #{stale_count}"
115
+ end
116
+
117
+ private
118
+
119
+ def parse_options!(argv)
120
+ require 'optparse'
121
+
122
+ if !argv.empty? && SUBCOMMANDS.include?(argv[0])
123
+ @cmd = argv.shift
124
+ end
125
+
126
+ op = OptionParser.new do |o|
127
+ o.banner = 'Usage: simplecov-mcp [subcommand] [options] [args]'
128
+ o.separator ''
129
+ o.separator 'Subcommands:'
130
+ o.separator ' list Show table of all files'
131
+ o.separator ' summary <path> Show covered/total/% for a file'
132
+ o.separator " raw <path> Show the SimpleCov 'lines' array"
133
+ o.separator ' uncovered <path> Show uncovered lines and a summary'
134
+ o.separator ' detailed <path> Show per-line rows with hits/covered'
135
+ o.separator ' version Show version information'
136
+ o.separator ''
137
+
138
+ o.separator ''
139
+ o.separator 'Options:'
140
+ o.on('--resultset PATH', String, 'Path or directory that contains .resultset.json') { |v| @resultset = v }
141
+ o.on('--root PATH', String, "Project root (default '.')") { |v| @root = v }
142
+ o.on('--json', 'Output JSON for machine consumption') { @json = true }
143
+ o.on('--sort-order ORDER', String, ['ascending', 'descending'], "Sort order for 'list' (ascending|descending)") { |v| @sort_order = v }
144
+ o.on('--source[=MODE]', [:full, :uncovered], 'Include source in output for summary/uncovered/detailed (MODE: full|uncovered; default full)') do |v|
145
+ @source_mode = (v || :full).to_s
146
+ end
147
+ o.on('--source-context N', Integer, 'For --source=uncovered, show N context lines (default 2)') { |v| @source_context = v }
148
+ o.on('--color', 'Enable ANSI colors for source output') { @color = true }
149
+ o.on('--no-color', 'Disable ANSI colors') { @color = false }
150
+ o.on('--stale MODE', [:off, :error], "Staleness mode: off|error (default off)") do |v|
151
+ @stale_mode = v.to_s
152
+ end
153
+ o.on('--tracked-globs x,y,z', Array, 'Globs for files that should be covered (list only)') { |v| @tracked_globs = v }
154
+ o.separator ''
155
+ o.separator 'Examples:'
156
+ o.separator ' simplecov-mcp list --resultset coverage'
157
+ o.separator ' simplecov-mcp summary lib/foo.rb --json --resultset coverage'
158
+ o.separator ' simplecov-mcp uncovered lib/foo.rb --source=uncovered --source-context 2'
159
+ o.on('-h', '--help', 'Show help') do
160
+ puts o
161
+ exit 0
162
+ end
163
+ end
164
+ op.parse!(argv)
165
+ @cmd_args = argv
166
+ end
167
+
168
+ def run_subcommand(cmd, args)
169
+ model = CoverageModel.new(root: @root, resultset: @resultset, staleness: @stale_mode)
170
+ case cmd
171
+ when 'list' then handle_list(model)
172
+ when 'summary' then handle_summary(model, args)
173
+ when 'raw' then handle_raw(model, args)
174
+ when 'uncovered' then handle_uncovered(model, args)
175
+ when 'detailed' then handle_detailed(model, args)
176
+ when 'version' then handle_version
177
+ else raise UsageError.for_subcommand('list | summary <path> | raw <path> | uncovered <path> | detailed <path> | version')
178
+ end
179
+ rescue SimpleCovMcp::Error => e
180
+ handle_user_facing_error(e)
181
+ rescue => e
182
+ @error_handler.handle_error(e, context: "subcommand '#{cmd}'")
183
+ end
184
+
185
+
186
+ def format_detailed_rows(rows)
187
+ # Simple aligned columns: line, hits, covered
188
+ out = []
189
+ out << sprintf('%6s %6s %7s', 'Line', 'Hits', 'Covered')
190
+ out << sprintf('%6s %6s %7s', '-----', '----', '-------')
191
+ rows.each do |r|
192
+ out << sprintf('%6d %6d %7s', r['line'], r['hits'], r['covered'] ? 'yes' : 'no')
193
+ end
194
+ out.join("\n")
195
+ end
196
+
197
+ def handle_list(model)
198
+ show_default_report(sort_order: @sort_order)
199
+ end
200
+
201
+ def handle_version
202
+ if @json
203
+ puts JSON.pretty_generate({ version: SimpleCovMcp::VERSION })
204
+ else
205
+ puts "SimpleCovMcp version #{SimpleCovMcp::VERSION}"
206
+ end
207
+ end
208
+
209
+ def handle_summary(model, args)
210
+ handle_with_path(args, 'summary') do |path|
211
+ data = model.summary_for(path)
212
+ if @source_mode && @json
213
+ data = relativize_file(data)
214
+ src = build_source_payload(model, path)
215
+ data['source'] = src
216
+ break puts(JSON.pretty_generate(data))
217
+ end
218
+ break if maybe_output_json(relativize_file(data))
219
+ rel = rel_path(data['file'])
220
+ s = data['summary']
221
+ printf '%8.2f%% %6d/%-6d %s\n', s['pct'], s['covered'], s['total'], rel
222
+ print_source_for(model, path) if @source_mode
223
+ end
224
+ end
225
+
226
+ def handle_raw(model, args)
227
+ handle_with_path(args, 'raw') do |path|
228
+ data = model.raw_for(path)
229
+ break if maybe_output_json(relativize_file(data))
230
+ rel = rel_path(data['file'])
231
+ puts "File: #{rel}"
232
+ puts data['lines'].inspect
233
+ end
234
+ end
235
+
236
+ def handle_uncovered(model, args)
237
+ handle_with_path(args, 'uncovered') do |path|
238
+ data = model.uncovered_for(path)
239
+ if @source_mode && @json
240
+ data = relativize_file(data)
241
+ src = build_source_payload(model, path)
242
+ data['source'] = src
243
+ break puts(JSON.pretty_generate(data))
244
+ end
245
+ break if maybe_output_json(relativize_file(data))
246
+ rel = rel_path(data['file'])
247
+ puts "File: #{rel}"
248
+ puts "Uncovered lines: #{data['uncovered'].join(', ')}"
249
+ s = data['summary']
250
+ printf 'Summary: %8.2f%% %6d/%-6d\n', s['pct'], s['covered'], s['total']
251
+ print_source_for(model, path) if @source_mode
252
+ end
253
+ end
254
+
255
+ def handle_detailed(model, args)
256
+ handle_with_path(args, 'detailed') do |path|
257
+ data = model.detailed_for(path)
258
+ if @source_mode && @json
259
+ data = relativize_file(data)
260
+ src = build_source_payload(model, path)
261
+ data['source'] = src
262
+ break puts(JSON.pretty_generate(data))
263
+ end
264
+ break if maybe_output_json(relativize_file(data))
265
+ rel = rel_path(data['file'])
266
+ puts "File: #{rel}"
267
+ puts format_detailed_rows(data['lines'])
268
+ print_source_for(model, path) if @source_mode
269
+ end
270
+ end
271
+
272
+ def handle_with_path(args, name)
273
+ path = args.shift or raise UsageError.for_subcommand("#{name} <path>")
274
+ yield(path)
275
+ rescue Errno::ENOENT => e
276
+ raise FileNotFoundError.new("File not found: #{path}")
277
+ rescue Errno::EACCES => e
278
+ raise FilePermissionError.new("Permission denied: #{path}")
279
+ end
280
+
281
+ def rel_path(abs)
282
+ rel_to_root(abs)
283
+ end
284
+
285
+ def maybe_output_json(obj)
286
+ return false unless @json
287
+ puts JSON.pretty_generate(obj)
288
+ true
289
+ end
290
+
291
+ def print_source_for(model, path)
292
+ raw = model.raw_for(path)
293
+ abs = raw['file']
294
+ lines_cov = raw['lines']
295
+ src = File.file?(abs) ? File.readlines(abs, chomp: true) : nil
296
+ unless src
297
+ puts '[source not available]'
298
+ return
299
+ end
300
+ begin
301
+ rows = build_source_rows(src, lines_cov, mode: @source_mode, context: @source_context)
302
+ puts format_source_rows(rows)
303
+ rescue StandardError
304
+ # If any unexpected formatting/indexing error occurs, avoid crashing the CLI
305
+ # and fall back to a neutral message rather than raising.
306
+ puts '[source not available]'
307
+ end
308
+ end
309
+
310
+ def build_source_payload(model, path)
311
+ raw = model.raw_for(path)
312
+ abs = raw['file']
313
+ lines_cov = raw['lines']
314
+ src = File.file?(abs) ? File.readlines(abs, chomp: true) : nil
315
+ return nil unless src
316
+ build_source_rows(src, lines_cov, mode: @source_mode, context: @source_context)
317
+ end
318
+
319
+ def build_source_rows(src_lines, cov_lines, mode:, context: 2)
320
+ # Normalize inputs defensively to avoid type errors in formatting
321
+ coverage_lines = cov_lines || []
322
+ context_line_count = context.to_i rescue 2
323
+ context_line_count = 0 if context_line_count.negative?
324
+
325
+ n = src_lines.length
326
+ include_line = Array.new(n, mode == 'full')
327
+ if mode == 'uncovered'
328
+ misses = []
329
+ coverage_lines.each_with_index do |hits, i|
330
+ misses << i if !hits.nil? && hits.to_i == 0
331
+ end
332
+ misses.each do |i|
333
+ a = [0, i - context_line_count].max
334
+ b = [n - 1, i + context_line_count].min
335
+ (a..b).each { |j| include_line[j] = true }
336
+ end
337
+ end
338
+ out = []
339
+ src_lines.each_with_index do |code, i|
340
+ next unless include_line[i]
341
+ hits = coverage_lines[i]
342
+ covered = hits.nil? ? nil : hits.to_i > 0
343
+ out << { line: i + 1, code: code, hits: hits, covered: covered }
344
+ end
345
+ out
346
+ end
347
+
348
+ def format_source_rows(rows)
349
+ marker = ->(covered, hits) do
350
+ case covered
351
+ when true then colorize('✓', :green)
352
+ when false then colorize('·', :red)
353
+ else colorize(' ', :dim)
354
+ end
355
+ end
356
+ lines = []
357
+ lines << sprintf('%6s %2s | %s', 'Line', ' ', 'Source')
358
+ lines << sprintf('%6s %2s-+-%s', '------', '--', '-' * 60)
359
+ rows.each do |r|
360
+ m = marker.call(r['covered'], r['hits'])
361
+ lines << sprintf('%6d %2s | %s', r['line'], m, r['code'])
362
+ end
363
+ lines.join("\n")
364
+ end
365
+
366
+ def colorize(text, color)
367
+ return text unless @color
368
+ codes = { green: 32, red: 31, dim: 2 }
369
+ code = codes[color] || 0
370
+ "\e[#{code}m#{text}\e[0m"
371
+ end
372
+
373
+ def rel_to_root(path)
374
+ Pathname.new(path).relative_path_from(Pathname.new(File.absolute_path(@root))).to_s
375
+ end
376
+
377
+ def relativize_file(h)
378
+ return h unless h.is_a?(Hash) && h['file']
379
+ dup = h.dup
380
+ dup['file'] = rel_to_root(dup['file'])
381
+ dup
382
+ end
383
+
384
+
385
+ def handle_user_facing_error(error)
386
+ warn error.user_friendly_message
387
+ exit 1
388
+ end
389
+ end
390
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module SimpleCovMcp
6
+ # Handles error reporting and logging with configurable behavior
7
+ class ErrorHandler
8
+ attr_accessor :log_errors, :show_stack_traces, :logger
9
+
10
+ def initialize(log_errors: true, show_stack_traces: false, logger: nil)
11
+ @log_errors = log_errors
12
+ @show_stack_traces = show_stack_traces
13
+ @logger = logger
14
+ end
15
+
16
+ # Handle an error with appropriate logging and re-raising behavior
17
+ def handle_error(error, context: nil, reraise: true)
18
+ log_error(error, context)
19
+
20
+ if reraise
21
+ if error.is_a?(SimpleCovMcp::Error)
22
+ # Re-raise our custom errors as-is
23
+ raise error
24
+ else
25
+ # Convert standard errors to our custom errors for better UX
26
+ converted_error = convert_standard_error(error)
27
+ raise converted_error
28
+ end
29
+ end
30
+ end
31
+
32
+ # Convert standard Ruby errors to user-friendly custom errors
33
+ def convert_standard_error(error)
34
+ case error
35
+ when Errno::ENOENT
36
+ filename = extract_filename(error.message)
37
+ FileNotFoundError.new("File not found: #{filename}", error)
38
+ when Errno::EACCES
39
+ filename = extract_filename(error.message)
40
+ FilePermissionError.new("Permission denied accessing file: #{filename}", error)
41
+ when Errno::EISDIR
42
+ filename = extract_filename(error.message)
43
+ NotAFileError.new("Expected file but found directory: #{filename}", error)
44
+ when JSON::ParserError
45
+ CoverageDataError.new("Invalid coverage data format - JSON parsing failed: #{error.message}", error)
46
+ when ArgumentError
47
+ if error.message.include?('wrong number of arguments')
48
+ UsageError.new("Invalid number of arguments: #{error.message}", error)
49
+ else
50
+ ConfigurationError.new("Invalid configuration: #{error.message}", error)
51
+ end
52
+ when NoMethodError
53
+ method_info = extract_method_info(error.message)
54
+ CoverageDataError.new("Invalid coverage data structure - #{method_info}", error)
55
+ when RuntimeError, StandardError
56
+ # Handle string errors from CovUtil and other runtime errors
57
+ if error.message.include?('No coverage entry found for')
58
+ # Extract the file path from the error message
59
+ filepath = error.message.match(/No coverage entry found for (.+)$/)&.[](1) || "specified file"
60
+ FileError.new("No coverage data found for file: #{filepath}", error)
61
+ elsif error.message.include?('Could not find .resultset.json')
62
+ # Extract directory info if available
63
+ dir_info = error.message.match(/under (.+?)(?:;|$)/)&.[](1) || "project directory"
64
+ CoverageDataError.new("Coverage data not found in #{dir_info} - please run your tests first", error)
65
+ elsif error.message.include?('No .resultset.json found in directory')
66
+ # Extract directory from error message
67
+ dir_info = error.message.match(/directory: (.+)$/)&.[](1) || "specified directory"
68
+ CoverageDataError.new("Coverage data not found in directory: #{dir_info}", error)
69
+ elsif error.message.include?('Specified resultset not found')
70
+ # Extract path from error message
71
+ path_info = error.message.match(/not found: (.+)$/)&.[](1) || "specified path"
72
+ ResultsetNotFoundError.new("Resultset file not found: #{path_info}", error)
73
+ else
74
+ Error.new("An unexpected error occurred: #{error.message}", error)
75
+ end
76
+ else
77
+ Error.new("An unexpected error occurred: #{error.message}", error)
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def log_error(error, context)
84
+ return unless @log_errors
85
+
86
+ message = build_log_message(error, context)
87
+ if @logger
88
+ @logger.error(message)
89
+ else
90
+ CovUtil.log(message)
91
+ end
92
+ end
93
+
94
+ def build_log_message(error, context)
95
+ parts = []
96
+ parts << "Error#{context ? " in #{context}" : ''}: #{error.class}: #{error.message}"
97
+
98
+ if @show_stack_traces && error.backtrace
99
+ parts << error.backtrace.join("\n")
100
+ end
101
+
102
+ parts.join("\n")
103
+ end
104
+
105
+ def extract_filename(message)
106
+ # Extract filename from "No such file or directory @ rb_sysopen - filename"
107
+ match = message.match(/@ \w+ - (.+)$/)
108
+ match ? match[1] : "unknown file"
109
+ end
110
+
111
+ def extract_method_info(message)
112
+ # Extract method info from "undefined method `foo' for #<Object:0x...>"
113
+ if match = message.match(/undefined method `(.+?)' for (.+)$/)
114
+ method_name = match[1]
115
+ object_info = match[2].gsub(/#<.*?>/, 'object')
116
+ "missing method '#{method_name}' on #{object_info}"
117
+ else
118
+ message
119
+ end
120
+ end
121
+ end
122
+
123
+ # Global error handler configuration
124
+ class << self
125
+ attr_writer :error_handler
126
+
127
+ def error_handler
128
+ @error_handler or raise "Error handler not configured. Use one of: SimpleCovMcp.run, .run_as_library, or set .error_handler= explicitly"
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ module ErrorHandlerFactory
5
+ # Error handler for CLI usage
6
+ # - Logs errors for debugging
7
+ # - Shows stack traces only in debug mode
8
+ # - Suitable for user-facing command line interface
9
+ def self.for_cli
10
+ ErrorHandler.new(
11
+ log_errors: true,
12
+ show_stack_traces: ENV['SIMPLECOV_MCP_DEBUG'] == '1'
13
+ )
14
+ end
15
+
16
+ # Error handler for library usage
17
+ # - No logging by default (avoids side effects in consuming applications)
18
+ # - No stack traces (libraries should let consumers handle error display)
19
+ # - Suitable for embedding in other applications
20
+ def self.for_library
21
+ ErrorHandler.new(
22
+ log_errors: false,
23
+ show_stack_traces: false
24
+ )
25
+ end
26
+
27
+ # Error handler for MCP server usage
28
+ # - Logs errors for server debugging
29
+ # - Shows stack traces only in debug mode
30
+ # - Suitable for long-running server processes
31
+ def self.for_mcp_server
32
+ ErrorHandler.new(
33
+ log_errors: true,
34
+ show_stack_traces: ENV['SIMPLECOV_MCP_DEBUG'] == '1'
35
+ )
36
+ end
37
+ end
38
+ end