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,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'Logging Fallback Behavior' do
6
+ describe 'CovUtil.log error handling' do
7
+ context 'when file logging fails in library mode' do
8
+ it 'falls back to stderr with error message' do
9
+ # Set up library mode context
10
+ context = SimpleCovMcp.create_context(
11
+ error_handler: SimpleCovMcp::ErrorHandlerFactory.for_library,
12
+ log_target: '/invalid/path/that/does/not/exist.log',
13
+ mode: :library
14
+ )
15
+
16
+ stderr_output = nil
17
+ SimpleCovMcp.with_context(context) do
18
+ silence_output do |_stdout, stderr|
19
+ SimpleCovMcp::CovUtil.log('test message')
20
+ stderr_output = stderr.string
21
+ end
22
+ end
23
+
24
+ expect(stderr_output).to include('LOGGING ERROR')
25
+ expect(stderr_output).to include('test message')
26
+ end
27
+ end
28
+
29
+ context 'when file logging fails in CLI mode' do
30
+ it 'falls back to stderr with error message' do
31
+ # Set up CLI mode context
32
+ context = SimpleCovMcp.create_context(
33
+ error_handler: SimpleCovMcp::ErrorHandlerFactory.for_cli,
34
+ log_target: '/invalid/path/that/does/not/exist.log',
35
+ mode: :cli
36
+ )
37
+
38
+ stderr_output = nil
39
+ SimpleCovMcp.with_context(context) do
40
+ silence_output do |_stdout, stderr|
41
+ SimpleCovMcp::CovUtil.log('test message')
42
+ stderr_output = stderr.string
43
+ end
44
+ end
45
+
46
+ expect(stderr_output).to include('LOGGING ERROR')
47
+ expect(stderr_output).to include('test message')
48
+ end
49
+ end
50
+
51
+ context 'when file logging fails in MCP server mode' do
52
+ it 'suppresses stderr output to avoid interfering with JSON-RPC' do
53
+ # Set up MCP server mode context
54
+ context = SimpleCovMcp.create_context(
55
+ error_handler: SimpleCovMcp::ErrorHandlerFactory.for_mcp_server,
56
+ log_target: '/invalid/path/that/does/not/exist.log',
57
+ mode: :mcp_server
58
+ )
59
+
60
+ stderr_output = nil
61
+ SimpleCovMcp.with_context(context) do
62
+ silence_output do |_stdout, stderr|
63
+ SimpleCovMcp::CovUtil.log('test message')
64
+ stderr_output = stderr.string
65
+ end
66
+ end
67
+
68
+ expect(stderr_output).to be_empty
69
+ end
70
+ end
71
+
72
+ context 'when logging succeeds' do
73
+ it 'does not write to stderr' do
74
+ Dir.mktmpdir do |dir|
75
+ log_file = File.join(dir, 'test.log')
76
+ context = SimpleCovMcp.create_context(
77
+ error_handler: SimpleCovMcp::ErrorHandlerFactory.for_library,
78
+ log_target: log_file,
79
+ mode: :library
80
+ )
81
+
82
+ stderr_output = nil
83
+ SimpleCovMcp.with_context(context) do
84
+ silence_output do |_stdout, stderr|
85
+ SimpleCovMcp::CovUtil.log('test message')
86
+ stderr_output = stderr.string
87
+ end
88
+ end
89
+
90
+ expect(stderr_output).to be_empty
91
+ expect(File.read(log_file)).to include('test message')
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ describe 'AppContext mode predicates' do
98
+ it 'correctly identifies library mode' do
99
+ context = SimpleCovMcp.create_context(
100
+ error_handler: SimpleCovMcp::ErrorHandlerFactory.for_library,
101
+ mode: :library
102
+ )
103
+ expect(context.library_mode?).to be true
104
+ expect(context.cli_mode?).to be false
105
+ expect(context.mcp_mode?).to be false
106
+ end
107
+
108
+ it 'correctly identifies CLI mode' do
109
+ context = SimpleCovMcp.create_context(
110
+ error_handler: SimpleCovMcp::ErrorHandlerFactory.for_cli,
111
+ mode: :cli
112
+ )
113
+ expect(context.library_mode?).to be false
114
+ expect(context.cli_mode?).to be true
115
+ expect(context.mcp_mode?).to be false
116
+ end
117
+
118
+ it 'correctly identifies MCP server mode' do
119
+ context = SimpleCovMcp.create_context(
120
+ error_handler: SimpleCovMcp::ErrorHandlerFactory.for_mcp_server,
121
+ mode: :mcp_server
122
+ )
123
+ expect(context.library_mode?).to be false
124
+ expect(context.cli_mode?).to be false
125
+ expect(context.mcp_mode?).to be true
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'MCP Mode Logging' do
6
+ it 'raises a configuration error when --log-file is stdout' do
7
+ argv = ['--log-file', 'stdout']
8
+
9
+ # Mock ModeDetector to force MCP mode
10
+ allow(SimpleCovMcp::ModeDetector).to receive(:cli_mode?).and_return(false)
11
+
12
+ expect do
13
+ SimpleCovMcp.run(argv)
14
+ end.to raise_error(SimpleCovMcp::ConfigurationError,
15
+ /Logging to stdout is not permitted in MCP server mode/)
16
+ end
17
+
18
+ it 'allows stderr logging in MCP mode' do
19
+ argv = ['--log-file', 'stderr']
20
+ original_target = SimpleCovMcp.active_log_file
21
+
22
+ # Mock ModeDetector to force MCP mode
23
+ allow(SimpleCovMcp::ModeDetector).to receive(:cli_mode?).and_return(false)
24
+
25
+ # The server would normally start here; stub it so we can capture the context without side effects.
26
+ mcp_server_double = instance_double(SimpleCovMcp::MCPServer, run: true)
27
+ captured_context = nil
28
+ allow(SimpleCovMcp::MCPServer).to receive(:new) do |context:|
29
+ # Record the context that the MCP server receives to ensure the log target was honored.
30
+ captured_context = context
31
+ mcp_server_double
32
+ end
33
+
34
+ expect do
35
+ SimpleCovMcp.run(argv)
36
+ end.not_to raise_error
37
+
38
+ # Server boot should have been given a context that points stdout logging to stderr.
39
+ expect(captured_context).not_to be_nil
40
+ expect(captured_context.log_target).to eq('stderr')
41
+ # After the run, the original active context should be restored.
42
+ expect(SimpleCovMcp.active_log_file).to eq(original_target)
43
+ end
44
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'MCP Server Bootstrap' do
6
+ it 'does not crash on startup in non-TTY environments' do
7
+ # Simulate a non-TTY environment, which should trigger MCP mode
8
+ allow(STDIN).to receive(:tty?).and_return(false)
9
+
10
+ # The server will try to run, but we only need to ensure it gets past
11
+ # the point where the NameError would have occurred. We can mock the
12
+ # server's run method to prevent it from hanging while waiting for input.
13
+ mcp_server_instance = instance_double(SimpleCovMcp::MCPServer)
14
+ allow(SimpleCovMcp::MCPServer).to receive(:new).and_return(mcp_server_instance)
15
+ allow(mcp_server_instance).to receive(:run)
16
+
17
+ # The key assertion is that this code executes without raising a NameError
18
+ # or any other exception related to the bootstrap process.
19
+ expect { SimpleCovMcp.run([]) }.not_to raise_error
20
+
21
+ expect(mcp_server_instance).to have_received(:run)
22
+ end
23
+ end
@@ -23,6 +23,7 @@ RSpec.describe SimpleCovMcp::MCPServer do
23
23
  attr_accessor :last_instance
24
24
  end
25
25
  attr_reader :params
26
+
26
27
  def initialize(name:, version:, tools:)
27
28
  @params = { name: name, version: version, tools: tools }
28
29
  self.class.last_instance = self
@@ -40,14 +41,17 @@ RSpec.describe SimpleCovMcp::MCPServer do
40
41
  attr_accessor :last_instance
41
42
  end
42
43
  attr_reader :server, :opened
44
+
43
45
  def initialize(server)
44
46
  @server = server
45
47
  @opened = false
46
48
  self.class.last_instance = self
47
49
  end
50
+
48
51
  def open
49
52
  @opened = true
50
53
  end
54
+
51
55
  def opened?
52
56
  @opened
53
57
  end
@@ -56,12 +60,18 @@ RSpec.describe SimpleCovMcp::MCPServer do
56
60
  stub_const('MCP::Server', fake_server_class)
57
61
  stub_const('MCP::Server::Transports::StdioTransport', fake_transport_class)
58
62
 
59
- server = described_class.new
60
- # Error handler should be set for MCP server usage (factory selection).
61
- expect(SimpleCovMcp.error_handler).to be_a(SimpleCovMcp::ErrorHandler)
63
+ server_context = SimpleCovMcp.create_context(
64
+ error_handler: SimpleCovMcp::ErrorHandlerFactory.for_mcp_server,
65
+ log_target: 'stderr'
66
+ )
67
+ server = described_class.new(context: server_context)
68
+ baseline_context = SimpleCovMcp.context
62
69
 
63
70
  # Run should construct server and open transport
64
71
  server.run
72
+ # Server should restore the caller's context after execution.
73
+ expect(SimpleCovMcp.context).to eq(baseline_context)
74
+
65
75
  # Fetch the instances created during `run` via the class-level hooks.
66
76
  fake_server = fake_server_class.last_instance
67
77
  fake_transport = fake_transport_class.last_instance
@@ -73,6 +83,7 @@ RSpec.describe SimpleCovMcp::MCPServer do
73
83
  expect(fake_server.params[:name]).to eq('simplecov-mcp')
74
84
  # Ensure expected tools are registered
75
85
  tool_names = fake_server.params[:tools].map { |t| t.name.split('::').last }
76
- expect(tool_names).to include('AllFilesCoverageTool', 'CoverageDetailedTool', 'CoverageRawTool', 'CoverageSummaryTool', 'UncoveredLinesTool', 'HelpTool')
86
+ expect(tool_names).to include('AllFilesCoverageTool', 'CoverageDetailedTool',
87
+ 'CoverageRawTool', 'CoverageSummaryTool', 'UncoveredLinesTool', 'HelpTool')
77
88
  end
78
89
  end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::ModeDetector do
6
+ describe '.cli_mode?' do
7
+ # Array-driven test cases for comprehensive coverage
8
+ # Format: [argv, tty?, expected_result, description]
9
+ CLI_MODE_SCENARIOS = [
10
+ # Priority 1: --force-cli flag (highest priority)
11
+ [['--force-cli'], false, true, '--force-cli with piped input'],
12
+ [['--force-cli', '--json'], false, true, '--force-cli with other flags'],
13
+
14
+ # Priority 2: Valid subcommands (must be first arg)
15
+ [['list'], false, true, 'list subcommand'],
16
+ [['summary', 'lib/foo.rb'], false, true, 'summary with path'],
17
+ [['version'], false, true, 'version subcommand'],
18
+ [['list', '--json'], false, true, 'subcommand with trailing flags'],
19
+
20
+ # Priority 3: Invalid subcommand attempts (must be first non-flag arg)
21
+ [['invalid-command'], false, true, 'invalid subcommand (shows error)'],
22
+ [['lib/foo.rb'], false, true, 'file path (shows error)'],
23
+
24
+ # Priority 4: TTY determines mode when no subcommand/force-cli
25
+ [[], true, true, 'empty args with TTY'],
26
+ [[], false, false, 'empty args with piped input'],
27
+ [['--json'], true, true, 'flags only with TTY'],
28
+ [['--json'], false, false, 'flags only with piped input'],
29
+ [['-r', 'foo', '--json'], false, false, 'multiple flags with piped input'],
30
+
31
+ # Edge cases: flags before subcommands should now be detected as CLI mode
32
+ [['--json', 'list'], false, true, 'flag first = CLI mode'],
33
+ [['-r', 'foo', 'summary'], false, true, 'option first = CLI mode'],
34
+ ].freeze
35
+
36
+ CLI_MODE_SCENARIOS.each do |argv, is_tty, expected, description|
37
+ it "#{expected ? 'CLI' : 'MCP'}: #{description}" do
38
+ stdin = double('stdin', tty?: is_tty)
39
+ result = described_class.cli_mode?(argv, stdin: stdin)
40
+ expect(result).to be(expected),
41
+ "Expected cli_mode?(#{argv.inspect}, tty: #{is_tty}) to be #{expected}, got #{result}"
42
+ end
43
+ end
44
+
45
+ # Test all subcommands dynamically
46
+ context 'with all valid subcommands' do
47
+ SimpleCovMcp::ModeDetector::SUBCOMMANDS.each do |subcommand|
48
+ it "CLI mode for '#{subcommand}' (no TTY)" do
49
+ stdin = double('stdin', tty?: false)
50
+ expect(described_class.cli_mode?([subcommand], stdin: stdin)).to be true
51
+ end
52
+ end
53
+ end
54
+
55
+ it 'uses STDIN by default when no stdin parameter given' do
56
+ allow(STDIN).to receive(:tty?).and_return(true)
57
+ expect(described_class.cli_mode?([])).to be true
58
+ end
59
+ end
60
+
61
+ describe '.mcp_server_mode?' do
62
+ # Simpler test cases for the inverse method
63
+ MCP_SCENARIOS = [
64
+ [[], false, true, 'piped input, no args'],
65
+ [['--json'], false, true, 'piped input with flags'],
66
+ [[], true, false, 'TTY, no args'],
67
+ [['--force-cli'], false, false, '--force-cli flag'],
68
+ [['list'], false, false, 'subcommand'],
69
+ ].freeze
70
+
71
+ MCP_SCENARIOS.each do |argv, is_tty, expected, description|
72
+ it "#{expected ? 'MCP' : 'CLI'}: #{description}" do
73
+ stdin = double('stdin', tty?: is_tty)
74
+ expect(described_class.mcp_server_mode?(argv, stdin: stdin)).to be expected
75
+ end
76
+ end
77
+
78
+ it 'is the logical inverse of cli_mode?' do
79
+ [[[], true], [[], false], [['list'], false]].each do |argv, is_tty|
80
+ stdin = double('stdin', tty?: is_tty)
81
+ cli = described_class.cli_mode?(argv, stdin: stdin)
82
+ mcp = described_class.mcp_server_mode?(argv, stdin: stdin)
83
+ expect(mcp).to eq(!cli)
84
+ end
85
+ end
86
+ end
87
+
88
+ describe 'priority order' do
89
+ let(:stdin) { double('stdin', tty?: false) }
90
+
91
+ it '1. --force-cli overrides everything' do
92
+ expect(described_class.cli_mode?(['--force-cli'], stdin: stdin)).to be true
93
+ end
94
+
95
+ it '2. subcommand (first arg) overrides TTY' do
96
+ expect(described_class.cli_mode?(['list'], stdin: stdin)).to be true
97
+ end
98
+
99
+ it '3. invalid first arg (not flag) triggers CLI' do
100
+ expect(described_class.cli_mode?(['invalid'], stdin: stdin)).to be true
101
+ end
102
+
103
+ it '4. TTY is checked last (when first arg is flag or empty)' do
104
+ tty = double('stdin', tty?: true)
105
+ no_tty = double('stdin', tty?: false)
106
+
107
+ expect(described_class.cli_mode?([], stdin: tty)).to be true
108
+ expect(described_class.cli_mode?([], stdin: no_tty)).to be false
109
+ end
110
+ end
111
+
112
+ describe 'consistency checks' do
113
+ it 'SUBCOMMANDS matches CoverageCLI' do
114
+ expect(SimpleCovMcp::ModeDetector::SUBCOMMANDS).to eq(SimpleCovMcp::CoverageCLI::SUBCOMMANDS)
115
+ end
116
+
117
+ it 'all SUBCOMMANDS are lowercase without dashes' do
118
+ SimpleCovMcp::ModeDetector::SUBCOMMANDS.each do |cmd|
119
+ expect(cmd).to eq(cmd.downcase)
120
+ expect(cmd).not_to start_with('-')
121
+ end
122
+ end
123
+ end
124
+
125
+ describe 'regression tests for non-TTY environment' do
126
+ let(:stdin) { double('stdin', tty?: false) }
127
+
128
+ it 'chooses CLI mode for --help' do
129
+ expect(described_class.cli_mode?(['--help'], stdin: stdin)).to be true
130
+ end
131
+
132
+ it 'chooses CLI mode for -h' do
133
+ expect(described_class.cli_mode?(['-h'], stdin: stdin)).to be true
134
+ end
135
+
136
+ it 'chooses CLI mode for --version' do
137
+ expect(described_class.cli_mode?(['--version'], stdin: stdin)).to be true
138
+ end
139
+
140
+ it 'chooses CLI mode for --json list' do
141
+ expect(described_class.cli_mode?(['--json', 'list'], stdin: stdin)).to be true
142
+ end
143
+
144
+ it 'chooses MCP mode for flags without a subcommand' do
145
+ expect(described_class.cli_mode?(['--json'], stdin: stdin)).to be false
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::CoverageModel, 'error handling' do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+
8
+ describe 'initialization error handling' do
9
+ it 'raises CoverageDataError with message detail for invalid JSON format' do
10
+ # Mock JSON.parse to raise JSON::ParserError
11
+ allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new('unexpected token'))
12
+
13
+ expect do
14
+ SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
15
+ end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
16
+ expect(error.message).to include('Invalid coverage data format')
17
+ expect(error.message).to include('unexpected token')
18
+ end
19
+ end
20
+
21
+ it 'raises FilePermissionError when coverage file is not readable' do
22
+ # Mock File.read to raise Errno::EACCES
23
+ allow(File).to receive(:read).and_call_original
24
+ allow(File).to receive(:read).with(end_with('.resultset.json')).and_raise(
25
+ Errno::EACCES.new('Permission denied')
26
+ )
27
+
28
+ expect do
29
+ SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
30
+ end.to raise_error(SimpleCovMcp::FilePermissionError) do |error|
31
+ expect(error.message).to include('Permission denied reading coverage data')
32
+ expect(error.message).to include('Permission denied')
33
+ end
34
+ end
35
+
36
+ it 'raises CoverageDataError when resultset structure is invalid (TypeError)' do
37
+ # Create a malformed resultset that will cause TypeError
38
+ malformed_resultset = {
39
+ 'RSpec' => {
40
+ 'coverage' => 'not_a_hash' # Should be a hash, not a string
41
+ }
42
+ }
43
+
44
+ allow(File).to receive(:read).and_call_original
45
+ allow(File).to receive(:read).with(end_with('.resultset.json'))
46
+ .and_return(malformed_resultset.to_json)
47
+
48
+ expect do
49
+ SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
50
+ end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
51
+ expect(error.message).to include('Invalid coverage data structure')
52
+ end
53
+ end
54
+
55
+ it 'raises CoverageDataError when resultset structure causes NoMethodError' do
56
+ # Create a resultset structure that will cause NoMethodError
57
+ malformed_resultset = {
58
+ 'RSpec' => {
59
+ 'coverage' => {
60
+ 'file.rb' => nil # Should have 'lines' key, this will cause NoMethodError
61
+ }
62
+ }
63
+ }
64
+
65
+ allow(File).to receive(:read).and_call_original
66
+ allow(File).to receive(:read).with(end_with('.resultset.json'))
67
+ .and_return(malformed_resultset.to_json)
68
+
69
+ # This might succeed or fail depending on how the code handles it
70
+ # Let's make it fail by mocking transform_keys to raise NoMethodError
71
+ allow_any_instance_of(Hash).to receive(:transform_keys)
72
+ .and_raise(NoMethodError.new("undefined method `upcase' for nil:NilClass"))
73
+
74
+ expect do
75
+ SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
76
+ end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
77
+ expect(error.message).to include('Invalid coverage data structure')
78
+ end
79
+ end
80
+
81
+ it 'raises CoverageDataError when path operations raise ArgumentError' do
82
+ # Create a valid resultset structure with a problematic path
83
+ valid_resultset = {
84
+ 'RSpec' => {
85
+ 'coverage' => {
86
+ "lib/foo\x00bar.rb" => { 'lines' => [1, 0, 1] } # Path with NULL byte
87
+ },
88
+ 'timestamp' => 1000
89
+ }
90
+ }
91
+
92
+ allow(File).to receive(:read).and_call_original
93
+ allow(File).to receive(:read).with(end_with('.resultset.json'))
94
+ .and_return(valid_resultset.to_json)
95
+
96
+ # Mock File.absolute_path to raise ArgumentError when called with the problematic path
97
+ # But allow it to work for the root initialization
98
+ allow(File).to receive(:absolute_path).and_call_original
99
+ allow(File).to receive(:absolute_path).with(include("\x00"), anything).and_raise(
100
+ ArgumentError.new('string contains null byte')
101
+ )
102
+
103
+ expect do
104
+ SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
105
+ end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
106
+ expect(error.message).to include('Invalid path in coverage data')
107
+ expect(error.message).to include('null byte')
108
+ end
109
+ end
110
+
111
+ it 'preserves error context in JSON::ParserError messages' do
112
+ # Mock JSON.parse to raise JSON::ParserError with specific message
113
+ allow(JSON).to receive(:parse).and_raise(
114
+ JSON::ParserError.new('765: unexpected token at line 3, column 5')
115
+ )
116
+
117
+ expect do
118
+ SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
119
+ end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
120
+ # Verify the original error message details are preserved
121
+ expect(error.message).to include('765')
122
+ expect(error.message).to include('line 3')
123
+ end
124
+ end
125
+
126
+ it 'provides helpful error for permission issues with file path' do
127
+ # Mock to raise permission error with actual file path
128
+ resultset_path = File.join(root, 'coverage', '.resultset.json')
129
+ allow(File).to receive(:read).and_call_original
130
+ allow(File).to receive(:read).with(resultset_path).and_raise(
131
+ Errno::EACCES.new(resultset_path)
132
+ )
133
+
134
+ expect do
135
+ SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
136
+ end.to raise_error(SimpleCovMcp::FilePermissionError) do |error|
137
+ expect(error.message).to include('Permission denied')
138
+ expect(error.message).to match(/\.resultset\.json/)
139
+ end
140
+ end
141
+ end
142
+
143
+ describe 'error context preservation' do
144
+ it 'includes original exception message in all specific error types' do
145
+ test_cases = [
146
+ {
147
+ error_class: JSON::ParserError,
148
+ message: 'unexpected character at byte 42',
149
+ expected_type: SimpleCovMcp::CoverageDataError,
150
+ expected_content: 'unexpected character at byte 42'
151
+ },
152
+ {
153
+ error_class: Errno::EACCES,
154
+ message: '/path/to/coverage/.resultset.json',
155
+ expected_type: SimpleCovMcp::FilePermissionError,
156
+ expected_content: '/path/to/coverage/.resultset.json'
157
+ },
158
+ {
159
+ error_class: TypeError,
160
+ message: 'no implicit conversion of String into Integer',
161
+ expected_type: SimpleCovMcp::CoverageDataError,
162
+ expected_content: 'no implicit conversion'
163
+ }
164
+ ]
165
+
166
+ test_cases.each do |test_case|
167
+ allow(File).to receive(:read).and_call_original
168
+ allow(File).to receive(:read).with(end_with('.resultset.json')).and_raise(
169
+ test_case[:error_class].new(test_case[:message])
170
+ )
171
+
172
+ expect do
173
+ SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
174
+ end.to raise_error(test_case[:expected_type]) do |error|
175
+ expect(error.message).to include(test_case[:expected_content])
176
+ end
177
+ end
178
+ end
179
+ end
180
+
181
+ describe 'RuntimeError handling from find_resultset' do
182
+ it 'converts RuntimeError to CoverageDataError with helpful message' do
183
+ # Mock find_resultset to raise RuntimeError (simulating missing resultset)
184
+ allow(SimpleCovMcp::CovUtil).to receive(:find_resultset).and_raise(
185
+ RuntimeError.new('Specified resultset not found: /nonexistent/path/.resultset.json')
186
+ )
187
+
188
+ expect do
189
+ SimpleCovMcp::CoverageModel.new(root: root, resultset: '/nonexistent/path')
190
+ end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
191
+ expect(error.message).to include('Failed to load coverage data')
192
+ expect(error.message).to include('Specified resultset not found')
193
+ end
194
+ end
195
+
196
+ it 'handles RuntimeError with generic messages' do
197
+ # Test RuntimeError with any generic message
198
+ allow(SimpleCovMcp::CovUtil).to receive(:find_resultset).and_raise(
199
+ RuntimeError.new('Something went wrong during resultset lookup')
200
+ )
201
+
202
+ expect do
203
+ SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
204
+ end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
205
+ expect(error.message).to include('Failed to load coverage data')
206
+ expect(error.message).to include('Something went wrong')
207
+ end
208
+ end
209
+ end
210
+ end