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
@@ -3,9 +3,29 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe SimpleCovMcp::CoverageModel do
6
- let(:root) { (FIXTURES / 'project1').to_s }
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
7
  subject(:model) { described_class.new(root: root) }
8
8
 
9
+ describe 'initialization error handling' do
10
+ it 'raises FileError when File.read raises Errno::ENOENT directly' do
11
+ # Stub find_resultset to return a path, but File.read to raise ENOENT
12
+ allow(SimpleCovMcp::CovUtil).to receive(:find_resultset)
13
+ .and_return('/some/path/.resultset.json')
14
+ allow(File).to receive(:read).with('/some/path/.resultset.json')
15
+ .and_raise(Errno::ENOENT, 'No such file')
16
+
17
+ expect do
18
+ described_class.new(root: root, resultset: '/some/path/.resultset.json')
19
+ end.to raise_error(SimpleCovMcp::FileError, /Coverage data not found/)
20
+ end
21
+
22
+ it 'raises CoverageDataError when resultset file does not exist' do
23
+ expect do
24
+ described_class.new(root: root, resultset: '/nonexistent/path/.resultset.json')
25
+ end.to raise_error(SimpleCovMcp::CoverageDataError, /Failed to load coverage data/)
26
+ end
27
+ end
28
+
9
29
  describe 'raw_for' do
10
30
  it 'returns absolute file and lines array' do
11
31
  data = model.raw_for('lib/foo.rb')
@@ -23,6 +43,17 @@ RSpec.describe SimpleCovMcp::CoverageModel do
23
43
  end
24
44
  end
25
45
 
46
+ describe '#relativize' do
47
+ it 'returns a copy with file paths relative to the root' do
48
+ data = model.summary_for('lib/foo.rb')
49
+ relative = model.relativize(data)
50
+
51
+ expect(relative['file']).to eq('lib/foo.rb')
52
+ expect(data['file']).not_to eq(relative['file'])
53
+ expect(relative).not_to equal(data)
54
+ end
55
+ end
56
+
26
57
  describe 'uncovered_for' do
27
58
  it 'lists uncovered executable line numbers' do
28
59
  data = model.uncovered_for('lib/foo.rb')
@@ -42,6 +73,36 @@ RSpec.describe SimpleCovMcp::CoverageModel do
42
73
  end
43
74
  end
44
75
 
76
+ describe 'staleness_for' do
77
+ it 'returns the staleness character for a file' do
78
+ allow_any_instance_of(SimpleCovMcp::StalenessChecker)
79
+ .to receive(:stale_for_file?) do |_, file_abs, _|
80
+ if file_abs == File.expand_path('lib/foo.rb', root)
81
+ 'T'
82
+ else
83
+ false
84
+ end
85
+ end
86
+
87
+ expect(model.staleness_for('lib/foo.rb')).to eq('T')
88
+ expect(model.staleness_for('lib/bar.rb')).to eq(false)
89
+ end
90
+
91
+ it 'returns false when an exception occurs during staleness check' do
92
+ # Stub the checker to raise an error
93
+ allow_any_instance_of(SimpleCovMcp::StalenessChecker).to receive(:stale_for_file?)
94
+ .and_raise(StandardError, 'Something went wrong')
95
+
96
+ # The rescue clause should catch the error and return false
97
+ expect(model.staleness_for('lib/foo.rb')).to eq(false)
98
+ end
99
+
100
+ it 'returns false when coverage data is not found for the file' do
101
+ # Try to get staleness for a file that doesn't exist in coverage
102
+ expect(model.staleness_for('lib/nonexistent.rb')).to eq(false)
103
+ end
104
+ end
105
+
45
106
  describe 'all_files' do
46
107
  it 'sorts ascending by percentage then by file path' do
47
108
  files = model.all_files(sort_order: :ascending)
@@ -49,6 +110,70 @@ RSpec.describe SimpleCovMcp::CoverageModel do
49
110
  expect(files.first['percentage']).to be_within(0.01).of(33.33)
50
111
  expect(files.last['file']).to eq(File.expand_path('lib/foo.rb', root))
51
112
  end
113
+
114
+ it 'filters rows when tracked_globs are provided' do
115
+ files = model.all_files(tracked_globs: ['lib/foo.rb'])
116
+
117
+ expect(files.length).to eq(1)
118
+ expect(files.first['file']).to eq(File.expand_path('lib/foo.rb', root))
119
+ end
120
+
121
+ it 'combines results from multiple tracked_globs patterns' do
122
+ abs_bar = File.expand_path('lib/bar.rb', root)
123
+
124
+ files = model.all_files(tracked_globs: ['lib/foo.rb', abs_bar])
125
+
126
+ expect(files.map { |f| f['file'] }).to contain_exactly(
127
+ File.expand_path('lib/foo.rb', root),
128
+ abs_bar
129
+ )
130
+ end
131
+
132
+ it 'handles files with paths that cannot be relativized' do
133
+ # Create a custom row with a path from a Windows-style drive (C:/) that will cause ArgumentError
134
+ # when trying to make it relative to a Unix-style root
135
+ custom_rows = [
136
+ {
137
+ 'file' => 'C:/Windows/system32/file.rb',
138
+ 'percentage' => 100.0,
139
+ 'covered' => 10,
140
+ 'total' => 10,
141
+ 'stale' => false
142
+ }
143
+ ]
144
+
145
+ # This should trigger the ArgumentError rescue in filter_rows_by_globs
146
+ # When the path cannot be made relative (different path types), it falls back to using the absolute path
147
+ output = model.format_table(custom_rows, tracked_globs: ['C:/Windows/**/*.rb'])
148
+
149
+ # The file should be included because the absolute path fallback matches the glob
150
+ expect(output).to include('C:/Windows/system32/file.rb')
151
+ end
152
+ end
153
+
154
+ describe 'resolve method error handling' do
155
+ it 'raises FileError when coverage_lines is nil after lookup' do
156
+ # Stub lookup_lines to return nil without raising
157
+ allow(SimpleCovMcp::CovUtil).to receive(:lookup_lines).and_return(nil)
158
+
159
+ expect do
160
+ model.summary_for('lib/nonexistent.rb')
161
+ end.to raise_error(SimpleCovMcp::FileError, /No coverage data found for file/)
162
+ end
163
+
164
+ it 'converts Errno::ENOENT to FileNotFoundError during resolve' do
165
+ # We need to trigger Errno::ENOENT inside the resolve method
166
+ # Stub the checker's check_file! method to raise Errno::ENOENT
167
+ allow_any_instance_of(SimpleCovMcp::StalenessChecker).to receive(:check_file!)
168
+ .and_raise(Errno::ENOENT, 'No such file or directory')
169
+
170
+ # Create a model with staleness checking enabled to trigger the check_file! call
171
+ stale_model = described_class.new(root: root, staleness: 'error')
172
+
173
+ expect do
174
+ stale_model.summary_for('lib/foo.rb')
175
+ end.to raise_error(SimpleCovMcp::FileNotFoundError, /File not found/)
176
+ end
52
177
  end
53
178
 
54
179
  describe 'resultset directory handling' do
@@ -58,16 +183,222 @@ RSpec.describe SimpleCovMcp::CoverageModel do
58
183
  expect(data['summary']['total']).to eq(3)
59
184
  expect(data['summary']['covered']).to eq(2)
60
185
  end
186
+ end
187
+
188
+ describe 'branch-only coverage resultsets' do
189
+ let(:branch_root) { (FIXTURES_DIR / 'branch_only_project').to_s }
190
+ let(:branch_model) { described_class.new(root: branch_root) }
191
+
192
+ it 'computes summaries by synthesizing branch data' do
193
+ data = branch_model.summary_for('lib/branch_only.rb')
194
+
195
+ expect(data['summary']['total']).to eq(5)
196
+ expect(data['summary']['covered']).to eq(3)
197
+ expect(data['summary']['pct']).to be_within(0.01).of(60.0)
198
+ end
199
+
200
+ it 'returns detailed data using branch-derived hits' do
201
+ data = branch_model.detailed_for('lib/branch_only.rb')
202
+
203
+ expect(data['lines']).to eq([
204
+ { 'line' => 6, 'hits' => 3, 'covered' => true },
205
+ { 'line' => 7, 'hits' => 0, 'covered' => false },
206
+ { 'line' => 13, 'hits' => 0, 'covered' => false },
207
+ { 'line' => 14, 'hits' => 2, 'covered' => true },
208
+ { 'line' => 16, 'hits' => 2, 'covered' => true }
209
+ ])
210
+ end
211
+
212
+ it 'identifies uncovered lines based on branch hits' do
213
+ data = branch_model.uncovered_for('lib/branch_only.rb')
214
+
215
+ expect(data['uncovered']).to eq([7, 13])
216
+ end
217
+
218
+ it 'includes branch-only files in all_files results' do
219
+ files = branch_model.all_files(sort_order: :ascending)
220
+ branch_path = File.expand_path('lib/branch_only.rb', branch_root)
221
+ another_path = File.expand_path('lib/another.rb', branch_root)
222
+
223
+ expect(files.map { |f| f['file'] }).to contain_exactly(branch_path, another_path)
224
+
225
+ branch_entry = files.find { |f| f['file'] == branch_path }
226
+ another_entry = files.find { |f| f['file'] == another_path }
227
+
228
+ expect(branch_entry['total']).to eq(5)
229
+ expect(branch_entry['covered']).to eq(3)
230
+ expect(another_entry['total']).to eq(1)
231
+ expect(another_entry['covered']).to eq(0)
232
+ end
233
+ end
234
+
235
+ describe 'multiple suites in resultset' do
236
+ let(:resultset_path) { '/tmp/multi_suite_resultset.json' }
237
+
238
+ before do
239
+ allow(SimpleCovMcp::CovUtil).to receive(:find_resultset).and_wrap_original do
240
+ |original, search_root, resultset: nil|
241
+ root_match = File.absolute_path(search_root) == File.absolute_path(root)
242
+ resultset_empty = resultset.nil? || resultset.to_s.empty?
243
+ if root_match && resultset_empty
244
+ resultset_path
245
+ else
246
+ original.call(search_root, resultset: resultset)
247
+ end
248
+ end
249
+ allow(File).to receive(:read).and_call_original
250
+ end
251
+
252
+ it 'merges coverage data from multiple suites while keeping latest timestamp' do
253
+ suite_a_cov = {
254
+ File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, nil, 2] }
255
+ }
256
+ suite_b_cov = {
257
+ File.join(root, 'lib', 'bar.rb') => { 'lines' => [0, 1, 1] }
258
+ }
259
+
260
+ resultset = {
261
+ 'RSpec' => { 'timestamp' => 100, 'coverage' => suite_a_cov },
262
+ 'Cucumber' => { 'timestamp' => 200, 'coverage' => suite_b_cov }
263
+ }
264
+
265
+ allow(File).to receive(:read).with(resultset_path).and_return(resultset.to_json)
266
+
267
+ model = described_class.new(root: root)
268
+ files = model.all_files(sort_order: :ascending)
269
+
270
+ expect(files.map { |f| File.basename(f['file']) }).to include('foo.rb', 'bar.rb')
271
+
272
+ timestamp = model.instance_variable_get(:@cov_timestamp)
273
+ expect(timestamp).to eq(200)
274
+ end
275
+
276
+ it 'combines coverage arrays when the same file appears in multiple suites' do
277
+ shared_file = File.join(root, 'lib', 'foo.rb')
278
+ suite_a_cov = {
279
+ shared_file => { 'lines' => [1, 0, nil, 0] }
280
+ }
281
+ suite_b_cov = {
282
+ shared_file => { 'lines' => [0, 3, nil, 1] }
283
+ }
284
+
285
+ resultset = {
286
+ 'RSpec' => { 'timestamp' => 100, 'coverage' => suite_a_cov },
287
+ 'Cucumber' => { 'timestamp' => 150, 'coverage' => suite_b_cov }
288
+ }
61
289
 
62
- it 'uses SIMPLECOV_RESULTSET when it is a directory' do
63
- begin
64
- ENV['SIMPLECOV_RESULTSET'] = 'coverage'
65
- model = described_class.new(root: root)
66
- data = model.summary_for('lib/foo.rb')
67
- expect(data['summary']['covered']).to eq(2)
68
- ensure
69
- ENV.delete('SIMPLECOV_RESULTSET')
290
+ allow(File).to receive(:read).with(resultset_path).and_return(resultset.to_json)
291
+
292
+ model = described_class.new(root: root)
293
+ detailed = model.detailed_for('lib/foo.rb')
294
+ hits_by_line = detailed['lines'].each_with_object({}) do |row, acc|
295
+ acc[row['line']] = row['hits']
70
296
  end
297
+
298
+ expect(hits_by_line[1]).to eq(1)
299
+ expect(hits_by_line[2]).to eq(3)
300
+ expect(hits_by_line[4]).to eq(1)
301
+ end
302
+ end
303
+
304
+ describe 'format_table' do
305
+ it 'returns a formatted table string with all files coverage data' do
306
+ output = model.format_table
307
+
308
+ # Should contain table structure
309
+ expect(output).to include('┌', '┬', '┐', '│', '├', '┼', '┤', '└', '┴', '┘')
310
+
311
+ # Should contain headers
312
+ expect(output).to include('File', '%', 'Covered', 'Total', 'Stale')
313
+
314
+ # Should contain file data
315
+ expect(output).to include('lib/foo.rb', 'lib/bar.rb')
316
+
317
+ # Should contain summary
318
+ expect(output).to include('Files: total', ', ok ', ', stale ')
319
+ end
320
+
321
+ it 'returns "No coverage data found" when rows is empty' do
322
+ rows = []
323
+ output = model.format_table(rows)
324
+ expect(output).to eq('No coverage data found')
325
+ end
326
+
327
+ it 'accepts custom rows parameter' do
328
+ custom_rows = [
329
+ {
330
+ 'file' => '/path/to/file1.rb',
331
+ 'percentage' => 100.0,
332
+ 'covered' => 10,
333
+ 'total' => 10,
334
+ 'stale' => false
335
+ },
336
+ {
337
+ 'file' => '/path/to/file2.rb',
338
+ 'percentage' => 50.0,
339
+ 'covered' => 5,
340
+ 'total' => 10,
341
+ 'stale' => 'M'
342
+ },
343
+ {
344
+ 'file' => '/path/to/file3.rb',
345
+ 'percentage' => 75.0,
346
+ 'covered' => 15,
347
+ 'total' => 20,
348
+ 'stale' => 'T'
349
+ }
350
+ ]
351
+
352
+ output = model.format_table(custom_rows)
353
+
354
+ expect(output).to include('file1.rb')
355
+ expect(output).to include('file2.rb')
356
+ expect(output).to include('file3.rb')
357
+ expect(output).to include('100.00')
358
+ expect(output).to include('50.00')
359
+ expect(output).to include('75.00')
360
+ expect(output).to include('M')
361
+ expect(output).to include('T')
362
+ expect(output).not_to include('!')
363
+ staleness_msg = 'Staleness: M = Missing file, T = Timestamp (source newer), ' \
364
+ 'L = Line count mismatch'
365
+ expect(output).to include(staleness_msg)
366
+ end
367
+
368
+ it 'accepts sort_order parameter' do
369
+ # Test that sort_order parameter is passed through correctly
370
+ rows_desc = model.all_files(sort_order: :descending)
371
+ output_asc = model.format_table(sort_order: :ascending)
372
+ output_desc = model.format_table(sort_order: :descending)
373
+
374
+ # Both should be valid table outputs
375
+ expect(output_asc).to include('┌')
376
+ expect(output_desc).to include('┌')
377
+ expect(output_asc).to include('Files: total')
378
+ expect(output_desc).to include('Files: total')
379
+ end
380
+
381
+ it 'sorts table output correctly when provided with custom rows' do
382
+ # Get all files data to use as custom rows
383
+ all_files_data = model.all_files
384
+
385
+ # Test ascending sort with custom rows
386
+ output_asc = model.format_table(all_files_data, sort_order: :ascending)
387
+ lines_asc = output_asc.split("\n")
388
+ bar_line_asc = lines_asc.find { |line| line.include?('bar.rb') }
389
+ foo_line_asc = lines_asc.find { |line| line.include?('foo.rb') }
390
+
391
+ # In ascending order, bar.rb (33.33%) should come before foo.rb (66.67%)
392
+ expect(lines_asc.index(bar_line_asc)).to be < lines_asc.index(foo_line_asc)
393
+
394
+ # Test descending sort with custom rows
395
+ output_desc = model.format_table(all_files_data, sort_order: :descending)
396
+ lines_desc = output_desc.split("\n")
397
+ bar_line_desc = lines_desc.find { |line| line.include?('bar.rb') }
398
+ foo_line_desc = lines_desc.find { |line| line.include?('foo.rb') }
399
+
400
+ # In descending order, foo.rb (66.67%) should come before bar.rb (33.33%)
401
+ expect(lines_desc.index(foo_line_desc)).to be < lines_desc.index(bar_line_desc)
71
402
  end
72
403
  end
73
404
  end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'SIMPLECOV_MCP_OPTS Environment Variable' do
6
+ let(:cli) { SimpleCovMcp::CoverageCLI.new }
7
+
8
+ around do |example|
9
+ original_value = ENV['SIMPLECOV_MCP_OPTS']
10
+ example.run
11
+ ensure
12
+ ENV['SIMPLECOV_MCP_OPTS'] = original_value
13
+ end
14
+
15
+ describe 'CLI option parsing from environment' do
16
+ it 'parses simple options from SIMPLECOV_MCP_OPTS' do
17
+ ENV['SIMPLECOV_MCP_OPTS'] = '--error-mode off --json'
18
+
19
+ begin
20
+ silence_output { cli.send(:run, ['summary', 'lib/foo.rb']) }
21
+ rescue Exception => e
22
+ # Expected to fail due to missing file, but options should be parsed
23
+ puts "DEBUG: Caught exception: #{e.class}: #{e.message}" if ENV['DEBUG']
24
+ end
25
+
26
+ expect(cli.config.error_mode).to eq(:off)
27
+ expect(cli.config.json).to be true
28
+ end
29
+
30
+ it 'handles quoted options with spaces' do
31
+ test_path = File.join(Dir.tmpdir, 'test path with spaces', '.resultset.json')
32
+ ENV['SIMPLECOV_MCP_OPTS'] = "--resultset \"#{test_path}\""
33
+
34
+ # Stub exit method to prevent process termination
35
+ allow_any_instance_of(Object).to receive(:exit)
36
+
37
+ # silence_output captures the expected error message from the CLI trying to
38
+ # load the (non-existent) resultset, preventing it from leaking to the console.
39
+ silence_output do
40
+ cli.send(:run, ['--help'])
41
+ end
42
+
43
+ expect(cli.config.resultset).to eq(test_path)
44
+ end
45
+
46
+ it 'supports setting log-file to stdout from environment' do
47
+ ENV['SIMPLECOV_MCP_OPTS'] = '--log-file stdout'
48
+
49
+ allow_any_instance_of(Object).to receive(:exit)
50
+
51
+ silence_output do
52
+ cli.send(:run, ['--help'])
53
+ end
54
+
55
+ expect(cli.config.log_file).to eq('stdout')
56
+ end
57
+
58
+ it 'command line arguments override environment options' do
59
+ ENV['SIMPLECOV_MCP_OPTS'] = '--error-mode off'
60
+
61
+ begin
62
+ silence_output { cli.send(:run, ['--error-mode', 'trace', 'summary', 'lib/foo.rb']) }
63
+ rescue SystemExit, SimpleCovMcp::Error
64
+ # Expected to fail, but options should be parsed
65
+ end
66
+
67
+ # Command line should override environment
68
+ expect(cli.config.error_mode).to eq(:trace)
69
+ end
70
+
71
+ it 'handles malformed SIMPLECOV_MCP_OPTS gracefully' do
72
+ ENV['SIMPLECOV_MCP_OPTS'] = '--option "unclosed quote'
73
+
74
+ # Should catch the ConfigurationError and exit cleanly
75
+ _out, _err, status = run_cli_with_status('summary', 'lib/foo.rb')
76
+ expect(status).not_to eq(0)
77
+ end
78
+
79
+ it 'returns empty array when SIMPLECOV_MCP_OPTS is not set' do
80
+ # ENV is already cleared by around block
81
+ opts = cli.send(:parse_env_opts)
82
+ expect(opts).to eq([])
83
+ end
84
+
85
+ it 'returns empty array when SIMPLECOV_MCP_OPTS is empty' do
86
+ ENV['SIMPLECOV_MCP_OPTS'] = ''
87
+ opts = cli.send(:parse_env_opts)
88
+ expect(opts).to eq([])
89
+ end
90
+ end
91
+
92
+ describe 'CLI mode detection with SIMPLECOV_MCP_OPTS' do
93
+ it 'respects --force-cli from environment variable' do
94
+ ENV['SIMPLECOV_MCP_OPTS'] = '--force-cli'
95
+
96
+ # This would normally be MCP mode (no TTY, no subcommand)
97
+ stdin = double('stdin', tty?: false)
98
+
99
+ env_opts = SimpleCovMcp.send(:parse_env_opts_for_mode_detection)
100
+ full_argv = env_opts + []
101
+
102
+ expect(SimpleCovMcp::ModeDetector.cli_mode?(full_argv, stdin: stdin)).to be true
103
+ end
104
+
105
+ it 'handles parse errors gracefully in mode detection' do
106
+ ENV['SIMPLECOV_MCP_OPTS'] = '--option "unclosed quote'
107
+
108
+ # Should return empty array and not crash
109
+ opts = SimpleCovMcp.send(:parse_env_opts_for_mode_detection)
110
+ expect(opts).to eq([])
111
+ end
112
+
113
+ it 'actually runs CLI when --force-cli is in SIMPLECOV_MCP_OPTS' do
114
+ ENV['SIMPLECOV_MCP_OPTS'] = '--force-cli'
115
+
116
+ # Mock STDIN to not be a TTY (would normally trigger MCP server mode)
117
+ allow(STDIN).to receive(:tty?).and_return(false)
118
+
119
+ # Stub exit to prevent process termination
120
+ allow_any_instance_of(Object).to receive(:exit)
121
+
122
+ # Run with --help which should produce help output
123
+ output = nil
124
+ silence_output do |out, err|
125
+ SimpleCovMcp.run(['--help'])
126
+ output = out.string + err.string
127
+ end
128
+
129
+ # Verify CLI actually ran by checking for help text
130
+ expect(output).to include('Usage:')
131
+ expect(output).to include('simplecov-mcp')
132
+ end
133
+
134
+ it 'actually runs MCP server mode when no CLI indicators present' do
135
+ ENV['SIMPLECOV_MCP_OPTS'] = ''
136
+
137
+ # Mock STDIN to not be a TTY and to provide valid JSON-RPC
138
+ allow(STDIN).to receive(:tty?).and_return(false)
139
+
140
+ # Provide a minimal JSON-RPC request that the server can handle
141
+ json_request = JSON.generate({
142
+ jsonrpc: '2.0',
143
+ id: 1,
144
+ method: 'initialize',
145
+ params: {
146
+ protocolVersion: '2024-11-05',
147
+ capabilities: {},
148
+ clientInfo: { name: 'test', version: '1.0' }
149
+ }
150
+ })
151
+
152
+ allow(STDIN).to receive(:gets).and_return(json_request, nil)
153
+
154
+ # Capture output to verify MCP server response
155
+ output = nil
156
+ silence_output do |out, err|
157
+ SimpleCovMcp.run([])
158
+ output = out.string + err.string
159
+ end
160
+
161
+ # Verify MCP server ran by checking for JSON-RPC response
162
+ expect(output).to include('"jsonrpc"')
163
+ expect(output).to include('"result"')
164
+ end
165
+ end
166
+
167
+ describe 'integration with actual CLI usage' do
168
+ it 'works end-to-end with --resultset option' do
169
+ test_resultset = File.join(Dir.tmpdir, 'test_coverage', '.resultset.json')
170
+ ENV['SIMPLECOV_MCP_OPTS'] = "--resultset #{test_resultset} --json"
171
+
172
+ allow_any_instance_of(Object).to receive(:exit)
173
+
174
+ expect do
175
+ silence_output { cli.send(:run, ['--help']) }
176
+ end.not_to raise_error
177
+
178
+ expect(cli.config.resultset).to eq(test_resultset)
179
+ expect(cli.config.json).to be true
180
+ end
181
+ end
182
+ end