cov-loupe 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +329 -0
  4. data/docs/dev/ARCHITECTURE.md +80 -0
  5. data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
  6. data/docs/dev/DEVELOPMENT.md +83 -0
  7. data/docs/dev/README.md +10 -0
  8. data/docs/dev/RELEASING.md +146 -0
  9. data/docs/dev/arch-decisions/001-x-arch-decision.md +95 -0
  10. data/docs/dev/arch-decisions/002-x-arch-decision.md +159 -0
  11. data/docs/dev/arch-decisions/003-x-arch-decision.md +165 -0
  12. data/docs/dev/arch-decisions/004-x-arch-decision.md +203 -0
  13. data/docs/dev/arch-decisions/005-x-arch-decision.md +189 -0
  14. data/docs/dev/arch-decisions/README.md +60 -0
  15. data/docs/dev/presentations/cov-loupe-presentation.md +255 -0
  16. data/docs/fixtures/demo_project/README.md +9 -0
  17. data/docs/user/ADVANCED_USAGE.md +777 -0
  18. data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
  19. data/docs/user/CLI_USAGE.md +750 -0
  20. data/docs/user/ERROR_HANDLING.md +93 -0
  21. data/docs/user/EXAMPLES.md +588 -0
  22. data/docs/user/INSTALLATION.md +130 -0
  23. data/docs/user/LIBRARY_API.md +693 -0
  24. data/docs/user/MCP_INTEGRATION.md +490 -0
  25. data/docs/user/README.md +14 -0
  26. data/docs/user/TROUBLESHOOTING.md +197 -0
  27. data/docs/user/V2-BREAKING-CHANGES.md +472 -0
  28. data/exe/cov-loupe +23 -0
  29. data/lib/cov_loupe/app_config.rb +56 -0
  30. data/lib/cov_loupe/app_context.rb +26 -0
  31. data/lib/cov_loupe/base_tool.rb +102 -0
  32. data/lib/cov_loupe/cli.rb +178 -0
  33. data/lib/cov_loupe/commands/base_command.rb +67 -0
  34. data/lib/cov_loupe/commands/command_factory.rb +45 -0
  35. data/lib/cov_loupe/commands/detailed_command.rb +38 -0
  36. data/lib/cov_loupe/commands/list_command.rb +13 -0
  37. data/lib/cov_loupe/commands/raw_command.rb +38 -0
  38. data/lib/cov_loupe/commands/summary_command.rb +41 -0
  39. data/lib/cov_loupe/commands/totals_command.rb +53 -0
  40. data/lib/cov_loupe/commands/uncovered_command.rb +45 -0
  41. data/lib/cov_loupe/commands/validate_command.rb +60 -0
  42. data/lib/cov_loupe/commands/version_command.rb +33 -0
  43. data/lib/cov_loupe/config_parser.rb +32 -0
  44. data/lib/cov_loupe/constants.rb +22 -0
  45. data/lib/cov_loupe/coverage_reporter.rb +31 -0
  46. data/lib/cov_loupe/error_handler.rb +165 -0
  47. data/lib/cov_loupe/error_handler_factory.rb +31 -0
  48. data/lib/cov_loupe/errors.rb +191 -0
  49. data/lib/cov_loupe/formatters/source_formatter.rb +152 -0
  50. data/lib/cov_loupe/formatters.rb +51 -0
  51. data/lib/cov_loupe/mcp_server.rb +42 -0
  52. data/lib/cov_loupe/mode_detector.rb +56 -0
  53. data/lib/cov_loupe/model.rb +339 -0
  54. data/lib/cov_loupe/option_normalizers.rb +113 -0
  55. data/lib/cov_loupe/option_parser_builder.rb +147 -0
  56. data/lib/cov_loupe/option_parsers/env_options_parser.rb +48 -0
  57. data/lib/cov_loupe/option_parsers/error_helper.rb +110 -0
  58. data/lib/cov_loupe/path_relativizer.rb +64 -0
  59. data/lib/cov_loupe/predicate_evaluator.rb +72 -0
  60. data/lib/cov_loupe/presenters/base_coverage_presenter.rb +42 -0
  61. data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +14 -0
  62. data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +14 -0
  63. data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +14 -0
  64. data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +14 -0
  65. data/lib/cov_loupe/presenters/project_coverage_presenter.rb +50 -0
  66. data/lib/cov_loupe/presenters/project_totals_presenter.rb +27 -0
  67. data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +122 -0
  68. data/lib/cov_loupe/resolvers/resolver_factory.rb +28 -0
  69. data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +76 -0
  70. data/lib/cov_loupe/resultset_loader.rb +131 -0
  71. data/lib/cov_loupe/staleness_checker.rb +247 -0
  72. data/lib/cov_loupe/table_formatter.rb +64 -0
  73. data/lib/cov_loupe/tools/all_files_coverage_tool.rb +51 -0
  74. data/lib/cov_loupe/tools/coverage_detailed_tool.rb +35 -0
  75. data/lib/cov_loupe/tools/coverage_raw_tool.rb +34 -0
  76. data/lib/cov_loupe/tools/coverage_summary_tool.rb +34 -0
  77. data/lib/cov_loupe/tools/coverage_table_tool.rb +50 -0
  78. data/lib/cov_loupe/tools/coverage_totals_tool.rb +44 -0
  79. data/lib/cov_loupe/tools/help_tool.rb +115 -0
  80. data/lib/cov_loupe/tools/uncovered_lines_tool.rb +34 -0
  81. data/lib/cov_loupe/tools/validate_tool.rb +72 -0
  82. data/lib/cov_loupe/tools/version_tool.rb +32 -0
  83. data/lib/cov_loupe/util.rb +88 -0
  84. data/lib/cov_loupe/version.rb +5 -0
  85. data/lib/cov_loupe.rb +140 -0
  86. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  87. data/spec/TIMESTAMPS.md +48 -0
  88. data/spec/all_files_coverage_tool_spec.rb +53 -0
  89. data/spec/app_config_spec.rb +142 -0
  90. data/spec/base_tool_spec.rb +62 -0
  91. data/spec/cli/show_default_report_spec.rb +33 -0
  92. data/spec/cli_enumerated_options_spec.rb +90 -0
  93. data/spec/cli_error_spec.rb +184 -0
  94. data/spec/cli_format_spec.rb +123 -0
  95. data/spec/cli_json_options_spec.rb +50 -0
  96. data/spec/cli_source_spec.rb +44 -0
  97. data/spec/cli_spec.rb +192 -0
  98. data/spec/cli_table_spec.rb +28 -0
  99. data/spec/cli_usage_spec.rb +42 -0
  100. data/spec/commands/base_command_spec.rb +107 -0
  101. data/spec/commands/command_factory_spec.rb +76 -0
  102. data/spec/commands/detailed_command_spec.rb +34 -0
  103. data/spec/commands/list_command_spec.rb +28 -0
  104. data/spec/commands/raw_command_spec.rb +69 -0
  105. data/spec/commands/summary_command_spec.rb +34 -0
  106. data/spec/commands/totals_command_spec.rb +34 -0
  107. data/spec/commands/uncovered_command_spec.rb +55 -0
  108. data/spec/commands/validate_command_spec.rb +213 -0
  109. data/spec/commands/version_command_spec.rb +38 -0
  110. data/spec/constants_spec.rb +61 -0
  111. data/spec/cov_loupe/formatters/source_formatter_spec.rb +267 -0
  112. data/spec/cov_loupe/formatters_spec.rb +76 -0
  113. data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +79 -0
  114. data/spec/cov_loupe_model_spec.rb +454 -0
  115. data/spec/cov_loupe_module_spec.rb +37 -0
  116. data/spec/cov_loupe_opts_spec.rb +185 -0
  117. data/spec/coverage_reporter_spec.rb +102 -0
  118. data/spec/coverage_table_tool_spec.rb +59 -0
  119. data/spec/coverage_totals_tool_spec.rb +37 -0
  120. data/spec/error_handler_spec.rb +197 -0
  121. data/spec/error_mode_spec.rb +139 -0
  122. data/spec/errors_edge_cases_spec.rb +312 -0
  123. data/spec/errors_stale_spec.rb +83 -0
  124. data/spec/file_based_mcp_tools_spec.rb +99 -0
  125. data/spec/fixtures/project1/lib/bar.rb +5 -0
  126. data/spec/fixtures/project1/lib/foo.rb +6 -0
  127. data/spec/help_tool_spec.rb +26 -0
  128. data/spec/integration_spec.rb +789 -0
  129. data/spec/logging_fallback_spec.rb +128 -0
  130. data/spec/mcp_logging_spec.rb +44 -0
  131. data/spec/mcp_server_integration_spec.rb +23 -0
  132. data/spec/mcp_server_spec.rb +106 -0
  133. data/spec/mode_detector_spec.rb +153 -0
  134. data/spec/model_error_handling_spec.rb +269 -0
  135. data/spec/model_staleness_spec.rb +79 -0
  136. data/spec/option_normalizers_spec.rb +203 -0
  137. data/spec/option_parsers/env_options_parser_spec.rb +221 -0
  138. data/spec/option_parsers/error_helper_spec.rb +222 -0
  139. data/spec/path_relativizer_spec.rb +98 -0
  140. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  141. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  142. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  143. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  144. data/spec/presenters/project_coverage_presenter_spec.rb +87 -0
  145. data/spec/presenters/project_totals_presenter_spec.rb +144 -0
  146. data/spec/resolvers/coverage_line_resolver_spec.rb +282 -0
  147. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  148. data/spec/resolvers/resultset_path_resolver_spec.rb +60 -0
  149. data/spec/resultset_loader_spec.rb +167 -0
  150. data/spec/shared_examples/README.md +115 -0
  151. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  152. data/spec/shared_examples/file_based_mcp_tools.rb +179 -0
  153. data/spec/shared_examples/formatted_command_examples.rb +64 -0
  154. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  155. data/spec/spec_helper.rb +127 -0
  156. data/spec/staleness_checker_spec.rb +374 -0
  157. data/spec/staleness_more_spec.rb +42 -0
  158. data/spec/support/cli_helpers.rb +22 -0
  159. data/spec/support/control_flow_helpers.rb +20 -0
  160. data/spec/support/fake_mcp.rb +40 -0
  161. data/spec/support/io_helpers.rb +29 -0
  162. data/spec/support/mcp_helpers.rb +35 -0
  163. data/spec/support/mcp_runner.rb +66 -0
  164. data/spec/support/mocking_helpers.rb +30 -0
  165. data/spec/table_format_spec.rb +70 -0
  166. data/spec/tools/validate_tool_spec.rb +132 -0
  167. data/spec/tools_error_handling_spec.rb +130 -0
  168. data/spec/util_spec.rb +154 -0
  169. data/spec/version_spec.rb +123 -0
  170. data/spec/version_tool_spec.rb +141 -0
  171. metadata +290 -0
@@ -0,0 +1,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 = CovLoupe.create_context(
11
+ error_handler: CovLoupe::ErrorHandlerFactory.for_library,
12
+ log_target: '/invalid/path/that/does/not/exist.log',
13
+ mode: :library
14
+ )
15
+
16
+ stderr_output = nil
17
+ CovLoupe.with_context(context) do
18
+ silence_output do |_stdout, stderr|
19
+ CovLoupe::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 = CovLoupe.create_context(
33
+ error_handler: CovLoupe::ErrorHandlerFactory.for_cli,
34
+ log_target: '/invalid/path/that/does/not/exist.log',
35
+ mode: :cli
36
+ )
37
+
38
+ stderr_output = nil
39
+ CovLoupe.with_context(context) do
40
+ silence_output do |_stdout, stderr|
41
+ CovLoupe::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 = CovLoupe.create_context(
55
+ error_handler: CovLoupe::ErrorHandlerFactory.for_mcp_server,
56
+ log_target: '/invalid/path/that/does/not/exist.log',
57
+ mode: :mcp
58
+ )
59
+
60
+ stderr_output = nil
61
+ CovLoupe.with_context(context) do
62
+ silence_output do |_stdout, stderr|
63
+ CovLoupe::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 = CovLoupe.create_context(
77
+ error_handler: CovLoupe::ErrorHandlerFactory.for_library,
78
+ log_target: log_file,
79
+ mode: :library
80
+ )
81
+
82
+ stderr_output = nil
83
+ CovLoupe.with_context(context) do
84
+ silence_output do |_stdout, stderr|
85
+ CovLoupe::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 = CovLoupe.create_context(
100
+ error_handler: CovLoupe::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 = CovLoupe.create_context(
110
+ error_handler: CovLoupe::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 mode' do
119
+ context = CovLoupe.create_context(
120
+ error_handler: CovLoupe::ErrorHandlerFactory.for_mcp_server,
121
+ mode: :mcp
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(CovLoupe::ModeDetector).to receive(:cli_mode?).and_return(false)
11
+
12
+ expect do
13
+ CovLoupe.run(argv)
14
+ end.to raise_error(CovLoupe::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 = CovLoupe.active_log_file
21
+
22
+ # Mock ModeDetector to force MCP mode
23
+ allow(CovLoupe::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(CovLoupe::MCPServer, run: true)
27
+ captured_context = nil
28
+ allow(CovLoupe::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
+ CovLoupe.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(CovLoupe.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(CovLoupe::MCPServer)
14
+ allow(CovLoupe::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 { CovLoupe.run([]) }.not_to raise_error
20
+
21
+ expect(mcp_server_instance).to have_received(:run)
22
+ end
23
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'support/fake_mcp'
5
+
6
+ RSpec.describe CovLoupe::MCPServer do
7
+ # This spec verifies the MCP server boot path without requiring the real
8
+ # MCP runtime. We stub the MCP::Server and its stdio transport to capture
9
+ # constructor parameters and observe that `open` is invoked.
10
+ it 'sets error handler and boots server with expected tools' do
11
+ # Prepare fakes for MCP server and transport
12
+ module ::MCP; end unless defined?(::MCP)
13
+
14
+ stub_const('MCP::Server', FakeMCP::Server)
15
+ stub_const('MCP::Server::Transports::StdioTransport', FakeMCP::StdioTransport)
16
+
17
+ server_context = CovLoupe.create_context(
18
+ error_handler: CovLoupe::ErrorHandlerFactory.for_mcp_server,
19
+ log_target: 'stderr'
20
+ )
21
+ server = described_class.new(context: server_context)
22
+ baseline_context = CovLoupe.context
23
+
24
+ # Run should construct server and open transport
25
+ server.run
26
+ # Server should restore the caller's context after execution.
27
+ expect(CovLoupe.context).to eq(baseline_context)
28
+
29
+ # Fetch the instances created during `run` via the class-level hooks.
30
+ fake_server = FakeMCP::Server.last_instance
31
+ fake_transport = FakeMCP::StdioTransport.last_instance
32
+
33
+ expect(fake_transport).not_to be_nil
34
+ expect(fake_transport).to be_opened
35
+ expect(fake_server).not_to be_nil
36
+
37
+ expect(fake_server.params[:name]).to eq('cov-loupe')
38
+ # Ensure expected tools are registered
39
+ tool_names = fake_server.params[:tools].map { |t| t.name.split('::').last }
40
+ expect(tool_names).to include(
41
+ 'AllFilesCoverageTool',
42
+ 'CoverageDetailedTool',
43
+ 'CoverageRawTool',
44
+ 'CoverageSummaryTool',
45
+ 'CoverageTotalsTool',
46
+ 'UncoveredLinesTool',
47
+ 'CoverageTableTool',
48
+ 'HelpTool',
49
+ 'VersionTool'
50
+ )
51
+ end
52
+
53
+ describe 'TOOLSET and TOOL_GUIDE consistency' do
54
+ it 'includes all tools documented in HelpTool TOOL_GUIDE' do
55
+ # Get tool classes from TOOLSET
56
+ toolset_classes = described_class::TOOLSET
57
+
58
+ # Get tool classes from TOOL_GUIDE
59
+ tool_guide_classes = CovLoupe::Tools::HelpTool::TOOL_GUIDE.map { |guide| guide[:tool] }
60
+
61
+ # Every tool in TOOL_GUIDE should be in TOOLSET
62
+ tool_guide_classes.each do |tool_class|
63
+ expect(toolset_classes).to include(tool_class),
64
+ "Expected TOOLSET to include #{tool_class.name}, but it was missing. " \
65
+ 'Add it to MCPServer::TOOLSET or remove from HelpTool::TOOL_GUIDE.'
66
+ end
67
+ end
68
+
69
+ it 'has corresponding TOOL_GUIDE entry for all tools (except HelpTool itself)' do
70
+ toolset_classes = described_class::TOOLSET
71
+ tool_guide_classes = CovLoupe::Tools::HelpTool::TOOL_GUIDE.map { |guide| guide[:tool] }
72
+
73
+ # Every tool in TOOLSET should be in TOOL_GUIDE (except HelpTool which documents itself)
74
+ toolset_classes.each do |tool_class|
75
+ # HelpTool doesn't need an entry about itself
76
+ next if tool_class == CovLoupe::Tools::HelpTool
77
+
78
+ expect(tool_guide_classes).to include(tool_class),
79
+ "Expected TOOL_GUIDE to document #{tool_class.name}, but it was missing. " \
80
+ 'Add documentation for this tool to HelpTool::TOOL_GUIDE.'
81
+ end
82
+ end
83
+
84
+ it 'registers the expected number of tools' do
85
+ expect(described_class::TOOLSET.length).to eq(10)
86
+ end
87
+
88
+ it 'registers all tool classes defined in CovLoupe::Tools module' do
89
+ # This test catches the bug where a tool file is created, required in
90
+ # cov_loupe.rb, but not added to MCPServer::TOOLSET.
91
+ #
92
+ # Get all classes in the Tools module that inherit from BaseTool
93
+ tool_classes = CovLoupe::Tools.constants
94
+ .map { |const_name| CovLoupe::Tools.const_get(const_name) }
95
+ .select { |const| const.is_a?(Class) && const < CovLoupe::BaseTool }
96
+
97
+ toolset_classes = described_class::TOOLSET
98
+
99
+ tool_classes.each do |tool_class|
100
+ expect(toolset_classes).to include(tool_class),
101
+ "Expected TOOLSET to include #{tool_class.name}, but it was missing. " \
102
+ 'The tool class exists in CovLoupe::Tools but is not registered in MCPServer::TOOLSET.'
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Array-driven test cases for comprehensive coverage
6
+ # Format: [argv, tty?, expected_result, description]
7
+ CLI_MODE_SCENARIOS = [
8
+ # Priority 1: --force-cli flag (highest priority)
9
+ [['--force-cli'], false, true, '--force-cli with piped input'],
10
+ [['--force-cli', '--format', 'json'], false, true, '--force-cli with other flags'],
11
+
12
+ # Priority 2: Valid subcommands (must be first arg)
13
+ [['list'], false, true, 'list subcommand'],
14
+ [['summary', 'lib/foo.rb'], false, true, 'summary with path'],
15
+ [['version'], false, true, 'version subcommand'],
16
+ [['total'], false, true, 'total subcommand'],
17
+ [['list', '--format', 'json'], false, true, 'subcommand with trailing flags'],
18
+
19
+ # Priority 3: Invalid subcommand attempts (must be first non-flag arg)
20
+ [['invalid-command'], false, true, 'invalid subcommand (shows error)'],
21
+ [['lib/foo.rb'], false, true, 'file path (shows error)'],
22
+
23
+ # Priority 4: TTY determines mode when no subcommand/force-cli
24
+ [[], true, true, 'empty args with TTY'],
25
+ [[], false, false, 'empty args with piped input'],
26
+ [['--format', 'json'], true, true, 'flags only with TTY'],
27
+ [['--format', 'json'], false, false, 'flags only with piped input'],
28
+ [['-r', 'foo', '--format', 'json'], false, false, 'multiple flags with piped input'],
29
+
30
+ # Edge cases: flags before subcommands should now be detected as CLI mode
31
+ [['--format', 'json', 'list'], false, true, 'flag first = CLI mode'],
32
+ [['-r', 'foo', 'summary'], false, true, 'option first = CLI mode'],
33
+ ].freeze
34
+
35
+ # Simpler test cases for the inverse method
36
+ MCP_SCENARIOS = [
37
+ [[], false, true, 'piped input, no args'],
38
+ [['--format', 'json'], false, true, 'piped input with flags'],
39
+ [[], true, false, 'TTY, no args'],
40
+ [['--force-cli'], false, false, '--force-cli flag'],
41
+ [['list'], false, false, 'subcommand'],
42
+ ].freeze
43
+
44
+ RSpec.describe CovLoupe::ModeDetector do
45
+ describe '.cli_mode?' do
46
+ CLI_MODE_SCENARIOS.each do |argv, is_tty, expected, description|
47
+ it "#{expected ? 'CLI' : 'MCP'}: #{description}" do
48
+ stdin = double('stdin', tty?: is_tty)
49
+ result = described_class.cli_mode?(argv, stdin: stdin)
50
+ expect(result).to be(expected),
51
+ "Expected cli_mode?(#{argv.inspect}, tty: #{is_tty}) to be #{expected}, got #{result}"
52
+ end
53
+ end
54
+
55
+ # Test all subcommands dynamically
56
+ context 'with all valid subcommands' do
57
+ CovLoupe::ModeDetector::SUBCOMMANDS.each do |subcommand|
58
+ it "CLI mode for '#{subcommand}' (no TTY)" do
59
+ stdin = double('stdin', tty?: false)
60
+ expect(described_class.cli_mode?([subcommand], stdin: stdin)).to be true
61
+ end
62
+ end
63
+ end
64
+
65
+ it 'uses STDIN by default when no stdin parameter given' do
66
+ allow($stdin).to receive(:tty?).and_return(true)
67
+ expect(described_class.cli_mode?([])).to be true
68
+ end
69
+ end
70
+
71
+ describe '.mcp_server_mode?' do
72
+ MCP_SCENARIOS.each do |argv, is_tty, expected, description|
73
+ it "#{expected ? 'MCP' : 'CLI'}: #{description}" do
74
+ stdin = double('stdin', tty?: is_tty)
75
+ expect(described_class.mcp_server_mode?(argv, stdin: stdin)).to be expected
76
+ end
77
+ end
78
+
79
+ it 'is the logical inverse of cli_mode?' do
80
+ [[[], true], [[], false], [['list'], false]].each do |argv, is_tty|
81
+ stdin = double('stdin', tty?: is_tty)
82
+ cli = described_class.cli_mode?(argv, stdin: stdin)
83
+ mcp = described_class.mcp_server_mode?(argv, stdin: stdin)
84
+ expect(mcp).to eq(!cli)
85
+ end
86
+ end
87
+ end
88
+
89
+ describe 'priority order' do
90
+ let(:stdin) { double('stdin', tty?: false) }
91
+
92
+ it '1. --force-cli overrides everything' do
93
+ expect(described_class.cli_mode?(['--force-cli'], stdin: stdin)).to be true
94
+ end
95
+
96
+ it '2. subcommand (first arg) overrides TTY' do
97
+ expect(described_class.cli_mode?(['list'], stdin: stdin)).to be true
98
+ end
99
+
100
+ it '3. invalid first arg (not flag) triggers CLI' do
101
+ expect(described_class.cli_mode?(['invalid'], stdin: stdin)).to be true
102
+ end
103
+
104
+ it '4. TTY is checked last (when first arg is flag or empty)' do
105
+ tty = double('stdin', tty?: true)
106
+ no_tty = double('stdin', tty?: false)
107
+
108
+ expect(described_class.cli_mode?([], stdin: tty)).to be true
109
+ expect(described_class.cli_mode?([], stdin: no_tty)).to be false
110
+ end
111
+ end
112
+
113
+ describe 'consistency checks' do
114
+ it 'SUBCOMMANDS matches CoverageCLI' do
115
+ expect(CovLoupe::ModeDetector::SUBCOMMANDS).to eq(CovLoupe::CoverageCLI::SUBCOMMANDS)
116
+ end
117
+
118
+ it 'all SUBCOMMANDS are lowercase without dashes' do
119
+ CovLoupe::ModeDetector::SUBCOMMANDS.each do |cmd|
120
+ expect(cmd).to eq(cmd.downcase)
121
+ expect(cmd).not_to start_with('-')
122
+ end
123
+ end
124
+ end
125
+
126
+ describe 'regression tests for non-TTY environment' do
127
+ let(:stdin) { double('stdin', tty?: false) }
128
+
129
+ it 'chooses CLI mode for --help' do
130
+ expect(described_class.cli_mode?(['--help'], stdin: stdin)).to be true
131
+ end
132
+
133
+ it 'chooses CLI mode for -h' do
134
+ expect(described_class.cli_mode?(['-h'], stdin: stdin)).to be true
135
+ end
136
+
137
+ it 'chooses CLI mode for --version' do
138
+ expect(described_class.cli_mode?(['--version'], stdin: stdin)).to be true
139
+ end
140
+
141
+ it 'chooses CLI mode for -v' do
142
+ expect(described_class.cli_mode?(['-v'], stdin: stdin)).to be true
143
+ end
144
+
145
+ it 'chooses CLI mode for --json list' do
146
+ expect(described_class.cli_mode?(['--format', 'json', 'list'], stdin: stdin)).to be true
147
+ end
148
+
149
+ it 'chooses MCP mode for flags without a subcommand' do
150
+ expect(described_class.cli_mode?(['--format', 'json'], stdin: stdin)).to be false
151
+ end
152
+ end
153
+ end