simplecov-mcp 0.3.0 → 1.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +173 -356
  4. data/docs/ADVANCED_USAGE.md +967 -0
  5. data/docs/ARCHITECTURE.md +79 -0
  6. data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
  7. data/docs/CLI_USAGE.md +637 -0
  8. data/docs/DEVELOPMENT.md +82 -0
  9. data/docs/ERROR_HANDLING.md +93 -0
  10. data/docs/EXAMPLES.md +430 -0
  11. data/docs/INSTALLATION.md +352 -0
  12. data/docs/LIBRARY_API.md +635 -0
  13. data/docs/MCP_INTEGRATION.md +488 -0
  14. data/docs/TROUBLESHOOTING.md +276 -0
  15. data/docs/arch-decisions/001-x-arch-decision.md +93 -0
  16. data/docs/arch-decisions/002-x-arch-decision.md +157 -0
  17. data/docs/arch-decisions/003-x-arch-decision.md +163 -0
  18. data/docs/arch-decisions/004-x-arch-decision.md +199 -0
  19. data/docs/arch-decisions/005-x-arch-decision.md +187 -0
  20. data/docs/arch-decisions/README.md +60 -0
  21. data/docs/presentations/simplecov-mcp-presentation.md +249 -0
  22. data/exe/simplecov-mcp +4 -4
  23. data/lib/simplecov_mcp/app_context.rb +26 -0
  24. data/lib/simplecov_mcp/base_tool.rb +74 -0
  25. data/lib/simplecov_mcp/cli.rb +234 -0
  26. data/lib/simplecov_mcp/cli_config.rb +56 -0
  27. data/lib/simplecov_mcp/commands/base_command.rb +78 -0
  28. data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
  29. data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
  30. data/lib/simplecov_mcp/commands/list_command.rb +13 -0
  31. data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
  32. data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
  33. data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
  34. data/lib/simplecov_mcp/commands/version_command.rb +18 -0
  35. data/lib/simplecov_mcp/constants.rb +22 -0
  36. data/lib/simplecov_mcp/error_handler.rb +124 -0
  37. data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
  38. data/lib/simplecov_mcp/errors.rb +179 -0
  39. data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
  40. data/lib/simplecov_mcp/mcp_server.rb +40 -0
  41. data/lib/simplecov_mcp/mode_detector.rb +55 -0
  42. data/lib/simplecov_mcp/model.rb +300 -0
  43. data/lib/simplecov_mcp/option_normalizers.rb +92 -0
  44. data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
  45. data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
  46. data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
  47. data/lib/simplecov_mcp/path_relativizer.rb +61 -0
  48. data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
  49. data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
  50. data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
  51. data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
  52. data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
  53. data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
  54. data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
  55. data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
  56. data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
  57. data/lib/simplecov_mcp/resultset_loader.rb +136 -0
  58. data/lib/simplecov_mcp/staleness_checker.rb +243 -0
  59. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
  60. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
  61. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
  62. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
  63. data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
  64. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
  65. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
  66. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
  67. data/lib/simplecov_mcp/util.rb +82 -0
  68. data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
  69. data/lib/simplecov_mcp.rb +144 -2
  70. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  71. data/spec/TIMESTAMPS.md +48 -0
  72. data/spec/all_files_coverage_tool_spec.rb +29 -25
  73. data/spec/base_tool_spec.rb +11 -10
  74. data/spec/cli/show_default_report_spec.rb +33 -0
  75. data/spec/cli_config_spec.rb +137 -0
  76. data/spec/cli_enumerated_options_spec.rb +68 -0
  77. data/spec/cli_error_spec.rb +105 -47
  78. data/spec/cli_source_spec.rb +82 -23
  79. data/spec/cli_spec.rb +140 -5
  80. data/spec/cli_success_predicate_spec.rb +141 -0
  81. data/spec/cli_table_spec.rb +1 -1
  82. data/spec/cli_usage_spec.rb +10 -26
  83. data/spec/commands/base_command_spec.rb +187 -0
  84. data/spec/commands/command_factory_spec.rb +72 -0
  85. data/spec/commands/detailed_command_spec.rb +48 -0
  86. data/spec/commands/raw_command_spec.rb +46 -0
  87. data/spec/commands/summary_command_spec.rb +47 -0
  88. data/spec/commands/uncovered_command_spec.rb +49 -0
  89. data/spec/constants_spec.rb +61 -0
  90. data/spec/coverage_table_tool_spec.rb +17 -33
  91. data/spec/error_handler_spec.rb +22 -13
  92. data/spec/error_mode_spec.rb +143 -0
  93. data/spec/errors_edge_cases_spec.rb +239 -0
  94. data/spec/errors_stale_spec.rb +2 -2
  95. data/spec/file_based_mcp_tools_spec.rb +99 -0
  96. data/spec/fixtures/project1/lib/bar.rb +0 -1
  97. data/spec/fixtures/project1/lib/foo.rb +0 -1
  98. data/spec/help_tool_spec.rb +11 -17
  99. data/spec/integration_spec.rb +845 -0
  100. data/spec/logging_fallback_spec.rb +128 -0
  101. data/spec/mcp_logging_spec.rb +44 -0
  102. data/spec/mcp_server_integration_spec.rb +23 -0
  103. data/spec/mcp_server_spec.rb +15 -4
  104. data/spec/mode_detector_spec.rb +148 -0
  105. data/spec/model_error_handling_spec.rb +210 -0
  106. data/spec/model_staleness_spec.rb +40 -10
  107. data/spec/option_normalizers_spec.rb +204 -0
  108. data/spec/option_parsers/env_options_parser_spec.rb +233 -0
  109. data/spec/option_parsers/error_helper_spec.rb +222 -0
  110. data/spec/path_relativizer_spec.rb +83 -0
  111. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  112. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  113. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  114. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  115. data/spec/presenters/project_coverage_presenter_spec.rb +86 -0
  116. data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
  117. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  118. data/spec/resolvers/resultset_path_resolver_spec.rb +55 -0
  119. data/spec/resultset_loader_spec.rb +167 -0
  120. data/spec/shared_examples/README.md +115 -0
  121. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  122. data/spec/shared_examples/file_based_mcp_tools.rb +174 -0
  123. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  124. data/spec/simple_cov_mcp_module_spec.rb +16 -0
  125. data/spec/simplecov_mcp_model_spec.rb +340 -9
  126. data/spec/simplecov_mcp_opts_spec.rb +182 -0
  127. data/spec/spec_helper.rb +147 -4
  128. data/spec/staleness_checker_spec.rb +373 -0
  129. data/spec/staleness_more_spec.rb +16 -13
  130. data/spec/support/mcp_runner.rb +64 -0
  131. data/spec/tools_error_handling_spec.rb +144 -0
  132. data/spec/util_spec.rb +109 -34
  133. data/spec/version_spec.rb +117 -9
  134. data/spec/version_tool_spec.rb +131 -10
  135. metadata +120 -63
  136. data/lib/simple_cov/mcp.rb +0 -9
  137. data/lib/simple_cov_mcp/base_tool.rb +0 -70
  138. data/lib/simple_cov_mcp/cli.rb +0 -390
  139. data/lib/simple_cov_mcp/error_handler.rb +0 -131
  140. data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
  141. data/lib/simple_cov_mcp/errors.rb +0 -176
  142. data/lib/simple_cov_mcp/mcp_server.rb +0 -30
  143. data/lib/simple_cov_mcp/model.rb +0 -104
  144. data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
  145. data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
  146. data/lib/simple_cov_mcp/util.rb +0 -122
  147. data/lib/simple_cov_mcp.rb +0 -102
  148. data/spec/coverage_detailed_tool_spec.rb +0 -36
  149. data/spec/coverage_raw_tool_spec.rb +0 -32
  150. data/spec/coverage_summary_tool_spec.rb +0 -39
  151. data/spec/legacy_shim_spec.rb +0 -13
  152. data/spec/uncovered_lines_tool_spec.rb +0 -33
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'CLI enumerated option parsing' do
6
+ def parse!(argv)
7
+ cli = SimpleCovMcp::CoverageCLI.new
8
+ cli.send(:parse_options!, argv.dup)
9
+ cli
10
+ end
11
+
12
+ describe 'accepts short and long forms' do
13
+ cases = [
14
+ { argv: ['--sort-order', 'a', 'list'], accessor: :sort_order, expected: :ascending },
15
+ { argv: ['--sort-order', 'd', 'list'], accessor: :sort_order, expected: :descending },
16
+ { argv: ['--sort-order', 'ascending', 'list'], accessor: :sort_order, expected: :ascending },
17
+ { argv: ['--sort-order', 'descending', 'list'], accessor: :sort_order,
18
+ expected: :descending },
19
+
20
+ { argv: ['--source', 'f', 'summary', 'lib/foo.rb'], accessor: :source_mode, expected: :full },
21
+ { argv: ['--source=u', 'summary', 'lib/foo.rb'], accessor: :source_mode,
22
+ expected: :uncovered },
23
+ { argv: ['--source=full', 'summary', 'lib/foo.rb'], accessor: :source_mode, expected: :full },
24
+ { argv: ['--source=uncovered', 'summary', 'lib/foo.rb'], accessor: :source_mode,
25
+ expected: :uncovered },
26
+
27
+ { argv: ['-S', 'e', 'list'], accessor: :stale_mode, expected: :error },
28
+ { argv: ['-S', 'o', 'list'], accessor: :stale_mode, expected: :off },
29
+
30
+ { argv: ['--error-mode', 'off', 'list'], accessor: :error_mode, expected: :off },
31
+ { argv: ['--error-mode', 'on', 'list'], accessor: :error_mode, expected: :on },
32
+ { argv: ['--error-mode', 't', 'list'], accessor: :error_mode, expected: :trace }
33
+ ]
34
+
35
+ cases.each do |c|
36
+ it "parses #{c[:argv].join(' ')}" do
37
+ cli = parse!(c[:argv])
38
+ expect(cli.config.public_send(c[:accessor])).to eq(c[:expected])
39
+ end
40
+ end
41
+ end
42
+
43
+ describe 'rejects invalid values' do
44
+ invalid_cases = [
45
+ { argv: ['--sort-order', 'asc', 'list'] },
46
+ { argv: ['--source=x', 'summary', 'lib/foo.rb'] },
47
+ { argv: ['-S', 'x', 'list'] },
48
+ { argv: ['--error-mode', 'bad', 'list'] }
49
+ ]
50
+
51
+ invalid_cases.each do |c|
52
+ it "exits 1 for #{c[:argv].join(' ')}" do
53
+ _out, err, status = run_cli_with_status(*c[:argv])
54
+ expect(status).to eq(1)
55
+ expect(err).to include('Error:')
56
+ expect(err).to include('invalid argument')
57
+ end
58
+ end
59
+ end
60
+
61
+ describe 'missing value hints' do
62
+ it 'exits 1 when -S is provided without a value' do
63
+ _out, err, status = run_cli_with_status('-S', 'list')
64
+ expect(status).to eq(1)
65
+ expect(err).to include('invalid argument')
66
+ end
67
+ end
68
+ end
@@ -3,30 +3,16 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe SimpleCovMcp::CoverageCLI do
6
- let(:root) { (FIXTURES / 'project1').to_s }
7
-
8
- def run_cli_with_status(*argv)
9
- cli = described_class.new
10
- status = nil
11
- out_str = err_str = nil
12
- silence_output do |out, err|
13
- begin
14
- cli.run(argv.flatten)
15
- status = 0
16
- rescue SystemExit => e
17
- status = e.status
18
- end
19
- out_str = out.string
20
- err_str = err.string
21
- end
22
- [out_str, err_str, status]
23
- end
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
24
7
 
25
8
  it 'shows help and exits 0' do
26
9
  out, err, status = run_cli_with_status('--help')
27
10
  expect(status).to eq(0)
28
- expect(out).to include('Usage: simplecov-mcp')
29
- expect(err).to eq("")
11
+ expect(out).to match(/Usage:.*simplecov-mcp/)
12
+ expect(out).to include('Repository: https://github.com/keithrbennett/simplecov-mcp')
13
+ expect(out).to match(/Version:.*#{SimpleCovMcp::VERSION}/)
14
+ expect(out).to include('Subcommands:')
15
+ expect(err).to eq('')
30
16
  end
31
17
 
32
18
  shared_examples 'maps error to exit 1 with message' do
@@ -64,40 +50,112 @@ RSpec.describe SimpleCovMcp::CoverageCLI do
64
50
  end
65
51
 
66
52
  it 'emits detailed stale coverage info and exits 1' do
67
- begin
68
- allow(SimpleCovMcp::CovUtil).to receive(:latest_timestamp).and_return(0)
69
- _out, err, status = run_cli_with_status('summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--stale', 'error')
70
- expect(status).to eq(1)
71
- expect(err).to include('Coverage data stale:')
72
- expect(err).to match(/File\s+- time:/)
73
- expect(err).to match('Coverage\s+- time:')
74
- expect(err).to match(/Delta\s+- file is [+-]?\d+s newer than coverage/)
75
- expect(err).to match('Resultset\s+-')
76
- ensure
77
- end
53
+ mock_resultset_with_timestamp(root, VERY_OLD_TIMESTAMP, coverage: {
54
+ File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, 1] }
55
+ })
56
+
57
+ _out, err, status = run_cli_with_status('summary', 'lib/foo.rb', '--root', root, '--resultset',
58
+ 'coverage', '--stale', 'error')
59
+ expect(status).to eq(1)
60
+ expect(err).to include('Coverage data stale:')
61
+ expect(err).to match(/File\s+- time:/)
62
+ expect(err).to match('Coverage\s+- time:')
63
+ expect(err).to match(/Delta\s+- file is [+-]?\d+s newer than coverage/)
64
+ expect(err).to match('Resultset\s+-')
78
65
  end
79
66
 
80
67
  it 'honors --no-strict-staleness to disable checks' do
68
+ mock_resultset_with_timestamp(root, VERY_OLD_TIMESTAMP, coverage: {
69
+ File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, 1] }
70
+ })
71
+
72
+ _out, err, status = run_cli_with_status('summary', 'lib/foo.rb', '--root', root, '--resultset',
73
+ 'coverage', '--stale', 'off')
74
+ expect(status).to eq(0)
75
+ expect(err).to eq('')
76
+ end
77
+
78
+ it 'handles source rendering errors gracefully with fallback message' do
79
+ # Test that source rendering with problematic coverage data doesn't crash
80
+ # This is a regression test for the "can't convert nil into Integer" crash
81
+ # that was previously mentioned in comments
82
+ out, err, status = run_cli_with_status(
83
+ 'uncovered', 'lib/foo.rb', '--root', root, '--resultset', 'coverage',
84
+ '--source=uncovered', '--source-context', '2', '--no-color'
85
+ )
86
+
87
+ expect(status).to eq(0)
88
+ expect(err).to eq('')
89
+ expect(out).to match(/File:\s+lib\/foo\.rb/)
90
+ expect(out).to include('Uncovered lines: 2')
91
+ expect(out).to show_source_table_or_fallback
92
+ end
93
+
94
+ it 'renders source with full mode without crashing' do
95
+ # Additional regression test for source rendering with full mode
96
+ out, err, status = run_cli_with_status(
97
+ 'summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage',
98
+ '--source=full', '--no-color'
99
+ )
100
+
101
+ expect(status).to eq(0)
102
+ expect(err).to eq('')
103
+ expect(out).to include('lib/foo.rb')
104
+ expect(out).to include('66.67%')
105
+ expect(out).to show_source_table_or_fallback
106
+ end
107
+
108
+ it 'shows fallback message when source file is unreadable' do
109
+ # Test the fallback path when source files can't be read
110
+ # Temporarily rename the source file to make it unreadable
111
+ foo_path = File.join(root, 'lib', 'foo.rb')
112
+ temp_path = "#{foo_path}.hidden"
113
+
81
114
  begin
82
- allow(SimpleCovMcp::CovUtil).to receive(:latest_timestamp).and_return(0)
83
- _out, err, status = run_cli_with_status('summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--stale', 'off')
115
+ File.rename(foo_path, temp_path) if File.exist?(foo_path)
116
+
117
+ out, err, status = run_cli_with_status(
118
+ 'summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage',
119
+ '--source=full', '--no-color'
120
+ )
121
+
84
122
  expect(status).to eq(0)
85
- expect(err).to eq("")
123
+ expect(err).to eq('')
124
+ expect(out).to include('lib/foo.rb')
125
+ expect(out).to include('66.67%')
126
+ expect(out).to include('[source not available]')
86
127
  ensure
128
+ # Restore the file
129
+ File.rename(temp_path, foo_path) if File.exist?(temp_path)
87
130
  end
88
131
  end
89
132
 
90
- # Note on text-mode source rendering tests:
91
- # - "Text-mode source" refers to the plain-text source view (no ANSI colors)
92
- # when passing --source or --source=uncovered (checkmarks/dots, line nums).
93
- # - Direct tests are omitted here because behavior depends on how paths are
94
- # resolved (relative vs absolute) in combination with --root/--resultset
95
- # and whether the source file is readable. In uncovered mode, we observed
96
- # a crash ("can't convert nil into Integer") when coverage arrays include
97
- # nils or don’t line up with file lines. JSON paths avoid this formatting
98
- # nuance and are already covered elsewhere.
99
- # - Once the uncovered+source crash is guarded (treat out-of-range/nil hits
100
- # defensively and only format integers where expected), we can add a
101
- # regression: run `uncovered` with --source=uncovered against the fixtures
102
- # and assert exit status 0 and rendered source.
133
+ describe 'invalid option handling' do
134
+ it 'suggests subcommand for --subcommand-like option' do
135
+ _out, err, status = run_cli_with_status('--summary')
136
+ expect(status).to eq(1)
137
+ expect(err).to include(
138
+ "Error: '--summary' is not a valid option. Did you mean the 'summary' subcommand?"
139
+ )
140
+ expect(err).to include('Try: simplecov-mcp summary [args]')
141
+ end
142
+
143
+ it 'reports invalid enum value for --opt=value' do
144
+ _out, err, status = run_cli_with_status('list', '--stale=bogus')
145
+ expect(status).to eq(1)
146
+ expect(err).to include('invalid argument: --stale=bogus')
147
+ end
148
+
149
+ it 'reports invalid enum value for --opt value' do
150
+ _out, err, status = run_cli_with_status('list', '--stale', 'bogus')
151
+ expect(status).to eq(1)
152
+ expect(err).to include('invalid argument: bogus')
153
+ end
154
+
155
+ it 'handles generic invalid options' do
156
+ _out, err, status = run_cli_with_status('--no-such-option')
157
+ expect(status).to eq(1)
158
+ expect(err).to include('Error: invalid option: --no-such-option')
159
+ end
160
+ end
103
161
  end
@@ -3,24 +3,7 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe SimpleCovMcp::CoverageCLI do
6
- let(:root) { (FIXTURES / 'project1').to_s }
7
-
8
- def run_cli_with_status(*argv)
9
- cli = described_class.new
10
- status = nil
11
- out_str = err_str = nil
12
- silence_output do |out, err|
13
- begin
14
- cli.run(argv.flatten)
15
- status = 0
16
- rescue SystemExit => e
17
- status = e.status
18
- end
19
- out_str = out.string
20
- err_str = err.string
21
- end
22
- [out_str, err_str, status]
23
- end
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
24
7
 
25
8
  it 'renders uncovered source without error for fixture file' do
26
9
  out, err, status = run_cli_with_status(
@@ -28,10 +11,86 @@ RSpec.describe SimpleCovMcp::CoverageCLI do
28
11
  '--source=uncovered', '--source-context', '1', '--no-color'
29
12
  )
30
13
  expect(status).to eq(0)
31
- expect(err).to eq("")
32
- expect(out).to include('File: lib/foo.rb')
33
- expect(out).to include('Uncovered lines: 2')
34
- # Accept either rendered source table or fallback message
35
- expect(out).to satisfy { |s| s.include?('Line') || s.include?('[source not available]') }
14
+ expect(err).to eq('')
15
+ expect(out).to match(/File:\s+lib\/foo\.rb/)
16
+ expect(out).to match(/Uncovered lines:\s*2\b/)
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
+ 'uncovered', 'lib/foo.rb', '--root', root, '--resultset', 'coverage',
23
+ '--source=full', '--no-color'
24
+ )
25
+ expect(status).to eq(0)
26
+ expect(err).to eq('')
27
+ # Summary line with flexible spacing
28
+ expect(out).to match(/Summary:\s*\d+\.\d{2}%\s*\d+\/\d+/)
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
+ 'summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage',
35
+ '--source=uncovered', '--source-context', '1', '--no-color'
36
+ )
37
+ expect(status).to eq(0)
38
+ expect(err).to eq('')
39
+ expect(out).to include('lib/foo.rb')
40
+ # Presence of percentage and counts, spacing-agnostic
41
+ expect(out).to match(/66\.67%/)
42
+ expect(out).to match(/\b2\/3\b/)
43
+ expect(out).to show_source_table_or_fallback
44
+ end
45
+
46
+ context 'source option without equals sign' do
47
+ it 'parses --source uncovered correctly (space-separated argument)' do
48
+ out, err, status = run_cli_with_status(
49
+ 'summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage',
50
+ '--source', 'uncovered', '--source-context', '1', '--no-color'
51
+ )
52
+ expect(status).to eq(0)
53
+ expect(err).to eq('')
54
+ expect(out).to include('lib/foo.rb')
55
+ expect(out).to match(/66\.67%/)
56
+ expect(out).to match(/\b2\/3\b/)
57
+ expect(out).to show_source_table_or_fallback
58
+ end
59
+
60
+ it 'parses -s full correctly (short form with space-separated argument)' do
61
+ out, err, status = run_cli_with_status(
62
+ 'uncovered', 'lib/foo.rb', '--root', root, '--resultset', 'coverage',
63
+ '-s', 'full', '--no-color'
64
+ )
65
+ expect(status).to eq(0)
66
+ expect(err).to eq('')
67
+ expect(out).to match(/Summary:\s*\d+\.\d{2}%\s*\d+\/\d+/)
68
+ expect(out).to show_source_table_or_fallback
69
+ end
70
+
71
+ it 'handles --source uncovered in default report (no subcommand)' do
72
+ out, err, status = run_cli_with_status(
73
+ '--root', root, '--resultset', 'coverage',
74
+ '--source', 'uncovered', '--no-color'
75
+ )
76
+ expect(status).to eq(0)
77
+ expect(err).to eq('')
78
+ expect(out).to match(/66\.67%/)
79
+ # Default report doesn't show source tables, that's OK - just check it parses correctly
80
+ expect(out).not_to include('Unknown subcommand')
81
+ end
82
+
83
+ it 'does not misinterpret following token as subcommand when using --source' do
84
+ # This test specifically addresses the bug where --source uncovered
85
+ # was interpreting 'uncovered' as a subcommand
86
+ out, err, status = run_cli_with_status(
87
+ '--root', root, '--resultset', 'coverage',
88
+ '--source', 'uncovered'
89
+ )
90
+ expect(status).to eq(0)
91
+ expect(err).to eq('')
92
+ expect(out).not_to include('Unknown subcommand')
93
+ expect(out).to match(/66\.67%/)
94
+ end
36
95
  end
37
96
  end
data/spec/cli_spec.rb CHANGED
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'tempfile'
4
5
 
5
6
  RSpec.describe SimpleCovMcp::CoverageCLI do
6
- let(:root) { (FIXTURES / 'project1').to_s }
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
8
 
8
9
  def run_cli(*argv)
9
10
  cli = described_class.new
@@ -27,6 +28,12 @@ RSpec.describe SimpleCovMcp::CoverageCLI do
27
28
  expect(data['lines']).to eq([1, 0, nil, 2])
28
29
  end
29
30
 
31
+ it 'prints raw lines as text' do
32
+ output = run_cli('raw', 'lib/foo.rb', '--root', root, '--resultset', 'coverage')
33
+ expect(output).to include('File: lib/foo.rb')
34
+ expect(output).to include('[1, 0, nil, 2]')
35
+ end
36
+
30
37
  it 'prints uncovered lines as JSON' do
31
38
  output = run_cli('uncovered', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage')
32
39
  data = JSON.parse(output)
@@ -41,8 +48,9 @@ RSpec.describe SimpleCovMcp::CoverageCLI do
41
48
  expect(data['lines'].first).to include('line', 'hits', 'covered')
42
49
  end
43
50
 
44
- it 'lists all files as JSON with sort order' do
45
- output = run_cli('list', '--json', '--root', root, '--resultset', 'coverage', '--sort-order', 'ascending')
51
+ it 'list subcommand with --json outputs JSON with sort order' do
52
+ output = run_cli('list', '--json', '--root', root, '--resultset', 'coverage', '--sort-order',
53
+ 'a')
46
54
  asc = JSON.parse(output)
47
55
  expect(asc['files']).to be_an(Array)
48
56
  expect(asc['files'].first['file']).to end_with('lib/bar.rb')
@@ -55,18 +63,145 @@ RSpec.describe SimpleCovMcp::CoverageCLI do
55
63
  expect(total).to eq(asc['files'].length)
56
64
  expect(ok + stale).to eq(total)
57
65
 
58
- output = run_cli('list', '--json', '--root', root, '--resultset', 'coverage', '--sort-order', 'descending')
66
+ output = run_cli('list', '--json', '--root', root, '--resultset', 'coverage', '--sort-order',
67
+ 'd')
59
68
  desc = JSON.parse(output)
60
69
  expect(desc['files'].first['file']).to end_with('lib/foo.rb')
61
70
  end
62
71
 
72
+ it 'list subcommand outputs formatted table' do
73
+ output = run_cli('list', '--root', root, '--resultset', 'coverage')
74
+ expect(output).to include('File')
75
+ expect(output).to include('lib/foo.rb')
76
+ expect(output).to include('lib/bar.rb')
77
+ expect(output).to match(/Files: total \d+/)
78
+ end
79
+
80
+ it 'list subcommand retains rows when using an absolute tracked glob' do
81
+ absolute_glob = File.join(root, 'lib', '**', '*.rb')
82
+ output = run_cli('list', '--root', root, '--resultset', 'coverage', '--tracked-globs',
83
+ absolute_glob)
84
+ expect(output).not_to include('No coverage data found')
85
+ expect(output).to include('lib/foo.rb')
86
+ expect(output).to include('lib/bar.rb')
87
+ end
88
+
63
89
  it 'exposes expected subcommands via constant' do
64
90
  expect(described_class::SUBCOMMANDS).to eq(%w[list summary raw uncovered detailed version])
65
91
  end
66
92
 
67
93
  it 'can include source in JSON payload (nil if file missing)' do
68
- output = run_cli('summary', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage', '--source')
94
+ output = run_cli('summary', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage',
95
+ '--source')
69
96
  data = JSON.parse(output)
70
97
  expect(data).to have_key('source')
71
98
  end
99
+
100
+ describe 'log file configuration' do
101
+ it 'passes --log-file path into the CLI execution context' do
102
+ Dir.mktmpdir do |dir|
103
+ log_path = File.join(dir, 'custom.log')
104
+ expect(SimpleCovMcp).to receive(:create_context)
105
+ .and_wrap_original do |m, error_handler:, log_target:, mode:|
106
+ # Ensure CLI forwards the requested log path into the context without changing other fields.
107
+ expect(log_target).to eq(log_path)
108
+ m.call(error_handler: error_handler, log_target: log_target, mode: mode)
109
+ end
110
+ original_target = SimpleCovMcp.active_log_file
111
+ run_cli('summary', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage',
112
+ '--log-file', log_path)
113
+ expect(SimpleCovMcp.active_log_file).to eq(original_target)
114
+ end
115
+ end
116
+
117
+ it 'supports stdout logging within the CLI context' do
118
+ expect(SimpleCovMcp).to receive(:create_context)
119
+ .and_wrap_original do |m, error_handler:, log_target:, mode:|
120
+ # For stdout logging, verify the context is still constructed with the expected value.
121
+ expect(log_target).to eq('stdout')
122
+ m.call(error_handler: error_handler, log_target: log_target, mode: mode)
123
+ end
124
+ original_target = SimpleCovMcp.active_log_file
125
+ run_cli('summary', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage',
126
+ '--log-file', 'stdout')
127
+ expect(SimpleCovMcp.active_log_file).to eq(original_target)
128
+ end
129
+ end
130
+
131
+ describe '#load_success_predicate' do
132
+ let(:cli) { described_class.new }
133
+
134
+ def with_temp_predicate(content)
135
+ Tempfile.create(['predicate', '.rb']) do |file|
136
+ file.write(content)
137
+ file.flush
138
+ yield file.path
139
+ end
140
+ end
141
+
142
+ it 'loads a callable predicate from file' do
143
+ with_temp_predicate("->(model) { model }\n") do |path|
144
+ predicate = cli.send(:load_success_predicate, path)
145
+ expect(predicate).to respond_to(:call)
146
+ expect(predicate.call(:ok)).to eq(:ok)
147
+ end
148
+ end
149
+
150
+ it 'raises when file does not return callable' do
151
+ with_temp_predicate(":not_callable\n") do |path|
152
+ expect { cli.send(:load_success_predicate, path) }
153
+ .to raise_error(RuntimeError, include('Success predicate must be callable'))
154
+ end
155
+ end
156
+
157
+ it 'wraps syntax errors with friendly message' do
158
+ with_temp_predicate("->(model) {\n") do |path|
159
+ expect { cli.send(:load_success_predicate, path) }
160
+ .to raise_error(RuntimeError, include('Syntax error in success predicate file'))
161
+ end
162
+ end
163
+ end
164
+
165
+ describe '#extract_subcommand!' do
166
+ let(:cli) { described_class.new }
167
+
168
+ around do |example|
169
+ original = ENV['SIMPLECOV_MCP_OPTS']
170
+ example.run
171
+ ensure
172
+ ENV['SIMPLECOV_MCP_OPTS'] = original
173
+ end
174
+
175
+ it 'picks up subcommands that appear after env-provided options' do
176
+ ENV['SIMPLECOV_MCP_OPTS'] = '--resultset coverage'
177
+ argv = cli.send(:parse_env_opts) + ['summary', 'lib/foo.rb']
178
+
179
+ expect do
180
+ cli.send(:extract_subcommand!, argv)
181
+ end.to change { cli.instance_variable_get(:@cmd) }.from(nil).to('summary')
182
+ end
183
+ end
184
+
185
+ describe 'version command' do
186
+ it 'prints version as plain text by default' do
187
+ output = run_cli('version')
188
+ expect(output).to include('SimpleCovMcp version')
189
+ expect(output).to include(SimpleCovMcp::VERSION)
190
+ expect(output).not_to include('{')
191
+ expect(output).not_to include('}')
192
+ end
193
+
194
+ it 'prints version as JSON when --json flag is used' do
195
+ output = run_cli('version', '--json')
196
+ data = JSON.parse(output)
197
+ expect(data).to have_key('version')
198
+ expect(data['version']).to eq(SimpleCovMcp::VERSION)
199
+ end
200
+
201
+ it 'works with version command and other flags' do
202
+ output = run_cli('version', '--root', root)
203
+ expect(output).to include('SimpleCovMcp version')
204
+ expect(output).to include(SimpleCovMcp::VERSION)
205
+ end
206
+ end
72
207
  end