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,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::CoverageCLI do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+
8
+ it 'shows help and exits 0' do
9
+ out, err, status = run_cli_with_status('--help')
10
+ expect(status).to eq(0)
11
+ expect(out).to match(/Usage:.*cov-loupe/)
12
+ expect(out).to include(
13
+ 'Repository: https://github.com/keithrbennett/cov-loupe',
14
+ 'Subcommands:'
15
+ )
16
+ expect(out).to match(/Version:.*#{CovLoupe::VERSION}/)
17
+ expect(err).to eq('')
18
+ end
19
+
20
+ shared_examples 'maps error to exit 1 with message' do
21
+ before do
22
+ # Build a fake model that raises the specified error from the specified method
23
+ fake_model = Class.new do
24
+ def initialize(*)
25
+ end
26
+ end
27
+ error_to_raise = raised_error
28
+ fake_model.define_method(model_method) { |*| raise error_to_raise }
29
+ stub_const('CovLoupe::CoverageModel', fake_model)
30
+ end
31
+
32
+ it 'exits with status 1 and friendly message' do
33
+ _out, err, status = run_cli_with_status(*invoke_args)
34
+ expect(status).to eq(1)
35
+ expect(err).to include(expected_message)
36
+ end
37
+ end
38
+
39
+ context 'when mapping ENOENT' do
40
+ let(:model_method) { :summary_for }
41
+ let(:raised_error) { Errno::ENOENT.new('No such file or directory @ rb_sysopen - missing.rb') }
42
+ let(:invoke_args) { ['--root', root, '--resultset', 'coverage', 'summary', 'lib/missing.rb'] }
43
+ let(:expected_message) { 'File error: File not found: lib/missing.rb' }
44
+
45
+ it_behaves_like 'maps error to exit 1 with message'
46
+ end
47
+
48
+ context 'when mapping EACCES' do
49
+ let(:model_method) { :raw_for }
50
+ let(:raised_error) { Errno::EACCES.new('Permission denied @ rb_sysopen - secret.rb') }
51
+ let(:invoke_args) { ['--root', root, '--resultset', 'coverage', 'raw', 'lib/secret.rb'] }
52
+ let(:expected_message) { 'Permission denied: lib/secret.rb' }
53
+
54
+ it_behaves_like 'maps error to exit 1 with message'
55
+ end
56
+
57
+ it 'emits detailed stale coverage info and exits 1' do
58
+ mock_resultset_with_timestamp(root, VERY_OLD_TIMESTAMP, coverage: {
59
+ File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, 1] }
60
+ })
61
+
62
+ _out, err, status = run_cli_with_status('--root', root, '--resultset', 'coverage',
63
+ '--staleness', 'error', 'summary', 'lib/foo.rb')
64
+ expect(status).to eq(1)
65
+ expect(err).to include('Coverage data stale:')
66
+ expect(err).to match(/File\s+- time:/)
67
+ expect(err).to match('Coverage\s+- time:')
68
+ expect(err).to match(/Delta\s+- file is [+-]?\d+s newer than coverage/)
69
+ expect(err).to match('Resultset\s+-')
70
+ end
71
+
72
+ it 'honors --no-strict-staleness to disable checks' do
73
+ mock_resultset_with_timestamp(root, VERY_OLD_TIMESTAMP, coverage: {
74
+ File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, 1] }
75
+ })
76
+
77
+ _out, err, status = run_cli_with_status('--root', root, '--resultset', 'coverage',
78
+ '--staleness', 'off', 'summary', 'lib/foo.rb')
79
+ expect(status).to eq(0)
80
+ expect(err).to eq('')
81
+ end
82
+
83
+ it 'handles source rendering errors gracefully with fallback message' do
84
+ # Test that source rendering with problematic coverage data doesn't crash
85
+ # This is a regression test for the "can't convert nil into Integer" crash
86
+ # that was previously mentioned in comments
87
+ out, err, status = run_cli_with_status(
88
+ '--root', root, '--resultset', 'coverage', '--source', 'uncovered', '--context-lines', '2',
89
+ '--no-color', 'uncovered', 'lib/foo.rb'
90
+ )
91
+
92
+ expect(status).to eq(0)
93
+ expect(err).to eq('')
94
+ expect(out).to match(/File:\s+lib\/foo\.rb/)
95
+ expect(out).to include('│') # Table format
96
+ expect(out).to show_source_table_or_fallback
97
+ end
98
+
99
+ it 'renders source with full mode without crashing' do
100
+ # Additional regression test for source rendering with full mode
101
+ out, err, status = run_cli_with_status(
102
+ '--root', root, '--resultset', 'coverage', '--source', 'full', '--no-color',
103
+ 'summary', 'lib/foo.rb'
104
+ )
105
+
106
+ expect(status).to eq(0)
107
+ expect(err).to eq('')
108
+ expect(out).to include('lib/foo.rb')
109
+ expect(out).to include('66.67%')
110
+ expect(out).to show_source_table_or_fallback
111
+ end
112
+
113
+ it 'shows fallback message when source file is unreadable' do
114
+ # Test the fallback path when source files can't be read
115
+ # Temporarily rename the source file to make it unreadable
116
+ foo_path = File.join(root, 'lib', 'foo.rb')
117
+ temp_path = "#{foo_path}.hidden"
118
+
119
+ begin
120
+ File.rename(foo_path, temp_path) if File.exist?(foo_path)
121
+
122
+ out, err, status = run_cli_with_status(
123
+ '--root', root, '--resultset', 'coverage', '--source', 'full', '--no-color',
124
+ 'summary', 'lib/foo.rb'
125
+ )
126
+
127
+ expect(status).to eq(0)
128
+ expect(err).to eq('')
129
+ expect(out).to include('lib/foo.rb')
130
+ expect(out).to include('66.67%')
131
+ expect(out).to include('[source not available]')
132
+ ensure
133
+ # Restore the file
134
+ File.rename(temp_path, foo_path) if File.exist?(temp_path)
135
+ end
136
+ end
137
+
138
+ describe 'invalid option handling' do
139
+ it 'suggests subcommand for --subcommand-like option' do
140
+ _out, err, status = run_cli_with_status('--summary')
141
+ expect(status).to eq(1)
142
+ expect(err).to include(
143
+ "Error: '--summary' is not a valid option. Did you mean the 'summary' subcommand?"
144
+ )
145
+ expect(err).to include('Try: cov-loupe summary [args]')
146
+ end
147
+
148
+ it 'reports invalid enum value for --opt=value' do
149
+ _out, err, status = run_cli_with_status('--staleness=bogus', 'list')
150
+ expect(status).to eq(1)
151
+ expect(err).to include('invalid argument: --staleness=bogus')
152
+ end
153
+
154
+ it 'reports invalid enum value for --opt value' do
155
+ _out, err, status = run_cli_with_status('--staleness', 'bogus', 'list')
156
+ expect(status).to eq(1)
157
+ expect(err).to include('invalid argument: bogus')
158
+ end
159
+
160
+ it 'handles generic invalid options' do
161
+ _out, err, status = run_cli_with_status('--no-such-option')
162
+ expect(status).to eq(1)
163
+ expect(err).to include('Error: invalid option: --no-such-option')
164
+ end
165
+ end
166
+
167
+ describe 'subcommand error handling' do
168
+ it 'handles generic exceptions from subcommands' do
169
+ # Stub the CommandFactory to return a command that raises a StandardError
170
+ fake_command = Class.new do
171
+ def initialize(_cli) = nil
172
+ def execute(_args) = raise(StandardError, 'Unexpected error in subcommand')
173
+ end
174
+
175
+ allow(CovLoupe::Commands::CommandFactory).to receive(:create)
176
+ .and_return(fake_command.new(nil))
177
+
178
+ _out, err, status = run_cli_with_status('--root', root, '--resultset', 'coverage', 'summary',
179
+ 'lib/foo.rb')
180
+ expect(status).to eq(1)
181
+ expect(err).to include('Unexpected error in subcommand')
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::CoverageCLI, 'format option' do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+
8
+ def run_cli(*argv)
9
+ cli = CovLoupe::CoverageCLI.new
10
+ output = nil
11
+ silence_output do |stdout, _stderr|
12
+ cli.send(:run, argv)
13
+ output = stdout.string
14
+ end
15
+ output
16
+ end
17
+
18
+ describe 'format normalization' do
19
+ it 'normalizes short format aliases' do
20
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'j', 'list')
21
+ expect(output).to include('"files":', '"percentage":')
22
+ data = JSON.parse(output)
23
+ expect(data['files']).to be_an(Array)
24
+ end
25
+
26
+ it 'normalizes table format' do
27
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 't', 'list')
28
+ expect(output).to include('File', '%') # Table output
29
+ expect(output).not_to include('"files"') # Not JSON
30
+ end
31
+
32
+ it 'supports yaml format' do
33
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'y', 'list')
34
+ expect(output).to include('---', 'files:', 'file:')
35
+ end
36
+
37
+ it 'supports awesome_print format' do
38
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'a', 'list')
39
+ # AwesomePrint output contains colored/formatted structure
40
+ expect(output).to match(/:files|"files"/)
41
+ end
42
+ end
43
+
44
+ describe 'option order requirements' do
45
+ it 'works with format option before subcommand' do
46
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'json', 'list')
47
+ data = JSON.parse(output)
48
+ expect(data).to have_key('files')
49
+ end
50
+
51
+ it 'shows helpful error when global option comes after subcommand' do
52
+ _out, err, status = run_cli_with_status(
53
+ '--root', root, '--resultset', 'coverage', 'list', '--format', 'json'
54
+ )
55
+ expect(status).to eq(1)
56
+ expect(err).to include(
57
+ 'Global option(s) must come BEFORE the subcommand',
58
+ 'You used: list --format',
59
+ 'Correct: --format list',
60
+ 'Example:'
61
+ )
62
+ end
63
+ end
64
+
65
+ describe 'format with different subcommands' do
66
+ it 'works with totals subcommand' do
67
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'json', 'totals')
68
+ data = JSON.parse(output)
69
+ expect(data).to have_key('lines')
70
+ expect(data).to have_key('percentage')
71
+ end
72
+
73
+ it 'works with summary subcommand' do
74
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'json',
75
+ 'summary', 'lib/foo.rb')
76
+ data = JSON.parse(output)
77
+ expect(data).to have_key('file')
78
+ expect(data).to have_key('summary')
79
+ end
80
+
81
+ it 'works with version subcommand' do
82
+ output = run_cli('--format', 'json', 'version')
83
+ data = JSON.parse(output)
84
+ expect(data).to have_key('version')
85
+ expect(data).to have_key('gem_root')
86
+ end
87
+ end
88
+
89
+ describe 'comprehensive misplaced option detection' do
90
+ # Array of test cases: [description, args_array, expected_option_in_error]
91
+ [
92
+ # Short-form options
93
+ ['short -f after list', ['list', '-f', 'json'], '-f'],
94
+ ['short -r after totals', ['totals', '-r', '.resultset.json'], '-r'],
95
+ ['short -R after list', ['list', '-R', '/tmp'], '-R'],
96
+ ['short -o after list', ['list', '-o', 'a'], '-o'],
97
+ ['short -s after list', ['list', '-s', 'full'], '-s'],
98
+ ['short -S after list', ['list', '-S', 'error'], '-S'],
99
+
100
+ # Long-form options
101
+ ['--sort-order after list', ['list', '--sort-order', 'ascending'], '--sort-order'],
102
+ ['--source after list', ['list', '--source', 'full'], '--source'],
103
+ ['--staleness after totals', ['totals', '--staleness', 'error'], '--staleness'],
104
+ ['--color after list', ['list', '--color'], '--color'],
105
+ ['--no-color after list', ['list', '--no-color'], '--no-color'],
106
+ ['--log-file after list', ['list', '--log-file', '/tmp/test.log'], '--log-file'],
107
+
108
+ # Different subcommands
109
+ ['option after version', ['version', '--format', 'json'], '--format'],
110
+ ['option after summary', ['summary', 'lib/foo.rb', '--format', 'json'], '--format'],
111
+ ['option after raw', ['raw', 'lib/foo.rb', '-f', 'json'], '-f'],
112
+ ['option after detailed', ['detailed', 'lib/foo.rb', '-f', 'json'], '-f'],
113
+ ['option after uncovered', ['uncovered', 'lib/foo.rb', '--root', '/tmp'], '--root']
114
+ ].each do |desc, args, option|
115
+ it "detects #{desc}" do
116
+ _out, err, status = run_cli_with_status(*args)
117
+ expect(status).to eq(1)
118
+ expect(err).to include('Global option(s) must come BEFORE the subcommand')
119
+ expect(err).to include(option)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::CoverageCLI, 'json format options' do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+
8
+ def run_cli_output(*argv)
9
+ cli = CovLoupe::CoverageCLI.new
10
+ output = nil
11
+ silence_output do |stdout, _stderr|
12
+ cli.send(:run, argv)
13
+ output = stdout.string
14
+ end
15
+ output
16
+ end
17
+
18
+ describe 'JSON format options' do
19
+ it 'produces compact JSON with -f j' do
20
+ output = run_cli_output('--root', root, '--resultset', 'coverage', '-f', 'j', 'list')
21
+
22
+ expect(output.strip.lines.count).to eq(1)
23
+ data = JSON.parse(output)
24
+ expect(data['files']).to be_an(Array)
25
+ end
26
+
27
+ it 'produces pretty JSON with -f pretty-json' do
28
+ output = run_cli_output('--root', root, '--resultset', 'coverage', '-f', 'pretty-json',
29
+ 'list')
30
+ expect(output.strip.lines.count).to be > 1
31
+ data = JSON.parse(output)
32
+ expect(data['files']).to be_an(Array)
33
+ end
34
+
35
+ it 'produces pretty JSON with -f pretty_json (underscore variant)' do
36
+ output = run_cli_output('--root', root, '--resultset', 'coverage', '-f', 'pretty_json',
37
+ 'list')
38
+ expect(output.strip.lines.count).to be > 1
39
+ data = JSON.parse(output)
40
+ expect(data['files']).to be_an(Array)
41
+ end
42
+
43
+ it 'produces compact JSON with -f json' do
44
+ output = run_cli_output('--root', root, '--resultset', 'coverage', '-f', 'json', 'list')
45
+ expect(output.strip.lines.count).to eq(1)
46
+ data = JSON.parse(output)
47
+ expect(data['files']).to be_an(Array)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::CoverageCLI do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+
8
+ it 'renders uncovered source without error for fixture file' do
9
+ out, err, status = run_cli_with_status(
10
+ '--root', root, '--resultset', 'coverage', '--source', 'uncovered', '--context-lines', '1',
11
+ '--no-color', 'uncovered', 'lib/foo.rb'
12
+ )
13
+ expect(status).to eq(0)
14
+ expect(err).to eq('')
15
+ expect(out).to match(/File:\s+lib\/foo\.rb/)
16
+ expect(out).to include('│') # Table format
17
+ expect(out).to show_source_table_or_fallback
18
+ end
19
+
20
+ it 'renders full source for uncovered command without brittle spacing' do
21
+ out, err, status = run_cli_with_status(
22
+ '--root', root, '--resultset', 'coverage', '--source', 'full', '--no-color',
23
+ 'uncovered', 'lib/foo.rb'
24
+ )
25
+ expect(status).to eq(0)
26
+ expect(err).to eq('')
27
+ expect(out).to include('│') # Table format
28
+ expect(out).to include('66.67%')
29
+ expect(out).to show_source_table_or_fallback
30
+ end
31
+
32
+ it 'renders source for summary with uncovered mode without crashing' do
33
+ out, err, status = run_cli_with_status(
34
+ '--root', root, '--resultset', 'coverage', '--source', 'uncovered', '--context-lines', '1',
35
+ '--no-color', 'summary', 'lib/foo.rb'
36
+ )
37
+ expect(status).to eq(0)
38
+ expect(err).to eq('')
39
+ expect(out).to include('lib/foo.rb')
40
+ expect(out).to include('66.67%')
41
+ expect(out).to include('│') # Table format
42
+ expect(out).to show_source_table_or_fallback
43
+ end
44
+ end
data/spec/cli_spec.rb ADDED
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tempfile'
5
+
6
+ RSpec.describe CovLoupe::CoverageCLI do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+
9
+ def run_cli(*argv)
10
+ cli = described_class.new
11
+ silence_output do |out, _err|
12
+ begin
13
+ cli.run(argv.flatten)
14
+ rescue SystemExit
15
+ # Ignore exit, just capture output
16
+ end
17
+ return out.string
18
+ end
19
+ end
20
+
21
+ describe 'JSON output' do
22
+ def with_json_output(command, *args)
23
+ output = run_cli('--format', 'json', '--root', root, '--resultset', 'coverage',
24
+ command, *args)
25
+ yield JSON.parse(output)
26
+ end
27
+
28
+ it 'prints summary as JSON' do
29
+ with_json_output('summary', 'lib/foo.rb') do |data|
30
+ expect(data['summary']).to include('covered' => 2)
31
+ end
32
+ end
33
+
34
+ it 'prints raw as JSON' do
35
+ with_json_output('raw', 'lib/foo.rb') do |data|
36
+ expect(data['lines']).to eq([1, 0, nil, 2])
37
+ end
38
+ end
39
+
40
+ it 'prints uncovered as JSON' do
41
+ with_json_output('uncovered', 'lib/foo.rb') do |data|
42
+ expect(data['uncovered']).to eq([2])
43
+ end
44
+ end
45
+
46
+ it 'prints detailed as JSON' do
47
+ with_json_output('detailed', 'lib/foo.rb') do |data|
48
+ expect(data['lines']).to be_an(Array)
49
+ end
50
+ end
51
+
52
+ it 'prints totals as JSON' do
53
+ with_json_output('totals') do |data|
54
+ expect(data['lines']).to include('total' => 6, 'covered' => 3, 'uncovered' => 3)
55
+ expect(data['files']).to include('total' => 2)
56
+ expect(data['files']['ok'] + data['files']['stale']).to eq(data['files']['total'])
57
+ end
58
+ end
59
+ end
60
+
61
+ it 'prints raw lines as text' do
62
+ output = run_cli('--root', root, '--resultset', 'coverage', 'raw', 'lib/foo.rb')
63
+ expect(output).to include('File: lib/foo.rb')
64
+ expect(output).to include('│') # Table format
65
+ end
66
+
67
+ it 'list subcommand with --json outputs JSON with sort order' do
68
+ output = run_cli(
69
+ '--format', 'json', '--root', root, '--resultset', 'coverage', '--sort-order', 'a', 'list'
70
+ )
71
+ asc = JSON.parse(output)
72
+ expect(asc['files']).to be_an(Array)
73
+ expect(asc['files'].first['file']).to end_with('lib/bar.rb')
74
+
75
+ # Includes counts for total/ok/stale and they are consistent
76
+ expect(asc['counts']).to include('total', 'ok', 'stale')
77
+ total = asc['counts']['total']
78
+ ok = asc['counts']['ok']
79
+ stale = asc['counts']['stale']
80
+ expect(total).to eq(asc['files'].length)
81
+ expect(ok + stale).to eq(total)
82
+
83
+ output = run_cli(
84
+ '--format', 'json', '--root', root, '--resultset', 'coverage', '--sort-order', 'd', 'list'
85
+ )
86
+ desc = JSON.parse(output)
87
+ expect(desc['files'].first['file']).to end_with('lib/foo.rb')
88
+ end
89
+
90
+ it 'list subcommand outputs formatted table' do
91
+ output = run_cli('--root', root, '--resultset', 'coverage', 'list')
92
+ expect(output).to include('File')
93
+ expect(output).to include('lib/foo.rb')
94
+ expect(output).to include('lib/bar.rb')
95
+ expect(output).to match(/Files: total \d+/)
96
+ end
97
+
98
+ it 'list subcommand retains rows when using an absolute tracked glob' do
99
+ absolute_glob = File.join(root, 'lib', '**', '*.rb')
100
+ output = run_cli('--root', root, '--resultset', 'coverage', '--tracked-globs',
101
+ absolute_glob, 'list')
102
+ expect(output).not_to include('No coverage data found')
103
+ expect(output).to include('lib/foo.rb')
104
+ expect(output).to include('lib/bar.rb')
105
+ end
106
+
107
+ it 'totals subcommand prints a readable summary by default' do
108
+ output = run_cli('--root', root, '--resultset', 'coverage', 'totals')
109
+ expect(output).to include('│') # Table format
110
+ expect(output).to include('Lines')
111
+ # expect(output).to include('Average coverage:') # Not in table version
112
+ end
113
+
114
+ it 'can include source in JSON payload (nil if file missing)' do
115
+ output = run_cli('--format', 'json', '--root', root, '--resultset', 'coverage',
116
+ '--source', 'full', 'summary', 'lib/foo.rb')
117
+ data = JSON.parse(output)
118
+ expect(data).to have_key('source')
119
+ end
120
+
121
+ describe 'log file configuration' do
122
+ it 'passes --log-file path into the CLI execution context' do
123
+ Dir.mktmpdir do |dir|
124
+ log_path = File.join(dir, 'custom.log')
125
+ expect(CovLoupe).to receive(:create_context)
126
+ .and_wrap_original do |m, error_handler:, log_target:, mode:|
127
+ # Ensure CLI forwards the requested log path into the context without changing other fields.
128
+ expect(log_target).to eq(log_path)
129
+ m.call(error_handler: error_handler, log_target: log_target, mode: mode)
130
+ end
131
+ original_target = CovLoupe.active_log_file
132
+ run_cli('--format', 'json', '--root', root, '--resultset', 'coverage',
133
+ '--log-file', log_path, 'summary', 'lib/foo.rb')
134
+ expect(CovLoupe.active_log_file).to eq(original_target)
135
+ end
136
+ end
137
+
138
+ it 'supports stdout logging within the CLI context' do
139
+ expect(CovLoupe).to receive(:create_context)
140
+ .and_wrap_original do |m, error_handler:, log_target:, mode:|
141
+ # For stdout logging, verify the context is still constructed with the expected value.
142
+ expect(log_target).to eq('stdout')
143
+ m.call(error_handler: error_handler, log_target: log_target, mode: mode)
144
+ end
145
+ original_target = CovLoupe.active_log_file
146
+ run_cli('--format', 'json', '--root', root, '--resultset', 'coverage',
147
+ '--log-file', 'stdout', 'summary', 'lib/foo.rb')
148
+ expect(CovLoupe.active_log_file).to eq(original_target)
149
+ end
150
+ end
151
+
152
+
153
+
154
+
155
+
156
+ describe 'version command' do
157
+ it 'prints version as plain text by default' do
158
+ output = run_cli('version')
159
+ expect(output).to include('│') # Table format
160
+ expect(output).to include(CovLoupe::VERSION)
161
+ expect(output).not_to include('{')
162
+ expect(output).not_to include('}')
163
+ end
164
+
165
+ it 'prints version as JSON when --json flag is used' do
166
+ output = run_cli('--format', 'json', 'version')
167
+ data = JSON.parse(output)
168
+ expect(data).to have_key('version')
169
+ expect(data['version']).to eq(CovLoupe::VERSION)
170
+ end
171
+
172
+ it 'works with version command and other flags' do
173
+ output = run_cli('--root', root, 'version')
174
+ expect(output).to include('│') # Table format
175
+ expect(output).to include(CovLoupe::VERSION)
176
+ end
177
+ end
178
+
179
+ describe 'version option (-v)' do
180
+ it 'prints the same version info as the version subcommand' do
181
+ output = run_cli('-v')
182
+ expect(output).to include('│') # Table format
183
+ expect(output).to include(CovLoupe::VERSION)
184
+ end
185
+
186
+ it 'respects --json when -v is used' do
187
+ output = run_cli('-v', '--format', 'json')
188
+ data = JSON.parse(output)
189
+ expect(data['version']).to eq(CovLoupe::VERSION)
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::CoverageCLI do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+
8
+ def run_cli(*argv)
9
+ cli = described_class.new
10
+ silence_output do |out, _err|
11
+ cli.run(argv.flatten)
12
+ return out.string
13
+ end
14
+ end
15
+
16
+ it 'prints default table when no subcommand is given' do
17
+ output = run_cli('--root', root, '--resultset', 'coverage')
18
+
19
+ # Contains a header row and at least one data row with expected columns
20
+ expect(output).to include('File')
21
+ expect(output).to include('Covered')
22
+ expect(output).to include('Total')
23
+
24
+ # Should list fixture files from the demo project
25
+ expect(output).to include('lib/foo.rb')
26
+ expect(output).to include('lib/bar.rb')
27
+ end
28
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tempfile'
5
+
6
+ RSpec.describe CovLoupe::CoverageCLI do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+
9
+ it 'errors with usage when summary path is missing' do
10
+ _out, err, status = run_cli_with_status('--root', root, '--resultset', 'coverage', 'summary')
11
+ expect(status).to eq(1)
12
+ expect(err).to include('Usage: cov-loupe summary <path>')
13
+ end
14
+
15
+ it 'errors with meaningful message for unknown subcommand' do
16
+ _out, err, status = run_cli_with_status('--root', root, '--resultset', 'coverage', 'bogus')
17
+ expect(status).to eq(1)
18
+ expect(err).to include("Unknown subcommand: 'bogus'", 'Valid subcommands:')
19
+ end
20
+
21
+ it 'list honors stale=error and tracked_globs by exiting 1 when project is stale' do
22
+ Tempfile.create(['brand_new_file_for_cli_usage_spec', '.rb'], File.join(root, 'lib')) do |f|
23
+ f.write("# new file\n")
24
+ f.flush
25
+ _out, err, status = run_cli_with_status(
26
+ '--root', root, '--resultset', 'coverage', '--staleness', 'error', '--tracked-globs',
27
+ 'lib/**/*.rb', 'list'
28
+ )
29
+ expect(status).to eq(1)
30
+ expect(err).to include('Coverage data stale (project)')
31
+ end
32
+ end
33
+
34
+ it 'list with stale=off prints table and exits 0' do
35
+ out, err, status = run_cli_with_status(
36
+ '--root', root, '--resultset', 'coverage', '--staleness', 'off', 'list'
37
+ )
38
+ expect(status).to eq(0)
39
+ expect(err).to eq('')
40
+ expect(out).to include('File', 'lib/foo.rb')
41
+ end
42
+ end