rubyn 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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +251 -0
  4. data/Rakefile +12 -0
  5. data/exe/rubyn +5 -0
  6. data/lib/generators/rubyn/install_generator.rb +16 -0
  7. data/lib/rubyn/cli.rb +85 -0
  8. data/lib/rubyn/client/api_client.rb +172 -0
  9. data/lib/rubyn/commands/agent.rb +191 -0
  10. data/lib/rubyn/commands/base.rb +60 -0
  11. data/lib/rubyn/commands/config.rb +51 -0
  12. data/lib/rubyn/commands/dashboard.rb +85 -0
  13. data/lib/rubyn/commands/index.rb +101 -0
  14. data/lib/rubyn/commands/init.rb +166 -0
  15. data/lib/rubyn/commands/refactor.rb +175 -0
  16. data/lib/rubyn/commands/review.rb +61 -0
  17. data/lib/rubyn/commands/spec.rb +72 -0
  18. data/lib/rubyn/commands/usage.rb +56 -0
  19. data/lib/rubyn/config/credentials.rb +39 -0
  20. data/lib/rubyn/config/project_config.rb +42 -0
  21. data/lib/rubyn/config/settings.rb +53 -0
  22. data/lib/rubyn/context/codebase_indexer.rb +195 -0
  23. data/lib/rubyn/context/context_builder.rb +36 -0
  24. data/lib/rubyn/context/file_resolver.rb +235 -0
  25. data/lib/rubyn/context/project_scanner.rb +132 -0
  26. data/lib/rubyn/engine/app/assets/images/rubyn/RubynLogo.png +0 -0
  27. data/lib/rubyn/engine/app/assets/javascripts/rubyn/application.js +579 -0
  28. data/lib/rubyn/engine/app/assets/stylesheets/rubyn/application.css +1073 -0
  29. data/lib/rubyn/engine/app/controllers/rubyn/agent_controller.rb +26 -0
  30. data/lib/rubyn/engine/app/controllers/rubyn/application_controller.rb +44 -0
  31. data/lib/rubyn/engine/app/controllers/rubyn/dashboard_controller.rb +19 -0
  32. data/lib/rubyn/engine/app/controllers/rubyn/feedback_controller.rb +17 -0
  33. data/lib/rubyn/engine/app/controllers/rubyn/files_controller.rb +54 -0
  34. data/lib/rubyn/engine/app/controllers/rubyn/refactor_controller.rb +56 -0
  35. data/lib/rubyn/engine/app/controllers/rubyn/reviews_controller.rb +32 -0
  36. data/lib/rubyn/engine/app/controllers/rubyn/settings_controller.rb +17 -0
  37. data/lib/rubyn/engine/app/controllers/rubyn/specs_controller.rb +33 -0
  38. data/lib/rubyn/engine/app/views/layouts/rubyn/application.html.erb +63 -0
  39. data/lib/rubyn/engine/app/views/rubyn/agent/show.html.erb +22 -0
  40. data/lib/rubyn/engine/app/views/rubyn/dashboard/index.html.erb +120 -0
  41. data/lib/rubyn/engine/app/views/rubyn/files/index.html.erb +45 -0
  42. data/lib/rubyn/engine/app/views/rubyn/refactor/show.html.erb +28 -0
  43. data/lib/rubyn/engine/app/views/rubyn/reviews/show.html.erb +28 -0
  44. data/lib/rubyn/engine/app/views/rubyn/settings/show.html.erb +42 -0
  45. data/lib/rubyn/engine/app/views/rubyn/specs/show.html.erb +28 -0
  46. data/lib/rubyn/engine/config/routes.rb +13 -0
  47. data/lib/rubyn/engine/engine.rb +18 -0
  48. data/lib/rubyn/output/diff_renderer.rb +106 -0
  49. data/lib/rubyn/output/formatter.rb +123 -0
  50. data/lib/rubyn/output/spinner.rb +26 -0
  51. data/lib/rubyn/tools/base_tool.rb +74 -0
  52. data/lib/rubyn/tools/bundle_add.rb +77 -0
  53. data/lib/rubyn/tools/create_file.rb +32 -0
  54. data/lib/rubyn/tools/delete_file.rb +29 -0
  55. data/lib/rubyn/tools/executor.rb +68 -0
  56. data/lib/rubyn/tools/find_files.rb +33 -0
  57. data/lib/rubyn/tools/find_references.rb +72 -0
  58. data/lib/rubyn/tools/git_commit.rb +65 -0
  59. data/lib/rubyn/tools/git_create_branch.rb +58 -0
  60. data/lib/rubyn/tools/git_diff.rb +42 -0
  61. data/lib/rubyn/tools/git_log.rb +43 -0
  62. data/lib/rubyn/tools/git_status.rb +26 -0
  63. data/lib/rubyn/tools/list_directory.rb +82 -0
  64. data/lib/rubyn/tools/move_file.rb +35 -0
  65. data/lib/rubyn/tools/patch_file.rb +47 -0
  66. data/lib/rubyn/tools/rails_generate.rb +40 -0
  67. data/lib/rubyn/tools/rails_migrate.rb +55 -0
  68. data/lib/rubyn/tools/rails_routes.rb +35 -0
  69. data/lib/rubyn/tools/read_file.rb +45 -0
  70. data/lib/rubyn/tools/registry.rb +28 -0
  71. data/lib/rubyn/tools/run_command.rb +48 -0
  72. data/lib/rubyn/tools/run_tests.rb +52 -0
  73. data/lib/rubyn/tools/search_files.rb +82 -0
  74. data/lib/rubyn/tools/write_file.rb +30 -0
  75. data/lib/rubyn/version.rb +5 -0
  76. data/lib/rubyn/version_checker.rb +74 -0
  77. data/lib/rubyn.rb +95 -0
  78. data/sig/rubyn.rbs +4 -0
  79. metadata +379 -0
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rubyn
6
+ module Commands
7
+ class Refactor < Base
8
+ def execute(file)
9
+ formatter = Rubyn::Output::Formatter
10
+ ensure_configured!
11
+ auto_index_if_needed!
12
+
13
+ unless File.exist?(file)
14
+ formatter.error("File not found: #{file}")
15
+ return
16
+ end
17
+
18
+ file_content = File.read(file)
19
+ relative_path = relative_to_project(file)
20
+ formatted_context = build_context_files(relative_path)
21
+
22
+ formatter.info("Analyzing #{File.basename(file)}...")
23
+ formatter.info("Context: #{formatted_context.size} related file(s)")
24
+ formatter.newline
25
+ begin
26
+ result = Rubyn.api_client.refactor(
27
+ file_path: relative_path,
28
+ code: file_content,
29
+ context_files: formatted_context,
30
+ project_token: project_token
31
+ )
32
+
33
+ full_response = result["response"] || ""
34
+ formatter.print_content(full_response)
35
+ formatter.newline
36
+ formatter.credit_usage(result["credits_used"], nil) if result["credits_used"]
37
+ formatter.newline
38
+ prompt_apply(file, file_content, full_response, formatter)
39
+ rescue Rubyn::AuthenticationError => e
40
+ formatter.error("Authentication failed: #{e.message}")
41
+ rescue Rubyn::APIError => e
42
+ formatter.error(e.message)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def prompt_apply(file, original, response, formatter)
49
+ file_blocks = extract_file_blocks(response)
50
+ return if file_blocks.empty?
51
+
52
+ if file_blocks.length == 1 && file_blocks.first[:tag].nil?
53
+ prompt_apply_single(file, original, file_blocks.first[:code], formatter)
54
+ else
55
+ prompt_apply_multi(file, original, file_blocks, formatter)
56
+ end
57
+ end
58
+
59
+ def extract_file_blocks(response)
60
+ blocks = []
61
+
62
+ # Match path header lines directly above ```ruby blocks
63
+ # Handles: `[NEW] path.rb`, `path.rb`, backtick-wrapped or plain
64
+ response.scan(/(?:\[NEW\]\s*)?`?([a-zA-Z0-9_\/\.\-]+\.rb)`?\s*\n```ruby\n(.*?)```/m) do
65
+ path = $1
66
+ code = $2
67
+ is_new = $~[0].include?("[NEW]")
68
+ blocks << { tag: is_new ? "NEW" : nil, path: path, code: code }
69
+ end
70
+
71
+ # Fallback: no path headers found, grab first code block
72
+ if blocks.empty?
73
+ code_match = response.match(/```ruby\n(.*?)```/m)
74
+ blocks << { tag: nil, path: nil, code: code_match[1] } if code_match
75
+ end
76
+
77
+ blocks
78
+ end
79
+
80
+ def prompt_apply_single(file, original, new_content, formatter)
81
+ print "\nApply changes? (y/n/diff) "
82
+ choice = $stdin.gets&.strip&.downcase
83
+
84
+ case choice
85
+ when "y"
86
+ File.write(file, new_content)
87
+ formatter.success("Changes applied to #{file}")
88
+ when "diff"
89
+ Rubyn::Output::DiffRenderer.render(original: original, modified: new_content)
90
+ print "\nApply changes? (y/n) "
91
+ if $stdin.gets&.strip&.downcase == "y"
92
+ File.write(file, new_content)
93
+ formatter.success("Changes applied to #{file}")
94
+ else
95
+ formatter.info("Changes discarded.")
96
+ end
97
+ else
98
+ formatter.info("Changes discarded.")
99
+ end
100
+ end
101
+
102
+ def prompt_apply_multi(file, original, file_blocks, formatter)
103
+ formatter.newline
104
+ formatter.info("This refactor produces #{file_blocks.length} file(s):")
105
+ file_blocks.each do |block|
106
+ label = block[:tag] == "NEW" ? "NEW" : "MODIFIED"
107
+ path = block[:path] || file
108
+ formatter.info(" [#{label}] #{path}")
109
+ end
110
+
111
+ print "\nApply all changes? (y/n/select) "
112
+ choice = $stdin.gets&.strip&.downcase
113
+
114
+ case choice
115
+ when "y"
116
+ apply_all_blocks(file, original, file_blocks, formatter)
117
+ when "select"
118
+ apply_selected_blocks(file, original, file_blocks, formatter)
119
+ else
120
+ formatter.info("All changes discarded.")
121
+ end
122
+ end
123
+
124
+ def apply_all_blocks(file, original, file_blocks, formatter)
125
+ file_blocks.each do |block|
126
+ write_block(file, block, formatter)
127
+ end
128
+ end
129
+
130
+ def apply_selected_blocks(file, original, file_blocks, formatter)
131
+ file_blocks.each do |block|
132
+ label = block[:tag] == "NEW" ? "NEW" : "MODIFIED"
133
+ path = block[:path] || file
134
+ print " Apply [#{label}] #{path}? (y/n/diff) "
135
+ choice = $stdin.gets&.strip&.downcase
136
+
137
+ case choice
138
+ when "y"
139
+ write_block(file, block, formatter)
140
+ when "diff"
141
+ existing = block[:tag] == "NEW" ? "" : File.read(resolve_path(file, block))
142
+ Rubyn::Output::DiffRenderer.render(original: existing, modified: block[:code])
143
+ print " Apply? (y/n) "
144
+ if $stdin.gets&.strip&.downcase == "y"
145
+ write_block(file, block, formatter)
146
+ else
147
+ formatter.info(" Skipped #{path}")
148
+ end
149
+ else
150
+ formatter.info(" Skipped #{path}")
151
+ end
152
+ end
153
+ end
154
+
155
+ def write_block(original_file, block, formatter)
156
+ target = resolve_path(original_file, block)
157
+ dir = File.dirname(target)
158
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
159
+ File.write(target, block[:code])
160
+ label = block[:tag] == "NEW" ? "Created" : "Updated"
161
+ formatter.success("#{label} #{target}")
162
+ end
163
+
164
+ def resolve_path(original_file, block)
165
+ return original_file unless block[:path]
166
+
167
+ if block[:path].start_with?("/")
168
+ block[:path]
169
+ else
170
+ File.join(Dir.pwd, block[:path])
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ module Commands
5
+ class Review < Base
6
+ def execute(file_or_dir)
7
+ formatter = Rubyn::Output::Formatter
8
+ ensure_configured!
9
+ auto_index_if_needed!
10
+
11
+ files = resolve_files(file_or_dir)
12
+ if files.empty?
13
+ formatter.error("No Ruby files found in: #{file_or_dir}")
14
+ return
15
+ end
16
+
17
+ formatter.info("Reviewing #{files.size} file(s)...")
18
+ formatter.newline
19
+
20
+ formatted_files = files.map do |file|
21
+ { file_path: relative_to_project(file), code: File.read(file) }
22
+ end
23
+
24
+ file_paths = files.map { |f| relative_to_project(f) }
25
+ formatted_context = file_paths.flat_map { |rel_path| build_context_files(rel_path) }
26
+ .uniq { |ctx| ctx[:file_path] }
27
+ .reject { |ctx| file_paths.include?(ctx[:file_path]) }
28
+
29
+ formatter.header(formatted_files.map { |f| f[:file_path] }.join(", "))
30
+
31
+ begin
32
+ result = Rubyn.api_client.review(
33
+ files: formatted_files,
34
+ context_files: formatted_context,
35
+ project_token: project_token
36
+ )
37
+
38
+ formatter.print_content(result["response"] || "")
39
+ formatter.newline
40
+ formatter.credit_usage(result["credits_used"], nil) if result["credits_used"]
41
+ rescue Rubyn::AuthenticationError => e
42
+ formatter.error("Authentication failed: #{e.message}")
43
+ rescue Rubyn::APIError => e
44
+ formatter.error(e.message)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def resolve_files(path)
51
+ if File.directory?(path)
52
+ Dir.glob(File.join(path, "**/*.rb"))
53
+ elsif File.exist?(path)
54
+ [path]
55
+ else
56
+ []
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ module Commands
5
+ class Spec < Base
6
+ def execute(file)
7
+ formatter = Rubyn::Output::Formatter
8
+ ensure_configured!
9
+ auto_index_if_needed!
10
+
11
+ unless File.exist?(file)
12
+ formatter.error("File not found: #{file}")
13
+ return
14
+ end
15
+
16
+ file_content = File.read(file)
17
+ relative_path = relative_to_project(file)
18
+ formatted_context = build_context_files(relative_path)
19
+
20
+ formatter.info("Generating specs for #{File.basename(file)}...")
21
+ formatter.newline
22
+ begin
23
+ result = Rubyn.api_client.generate_spec(
24
+ file_path: relative_path,
25
+ code: file_content,
26
+ context_files: formatted_context,
27
+ project_token: project_token
28
+ )
29
+
30
+ full_response = result["response"] || ""
31
+ formatter.print_content(full_response)
32
+ formatter.newline
33
+ formatter.credit_usage(result["credits_used"], nil) if result["credits_used"]
34
+ formatter.newline
35
+ prompt_write_spec(relative_path, full_response, formatter)
36
+ rescue Rubyn::AuthenticationError => e
37
+ formatter.error("Authentication failed: #{e.message}")
38
+ rescue Rubyn::APIError => e
39
+ formatter.error(e.message)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def prompt_write_spec(source_path, response, formatter)
46
+ code_match = response.match(/```ruby\n(.*?)```/m)
47
+ return unless code_match
48
+
49
+ spec_content = code_match[1]
50
+ spec_path = derive_spec_path(source_path)
51
+
52
+ print "\nWrite spec to #{spec_path}? (y/n) "
53
+ choice = $stdin.gets&.strip&.downcase
54
+
55
+ if choice == "y"
56
+ FileUtils.mkdir_p(File.dirname(spec_path))
57
+ File.write(spec_path, spec_content)
58
+ formatter.success("Spec written to #{spec_path}")
59
+ else
60
+ formatter.info("Spec discarded.")
61
+ end
62
+ end
63
+
64
+ def derive_spec_path(source_path)
65
+ source_path
66
+ .sub(%r{^app/}, "spec/")
67
+ .sub(%r{^lib/}, "spec/")
68
+ .sub(/\.rb$/, "_spec.rb")
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ module Commands
5
+ class Usage < Base
6
+ MAX_HISTORY_ENTRIES = 10
7
+
8
+ def execute
9
+ formatter = Rubyn::Output::Formatter
10
+ ensure_credentials!
11
+
12
+ formatter.header("Rubyn Usage")
13
+
14
+ spinner = Rubyn::Output::Spinner.new
15
+ spinner.start("Fetching usage data...")
16
+
17
+ balance = Rubyn.api_client.get_balance
18
+ history = Rubyn.api_client.get_history
19
+
20
+ spinner.stop_success("Done!")
21
+
22
+ display_balance(formatter, balance)
23
+ display_history(formatter, history)
24
+ rescue Rubyn::ConfigurationError
25
+ raise
26
+ rescue Rubyn::Error => e
27
+ spinner&.stop_error("Failed")
28
+ formatter.error(e.message)
29
+ end
30
+
31
+ private
32
+
33
+ def display_balance(formatter, balance)
34
+ formatter.newline
35
+ formatter.info("Credits: #{balance["credit_balance"]}")
36
+ formatter.info("Credit allowance: #{balance["credit_allowance"]}") if balance["credit_allowance"]
37
+ formatter.info("Reset period: #{balance["reset_period"]}") if balance["reset_period"]
38
+ formatter.info("Resets at: #{balance["resets_at"]}") if balance["resets_at"]
39
+ formatter.newline
40
+ end
41
+
42
+ def display_history(formatter, history)
43
+ entries = history["interactions"] || []
44
+ return formatter.info("No recent activity.") if entries.empty?
45
+
46
+ formatter.header("Recent Activity")
47
+ entries.first(MAX_HISTORY_ENTRIES).each do |entry|
48
+ time = entry["created_at"]
49
+ command = entry["command"]
50
+ credits = entry["credits_charged"]
51
+ formatter.info("#{time} #{command.ljust(10)} #{credits} credits")
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Rubyn
7
+ module Config
8
+ class Credentials
9
+ CREDENTIALS_DIR = File.expand_path("~/.rubyn")
10
+ CREDENTIALS_FILE = File.join(CREDENTIALS_DIR, "credentials")
11
+
12
+ class << self
13
+ def api_key
14
+ ENV["RUBYN_API_KEY"] || read_credentials["api_key"]
15
+ end
16
+
17
+ def store(api_key:)
18
+ FileUtils.mkdir_p(CREDENTIALS_DIR)
19
+ data = read_credentials.merge("api_key" => api_key)
20
+ File.write(CREDENTIALS_FILE, YAML.dump(data))
21
+ File.chmod(0o600, CREDENTIALS_FILE)
22
+ data
23
+ end
24
+
25
+ def exists?
26
+ !api_key.nil? && !api_key.empty?
27
+ end
28
+
29
+ private
30
+
31
+ def read_credentials
32
+ return {} unless File.exist?(CREDENTIALS_FILE)
33
+
34
+ YAML.safe_load_file(CREDENTIALS_FILE) || {}
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Rubyn
7
+ module Config
8
+ class ProjectConfig
9
+ CONFIG_DIR = ".rubyn"
10
+ CONFIG_FILE = File.join(CONFIG_DIR, "project.yml")
11
+
12
+ class << self
13
+ def read(project_root = Dir.pwd)
14
+ path = File.join(project_root, CONFIG_FILE)
15
+ return {} unless File.exist?(path)
16
+
17
+ YAML.safe_load_file(path) || {}
18
+ end
19
+
20
+ def write(data, project_root = Dir.pwd)
21
+ dir = File.join(project_root, CONFIG_DIR)
22
+ path = File.join(project_root, CONFIG_FILE)
23
+ FileUtils.mkdir_p(dir)
24
+ File.write(path, YAML.dump(data))
25
+ data
26
+ end
27
+
28
+ def exists?(project_root = Dir.pwd)
29
+ File.exist?(File.join(project_root, CONFIG_FILE))
30
+ end
31
+
32
+ def project_token(project_root = Dir.pwd)
33
+ read(project_root)["project_token"]
34
+ end
35
+
36
+ def project_id(project_root = Dir.pwd)
37
+ read(project_root)["project_id"]
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Rubyn
7
+ module Config
8
+ class Settings
9
+ SETTINGS_DIR = File.expand_path("~/.rubyn")
10
+ SETTINGS_FILE = File.join(SETTINGS_DIR, "config.yml")
11
+
12
+ DEFAULTS = {
13
+ "default_model" => "haiku",
14
+ "auto_apply" => false,
15
+ "output_format" => "diff"
16
+ }.freeze
17
+
18
+ class << self
19
+ def get(key)
20
+ all[key.to_s]
21
+ end
22
+
23
+ def set(key, value)
24
+ data = all.merge(key.to_s => coerce_value(value))
25
+ FileUtils.mkdir_p(SETTINGS_DIR)
26
+ File.write(SETTINGS_FILE, YAML.dump(data))
27
+ data
28
+ end
29
+
30
+ def all
31
+ DEFAULTS.merge(read_settings)
32
+ end
33
+
34
+ private
35
+
36
+ def read_settings
37
+ return {} unless File.exist?(SETTINGS_FILE)
38
+
39
+ YAML.safe_load_file(SETTINGS_FILE) || {}
40
+ end
41
+
42
+ def coerce_value(value)
43
+ case value
44
+ when "true" then true
45
+ when "false" then false
46
+ when /\A\d+\z/ then value.to_i
47
+ else value
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "yaml"
5
+ require "fileutils"
6
+
7
+ module Rubyn
8
+ module Context
9
+ class CodebaseIndexer
10
+ CACHE_FILE = ".rubyn/index_cache.yml"
11
+ BATCH_SIZE = 50
12
+
13
+ attr_reader :project_root
14
+
15
+ def initialize(project_root = Dir.pwd)
16
+ @project_root = project_root
17
+ end
18
+
19
+ # Yields progress events: { phase:, current:, total:, file:, message: }
20
+ def index(force: false, &on_progress)
21
+ ruby_files = find_ruby_files
22
+ cache = force ? {} : load_cache
23
+ changed_files = detect_changes(ruby_files, cache)
24
+
25
+ if changed_files.empty?
26
+ report(on_progress, phase: :complete, message: "Everything up to date")
27
+ return { indexed: 0, skipped: ruby_files.size, chunks: 0 }
28
+ end
29
+
30
+ report(on_progress, phase: :scan, message: "Found #{changed_files.size} files to analyse")
31
+
32
+ result = process_changed_files(changed_files, cache, on_progress)
33
+
34
+ report(on_progress, phase: :upload,
35
+ message: "Uploading #{result[:files].size} files in #{batch_count(result[:files])} batches")
36
+
37
+ send_files(result[:files], on_progress)
38
+ save_cache(result[:cache])
39
+
40
+ report(on_progress, phase: :complete,
41
+ message: "Analysed #{changed_files.size} files")
42
+
43
+ { indexed: changed_files.size, skipped: ruby_files.size - changed_files.size }
44
+ end
45
+
46
+ private
47
+
48
+ def process_changed_files(changed_files, cache, on_progress = nil)
49
+ all_files = []
50
+ new_cache = cache.dup
51
+
52
+ changed_files.each_with_index do |file, idx|
53
+ report(on_progress, phase: :chunk, current: idx + 1, total: changed_files.size, file: file)
54
+
55
+ content = File.read(File.join(project_root, file))
56
+ file_hash = Digest::SHA256.hexdigest(content)
57
+ all_files << { file_path: file, content: content, file_hash: file_hash }
58
+ new_cache[file] = file_hash
59
+ end
60
+
61
+ { files: all_files, cache: new_cache }
62
+ end
63
+
64
+ def chunk_file(file_path, content)
65
+ lines = content.lines
66
+ chunks = parse_chunks(file_path, lines)
67
+
68
+ if chunks.empty?
69
+ chunks << default_chunk(file_path, content, lines)
70
+ end
71
+
72
+ chunks
73
+ end
74
+
75
+ def parse_chunks(file_path, lines)
76
+ chunks = []
77
+ current_chunk = nil
78
+ current_start = nil
79
+ nesting = 0
80
+
81
+ lines.each_with_index do |line, index|
82
+ line_num = index + 1
83
+
84
+ if line.match?(/^\s*(class|module)\s+/)
85
+ finalize_chunk(chunks, current_chunk, file_path, current_start, line_num - 1) if current_chunk
86
+ current_chunk, current_start, nesting = start_class_or_module_chunk(line, line_num)
87
+ elsif line.match?(/^\s*def\s+/) && nesting <= 1
88
+ current_chunk, current_start = start_method_chunk(line, line_num, chunks, current_chunk, current_start, file_path)
89
+ else
90
+ current_chunk[:lines] << line if current_chunk
91
+
92
+ if line.match?(/\b(do|begin)\b/) || (line.match?(/\bclass\b|\bmodule\b|\bdef\b/) && !line.match?(/end/))
93
+ nesting += 1
94
+ end
95
+ nesting -= 1 if line.strip == "end"
96
+ end
97
+ end
98
+
99
+ finalize_chunk(chunks, current_chunk, file_path, current_start, lines.size) if current_chunk
100
+ chunks
101
+ end
102
+
103
+ def start_class_or_module_chunk(line, line_num)
104
+ match = line.match(/^\s*(class|module)\s+(\S+)/)
105
+ chunk = { type: match[1], name: match[2], lines: [line] }
106
+ [chunk, line_num, 1]
107
+ end
108
+
109
+ def start_method_chunk(line, line_num, chunks, current_chunk, current_start, file_path)
110
+ if current_chunk && current_chunk[:type] == "method"
111
+ finalize_chunk(chunks, current_chunk, file_path, current_start, line_num - 1)
112
+ end
113
+
114
+ match = line.match(/^\s*def\s+(\S+)/)
115
+ method_chunk = { type: "method", name: match[1], lines: [line] }
116
+
117
+ if current_chunk.nil?
118
+ [method_chunk, line_num]
119
+ else
120
+ current_chunk[:lines] << line
121
+ [current_chunk, current_start]
122
+ end
123
+ end
124
+
125
+ def default_chunk(file_path, content, lines)
126
+ {
127
+ file_path: file_path,
128
+ chunk_type: "file",
129
+ chunk_name: File.basename(file_path, ".rb"),
130
+ chunk_content: content,
131
+ line_start: 1,
132
+ line_end: lines.size
133
+ }
134
+ end
135
+
136
+ def report(callback, **data)
137
+ callback&.call(data)
138
+ end
139
+
140
+ def batch_count(chunks)
141
+ (chunks.size.to_f / BATCH_SIZE).ceil
142
+ end
143
+
144
+ def finalize_chunk(chunks, chunk, file_path, start_line, end_line)
145
+ return unless chunk
146
+
147
+ chunks << {
148
+ file_path: file_path,
149
+ chunk_type: chunk[:type],
150
+ chunk_name: chunk[:name],
151
+ chunk_content: chunk[:lines].join,
152
+ line_start: start_line,
153
+ line_end: end_line
154
+ }
155
+ end
156
+
157
+ def find_ruby_files
158
+ Dir.glob(File.join(project_root, "**/*.rb"))
159
+ .reject { |f| f.include?("/vendor/") || f.include?("/node_modules/") || f.include?("/.rubyn/") }
160
+ .map { |f| f.sub("#{project_root}/", "") }
161
+ end
162
+
163
+ def detect_changes(files, cache)
164
+ files.reject do |file|
165
+ full_path = File.join(project_root, file)
166
+ current_hash = Digest::SHA256.hexdigest(File.read(full_path))
167
+ cache[file] == current_hash
168
+ end
169
+ end
170
+
171
+ def load_cache
172
+ path = File.join(project_root, CACHE_FILE)
173
+ return {} unless File.exist?(path)
174
+
175
+ YAML.safe_load_file(path) || {}
176
+ end
177
+
178
+ def save_cache(cache)
179
+ path = File.join(project_root, CACHE_FILE)
180
+ FileUtils.mkdir_p(File.dirname(path))
181
+ File.write(path, YAML.dump(cache))
182
+ end
183
+
184
+ def send_files(files, on_progress = nil)
185
+ project_token = Rubyn::Config::ProjectConfig.project_token
186
+ batches = files.each_slice(BATCH_SIZE).to_a
187
+
188
+ batches.each_with_index do |batch, idx|
189
+ report(on_progress, phase: :upload_batch, current: idx + 1, total: batches.size)
190
+ Rubyn.api_client.index_project(project_token: project_token, files: batch)
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end