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,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::OptionParsers::EnvOptionsParser do
6
+ let(:parser) { described_class.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 '#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('COV_LOUPE_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['COV_LOUPE_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['COV_LOUPE_OPTS'] = ' '
29
+ expect(parser.parse_env_opts).to eq([])
30
+ end
31
+
32
+ it 'parses simple options correctly' do
33
+ ENV['COV_LOUPE_OPTS'] = '--error-mode off --format json'
34
+ expect(parser.parse_env_opts).to eq(['--error-mode', 'off', '--format', 'json'])
35
+ end
36
+
37
+ it 'handles quoted strings with spaces' do
38
+ ENV['COV_LOUPE_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['COV_LOUPE_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['COV_LOUPE_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['COV_LOUPE_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['COV_LOUPE_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['COV_LOUPE_OPTS'] = '--resultset "unterminated string'
69
+
70
+ expect do
71
+ parser.parse_env_opts
72
+ end.to raise_error(CovLoupe::ConfigurationError, /Invalid COV_LOUPE_OPTS format/)
73
+ end
74
+
75
+ it 'raises ConfigurationError for unmatched single quotes' do
76
+ ENV['COV_LOUPE_OPTS'] = "--resultset 'unterminated string"
77
+
78
+ expect do
79
+ parser.parse_env_opts
80
+ end.to raise_error(CovLoupe::ConfigurationError, /Invalid COV_LOUPE_OPTS format/)
81
+ end
82
+
83
+ it 'raises ConfigurationError with descriptive message' do
84
+ ENV['COV_LOUPE_OPTS'] = '--option "bad quote'
85
+
86
+ expect do
87
+ parser.parse_env_opts
88
+ end.to raise_error(CovLoupe::ConfigurationError) do |error|
89
+ expect(error.message).to include('Invalid COV_LOUPE_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['COV_LOUPE_OPTS'] = '"first "second "third'
96
+
97
+ expect do
98
+ parser.parse_env_opts
99
+ end.to raise_error(CovLoupe::ConfigurationError, /Invalid COV_LOUPE_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', 'debug', '--other-option']
110
+ result = parser.pre_scan_error_mode(argv, error_mode_normalizer: error_mode_normalizer)
111
+ expect(result).to eq(:debug)
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', 'log', '--error-mode', 'off']
129
+ result = parser.pre_scan_error_mode(argv, error_mode_normalizer: error_mode_normalizer)
130
+ expect(result).to eq(:log)
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 'when handling errors during pre-scan' do
149
+ it 'returns nil when normalizer raises an error' do
150
+ faulty_normalizer = ->(_) { raise StandardError, 'Intentional error' }
151
+ argv = ['--error-mode', 'log']
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 = ->(_) { raise ArgumentError, 'Bad argument' }
159
+ argv = ['--error-mode', 'log']
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 = ->(_) { raise '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 '#normalize_error_mode (private)' do
176
+ it 'normalizes "off" to :off' do
177
+ expect(parser.send(:normalize_error_mode, 'off')).to eq(:off)
178
+ expect(parser.send(:normalize_error_mode, 'OFF')).to eq(:off)
179
+ expect(parser.send(:normalize_error_mode, 'Off')).to eq(:off)
180
+ end
181
+
182
+ it 'normalizes "log" to :log' do
183
+ expect(parser.send(:normalize_error_mode, 'log')).to eq(:log)
184
+ expect(parser.send(:normalize_error_mode, 'LOG')).to eq(:log)
185
+ expect(parser.send(:normalize_error_mode, 'Log')).to eq(:log)
186
+ end
187
+
188
+ it 'normalizes "debug" to :debug' do
189
+ expect(parser.send(:normalize_error_mode, 'debug')).to eq(:debug)
190
+ expect(parser.send(:normalize_error_mode, 'DEBUG')).to eq(:debug)
191
+ end
192
+
193
+ it 'defaults unknown values to :log' do
194
+ expect(parser.send(:normalize_error_mode, 'unknown')).to eq(:log)
195
+ expect(parser.send(:normalize_error_mode, 'invalid')).to eq(:log)
196
+ expect(parser.send(:normalize_error_mode, '')).to eq(:log)
197
+ end
198
+
199
+ it 'handles nil by defaulting to :log' do
200
+ expect(parser.send(:normalize_error_mode, nil)).to eq(:log)
201
+ end
202
+ end
203
+
204
+ describe 'custom environment variable name' do
205
+ it 'uses custom environment variable when specified' do
206
+ custom_parser = described_class.new(env_var: 'CUSTOM_OPTS')
207
+ ENV['CUSTOM_OPTS'] = '--error-mode off'
208
+
209
+ expect(custom_parser.parse_env_opts).to eq(['--error-mode', 'off'])
210
+ end
211
+
212
+ it 'includes custom env var name in error messages' do
213
+ custom_parser = described_class.new(env_var: 'MY_CUSTOM_VAR')
214
+ ENV['MY_CUSTOM_VAR'] = '"bad quote'
215
+
216
+ expect do
217
+ custom_parser.parse_env_opts
218
+ end.to raise_error(CovLoupe::ConfigurationError, /Invalid MY_CUSTOM_VAR format/)
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ OPTION_TESTS = {
6
+ staleness: {
7
+ long: '--staleness',
8
+ short: '-S',
9
+ pattern: /Valid values for --staleness: o\[ff\]|e\[rror\]/
10
+ },
11
+ source: {
12
+ long: '--source',
13
+ short: '-s',
14
+ pattern: /Valid values for --source: f\[ull\]|u\[ncovered\]/
15
+ },
16
+ error_mode: {
17
+ long: '--error-mode',
18
+ short: nil,
19
+ pattern: /Valid values for --error-mode: o\[ff\]|l\[og\]|d\[ebug\]/
20
+ },
21
+ sort_order: {
22
+ long: '--sort-order',
23
+ short: '-o',
24
+ pattern: /Valid values for --sort-order: a\[scending\]|d\[escending\]/
25
+ }
26
+ }.freeze
27
+
28
+ RSpec.describe CovLoupe::OptionParsers::ErrorHelper do
29
+ subject(:helper) { described_class.new }
30
+
31
+ # Helper method to capture stderr output
32
+ def capture_stderr
33
+ captured = StringIO.new
34
+ original = $stderr
35
+ $stderr = captured
36
+ begin
37
+ yield
38
+ rescue SystemExit
39
+ # Ignore exit calls
40
+ ensure
41
+ $stderr = original
42
+ end
43
+ captured.string
44
+ end
45
+
46
+ # Helper method to test error output matches expected pattern
47
+ def expect_error_output(error:, argv:, pattern:)
48
+ expect do
49
+ helper.handle_option_parser_error(error, argv: argv)
50
+ rescue SystemExit
51
+ # Ignore exit call
52
+ end.to output(pattern).to_stderr
53
+ end
54
+
55
+ describe '#handle_option_parser_error' do
56
+ context 'with invalid enumerated option values' do
57
+ OPTION_TESTS.each_value do |config|
58
+ context "when parsing #{config[:long]} option" do
59
+ let(:error) { OptionParser::InvalidArgument.new('invalid argument: xyz') }
60
+
61
+ it 'suggests valid values for space-separated form with invalid value' do
62
+ expect_error_output(
63
+ error: error,
64
+ argv: [config[:long], 'xyz'],
65
+ pattern: config[:pattern]
66
+ )
67
+ end
68
+
69
+ it 'suggests valid values for equal form with invalid value' do
70
+ expect_error_output(
71
+ error: error,
72
+ argv: ["#{config[:long]}=xyz"],
73
+ pattern: config[:pattern]
74
+ )
75
+ end
76
+
77
+ if config[:short]
78
+ it 'suggests valid values for short form with invalid value' do
79
+ expect_error_output(
80
+ error: error,
81
+ argv: [config[:short], 'xyz'],
82
+ pattern: config[:pattern]
83
+ )
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ context 'when handling --staleness option edge cases' do
90
+ it 'suggests valid values when value is missing' do
91
+ error = OptionParser::InvalidArgument.new('missing argument: --staleness')
92
+ expect_error_output(
93
+ error: error,
94
+ argv: ['--staleness'],
95
+ pattern: /Valid values for --staleness: o\[ff\]|e\[rror\]/
96
+ )
97
+ end
98
+
99
+ it 'suggests valid values when next token looks like an option' do
100
+ error = OptionParser::InvalidArgument.new('invalid argument: --other')
101
+ expect_error_output(
102
+ error: error,
103
+ argv: ['--staleness', '--other-option'],
104
+ pattern: /Valid values for --staleness: o\[ff\]|e\[rror\]/
105
+ )
106
+ end
107
+ end
108
+ end
109
+
110
+ context 'with multiple options in argv' do
111
+ it 'correctly identifies the problematic option among valid options' do
112
+ error = OptionParser::InvalidArgument.new('invalid argument: bad')
113
+ expect_error_output(
114
+ error: error,
115
+ argv: ['--resultset', 'coverage', '--staleness', 'bad', '--format', 'json'],
116
+ pattern: /Valid values for --staleness: o\[ff\]|e\[rror\]/
117
+ )
118
+ end
119
+
120
+ it 'handles equal form mixed with other options' do
121
+ error = OptionParser::InvalidArgument.new('invalid argument: invalid')
122
+ expect_error_output(
123
+ error: error,
124
+ argv: ['--format', 'json', '--sort-order=invalid', '--resultset', 'coverage'],
125
+ pattern: /Valid values for --sort-order: a\[scending\]|d\[escending\]/
126
+ )
127
+ end
128
+ end
129
+
130
+ context 'when option is not an enumerated type' do
131
+ it 'shows generic error message without enum hint' do
132
+ error = OptionParser::InvalidArgument.new('invalid option: --unknown')
133
+
134
+ stderr_output = capture_stderr do
135
+ helper.handle_option_parser_error(error, argv: ['--unknown'])
136
+ end
137
+
138
+ expect(stderr_output).to match(/Error:.*invalid option.*--unknown/)
139
+ expect(stderr_output).to match(/Run 'cov-loupe --help'/)
140
+ expect(stderr_output).not_to match(/Valid values/)
141
+ end
142
+ end
143
+
144
+ context 'when invalid option matches a subcommand' do
145
+ it 'suggests using it as a subcommand instead' do
146
+ error = OptionParser::InvalidOption.new('invalid option: --summary')
147
+
148
+ stderr_output = capture_stderr do
149
+ helper.handle_option_parser_error(error, argv: ['--summary'])
150
+ end
151
+
152
+ # NOTE: The subcommand detection logic isn't fully working as expected
153
+ # because extract_invalid_option doesn't properly parse the error message
154
+ expect(stderr_output).to match(/Error:.*--summary/)
155
+ expect(stderr_output).to match(/Run 'cov-loupe --help'/)
156
+ end
157
+ end
158
+
159
+ context 'when exiting after invalid option' do
160
+ it 'exits with status 1' do
161
+ error = OptionParser::InvalidArgument.new('invalid argument: xyz')
162
+
163
+ stderr_output = capture_stderr do
164
+ expect do
165
+ helper.handle_option_parser_error(error, argv: ['--staleness', 'xyz'])
166
+ end.to raise_error(SystemExit) do |e|
167
+ expect(e.status).to eq(1)
168
+ end
169
+ end
170
+
171
+ expect(stderr_output).to include('invalid argument: xyz')
172
+ end
173
+ end
174
+
175
+ context 'when customizing usage hint' do
176
+ it 'uses custom usage hint when provided' do
177
+ error = OptionParser::InvalidArgument.new('invalid argument: xyz')
178
+
179
+ expect do
180
+ helper.handle_option_parser_error(error, argv: ['--staleness', 'xyz'],
181
+ usage_hint: 'Custom hint message')
182
+ rescue SystemExit
183
+ # Ignore exit call
184
+ end.to output(/Custom hint message/).to_stderr
185
+ end
186
+ end
187
+ end
188
+
189
+ describe 'when handling edge cases' do
190
+ it 'handles empty argv gracefully' do
191
+ error = OptionParser::InvalidArgument.new('some error')
192
+ expect_error_output(
193
+ error: error,
194
+ argv: [],
195
+ pattern: /Error: invalid argument: some error/
196
+ )
197
+ end
198
+
199
+ it 'handles argv with only valid options (no problematic enum)' do
200
+ error = OptionParser::InvalidArgument.new('some error')
201
+
202
+ stderr_output = capture_stderr do
203
+ helper.handle_option_parser_error(error,
204
+ argv: ['--format', 'json', '--resultset', 'coverage'])
205
+ end
206
+
207
+ expect(stderr_output).to match(/Error: invalid argument: some error/)
208
+ expect(stderr_output).to match(/Run 'cov-loupe --help'/)
209
+ end
210
+
211
+ it 'does not show enum hint when all enum values are valid' do
212
+ error = OptionParser::MissingArgument.new('missing argument: --resultset')
213
+
214
+ stderr_output = capture_stderr do
215
+ helper.handle_option_parser_error(error, argv: ['--staleness', 'off', '--resultset'])
216
+ end
217
+
218
+ expect(stderr_output).to match(/Error:.*missing argument.*--resultset/)
219
+ expect(stderr_output).not_to match(/Valid values/)
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::PathRelativizer do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+ let(:relativizer) do
8
+ described_class.new(
9
+ root: root,
10
+ scalar_keys: %w[file file_path],
11
+ array_keys: %w[newer_files missing_files deleted_files]
12
+ )
13
+ end
14
+
15
+ describe '#relativize' do
16
+ it 'converts configured scalar keys to root-relative paths' do
17
+ payload = { 'file' => File.join(root, 'lib/foo.rb') }
18
+ result = relativizer.relativize(payload)
19
+
20
+ expect(result['file']).to eq('lib/foo.rb')
21
+ expect(payload['file']).to eq(File.join(root, 'lib/foo.rb'))
22
+ end
23
+
24
+ it 'relativizes arrays for configured keys without mutating originals' do
25
+ payload = {
26
+ 'newer_files' => [File.join(root, 'lib/foo.rb'), File.join(root, 'lib/bar.rb')]
27
+ }
28
+
29
+ result = relativizer.relativize(payload)
30
+
31
+ expect(result['newer_files']).to contain_exactly('lib/foo.rb', 'lib/bar.rb')
32
+ expect(payload['newer_files']).to all(start_with(root))
33
+ end
34
+
35
+ it 'leaves unconfigured keys untouched' do
36
+ payload = { 'other' => File.join(root, 'lib/foo.rb') }
37
+ result = relativizer.relativize(payload)
38
+
39
+ expect(result['other']).to eq(payload['other'])
40
+ end
41
+
42
+ it 'ignores paths outside the root' do
43
+ outside = '/tmp/external.rb'
44
+ payload = { 'file' => outside }
45
+
46
+ result = relativizer.relativize(payload)
47
+
48
+ expect(result['file']).to eq(outside)
49
+ end
50
+
51
+ it 'relativizes nested arrays of hashes' do
52
+ payload = {
53
+ 'files' => [
54
+ { 'file' => File.join(root, 'lib/foo.rb') },
55
+ { 'file' => File.join(root, 'lib/bar.rb') }
56
+ ],
57
+ 'counts' => { 'total' => 2 }
58
+ }
59
+
60
+ result = relativizer.relativize(payload)
61
+
62
+ expect(result['files'].map { |h| h['file'] }).to eq(%w[lib/foo.rb lib/bar.rb])
63
+ expect(result['counts']).to eq('total' => 2)
64
+ end
65
+
66
+ it "handles paths with '..' components" do
67
+ payload = { 'file' => File.join(root, 'lib/../lib/foo.rb') }
68
+ result = relativizer.relativize(payload)
69
+ expect(result['file']).to eq('lib/foo.rb')
70
+ end
71
+
72
+ it 'handles paths with spaces' do
73
+ file_with_space = File.join(root, 'lib/file with space.rb')
74
+ FileUtils.touch(file_with_space)
75
+
76
+ payload = { 'file' => file_with_space }
77
+ result = relativizer.relativize(payload)
78
+ expect(result['file']).to eq('lib/file with space.rb')
79
+ ensure
80
+ FileUtils.rm_f(file_with_space)
81
+ end
82
+
83
+ # On Windows, relative_path_from raises ArgumentError for paths on different
84
+ # drives (e.g., C: vs D:). The rescue block returns the original path.
85
+ it 'returns original path when relative_path_from raises ArgumentError' do
86
+ fake_pathname = instance_double(Pathname)
87
+ allow(fake_pathname).to receive(:relative_path_from)
88
+ .and_raise(ArgumentError, 'different prefix')
89
+ allow(Pathname).to receive(:new).and_call_original
90
+ allow(Pathname).to receive(:new).with(File.absolute_path('lib/foo.rb', root))
91
+ .and_return(fake_pathname)
92
+
93
+ result = relativizer.relativize_path(File.join(root, 'lib/foo.rb'))
94
+
95
+ expect(result).to eq(File.join(root, 'lib/foo.rb'))
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/coverage_presenter_examples'
5
+
6
+ RSpec.describe CovLoupe::Presenters::CoverageDetailedPresenter do
7
+ it_behaves_like 'a coverage presenter',
8
+ model_method: :detailed_for,
9
+ payload: {
10
+ 'file' => '/abs/path/lib/foo.rb',
11
+ 'lines' => [
12
+ { 'line' => 1, 'hits' => 1, 'covered' => true },
13
+ { 'line' => 2, 'hits' => 0, 'covered' => false }
14
+ ],
15
+ 'summary' => { 'covered' => 1, 'total' => 2, 'percentage' => 50.0 }
16
+ },
17
+ stale: 'L',
18
+ expected_keys: ['lines', 'summary']
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/coverage_presenter_examples'
5
+
6
+ RSpec.describe CovLoupe::Presenters::CoverageRawPresenter do
7
+ it_behaves_like 'a coverage presenter',
8
+ model_method: :raw_for,
9
+ payload: {
10
+ 'file' => '/abs/path/lib/foo.rb',
11
+ 'lines' => [1, 0, nil, 2]
12
+ },
13
+ stale: 'L',
14
+ expected_keys: ['lines']
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/coverage_presenter_examples'
5
+
6
+ RSpec.describe CovLoupe::Presenters::CoverageSummaryPresenter do
7
+ it_behaves_like 'a coverage presenter',
8
+ model_method: :summary_for,
9
+ payload: {
10
+ 'file' => '/abs/path/lib/foo.rb',
11
+ 'summary' => { 'covered' => 8, 'total' => 10, 'percentage' => 80.0 }
12
+ },
13
+ stale: false,
14
+ expected_keys: ['summary']
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/coverage_presenter_examples'
5
+
6
+ RSpec.describe CovLoupe::Presenters::CoverageUncoveredPresenter do
7
+ it_behaves_like 'a coverage presenter',
8
+ model_method: :uncovered_for,
9
+ payload: {
10
+ 'file' => '/abs/path/lib/foo.rb',
11
+ 'uncovered' => [2, 4],
12
+ 'summary' => { 'covered' => 2, 'total' => 4, 'percentage' => 50.0 }
13
+ },
14
+ stale: 'M',
15
+ expected_keys: ['uncovered', 'summary']
16
+ end