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,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tempfile'
5
+
6
+ RSpec.describe SimpleCovMcp::CoverageCLI, 'success predicate' 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 '--success-predicate' 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
+ '--success-predicate', path,
22
+ '--root', root,
23
+ '--resultset', 'coverage'
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
+ '--success-predicate', path,
33
+ '--root', root,
34
+ '--resultset', 'coverage'
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
+ '--success-predicate', path,
44
+ '--root', root,
45
+ '--resultset', 'coverage'
46
+ )
47
+ expect(status).to eq(2)
48
+ expect(err).to include('Success predicate error: Boom!')
49
+ end
50
+ end
51
+
52
+ it 'shows backtrace when predicate errors with --error-mode trace' do
53
+ with_temp_predicate("->(model) { raise 'Boom!' }\n") do |path|
54
+ _out, err, status = run_cli_with_status(
55
+ '--success-predicate', path,
56
+ '--root', root,
57
+ '--resultset', 'coverage',
58
+ '--error-mode', 'trace'
59
+ )
60
+ expect(status).to eq(2)
61
+ expect(err).to include('Success 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
+ '--success-predicate', '/nonexistent/predicate.rb',
70
+ '--root', root,
71
+ '--resultset', 'coverage'
72
+ )
73
+ expect(status).to eq(2)
74
+ expect(err).to include('Success 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
+ '--success-predicate', path,
81
+ '--root', root,
82
+ '--resultset', 'coverage'
83
+ )
84
+ expect(status).to eq(2)
85
+ expect(err).to include('Syntax error in success 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
+ '--success-predicate', path,
93
+ '--root', root,
94
+ '--resultset', 'coverage'
95
+ )
96
+ expect(status).to eq(2)
97
+ expect(err).to include('Success 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']['pct'] > 50 # Should be true for foo.rb
108
+ end
109
+ RUBY
110
+ _out, _err, status = run_cli_with_status(
111
+ '--success-predicate', path,
112
+ '--root', root,
113
+ '--resultset', 'coverage'
114
+ )
115
+ expect(status).to eq(0)
116
+ end
117
+ end
118
+ end
119
+
120
+ describe 'run_subcommand error handling' do
121
+ it 'handles generic errors in subcommands' do
122
+ # Force a generic error in the command execution
123
+ fake_command_class = Class.new do
124
+ def initialize(_cli); end
125
+
126
+ def execute(_args)
127
+ raise StandardError, 'Generic error in command'
128
+ end
129
+ end
130
+
131
+ allow(SimpleCovMcp::Commands::CommandFactory).to receive(:create)
132
+ .and_return(fake_command_class.new(nil))
133
+
134
+ _out, err, status = run_cli_with_status('summary', 'lib/foo.rb', '--root', root,
135
+ '--resultset', 'coverage')
136
+
137
+ expect(status).to eq(1)
138
+ expect(err).to include('An unexpected error occurred')
139
+ end
140
+ end
141
+ end
@@ -3,7 +3,7 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe SimpleCovMcp::CoverageCLI do
6
- let(:root) { (FIXTURES / 'project1').to_s }
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
7
 
8
8
  def run_cli(*argv)
9
9
  cli = described_class.new
@@ -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 'errors with usage when summary path is missing' do
26
9
  _out, err, status = run_cli_with_status('summary', '--root', root, '--resultset', 'coverage')
@@ -28,19 +11,19 @@ RSpec.describe SimpleCovMcp::CoverageCLI do
28
11
  expect(err).to include('Usage: simplecov-mcp summary <path>')
29
12
  end
30
13
 
31
- it 'treats unknown subcommand as no subcommand and prints default table' do
14
+ it 'errors with meaningful message for unknown subcommand' do
32
15
  out, err, status = run_cli_with_status('bogus', '--root', root, '--resultset', 'coverage')
33
- expect(status).to eq(0)
34
- expect(err).to eq("")
35
- expect(out).to include('File')
36
- expect(out).to include('lib/foo.rb')
16
+ expect(status).to eq(1)
17
+ expect(err).to include("Unknown subcommand: 'bogus'")
18
+ expect(err).to include('Valid subcommands:')
37
19
  end
38
20
 
39
21
  it 'list honors stale=error and tracked_globs by exiting 1 when project is stale' do
40
22
  tmp = File.join(root, 'lib', 'brand_new_file_for_cli_usage_spec.rb')
41
23
  begin
42
24
  File.write(tmp, "# new file\n")
43
- _out, err, status = run_cli_with_status('list', '--root', root, '--resultset', 'coverage', '--stale', 'error', '--tracked-globs', 'lib/**/*.rb')
25
+ _out, err, status = run_cli_with_status('list', '--root', root, '--resultset', 'coverage',
26
+ '--stale', 'error', '--tracked-globs', 'lib/**/*.rb')
44
27
  expect(status).to eq(1)
45
28
  expect(err).to include('Coverage data stale (project)')
46
29
  ensure
@@ -49,9 +32,10 @@ RSpec.describe SimpleCovMcp::CoverageCLI do
49
32
  end
50
33
 
51
34
  it 'list with stale=off prints table and exits 0' do
52
- out, err, status = run_cli_with_status('list', '--root', root, '--resultset', 'coverage', '--stale', 'off')
35
+ out, err, status = run_cli_with_status('list', '--root', root, '--resultset', 'coverage',
36
+ '--stale', 'off')
53
37
  expect(status).to eq(0)
54
- expect(err).to eq("")
38
+ expect(err).to eq('')
55
39
  expect(out).to include('File')
56
40
  expect(out).to include('lib/foo.rb')
57
41
  end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::Commands::BaseCommand do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+ let(:cli_context) { SimpleCovMcp::CoverageCLI.new }
8
+
9
+ # Create a test command class that exposes protected methods for testing
10
+ let(:test_command_class) do
11
+ Class.new(SimpleCovMcp::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, &block)
18
+ handle_with_path(args, name, &block)
19
+ end
20
+
21
+ def public_fetch_raw(model, path)
22
+ fetch_raw(model, path)
23
+ end
24
+ end
25
+ end
26
+
27
+ let(:test_command) { test_command_class.new(cli_context) }
28
+
29
+ describe '#handle_with_path' do
30
+ context 'when Errno::ENOENT is raised' do
31
+ it 'converts to FileNotFoundError with correct message' do
32
+ args = ['lib/missing.rb']
33
+
34
+ # Stub the block to raise Errno::ENOENT
35
+ expect do
36
+ test_command.public_handle_with_path(args, 'test') do |path|
37
+ raise Errno::ENOENT.new('No such file or directory')
38
+ end
39
+ end.to raise_error(SimpleCovMcp::FileNotFoundError, 'File not found: lib/missing.rb')
40
+ end
41
+
42
+ it 'includes the path from the args in the error message' do
43
+ args = ['some/other/path.rb']
44
+
45
+ expect do
46
+ test_command.public_handle_with_path(args, 'test') do |path|
47
+ raise Errno::ENOENT.new('No such file or directory')
48
+ end
49
+ end.to raise_error(SimpleCovMcp::FileNotFoundError, /some\/other\/path\.rb/)
50
+ end
51
+ end
52
+
53
+ context 'when Errno::EACCES is raised' do
54
+ it 'converts to FilePermissionError with correct message' do
55
+ args = ['lib/secret.rb']
56
+
57
+ # Stub the block to raise Errno::EACCES
58
+ expect do
59
+ test_command.public_handle_with_path(args, 'test') do |path|
60
+ raise Errno::EACCES.new('Permission denied')
61
+ end
62
+ end.to raise_error(SimpleCovMcp::FilePermissionError, 'Permission denied: lib/secret.rb')
63
+ end
64
+
65
+ it 'includes the path from the args in the error message' do
66
+ args = ['/root/protected.rb']
67
+
68
+ expect do
69
+ test_command.public_handle_with_path(args, 'test') do |path|
70
+ raise Errno::EACCES.new('Permission denied')
71
+ end
72
+ end.to raise_error(SimpleCovMcp::FilePermissionError, /\/root\/protected\.rb/)
73
+ end
74
+ end
75
+
76
+ context 'when no path is provided' do
77
+ it 'raises UsageError' do
78
+ args = []
79
+
80
+ expect do
81
+ test_command.public_handle_with_path(args, 'summary') do |path|
82
+ # Should not reach here
83
+ end
84
+ end.to raise_error(SimpleCovMcp::UsageError, /summary <path>/)
85
+ end
86
+ end
87
+
88
+ context 'when successful' do
89
+ it 'yields the path to the block' do
90
+ args = ['lib/foo.rb']
91
+ yielded_path = nil
92
+
93
+ test_command.public_handle_with_path(args, 'test') do |path|
94
+ yielded_path = path
95
+ end
96
+
97
+ expect(yielded_path).to eq('lib/foo.rb')
98
+ end
99
+
100
+ it 'shifts the path from args' do
101
+ args = ['lib/foo.rb', 'extra', 'args']
102
+
103
+ test_command.public_handle_with_path(args, 'test') do |path|
104
+ # Block execution
105
+ end
106
+
107
+ expect(args).to eq(['extra', 'args'])
108
+ end
109
+ end
110
+ end
111
+
112
+ describe '#fetch_raw' do
113
+ let(:model) { SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage') }
114
+
115
+ context 'when model.raw_for raises an exception' do
116
+ it 'returns nil instead of propagating the error' do
117
+ # Stub model.raw_for to raise an exception
118
+ allow(model).to receive(:raw_for).and_raise(StandardError, 'Something went wrong')
119
+
120
+ result = test_command.public_fetch_raw(model, 'lib/nonexistent.rb')
121
+
122
+ expect(result).to be_nil
123
+ end
124
+
125
+ it 'handles RuntimeError' do
126
+ allow(model).to receive(:raw_for).and_raise(RuntimeError, 'Runtime error')
127
+
128
+ result = test_command.public_fetch_raw(model, 'lib/foo.rb')
129
+
130
+ expect(result).to be_nil
131
+ end
132
+
133
+ it 'handles ArgumentError' do
134
+ allow(model).to receive(:raw_for).and_raise(ArgumentError, 'Invalid argument')
135
+
136
+ result = test_command.public_fetch_raw(model, 'lib/foo.rb')
137
+
138
+ expect(result).to be_nil
139
+ end
140
+ end
141
+
142
+ context 'when successful' do
143
+ it 'returns the raw coverage data' do
144
+ result = test_command.public_fetch_raw(model, 'lib/foo.rb')
145
+
146
+ expect(result).to be_a(Hash)
147
+ expect(result).to have_key('lines')
148
+ expect(result['lines']).to be_an(Array)
149
+ end
150
+
151
+ it 'caches the result for subsequent calls' do
152
+ # First call should hit the model
153
+ expect(model).to receive(:raw_for).with('lib/foo.rb').once.and_call_original
154
+
155
+ result1 = test_command.public_fetch_raw(model, 'lib/foo.rb')
156
+ result2 = test_command.public_fetch_raw(model, 'lib/foo.rb')
157
+
158
+ expect(result1).to eq(result2)
159
+ end
160
+
161
+ it 'caches different paths separately' do
162
+ result1 = test_command.public_fetch_raw(model, 'lib/foo.rb')
163
+ result2 = test_command.public_fetch_raw(model, 'lib/bar.rb')
164
+
165
+ expect(result1).not_to eq(result2)
166
+ end
167
+
168
+ it 'does not cache nil results from exceptions' do
169
+ # Set up the stub to raise an error
170
+ call_count = 0
171
+ allow(model).to receive(:raw_for).with('lib/missing.rb') do
172
+ call_count += 1
173
+ raise StandardError, 'File not found'
174
+ end
175
+
176
+ result1 = test_command.public_fetch_raw(model, 'lib/missing.rb')
177
+ result2 = test_command.public_fetch_raw(model, 'lib/missing.rb')
178
+
179
+ expect(result1).to be_nil
180
+ expect(result2).to be_nil
181
+ # Note: Due to current implementation, nil results are NOT cached,
182
+ # so raw_for is called each time an exception occurs
183
+ expect(call_count).to eq(2)
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::Commands::CommandFactory do
6
+ let(:cli_context) { SimpleCovMcp::CoverageCLI.new }
7
+
8
+ describe '.create' do
9
+ context 'with valid command names' do
10
+ [
11
+ ['list', SimpleCovMcp::Commands::ListCommand],
12
+ ['version', SimpleCovMcp::Commands::VersionCommand],
13
+ ['summary', SimpleCovMcp::Commands::SummaryCommand],
14
+ ['raw', SimpleCovMcp::Commands::RawCommand],
15
+ ['uncovered', SimpleCovMcp::Commands::UncoveredCommand],
16
+ ['detailed', SimpleCovMcp::Commands::DetailedCommand]
17
+ ].each do |command_name, command_class|
18
+ it "creates a #{command_class.name.split('::').last} for \"#{command_name}\"" do
19
+ command = described_class.create(command_name, cli_context)
20
+ expect(command).to be_a(command_class)
21
+ end
22
+ end
23
+ end
24
+
25
+ context 'with unknown command name' do
26
+ [
27
+ [
28
+ 'invalid_cmd',
29
+ 'invalid command',
30
+ /list | summary <path> | raw <path> | uncovered <path> | detailed <path> | version/
31
+ ],
32
+ [nil, 'nil command', nil],
33
+ ['', 'empty string command', nil],
34
+ ['sumary', 'misspelled command', nil]
35
+ ].each do |command_name, description, pattern|
36
+ it "raises UsageError for #{description}" do
37
+ expect do
38
+ described_class.create(command_name, cli_context)
39
+ end.to raise_error(SimpleCovMcp::UsageError, pattern)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ describe '.available_commands' do
46
+ it 'returns an array of available command names' do
47
+ commands = described_class.available_commands
48
+ expect(commands).to be_an(Array)
49
+ expect(commands).to contain_exactly('list', 'version', 'summary', 'raw', 'uncovered',
50
+ 'detailed')
51
+ end
52
+
53
+ it 'returns the keys from COMMAND_MAP' do
54
+ expect(described_class.available_commands).to eq(described_class::COMMAND_MAP.keys)
55
+ end
56
+ end
57
+
58
+ describe 'COMMAND_MAP' do
59
+ it 'is frozen to prevent modifications' do
60
+ expect(described_class::COMMAND_MAP).to be_frozen
61
+ end
62
+
63
+ it 'maps command names to command classes' do
64
+ expect(described_class::COMMAND_MAP['list']).to eq(SimpleCovMcp::Commands::ListCommand)
65
+ expect(described_class::COMMAND_MAP['version']).to eq(SimpleCovMcp::Commands::VersionCommand)
66
+ expect(described_class::COMMAND_MAP['summary']).to eq(SimpleCovMcp::Commands::SummaryCommand)
67
+ expect(described_class::COMMAND_MAP['raw']).to eq(SimpleCovMcp::Commands::RawCommand)
68
+ expect(described_class::COMMAND_MAP['uncovered']).to eq(SimpleCovMcp::Commands::UncoveredCommand)
69
+ expect(described_class::COMMAND_MAP['detailed']).to eq(SimpleCovMcp::Commands::DetailedCommand)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::Commands::DetailedCommand do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+ let(:cli_context) { SimpleCovMcp::CoverageCLI.new }
8
+ let(:command) { described_class.new(cli_context) }
9
+
10
+ before do
11
+ cli_context.config.root = root
12
+ cli_context.config.resultset = 'coverage'
13
+ cli_context.config.json = false
14
+ cli_context.config.source_mode = nil
15
+ end
16
+
17
+ describe '#execute' do
18
+ it 'prints the detailed coverage table' do
19
+ output = nil
20
+
21
+ silence_output do |stdout, _stderr|
22
+ command.execute(['lib/foo.rb'])
23
+ output = stdout.string
24
+ end
25
+
26
+ expect(output).to include('File: lib/foo.rb')
27
+ expect(output).to include('Line')
28
+ expect(output).to include('Covered')
29
+ end
30
+
31
+ it 'emits JSON when requested, including stale metadata' do
32
+ cli_context.config.json = true
33
+ stub_staleness_check('L')
34
+
35
+ json_output = nil
36
+ silence_output do |stdout, _stderr|
37
+ command.execute(['lib/foo.rb'])
38
+ json_output = stdout.string
39
+ end
40
+
41
+ payload = JSON.parse(json_output)
42
+ expect(payload['file']).to eq('lib/foo.rb')
43
+ expect(payload['lines']).to be_an(Array)
44
+ expect(payload['summary']).to include('covered' => 2, 'total' => 3, 'pct' => 66.67)
45
+ expect(payload['stale']).to eq('L')
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::Commands::RawCommand do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+ let(:cli_context) { SimpleCovMcp::CoverageCLI.new }
8
+ let(:command) { described_class.new(cli_context) }
9
+
10
+ before do
11
+ cli_context.config.root = root
12
+ cli_context.config.resultset = 'coverage'
13
+ cli_context.config.json = false
14
+ end
15
+
16
+ describe '#execute' do
17
+ it 'prints the raw coverage lines for the requested file' do
18
+ output = nil
19
+
20
+ silence_output do |stdout, _stderr|
21
+ command.execute(['lib/foo.rb'])
22
+ output = stdout.string
23
+ end
24
+
25
+ expect(output).to include('File: lib/foo.rb')
26
+ # Example match: "[1, 0, nil, 2]"
27
+ expect(output).to match(/\[1,\s0,\snil,\s2\]/)
28
+ end
29
+
30
+ it 'emits JSON when requested, including stale metadata' do
31
+ cli_context.config.json = true
32
+ stub_staleness_check('L')
33
+
34
+ json_output = nil
35
+ silence_output do |stdout, _stderr|
36
+ command.execute(['lib/foo.rb'])
37
+ json_output = stdout.string
38
+ end
39
+
40
+ payload = JSON.parse(json_output)
41
+ expect(payload['file']).to eq('lib/foo.rb')
42
+ expect(payload['lines']).to eq([1, 0, nil, 2])
43
+ expect(payload['stale']).to eq('L')
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::Commands::SummaryCommand do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+ let(:cli_context) { SimpleCovMcp::CoverageCLI.new }
8
+ let(:command) { described_class.new(cli_context) }
9
+
10
+ before do
11
+ cli_context.config.root = root
12
+ cli_context.config.resultset = 'coverage'
13
+ cli_context.config.json = false
14
+ cli_context.config.source_mode = nil
15
+ end
16
+
17
+ describe '#execute' do
18
+ it 'prints a coverage summary line with a relative path' do
19
+ output = nil
20
+
21
+ silence_output do |stdout, _stderr|
22
+ command.execute(['lib/foo.rb'])
23
+ output = stdout.string
24
+ end
25
+
26
+ # Example match: " 66.67% 2/3 lib/foo.rb"
27
+ expect(output).to match(/66\.67%.*2\/3.*lib\/foo\.rb/)
28
+ end
29
+
30
+ it 'emits JSON when requested, including stale metadata' do
31
+ cli_context.config.json = true
32
+ stub_staleness_check('L')
33
+
34
+ json_output = nil
35
+ silence_output do |stdout, _stderr|
36
+ command.execute(['lib/foo.rb'])
37
+ json_output = stdout.string
38
+ end
39
+
40
+ payload = JSON.parse(json_output)
41
+ expect(payload['file']).to eq('lib/foo.rb')
42
+ expect(payload['summary']).to include('covered' => 2, 'total' => 3, 'pct' => 66.67)
43
+ expect(payload).to have_key('stale')
44
+ expect(payload['stale']).to eq('L')
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::Commands::UncoveredCommand do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+ let(:cli_context) { SimpleCovMcp::CoverageCLI.new }
8
+ let(:command) { described_class.new(cli_context) }
9
+
10
+ before do
11
+ cli_context.config.root = root
12
+ cli_context.config.resultset = 'coverage'
13
+ cli_context.config.json = false
14
+ cli_context.config.source_mode = nil
15
+ end
16
+
17
+ describe '#execute' do
18
+ it 'prints uncovered line numbers with the summary' do
19
+ output = nil
20
+
21
+ silence_output do |stdout, _stderr|
22
+ command.execute(['lib/foo.rb'])
23
+ output = stdout.string
24
+ end
25
+
26
+ expect(output).to include('File: lib/foo.rb')
27
+ expect(output).to include('Uncovered lines: 2')
28
+ # Example match: "Summary: 66.67% 2/3"
29
+ expect(output).to match(/Summary:\s+66\.67%.*2\/3/)
30
+ end
31
+
32
+ it 'emits JSON when requested, including stale metadata' do
33
+ cli_context.config.json = true
34
+ stub_staleness_check('L')
35
+
36
+ json_output = nil
37
+ silence_output do |stdout, _stderr|
38
+ command.execute(['lib/foo.rb'])
39
+ json_output = stdout.string
40
+ end
41
+
42
+ payload = JSON.parse(json_output)
43
+ expect(payload['file']).to eq('lib/foo.rb')
44
+ expect(payload['uncovered']).to eq([2])
45
+ expect(payload['summary']).to include('covered' => 2, 'total' => 3, 'pct' => 66.67)
46
+ expect(payload['stale']).to eq('L')
47
+ end
48
+ end
49
+ end