roast-ai 0.1.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 (57) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/settings.json +12 -0
  3. data/.github/workflows/ci.yaml +29 -0
  4. data/.github/workflows/cla.yml +22 -0
  5. data/.gitignore +13 -0
  6. data/.rspec +1 -0
  7. data/.rubocop.yml +12 -0
  8. data/.ruby-version +1 -0
  9. data/CHANGELOG.md +0 -0
  10. data/CLAUDE.md +31 -0
  11. data/CODE_OF_CONDUCT.md +133 -0
  12. data/CONTRIBUTING.md +35 -0
  13. data/Gemfile +19 -0
  14. data/Gemfile.lock +194 -0
  15. data/LICENSE.md +21 -0
  16. data/README.md +27 -0
  17. data/Rakefile +24 -0
  18. data/bin/console +11 -0
  19. data/examples/grading/analyze_coverage/prompt.md +52 -0
  20. data/examples/grading/calculate_final_grade.rb +67 -0
  21. data/examples/grading/format_result.rb +48 -0
  22. data/examples/grading/generate_grades/prompt.md +105 -0
  23. data/examples/grading/generate_recommendations/output.txt +17 -0
  24. data/examples/grading/generate_recommendations/prompt.md +60 -0
  25. data/examples/grading/run_coverage.rb +47 -0
  26. data/examples/grading/verify_mocks_and_stubs/prompt.md +12 -0
  27. data/examples/grading/verify_test_helpers/prompt.md +53 -0
  28. data/examples/grading/workflow.md +8 -0
  29. data/examples/grading/workflow.rb.md +6 -0
  30. data/examples/grading/workflow.ts+tsx.md +6 -0
  31. data/examples/grading/workflow.yml +46 -0
  32. data/exe/roast +17 -0
  33. data/lib/roast/helpers/function_caching_interceptor.rb +27 -0
  34. data/lib/roast/helpers/logger.rb +104 -0
  35. data/lib/roast/helpers/minitest_coverage_runner.rb +244 -0
  36. data/lib/roast/helpers/path_resolver.rb +148 -0
  37. data/lib/roast/helpers/prompt_loader.rb +97 -0
  38. data/lib/roast/helpers.rb +12 -0
  39. data/lib/roast/tools/cmd.rb +72 -0
  40. data/lib/roast/tools/grep.rb +43 -0
  41. data/lib/roast/tools/read_file.rb +49 -0
  42. data/lib/roast/tools/search_file.rb +51 -0
  43. data/lib/roast/tools/write_file.rb +60 -0
  44. data/lib/roast/tools.rb +50 -0
  45. data/lib/roast/version.rb +5 -0
  46. data/lib/roast/workflow/base_step.rb +94 -0
  47. data/lib/roast/workflow/base_workflow.rb +79 -0
  48. data/lib/roast/workflow/configuration.rb +117 -0
  49. data/lib/roast/workflow/configuration_parser.rb +92 -0
  50. data/lib/roast/workflow/validator.rb +37 -0
  51. data/lib/roast/workflow/workflow_executor.rb +119 -0
  52. data/lib/roast/workflow.rb +13 -0
  53. data/lib/roast.rb +40 -0
  54. data/roast.gemspec +44 -0
  55. data/schema/workflow.json +92 -0
  56. data/shipit.rubygems.yml +0 -0
  57. metadata +171 -0
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+ require "minitest"
5
+ require_relative "logger"
6
+
7
+ # Disable the built-in `at_exit` hook for Minitest before anything else
8
+ module Minitest
9
+ class << self
10
+ alias_method :original_at_exit, :at_exit
11
+ def at_exit(*)
12
+ # Do nothing to prevent autorun hooks
13
+ end
14
+ end
15
+ end
16
+
17
+ module Roast
18
+ module Helpers
19
+ class TestStatsCollector
20
+ attr_reader :tests_count, :assertions_count
21
+
22
+ def initialize
23
+ @tests_count = 0
24
+ @assertions_count = 0
25
+
26
+ # Install our hook into Minitest's before and after hooks
27
+ Minitest.after_run { @reported = true }
28
+
29
+ # Install a custom hook to count tests
30
+ Minitest::Test.class_eval do
31
+ original_run = instance_method(:run)
32
+
33
+ define_method(:run) do |*args|
34
+ result = original_run.bind(self).call(*args)
35
+ TestStatsCollector.instance.count_test(result)
36
+ result
37
+ end
38
+ end
39
+ end
40
+
41
+ def count_test(result)
42
+ @tests_count += 1
43
+ @assertions_count += result.assertions
44
+ end
45
+
46
+ class << self
47
+ def instance
48
+ @instance ||= new
49
+ end
50
+ end
51
+ end
52
+
53
+ class MinitestCoverageRunner
54
+ def initialize(test_file_path, subject_file_path)
55
+ @test_file = File.expand_path(test_file_path)
56
+ @subject_file = File.expand_path(subject_file_path)
57
+
58
+ # Detect Rails vs Gem by checking for config/environment.rb or a .gemspec
59
+ @rails_app = File.exist?(File.join(Dir.pwd, "config", "environment.rb"))
60
+ @gem_project = Dir.glob(File.join(Dir.pwd, "*.gemspec")).any?
61
+ end
62
+
63
+ def run
64
+ # Make sure the test dir (and possibly the test file's dir) is on the LOAD_PATH,
65
+ # so that 'require "test_helper"' from inside the test works in plain IRB.
66
+ ensure_load_path_for_test
67
+
68
+ # Start coverage
69
+ Coverage.start(lines: true, branches: true, methods: true)
70
+
71
+ # If Rails app, load Rails environment & test_help
72
+ if @rails_app
73
+ ENV["RAILS_ENV"] = "test"
74
+ ENV["DISABLE_SPRING"] = "1" # ensure we don't use Spring, so coverage is captured
75
+ require File.expand_path("config/environment", Dir.pwd)
76
+ require "rails/test_help"
77
+ else
78
+ require "bundler/setup"
79
+ end
80
+
81
+ # Now require the test file directly
82
+ require @test_file
83
+
84
+ # Require the source file to make sure it's loaded for coverage
85
+ require @subject_file
86
+
87
+ # Initialize our test stats collector - must happen before tests run
88
+ stats_collector = TestStatsCollector.instance
89
+
90
+ # Run Minitest tests
91
+ # Redirect stdout to stderr for test output so that it doesn't pollute
92
+ # the JSON output of the coverage runner
93
+ original_stdout = $stdout.dup
94
+ $stdout.reopen($stderr)
95
+ test_passed = Minitest.run([])
96
+ $stdout.reopen(original_stdout) # Restore original stdout
97
+
98
+ # Report test stats
99
+ test_count = stats_collector.tests_count
100
+ assertion_count = stats_collector.assertions_count
101
+
102
+ coverage_data = Coverage.result(stop: false)
103
+
104
+ file_data = coverage_data[@subject_file]
105
+ coverage_result =
106
+ if file_data.nil?
107
+ # If file never got loaded, coverage is effectively zero for that file
108
+ { line: 0.0, branch: 0.0, method: 0.0, tests: test_count, assertions: assertion_count }
109
+ else
110
+ result = compute_coverage_stats(file_data)
111
+ result.merge(tests: test_count, assertions: assertion_count)
112
+ end
113
+
114
+ # If the test run failed (returned false), exit 1
115
+ unless test_passed
116
+ Roast::Helpers::Logger.error("\nTest failures detected. Exiting with status 1.")
117
+ exit(1)
118
+ end
119
+
120
+ puts coverage_result.to_json
121
+ end
122
+
123
+ private
124
+
125
+ # Ensures that your test directory (and possibly the directory of the test file)
126
+ # is added to the load path so `require 'test_helper'` works from IRB context.
127
+ def ensure_load_path_for_test
128
+ test_dir = File.join(Dir.pwd, "test")
129
+ $LOAD_PATH.unshift(test_dir) if File.directory?(test_dir) && !$LOAD_PATH.include?(test_dir)
130
+
131
+ # Also add the directory of the specific test file; sometimes test files are in subdirs like `test/models`
132
+ test_file_dir = File.dirname(@test_file)
133
+ unless $LOAD_PATH.include?(test_file_dir)
134
+ $LOAD_PATH.unshift(test_file_dir)
135
+ end
136
+ end
137
+
138
+ def compute_coverage_stats(file_data)
139
+ lines_info = file_data[:lines] || []
140
+ branches_info = file_data[:branches] || {}
141
+ methods_info = file_data[:methods] || {}
142
+ source_code_lines = File.readlines(@subject_file).map(&:chomp)
143
+
144
+ # --- Line Coverage ---
145
+ executable_lines = lines_info.count { |count| !count.nil? }
146
+ covered_lines = lines_info.count { |count| count && count > 0 }
147
+ line_percent = if executable_lines.zero?
148
+ 100.0
149
+ else
150
+ (covered_lines.to_f / executable_lines * 100).round(2)
151
+ end
152
+
153
+ # --- Branch Coverage ---
154
+ total_branches = 0
155
+ covered_branches = 0
156
+ uncovered_branches = []
157
+
158
+ # Track line numbers with branches for reporting
159
+ branches_info.each do |line_number, branch_group|
160
+ # Convert line_number from symbol/array to integer if needed
161
+ # Ruby Coverage module can return different formats for line numbers
162
+ line_num = if line_number.is_a?(Array)
163
+ line_number[2] # Usually the line number is the 3rd element in the array
164
+ elsif line_number.is_a?(Symbol)
165
+ # Try to extract line number from a symbol like :line_10
166
+ begin
167
+ line_number.to_s.match(/\d+/)&.[](0).to_i
168
+ rescue
169
+ 0
170
+ end
171
+ else
172
+ line_number.to_i
173
+ end
174
+
175
+ # Adjust for zero-based line numbering if needed
176
+ actual_line_num = [line_num, 0].max
177
+
178
+ # Get the actual code from that line if available
179
+ line_code = begin
180
+ source_code_lines[actual_line_num - 1]&.strip
181
+ rescue
182
+ "Unknown code"
183
+ end
184
+
185
+ branch_group.each do |_branch_id, count|
186
+ total_branches += 1
187
+ if count && count > 0
188
+ covered_branches += 1
189
+ else
190
+ # Add uncovered branch to the result with line number and code
191
+ uncovered_branches << "Line #{actual_line_num}: #{line_code}"
192
+ end
193
+ end
194
+ end
195
+
196
+ branch_percent = if total_branches.zero?
197
+ 100.0
198
+ else
199
+ (covered_branches.to_f / total_branches * 100).round(2)
200
+ end
201
+
202
+ # --- Method Coverage ---
203
+ total_methods = methods_info.size
204
+ covered_methods = 0
205
+ uncovered_methods = []
206
+
207
+ methods_info.each do |method_id, count|
208
+ # Method IDs in Coverage are usually in the format: [class_name, method_name, start_line, end_line]
209
+ if method_id.is_a?(Array) && method_id.size >= 4
210
+ class_name, method_name, start_line, end_line = method_id
211
+ # Construct a user-friendly representation of the method
212
+ method_signature = "#{class_name}##{method_name} (lines #{start_line}-#{end_line})"
213
+
214
+ if count && count > 0
215
+ covered_methods += 1
216
+ else
217
+ # Add uncovered method to the result
218
+ uncovered_methods << method_signature
219
+ end
220
+ elsif count && count > 0
221
+ # Handle any other format the Coverage module might return
222
+ covered_methods += 1
223
+ else
224
+ uncovered_methods << method_id.to_s
225
+ end
226
+ end
227
+
228
+ method_percent = if total_methods.zero?
229
+ 100.0
230
+ else
231
+ (covered_methods.to_f / total_methods * 100).round(2)
232
+ end
233
+
234
+ {
235
+ line: line_percent,
236
+ branch: branch_percent,
237
+ method: method_percent,
238
+ uncovered_branches: uncovered_branches,
239
+ uncovered_methods: uncovered_methods,
240
+ }
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Helpers
5
+ # Utility class for resolving file paths with directory structure issues
6
+ class PathResolver
7
+ class << self
8
+ # Intelligently resolves a path considering possible directory structure issues
9
+ def resolve(path)
10
+ # Store original path for logging if needed
11
+ original_path = path
12
+
13
+ # Early return if the path is nil or empty
14
+ return path if path.nil? || path.empty?
15
+
16
+ # First try standard path expansion
17
+ expanded_path = File.expand_path(path)
18
+
19
+ # Return early if the file exists at the expanded path
20
+ return expanded_path if File.exist?(expanded_path)
21
+
22
+ # Get current directory and possible project root paths
23
+ current_dir = Dir.pwd
24
+ possible_roots = [
25
+ current_dir,
26
+ File.expand_path(File.join(current_dir, "..")),
27
+ File.expand_path(File.join(current_dir, "../..")),
28
+ File.expand_path(File.join(current_dir, "../../..")),
29
+ File.expand_path(File.join(current_dir, "../../../..")),
30
+ File.expand_path(File.join(current_dir, "../../../../..")),
31
+ ]
32
+
33
+ # Check for directory name duplications anywhere in the path
34
+ path_parts = expanded_path.split(File::SEPARATOR).reject(&:empty?)
35
+
36
+ # Try removing each duplicate segment individually and check if the resulting path exists
37
+ path_parts.each_with_index do |part, i|
38
+ next if i == 0 # Skip the first segment
39
+
40
+ # Check if this segment appears earlier in the path
41
+ next unless path_parts[0...i].include?(part)
42
+
43
+ # Create a new path without this segment
44
+ test_parts = path_parts.dup
45
+ test_parts.delete_at(i)
46
+
47
+ test_path = if original_path.start_with?("/")
48
+ File.join("/", *test_parts)
49
+ else
50
+ File.join(test_parts)
51
+ end
52
+
53
+ # If this path exists, return it
54
+ return test_path if File.exist?(test_path)
55
+
56
+ # Also try removing all future occurrences of this segment name
57
+ duplicate_indices = []
58
+ path_parts.each_with_index do |segment, idx|
59
+ if idx > 0 && segment == part && idx >= i
60
+ duplicate_indices << idx
61
+ end
62
+ end
63
+
64
+ next if duplicate_indices.none?
65
+
66
+ filtered_parts = path_parts.dup
67
+ # Remove from end to beginning to keep indices valid
68
+ duplicate_indices.reverse_each { |idx| filtered_parts.delete_at(idx) }
69
+
70
+ test_path = if original_path.start_with?("/")
71
+ File.join("/", *filtered_parts)
72
+ else
73
+ File.join(filtered_parts)
74
+ end
75
+
76
+ return test_path if File.exist?(test_path)
77
+ end
78
+
79
+ # Try detecting all duplicates at once
80
+ seen_segments = {}
81
+ duplicate_indices = []
82
+
83
+ path_parts.each_with_index do |part, i|
84
+ if seen_segments[part]
85
+ duplicate_indices << i
86
+ else
87
+ seen_segments[part] = true
88
+ end
89
+ end
90
+
91
+ if duplicate_indices.any?
92
+ # Try removing all duplicates
93
+ unique_parts = path_parts.dup
94
+ # Remove from end to beginning to keep indices valid
95
+ duplicate_indices.reverse_each { |i| unique_parts.delete_at(i) }
96
+
97
+ test_path = if original_path.start_with?("/")
98
+ File.join("/", *unique_parts)
99
+ else
100
+ File.join(unique_parts)
101
+ end
102
+
103
+ return test_path if File.exist?(test_path)
104
+ end
105
+
106
+ # Try relative path resolution from various possible roots
107
+ relative_path = path.sub(%r{^\./}, "")
108
+ possible_roots.each do |root|
109
+ # Try the path as-is from this root
110
+ candidate = File.join(root, relative_path)
111
+ return candidate if File.exist?(candidate)
112
+
113
+ # Try with a leading slash removed
114
+ if relative_path.start_with?("/")
115
+ candidate = File.join(root, relative_path.sub(%r{^/}, ""))
116
+ return candidate if File.exist?(candidate)
117
+ end
118
+ end
119
+
120
+ # Try extracting the path after a potential project root
121
+ if expanded_path.include?("/src/") || expanded_path.include?("/lib/") || expanded_path.include?("/test/")
122
+ # Potential project markers
123
+ markers = ["/src/", "/lib/", "/test/", "/app/", "/config/"]
124
+ markers.each do |marker|
125
+ next unless expanded_path.include?(marker)
126
+
127
+ # Get the part after the marker
128
+ parts = expanded_path.split(marker, 2)
129
+ next unless parts.size == 2
130
+
131
+ marker_dir = marker.gsub("/", "")
132
+ relative_from_marker = parts[1]
133
+
134
+ # Try each possible root with this marker
135
+ possible_roots.each do |root|
136
+ candidate = File.join(root, marker_dir, relative_from_marker)
137
+ return candidate if File.exist?(candidate)
138
+ end
139
+ end
140
+ end
141
+
142
+ # Default to the original expanded path if all else fails
143
+ expanded_path
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string"
4
+ require "erb"
5
+
6
+ module Roast
7
+ module Helpers
8
+ class PromptLoader
9
+ class << self
10
+ # Loads a sidecar prompt file for a given context (workflow or step) and target file
11
+ #
12
+ # @param context [Object] The workflow or step instance
13
+ # @param target_file [String] The path to the target file
14
+ # @return [String, nil] The processed prompt content, or nil if no prompt is found
15
+ def load_prompt(context, target_file)
16
+ new(context, target_file).load
17
+ end
18
+ end
19
+
20
+ def initialize(context, target_file)
21
+ @context = context
22
+ @name = context.name
23
+ @context_path = context.context_path
24
+ @target_file = target_file
25
+ end
26
+
27
+ def load
28
+ prompt_content = read_prompt_file(find_prompt_path)
29
+ return unless prompt_content
30
+
31
+ process_erb_if_needed(prompt_content)
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :context, :name, :context_path, :target_file
37
+
38
+ def read_prompt_file(path)
39
+ if path && File.exist?(path)
40
+ File.read(path)
41
+ else
42
+ $stderr.puts "Prompt file for #{name} not found: #{path}"
43
+ end
44
+ end
45
+
46
+ def find_prompt_path
47
+ find_specialized_prompt_path(name, extract_file_extensions)
48
+ end
49
+
50
+ def find_specialized_prompt_path(base_name, extensions)
51
+ context_dir = File.expand_path(context_path)
52
+
53
+ # Try each extension to find a specialized prompt
54
+ extensions.each do |ext|
55
+ path = File.join(context_dir, "#{base_name}.#{ext}.md")
56
+ path = File.join(context_dir, "prompt.#{ext}.md") unless File.exist?(path)
57
+ return path if File.exist?(path)
58
+ end
59
+
60
+ # Check for combined format patterns (like ts+tsx)
61
+ glob_pattern = File.join(context_dir, "{#{base_name},prompt}.*+*.md")
62
+ Dir.glob(glob_pattern).each do |combined_path|
63
+ basename = File.basename(combined_path, ".md")
64
+ combined_exts = basename.split(".", 2)[1].split("+")
65
+
66
+ # Return the first matching combined format
67
+ return combined_path if extensions.intersect?(combined_exts)
68
+ end
69
+
70
+ # Fall back to the general prompt
71
+ general_path = File.join(context_dir, "#{base_name}.md")
72
+ general_path = File.join(context_dir, "prompt.md") unless File.exist?(general_path)
73
+ general_path if File.exist?(general_path)
74
+ end
75
+
76
+ def extract_file_extensions
77
+ file_basename = File.basename(target_file)
78
+
79
+ if file_basename.end_with?(".md") && file_basename.count(".") > 1
80
+ without_md = file_basename[0...-3] # Remove .md
81
+ without_md.split(".", 2)[1]&.split("+") || []
82
+ else
83
+ ext = File.extname(target_file)[1..]
84
+ ext&.empty? ? [] : [ext]
85
+ end
86
+ end
87
+
88
+ def process_erb_if_needed(content)
89
+ if content.include?("<%")
90
+ ERB.new(content, trim_mode: "-").result(context.instance_eval { binding })
91
+ else
92
+ content
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/helpers/logger"
4
+ require "roast/helpers/path_resolver"
5
+ require "roast/helpers/prompt_loader"
6
+ require "roast/helpers/minitest_coverage_runner"
7
+ require "roast/helpers/function_caching_interceptor"
8
+
9
+ module Roast
10
+ module Helpers
11
+ end
12
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "roast/helpers/logger"
5
+
6
+ module Roast
7
+ module Tools
8
+ module Cmd
9
+ extend self
10
+
11
+ class << self
12
+ # Add this method to be included in other classes
13
+ def included(base)
14
+ base.class_eval do
15
+ function(
16
+ :cmd,
17
+ 'Run a command in the current working directory (e.g. "ls", "rake", "ruby"). ' \
18
+ "You may use this tool to execute tests and verify if they pass.",
19
+ command: { type: "string", description: "The command to run in a bash shell." },
20
+ ) do |params|
21
+ Roast::Tools::Cmd.call(params[:command])
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ def call(command)
28
+ Roast::Helpers::Logger.info("🔧 Running command: #{command}\n")
29
+
30
+ # Validate the command starts with one of the allowed prefixes
31
+ allowed_prefixes = ["pwd", "find", "ls", "rake", "ruby", "dev"]
32
+ command_prefix = command.split(" ").first
33
+
34
+ err = "Error: Command not allowed. Only commands starting with #{allowed_prefixes.join(", ")} are permitted."
35
+ return err unless allowed_prefixes.any? do |prefix|
36
+ command_prefix == prefix
37
+ end
38
+
39
+ # Execute the command in the current working directory
40
+ result = ""
41
+
42
+ # Use a full shell environment for commands, especially for 'dev'
43
+ if command_prefix == "dev"
44
+ # Use bash -l -c to ensure we get a login shell with all environment variables
45
+ full_command = "bash -l -c '#{command.gsub("'", "\\'")}'"
46
+ IO.popen(full_command, chdir: Dir.pwd) do |io|
47
+ result = io.read
48
+ end
49
+ else
50
+ # For other commands, use the original approach
51
+ IO.popen(command, chdir: Dir.pwd) do |io|
52
+ result = io.read
53
+ end
54
+ end
55
+
56
+ exit_status = $CHILD_STATUS.exitstatus
57
+
58
+ # Return the command output along with exit status information
59
+ output = "Command: #{command}\n"
60
+ output += "Exit status: #{exit_status}\n"
61
+ output += "Output:\n#{result}"
62
+
63
+ output
64
+ rescue StandardError => e
65
+ "Error running command: #{e.message}".tap do |error_message|
66
+ Roast::Helpers::Logger.error(error_message + "\n")
67
+ Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/helpers/logger"
4
+
5
+ module Roast
6
+ module Tools
7
+ module Grep
8
+ extend self
9
+
10
+ MAX_RESULT_LINES = 100
11
+
12
+ class << self
13
+ # Add this method to be included in other classes
14
+ def included(base)
15
+ base.class_eval do
16
+ function(
17
+ :grep,
18
+ 'Search for a string in the project using `grep -rni "#{@search_string}" .` in the project root',
19
+ string: { type: "string", description: "The string to search for" },
20
+ ) do |params|
21
+ Roast::Tools::Grep.call(params[:string]).tap do |result|
22
+ Roast::Helpers::Logger.debug(result) if ENV["DEBUG"]
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def call(string)
30
+ Roast::Helpers::Logger.info("🔍 Grepping for string: #{string}\n")
31
+ # Escape regex special characters in strings with curly braces
32
+ # Example: "import {render}" becomes "import \{render\}"
33
+ escaped_string = string.gsub(/(\{|\})/, '\\\\\\1')
34
+ %x(rg -C 4 --trim --color=never --heading -F -- "#{escaped_string}" . | head -n #{MAX_RESULT_LINES})
35
+ rescue StandardError => e
36
+ "Error grepping for string: #{e.message}".tap do |error_message|
37
+ Roast::Helpers::Logger.error(error_message + "\n")
38
+ Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/helpers/logger"
4
+
5
+ module Roast
6
+ module Tools
7
+ module ReadFile
8
+ extend self
9
+
10
+ class << self
11
+ def included(base)
12
+ base.class_eval do
13
+ function(
14
+ :read_file,
15
+ "Read the contents of a file. (If the path is a directory, list the contents.) " \
16
+ "NOTE: Do not use for .rbi files, they are not useful.",
17
+ path: { type: "string", description: "The path to the file to read" },
18
+ ) do |params|
19
+ Roast::Tools::ReadFile.call(params[:path]).tap do |result|
20
+ if ENV["DEBUG"]
21
+ result_lines = result.lines
22
+ if result_lines.size > 20
23
+ Roast::Helpers::Logger.debug(result_lines.first(20).join + "\n...")
24
+ else
25
+ Roast::Helpers::Logger.debug(result)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ def call(path)
35
+ Roast::Helpers::Logger.info("📖 Reading file: #{path}\n")
36
+ if File.directory?(path)
37
+ %x(ls -la #{path})
38
+ else
39
+ File.read(File.join(Dir.pwd, path))
40
+ end
41
+ rescue StandardError => e
42
+ "Error reading file: #{e.message}".tap do |error_message|
43
+ Roast::Helpers::Logger.error(error_message + "\n")
44
+ Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end