cov-loupe 3.0.0 → 4.0.0.pre

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 (281) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +230 -0
  3. data/CLAUDE.md +5 -0
  4. data/CODE_OF_CONDUCT.md +62 -0
  5. data/CONTRIBUTING.md +102 -0
  6. data/GEMINI.md +5 -0
  7. data/README.md +154 -51
  8. data/RELEASE_NOTES.md +452 -0
  9. data/dev/images/cov-loupe-icon-lores.png +0 -0
  10. data/dev/images/cov-loupe-icon-square.png +0 -0
  11. data/dev/images/cov-loupe-icon.png +0 -0
  12. data/dev/images/cov-loupe-logo.png +0 -0
  13. data/dev/prompts/README.md +74 -0
  14. data/dev/prompts/archive/architectural-review-and-actions-prompt.md +53 -0
  15. data/dev/prompts/archive/investigate-and-report-issues-prompt.md +33 -0
  16. data/dev/prompts/archive/produce-action-items-prompt.md +25 -0
  17. data/dev/prompts/guidelines/ai-code-evaluator-guidelines.md +337 -0
  18. data/dev/prompts/improve/refactor-test-suite.md +18 -0
  19. data/dev/prompts/improve/simplify-code-logic.md +133 -0
  20. data/dev/prompts/improve/update-documentation.md +21 -0
  21. data/dev/prompts/review/comprehensive-codebase-review.md +176 -0
  22. data/dev/prompts/review/identify-action-items.md +143 -0
  23. data/dev/prompts/review/verify-code-changes.md +54 -0
  24. data/dev/prompts/validate/create-screencast-outline.md +234 -0
  25. data/dev/prompts/validate/test-documentation-examples.md +180 -0
  26. data/docs/QUICKSTART.md +63 -0
  27. data/docs/assets/images/cov-loupe-logo-lores.png +0 -0
  28. data/docs/assets/images/cov-loupe-logo.png +0 -0
  29. data/docs/assets/images/favicon.png +0 -0
  30. data/docs/assets/stylesheets/branding.css +16 -0
  31. data/docs/assets/stylesheets/extra.css +15 -0
  32. data/docs/code_of_conduct.md +1 -0
  33. data/docs/contributing.md +1 -0
  34. data/docs/dev/ARCHITECTURE.md +56 -11
  35. data/docs/dev/DEVELOPMENT.md +116 -12
  36. data/docs/dev/FUTURE_ENHANCEMENTS.md +14 -0
  37. data/docs/dev/README.md +3 -2
  38. data/docs/dev/RELEASING.md +2 -0
  39. data/docs/dev/arch-decisions/README.md +10 -7
  40. data/docs/dev/arch-decisions/application-architecture.md +259 -0
  41. data/docs/dev/arch-decisions/coverage-data-quality.md +193 -0
  42. data/docs/dev/arch-decisions/output-character-mode.md +217 -0
  43. data/docs/dev/arch-decisions/path-resolution.md +90 -0
  44. data/docs/dev/arch-decisions/{004-x-arch-decision.md → policy-validation.md} +32 -28
  45. data/docs/dev/arch-decisions/{005-x-arch-decision.md → simplecov-integration.md} +47 -44
  46. data/docs/dev/presentations/cov-loupe-presentation.md +15 -13
  47. data/docs/examples/mcp-inputs.md +3 -0
  48. data/docs/examples/prompts.md +3 -0
  49. data/docs/examples/success_predicates.md +3 -0
  50. data/docs/fixtures/demo_project/.resultset.json +170 -0
  51. data/docs/fixtures/demo_project/README.md +6 -0
  52. data/docs/fixtures/demo_project/app/controllers/admin/audit_logs_controller.rb +19 -0
  53. data/docs/fixtures/demo_project/app/controllers/orders_controller.rb +26 -0
  54. data/docs/fixtures/demo_project/app/models/order.rb +20 -0
  55. data/docs/fixtures/demo_project/app/models/user.rb +19 -0
  56. data/docs/fixtures/demo_project/lib/api/client.rb +22 -0
  57. data/docs/fixtures/demo_project/lib/ops/jobs/cleanup_job.rb +16 -0
  58. data/docs/fixtures/demo_project/lib/ops/jobs/report_job.rb +17 -0
  59. data/docs/fixtures/demo_project/lib/payments/processor.rb +15 -0
  60. data/docs/fixtures/demo_project/lib/payments/refund_service.rb +15 -0
  61. data/docs/fixtures/demo_project/lib/payments/reporting/exporter.rb +16 -0
  62. data/docs/index.md +1 -0
  63. data/docs/license.md +3 -0
  64. data/docs/release_notes.md +3 -0
  65. data/docs/user/ADVANCED_USAGE.md +208 -115
  66. data/docs/user/CLI_FALLBACK_FOR_LLMS.md +2 -0
  67. data/docs/user/CLI_USAGE.md +276 -101
  68. data/docs/user/ERROR_HANDLING.md +4 -4
  69. data/docs/user/EXAMPLES.md +121 -128
  70. data/docs/user/INSTALLATION.md +9 -28
  71. data/docs/user/LIBRARY_API.md +227 -122
  72. data/docs/user/MCP_INTEGRATION.md +114 -203
  73. data/docs/user/README.md +5 -1
  74. data/docs/user/TROUBLESHOOTING.md +49 -27
  75. data/docs/user/installing-a-prelease-version-of-covloupe.md +43 -0
  76. data/docs/user/{V2-BREAKING-CHANGES.md → migrations/MIGRATING_TO_V2.md} +62 -72
  77. data/docs/user/migrations/MIGRATING_TO_V3.md +72 -0
  78. data/docs/user/migrations/MIGRATING_TO_V4.md +591 -0
  79. data/docs/user/migrations/README.md +22 -0
  80. data/docs/user/prompts/README.md +9 -0
  81. data/docs/user/prompts/non-web-coverage-analysis-prompt.md +103 -0
  82. data/docs/user/prompts/rails-coverage-analysis-prompt.md +94 -0
  83. data/docs/user/prompts/use-cli-not-mcp-prompt.md +53 -0
  84. data/examples/cli_demo.sh +77 -0
  85. data/examples/filter_and_table_demo-output.md +114 -0
  86. data/examples/filter_and_table_demo.rb +174 -0
  87. data/examples/fixtures/demo_project/coverage/.resultset.json +10 -0
  88. data/examples/mcp-inputs/README.md +66 -0
  89. data/examples/mcp-inputs/coverage_detailed.json +1 -0
  90. data/examples/mcp-inputs/coverage_raw.json +1 -0
  91. data/examples/mcp-inputs/coverage_summary.json +1 -0
  92. data/examples/mcp-inputs/list.json +1 -0
  93. data/examples/mcp-inputs/uncovered_lines.json +1 -0
  94. data/examples/prompts/README.md +27 -0
  95. data/examples/prompts/custom_resultset.txt +2 -0
  96. data/examples/prompts/detailed_with_source.txt +2 -0
  97. data/examples/prompts/list_lowest.txt +2 -0
  98. data/examples/prompts/summary.txt +2 -0
  99. data/examples/prompts/uncovered.txt +2 -0
  100. data/examples/success_predicates/README.md +198 -0
  101. data/examples/success_predicates/all_files_above_threshold_predicate.rb +21 -0
  102. data/examples/success_predicates/directory_specific_thresholds_predicate.rb +30 -0
  103. data/examples/success_predicates/project_coverage_minimum_predicate.rb +6 -0
  104. data/lib/cov_loupe/base_tool.rb +229 -20
  105. data/lib/cov_loupe/cli.rb +132 -23
  106. data/lib/cov_loupe/commands/base_command.rb +25 -6
  107. data/lib/cov_loupe/commands/command_factory.rb +0 -1
  108. data/lib/cov_loupe/commands/detailed_command.rb +10 -5
  109. data/lib/cov_loupe/commands/list_command.rb +2 -1
  110. data/lib/cov_loupe/commands/raw_command.rb +7 -5
  111. data/lib/cov_loupe/commands/summary_command.rb +12 -7
  112. data/lib/cov_loupe/commands/totals_command.rb +74 -10
  113. data/lib/cov_loupe/commands/uncovered_command.rb +7 -5
  114. data/lib/cov_loupe/commands/validate_command.rb +11 -3
  115. data/lib/cov_loupe/commands/version_command.rb +6 -4
  116. data/lib/cov_loupe/{app_config.rb → config/app_config.rb} +13 -5
  117. data/lib/cov_loupe/config/app_context.rb +43 -0
  118. data/lib/cov_loupe/config/boolean_type.rb +91 -0
  119. data/lib/cov_loupe/config/logger.rb +92 -0
  120. data/lib/cov_loupe/{option_normalizers.rb → config/option_normalizers.rb} +55 -24
  121. data/lib/cov_loupe/{option_parser_builder.rb → config/option_parser_builder.rb} +46 -24
  122. data/lib/cov_loupe/coverage/coverage_calculator.rb +53 -0
  123. data/lib/cov_loupe/coverage/coverage_reporter.rb +63 -0
  124. data/lib/cov_loupe/coverage/coverage_table_formatter.rb +133 -0
  125. data/lib/cov_loupe/{error_handler.rb → errors/error_handler.rb} +21 -33
  126. data/lib/cov_loupe/{errors.rb → errors/errors.rb} +48 -71
  127. data/lib/cov_loupe/formatters/formatters.rb +75 -0
  128. data/lib/cov_loupe/formatters/source_formatter.rb +18 -7
  129. data/lib/cov_loupe/formatters/table_formatter.rb +80 -0
  130. data/lib/cov_loupe/loaders/all.rb +15 -0
  131. data/lib/cov_loupe/loaders/all_cli.rb +10 -0
  132. data/lib/cov_loupe/loaders/all_mcp.rb +23 -0
  133. data/lib/cov_loupe/loaders/resultset_loader.rb +147 -0
  134. data/lib/cov_loupe/mcp_server.rb +3 -2
  135. data/lib/cov_loupe/model/model.rb +520 -0
  136. data/lib/cov_loupe/model/model_data.rb +13 -0
  137. data/lib/cov_loupe/model/model_data_cache.rb +116 -0
  138. data/lib/cov_loupe/option_parsers/env_options_parser.rb +17 -6
  139. data/lib/cov_loupe/option_parsers/error_helper.rb +16 -10
  140. data/lib/cov_loupe/output_chars.rb +192 -0
  141. data/lib/cov_loupe/paths/glob_utils.rb +100 -0
  142. data/lib/cov_loupe/{path_relativizer.rb → paths/path_relativizer.rb} +5 -13
  143. data/lib/cov_loupe/paths/path_utils.rb +265 -0
  144. data/lib/cov_loupe/paths/volume_case_sensitivity.rb +173 -0
  145. data/lib/cov_loupe/presenters/base_coverage_presenter.rb +9 -13
  146. data/lib/cov_loupe/presenters/coverage_payload_presenter.rb +21 -0
  147. data/lib/cov_loupe/presenters/payload_caching.rb +23 -0
  148. data/lib/cov_loupe/presenters/project_coverage_presenter.rb +73 -21
  149. data/lib/cov_loupe/presenters/project_totals_presenter.rb +16 -10
  150. data/lib/cov_loupe/repositories/coverage_repository.rb +149 -0
  151. data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +90 -76
  152. data/lib/cov_loupe/resolvers/{resolver_factory.rb → resolver_helpers.rb} +6 -5
  153. data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +40 -12
  154. data/lib/cov_loupe/scripts/command_execution.rb +113 -0
  155. data/lib/cov_loupe/scripts/latest_ci_status.rb +97 -0
  156. data/lib/cov_loupe/scripts/pre_release_check.rb +164 -0
  157. data/lib/cov_loupe/scripts/setup_doc_server.rb +23 -0
  158. data/lib/cov_loupe/scripts/start_doc_server.rb +24 -0
  159. data/lib/cov_loupe/staleness/stale_status.rb +23 -0
  160. data/lib/cov_loupe/staleness/staleness_checker.rb +328 -0
  161. data/lib/cov_loupe/staleness/staleness_message_formatter.rb +91 -0
  162. data/lib/cov_loupe/tools/coverage_detailed_tool.rb +14 -15
  163. data/lib/cov_loupe/tools/coverage_raw_tool.rb +14 -14
  164. data/lib/cov_loupe/tools/coverage_summary_tool.rb +16 -16
  165. data/lib/cov_loupe/tools/coverage_table_tool.rb +139 -21
  166. data/lib/cov_loupe/tools/coverage_totals_tool.rb +31 -13
  167. data/lib/cov_loupe/tools/help_tool.rb +16 -20
  168. data/lib/cov_loupe/tools/list_tool.rb +65 -0
  169. data/lib/cov_loupe/tools/uncovered_lines_tool.rb +14 -14
  170. data/lib/cov_loupe/tools/validate_tool.rb +18 -24
  171. data/lib/cov_loupe/tools/version_tool.rb +8 -3
  172. data/lib/cov_loupe/version.rb +1 -1
  173. data/lib/cov_loupe.rb +83 -55
  174. metadata +184 -154
  175. data/docs/dev/BRANCH_ONLY_COVERAGE.md +0 -158
  176. data/docs/dev/arch-decisions/001-x-arch-decision.md +0 -95
  177. data/docs/dev/arch-decisions/002-x-arch-decision.md +0 -159
  178. data/docs/dev/arch-decisions/003-x-arch-decision.md +0 -165
  179. data/lib/cov_loupe/app_context.rb +0 -26
  180. data/lib/cov_loupe/constants.rb +0 -22
  181. data/lib/cov_loupe/coverage_reporter.rb +0 -31
  182. data/lib/cov_loupe/formatters.rb +0 -51
  183. data/lib/cov_loupe/mode_detector.rb +0 -56
  184. data/lib/cov_loupe/model.rb +0 -339
  185. data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +0 -14
  186. data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +0 -14
  187. data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +0 -14
  188. data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +0 -14
  189. data/lib/cov_loupe/resultset_loader.rb +0 -131
  190. data/lib/cov_loupe/staleness_checker.rb +0 -247
  191. data/lib/cov_loupe/table_formatter.rb +0 -64
  192. data/lib/cov_loupe/tools/all_files_coverage_tool.rb +0 -51
  193. data/lib/cov_loupe/util.rb +0 -88
  194. data/spec/MCP_INTEGRATION_TESTS_README.md +0 -111
  195. data/spec/TIMESTAMPS.md +0 -48
  196. data/spec/all_files_coverage_tool_spec.rb +0 -53
  197. data/spec/app_config_spec.rb +0 -142
  198. data/spec/base_tool_spec.rb +0 -62
  199. data/spec/cli/show_default_report_spec.rb +0 -33
  200. data/spec/cli_enumerated_options_spec.rb +0 -90
  201. data/spec/cli_error_spec.rb +0 -184
  202. data/spec/cli_format_spec.rb +0 -123
  203. data/spec/cli_json_options_spec.rb +0 -50
  204. data/spec/cli_source_spec.rb +0 -44
  205. data/spec/cli_spec.rb +0 -192
  206. data/spec/cli_table_spec.rb +0 -28
  207. data/spec/cli_usage_spec.rb +0 -42
  208. data/spec/commands/base_command_spec.rb +0 -107
  209. data/spec/commands/command_factory_spec.rb +0 -76
  210. data/spec/commands/detailed_command_spec.rb +0 -34
  211. data/spec/commands/list_command_spec.rb +0 -28
  212. data/spec/commands/raw_command_spec.rb +0 -69
  213. data/spec/commands/summary_command_spec.rb +0 -34
  214. data/spec/commands/totals_command_spec.rb +0 -34
  215. data/spec/commands/uncovered_command_spec.rb +0 -55
  216. data/spec/commands/validate_command_spec.rb +0 -213
  217. data/spec/commands/version_command_spec.rb +0 -38
  218. data/spec/constants_spec.rb +0 -61
  219. data/spec/cov_loupe/formatters/source_formatter_spec.rb +0 -267
  220. data/spec/cov_loupe/formatters_spec.rb +0 -76
  221. data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +0 -79
  222. data/spec/cov_loupe_model_spec.rb +0 -454
  223. data/spec/cov_loupe_module_spec.rb +0 -37
  224. data/spec/cov_loupe_opts_spec.rb +0 -185
  225. data/spec/coverage_reporter_spec.rb +0 -102
  226. data/spec/coverage_table_tool_spec.rb +0 -59
  227. data/spec/coverage_totals_tool_spec.rb +0 -37
  228. data/spec/error_handler_spec.rb +0 -197
  229. data/spec/error_mode_spec.rb +0 -139
  230. data/spec/errors_edge_cases_spec.rb +0 -312
  231. data/spec/errors_stale_spec.rb +0 -83
  232. data/spec/file_based_mcp_tools_spec.rb +0 -99
  233. data/spec/help_tool_spec.rb +0 -26
  234. data/spec/integration_spec.rb +0 -789
  235. data/spec/logging_fallback_spec.rb +0 -128
  236. data/spec/mcp_logging_spec.rb +0 -44
  237. data/spec/mcp_server_integration_spec.rb +0 -23
  238. data/spec/mcp_server_spec.rb +0 -106
  239. data/spec/mode_detector_spec.rb +0 -153
  240. data/spec/model_error_handling_spec.rb +0 -269
  241. data/spec/model_staleness_spec.rb +0 -79
  242. data/spec/option_normalizers_spec.rb +0 -203
  243. data/spec/option_parsers/env_options_parser_spec.rb +0 -221
  244. data/spec/option_parsers/error_helper_spec.rb +0 -222
  245. data/spec/path_relativizer_spec.rb +0 -98
  246. data/spec/presenters/coverage_detailed_presenter_spec.rb +0 -19
  247. data/spec/presenters/coverage_raw_presenter_spec.rb +0 -15
  248. data/spec/presenters/coverage_summary_presenter_spec.rb +0 -15
  249. data/spec/presenters/coverage_uncovered_presenter_spec.rb +0 -16
  250. data/spec/presenters/project_coverage_presenter_spec.rb +0 -87
  251. data/spec/presenters/project_totals_presenter_spec.rb +0 -144
  252. data/spec/resolvers/coverage_line_resolver_spec.rb +0 -282
  253. data/spec/resolvers/resolver_factory_spec.rb +0 -61
  254. data/spec/resolvers/resultset_path_resolver_spec.rb +0 -60
  255. data/spec/resultset_loader_spec.rb +0 -167
  256. data/spec/shared_examples/README.md +0 -115
  257. data/spec/shared_examples/coverage_presenter_examples.rb +0 -66
  258. data/spec/shared_examples/file_based_mcp_tools.rb +0 -179
  259. data/spec/shared_examples/formatted_command_examples.rb +0 -64
  260. data/spec/shared_examples/mcp_tool_text_json_response.rb +0 -16
  261. data/spec/spec_helper.rb +0 -127
  262. data/spec/staleness_checker_spec.rb +0 -374
  263. data/spec/staleness_more_spec.rb +0 -42
  264. data/spec/support/cli_helpers.rb +0 -22
  265. data/spec/support/control_flow_helpers.rb +0 -20
  266. data/spec/support/fake_mcp.rb +0 -40
  267. data/spec/support/io_helpers.rb +0 -29
  268. data/spec/support/mcp_helpers.rb +0 -35
  269. data/spec/support/mcp_runner.rb +0 -66
  270. data/spec/support/mocking_helpers.rb +0 -30
  271. data/spec/table_format_spec.rb +0 -70
  272. data/spec/tools/validate_tool_spec.rb +0 -132
  273. data/spec/tools_error_handling_spec.rb +0 -130
  274. data/spec/util_spec.rb +0 -154
  275. data/spec/version_spec.rb +0 -123
  276. data/spec/version_tool_spec.rb +0 -141
  277. /data/{spec/fixtures/project1 → examples/fixtures/demo_project}/lib/bar.rb +0 -0
  278. /data/{spec/fixtures/project1 → examples/fixtures/demo_project}/lib/foo.rb +0 -0
  279. /data/lib/cov_loupe/{config_parser.rb → config/config_parser.rb} +0 -0
  280. /data/lib/cov_loupe/{predicate_evaluator.rb → config/predicate_evaluator.rb} +0 -0
  281. /data/lib/cov_loupe/{error_handler_factory.rb → errors/error_handler_factory.rb} +0 -0
@@ -1,21 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../output_chars'
4
+
3
5
  module CovLoupe
4
6
  module OptionParsers
5
7
  class ErrorHelper
6
- SUBCOMMANDS = %w[list summary raw uncovered detailed totals version].freeze
7
-
8
- def initialize(subcommands = SUBCOMMANDS)
8
+ def initialize(subcommands)
9
9
  @subcommands = subcommands
10
10
  end
11
11
 
12
- def handle_option_parser_error(error, argv: [], usage_hint: "Run '#{program_name} --help' for usage information.")
13
- message = error.message.to_s
12
+ def handle_option_parser_error(error, argv: [], output_chars: :default,
13
+ usage_hint: "Run '#{program_name} --help' for usage information.")
14
+ message = convert_text(error.message.to_s, output_chars)
14
15
  # Suggest a subcommand when an invalid option matches a known subcommand
15
16
  option = extract_invalid_option(message)
16
17
 
17
18
  if option&.start_with?('--') && @subcommands.include?(option[2..])
18
- suggest_subcommand(option)
19
+ suggest_subcommand(option, output_chars)
19
20
  else
20
21
  # Generic message from OptionParser
21
22
  warn "Error: #{message}"
@@ -34,10 +35,16 @@ module CovLoupe
34
35
  nil
35
36
  end
36
37
 
37
- private def suggest_subcommand(option)
38
+ private def suggest_subcommand(option, output_chars)
38
39
  subcommand = option[2..]
39
- warn "Error: '#{option}' is not a valid option. Did you mean the '#{subcommand}' subcommand?"
40
- warn "Try: #{program_name} #{subcommand} [args]"
40
+ msg1 = convert_text("Error: '#{option}' is not a valid option. Did you mean the '#{subcommand}' subcommand?", output_chars)
41
+ msg2 = convert_text("Try: #{program_name} #{subcommand} [args]", output_chars)
42
+ warn msg1
43
+ warn msg2
44
+ end
45
+
46
+ private def convert_text(text, output_chars)
47
+ OutputChars.convert(text, output_chars)
41
48
  end
42
49
 
43
50
  private def build_enum_value_hint(argv)
@@ -92,7 +99,6 @@ module CovLoupe
92
99
 
93
100
  private def enumerated_option_rules
94
101
  [
95
- { switches: ['-S', '--staleness'], values: %w[off o error e], display: 'o[ff]|e[rror]' },
96
102
  { switches: ['-s', '--source'], values: %w[full f uncovered u],
97
103
  display: 'f[ull]|u[ncovered]' },
98
104
  { switches: ['--error-mode'], values: %w[off o log l debug d],
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ # Central module for controlling ASCII vs Unicode (fancy) output.
5
+ #
6
+ # This module provides:
7
+ # - Mode resolution (:default -> :fancy or :ascii based on output encoding)
8
+ # - Character sets for table borders (Unicode box-drawing vs ASCII)
9
+ # - Text conversion for ensuring ASCII-only output when needed
10
+ #
11
+ # Usage:
12
+ # mode = OutputChars.resolve_mode(:default) # => :fancy or :ascii
13
+ # charset = OutputChars.charset_for(mode) # => hash of border chars
14
+ # text = OutputChars.convert("café", mode) # => "caf?" in :ascii mode
15
+ module OutputChars
16
+ # Valid output character modes
17
+ MODES = %i[default fancy ascii].freeze
18
+
19
+ # Unicode box-drawing characters (fancy mode)
20
+ UNICODE_CHARSET = {
21
+ top_left: "\u250C",
22
+ top_right: "\u2510",
23
+ bottom_left: "\u2514",
24
+ bottom_right: "\u2518",
25
+ horizontal: "\u2500",
26
+ vertical: "\u2502",
27
+ top_tee: "\u252C",
28
+ bottom_tee: "\u2534",
29
+ left_tee: "\u251C",
30
+ right_tee: "\u2524",
31
+ cross: "\u253C"
32
+ }.freeze
33
+
34
+ # ASCII characters for table borders (ascii mode)
35
+ ASCII_CHARSET = {
36
+ top_left: '+',
37
+ top_right: '+',
38
+ bottom_left: '+',
39
+ bottom_right: '+',
40
+ horizontal: '-',
41
+ vertical: '|',
42
+ top_tee: '+',
43
+ bottom_tee: '+',
44
+ left_tee: '+',
45
+ right_tee: '+',
46
+ cross: '+'
47
+ }.freeze
48
+
49
+ class << self
50
+ # Resolves :default mode to :fancy or :ascii based on output encoding.
51
+ #
52
+ # @param mode [Symbol] One of :default, :fancy, or :ascii
53
+ # @param io [IO] The output stream to check encoding for (default: $stdout)
54
+ # @return [Symbol] :fancy or :ascii
55
+ def resolve_mode(mode, io: $stdout)
56
+ case mode
57
+ when :fancy then :fancy
58
+ when :ascii then :ascii
59
+ when :default then default_mode_for(io)
60
+ else
61
+ raise ArgumentError, "Invalid output_chars mode: #{mode.inspect}"
62
+ end
63
+ end
64
+
65
+ # Returns the character set hash for the given mode.
66
+ #
67
+ # @param mode [Symbol] :fancy or :ascii (use resolve_mode first if :default)
68
+ # @return [Hash] Character set with keys like :top_left, :horizontal, etc.
69
+ def charset_for(mode)
70
+ case mode
71
+ when :fancy then UNICODE_CHARSET
72
+ when :ascii then ASCII_CHARSET
73
+ when :default then charset_for(resolve_mode(:default))
74
+ else
75
+ raise ArgumentError, "Invalid output_chars mode: #{mode.inspect}"
76
+ end
77
+ end
78
+
79
+ # Converts text to ASCII-only when in :ascii mode.
80
+ # In :fancy mode, returns text unchanged.
81
+ #
82
+ # @param text [String] The text to convert
83
+ # @param mode [Symbol] :fancy, :ascii, or :default
84
+ # @param io [IO] The output stream for resolving :default (default: $stdout)
85
+ # @return [String] Original text (:fancy) or ASCII-only text (:ascii)
86
+ def convert(text, mode, io: $stdout)
87
+ return text if text.nil?
88
+
89
+ resolved = mode == :default ? resolve_mode(mode, io: io) : mode
90
+ return text if resolved == :fancy
91
+
92
+ to_ascii(text)
93
+ end
94
+
95
+ # Checks if output should be ASCII-only for the given mode.
96
+ #
97
+ # @param mode [Symbol] :fancy, :ascii, or :default
98
+ # @param io [IO] The output stream for resolving :default (default: $stdout)
99
+ # @return [Boolean] true if ASCII-only output is required
100
+ def ascii_mode?(mode, io: $stdout)
101
+ resolved = mode == :default ? resolve_mode(mode, io: io) : mode
102
+ resolved == :ascii
103
+ end
104
+
105
+ private def default_mode_for(io)
106
+ encoding = io.respond_to?(:external_encoding) ? io.external_encoding : nil
107
+ encoding ||= Encoding.default_external
108
+
109
+ utf8_compatible?(encoding) ? :fancy : :ascii
110
+ end
111
+
112
+ # Checks if the encoding is UTF-8 compatible.
113
+ #
114
+ # @param encoding [Encoding, nil] The encoding to check
115
+ # @return [Boolean] true if UTF-8 compatible
116
+ private def utf8_compatible?(encoding)
117
+ return false if encoding.nil?
118
+
119
+ encoding_name = encoding.name.upcase
120
+ encoding_name.include?('UTF-8') || encoding_name == 'UTF8'
121
+ end
122
+
123
+ # Converts a string to ASCII-only, replacing non-ASCII characters.
124
+ #
125
+ # Uses transliteration for common characters where sensible,
126
+ # falls back to '?' for others. This is a best-effort conversion
127
+ # that prioritizes readability over exactness.
128
+ #
129
+ # @param text [String] The text to convert
130
+ # @return [String] ASCII-only text
131
+ private def to_ascii(text)
132
+ text.each_char.map do |char|
133
+ if char.ord < 128
134
+ char
135
+ else
136
+ transliterate(char)
137
+ end
138
+ end.join
139
+ end
140
+
141
+ # Transliterates a single non-ASCII character to ASCII.
142
+ #
143
+ # @param char [String] Single character to transliterate
144
+ # @return [String] ASCII replacement (may be multiple characters)
145
+ private def transliterate(char)
146
+ # Common transliterations for readability
147
+ TRANSLITERATIONS[char] || '?'
148
+ end
149
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
150
+ end
151
+
152
+ # Common character transliterations to ASCII.
153
+ # This covers common accented characters and symbols users might encounter.
154
+ # Box-drawing characters are not included here; they're handled by charset_for.
155
+ TRANSLITERATIONS = {
156
+ # Accented vowels
157
+ 'á' => 'a', 'à' => 'a', 'â' => 'a', 'ä' => 'a', 'ã' => 'a', 'å' => 'a',
158
+ 'Á' => 'A', 'À' => 'A', 'Â' => 'A', 'Ä' => 'A', 'Ã' => 'A', 'Å' => 'A',
159
+ 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
160
+ 'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
161
+ 'í' => 'i', 'ì' => 'i', 'î' => 'i', 'ï' => 'i',
162
+ 'Í' => 'I', 'Ì' => 'I', 'Î' => 'I', 'Ï' => 'I',
163
+ 'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'ö' => 'o', 'õ' => 'o', 'ø' => 'o',
164
+ 'Ó' => 'O', 'Ò' => 'O', 'Ô' => 'O', 'Ö' => 'O', 'Õ' => 'O', 'Ø' => 'O',
165
+ 'ú' => 'u', 'ù' => 'u', 'û' => 'u', 'ü' => 'u',
166
+ 'Ú' => 'U', 'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U',
167
+ # Other common characters
168
+ 'ñ' => 'n', 'Ñ' => 'N',
169
+ 'ç' => 'c', 'Ç' => 'C',
170
+ 'ß' => 'ss',
171
+ 'æ' => 'ae', 'Æ' => 'AE',
172
+ 'œ' => 'oe', 'Œ' => 'OE',
173
+ # Common symbols
174
+ "\u20AC" => 'EUR', "\u00A3" => 'GBP', "\u00A5" => 'JPY',
175
+ "\u00A9" => '(c)', "\u00AE" => '(R)', "\u2122" => '(TM)',
176
+ "\u00B0" => 'deg',
177
+ "\u2026" => '...',
178
+ "\u2018" => "'", "\u2019" => "'", "\u201C" => '"', "\u201D" => '"',
179
+ "\u2013" => '-', "\u2014" => '--',
180
+ "\u00D7" => 'x', "\u00F7" => '/',
181
+ "\u2264" => '<=', "\u2265" => '>=', "\u2260" => '!=',
182
+ "\u2192" => '->', "\u2190" => '<-', "\u2194" => '<->',
183
+ "\u2713" => '[x]', "\u2717" => '[ ]', "\u2714" => '[x]', "\u2718" => '[ ]',
184
+ # Bullets and list markers
185
+ "\u2022" => '*', "\u25E6" => 'o', "\u25AA" => '-', "\u25B8" => '>',
186
+ # Box-drawing (for any stray usage outside tables)
187
+ "\u250C" => '+', "\u2510" => '+', "\u2514" => '+', "\u2518" => '+',
188
+ "\u2500" => '-', "\u2502" => '|',
189
+ "\u252C" => '+', "\u2534" => '+', "\u251C" => '+', "\u2524" => '+', "\u253C" => '+'
190
+ }.freeze
191
+ end
192
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'path_utils'
4
+
5
+ module CovLoupe
6
+ module GlobUtils
7
+ GLOB_MATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
8
+
9
+ # Returns a lambda that normalizes path separators for the current platform.
10
+ # On Windows, returns a lambda that converts backslashes to forward slashes.
11
+ # On Unix, returns a pass-through lambda.
12
+ # The lambda is memoized so platform detection only happens once.
13
+ # @return [Proc] lambda that takes a string and returns it normalized
14
+ module_function def fn_normalize_path_separators
15
+ @fn_normalize_path_separators ||= if CovLoupe.windows?
16
+ ->(str) { str.tr('\\', '/') }
17
+ else
18
+ ->(str) { str }
19
+ end
20
+ end
21
+
22
+ module_function def normalize_patterns(globs)
23
+ Array(globs).compact.map(&:to_s).reject(&:empty?)
24
+ end
25
+
26
+ # Converts a pattern to absolute path relative to a root.
27
+ # Handles both relative patterns ("lib/*.rb") and absolute ones ("/tmp/*.rb").
28
+ #
29
+ # @param pattern [String] glob pattern
30
+ # @param root [String] root directory path
31
+ # @return [String] absolute pattern
32
+ module_function def absolutize_pattern(pattern, root)
33
+ File.expand_path(pattern, root)
34
+ end
35
+
36
+ # Tests if a file path matches any of the given absolute glob patterns.
37
+ # Uses File.fnmatch? for pure string matching without filesystem access.
38
+ # Normalizes paths to forward slashes on Windows for cross-platform compatibility.
39
+ # Automatically handles case-insensitive filesystems by detecting volume case-sensitivity.
40
+ #
41
+ # @param abs_path [String] absolute file path to test
42
+ # @param patterns [Array<String>] absolute glob patterns
43
+ # @return [Boolean] true if the path matches at least one pattern
44
+ module_function def matches_any_pattern?(abs_path, patterns)
45
+ normalizer = fn_normalize_path_separators
46
+ normalized_path = normalizer.call(abs_path)
47
+
48
+ # Determine match flags based on volume case-sensitivity
49
+ # Find first existing parent directory to test volume properties
50
+ test_dir = abs_path
51
+ until File.directory?(test_dir)
52
+ parent = File.dirname(test_dir)
53
+ break if parent == test_dir # Reached root (works on Windows and Unix)
54
+
55
+ test_dir = parent
56
+ end
57
+
58
+ flags = GLOB_MATCH_FLAGS
59
+ begin
60
+ # Add case-insensitive matching for case-insensitive volumes
61
+ flags |= File::FNM_CASEFOLD unless PathUtils.volume_case_sensitive?(test_dir)
62
+ rescue SystemCallError, IOError
63
+ # If we can't detect case sensitivity, assume case-insensitive to be conservative
64
+ flags |= File::FNM_CASEFOLD
65
+ end
66
+
67
+ patterns.any? do |pattern|
68
+ normalized_pattern = normalizer.call(pattern)
69
+ File.fnmatch?(normalized_pattern, normalized_path, flags)
70
+ end
71
+ end
72
+
73
+ # Filters items where a key contains a file path matching the patterns.
74
+ #
75
+ # @param items [Array<Hash>] items to filter
76
+ # @param patterns [Array<String>] absolute glob patterns
77
+ # @param key [String] key in item hash containing the absolute file path
78
+ # @return [Array<Hash>] items whose file path matches at least one pattern
79
+ module_function def filter_by_pattern(items, patterns, key: 'file')
80
+ return items if patterns.nil? || patterns.empty?
81
+
82
+ items.select { |item| matches_any_pattern?(item[key], patterns) }
83
+ end
84
+
85
+ # Filters an array of absolute file paths by glob patterns.
86
+ # Handles normalization and absolutization of patterns internally.
87
+ #
88
+ # @param paths [Array<String>] absolute file paths to filter
89
+ # @param globs [Array<String>, String, nil] glob patterns (can be relative)
90
+ # @param root [String] root directory for resolving relative patterns
91
+ # @return [Array<String>] paths that match at least one pattern (or all if no patterns)
92
+ module_function def filter_paths(paths, globs, root:)
93
+ patterns = normalize_patterns(globs)
94
+ return paths if patterns.empty?
95
+
96
+ absolute_patterns = patterns.map { |p| absolutize_pattern(p, root) }
97
+ paths.select { |path| matches_any_pattern?(path, absolute_patterns) }
98
+ end
99
+ end
100
+ end
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'pathname'
4
+ require_relative 'path_utils'
4
5
 
5
6
  module CovLoupe
6
7
  # Utility object that converts configured path-bearing keys to forms
7
- # relative to the project root while leaving the original payload untouched.
8
+ # relative to a project root while leaving the original payload untouched.
8
9
  class PathRelativizer
9
10
  def initialize(root:, scalar_keys:, array_keys: [])
10
- @root = Pathname.new(File.absolute_path(root || '.'))
11
+ @root = Pathname.new(PathUtils.expand(root || '.'))
11
12
  @scalar_keys = Array(scalar_keys).map(&:to_s).freeze
12
13
  @array_keys = Array(array_keys).map(&:to_s).freeze
13
14
  end
@@ -22,13 +23,8 @@ module CovLoupe
22
23
  # @param path [String] file path (absolute or relative)
23
24
  # @return [String] relative path or original path on failure
24
25
  def relativize_path(path)
25
- root_str = @root.to_s
26
- abs = File.absolute_path(path, root_str)
27
- return path unless abs.start_with?(root_prefix(root_str)) || abs == root_str
28
-
29
- Pathname.new(abs).relative_path_from(@root).to_s
30
- rescue ArgumentError
31
- path
26
+ # PathUtils handles all the complexity automatically
27
+ PathUtils.relativize(path, @root.to_s)
32
28
  end
33
29
 
34
30
  private def deep_copy_and_relativize(obj)
@@ -56,9 +52,5 @@ module CovLoupe
56
52
  deep_copy_and_relativize(value)
57
53
  end
58
54
  end
59
-
60
- private def root_prefix(root_str)
61
- root_str.end_with?(File::SEPARATOR) ? root_str : root_str + File::SEPARATOR
62
- end
63
55
  end
64
56
  end
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative 'volume_case_sensitivity'
5
+
6
+ module CovLoupe
7
+ # Centralized path handling utilities providing consistent normalization,
8
+ # relativization, and absolutization across all components.
9
+ module PathUtils
10
+ # Platform detection - delegates to main CovLoupe module for testability
11
+ def self.windows?
12
+ CovLoupe.windows?
13
+ end
14
+
15
+ def self.windows_drive?
16
+ File.expand_path('.').match?(/^[A-Za-z]:/)
17
+ end
18
+
19
+ # Normalizes a path by handling:
20
+ # 1. Slash normalization (Windows backslashes -> forward slashes)
21
+ # 2. Case normalization (case-insensitive volumes)
22
+ # 3. Path cleaning (removing ., .., redundant separators)
23
+ #
24
+ # @param path [String, Pathname] path to normalize
25
+ # @param options [Hash] normalization options
26
+ # @option options [Boolean] :normalize_case (true on case-insensitive volumes)
27
+ # @option options [String] :root (nil) root directory for determining volume case-sensitivity
28
+ # @return [String] normalized path
29
+ def self.normalize(path, options = {})
30
+ return path if path.nil? || path.empty?
31
+
32
+ result = path.to_s
33
+
34
+ # Always normalize slashes on Windows (Pathname#cleanpath does this anyway)
35
+ result = result.tr('\\', '/') if windows?
36
+
37
+ # Handle case normalization for case-insensitive volumes
38
+ # If root is provided, derive case-sensitivity from root's volume
39
+ root = options[:root]
40
+ begin
41
+ default_normalize_case = if root
42
+ !VolumeCaseSensitivity.volume_case_sensitive?(root)
43
+ else
44
+ !VolumeCaseSensitivity.volume_case_sensitive?
45
+ end
46
+ rescue SystemCallError, IOError
47
+ # If we can't detect case sensitivity, assume case-insensitive to be conservative
48
+ default_normalize_case = true
49
+ end
50
+ if options.fetch(:normalize_case, default_normalize_case)
51
+ result = result.downcase
52
+ end
53
+
54
+ # Clean path components
55
+ Pathname.new(result).cleanpath.to_s
56
+ end
57
+
58
+ # Expands a path to absolute form, optionally relative to a base directory
59
+ #
60
+ # @param path [String] path to expand
61
+ # @param base [String, nil] base directory (defaults to current working directory)
62
+ # @return [String] absolute path
63
+ def self.expand(path, base = nil)
64
+ return path if path.nil? || path.empty?
65
+
66
+ # On Windows, only bypass File.expand_path if path already has a drive letter.
67
+ # Paths like "/foo" are considered absolute by absolute? but need File.expand_path
68
+ # to acquire the current drive letter (e.g., "C:/foo").
69
+ if absolute?(path) && (!windows? || path.match?(/^[A-Za-z]:/))
70
+ # Use Pathname#cleanpath to preserve case on Windows, as File.expand_path
71
+ # can sometimes canonicalize case for existing files.
72
+ Pathname.new(path).cleanpath.to_s
73
+ else
74
+ base ? File.expand_path(path, base) : File.expand_path(path)
75
+ end
76
+ end
77
+
78
+ # Converts an absolute path to a path relative to the given root
79
+ #
80
+ # @param path [String] absolute path to relativize
81
+ # @param root [String] root directory for relativization
82
+ # @return [String] relative path or original path if conversion fails
83
+ def self.relativize(path, root)
84
+ return path if path.nil? || path.empty? || root.nil? || root.empty?
85
+
86
+ # Only expand relative paths against root; absolute paths expand without base
87
+ abs_path = absolute?(path) ? expand(path) : expand(path, root)
88
+ abs_root = expand(root)
89
+
90
+ # Check if path is within root using normalized comparison
91
+ # Derive case-sensitivity from root's volume for accurate cross-volume handling
92
+ return path unless normalized_start_with?(abs_path, abs_root, root: abs_root)
93
+
94
+ # Normalize paths before calling relative_path_from to handle case-insensitive
95
+ # volumes and mixed separators. This ensures Pathname can correctly compute
96
+ # the relative path even when the input paths have different casings or separators.
97
+ # On case-insensitive volumes, normalize case as well so Pathname recognizes them as the same path.
98
+ # Derive case-sensitivity from root's volume
99
+ case_sensitive = begin
100
+ VolumeCaseSensitivity.volume_case_sensitive?(abs_root)
101
+ rescue SystemCallError, IOError
102
+ # If we can't detect case sensitivity, assume case-insensitive to be conservative
103
+ false
104
+ end
105
+ normalized_path = normalize(abs_path, normalize_case: !case_sensitive, root: abs_root)
106
+ normalized_root = normalize(abs_root, normalize_case: !case_sensitive, root: abs_root)
107
+
108
+ relative = Pathname.new(normalized_path)
109
+ .relative_path_from(Pathname.new(normalized_root))
110
+ .to_s
111
+
112
+ # Preserve original casing from abs_path by mapping normalized components back
113
+ if !case_sensitive && relative != '.'
114
+ preserve_original_casing(relative, abs_path, abs_root)
115
+ else
116
+ relative
117
+ end
118
+ rescue ArgumentError
119
+ # Path is on a different drive or cannot be made relative
120
+ path
121
+ end
122
+
123
+ # Checks if a path is absolute
124
+ #
125
+ # @param path [String] path to check
126
+ # @return [Boolean] true if path is absolute
127
+ def self.absolute?(path)
128
+ return false if path.nil? || path.empty?
129
+
130
+ # Check for Windows drive paths (C:/, D:/, etc.)
131
+ return true if path.match?(/^[A-Za-z]:[\/\\]/)
132
+
133
+ Pathname.new(path).absolute?
134
+ end
135
+
136
+ # Checks if a path is relative
137
+ #
138
+ # @param path [String] path to check
139
+ # @return [Boolean] true if path is relative
140
+ def self.relative?(path)
141
+ !absolute?(path)
142
+ end
143
+
144
+ # Checks if a path is within a given root directory
145
+ #
146
+ # @param path [String] path to check
147
+ # @param root [String] root directory
148
+ # @return [Boolean] true if path is within root
149
+ def self.within_root?(path, root)
150
+ return false if path.nil? || root.nil?
151
+
152
+ abs_path = expand(path)
153
+ abs_root = expand(root)
154
+
155
+ normalized_start_with?(abs_path, abs_root, root: abs_root)
156
+ end
157
+
158
+ # Extracts basename from a path, handling normalization
159
+ #
160
+ # @param path [String] path to extract basename from
161
+ # @param options [Hash] options passed to normalize
162
+ # @return [String] basename
163
+ def self.basename(path, options = {})
164
+ return '' if path.nil? || path.empty?
165
+
166
+ normalize(path, options).split('/').last
167
+ end
168
+
169
+ # Joins path components using platform-appropriate separators
170
+ #
171
+ # @param components [Array<String>] path components
172
+ # @return [String] joined path
173
+ def self.join(*components)
174
+ File.join(*components)
175
+ end
176
+
177
+ # Detects whether the volume at the given path is case-sensitive.
178
+ # Delegates to VolumeCaseSensitivity module for implementation.
179
+ #
180
+ # @param path [String, nil] directory path to test (defaults to current directory)
181
+ # @return [Boolean] true if case-sensitive, false if case-insensitive or on error
182
+ def self.volume_case_sensitive?(path = nil)
183
+ VolumeCaseSensitivity.volume_case_sensitive?(path)
184
+ end
185
+
186
+ # Clears the volume case sensitivity cache (useful for testing)
187
+ #
188
+ # @return [void]
189
+ def self.clear_volume_case_sensitivity_cache
190
+ VolumeCaseSensitivity.clear_cache
191
+ end
192
+
193
+ # Returns root path with trailing separator for prefix matching
194
+ #
195
+ # @param root [String] root path
196
+ # @return [String] root with trailing separator
197
+ def self.root_prefix(root)
198
+ return '' if root.nil? || root.empty?
199
+
200
+ root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}"
201
+ end
202
+
203
+ # Preserves original casing from the source path when creating a relative path
204
+ #
205
+ # @param relative_path [String] normalized relative path
206
+ # @param source_path [String] original source path with original casing
207
+ # @param root_path [String] root path
208
+ # @return [String] relative path with original casing preserved
209
+ def self.preserve_original_casing(relative_path, source_path, root_path)
210
+ # Split paths into components
211
+ relative_components = relative_path.split('/')
212
+ source_components = normalize(source_path, normalize_case: false, root: root_path).split('/')
213
+ root_components = normalize(root_path, normalize_case: false, root: root_path).split('/')
214
+
215
+ # Skip root components to get to the relative part
216
+ relative_start_index = root_components.length
217
+
218
+ # Map each normalized component back to its original casing
219
+ original_components = relative_components.map.with_index do |_component, index|
220
+ source_index = relative_start_index + index
221
+ source_components[source_index] || relative_components[index]
222
+ end
223
+
224
+ original_components.join('/')
225
+ end
226
+
227
+ # Checks if a path starts with a prefix using normalized comparison
228
+ # to handle case-insensitive volumes and mixed separators
229
+ #
230
+ # @param path [String] path to check
231
+ # @param prefix [String] prefix to match against
232
+ # @param root [String, nil] root directory for determining volume case-sensitivity
233
+ # @return [Boolean] true if path starts with prefix (after normalization)
234
+ def self.normalized_start_with?(path, prefix, root: nil)
235
+ return false if path.nil? || prefix.nil? || prefix.empty?
236
+
237
+ # Normalize both paths for comparison (case + separators)
238
+ # If root is provided, derive case-sensitivity from root's volume
239
+ case_sensitive = begin
240
+ if root
241
+ VolumeCaseSensitivity.volume_case_sensitive?(root)
242
+ else
243
+ VolumeCaseSensitivity.volume_case_sensitive?
244
+ end
245
+ rescue SystemCallError, IOError
246
+ # If we can't detect case sensitivity, assume case-insensitive to be conservative
247
+ false
248
+ end
249
+ normalized_path = normalize(path, normalize_case: !case_sensitive, root: root)
250
+ normalized_prefix = normalize(prefix, normalize_case: !case_sensitive, root: root)
251
+
252
+ # Check if normalized path starts with normalized prefix
253
+ # AND ensure we have proper path boundary (either exact match or followed by separator)
254
+ return false unless normalized_path.start_with?(normalized_prefix)
255
+
256
+ # If exact match, return true
257
+ return true if normalized_path == normalized_prefix
258
+
259
+ # Otherwise, ensure character after prefix is a path separator
260
+ # (normalize converts all backslashes to forward slashes, so only check for /)
261
+ prefix_length = normalized_prefix.length
262
+ normalized_path[prefix_length] == '/'
263
+ end
264
+ end
265
+ end