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,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::Commands::BaseCommand do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+ let(:cli_context) { CovLoupe::CoverageCLI.new }
8
+
9
+ # Create a test command class that exposes protected methods for testing
10
+ let(:test_command_class) do
11
+ Class.new(CovLoupe::Commands::BaseCommand) do
12
+ def execute(args)
13
+ # Not needed for these tests
14
+ end
15
+
16
+ # Expose protected methods for testing
17
+ def public_handle_with_path(args, name, &)
18
+ handle_with_path(args, name, &)
19
+ end
20
+ end
21
+ end
22
+
23
+ let(:test_command) { test_command_class.new(cli_context) }
24
+
25
+ describe '#handle_with_path' do
26
+ context 'when Errno::ENOENT is raised' do
27
+ it 'converts to FileNotFoundError with correct message' do
28
+ args = ['lib/missing.rb']
29
+
30
+ # Stub the block to raise Errno::ENOENT
31
+ expect do
32
+ test_command.public_handle_with_path(args, 'test') do |_path|
33
+ raise Errno::ENOENT, 'No such file or directory'
34
+ end
35
+ end.to raise_error(CovLoupe::FileNotFoundError, 'File not found: lib/missing.rb')
36
+ end
37
+
38
+ it 'includes the path from the args in the error message' do
39
+ args = ['some/other/path.rb']
40
+
41
+ expect do
42
+ test_command.public_handle_with_path(args, 'test') do |_path|
43
+ raise Errno::ENOENT, 'No such file or directory'
44
+ end
45
+ end.to raise_error(CovLoupe::FileNotFoundError, /some\/other\/path\.rb/)
46
+ end
47
+ end
48
+
49
+ context 'when Errno::EACCES is raised' do
50
+ it 'converts to FilePermissionError with correct message' do
51
+ args = ['lib/secret.rb']
52
+
53
+ # Stub the block to raise Errno::EACCES
54
+ expect do
55
+ test_command.public_handle_with_path(args, 'test') do |_path|
56
+ raise Errno::EACCES, 'Permission denied'
57
+ end
58
+ end.to raise_error(CovLoupe::FilePermissionError, 'Permission denied: lib/secret.rb')
59
+ end
60
+
61
+ it 'includes the path from the args in the error message' do
62
+ args = ['/root/protected.rb']
63
+
64
+ expect do
65
+ test_command.public_handle_with_path(args, 'test') do |_path|
66
+ raise Errno::EACCES, 'Permission denied'
67
+ end
68
+ end.to raise_error(CovLoupe::FilePermissionError, /\/root\/protected\.rb/)
69
+ end
70
+ end
71
+
72
+ context 'when no path is provided' do
73
+ it 'raises UsageError' do
74
+ args = []
75
+
76
+ expect do
77
+ test_command.public_handle_with_path(args, 'summary') do |_path|
78
+ # Should not reach here
79
+ end
80
+ end.to raise_error(CovLoupe::UsageError, /summary <path>/)
81
+ end
82
+ end
83
+
84
+ context 'when successful' do
85
+ it 'yields the path to the block' do
86
+ args = ['lib/foo.rb']
87
+ yielded_path = nil
88
+
89
+ test_command.public_handle_with_path(args, 'test') do |path|
90
+ yielded_path = path
91
+ end
92
+
93
+ expect(yielded_path).to eq('lib/foo.rb')
94
+ end
95
+
96
+ it 'shifts the path from args' do
97
+ args = ['lib/foo.rb', 'extra', 'args']
98
+
99
+ test_command.public_handle_with_path(args, 'test') do |_path|
100
+ # Block execution
101
+ end
102
+
103
+ expect(args).to eq(['extra', 'args'])
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::Commands::CommandFactory do
6
+ let(:cli_context) { CovLoupe::CoverageCLI.new }
7
+
8
+ describe '.create' do
9
+ context 'with valid command names' do
10
+ [
11
+ ['list', CovLoupe::Commands::ListCommand],
12
+ ['version', CovLoupe::Commands::VersionCommand],
13
+ ['summary', CovLoupe::Commands::SummaryCommand],
14
+ ['raw', CovLoupe::Commands::RawCommand],
15
+ ['uncovered', CovLoupe::Commands::UncoveredCommand],
16
+ ['detailed', CovLoupe::Commands::DetailedCommand],
17
+ ['totals', CovLoupe::Commands::TotalsCommand],
18
+ ['total', CovLoupe::Commands::TotalsCommand] # Alias
19
+ ].each do |command_name, command_class|
20
+ it "creates a #{command_class.name.split('::').last} for \"#{command_name}\"" do
21
+ command = described_class.create(command_name, cli_context)
22
+ expect(command).to be_a(command_class)
23
+ end
24
+ end
25
+ end
26
+
27
+ context 'with unknown command name' do
28
+ [
29
+ [
30
+ 'invalid_cmd',
31
+ 'invalid command',
32
+ /list \| summary <path> \| raw <path> \| uncovered <path>/
33
+ ],
34
+ [nil, 'nil command', nil],
35
+ ['', 'empty string command', nil],
36
+ ['sumary', 'misspelled command', nil]
37
+ ].each do |command_name, description, pattern|
38
+ it "raises UsageError for #{description}" do
39
+ expect do
40
+ described_class.create(command_name, cli_context)
41
+ end.to raise_error(CovLoupe::UsageError, pattern)
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ describe '.available_commands' do
48
+ it 'returns an array of available command names' do
49
+ commands = described_class.available_commands
50
+ expect(commands).to be_an(Array)
51
+ expect(commands).to contain_exactly('list', 'version', 'summary', 'raw', 'uncovered',
52
+ 'detailed', 'totals', 'total', 'validate')
53
+ end
54
+
55
+ it 'returns the keys from COMMAND_MAP' do
56
+ expect(described_class.available_commands).to eq(described_class::COMMAND_MAP.keys)
57
+ end
58
+ end
59
+
60
+ describe 'COMMAND_MAP' do
61
+ it 'is frozen to prevent modifications' do
62
+ expect(described_class::COMMAND_MAP).to be_frozen
63
+ end
64
+
65
+ it 'maps command names to command classes' do
66
+ expect(described_class::COMMAND_MAP['list']).to eq(CovLoupe::Commands::ListCommand)
67
+ expect(described_class::COMMAND_MAP['version']).to eq(CovLoupe::Commands::VersionCommand)
68
+ expect(described_class::COMMAND_MAP['summary']).to eq(CovLoupe::Commands::SummaryCommand)
69
+ expect(described_class::COMMAND_MAP['raw']).to eq(CovLoupe::Commands::RawCommand)
70
+ expect(described_class::COMMAND_MAP['uncovered']).to eq(CovLoupe::Commands::UncoveredCommand)
71
+ expect(described_class::COMMAND_MAP['detailed']).to eq(CovLoupe::Commands::DetailedCommand)
72
+ expect(described_class::COMMAND_MAP['totals']).to eq(CovLoupe::Commands::TotalsCommand)
73
+ expect(described_class::COMMAND_MAP['total']).to eq(CovLoupe::Commands::TotalsCommand)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/formatted_command_examples'
5
+
6
+ RSpec.describe CovLoupe::Commands::DetailedCommand do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+ let(:cli_context) { CovLoupe::CoverageCLI.new }
9
+ let(:command) { described_class.new(cli_context) }
10
+
11
+ before do
12
+ cli_context.config.root = root
13
+ cli_context.config.resultset = 'coverage'
14
+ cli_context.config.format = :table
15
+ cli_context.config.source_mode = nil
16
+ end
17
+
18
+ describe '#execute' do
19
+ context 'with table format' do
20
+ it 'prints the detailed coverage table' do
21
+ output = capture_command_output(command, ['lib/foo.rb'])
22
+
23
+ expect(output).to include('File: lib/foo.rb', 'Line', 'Covered')
24
+ end
25
+ end
26
+
27
+ context 'with stale data' do
28
+ before { stub_staleness_check('L') }
29
+
30
+ it_behaves_like 'a command with formatted output', ['lib/foo.rb'],
31
+ { 'file' => 'lib/foo.rb', 'lines' => nil, 'summary' => nil, 'stale' => 'L' }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/formatted_command_examples'
5
+
6
+ RSpec.describe CovLoupe::Commands::ListCommand do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+ let(:cli_context) { CovLoupe::CoverageCLI.new }
9
+ let(:command) { described_class.new(cli_context) }
10
+
11
+ before do
12
+ cli_context.config.root = root
13
+ cli_context.config.resultset = 'coverage'
14
+ cli_context.config.format = :table
15
+ end
16
+
17
+ describe '#execute' do
18
+ context 'with table format' do
19
+ it 'outputs a formatted table' do
20
+ output = capture_command_output(command, [])
21
+
22
+ expect(output).to include('│', 'lib/foo.rb', 'lib/bar.rb')
23
+ end
24
+ end
25
+
26
+ it_behaves_like 'a command with formatted output', [], ['files', 'counts']
27
+ end
28
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/formatted_command_examples'
5
+
6
+ RSpec.describe CovLoupe::Commands::RawCommand do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+ let(:cli_context) { CovLoupe::CoverageCLI.new }
9
+ let(:command) { described_class.new(cli_context) }
10
+
11
+ before do
12
+ cli_context.config.root = root
13
+ cli_context.config.resultset = 'coverage'
14
+ cli_context.config.format = :table
15
+ end
16
+
17
+ describe '#execute' do
18
+ context 'with table format' do
19
+ it 'prints the raw coverage lines for the requested file' do
20
+ output = capture_command_output(command, ['lib/foo.rb'])
21
+
22
+ expect(output).to include('│', 'lib/foo.rb', 'Line', 'Coverage')
23
+ end
24
+ end
25
+
26
+ context 'when the file is fully covered' do
27
+ it 'still prints the raw table' do
28
+ mock_presenter(
29
+ CovLoupe::Presenters::CoverageRawPresenter,
30
+ absolute_payload: {
31
+ 'file' => 'lib/perfect.rb',
32
+ 'lines' => [1, 1, 1],
33
+ 'stale' => false
34
+ },
35
+ relative_path: 'lib/perfect.rb'
36
+ )
37
+
38
+ output = capture_command_output(command, ['lib/perfect.rb'])
39
+
40
+ expect(output).to include('│', '│ 1 │ 1 │')
41
+ expect(output).not_to include('All lines covered!')
42
+ end
43
+ end
44
+
45
+ context 'with JSON output' do
46
+ before { cli_context.config.format = :json }
47
+
48
+ it 'emits JSON with specific line data' do
49
+ stub_staleness_check('L') # Needed for stale data
50
+
51
+ output = capture_command_output(command, ['lib/foo.rb'])
52
+
53
+ payload = JSON.parse(output)
54
+ expect(payload['file']).to eq('lib/foo.rb')
55
+ expect(payload['lines']).to be_an(Array)
56
+ expect(payload['lines'][0]).to eq(1) # specific value
57
+ expect(payload['lines'][1]).to eq(0) # specific value
58
+ expect(payload['stale']).to eq('L')
59
+ end
60
+ end
61
+
62
+ context 'with stale data (other formats)' do
63
+ before { stub_staleness_check('L') }
64
+
65
+ # Use an array for expected_json_keys as we don't need exact value matching for these generic format tests
66
+ it_behaves_like 'a command with formatted output', ['lib/foo.rb'], ['file', 'lines', 'stale']
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/formatted_command_examples'
5
+
6
+ RSpec.describe CovLoupe::Commands::SummaryCommand do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+ let(:cli_context) { CovLoupe::CoverageCLI.new }
9
+ let(:command) { described_class.new(cli_context) }
10
+
11
+ before do
12
+ cli_context.config.root = root
13
+ cli_context.config.resultset = 'coverage'
14
+ cli_context.config.format = :table
15
+ cli_context.config.source_mode = nil
16
+ end
17
+
18
+ describe '#execute' do
19
+ context 'with table format' do
20
+ it 'prints a coverage summary line with a relative path' do
21
+ output = capture_command_output(command, ['lib/foo.rb'])
22
+
23
+ expect(output).to include('│', '66.67%', 'lib/foo.rb')
24
+ end
25
+ end
26
+
27
+ context 'with stale data' do
28
+ before { stub_staleness_check('L') }
29
+
30
+ it_behaves_like 'a command with formatted output', ['lib/foo.rb'],
31
+ { 'file' => 'lib/foo.rb', 'summary' => nil, 'stale' => 'L' }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/formatted_command_examples'
5
+
6
+ RSpec.describe CovLoupe::Commands::TotalsCommand do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+ let(:cli_context) { CovLoupe::CoverageCLI.new }
9
+ let(:command) { described_class.new(cli_context) }
10
+
11
+ before do
12
+ cli_context.config.root = root
13
+ cli_context.config.resultset = 'coverage'
14
+ cli_context.config.format = :table
15
+ end
16
+
17
+ describe '#execute' do
18
+ context 'with table format' do
19
+ it 'prints aggregated totals for the project' do
20
+ output = capture_command_output(command, [])
21
+
22
+ expect(output).to include('│', 'Lines', '50.00%')
23
+ end
24
+ end
25
+
26
+ it_behaves_like 'a command with formatted output', [], ['lines', 'files', 'percentage']
27
+
28
+ it 'raises when unexpected arguments are provided' do
29
+ expect do
30
+ command.execute(['extra'])
31
+ end.to raise_error(CovLoupe::UsageError, include('totals'))
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/formatted_command_examples'
5
+
6
+ RSpec.describe CovLoupe::Commands::UncoveredCommand do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+ let(:cli_context) { CovLoupe::CoverageCLI.new }
9
+ let(:command) { described_class.new(cli_context) }
10
+
11
+ before do
12
+ cli_context.config.root = root
13
+ cli_context.config.resultset = 'coverage'
14
+ cli_context.config.format = :table
15
+ cli_context.config.source_mode = nil
16
+ end
17
+
18
+ describe '#execute' do
19
+ context 'with table format' do
20
+ it 'prints uncovered line numbers with the summary' do
21
+ output = capture_command_output(command, ['lib/bar.rb'])
22
+
23
+ expect(output).to include('│', 'lib/bar.rb', '33.33%')
24
+ end
25
+ end
26
+
27
+ context 'when the file is fully covered' do
28
+ before do
29
+ mock_presenter(
30
+ CovLoupe::Presenters::CoverageUncoveredPresenter,
31
+ absolute_payload: {
32
+ 'file' => 'lib/perfect.rb',
33
+ 'uncovered' => [],
34
+ 'summary' => { 'covered' => 10, 'total' => 10, 'percentage' => 100.0 }
35
+ },
36
+ relative_path: 'lib/perfect.rb'
37
+ )
38
+ end
39
+
40
+ it 'prints a success message instead of a table' do
41
+ output = capture_command_output(command, ['lib/perfect.rb'])
42
+
43
+ expect(output).to include('All lines covered!', '100.00%')
44
+ expect(output).not_to include('│')
45
+ end
46
+ end
47
+
48
+ context 'with stale data' do
49
+ before { stub_staleness_check('L') }
50
+
51
+ it_behaves_like 'a command with formatted output', ['lib/foo.rb'],
52
+ { 'file' => 'lib/foo.rb', 'uncovered' => [2], 'summary' => nil, 'stale' => 'L' }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tempfile'
5
+
6
+ RSpec.describe CovLoupe::Commands::ValidateCommand do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+
9
+ def with_temp_predicate(content)
10
+ Tempfile.create(['predicate', '.rb']) do |file|
11
+ file.write(content)
12
+ file.flush
13
+ yield file.path
14
+ end
15
+ end
16
+
17
+ describe 'validate subcommand with file' do
18
+ it 'exits 0 when predicate returns truthy value' do
19
+ with_temp_predicate("->(model) { true }\n") do |path|
20
+ _out, _err, status = run_cli_with_status(
21
+ '--root', root,
22
+ '--resultset', 'coverage',
23
+ 'validate', path
24
+ )
25
+ expect(status).to eq(0)
26
+ end
27
+ end
28
+
29
+ it 'exits 1 when predicate returns falsy value' do
30
+ with_temp_predicate("->(model) { false }\n") do |path|
31
+ _out, _err, status = run_cli_with_status(
32
+ '--root', root,
33
+ '--resultset', 'coverage',
34
+ 'validate', path
35
+ )
36
+ expect(status).to eq(1)
37
+ end
38
+ end
39
+
40
+ it 'exits 2 when predicate raises an error' do
41
+ with_temp_predicate("->(model) { raise 'Boom!' }\n") do |path|
42
+ _out, err, status = run_cli_with_status(
43
+ '--root', root,
44
+ '--resultset', 'coverage',
45
+ 'validate', path
46
+ )
47
+ expect(status).to eq(2)
48
+ expect(err).to include('Predicate error: Boom!')
49
+ end
50
+ end
51
+
52
+ it 'shows backtrace when predicate errors with --error-mode debug' do
53
+ with_temp_predicate("->(model) { raise 'Boom!' }\n") do |path|
54
+ _out, err, status = run_cli_with_status(
55
+ '--error-mode', 'debug',
56
+ '--root', root,
57
+ '--resultset', 'coverage',
58
+ 'validate', path
59
+ )
60
+ expect(status).to eq(2)
61
+ expect(err).to include('Predicate error: Boom!')
62
+ # With trace mode, should show backtrace
63
+ expect(err).to match(/predicate.*\.rb:\d+/)
64
+ end
65
+ end
66
+
67
+ it 'exits 2 when predicate file is not found' do
68
+ _out, err, status = run_cli_with_status(
69
+ '--root', root,
70
+ '--resultset', 'coverage',
71
+ 'validate', '/nonexistent/predicate.rb'
72
+ )
73
+ expect(status).to eq(2)
74
+ expect(err).to include('Predicate file not found')
75
+ end
76
+
77
+ it 'exits 2 when predicate has syntax error' do
78
+ with_temp_predicate("-> { this is invalid syntax\n") do |path|
79
+ _out, err, status = run_cli_with_status(
80
+ '--root', root,
81
+ '--resultset', 'coverage',
82
+ 'validate', path
83
+ )
84
+ expect(status).to eq(2)
85
+ expect(err).to include('Syntax error in predicate file')
86
+ end
87
+ end
88
+
89
+ it 'exits 2 when predicate is not callable' do
90
+ with_temp_predicate("42\n") do |path|
91
+ _out, err, status = run_cli_with_status(
92
+ '--root', root,
93
+ '--resultset', 'coverage',
94
+ 'validate', path
95
+ )
96
+ expect(status).to eq(2)
97
+ expect(err).to include('Predicate must be callable')
98
+ end
99
+ end
100
+
101
+ it 'provides model to predicate that can query coverage' do
102
+ # Test that the predicate receives a working CoverageModel
103
+ with_temp_predicate(<<~RUBY) do |path|
104
+ ->(model) do
105
+ # Access coverage data via the model
106
+ summary = model.summary_for('lib/foo.rb')
107
+ summary['summary']['percentage'] > 50 # Should be true for foo.rb
108
+ end
109
+ RUBY
110
+ _out, _err, status = run_cli_with_status(
111
+ '--root', root,
112
+ '--resultset', 'coverage',
113
+ 'validate', path
114
+ )
115
+ expect(status).to eq(0)
116
+ end
117
+ end
118
+ end
119
+
120
+ describe 'validate subcommand with -i/--inline flag' do
121
+ it 'exits 0 when predicate code returns truthy value' do
122
+ _out, _err, status = run_cli_with_status(
123
+ '--root', root,
124
+ '--resultset', 'coverage',
125
+ 'validate', '-i', '->(model) { true }'
126
+ )
127
+ expect(status).to eq(0)
128
+ end
129
+
130
+ it 'exits 1 when predicate code returns falsy value' do
131
+ _out, _err, status = run_cli_with_status(
132
+ '--root', root,
133
+ '--resultset', 'coverage',
134
+ 'validate', '-i', '->(model) { false }'
135
+ )
136
+ expect(status).to eq(1)
137
+ end
138
+
139
+ it 'exits 2 when predicate code raises an error' do
140
+ _out, err, status = run_cli_with_status(
141
+ '--root', root,
142
+ '--resultset', 'coverage',
143
+ 'validate', '-i', "->(model) { raise 'Boom!' }"
144
+ )
145
+ expect(status).to eq(2)
146
+ expect(err).to include('Predicate error: Boom!')
147
+ end
148
+
149
+ it 'exits 2 when predicate code has syntax error' do
150
+ _out, err, status = run_cli_with_status(
151
+ '--root', root,
152
+ '--resultset', 'coverage',
153
+ 'validate', '-i', '-> { invalid syntax'
154
+ )
155
+ expect(status).to eq(2)
156
+ expect(err).to include('Syntax error in predicate code')
157
+ end
158
+
159
+ it 'exits 2 when predicate code is not callable' do
160
+ _out, err, status = run_cli_with_status(
161
+ '--root', root,
162
+ '--resultset', 'coverage',
163
+ 'validate', '-i', '42'
164
+ )
165
+ expect(status).to eq(2)
166
+ expect(err).to include('Predicate must be callable')
167
+ end
168
+
169
+ it 'provides model to predicate that can query coverage' do
170
+ code = <<~RUBY.strip
171
+ ->(model) { model.summary_for('lib/foo.rb')['summary']['percentage'] > 50 }
172
+ RUBY
173
+ _out, _err, status = run_cli_with_status(
174
+ '--root', root,
175
+ '--resultset', 'coverage',
176
+ 'validate', '-i', code
177
+ )
178
+ expect(status).to eq(0)
179
+ end
180
+ end
181
+
182
+ describe 'error handling' do
183
+ it 'raises error when no file or -i flag provided' do
184
+ _out, err, status = run_cli_with_status(
185
+ '--root', root,
186
+ '--resultset', 'coverage',
187
+ 'validate'
188
+ )
189
+ expect(status).to eq(1)
190
+ expect(err).to include('validate <file> | -i <code>')
191
+ end
192
+
193
+ it 'raises error when -i flag provided without code' do
194
+ _out, err, status = run_cli_with_status(
195
+ '--root', root,
196
+ '--resultset', 'coverage',
197
+ 'validate', '-i'
198
+ )
199
+ expect(status).to eq(1)
200
+ expect(err).to include('validate -i <code>')
201
+ end
202
+
203
+ it 'raises error when unknown option is provided' do
204
+ _out, err, status = run_cli_with_status(
205
+ '--root', root,
206
+ '--resultset', 'coverage',
207
+ 'validate', '--unknown-option'
208
+ )
209
+ expect(status).to eq(1)
210
+ expect(err).to include('Unknown option for validate: --unknown-option')
211
+ end
212
+ end
213
+ end