simplecov-mcp 1.0.1 → 2.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 (164) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +98 -50
  3. data/docs/{ARCHITECTURE.md → dev/ARCHITECTURE.md} +11 -10
  4. data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
  5. data/docs/{DEVELOPMENT.md → dev/DEVELOPMENT.md} +2 -1
  6. data/docs/dev/README.md +10 -0
  7. data/docs/dev/RELEASING.md +146 -0
  8. data/docs/{arch-decisions → dev/arch-decisions}/001-x-arch-decision.md +3 -1
  9. data/docs/{arch-decisions → dev/arch-decisions}/002-x-arch-decision.md +7 -5
  10. data/docs/{arch-decisions → dev/arch-decisions}/003-x-arch-decision.md +2 -0
  11. data/docs/{arch-decisions → dev/arch-decisions}/004-x-arch-decision.md +6 -2
  12. data/docs/{arch-decisions → dev/arch-decisions}/005-x-arch-decision.md +4 -2
  13. data/docs/{arch-decisions → dev/arch-decisions}/README.md +3 -3
  14. data/docs/{presentations → dev/presentations}/simplecov-mcp-presentation.md +28 -22
  15. data/docs/fixtures/demo_project/README.md +9 -0
  16. data/docs/{ADVANCED_USAGE.md → user/ADVANCED_USAGE.md} +129 -319
  17. data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
  18. data/docs/user/CLI_USAGE.md +750 -0
  19. data/docs/{ERROR_HANDLING.md → user/ERROR_HANDLING.md} +12 -12
  20. data/docs/user/EXAMPLES.md +588 -0
  21. data/docs/user/INSTALLATION.md +130 -0
  22. data/docs/{LIBRARY_API.md → user/LIBRARY_API.md} +90 -32
  23. data/docs/{MCP_INTEGRATION.md → user/MCP_INTEGRATION.md} +36 -34
  24. data/docs/user/README.md +14 -0
  25. data/docs/{TROUBLESHOOTING.md → user/TROUBLESHOOTING.md} +21 -100
  26. data/docs/user/V2-BREAKING-CHANGES.md +472 -0
  27. data/exe/simplecov-mcp +1 -1
  28. data/lib/simplecov_mcp/{cli_config.rb → app_config.rb} +12 -12
  29. data/lib/simplecov_mcp/app_context.rb +1 -1
  30. data/lib/simplecov_mcp/base_tool.rb +66 -38
  31. data/lib/simplecov_mcp/cli.rb +67 -123
  32. data/lib/simplecov_mcp/commands/base_command.rb +16 -27
  33. data/lib/simplecov_mcp/commands/command_factory.rb +8 -2
  34. data/lib/simplecov_mcp/commands/detailed_command.rb +16 -2
  35. data/lib/simplecov_mcp/commands/list_command.rb +1 -1
  36. data/lib/simplecov_mcp/commands/raw_command.rb +18 -2
  37. data/lib/simplecov_mcp/commands/summary_command.rb +20 -3
  38. data/lib/simplecov_mcp/commands/totals_command.rb +53 -0
  39. data/lib/simplecov_mcp/commands/uncovered_command.rb +24 -5
  40. data/lib/simplecov_mcp/commands/validate_command.rb +60 -0
  41. data/lib/simplecov_mcp/commands/version_command.rb +19 -4
  42. data/lib/simplecov_mcp/config_parser.rb +32 -0
  43. data/lib/simplecov_mcp/constants.rb +3 -3
  44. data/lib/simplecov_mcp/coverage_reporter.rb +31 -0
  45. data/lib/simplecov_mcp/error_handler.rb +81 -40
  46. data/lib/simplecov_mcp/error_handler_factory.rb +2 -2
  47. data/lib/simplecov_mcp/errors.rb +12 -19
  48. data/lib/simplecov_mcp/formatters/source_formatter.rb +23 -19
  49. data/lib/simplecov_mcp/formatters.rb +51 -0
  50. data/lib/simplecov_mcp/mcp_server.rb +9 -7
  51. data/lib/simplecov_mcp/mode_detector.rb +6 -5
  52. data/lib/simplecov_mcp/model.rb +122 -88
  53. data/lib/simplecov_mcp/option_normalizers.rb +39 -18
  54. data/lib/simplecov_mcp/option_parser_builder.rb +82 -65
  55. data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +3 -5
  56. data/lib/simplecov_mcp/option_parsers/error_helper.rb +18 -17
  57. data/lib/simplecov_mcp/path_relativizer.rb +17 -14
  58. data/lib/simplecov_mcp/predicate_evaluator.rb +72 -0
  59. data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +1 -3
  60. data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +1 -3
  61. data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +1 -3
  62. data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +1 -3
  63. data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +1 -3
  64. data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +1 -3
  65. data/lib/simplecov_mcp/presenters/project_totals_presenter.rb +27 -0
  66. data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +14 -18
  67. data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +7 -9
  68. data/lib/simplecov_mcp/resultset_loader.rb +20 -25
  69. data/lib/simplecov_mcp/staleness_checker.rb +50 -46
  70. data/lib/simplecov_mcp/table_formatter.rb +64 -0
  71. data/lib/simplecov_mcp/tools/all_files_coverage_tool.rb +20 -50
  72. data/lib/simplecov_mcp/tools/coverage_detailed_tool.rb +13 -7
  73. data/lib/simplecov_mcp/tools/coverage_raw_tool.rb +12 -7
  74. data/lib/simplecov_mcp/tools/coverage_summary_tool.rb +13 -8
  75. data/lib/simplecov_mcp/tools/coverage_table_tool.rb +20 -60
  76. data/lib/simplecov_mcp/tools/coverage_totals_tool.rb +44 -0
  77. data/lib/simplecov_mcp/tools/help_tool.rb +38 -66
  78. data/lib/simplecov_mcp/tools/uncovered_lines_tool.rb +13 -8
  79. data/lib/simplecov_mcp/tools/validate_tool.rb +72 -0
  80. data/lib/simplecov_mcp/tools/version_tool.rb +7 -14
  81. data/lib/simplecov_mcp/util.rb +18 -12
  82. data/lib/simplecov_mcp/version.rb +1 -1
  83. data/lib/simplecov_mcp.rb +23 -29
  84. data/spec/all_files_coverage_tool_spec.rb +4 -3
  85. data/spec/{cli_config_spec.rb → app_config_spec.rb} +31 -26
  86. data/spec/base_tool_spec.rb +17 -14
  87. data/spec/cli/show_default_report_spec.rb +2 -2
  88. data/spec/cli_enumerated_options_spec.rb +31 -9
  89. data/spec/cli_error_spec.rb +46 -23
  90. data/spec/cli_format_spec.rb +123 -0
  91. data/spec/cli_json_options_spec.rb +50 -0
  92. data/spec/cli_source_spec.rb +11 -63
  93. data/spec/cli_spec.rb +82 -97
  94. data/spec/cli_usage_spec.rb +15 -15
  95. data/spec/commands/base_command_spec.rb +12 -92
  96. data/spec/commands/command_factory_spec.rb +7 -3
  97. data/spec/commands/detailed_command_spec.rb +10 -24
  98. data/spec/commands/list_command_spec.rb +28 -0
  99. data/spec/commands/raw_command_spec.rb +43 -20
  100. data/spec/commands/summary_command_spec.rb +10 -23
  101. data/spec/commands/totals_command_spec.rb +34 -0
  102. data/spec/commands/uncovered_command_spec.rb +29 -23
  103. data/spec/commands/validate_command_spec.rb +213 -0
  104. data/spec/commands/version_command_spec.rb +38 -0
  105. data/spec/constants_spec.rb +3 -3
  106. data/spec/coverage_reporter_spec.rb +102 -0
  107. data/spec/coverage_table_tool_spec.rb +21 -10
  108. data/spec/coverage_totals_tool_spec.rb +37 -0
  109. data/spec/error_handler_spec.rb +120 -4
  110. data/spec/error_mode_spec.rb +18 -22
  111. data/spec/errors_edge_cases_spec.rb +101 -28
  112. data/spec/errors_stale_spec.rb +34 -0
  113. data/spec/file_based_mcp_tools_spec.rb +6 -6
  114. data/spec/fixtures/project1/lib/bar.rb +2 -0
  115. data/spec/fixtures/project1/lib/foo.rb +2 -0
  116. data/spec/help_tool_spec.rb +2 -18
  117. data/spec/integration_spec.rb +103 -161
  118. data/spec/logging_fallback_spec.rb +3 -3
  119. data/spec/mcp_server_integration_spec.rb +1 -1
  120. data/spec/mcp_server_spec.rb +70 -53
  121. data/spec/mode_detector_spec.rb +46 -41
  122. data/spec/model_error_handling_spec.rb +139 -78
  123. data/spec/model_staleness_spec.rb +13 -13
  124. data/spec/option_normalizers_spec.rb +111 -112
  125. data/spec/option_parsers/env_options_parser_spec.rb +25 -37
  126. data/spec/option_parsers/error_helper_spec.rb +56 -56
  127. data/spec/path_relativizer_spec.rb +15 -0
  128. data/spec/presenters/coverage_detailed_presenter_spec.rb +1 -1
  129. data/spec/presenters/coverage_summary_presenter_spec.rb +1 -1
  130. data/spec/presenters/coverage_uncovered_presenter_spec.rb +1 -1
  131. data/spec/presenters/project_coverage_presenter_spec.rb +9 -8
  132. data/spec/presenters/project_totals_presenter_spec.rb +144 -0
  133. data/spec/resolvers/coverage_line_resolver_spec.rb +261 -36
  134. data/spec/resolvers/resultset_path_resolver_spec.rb +13 -8
  135. data/spec/shared_examples/file_based_mcp_tools.rb +23 -18
  136. data/spec/shared_examples/formatted_command_examples.rb +64 -0
  137. data/spec/simple_cov_mcp_module_spec.rb +24 -3
  138. data/spec/simplecov_mcp/formatters/source_formatter_spec.rb +267 -0
  139. data/spec/simplecov_mcp/formatters_spec.rb +76 -0
  140. data/spec/simplecov_mcp/presenters/base_coverage_presenter_spec.rb +79 -0
  141. data/spec/simplecov_mcp_model_spec.rb +97 -47
  142. data/spec/simplecov_mcp_opts_spec.rb +42 -39
  143. data/spec/spec_helper.rb +27 -92
  144. data/spec/staleness_checker_spec.rb +10 -9
  145. data/spec/staleness_more_spec.rb +4 -4
  146. data/spec/support/cli_helpers.rb +22 -0
  147. data/spec/support/control_flow_helpers.rb +20 -0
  148. data/spec/support/fake_mcp.rb +40 -0
  149. data/spec/support/io_helpers.rb +29 -0
  150. data/spec/support/mcp_helpers.rb +35 -0
  151. data/spec/support/mcp_runner.rb +10 -8
  152. data/spec/support/mocking_helpers.rb +30 -0
  153. data/spec/table_format_spec.rb +70 -0
  154. data/spec/tools/validate_tool_spec.rb +132 -0
  155. data/spec/tools_error_handling_spec.rb +34 -48
  156. data/spec/util_spec.rb +5 -4
  157. data/spec/version_spec.rb +7 -7
  158. data/spec/version_tool_spec.rb +20 -22
  159. metadata +90 -23
  160. data/docs/BRANCH_ONLY_COVERAGE.md +0 -81
  161. data/docs/CLI_USAGE.md +0 -637
  162. data/docs/EXAMPLES.md +0 -430
  163. data/docs/INSTALLATION.md +0 -352
  164. data/spec/cli_success_predicate_spec.rb +0 -141
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tempfile'
5
+
6
+ RSpec.describe SimpleCovMcp::Commands::ValidateCommand do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+
9
+ def with_temp_predicate(content)
10
+ Tempfile.create(['predicate', '.rb']) do |file|
11
+ file.write(content)
12
+ file.flush
13
+ yield file.path
14
+ end
15
+ end
16
+
17
+ describe 'validate subcommand with file' do
18
+ it 'exits 0 when predicate returns truthy value' do
19
+ with_temp_predicate("->(model) { true }\n") do |path|
20
+ _out, _err, status = run_cli_with_status(
21
+ '--root', root,
22
+ '--resultset', 'coverage',
23
+ 'validate', path
24
+ )
25
+ expect(status).to eq(0)
26
+ end
27
+ end
28
+
29
+ it 'exits 1 when predicate returns falsy value' do
30
+ with_temp_predicate("->(model) { false }\n") do |path|
31
+ _out, _err, status = run_cli_with_status(
32
+ '--root', root,
33
+ '--resultset', 'coverage',
34
+ 'validate', path
35
+ )
36
+ expect(status).to eq(1)
37
+ end
38
+ end
39
+
40
+ it 'exits 2 when predicate raises an error' do
41
+ with_temp_predicate("->(model) { raise 'Boom!' }\n") do |path|
42
+ _out, err, status = run_cli_with_status(
43
+ '--root', root,
44
+ '--resultset', 'coverage',
45
+ 'validate', path
46
+ )
47
+ expect(status).to eq(2)
48
+ expect(err).to include('Predicate error: Boom!')
49
+ end
50
+ end
51
+
52
+ it 'shows backtrace when predicate errors with --error-mode debug' do
53
+ with_temp_predicate("->(model) { raise 'Boom!' }\n") do |path|
54
+ _out, err, status = run_cli_with_status(
55
+ '--error-mode', 'debug',
56
+ '--root', root,
57
+ '--resultset', 'coverage',
58
+ 'validate', path
59
+ )
60
+ expect(status).to eq(2)
61
+ expect(err).to include('Predicate error: Boom!')
62
+ # With trace mode, should show backtrace
63
+ expect(err).to match(/predicate.*\.rb:\d+/)
64
+ end
65
+ end
66
+
67
+ it 'exits 2 when predicate file is not found' do
68
+ _out, err, status = run_cli_with_status(
69
+ '--root', root,
70
+ '--resultset', 'coverage',
71
+ 'validate', '/nonexistent/predicate.rb'
72
+ )
73
+ expect(status).to eq(2)
74
+ expect(err).to include('Predicate file not found')
75
+ end
76
+
77
+ it 'exits 2 when predicate has syntax error' do
78
+ with_temp_predicate("-> { this is invalid syntax\n") do |path|
79
+ _out, err, status = run_cli_with_status(
80
+ '--root', root,
81
+ '--resultset', 'coverage',
82
+ 'validate', path
83
+ )
84
+ expect(status).to eq(2)
85
+ expect(err).to include('Syntax error in predicate file')
86
+ end
87
+ end
88
+
89
+ it 'exits 2 when predicate is not callable' do
90
+ with_temp_predicate("42\n") do |path|
91
+ _out, err, status = run_cli_with_status(
92
+ '--root', root,
93
+ '--resultset', 'coverage',
94
+ 'validate', path
95
+ )
96
+ expect(status).to eq(2)
97
+ expect(err).to include('Predicate must be callable')
98
+ end
99
+ end
100
+
101
+ it 'provides model to predicate that can query coverage' do
102
+ # Test that the predicate receives a working CoverageModel
103
+ with_temp_predicate(<<~RUBY) do |path|
104
+ ->(model) do
105
+ # Access coverage data via the model
106
+ summary = model.summary_for('lib/foo.rb')
107
+ summary['summary']['percentage'] > 50 # Should be true for foo.rb
108
+ end
109
+ RUBY
110
+ _out, _err, status = run_cli_with_status(
111
+ '--root', root,
112
+ '--resultset', 'coverage',
113
+ 'validate', path
114
+ )
115
+ expect(status).to eq(0)
116
+ end
117
+ end
118
+ end
119
+
120
+ describe 'validate subcommand with -i/--inline flag' do
121
+ it 'exits 0 when predicate code returns truthy value' do
122
+ _out, _err, status = run_cli_with_status(
123
+ '--root', root,
124
+ '--resultset', 'coverage',
125
+ 'validate', '-i', '->(model) { true }'
126
+ )
127
+ expect(status).to eq(0)
128
+ end
129
+
130
+ it 'exits 1 when predicate code returns falsy value' do
131
+ _out, _err, status = run_cli_with_status(
132
+ '--root', root,
133
+ '--resultset', 'coverage',
134
+ 'validate', '-i', '->(model) { false }'
135
+ )
136
+ expect(status).to eq(1)
137
+ end
138
+
139
+ it 'exits 2 when predicate code raises an error' do
140
+ _out, err, status = run_cli_with_status(
141
+ '--root', root,
142
+ '--resultset', 'coverage',
143
+ 'validate', '-i', "->(model) { raise 'Boom!' }"
144
+ )
145
+ expect(status).to eq(2)
146
+ expect(err).to include('Predicate error: Boom!')
147
+ end
148
+
149
+ it 'exits 2 when predicate code has syntax error' do
150
+ _out, err, status = run_cli_with_status(
151
+ '--root', root,
152
+ '--resultset', 'coverage',
153
+ 'validate', '-i', '-> { invalid syntax'
154
+ )
155
+ expect(status).to eq(2)
156
+ expect(err).to include('Syntax error in predicate code')
157
+ end
158
+
159
+ it 'exits 2 when predicate code is not callable' do
160
+ _out, err, status = run_cli_with_status(
161
+ '--root', root,
162
+ '--resultset', 'coverage',
163
+ 'validate', '-i', '42'
164
+ )
165
+ expect(status).to eq(2)
166
+ expect(err).to include('Predicate must be callable')
167
+ end
168
+
169
+ it 'provides model to predicate that can query coverage' do
170
+ code = <<~RUBY.strip
171
+ ->(model) { model.summary_for('lib/foo.rb')['summary']['percentage'] > 50 }
172
+ RUBY
173
+ _out, _err, status = run_cli_with_status(
174
+ '--root', root,
175
+ '--resultset', 'coverage',
176
+ 'validate', '-i', code
177
+ )
178
+ expect(status).to eq(0)
179
+ end
180
+ end
181
+
182
+ describe 'error handling' do
183
+ it 'raises error when no file or -i flag provided' do
184
+ _out, err, status = run_cli_with_status(
185
+ '--root', root,
186
+ '--resultset', 'coverage',
187
+ 'validate'
188
+ )
189
+ expect(status).to eq(1)
190
+ expect(err).to include('validate <file> | -i <code>')
191
+ end
192
+
193
+ it 'raises error when -i flag provided without code' do
194
+ _out, err, status = run_cli_with_status(
195
+ '--root', root,
196
+ '--resultset', 'coverage',
197
+ 'validate', '-i'
198
+ )
199
+ expect(status).to eq(1)
200
+ expect(err).to include('validate -i <code>')
201
+ end
202
+
203
+ it 'raises error when unknown option is provided' do
204
+ _out, err, status = run_cli_with_status(
205
+ '--root', root,
206
+ '--resultset', 'coverage',
207
+ 'validate', '--unknown-option'
208
+ )
209
+ expect(status).to eq(1)
210
+ expect(err).to include('Unknown option for validate: --unknown-option')
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/formatted_command_examples'
5
+
6
+ RSpec.describe SimpleCovMcp::Commands::VersionCommand do
7
+ let(:cli_context) { SimpleCovMcp::CoverageCLI.new }
8
+ let(:command) { described_class.new(cli_context) }
9
+
10
+ before do
11
+ cli_context.config.format = :table
12
+ end
13
+
14
+ describe '#execute' do
15
+ context 'with table format' do
16
+ it 'prints version, gem root, and documentation info in text mode' do
17
+ output = capture_command_output(command, [])
18
+
19
+ expect(output).to include('│', SimpleCovMcp::VERSION, 'Gem Root', 'Documentation',
20
+ 'README.md')
21
+ end
22
+
23
+ it 'includes a valid gem root path that exists' do
24
+ output = capture_command_output(command, [])
25
+
26
+ # Extract gem root from table output
27
+ gem_root_line = output.lines.find { |line| line.include?('Gem Root') }
28
+ expect(gem_root_line).not_to be_nil
29
+
30
+ parts = gem_root_line.split('│')
31
+ gem_root = parts[-2].strip
32
+ expect(File.directory?(gem_root)).to be true
33
+ end
34
+ end
35
+
36
+ it_behaves_like 'a command with formatted output', [], ['version', 'gem_root']
37
+ end
38
+ end
@@ -18,14 +18,14 @@ RSpec.describe SimpleCovMcp::Constants do
18
18
  expected_options = %w[
19
19
  -r --resultset
20
20
  -R --root
21
+ -f --format
21
22
  -o --sort-order
22
23
  -s --source
23
- -c --source-context
24
- -S --stale
24
+ -c --context-lines
25
+ -S --staleness
25
26
  -g --tracked-globs
26
27
  -l --log-file
27
28
  --error-mode
28
- --success-predicate
29
29
  ]
30
30
 
31
31
  expect(options).to eq(expected_options)
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::CoverageReporter do
6
+ let(:model) { instance_double(SimpleCovMcp::CoverageModel) }
7
+ # Data is pre-sorted by percentage ascending (as model.all_files returns)
8
+ let(:all_files_data) do
9
+ [
10
+ { 'file' => '/project/lib/zero.rb', 'percentage' => 0.0, 'covered' => 0, 'total' => 10 },
11
+ { 'file' => '/project/lib/low.rb', 'percentage' => 25.0, 'covered' => 5, 'total' => 20 },
12
+ { 'file' => '/project/lib/medium.rb', 'percentage' => 60.0, 'covered' => 12, 'total' => 20 },
13
+ { 'file' => '/project/lib/high.rb', 'percentage' => 95.0, 'covered' => 19, 'total' => 20 }
14
+ ]
15
+ end
16
+
17
+ before do
18
+ allow(model).to receive(:all_files).with(sort_order: :ascending).and_return(all_files_data)
19
+ allow(model).to receive(:relativize) do |files|
20
+ files.map { |f| f.merge('file' => f['file'].sub('/project/', '')) }
21
+ end
22
+ end
23
+
24
+ describe '.report' do
25
+ it 'returns formatted low coverage files string' do
26
+ result = described_class.report(threshold: 80, count: 5, model: model)
27
+
28
+ expect(result).to be_a(String)
29
+ expect(result).to include('Lowest coverage files (< 80%):')
30
+ expect(result).to include('lib/zero.rb')
31
+ end
32
+
33
+ it 'includes files below threshold sorted by coverage ascending' do
34
+ result = described_class.report(threshold: 80, count: 5, model: model)
35
+
36
+ expect(result).to include('lib/zero.rb', 'lib/low.rb', 'lib/medium.rb')
37
+ expect(result).not_to include('lib/high.rb')
38
+ end
39
+
40
+ it 'respects count parameter' do
41
+ result = described_class.report(threshold: 80, count: 2, model: model)
42
+
43
+ expect(result).to include('lib/zero.rb')
44
+ expect(result).to include('lib/low.rb')
45
+ expect(result).not_to include('lib/medium.rb')
46
+ end
47
+
48
+ it 'returns nil when no files below threshold' do
49
+ result = described_class.report(threshold: 0, count: 5, model: model)
50
+
51
+ expect(result).to be_nil
52
+ end
53
+
54
+ it 'uses threshold in header' do
55
+ result = described_class.report(threshold: 90, count: 5, model: model)
56
+
57
+ expect(result).to include('< 90%')
58
+ end
59
+
60
+ it 'uses default threshold of 80' do
61
+ result = described_class.report(count: 5, model: model)
62
+
63
+ expect(result).to include('< 80%')
64
+ expect(result).not_to include('lib/high.rb')
65
+ end
66
+
67
+ it 'uses default count of 5' do
68
+ result = described_class.report(threshold: 100, model: model)
69
+
70
+ # All 4 files are below 100%
71
+ expect(result).to include('lib/zero.rb')
72
+ expect(result).to include('lib/high.rb')
73
+ end
74
+
75
+ it 'relativizes file paths' do
76
+ result = described_class.report(threshold: 80, count: 5, model: model)
77
+
78
+ expect(result).to include('lib/zero.rb')
79
+ expect(result).not_to include('/project/')
80
+ end
81
+
82
+ it 'aligns percentages correctly' do
83
+ result = described_class.report(threshold: 100, count: 5, model: model)
84
+ lines = result.split("\n")
85
+
86
+ # lines[0] is empty (leading newline), lines[1] is header, lines[2..] are data
87
+ expect(lines[2]).to match(/^\s+0\.0%/)
88
+ expect(lines[3]).to match(/^\s+25\.0%/)
89
+ end
90
+ end
91
+
92
+ describe 'module_function behavior' do
93
+ it 'report is available as a module method' do
94
+ expect(described_class).to respond_to(:report)
95
+ end
96
+
97
+ it 'report is available as a private instance method when included' do
98
+ klass = Class.new { include SimpleCovMcp::CoverageReporter }
99
+ expect(klass.private_instance_methods).to include(:report)
100
+ end
101
+ end
102
+ end
@@ -11,10 +11,10 @@ RSpec.describe SimpleCovMcp::Tools::CoverageTableTool do
11
11
  setup_mcp_response_stub
12
12
  end
13
13
 
14
- def run_tool(stale: 'off')
14
+ def run_tool(staleness: :off)
15
15
  # Let real CoverageModel work to test actual format_table behavior
16
- described_class.call(root: root, stale: stale,
17
- server_context: server_context).payload.first[:text]
16
+ described_class.call(root: root, staleness: staleness,
17
+ server_context: server_context).payload.first['text']
18
18
  end
19
19
 
20
20
  it 'returns a formatted table as a string' do
@@ -30,12 +30,15 @@ RSpec.describe SimpleCovMcp::Tools::CoverageTableTool do
30
30
  end
31
31
 
32
32
  it 'configures CLI to enforce stale checking when requested' do
33
- model = instance_double(SimpleCovMcp::CoverageModel)
34
- allow(model).to receive(:all_files).and_return([
35
- { 'file' => "#{root}/lib/foo.rb", 'percentage' => 100.0, 'covered' => 10, 'total' => 10, 'stale' => false }
36
- ])
37
- allow(model).to receive(:relativize) { |payload| payload }
38
- expect(SimpleCovMcp::CoverageModel).to receive(:new).with(
33
+ model = instance_double(SimpleCovMcp::CoverageModel,
34
+ all_files: [
35
+ { 'file' => "#{root}/lib/foo.rb", 'percentage' => 100.0, 'covered' => 10, 'total' => 10,
36
+ 'stale' => false }
37
+ ],
38
+ relativize: ->(payload) { payload },
39
+ format_table: 'Mock table output'
40
+ )
41
+ allow(SimpleCovMcp::CoverageModel).to receive(:new).with(
39
42
  root: root,
40
43
  resultset: nil,
41
44
  staleness: :error,
@@ -43,6 +46,14 @@ RSpec.describe SimpleCovMcp::Tools::CoverageTableTool do
43
46
  ).and_return(model)
44
47
  allow(model).to receive(:format_table).and_return('Mock table output')
45
48
 
46
- described_class.call(root: root, stale: 'error', server_context: server_context)
49
+ described_class.call(root: root, staleness: :error, server_context: server_context)
50
+
51
+ expect(SimpleCovMcp::CoverageModel).to have_received(:new).with(
52
+ root: root,
53
+ resultset: nil,
54
+ staleness: :error,
55
+ tracked_globs: nil
56
+ )
57
+ expect(model).to have_received(:format_table)
47
58
  end
48
59
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'simplecov_mcp/tools/coverage_totals_tool'
5
+
6
+ RSpec.describe SimpleCovMcp::Tools::CoverageTotalsTool do
7
+ subject(:tool_response) { described_class.call(root: root, server_context: server_context) }
8
+
9
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
10
+ let(:server_context) { instance_double('ServerContext').as_null_object }
11
+
12
+ before do
13
+ setup_mcp_response_stub
14
+ model = instance_double(SimpleCovMcp::CoverageModel)
15
+ allow(SimpleCovMcp::CoverageModel).to receive(:new).and_return(model)
16
+
17
+ payload = {
18
+ 'lines' => { 'total' => 42, 'covered' => 40, 'uncovered' => 2 },
19
+ 'percentage' => 95.24,
20
+ 'files' => { 'total' => 4, 'ok' => 4, 'stale' => 0 }
21
+ }
22
+
23
+ presenter = instance_double(SimpleCovMcp::Presenters::ProjectTotalsPresenter)
24
+ allow(SimpleCovMcp::Presenters::ProjectTotalsPresenter).to receive(:new).and_return(presenter)
25
+ allow(presenter).to receive(:relativized_payload).and_return(payload)
26
+ end
27
+
28
+ it_behaves_like 'an MCP tool that returns text JSON'
29
+
30
+ it 'returns aggregated totals' do
31
+ data, = expect_mcp_text_json(tool_response, expected_keys: ['lines', 'percentage', 'files'])
32
+
33
+ expect(data['lines']).to include('total' => 42, 'covered' => 40, 'uncovered' => 2)
34
+ expect(data['files']).to include('total' => 4, 'stale' => 0)
35
+ expect(data['percentage']).to eq(95.24)
36
+ end
37
+ end
@@ -3,16 +3,17 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe SimpleCovMcp::ErrorHandler do
6
+ subject(:handler) { described_class.new(error_mode: :log, logger: logger) }
7
+
6
8
  let(:logger) do
7
9
  Class.new do
8
10
  attr_reader :messages
9
11
 
10
- def initialize; @messages = []; end
11
- def error(msg); @messages << msg; end
12
+ def initialize = @messages = []
13
+ def error(msg) = @messages << msg
12
14
  end.new
13
15
  end
14
16
 
15
- subject(:handler) { described_class.new(error_mode: :on, logger: logger) }
16
17
 
17
18
  it 'maps filesystem errors to friendly custom errors' do
18
19
  e = handler.convert_standard_error(Errno::EISDIR.new('Is a directory @ rb_sysopen - a_dir'))
@@ -73,9 +74,124 @@ RSpec.describe SimpleCovMcp::ErrorHandler do
73
74
  begin
74
75
  handler.handle_error(Errno::ENOENT.new('No such file or directory @ rb_sysopen - x'),
75
76
  context: 'test', reraise: false)
76
- rescue StandardError
77
+ rescue
77
78
  # reraise disabled
78
79
  end
79
80
  expect(logger.messages.join).to include('Error in test')
80
81
  end
82
+
83
+ it 'converts TypeError to CoverageDataError for invalid data structures' do
84
+ error = TypeError.new('wrong argument type')
85
+ result = handler.convert_standard_error(error)
86
+
87
+ expect(result).to be_a(SimpleCovMcp::CoverageDataError)
88
+ expect(result.user_friendly_message).to include('Invalid coverage data structure')
89
+ end
90
+
91
+ it 'returns generic Error for unrecognized SystemCallError' do
92
+ error = Errno::EEXIST.new('File exists')
93
+ result = handler.convert_standard_error(error)
94
+
95
+ expect(result).to be_a(SimpleCovMcp::Error)
96
+ expect(result.user_friendly_message).to include('An unexpected error occurred')
97
+ end
98
+
99
+ it 'handles NoMethodError with non-standard message format' do
100
+ error = NoMethodError.new('some weird error message without the expected pattern')
101
+ result = handler.convert_standard_error(error)
102
+
103
+ expect(result).to be_a(SimpleCovMcp::CoverageDataError)
104
+ expect(result.user_friendly_message).to include('some weird error message')
105
+ end
106
+
107
+ describe 'else branch for non-StandardError exceptions' do
108
+ # This tests the else clause in convert_standard_error for exceptions
109
+ # that don't inherit from StandardError
110
+ it 'returns generic Error for Exception subclasses not inheriting from StandardError' do
111
+ # Create a custom exception that inherits from Exception, not StandardError
112
+ custom_exception_class = Class.new(StandardError) do
113
+ def message
114
+ 'Custom non-standard exception'
115
+ end
116
+ end
117
+
118
+ error = custom_exception_class.new
119
+ result = handler.convert_standard_error(error)
120
+
121
+ expect(result).to be_a(SimpleCovMcp::Error)
122
+ expect(result.user_friendly_message).to include('An unexpected error occurred')
123
+ expect(result.user_friendly_message).to include('Custom non-standard exception')
124
+ end
125
+
126
+ it 'returns generic Error for ScriptError subclasses' do
127
+ # ScriptError inherits from Exception, not StandardError
128
+ error = NotImplementedError.new('This feature is not implemented')
129
+ result = handler.convert_standard_error(error)
130
+
131
+ expect(result).to be_a(SimpleCovMcp::Error)
132
+ expect(result.user_friendly_message).to include('An unexpected error occurred')
133
+ end
134
+ end
135
+
136
+ describe 'extract_method_info fallback' do
137
+ # This tests the fallback path in extract_method_info when NoMethodError
138
+ # message doesn't match the expected pattern
139
+ it 'returns original message when pattern does not match' do
140
+ # Test various NoMethodError formats that won't match the regex
141
+ test_messages = [
142
+ 'method not found',
143
+ 'private method called',
144
+ 'undefined local variable or method',
145
+ ''
146
+ ]
147
+
148
+ test_messages.each do |msg|
149
+ error = NoMethodError.new(msg)
150
+ result = handler.convert_standard_error(error)
151
+
152
+ expect(result).to be_a(SimpleCovMcp::CoverageDataError)
153
+ # The original message should be preserved
154
+ expect(result.message).to include(msg) unless msg.empty?
155
+ end
156
+ end
157
+ end
158
+
159
+ # ErrorHandler#convert_runtime_error handles RuntimeErrors differently based on context:
160
+ # - :coverage_loading assumes errors relate to coverage data and maps them to
161
+ # CoverageDataError or ResultsetNotFoundError
162
+ # - :general (or any other context) maps unrecognized errors to generic Error
163
+ # This tests the final else branch in convert_runtime_error.
164
+ describe 'convert_runtime_error with general context' do
165
+ it 'converts RuntimeError with unrecognized message to generic Error' do
166
+ error = RuntimeError.new('Some completely unexpected runtime error')
167
+
168
+ result = handler.convert_standard_error(error, context: :general)
169
+
170
+ expect(result).to be_a(SimpleCovMcp::Error)
171
+ expect(result.user_friendly_message)
172
+ .to include('An unexpected error occurred', 'unexpected runtime error')
173
+ end
174
+ end
175
+
176
+ describe '#handle_error with reraise' do
177
+ it 're-raises SimpleCovMcp::Error when reraise is true' do
178
+ error = SimpleCovMcp::FileNotFoundError.new('Test file not found')
179
+
180
+ expect { handler.handle_error(error, context: 'test', reraise: true) }
181
+ .to raise_error(SimpleCovMcp::FileNotFoundError, 'Test file not found')
182
+
183
+ # Verify it was logged
184
+ expect(logger.messages.join).to include('Error in test')
185
+ end
186
+
187
+ it 'converts and re-raises StandardError when reraise is true' do
188
+ error = Errno::ENOENT.new('No such file or directory @ rb_sysopen - missing.rb')
189
+
190
+ expect { handler.handle_error(error, context: 'test', reraise: true) }
191
+ .to raise_error(SimpleCovMcp::FileNotFoundError)
192
+
193
+ # Verify it was logged
194
+ expect(logger.messages.join).to include('Error in test')
195
+ end
196
+ end
81
197
  end