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,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+
6
+ RSpec.shared_examples 'a command with formatted output' do |command_args, expected_json_keys|
7
+ context 'with json format' do
8
+ before { cli_context.config.format = :json }
9
+
10
+ it 'outputs valid JSON' do
11
+ output = capture_command_output(command, command_args)
12
+ json = JSON.parse(output)
13
+
14
+ if expected_json_keys.is_a?(Array)
15
+ expected_json_keys.each { |k| expect(json).to have_key(k) }
16
+ elsif expected_json_keys.is_a?(Hash)
17
+ expected_json_keys.each do |k, v|
18
+ expect(json).to have_key(k)
19
+ # Skip deep comparison if v is nil, just check key existence
20
+ expect(json[k]).to eq(v) if v
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ context 'with yaml format' do
27
+ before { cli_context.config.format = :yaml }
28
+
29
+ it 'outputs valid YAML' do
30
+ output = capture_command_output(command, command_args)
31
+ # Allow Symbol for keys that might be symbols (e.g. from version command)
32
+ yaml = YAML.safe_load(output, permitted_classes: [Symbol])
33
+
34
+ if expected_json_keys.is_a?(Array)
35
+ expected_json_keys.each do |k|
36
+ # Check for string or symbol key
37
+ expect(yaml).to have_key(k).or have_key(k.to_sym)
38
+ end
39
+ elsif expected_json_keys.is_a?(Hash)
40
+ expected_json_keys.each do |k, v|
41
+ val = yaml.key?(k) ? yaml[k] : yaml[k.to_sym]
42
+ expect(val).not_to be_nil
43
+ expect(val).to eq(v) if v
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ context 'with awesome_print format' do
50
+ before { cli_context.config.format = :awesome_print }
51
+
52
+ it 'outputs awesome_print formatted string' do
53
+ output = capture_command_output(command, command_args)
54
+ # Strip ANSI color codes for matching
55
+ plain_output = output.gsub(/\e\[([;\d]+)?m/, '')
56
+
57
+ keys_to_check = expected_json_keys.is_a?(Hash) ? expected_json_keys.keys : expected_json_keys
58
+ keys_to_check.each do |k|
59
+ # Check for string key "key" => or symbol key :key =>
60
+ expect(plain_output).to match(/"#{k}"\s*=>|:#{k}\s*=>/)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -7,10 +7,31 @@ RSpec.describe SimpleCovMcp do
7
7
  # These tests verify the integration with ModeDetector
8
8
  describe 'mode detection integration' do
9
9
  it 'uses ModeDetector for CLI mode detection' do
10
- expect(SimpleCovMcp::ModeDetector).to receive(:cli_mode?).with(['--force-cli'])
10
+ allow(described_class::ModeDetector).to receive(:cli_mode?).with(['--force-cli'])
11
11
  .and_return(true)
12
- expect(SimpleCovMcp::CoverageCLI).to receive_message_chain(:new, :run)
13
- SimpleCovMcp.run(['--force-cli'])
12
+ cli = instance_double(described_class::CoverageCLI, run: nil)
13
+ allow(described_class::CoverageCLI).to receive(:new).and_return(cli)
14
+
15
+ described_class.run(['--force-cli'])
16
+
17
+ expect(described_class::ModeDetector).to have_received(:cli_mode?).with(['--force-cli'])
18
+ expect(described_class::CoverageCLI).to have_received(:new)
19
+ expect(cli).to have_received(:run)
20
+ end
21
+ end
22
+
23
+ # When no thread-local context exists, active_log_file= creates one
24
+ # from the default context rather than modifying an existing one.
25
+ describe '.active_log_file=' do
26
+ it 'creates context from default when no current context exists' do
27
+ Thread.current[:simplecov_mcp_context] = nil
28
+
29
+ described_class.active_log_file = '/tmp/test.log'
30
+
31
+ expect(described_class.context).not_to be_nil
32
+ expect(described_class.active_log_file).to eq('/tmp/test.log')
33
+ ensure
34
+ described_class.active_log_file = File::NULL
14
35
  end
15
36
  end
16
37
  end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::Formatters::SourceFormatter do
6
+ subject(:formatter) { described_class.new(color_enabled: color_enabled) }
7
+
8
+ let(:color_enabled) { false }
9
+ let(:model) { instance_double(SimpleCovMcp::CoverageModel) }
10
+ let(:path) { 'lib/foo.rb' }
11
+ let(:abs_path) { File.expand_path(path) }
12
+ let(:source_content) do
13
+ <<~RUBY
14
+ class Foo
15
+ def bar
16
+ puts 'bar'
17
+ end
18
+ end
19
+ RUBY
20
+ end
21
+ let(:coverage_lines) { [1, 1, 0, nil, nil] } # Line 3 is uncovered
22
+
23
+ before do
24
+ allow(model).to receive(:raw_for).with(path).and_return(
25
+ 'file' => abs_path,
26
+ 'lines' => coverage_lines
27
+ )
28
+ allow(File).to receive(:file?).with(abs_path).and_return(true)
29
+ allow(File).to receive(:readlines).with(abs_path, chomp: true)
30
+ .and_return(source_content.lines(chomp: true))
31
+ end
32
+
33
+ describe '#format_source_for' do
34
+ context 'when source is available' do
35
+ it 'renders formatted source lines with line numbers and markers' do
36
+ # Full mode should print every line with coverage markers and numbering.
37
+ result = formatter.format_source_for(model, path, mode: :full)
38
+
39
+ expect(result.lines(chomp: true)).to eq(
40
+ [
41
+ ' Line | Source',
42
+ '------ ---+-------------------------------------------------------------',
43
+ ' 1 ✓ | class Foo',
44
+ ' 2 ✓ | def bar',
45
+ " 3 · | puts 'bar'",
46
+ ' 4 | end',
47
+ ' 5 | end'
48
+ ]
49
+ )
50
+ end
51
+
52
+ it 'marks covered lines with a checkmark' do
53
+ # Two covered lines should each get a ✓ in the rendered output.
54
+ result = formatter.format_source_for(model, path, mode: :full)
55
+ # covered: true -> '✓', false -> '·', nil -> ' '
56
+ expect(result.count('✓')).to eq(2)
57
+ expect(result.lines[2]).to match(/\b1\s+✓ \| class Foo/)
58
+ expect(result.lines[3]).to match(/\b2\s+✓ \| def bar/)
59
+ end
60
+
61
+ it 'marks uncovered lines with a dot' do
62
+ # The single uncovered line should be marked with a dot.
63
+ result = formatter.format_source_for(model, path, mode: :full)
64
+ expect(result.count('·')).to eq(1)
65
+ expect(result.lines[4]).to match(/\b3\s+· \| puts 'bar'/)
66
+ end
67
+
68
+ it 'returns only header when mode is nil (default)' do
69
+ # Default mode skips body rows but still emits the header scaffold.
70
+ result = formatter.format_source_for(model, path)
71
+ # Example header-only output:
72
+ # Line | Source
73
+ # ------ ---+-------------------------------------------------------------
74
+ expect(result).not_to include('class Foo')
75
+ expect(result).to include('Line', 'Source')
76
+ end
77
+ end
78
+
79
+ context 'when source file is not found' do
80
+ it 'returns fallback message' do
81
+ # Simulate missing file; formatter should not raise and should return a placeholder.
82
+ allow(File).to receive(:file?).with(abs_path).and_return(false)
83
+ expect(formatter.format_source_for(model, path)).to eq('[source not available]')
84
+ end
85
+ end
86
+
87
+ context 'when raw coverage data is missing' do
88
+ it 'returns fallback message' do
89
+ # No coverage entry for the path should also trigger the placeholder.
90
+ allow(model).to receive(:raw_for).with(path).and_return(nil)
91
+ expect(formatter.format_source_for(model, path)).to eq('[source not available]')
92
+ end
93
+ end
94
+
95
+ context 'when an error occurs during formatting' do
96
+ it 'returns fallback message instead of crashing' do
97
+ # Create a pathological coverage array with an object that raises on to_i
98
+ bad_object = Object.new
99
+ def bad_object.to_i = raise(StandardError, 'Bad data')
100
+ def bad_object.nil? = false
101
+
102
+ bad_coverage = [1, 1, bad_object, nil, nil]
103
+
104
+ allow(model).to receive(:raw_for).with(path)
105
+ .and_return('file' => abs_path, 'lines' => bad_coverage)
106
+
107
+ result = formatter.format_source_for(model, path, mode: :full)
108
+ expect(result).to eq('[source not available]')
109
+ end
110
+
111
+ it 'propagates ArgumentError' do
112
+ expect do
113
+ formatter.format_source_for(model, path, mode: :full, context: -1)
114
+ end.to raise_error(ArgumentError, 'Context lines cannot be negative')
115
+ end
116
+ end
117
+
118
+ context 'with color enabled' do
119
+ let(:color_enabled) { true }
120
+
121
+ it 'includes ANSI color codes' do
122
+ # Markers should be wrapped with green/red ANSI sequences when colors are on.
123
+ # Example colored line: " 1 \e[32m✓\e[0m | class Foo"
124
+ result = formatter.format_source_for(model, path, mode: :full)
125
+ expect(result).to include("\e[32m", "\e[31m") # green for checkmark, red for dot
126
+ expect(result.lines[2]).to include("\e[32m✓\e[0m") # line 1 checkmark is green
127
+ expect(result.lines[3]).to include("\e[32m✓\e[0m") # line 2 checkmark is green
128
+ expect(result.lines[4]).to include("\e[31m·\e[0m") # line 3 dot is red
129
+ end
130
+ end
131
+ end
132
+
133
+ describe '#build_source_payload' do
134
+ it 'returns row data when source is available' do
135
+ # Payload should mirror the row hashes used by CLI formatting.
136
+ result = formatter.build_source_payload(model, path, mode: :full)
137
+ expect(result).to be_a(Array)
138
+ expect(result.size).to eq(5)
139
+ expect(result.first).to include('code' => 'class Foo', 'line' => 1)
140
+ end
141
+
142
+ it 'returns nil when raw coverage is missing' do
143
+ # Without coverage data, there is no payload to build.
144
+ allow(model).to receive(:raw_for).with(path).and_return(nil)
145
+ expect(formatter.build_source_payload(model, path)).to be_nil
146
+ end
147
+
148
+ it 'returns nil when source file is missing' do
149
+ # Missing source file should also produce a nil payload.
150
+ allow(File).to receive(:file?).with(abs_path).and_return(false)
151
+ expect(formatter.build_source_payload(model, path)).to be_nil
152
+ end
153
+ end
154
+
155
+ describe '#build_source_rows' do
156
+ it 'raises error for negative context count' do
157
+ # Negative context should raise ArgumentError
158
+ expect do
159
+ formatter.build_source_rows(
160
+ source_content.lines(chomp: true),
161
+ coverage_lines,
162
+ mode: :uncovered,
163
+ context: -1
164
+ )
165
+ end.to raise_error(ArgumentError, 'Context lines cannot be negative')
166
+ end
167
+
168
+ it 'handles default context (2 lines)' do
169
+ # With the default context of 2, uncovered lines pull in surrounding rows.
170
+ rows = formatter.build_source_rows(
171
+ source_content.lines(chomp: true),
172
+ coverage_lines,
173
+ mode: :uncovered,
174
+ context: 2
175
+ )
176
+ # Uncovered is line 3. Context 2 means lines 1..5 (indexes 0..4).
177
+ # Total line count is 5, so all lines should be included.
178
+ expect(rows.size).to eq(5)
179
+ end
180
+
181
+ it 'handles bad context input (non-numeric)' do
182
+ # Non-numeric context coerces to 0 via to_i, so only the miss is included.
183
+ rows = formatter.build_source_rows(
184
+ source_content.lines(chomp: true),
185
+ coverage_lines,
186
+ mode: :uncovered,
187
+ context: 'bad'
188
+ )
189
+ # "bad".to_i is 0 so context should be 0.
190
+ # Uncovered is line 3.
191
+ expect(rows.size).to eq(1)
192
+ expect(rows.first['line']).to eq(3)
193
+ end
194
+
195
+ it 'handles context input that raises error on to_i conversion' do
196
+ # Create an object where to_i raises an error
197
+ bad_context = Object.new
198
+ def bad_context.to_i = raise(StandardError, 'Cannot convert')
199
+
200
+ # Falling back to default context should still include surrounding lines.
201
+ rows = formatter.build_source_rows(
202
+ source_content.lines(chomp: true),
203
+ coverage_lines,
204
+ mode: :uncovered,
205
+ context: bad_context
206
+ )
207
+ # Should fall back to default context of 2
208
+ # Uncovered is line 3. Context 2 means lines 1..5 (indexes 0..4).
209
+ # Total lines is 5. So all lines should be included.
210
+ expect(rows.size).to eq(5)
211
+ end
212
+
213
+ it 'handles nil coverage lines defensively' do
214
+ # Nil coverage array should not raise; hits/covered become nil.
215
+ # This covers the "coverage data missing entirely" path; in real coverage we'd see 1/0 hits.
216
+ # Expected rows when coverage is nil:
217
+ # [
218
+ # { 'line' => 1, 'code' => 'class Foo', 'hits' => nil, 'covered' => nil },
219
+ # { 'line' => 2, 'code' => ' def bar', 'hits' => nil, 'covered' => nil },
220
+ # { 'line' => 3, 'code' => " puts 'bar'", 'hits' => nil, 'covered' => nil },
221
+ # { 'line' => 4, 'code' => ' end', 'hits' => nil, 'covered' => nil },
222
+ # { 'line' => 5, 'code' => 'end', 'hits' => nil, 'covered' => nil }
223
+ # ]
224
+ # And the formatted output (markers blank because coverage is missing) would be:
225
+ # Line | Source
226
+ # ------ ---+-------------------------------------------------------------
227
+ # 1 | class Foo
228
+ # 2 | def bar
229
+ # 3 | puts 'bar'
230
+ # 4 | end
231
+ # 5 | end
232
+ rows = formatter.build_source_rows(
233
+ source_content.lines(chomp: true),
234
+ nil,
235
+ mode: :full,
236
+ context: 2
237
+ )
238
+ expect(rows.size).to eq(5)
239
+ expect(rows.first['hits']).to be_nil
240
+ end
241
+ end
242
+
243
+ describe '#format_detailed_rows' do
244
+ it 'formats rows into a table' do
245
+ # Detailed mode should align numeric columns and boolean covered flags.
246
+ rows = [
247
+ { 'line' => 1, 'hits' => 5, 'covered' => true },
248
+ { 'line' => 2, 'hits' => 0, 'covered' => false }
249
+ ]
250
+ # Expected table:
251
+ # Line Hits Covered
252
+ # ----- ---- -------
253
+ # 1 5 yes
254
+ # 2 0 no
255
+ result = formatter.format_detailed_rows(rows)
256
+ expect(result).to include('Line', 'Hits', 'Covered', '5', 'yes', 'no')
257
+ end
258
+ end
259
+
260
+ describe 'private #fetch_raw error handling' do
261
+ it 'returns nil if model raises error' do
262
+ # fetch_raw should swallow model errors and return nil instead of propagating.
263
+ allow(model).to receive(:raw_for).and_raise(StandardError)
264
+ expect(formatter.send(:fetch_raw, model, path)).to be_nil
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::Formatters do
6
+ describe '.formatter_for' do
7
+ it 'returns a lambda for known format' do
8
+ expect(described_class.formatter_for(:json)).to respond_to(:call)
9
+ end
10
+
11
+ it 'raises ArgumentError for unknown format' do
12
+ expect { described_class.formatter_for(:unknown) }
13
+ .to raise_error(ArgumentError, /Unknown format: unknown/)
14
+ end
15
+ end
16
+
17
+ describe '.ensure_requirements_for' do
18
+ it 'requires the library if needed' do
19
+ # We rely on the fact that 'yaml' is in FORMAT_REQUIRES
20
+ expect(described_class).to receive(:require).with('yaml')
21
+ described_class.ensure_requirements_for(:yaml)
22
+ end
23
+
24
+ it 'does nothing if no requirement' do
25
+ expect(described_class).not_to receive(:require)
26
+ described_class.ensure_requirements_for(:json) # JSON already required by app
27
+ end
28
+ end
29
+
30
+ describe '.format' do
31
+ let(:obj) { { 'foo' => 'bar' } }
32
+
33
+ [
34
+ [:json, '{"foo":"bar"}', :eq],
35
+ [:pretty_json, "{\n \"foo\": \"bar\"\n}", :include],
36
+ [:table, { 'foo' => 'bar' }, :eq],
37
+ [:yaml, "---\nfoo: bar\n", :include]
38
+ ].each do |format, expected, matcher|
39
+ it "formats as #{format}" do
40
+ result = described_class.format(obj, format)
41
+ expect(result).to send(matcher, expected)
42
+ end
43
+ end
44
+
45
+ context 'when a required gem is missing' do
46
+ before do
47
+ error = LoadError.new('cannot load such file -- awesome_print')
48
+ allow(described_class).to receive(:require).with('awesome_print').and_raise(error)
49
+ end
50
+
51
+ it 'raises a helpful LoadError' do
52
+ expect { described_class.format(obj, :awesome_print) }
53
+ .to raise_error(LoadError, /requires the 'awesome_print' gem/)
54
+ end
55
+ end
56
+
57
+ context 'when awesome_print is available' do
58
+ before do
59
+ # Stub require on the module for ensure_requirements_for
60
+ allow(described_class).to receive(:require).with('awesome_print')
61
+
62
+ # Stub global require for the lambda's internal require
63
+ allow(Kernel).to receive(:require).and_call_original
64
+ allow(Kernel).to receive(:require).with('awesome_print').and_return(true)
65
+
66
+ # Mock .ai on the object
67
+ allow(obj).to receive(:ai).and_return('awesome output')
68
+ end
69
+
70
+ it 'formats using awesome_print' do
71
+ result = described_class.format(obj, :awesome_print)
72
+ expect(result).to eq('awesome output')
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::Presenters::BaseCoveragePresenter do
6
+ let(:model) { instance_double(SimpleCovMcp::CoverageModel) }
7
+ let(:path) { 'lib/foo.rb' }
8
+ let(:presenter) { described_class.new(model: model, path: path) }
9
+
10
+ describe '#initialize' do
11
+ it 'sets model and path' do
12
+ expect(presenter.model).to eq(model)
13
+ expect(presenter.path).to eq(path)
14
+ end
15
+ end
16
+
17
+ describe '#absolute_payload' do
18
+ it 'raises NotImplementedError because build_payload is abstract' do
19
+ expect { presenter.absolute_payload }.to raise_error(NotImplementedError)
20
+ end
21
+ end
22
+
23
+ context 'with a concrete implementation' do
24
+ let(:concrete_class) do
25
+ Class.new(described_class) do
26
+ # Provide a concrete implementation of the abstract build_payload method
27
+ # for testing the BaseCoveragePresenter functionality
28
+ def build_payload
29
+ { 'file' => path, 'data' => 'test' }
30
+ end
31
+ end
32
+ end
33
+ let(:presenter) { concrete_class.new(model: model, path: path) }
34
+ let(:payload_with_stale) { { 'file' => path, 'data' => 'test', 'stale' => false } }
35
+
36
+ before do
37
+ allow(model).to receive(:staleness_for).with(path).and_return(false)
38
+ allow(model).to receive(:relativize).with(payload_with_stale).and_return(payload_with_stale)
39
+ end
40
+
41
+ describe '#absolute_payload' do
42
+ it 'merges stale status into payload' do
43
+ expect(presenter.absolute_payload).to include('stale' => false)
44
+ expect(presenter.absolute_payload).to include('data' => 'test')
45
+ end
46
+
47
+ it 'caches the result' do
48
+ r1 = presenter.absolute_payload
49
+ r2 = presenter.absolute_payload
50
+ expect(r1).to equal(r2)
51
+ end
52
+ end
53
+
54
+ describe '#relativized_payload' do
55
+ it 'delegates to model.relativize' do
56
+ expect(model).to receive(:relativize).with(presenter.absolute_payload)
57
+ presenter.relativized_payload
58
+ end
59
+
60
+ it 'caches the result' do
61
+ presenter.relativized_payload
62
+ expect(model).to have_received(:relativize).once
63
+ presenter.relativized_payload
64
+ end
65
+ end
66
+
67
+ describe '#stale' do
68
+ it 'delegates to absolute_payload' do
69
+ expect(presenter.stale).to be(false)
70
+ end
71
+ end
72
+
73
+ describe '#relative_path' do
74
+ it 'delegates to relativized_payload' do
75
+ expect(presenter.relative_path).to eq('lib/foo.rb')
76
+ end
77
+ end
78
+ end
79
+ end