cov-loupe 3.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 (171) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +329 -0
  4. data/docs/dev/ARCHITECTURE.md +80 -0
  5. data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
  6. data/docs/dev/DEVELOPMENT.md +83 -0
  7. data/docs/dev/README.md +10 -0
  8. data/docs/dev/RELEASING.md +146 -0
  9. data/docs/dev/arch-decisions/001-x-arch-decision.md +95 -0
  10. data/docs/dev/arch-decisions/002-x-arch-decision.md +159 -0
  11. data/docs/dev/arch-decisions/003-x-arch-decision.md +165 -0
  12. data/docs/dev/arch-decisions/004-x-arch-decision.md +203 -0
  13. data/docs/dev/arch-decisions/005-x-arch-decision.md +189 -0
  14. data/docs/dev/arch-decisions/README.md +60 -0
  15. data/docs/dev/presentations/cov-loupe-presentation.md +255 -0
  16. data/docs/fixtures/demo_project/README.md +9 -0
  17. data/docs/user/ADVANCED_USAGE.md +777 -0
  18. data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
  19. data/docs/user/CLI_USAGE.md +750 -0
  20. data/docs/user/ERROR_HANDLING.md +93 -0
  21. data/docs/user/EXAMPLES.md +588 -0
  22. data/docs/user/INSTALLATION.md +130 -0
  23. data/docs/user/LIBRARY_API.md +693 -0
  24. data/docs/user/MCP_INTEGRATION.md +490 -0
  25. data/docs/user/README.md +14 -0
  26. data/docs/user/TROUBLESHOOTING.md +197 -0
  27. data/docs/user/V2-BREAKING-CHANGES.md +472 -0
  28. data/exe/cov-loupe +23 -0
  29. data/lib/cov_loupe/app_config.rb +56 -0
  30. data/lib/cov_loupe/app_context.rb +26 -0
  31. data/lib/cov_loupe/base_tool.rb +102 -0
  32. data/lib/cov_loupe/cli.rb +178 -0
  33. data/lib/cov_loupe/commands/base_command.rb +67 -0
  34. data/lib/cov_loupe/commands/command_factory.rb +45 -0
  35. data/lib/cov_loupe/commands/detailed_command.rb +38 -0
  36. data/lib/cov_loupe/commands/list_command.rb +13 -0
  37. data/lib/cov_loupe/commands/raw_command.rb +38 -0
  38. data/lib/cov_loupe/commands/summary_command.rb +41 -0
  39. data/lib/cov_loupe/commands/totals_command.rb +53 -0
  40. data/lib/cov_loupe/commands/uncovered_command.rb +45 -0
  41. data/lib/cov_loupe/commands/validate_command.rb +60 -0
  42. data/lib/cov_loupe/commands/version_command.rb +33 -0
  43. data/lib/cov_loupe/config_parser.rb +32 -0
  44. data/lib/cov_loupe/constants.rb +22 -0
  45. data/lib/cov_loupe/coverage_reporter.rb +31 -0
  46. data/lib/cov_loupe/error_handler.rb +165 -0
  47. data/lib/cov_loupe/error_handler_factory.rb +31 -0
  48. data/lib/cov_loupe/errors.rb +191 -0
  49. data/lib/cov_loupe/formatters/source_formatter.rb +152 -0
  50. data/lib/cov_loupe/formatters.rb +51 -0
  51. data/lib/cov_loupe/mcp_server.rb +42 -0
  52. data/lib/cov_loupe/mode_detector.rb +56 -0
  53. data/lib/cov_loupe/model.rb +339 -0
  54. data/lib/cov_loupe/option_normalizers.rb +113 -0
  55. data/lib/cov_loupe/option_parser_builder.rb +147 -0
  56. data/lib/cov_loupe/option_parsers/env_options_parser.rb +48 -0
  57. data/lib/cov_loupe/option_parsers/error_helper.rb +110 -0
  58. data/lib/cov_loupe/path_relativizer.rb +64 -0
  59. data/lib/cov_loupe/predicate_evaluator.rb +72 -0
  60. data/lib/cov_loupe/presenters/base_coverage_presenter.rb +42 -0
  61. data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +14 -0
  62. data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +14 -0
  63. data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +14 -0
  64. data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +14 -0
  65. data/lib/cov_loupe/presenters/project_coverage_presenter.rb +50 -0
  66. data/lib/cov_loupe/presenters/project_totals_presenter.rb +27 -0
  67. data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +122 -0
  68. data/lib/cov_loupe/resolvers/resolver_factory.rb +28 -0
  69. data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +76 -0
  70. data/lib/cov_loupe/resultset_loader.rb +131 -0
  71. data/lib/cov_loupe/staleness_checker.rb +247 -0
  72. data/lib/cov_loupe/table_formatter.rb +64 -0
  73. data/lib/cov_loupe/tools/all_files_coverage_tool.rb +51 -0
  74. data/lib/cov_loupe/tools/coverage_detailed_tool.rb +35 -0
  75. data/lib/cov_loupe/tools/coverage_raw_tool.rb +34 -0
  76. data/lib/cov_loupe/tools/coverage_summary_tool.rb +34 -0
  77. data/lib/cov_loupe/tools/coverage_table_tool.rb +50 -0
  78. data/lib/cov_loupe/tools/coverage_totals_tool.rb +44 -0
  79. data/lib/cov_loupe/tools/help_tool.rb +115 -0
  80. data/lib/cov_loupe/tools/uncovered_lines_tool.rb +34 -0
  81. data/lib/cov_loupe/tools/validate_tool.rb +72 -0
  82. data/lib/cov_loupe/tools/version_tool.rb +32 -0
  83. data/lib/cov_loupe/util.rb +88 -0
  84. data/lib/cov_loupe/version.rb +5 -0
  85. data/lib/cov_loupe.rb +140 -0
  86. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  87. data/spec/TIMESTAMPS.md +48 -0
  88. data/spec/all_files_coverage_tool_spec.rb +53 -0
  89. data/spec/app_config_spec.rb +142 -0
  90. data/spec/base_tool_spec.rb +62 -0
  91. data/spec/cli/show_default_report_spec.rb +33 -0
  92. data/spec/cli_enumerated_options_spec.rb +90 -0
  93. data/spec/cli_error_spec.rb +184 -0
  94. data/spec/cli_format_spec.rb +123 -0
  95. data/spec/cli_json_options_spec.rb +50 -0
  96. data/spec/cli_source_spec.rb +44 -0
  97. data/spec/cli_spec.rb +192 -0
  98. data/spec/cli_table_spec.rb +28 -0
  99. data/spec/cli_usage_spec.rb +42 -0
  100. data/spec/commands/base_command_spec.rb +107 -0
  101. data/spec/commands/command_factory_spec.rb +76 -0
  102. data/spec/commands/detailed_command_spec.rb +34 -0
  103. data/spec/commands/list_command_spec.rb +28 -0
  104. data/spec/commands/raw_command_spec.rb +69 -0
  105. data/spec/commands/summary_command_spec.rb +34 -0
  106. data/spec/commands/totals_command_spec.rb +34 -0
  107. data/spec/commands/uncovered_command_spec.rb +55 -0
  108. data/spec/commands/validate_command_spec.rb +213 -0
  109. data/spec/commands/version_command_spec.rb +38 -0
  110. data/spec/constants_spec.rb +61 -0
  111. data/spec/cov_loupe/formatters/source_formatter_spec.rb +267 -0
  112. data/spec/cov_loupe/formatters_spec.rb +76 -0
  113. data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +79 -0
  114. data/spec/cov_loupe_model_spec.rb +454 -0
  115. data/spec/cov_loupe_module_spec.rb +37 -0
  116. data/spec/cov_loupe_opts_spec.rb +185 -0
  117. data/spec/coverage_reporter_spec.rb +102 -0
  118. data/spec/coverage_table_tool_spec.rb +59 -0
  119. data/spec/coverage_totals_tool_spec.rb +37 -0
  120. data/spec/error_handler_spec.rb +197 -0
  121. data/spec/error_mode_spec.rb +139 -0
  122. data/spec/errors_edge_cases_spec.rb +312 -0
  123. data/spec/errors_stale_spec.rb +83 -0
  124. data/spec/file_based_mcp_tools_spec.rb +99 -0
  125. data/spec/fixtures/project1/lib/bar.rb +5 -0
  126. data/spec/fixtures/project1/lib/foo.rb +6 -0
  127. data/spec/help_tool_spec.rb +26 -0
  128. data/spec/integration_spec.rb +789 -0
  129. data/spec/logging_fallback_spec.rb +128 -0
  130. data/spec/mcp_logging_spec.rb +44 -0
  131. data/spec/mcp_server_integration_spec.rb +23 -0
  132. data/spec/mcp_server_spec.rb +106 -0
  133. data/spec/mode_detector_spec.rb +153 -0
  134. data/spec/model_error_handling_spec.rb +269 -0
  135. data/spec/model_staleness_spec.rb +79 -0
  136. data/spec/option_normalizers_spec.rb +203 -0
  137. data/spec/option_parsers/env_options_parser_spec.rb +221 -0
  138. data/spec/option_parsers/error_helper_spec.rb +222 -0
  139. data/spec/path_relativizer_spec.rb +98 -0
  140. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  141. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  142. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  143. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  144. data/spec/presenters/project_coverage_presenter_spec.rb +87 -0
  145. data/spec/presenters/project_totals_presenter_spec.rb +144 -0
  146. data/spec/resolvers/coverage_line_resolver_spec.rb +282 -0
  147. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  148. data/spec/resolvers/resultset_path_resolver_spec.rb +60 -0
  149. data/spec/resultset_loader_spec.rb +167 -0
  150. data/spec/shared_examples/README.md +115 -0
  151. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  152. data/spec/shared_examples/file_based_mcp_tools.rb +179 -0
  153. data/spec/shared_examples/formatted_command_examples.rb +64 -0
  154. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  155. data/spec/spec_helper.rb +127 -0
  156. data/spec/staleness_checker_spec.rb +374 -0
  157. data/spec/staleness_more_spec.rb +42 -0
  158. data/spec/support/cli_helpers.rb +22 -0
  159. data/spec/support/control_flow_helpers.rb +20 -0
  160. data/spec/support/fake_mcp.rb +40 -0
  161. data/spec/support/io_helpers.rb +29 -0
  162. data/spec/support/mcp_helpers.rb +35 -0
  163. data/spec/support/mcp_runner.rb +66 -0
  164. data/spec/support/mocking_helpers.rb +30 -0
  165. data/spec/table_format_spec.rb +70 -0
  166. data/spec/tools/validate_tool_spec.rb +132 -0
  167. data/spec/tools_error_handling_spec.rb +130 -0
  168. data/spec/util_spec.rb +154 -0
  169. data/spec/version_spec.rb +123 -0
  170. data/spec/version_tool_spec.rb +141 -0
  171. metadata +290 -0
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::CoverageModel do
6
+ subject(:model) { described_class.new(root: root) }
7
+
8
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
9
+
10
+
11
+ describe 'initialization error handling' do
12
+ it 'raises FileError when File.read raises Errno::ENOENT directly' do
13
+ # Stub find_resultset to return a path, but File.read to raise ENOENT
14
+ allow(CovLoupe::CovUtil).to receive(:find_resultset)
15
+ .and_return('/some/path/.resultset.json')
16
+ allow(JSON).to receive(:load_file).with('/some/path/.resultset.json')
17
+ .and_raise(Errno::ENOENT, 'No such file')
18
+
19
+ expect do
20
+ described_class.new(root: root, resultset: '/some/path/.resultset.json')
21
+ end.to raise_error(CovLoupe::FileError, /Coverage data not found/)
22
+ end
23
+
24
+ it 'raises ResultsetNotFoundError when resultset file does not exist' do
25
+ expect do
26
+ described_class.new(root: root, resultset: '/nonexistent/path/.resultset.json')
27
+ end.to raise_error(CovLoupe::ResultsetNotFoundError, /Specified resultset not found/)
28
+ end
29
+ end
30
+
31
+ describe 'raw_for' do
32
+ it 'returns absolute file and lines array' do
33
+ data = model.raw_for('lib/foo.rb')
34
+ expect(data['file']).to eq(File.expand_path('lib/foo.rb', root))
35
+ expect(data['lines']).to eq([1, 0, nil, 2])
36
+ end
37
+ end
38
+
39
+ describe 'summary_for' do
40
+ it 'computes covered/total/percentage' do
41
+ data = model.summary_for('lib/foo.rb')
42
+ expect(data['summary']['total']).to eq(3)
43
+ expect(data['summary']['covered']).to eq(2)
44
+ expect(data['summary']['percentage']).to be_within(0.01).of(66.67)
45
+ end
46
+ end
47
+
48
+ describe '#relativize' do
49
+ it 'returns a copy with file paths relative to the root' do
50
+ data = model.summary_for('lib/foo.rb')
51
+ relative = model.relativize(data)
52
+
53
+ expect(relative['file']).to eq('lib/foo.rb')
54
+ expect(data['file']).not_to eq(relative['file'])
55
+ expect(relative).not_to equal(data)
56
+ end
57
+ end
58
+
59
+ describe 'uncovered_for' do
60
+ it 'lists uncovered executable line numbers' do
61
+ data = model.uncovered_for('lib/foo.rb')
62
+ expect(data['uncovered']).to eq([2])
63
+ expect(data['summary']['total']).to eq(3)
64
+ end
65
+ end
66
+
67
+ describe 'detailed_for' do
68
+ it 'returns per-line details for non-nil lines' do
69
+ data = model.detailed_for('lib/foo.rb')
70
+ expect(data['lines']).to eq([
71
+ { 'line' => 1, 'hits' => 1, 'covered' => true },
72
+ { 'line' => 2, 'hits' => 0, 'covered' => false },
73
+ { 'line' => 4, 'hits' => 2, 'covered' => true }
74
+ ])
75
+ end
76
+ end
77
+
78
+ describe 'staleness_for' do
79
+ it 'returns the staleness character for a file' do
80
+ checker = instance_double(CovLoupe::StalenessChecker, off?: false)
81
+ allow(CovLoupe::StalenessChecker).to receive(:new).and_return(checker)
82
+ allow(checker).to receive(:stale_for_file?) do |file_abs, _|
83
+ if file_abs == File.expand_path('lib/foo.rb', root)
84
+ 'T'
85
+ else
86
+ false
87
+ end
88
+ end
89
+
90
+ expect(model.staleness_for('lib/foo.rb')).to eq('T')
91
+ expect(model.staleness_for('lib/bar.rb')).to be(false)
92
+ end
93
+
94
+ it 'returns false when an exception occurs during staleness check' do
95
+ # Stub the checker to raise an error
96
+ checker = instance_double(CovLoupe::StalenessChecker, off?: false)
97
+ allow(CovLoupe::StalenessChecker).to receive(:new).and_return(checker)
98
+ allow(checker).to receive(:stale_for_file?)
99
+ .and_raise(StandardError, 'Something went wrong')
100
+
101
+ # The rescue clause should catch the error and return false
102
+ expect(model.staleness_for('lib/foo.rb')).to be(false)
103
+ end
104
+
105
+ it 'returns false when coverage data is not found for the file' do
106
+ # Try to get staleness for a file that doesn't exist in coverage
107
+ expect(model.staleness_for('lib/nonexistent.rb')).to be(false)
108
+ end
109
+ end
110
+
111
+ describe 'all_files' do
112
+ it 'sorts descending (default) by percentage then by file path' do
113
+ files = model.all_files
114
+ # lib/foo.rb has 66.67%, lib/bar.rb has 33.33%
115
+ expect(files.first['file']).to eq(File.expand_path('lib/foo.rb', root))
116
+ expect(files.first['percentage']).to be_within(0.01).of(66.67)
117
+ expect(files.last['file']).to eq(File.expand_path('lib/bar.rb', root))
118
+ end
119
+
120
+ it 'sorts ascending by percentage then by file path' do
121
+ files = model.all_files(sort_order: :ascending)
122
+ expect(files.first['file']).to eq(File.expand_path('lib/bar.rb', root))
123
+ expect(files.first['percentage']).to be_within(0.01).of(33.33)
124
+ expect(files.last['file']).to eq(File.expand_path('lib/foo.rb', root))
125
+ end
126
+
127
+ it 'filters rows when tracked_globs are provided' do
128
+ files = model.all_files(tracked_globs: ['lib/foo.rb'])
129
+
130
+ expect(files.length).to eq(1)
131
+ expect(files.first['file']).to eq(File.expand_path('lib/foo.rb', root))
132
+ end
133
+
134
+ it 'combines results from multiple tracked_globs patterns' do
135
+ abs_bar = File.expand_path('lib/bar.rb', root)
136
+
137
+ files = model.all_files(tracked_globs: ['lib/foo.rb', abs_bar])
138
+
139
+ expect(files.map { |f| f['file'] }).to contain_exactly(
140
+ File.expand_path('lib/foo.rb', root),
141
+ abs_bar
142
+ )
143
+ end
144
+
145
+ it 'handles files with paths that cannot be relativized' do
146
+ # Create a custom row with a path from a Windows-style drive (C:/) that will cause ArgumentError
147
+ # when trying to make it relative to a Unix-style root
148
+ custom_rows = [
149
+ {
150
+ 'file' => 'C:/Windows/system32/file.rb',
151
+ 'percentage' => 100.0,
152
+ 'covered' => 10,
153
+ 'total' => 10,
154
+ 'stale' => false
155
+ }
156
+ ]
157
+
158
+ # This should trigger the ArgumentError rescue in filter_rows_by_globs
159
+ # When the path cannot be made relative (different path types), it falls back to using the absolute path
160
+ output = model.format_table(custom_rows, tracked_globs: ['C:/Windows/**/*.rb'])
161
+
162
+ # The file should be included because the absolute path fallback matches the glob
163
+ expect(output).to include('C:/Windows/system32/file.rb')
164
+ end
165
+ end
166
+
167
+ describe '#project_totals' do
168
+ it 'aggregates coverage totals across all files' do
169
+ totals = model.project_totals
170
+
171
+ expect(totals['lines']).to include('total' => 6, 'covered' => 3, 'uncovered' => 3)
172
+ expect(totals['percentage']).to be_within(0.01).of(50.0)
173
+ expect(totals['files']).to include('total' => 2)
174
+ expect(totals['files']['ok'] + totals['files']['stale']).to eq(totals['files']['total'])
175
+ end
176
+
177
+ it 'respects tracked_globs filtering' do
178
+ totals = model.project_totals(tracked_globs: ['lib/foo.rb'])
179
+
180
+ expect(totals['lines']).to include('total' => 3, 'covered' => 2, 'uncovered' => 1)
181
+ expect(totals['files']).to include('total' => 1)
182
+ end
183
+ end
184
+
185
+ describe 'resolve method error handling' do
186
+ it 'raises FileError when coverage_lines is nil after lookup' do
187
+ # Stub lookup_lines to return nil without raising
188
+ allow(CovLoupe::CovUtil).to receive(:lookup_lines).and_return(nil)
189
+
190
+ expect do
191
+ model.summary_for('lib/nonexistent.rb')
192
+ end.to raise_error(CovLoupe::FileError, /No coverage data found for file/)
193
+ end
194
+
195
+ it 'converts Errno::ENOENT to FileNotFoundError during resolve' do
196
+ # We need to trigger Errno::ENOENT inside the resolve method
197
+ # Stub the checker's check_file! method to raise Errno::ENOENT
198
+ checker = instance_double(CovLoupe::StalenessChecker, off?: false)
199
+ allow(CovLoupe::StalenessChecker).to receive(:new).and_return(checker)
200
+ allow(checker).to receive(:check_file!)
201
+ .and_raise(Errno::ENOENT, 'No such file or directory')
202
+
203
+ # Create a model with staleness checking enabled to trigger the check_file! call
204
+ stale_model = described_class.new(root: root, staleness: :error)
205
+
206
+ expect do
207
+ stale_model.summary_for('lib/foo.rb')
208
+ end.to raise_error(CovLoupe::FileNotFoundError, /File not found/)
209
+ end
210
+
211
+ it 'raises FileError when lookup_lines raises RuntimeError' do
212
+ allow(CovLoupe::CovUtil).to receive(:lookup_lines)
213
+ .and_raise(RuntimeError, 'Could not find coverage data')
214
+
215
+ expect do
216
+ model.summary_for('lib/some_file.rb')
217
+ end.to raise_error(CovLoupe::FileError, /No coverage data found for file/)
218
+ end
219
+ end
220
+
221
+ describe 'resultset directory handling' do
222
+ it 'accepts a directory containing .resultset.json' do
223
+ model = described_class.new(root: root, resultset: 'coverage')
224
+ data = model.summary_for('lib/foo.rb')
225
+ expect(data['summary']['total']).to eq(3)
226
+ expect(data['summary']['covered']).to eq(2)
227
+ end
228
+ end
229
+
230
+ describe 'branch-only coverage resultsets' do
231
+ let(:branch_root) { (FIXTURES_DIR / 'branch_only_project').to_s }
232
+ let(:branch_model) { described_class.new(root: branch_root) }
233
+
234
+ it 'computes summaries by synthesizing branch data' do
235
+ data = branch_model.summary_for('lib/branch_only.rb')
236
+
237
+ expect(data['summary']['total']).to eq(5)
238
+ expect(data['summary']['covered']).to eq(3)
239
+ expect(data['summary']['percentage']).to be_within(0.01).of(60.0)
240
+ end
241
+
242
+ it 'returns detailed data using branch-derived hits' do
243
+ data = branch_model.detailed_for('lib/branch_only.rb')
244
+
245
+ expect(data['lines']).to eq([
246
+ { 'line' => 6, 'hits' => 3, 'covered' => true },
247
+ { 'line' => 7, 'hits' => 0, 'covered' => false },
248
+ { 'line' => 13, 'hits' => 0, 'covered' => false },
249
+ { 'line' => 14, 'hits' => 2, 'covered' => true },
250
+ { 'line' => 16, 'hits' => 2, 'covered' => true }
251
+ ])
252
+ end
253
+
254
+ it 'identifies uncovered lines based on branch hits' do
255
+ data = branch_model.uncovered_for('lib/branch_only.rb')
256
+
257
+ expect(data['uncovered']).to eq([7, 13])
258
+ end
259
+
260
+ it 'includes branch-only files in all_files results' do
261
+ files = branch_model.all_files(sort_order: :ascending)
262
+ branch_path = File.expand_path('lib/branch_only.rb', branch_root)
263
+ another_path = File.expand_path('lib/another.rb', branch_root)
264
+
265
+ expect(files.map { |f| f['file'] }).to contain_exactly(branch_path, another_path)
266
+
267
+ branch_entry = files.find { |f| f['file'] == branch_path }
268
+ another_entry = files.find { |f| f['file'] == another_path }
269
+
270
+ expect(branch_entry['total']).to eq(5)
271
+ expect(branch_entry['covered']).to eq(3)
272
+ expect(another_entry['total']).to eq(1)
273
+ expect(another_entry['covered']).to eq(0)
274
+ end
275
+ end
276
+
277
+ describe 'multiple suites in resultset' do
278
+ let(:resultset_path) { '/tmp/multi_suite_resultset.json' }
279
+ let(:suite_a_cov) do
280
+ {
281
+ File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, nil, 2] }
282
+ }
283
+ end
284
+ let(:suite_b_cov) do
285
+ {
286
+ File.join(root, 'lib', 'bar.rb') => { 'lines' => [0, 1, 1] }
287
+ }
288
+ end
289
+ let(:resultset) do
290
+ {
291
+ 'RSpec' => { 'timestamp' => 100, 'coverage' => suite_a_cov },
292
+ 'Cucumber' => { 'timestamp' => 200, 'coverage' => suite_b_cov }
293
+ }
294
+ end
295
+
296
+ let(:shared_file) { File.join(root, 'lib', 'foo.rb') }
297
+ let(:suite_a_cov_combined) do
298
+ {
299
+ shared_file => { 'lines' => [1, 0, nil, 0] }
300
+ }
301
+ end
302
+ let(:suite_b_cov_combined) do
303
+ {
304
+ shared_file => { 'lines' => [0, 3, nil, 1] }
305
+ }
306
+ end
307
+ let(:resultset_combined) do
308
+ {
309
+ 'RSpec' => { 'timestamp' => 100, 'coverage' => suite_a_cov_combined },
310
+ 'Cucumber' => { 'timestamp' => 150, 'coverage' => suite_b_cov_combined }
311
+ }
312
+ end
313
+
314
+ before do
315
+ allow(CovLoupe::CovUtil).to receive(:find_resultset).and_wrap_original do
316
+ |original, search_root, resultset: nil|
317
+ root_match = File.absolute_path(search_root) == File.absolute_path(root)
318
+ resultset_empty = resultset.nil? || resultset.to_s.empty?
319
+ if root_match && resultset_empty
320
+ resultset_path
321
+ else
322
+ original.call(search_root, resultset: resultset)
323
+ end
324
+ end
325
+ # This line might need to be removed as we now mock JSON.load_file directly
326
+ end
327
+
328
+ it 'merges coverage data from multiple suites while keeping latest timestamp' do
329
+ allow(JSON).to receive(:load_file).with(resultset_path).and_return(resultset)
330
+
331
+ model = described_class.new(root: root)
332
+ files = model.all_files(sort_order: :ascending)
333
+
334
+ expect(files.map { |f| File.basename(f['file']) }).to include('foo.rb', 'bar.rb')
335
+
336
+ timestamp = model.instance_variable_get(:@cov_timestamp)
337
+ expect(timestamp).to eq(200)
338
+ end
339
+
340
+ it 'combines coverage arrays when the same file appears in multiple suites' do
341
+ allow(JSON).to receive(:load_file).with(resultset_path).and_return(resultset_combined)
342
+
343
+ model = described_class.new(root: root)
344
+ detailed = model.detailed_for('lib/foo.rb')
345
+ hits_by_line = detailed['lines'].each_with_object({}) do |row, acc|
346
+ acc[row['line']] = row['hits']
347
+ end
348
+
349
+ expect(hits_by_line[1]).to eq(1)
350
+ expect(hits_by_line[2]).to eq(3)
351
+ expect(hits_by_line[4]).to eq(1)
352
+ end
353
+ end
354
+
355
+ describe 'format_table' do
356
+ it 'returns a formatted table string with all files coverage data' do
357
+ output = model.format_table
358
+
359
+ # Should contain table structure
360
+ expect(output).to include('┌', '┬', '┐', '│', '├', '┼', '┤', '└', '┴', '┘')
361
+
362
+ # Should contain headers
363
+ expect(output).to include('File', '%', 'Covered', 'Total', 'Stale')
364
+
365
+ # Should contain file data
366
+ expect(output).to include('lib/foo.rb', 'lib/bar.rb')
367
+
368
+ # Should contain summary
369
+ expect(output).to include('Files: total', ', ok ', ', stale ')
370
+ end
371
+
372
+ it 'returns "No coverage data found" when rows is empty' do
373
+ rows = []
374
+ output = model.format_table(rows)
375
+ expect(output).to eq('No coverage data found')
376
+ end
377
+
378
+ it 'accepts custom rows parameter' do
379
+ custom_rows = [
380
+ {
381
+ 'file' => '/path/to/file1.rb',
382
+ 'percentage' => 100.0,
383
+ 'covered' => 10,
384
+ 'total' => 10,
385
+ 'stale' => false
386
+ },
387
+ {
388
+ 'file' => '/path/to/file2.rb',
389
+ 'percentage' => 50.0,
390
+ 'covered' => 5,
391
+ 'total' => 10,
392
+ 'stale' => 'M'
393
+ },
394
+ {
395
+ 'file' => '/path/to/file3.rb',
396
+ 'percentage' => 75.0,
397
+ 'covered' => 15,
398
+ 'total' => 20,
399
+ 'stale' => 'T'
400
+ }
401
+ ]
402
+
403
+ output = model.format_table(custom_rows)
404
+
405
+ expect(output).to include('file1.rb')
406
+ expect(output).to include('file2.rb')
407
+ expect(output).to include('file3.rb')
408
+ expect(output).to include('100.00')
409
+ expect(output).to include('50.00')
410
+ expect(output).to include('75.00')
411
+ expect(output).to include('M')
412
+ expect(output).to include('T')
413
+ expect(output).not_to include('!')
414
+ staleness_msg = 'Staleness: M = Missing file, T = Timestamp (source newer), ' \
415
+ 'L = Line count mismatch'
416
+ expect(output).to include(staleness_msg)
417
+ end
418
+
419
+ it 'accepts sort_order parameter' do
420
+ # Test that sort_order parameter is passed through correctly
421
+ output_asc = model.format_table(sort_order: :ascending)
422
+ output_desc = model.format_table(sort_order: :descending)
423
+
424
+ # Both should be valid table outputs
425
+ expect(output_asc).to include('┌')
426
+ expect(output_desc).to include('┌')
427
+ expect(output_asc).to include('Files: total')
428
+ expect(output_desc).to include('Files: total')
429
+ end
430
+
431
+ it 'sorts table output correctly when provided with custom rows' do
432
+ # Get all files data to use as custom rows
433
+ all_files_data = model.all_files
434
+
435
+ # Test ascending sort with custom rows
436
+ output_asc = model.format_table(all_files_data, sort_order: :ascending)
437
+ lines_asc = output_asc.split("\n")
438
+ bar_line_asc = lines_asc.find { |line| line.include?('bar.rb') }
439
+ foo_line_asc = lines_asc.find { |line| line.include?('foo.rb') }
440
+
441
+ # In ascending order, bar.rb (33.33%) should come before foo.rb (66.67%)
442
+ expect(lines_asc.index(bar_line_asc)).to be < lines_asc.index(foo_line_asc)
443
+
444
+ # Test descending sort with custom rows
445
+ output_desc = model.format_table(all_files_data, sort_order: :descending)
446
+ lines_desc = output_desc.split("\n")
447
+ bar_line_desc = lines_desc.find { |line| line.include?('bar.rb') }
448
+ foo_line_desc = lines_desc.find { |line| line.include?('foo.rb') }
449
+
450
+ # In descending order, foo.rb (66.67%) should come before bar.rb (33.33%)
451
+ expect(lines_desc.index(foo_line_desc)).to be < lines_desc.index(bar_line_desc)
452
+ end
453
+ end
454
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe do
6
+ # Mode detection tests moved to mode_detector_spec.rb
7
+ # These tests verify the integration with ModeDetector
8
+ describe 'mode detection integration' do
9
+ it 'uses ModeDetector for CLI mode detection' do
10
+ allow(described_class::ModeDetector).to receive(:cli_mode?).with(['--force-cli'])
11
+ .and_return(true)
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[:cov_loupe_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
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'COV_LOUPE_OPTS Environment Variable' do
6
+ let(:cli) { CovLoupe::CoverageCLI.new }
7
+
8
+ around do |example|
9
+ original_value = ENV['COV_LOUPE_OPTS']
10
+ example.run
11
+ ensure
12
+ ENV['COV_LOUPE_OPTS'] = original_value
13
+ end
14
+
15
+ describe 'CLI option parsing from environment' do
16
+ it 'parses simple options from COV_LOUPE_OPTS' do
17
+ ENV['COV_LOUPE_OPTS'] = '--error-mode off --format json'
18
+ env_opts = CovLoupe.send(:extract_env_opts)
19
+
20
+ swallow_system_exit do
21
+ silence_output do
22
+ cli.send(:run, env_opts + ['summary', 'lib/foo.rb'])
23
+ end
24
+ end
25
+ rescue CovLoupe::Error => e
26
+ # Expected to fail due to missing file, but options should be parsed
27
+ puts "DEBUG: Caught exception: #{e.class}: #{e.message}" if ENV['DEBUG']
28
+ ensure
29
+ expect(cli.config.error_mode).to eq(:off)
30
+ expect(cli.config.format).to eq(:json)
31
+ end
32
+
33
+ it 'handles quoted options with spaces' do
34
+ test_path = File.join(Dir.tmpdir, 'test path with spaces', '.resultset.json')
35
+ ENV['COV_LOUPE_OPTS'] = "--resultset \"#{test_path}\""
36
+ env_opts = CovLoupe.send(:extract_env_opts)
37
+
38
+ exit_status = swallow_system_exit do
39
+ silence_output do
40
+ cli.send(:run, env_opts + ['--help'])
41
+ end
42
+ end
43
+
44
+ expect(exit_status).to eq(0) # --help exits cleanly
45
+ expect(cli.config.resultset).to eq(test_path)
46
+ end
47
+
48
+ it 'supports setting log-file to stdout from environment' do
49
+ ENV['COV_LOUPE_OPTS'] = '--log-file stdout'
50
+ env_opts = CovLoupe.send(:extract_env_opts)
51
+
52
+ swallow_system_exit do
53
+ silence_output do
54
+ cli.send(:run, env_opts + ['--help'])
55
+ end
56
+ end
57
+
58
+ expect(cli.config.log_file).to eq('stdout')
59
+ end
60
+
61
+ it 'command line arguments override environment options' do
62
+ ENV['COV_LOUPE_OPTS'] = '--error-mode off'
63
+ env_opts = CovLoupe.send(:extract_env_opts)
64
+
65
+ begin
66
+ args = env_opts + ['--error-mode', 'debug', 'summary', 'lib/foo.rb']
67
+ silence_output { cli.send(:run, args) }
68
+ rescue SystemExit, CovLoupe::Error
69
+ # Expected to fail, but options should be parsed
70
+ end
71
+
72
+ # Command line should override environment
73
+ expect(cli.config.error_mode).to eq(:debug)
74
+ end
75
+
76
+ it 'handles malformed COV_LOUPE_OPTS gracefully' do
77
+ ENV['COV_LOUPE_OPTS'] = '--option "unclosed quote'
78
+
79
+ # Should catch the ConfigurationError and exit cleanly
80
+ _out, _err, status = run_cli_with_status('summary', 'lib/foo.rb')
81
+ expect(status).not_to eq(0)
82
+ end
83
+
84
+ it 'returns empty array when COV_LOUPE_OPTS is not set' do
85
+ # ENV is already cleared by around block
86
+ opts = CovLoupe.send(:extract_env_opts)
87
+ expect(opts).to eq([])
88
+ end
89
+
90
+ it 'returns empty array when COV_LOUPE_OPTS is empty' do
91
+ ENV['COV_LOUPE_OPTS'] = ''
92
+ opts = CovLoupe.send(:extract_env_opts)
93
+ expect(opts).to eq([])
94
+ end
95
+ end
96
+
97
+ describe 'CLI mode detection with COV_LOUPE_OPTS' do
98
+ it 'respects --force-cli from environment variable' do
99
+ ENV['COV_LOUPE_OPTS'] = '--force-cli'
100
+
101
+ # This would normally be MCP mode (no TTY, no subcommand)
102
+ stdin = double('stdin', tty?: false)
103
+
104
+ env_opts = CovLoupe.send(:extract_env_opts)
105
+ full_argv = env_opts + []
106
+
107
+ expect(CovLoupe::ModeDetector.cli_mode?(full_argv, stdin: stdin)).to be true
108
+ end
109
+
110
+ it 'handles parse errors gracefully in mode detection' do
111
+ ENV['COV_LOUPE_OPTS'] = '--option "unclosed quote'
112
+
113
+ # Should return empty array and not crash
114
+ opts = CovLoupe.send(:extract_env_opts)
115
+ expect(opts).to eq([])
116
+ end
117
+
118
+ it 'actually runs CLI when --force-cli is in COV_LOUPE_OPTS' do
119
+ ENV['COV_LOUPE_OPTS'] = '--force-cli'
120
+
121
+ # Mock STDIN to not be a TTY (would normally trigger MCP server mode)
122
+ allow($stdin).to receive(:tty?).and_return(false)
123
+
124
+ # Run with --help which should produce help output
125
+ output = nil
126
+ silence_output do |out, err|
127
+ swallow_system_exit do
128
+ CovLoupe.run(['--help'])
129
+ end
130
+ output = out.string + err.string
131
+ end
132
+
133
+ # Verify CLI actually ran by checking for help text
134
+ expect(output).to include('Usage:')
135
+ expect(output).to include('cov-loupe')
136
+ end
137
+
138
+ it 'actually runs MCP server mode when no CLI indicators present' do
139
+ ENV['COV_LOUPE_OPTS'] = ''
140
+
141
+ # Mock STDIN to not be a TTY and to provide valid JSON-RPC
142
+ allow($stdin).to receive(:tty?).and_return(false)
143
+
144
+ # Provide a minimal JSON-RPC request that the server can handle
145
+ json_request = JSON.generate({
146
+ jsonrpc: '2.0',
147
+ id: 1,
148
+ method: 'initialize',
149
+ params: {
150
+ protocolVersion: '2024-11-05',
151
+ capabilities: {},
152
+ clientInfo: { name: 'test', version: '1.0' }
153
+ }
154
+ })
155
+
156
+ allow($stdin).to receive(:gets).and_return(json_request, nil)
157
+
158
+ # Capture output to verify MCP server response
159
+ output = nil
160
+ silence_output do |out, err|
161
+ CovLoupe.run([])
162
+ output = out.string + err.string
163
+ end
164
+
165
+ # Verify MCP server ran by checking for JSON-RPC response
166
+ expect(output).to include('"jsonrpc"')
167
+ expect(output).to include('"result"')
168
+ end
169
+ end
170
+
171
+ describe 'integration with actual CLI usage' do
172
+ it 'works end-to-end with --resultset option' do
173
+ test_resultset = File.join(Dir.tmpdir, 'test_coverage', '.resultset.json')
174
+ ENV['COV_LOUPE_OPTS'] = "--resultset #{test_resultset} --format json"
175
+ env_opts = CovLoupe.send(:extract_env_opts)
176
+
177
+ swallow_system_exit do
178
+ silence_output { cli.send(:run, env_opts + ['--help']) }
179
+ end
180
+
181
+ expect(cli.config.resultset).to eq(test_resultset)
182
+ expect(cli.config.format).to eq(:json)
183
+ end
184
+ end
185
+ end