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,30 +3,30 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe SimpleCovMcp::CoverageModel do
6
- let(:root) { (FIXTURES / 'project1').to_s }
7
-
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+
8
8
  def with_stubbed_coverage_timestamp(ts) = begin
9
- allow(SimpleCovMcp::CovUtil).to receive(:latest_timestamp).and_return(ts)
9
+ mock_resultset_with_timestamp(root, ts)
10
10
  yield
11
11
  end
12
12
 
13
13
  it "raises stale error when staleness mode is 'error' and file is newer" do
14
- with_stubbed_coverage_timestamp(0) do
14
+ with_stubbed_coverage_timestamp(VERY_OLD_TIMESTAMP) do
15
15
  model = described_class.new(root: root, staleness: 'error')
16
- expect {
16
+ expect do
17
17
  model.summary_for('lib/foo.rb')
18
- }.to raise_error(SimpleCovMcp::CoverageDataStaleError, /stale/i)
18
+ end.to raise_error(SimpleCovMcp::CoverageDataStaleError, /stale/i)
19
19
  end
20
20
  end
21
21
 
22
22
  it "does not check staleness when mode is 'off'" do
23
- with_stubbed_coverage_timestamp(0) do
23
+ with_stubbed_coverage_timestamp(VERY_OLD_TIMESTAMP) do
24
24
  model = described_class.new(root: root, staleness: 'off')
25
25
  expect { model.summary_for('lib/foo.rb') }.not_to raise_error
26
26
  end
27
27
  end
28
28
  it 'all_files raises project-level stale when any source file is newer than coverage' do
29
- with_stubbed_coverage_timestamp(0) do
29
+ with_stubbed_coverage_timestamp(VERY_OLD_TIMESTAMP) do
30
30
  model = described_class.new(root: root, staleness: 'error')
31
31
  expect { model.all_files }.to raise_error(SimpleCovMcp::CoverageDataProjectStaleError)
32
32
  end
@@ -38,12 +38,42 @@ RSpec.describe SimpleCovMcp::CoverageModel do
38
38
  begin
39
39
  File.write(tmp, "# new file\n")
40
40
  model = described_class.new(root: root, staleness: 'error')
41
- expect {
41
+ expect do
42
42
  model.all_files(tracked_globs: ['lib/**/*.rb'])
43
- }.to raise_error(SimpleCovMcp::CoverageDataProjectStaleError)
43
+ end.to raise_error(SimpleCovMcp::CoverageDataProjectStaleError)
44
44
  ensure
45
45
  File.delete(tmp) if File.exist?(tmp)
46
46
  end
47
47
  end
48
48
  end
49
+
50
+ describe 'timestamp normalization' do
51
+ it 'parses created_at strings to epoch seconds' do
52
+ created_at = Time.new(2024, 7, 3, 16, 26, 40, '-07:00')
53
+ mock_resultset_with_created_at(root, created_at.strftime('%Y-%m-%d %H:%M:%S %z'))
54
+
55
+ model = described_class.new(root: root, staleness: 'off')
56
+
57
+ expect(model.instance_variable_get(:@cov_timestamp)).to eq(created_at.to_i)
58
+ end
59
+
60
+ it 'propagates parsed created_at timestamps into stale errors' do
61
+ file_mtime = File.mtime(File.join(root, 'lib', 'foo.rb'))
62
+ created_at_time = (file_mtime + 3600).utc
63
+ # Use mismatched coverage (3 lines instead of 4) to trigger staleness
64
+ mismatched_coverage = {
65
+ File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, nil] }
66
+ }
67
+ mock_resultset_with_created_at(root, created_at_time.iso8601, coverage: mismatched_coverage)
68
+
69
+ model = described_class.new(root: root, staleness: 'error')
70
+
71
+ expect(model.instance_variable_get(:@cov_timestamp)).to eq(created_at_time.to_i)
72
+ expect do
73
+ model.summary_for('lib/foo.rb')
74
+ end.to raise_error(SimpleCovMcp::CoverageDataStaleError) { |error|
75
+ expect(error.cov_timestamp).to eq(created_at_time.to_i)
76
+ }
77
+ end
78
+ end
49
79
  end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::OptionNormalizers do
6
+ describe '.normalize_sort_order' do
7
+ context 'with strict mode (default)' do
8
+ it 'normalizes "a" to :ascending' do
9
+ expect(described_class.normalize_sort_order('a')).to eq(:ascending)
10
+ end
11
+
12
+ it 'normalizes "ascending" to :ascending' do
13
+ expect(described_class.normalize_sort_order('ascending')).to eq(:ascending)
14
+ end
15
+
16
+ it 'normalizes "d" to :descending' do
17
+ expect(described_class.normalize_sort_order('d')).to eq(:descending)
18
+ end
19
+
20
+ it 'normalizes "descending" to :descending' do
21
+ expect(described_class.normalize_sort_order('descending')).to eq(:descending)
22
+ end
23
+
24
+ it 'is case-insensitive' do
25
+ expect(described_class.normalize_sort_order('ASCENDING')).to eq(:ascending)
26
+ expect(described_class.normalize_sort_order('Descending')).to eq(:descending)
27
+ end
28
+
29
+ it 'raises OptionParser::InvalidArgument for invalid values' do
30
+ expect { described_class.normalize_sort_order('invalid') }
31
+ .to raise_error(OptionParser::InvalidArgument, /invalid argument: invalid/)
32
+ end
33
+ end
34
+
35
+ context 'with strict: false' do
36
+ it 'returns nil for invalid values' do
37
+ expect(described_class.normalize_sort_order('invalid', strict: false)).to be_nil
38
+ end
39
+
40
+ it 'still normalizes valid values' do
41
+ expect(described_class.normalize_sort_order('a', strict: false)).to eq(:ascending)
42
+ end
43
+ end
44
+ end
45
+
46
+ describe '.normalize_source_mode' do
47
+ context 'with strict mode (default)' do
48
+ it 'normalizes nil to :full' do
49
+ expect(described_class.normalize_source_mode(nil)).to eq(:full)
50
+ end
51
+
52
+ it 'normalizes empty string to :full' do
53
+ expect(described_class.normalize_source_mode('')).to eq(:full)
54
+ end
55
+
56
+ it 'normalizes "f" to :full' do
57
+ expect(described_class.normalize_source_mode('f')).to eq(:full)
58
+ end
59
+
60
+ it 'normalizes "full" to :full' do
61
+ expect(described_class.normalize_source_mode('full')).to eq(:full)
62
+ end
63
+
64
+ it 'normalizes "u" to :uncovered' do
65
+ expect(described_class.normalize_source_mode('u')).to eq(:uncovered)
66
+ end
67
+
68
+ it 'normalizes "uncovered" to :uncovered' do
69
+ expect(described_class.normalize_source_mode('uncovered')).to eq(:uncovered)
70
+ end
71
+
72
+ it 'is case-insensitive' do
73
+ expect(described_class.normalize_source_mode('FULL')).to eq(:full)
74
+ expect(described_class.normalize_source_mode('Uncovered')).to eq(:uncovered)
75
+ end
76
+
77
+ it 'raises OptionParser::InvalidArgument for invalid values' do
78
+ expect { described_class.normalize_source_mode('invalid') }
79
+ .to raise_error(OptionParser::InvalidArgument, /invalid argument: invalid/)
80
+ end
81
+ end
82
+
83
+ context 'with strict: false' do
84
+ it 'returns nil for invalid values' do
85
+ expect(described_class.normalize_source_mode('invalid', strict: false)).to be_nil
86
+ end
87
+
88
+ it 'still normalizes valid values' do
89
+ expect(described_class.normalize_source_mode('u', strict: false)).to eq(:uncovered)
90
+ end
91
+ end
92
+ end
93
+
94
+ describe '.normalize_stale_mode' do
95
+ context 'with strict mode (default)' do
96
+ it 'normalizes "o" to :off' do
97
+ expect(described_class.normalize_stale_mode('o')).to eq(:off)
98
+ end
99
+
100
+ it 'normalizes "off" to :off' do
101
+ expect(described_class.normalize_stale_mode('off')).to eq(:off)
102
+ end
103
+
104
+ it 'normalizes "e" to :error' do
105
+ expect(described_class.normalize_stale_mode('e')).to eq(:error)
106
+ end
107
+
108
+ it 'normalizes "error" to :error' do
109
+ expect(described_class.normalize_stale_mode('error')).to eq(:error)
110
+ end
111
+
112
+ it 'is case-insensitive' do
113
+ expect(described_class.normalize_stale_mode('OFF')).to eq(:off)
114
+ expect(described_class.normalize_stale_mode('Error')).to eq(:error)
115
+ end
116
+
117
+ it 'raises OptionParser::InvalidArgument for invalid values' do
118
+ expect { described_class.normalize_stale_mode('invalid') }
119
+ .to raise_error(OptionParser::InvalidArgument, /invalid argument: invalid/)
120
+ end
121
+ end
122
+
123
+ context 'with strict: false' do
124
+ it 'returns nil for invalid values' do
125
+ expect(described_class.normalize_stale_mode('invalid', strict: false)).to be_nil
126
+ end
127
+
128
+ it 'still normalizes valid values' do
129
+ expect(described_class.normalize_stale_mode('e', strict: false)).to eq(:error)
130
+ end
131
+ end
132
+ end
133
+
134
+ describe '.normalize_error_mode' do
135
+ context 'with strict mode (default)' do
136
+ it 'normalizes "off" to :off' do
137
+ expect(described_class.normalize_error_mode('off')).to eq(:off)
138
+ end
139
+
140
+ it 'normalizes "on" to :on' do
141
+ expect(described_class.normalize_error_mode('on')).to eq(:on)
142
+ end
143
+
144
+ it 'normalizes "trace" to :trace' do
145
+ expect(described_class.normalize_error_mode('trace')).to eq(:trace)
146
+ end
147
+
148
+ it 'normalizes "t" to :trace' do
149
+ expect(described_class.normalize_error_mode('t')).to eq(:trace)
150
+ end
151
+
152
+ it 'is case-insensitive' do
153
+ expect(described_class.normalize_error_mode('OFF')).to eq(:off)
154
+ expect(described_class.normalize_error_mode('On')).to eq(:on)
155
+ expect(described_class.normalize_error_mode('TRACE')).to eq(:trace)
156
+ end
157
+
158
+ it 'raises OptionParser::InvalidArgument for invalid values' do
159
+ expect { described_class.normalize_error_mode('invalid') }
160
+ .to raise_error(OptionParser::InvalidArgument, /invalid argument: invalid/)
161
+ end
162
+ end
163
+
164
+ context 'with strict: false and default: :on' do
165
+ it 'returns default for invalid values' do
166
+ expect(described_class.normalize_error_mode('invalid', strict: false,
167
+ default: :on)).to eq(:on)
168
+ end
169
+
170
+ it 'returns default for nil' do
171
+ expect(described_class.normalize_error_mode(nil, strict: false, default: :on)).to eq(:on)
172
+ end
173
+
174
+ it 'still normalizes valid values' do
175
+ expect(described_class.normalize_error_mode('off', strict: false)).to eq(:off)
176
+ end
177
+ end
178
+
179
+ context 'with custom default' do
180
+ it 'returns custom default for invalid values when not strict' do
181
+ expect(described_class.normalize_error_mode('invalid', strict: false,
182
+ default: :off)).to eq(:off)
183
+ end
184
+ end
185
+ end
186
+
187
+ describe 'constant maps' do
188
+ it 'has frozen SORT_ORDER_MAP' do
189
+ expect(described_class::SORT_ORDER_MAP).to be_frozen
190
+ end
191
+
192
+ it 'has frozen SOURCE_MODE_MAP' do
193
+ expect(described_class::SOURCE_MODE_MAP).to be_frozen
194
+ end
195
+
196
+ it 'has frozen STALE_MODE_MAP' do
197
+ expect(described_class::STALE_MODE_MAP).to be_frozen
198
+ end
199
+
200
+ it 'has frozen ERROR_MODE_MAP' do
201
+ expect(described_class::ERROR_MODE_MAP).to be_frozen
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::OptionParsers::EnvOptionsParser do
6
+ let(:parser) { described_class.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 '#parse_env_opts' do
16
+ context 'with valid inputs' do
17
+ it 'returns empty array when environment variable is not set' do
18
+ ENV.delete('SIMPLECOV_MCP_OPTS')
19
+ expect(parser.parse_env_opts).to eq([])
20
+ end
21
+
22
+ it 'returns empty array when environment variable is empty string' do
23
+ ENV['SIMPLECOV_MCP_OPTS'] = ''
24
+ expect(parser.parse_env_opts).to eq([])
25
+ end
26
+
27
+ it 'returns empty array when environment variable contains only whitespace' do
28
+ ENV['SIMPLECOV_MCP_OPTS'] = ' '
29
+ expect(parser.parse_env_opts).to eq([])
30
+ end
31
+
32
+ it 'parses simple options correctly' do
33
+ ENV['SIMPLECOV_MCP_OPTS'] = '--error-mode off --json'
34
+ expect(parser.parse_env_opts).to eq(['--error-mode', 'off', '--json'])
35
+ end
36
+
37
+ it 'handles quoted strings with spaces' do
38
+ ENV['SIMPLECOV_MCP_OPTS'] = '--resultset "/path/to/my file.json"'
39
+ expect(parser.parse_env_opts).to eq(['--resultset', '/path/to/my file.json'])
40
+ end
41
+
42
+ it 'handles complex shell escaping scenarios' do
43
+ ENV['SIMPLECOV_MCP_OPTS'] = '--resultset "/path/with spaces/file.json" --error-mode on'
44
+ expect(parser.parse_env_opts)
45
+ .to eq(['--resultset', '/path/with spaces/file.json', '--error-mode', 'on'])
46
+ end
47
+
48
+ it 'handles single quotes' do
49
+ ENV['SIMPLECOV_MCP_OPTS'] = "--resultset '/path/with spaces/file.json'"
50
+ expect(parser.parse_env_opts).to eq(['--resultset', '/path/with spaces/file.json'])
51
+ end
52
+
53
+ it 'handles escaped characters' do
54
+ ENV['SIMPLECOV_MCP_OPTS'] = '--resultset /path/with\\ spaces/file.json'
55
+ expect(parser.parse_env_opts).to eq(['--resultset', '/path/with spaces/file.json'])
56
+ end
57
+
58
+ it 'handles mixed quoting styles' do
59
+ ENV['SIMPLECOV_MCP_OPTS'] = '--option1 "value with spaces" --option2 \'another value\''
60
+ expect(parser.parse_env_opts).to eq(
61
+ ['--option1', 'value with spaces', '--option2', 'another value']
62
+ )
63
+ end
64
+ end
65
+
66
+ context 'with malformed inputs' do
67
+ it 'raises ConfigurationError for unmatched double quotes' do
68
+ ENV['SIMPLECOV_MCP_OPTS'] = '--resultset "unterminated string'
69
+
70
+ expect do
71
+ parser.parse_env_opts
72
+ end.to raise_error(SimpleCovMcp::ConfigurationError, /Invalid SIMPLECOV_MCP_OPTS format/)
73
+ end
74
+
75
+ it 'raises ConfigurationError for unmatched single quotes' do
76
+ ENV['SIMPLECOV_MCP_OPTS'] = "--resultset 'unterminated string"
77
+
78
+ expect do
79
+ parser.parse_env_opts
80
+ end.to raise_error(SimpleCovMcp::ConfigurationError, /Invalid SIMPLECOV_MCP_OPTS format/)
81
+ end
82
+
83
+ it 'raises ConfigurationError with descriptive message' do
84
+ ENV['SIMPLECOV_MCP_OPTS'] = '--option "bad quote'
85
+
86
+ expect do
87
+ parser.parse_env_opts
88
+ end.to raise_error(SimpleCovMcp::ConfigurationError) do |error|
89
+ expect(error.message).to include('Invalid SIMPLECOV_MCP_OPTS format')
90
+ expect(error.message).to include('Unmatched') # from Shellwords error
91
+ end
92
+ end
93
+
94
+ it 'handles multiple quoting errors' do
95
+ ENV['SIMPLECOV_MCP_OPTS'] = '"first "second "third'
96
+
97
+ expect do
98
+ parser.parse_env_opts
99
+ end.to raise_error(SimpleCovMcp::ConfigurationError, /Invalid SIMPLECOV_MCP_OPTS format/)
100
+ end
101
+ end
102
+ end
103
+
104
+ describe '#pre_scan_error_mode' do
105
+ let(:error_mode_normalizer) { parser.send(:method, :normalize_error_mode) }
106
+
107
+ context 'when error-mode is found' do
108
+ it 'extracts error-mode with space separator' do
109
+ argv = ['--error-mode', 'trace', '--other-option']
110
+ result = parser.pre_scan_error_mode(argv, error_mode_normalizer: error_mode_normalizer)
111
+ expect(result).to eq(:trace)
112
+ end
113
+
114
+ it 'extracts error-mode with equals separator' do
115
+ argv = ['--error-mode=off', '--other-option']
116
+ result = parser.pre_scan_error_mode(argv, error_mode_normalizer: error_mode_normalizer)
117
+ expect(result).to eq(:off)
118
+ end
119
+
120
+ it 'handles error-mode with equals but empty value' do
121
+ argv = ['--error-mode=', '--other-option']
122
+ result = parser.pre_scan_error_mode(argv, error_mode_normalizer: error_mode_normalizer)
123
+ # Empty value after = explicitly returns nil (line 32)
124
+ expect(result).to be_nil
125
+ end
126
+
127
+ it 'returns first error-mode when multiple are present' do
128
+ argv = ['--error-mode', 'on', '--error-mode', 'off']
129
+ result = parser.pre_scan_error_mode(argv, error_mode_normalizer: error_mode_normalizer)
130
+ expect(result).to eq(:on)
131
+ end
132
+ end
133
+
134
+ context 'when error-mode is not found' do
135
+ it 'returns nil when no error-mode is present' do
136
+ argv = ['--other-option', 'value']
137
+ result = parser.pre_scan_error_mode(argv, error_mode_normalizer: error_mode_normalizer)
138
+ expect(result).to be_nil
139
+ end
140
+
141
+ it 'returns nil for empty argv' do
142
+ argv = []
143
+ result = parser.pre_scan_error_mode(argv, error_mode_normalizer: error_mode_normalizer)
144
+ expect(result).to be_nil
145
+ end
146
+ end
147
+
148
+ context 'error handling during pre-scan' do
149
+ it 'returns nil when normalizer raises an error' do
150
+ faulty_normalizer = ->(value) { raise StandardError, 'Intentional error' }
151
+ argv = ['--error-mode', 'on']
152
+
153
+ result = parser.pre_scan_error_mode(argv, error_mode_normalizer: faulty_normalizer)
154
+ expect(result).to be_nil
155
+ end
156
+
157
+ it 'returns nil when normalizer raises ArgumentError' do
158
+ faulty_normalizer = ->(value) { raise ArgumentError, 'Bad argument' }
159
+ argv = ['--error-mode', 'on']
160
+
161
+ result = parser.pre_scan_error_mode(argv, error_mode_normalizer: faulty_normalizer)
162
+ expect(result).to be_nil
163
+ end
164
+
165
+ it 'returns nil when normalizer raises RuntimeError' do
166
+ faulty_normalizer = ->(value) { raise RuntimeError, 'Runtime problem' }
167
+ argv = ['--error-mode=off']
168
+
169
+ result = parser.pre_scan_error_mode(argv, error_mode_normalizer: faulty_normalizer)
170
+ expect(result).to be_nil
171
+ end
172
+ end
173
+ end
174
+
175
+ describe 'integration with ErrorHandlerFactory' do
176
+ it 'maps trace alias to an accepted error_mode' do
177
+ mode = parser.pre_scan_error_mode(['--error-mode', 'trace'])
178
+ expect { SimpleCovMcp::ErrorHandlerFactory.for_cli(error_mode: mode) }.not_to raise_error
179
+ expect(mode).to eq(:trace)
180
+ end
181
+ end
182
+
183
+ describe '#normalize_error_mode (private)' do
184
+ it 'normalizes "off" to :off' do
185
+ expect(parser.send(:normalize_error_mode, 'off')).to eq(:off)
186
+ expect(parser.send(:normalize_error_mode, 'OFF')).to eq(:off)
187
+ expect(parser.send(:normalize_error_mode, 'Off')).to eq(:off)
188
+ end
189
+
190
+ it 'normalizes "on" to :on' do
191
+ expect(parser.send(:normalize_error_mode, 'on')).to eq(:on)
192
+ expect(parser.send(:normalize_error_mode, 'ON')).to eq(:on)
193
+ end
194
+
195
+ it 'normalizes "trace" to :trace' do
196
+ expect(parser.send(:normalize_error_mode, 'trace')).to eq(:trace)
197
+ expect(parser.send(:normalize_error_mode, 'TRACE')).to eq(:trace)
198
+ end
199
+
200
+ it 'normalizes "t" to :trace' do
201
+ expect(parser.send(:normalize_error_mode, 't')).to eq(:trace)
202
+ expect(parser.send(:normalize_error_mode, 'T')).to eq(:trace)
203
+ end
204
+
205
+ it 'defaults unknown values to :on' do
206
+ expect(parser.send(:normalize_error_mode, 'unknown')).to eq(:on)
207
+ expect(parser.send(:normalize_error_mode, 'invalid')).to eq(:on)
208
+ expect(parser.send(:normalize_error_mode, '')).to eq(:on)
209
+ end
210
+
211
+ it 'handles nil by defaulting to :on' do
212
+ expect(parser.send(:normalize_error_mode, nil)).to eq(:on)
213
+ end
214
+ end
215
+
216
+ describe 'custom environment variable name' do
217
+ it 'uses custom environment variable when specified' do
218
+ custom_parser = described_class.new(env_var: 'CUSTOM_OPTS')
219
+ ENV['CUSTOM_OPTS'] = '--error-mode off'
220
+
221
+ expect(custom_parser.parse_env_opts).to eq(['--error-mode', 'off'])
222
+ end
223
+
224
+ it 'includes custom env var name in error messages' do
225
+ custom_parser = described_class.new(env_var: 'MY_CUSTOM_VAR')
226
+ ENV['MY_CUSTOM_VAR'] = '"bad quote'
227
+
228
+ expect do
229
+ custom_parser.parse_env_opts
230
+ end.to raise_error(SimpleCovMcp::ConfigurationError, /Invalid MY_CUSTOM_VAR format/)
231
+ end
232
+ end
233
+ end