scint 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 (72) hide show
  1. checksums.yaml +7 -0
  2. data/FEATURES.md +13 -0
  3. data/README.md +216 -0
  4. data/bin/bundler-vs-scint +233 -0
  5. data/bin/scint +35 -0
  6. data/bin/scint-io-summary +46 -0
  7. data/bin/scint-syscall-trace +41 -0
  8. data/lib/bundler/setup.rb +5 -0
  9. data/lib/bundler.rb +168 -0
  10. data/lib/scint/cache/layout.rb +131 -0
  11. data/lib/scint/cache/metadata_store.rb +75 -0
  12. data/lib/scint/cache/prewarm.rb +192 -0
  13. data/lib/scint/cli/add.rb +85 -0
  14. data/lib/scint/cli/cache.rb +316 -0
  15. data/lib/scint/cli/exec.rb +150 -0
  16. data/lib/scint/cli/install.rb +1047 -0
  17. data/lib/scint/cli/remove.rb +60 -0
  18. data/lib/scint/cli.rb +77 -0
  19. data/lib/scint/commands/exec.rb +17 -0
  20. data/lib/scint/commands/install.rb +17 -0
  21. data/lib/scint/credentials.rb +153 -0
  22. data/lib/scint/debug/io_trace.rb +218 -0
  23. data/lib/scint/debug/sampler.rb +138 -0
  24. data/lib/scint/downloader/fetcher.rb +113 -0
  25. data/lib/scint/downloader/pool.rb +112 -0
  26. data/lib/scint/errors.rb +63 -0
  27. data/lib/scint/fs.rb +119 -0
  28. data/lib/scint/gem/extractor.rb +86 -0
  29. data/lib/scint/gem/package.rb +62 -0
  30. data/lib/scint/gemfile/dependency.rb +30 -0
  31. data/lib/scint/gemfile/editor.rb +93 -0
  32. data/lib/scint/gemfile/parser.rb +275 -0
  33. data/lib/scint/index/cache.rb +166 -0
  34. data/lib/scint/index/client.rb +301 -0
  35. data/lib/scint/index/parser.rb +142 -0
  36. data/lib/scint/installer/extension_builder.rb +264 -0
  37. data/lib/scint/installer/linker.rb +226 -0
  38. data/lib/scint/installer/planner.rb +140 -0
  39. data/lib/scint/installer/preparer.rb +207 -0
  40. data/lib/scint/lockfile/parser.rb +251 -0
  41. data/lib/scint/lockfile/writer.rb +178 -0
  42. data/lib/scint/platform.rb +71 -0
  43. data/lib/scint/progress.rb +579 -0
  44. data/lib/scint/resolver/provider.rb +230 -0
  45. data/lib/scint/resolver/resolver.rb +249 -0
  46. data/lib/scint/runtime/exec.rb +141 -0
  47. data/lib/scint/runtime/setup.rb +45 -0
  48. data/lib/scint/scheduler.rb +392 -0
  49. data/lib/scint/source/base.rb +46 -0
  50. data/lib/scint/source/git.rb +92 -0
  51. data/lib/scint/source/path.rb +70 -0
  52. data/lib/scint/source/rubygems.rb +79 -0
  53. data/lib/scint/vendor/pub_grub/assignment.rb +20 -0
  54. data/lib/scint/vendor/pub_grub/basic_package_source.rb +169 -0
  55. data/lib/scint/vendor/pub_grub/failure_writer.rb +182 -0
  56. data/lib/scint/vendor/pub_grub/incompatibility.rb +150 -0
  57. data/lib/scint/vendor/pub_grub/package.rb +43 -0
  58. data/lib/scint/vendor/pub_grub/partial_solution.rb +121 -0
  59. data/lib/scint/vendor/pub_grub/rubygems.rb +45 -0
  60. data/lib/scint/vendor/pub_grub/solve_failure.rb +19 -0
  61. data/lib/scint/vendor/pub_grub/static_package_source.rb +61 -0
  62. data/lib/scint/vendor/pub_grub/strategy.rb +42 -0
  63. data/lib/scint/vendor/pub_grub/term.rb +105 -0
  64. data/lib/scint/vendor/pub_grub/version.rb +3 -0
  65. data/lib/scint/vendor/pub_grub/version_constraint.rb +129 -0
  66. data/lib/scint/vendor/pub_grub/version_range.rb +423 -0
  67. data/lib/scint/vendor/pub_grub/version_solver.rb +236 -0
  68. data/lib/scint/vendor/pub_grub/version_union.rb +178 -0
  69. data/lib/scint/vendor/pub_grub.rb +32 -0
  70. data/lib/scint/worker_pool.rb +114 -0
  71. data/lib/scint.rb +87 -0
  72. metadata +116 -0
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../fs"
4
+ require_relative "../platform"
5
+ require "pathname"
6
+
7
+ module Scint
8
+ module Installer
9
+ module Linker
10
+ module_function
11
+
12
+ # Link a single extracted gem into .bundle/ruby/{version}/
13
+ # prepared_gem: PreparedGem struct (spec, extracted_path, gemspec, from_cache)
14
+ # bundle_path: root .bundle/ directory
15
+ def link(prepared_gem, bundle_path)
16
+ link_files(prepared_gem, bundle_path)
17
+ write_binstubs(prepared_gem, bundle_path)
18
+ end
19
+
20
+ # Link gem files + gemspec only (no binstubs).
21
+ # This allows binstubs to be scheduled as a separate DAG task.
22
+ def link_files(prepared_gem, bundle_path)
23
+ ruby_dir = ruby_install_dir(bundle_path)
24
+ link_files_to_ruby_dir(prepared_gem, ruby_dir)
25
+ end
26
+
27
+ # Link gem files + gemspec into an explicit ruby gem home directory.
28
+ # This is used for the install-time hermetic build environment.
29
+ def link_files_to_ruby_dir(prepared_gem, ruby_dir)
30
+ spec = prepared_gem.spec
31
+ full_name = spec_full_name(spec)
32
+
33
+ # 1. Link gem files into gems/{full_name}/
34
+ gem_dest = File.join(ruby_dir, "gems", full_name)
35
+ unless Dir.exist?(gem_dest)
36
+ FS.hardlink_tree(prepared_gem.extracted_path, gem_dest)
37
+ end
38
+
39
+ # 2. Write gemspec into specifications/
40
+ write_gemspec(prepared_gem, ruby_dir, full_name)
41
+ end
42
+
43
+ # Write binstubs for one already-linked gem.
44
+ def write_binstubs(prepared_gem, bundle_path)
45
+ ruby_dir = ruby_install_dir(bundle_path)
46
+ spec = prepared_gem.spec
47
+ full_name = spec_full_name(spec)
48
+ gem_dest = File.join(ruby_dir, "gems", full_name)
49
+ return unless Dir.exist?(gem_dest)
50
+
51
+ # 3. Create binstubs for executables
52
+ write_binstubs_impl(prepared_gem, bundle_path, ruby_dir, gem_dest)
53
+ end
54
+
55
+ # Link multiple gems. Thread-safe — each link is independent.
56
+ def link_batch(prepared_gems, bundle_path)
57
+ prepared_gems.each { |pg| link(pg, bundle_path) }
58
+ end
59
+
60
+ # --- private helpers ---
61
+
62
+ def write_gemspec(prepared_gem, ruby_dir, full_name)
63
+ spec_dir = File.join(ruby_dir, "specifications")
64
+ FS.mkdir_p(spec_dir)
65
+ spec_path = File.join(spec_dir, "#{full_name}.gemspec")
66
+ return if File.exist?(spec_path)
67
+
68
+ content = if prepared_gem.gemspec.is_a?(String)
69
+ prepared_gem.gemspec
70
+ elsif prepared_gem.gemspec.respond_to?(:to_ruby)
71
+ augment_executable_metadata(prepared_gem.gemspec, prepared_gem.extracted_path).to_ruby
72
+ else
73
+ minimal_gemspec(prepared_gem.spec, full_name)
74
+ end
75
+
76
+ FS.atomic_write(spec_path, content)
77
+ end
78
+
79
+ def write_binstubs_impl(prepared_gem, bundle_path, ruby_dir, gem_dir)
80
+ # Look for executables declared in the gemspec
81
+ executables = extract_executables(prepared_gem, gem_dir)
82
+ return if executables.empty?
83
+
84
+ ruby_bin_dir = File.join(ruby_dir, "bin")
85
+ bundle_bin_dir = File.join(bundle_path, "bin")
86
+ FS.mkdir_p(ruby_bin_dir)
87
+ FS.mkdir_p(bundle_bin_dir)
88
+
89
+ executables.each do |exe_name|
90
+ write_ruby_bin_stub(prepared_gem, ruby_bin_dir, exe_name)
91
+ write_bundle_bin_wrapper(bundle_path, ruby_bin_dir, bundle_bin_dir, exe_name)
92
+ end
93
+ end
94
+
95
+ def write_ruby_bin_stub(prepared_gem, ruby_bin_dir, exe_name)
96
+ binstub_path = File.join(ruby_bin_dir, exe_name)
97
+ return if File.exist?(binstub_path)
98
+
99
+ spec = prepared_gem.spec
100
+ content = <<~RUBY
101
+ #!/usr/bin/env ruby
102
+ # frozen_string_literal: true
103
+ #
104
+ # This file was generated by scint for #{spec_full_name(spec)}
105
+ #
106
+ gem "#{spec.name}", "#{spec.version}"
107
+ load Gem.bin_path("#{spec.name}", "#{exe_name}", "#{spec.version}")
108
+ RUBY
109
+
110
+ FS.atomic_write(binstub_path, content)
111
+ File.chmod(0o755, binstub_path)
112
+ end
113
+
114
+ def write_bundle_bin_wrapper(bundle_path, ruby_bin_dir, bundle_bin_dir, exe_name)
115
+ wrapper_path = File.join(bundle_bin_dir, exe_name)
116
+ return if File.exist?(wrapper_path)
117
+
118
+ target = File.join(ruby_bin_dir, exe_name)
119
+ relative_target = Pathname.new(target).relative_path_from(Pathname.new(bundle_bin_dir)).to_s
120
+ content = <<~RUBY
121
+ #!/usr/bin/env ruby
122
+ # frozen_string_literal: true
123
+ exec(File.expand_path("#{relative_target}", __dir__), *ARGV)
124
+ RUBY
125
+
126
+ FS.atomic_write(wrapper_path, content)
127
+ File.chmod(0o755, wrapper_path)
128
+ end
129
+
130
+ def extract_executables(prepared_gem, gem_dir)
131
+ gemspec = prepared_gem.gemspec
132
+ executables = if gemspec.respond_to?(:executables)
133
+ Array(gemspec.executables)
134
+ elsif gemspec.is_a?(Hash) && gemspec[:executables]
135
+ Array(gemspec[:executables])
136
+ else
137
+ []
138
+ end
139
+
140
+ if executables.empty?
141
+ executables = detect_executables_from_files(gem_dir)
142
+ end
143
+
144
+ executables.map(&:to_s).reject(&:empty?).uniq
145
+ end
146
+
147
+ def detect_executables_from_files(gem_dir)
148
+ names = []
149
+ %w[exe bin].each do |subdir|
150
+ dir = File.join(gem_dir, subdir)
151
+ next unless Dir.exist?(dir)
152
+
153
+ Dir.children(dir).each do |entry|
154
+ next if entry.start_with?(".")
155
+ path = File.join(dir, entry)
156
+ names << entry if File.file?(path)
157
+ end
158
+ end
159
+ names
160
+ end
161
+
162
+ def augment_executable_metadata(gemspec, gem_dir)
163
+ detected = detect_executables_from_files(gem_dir)
164
+ return gemspec if detected.empty?
165
+
166
+ executables = Array(gemspec.executables).map(&:to_s).reject(&:empty?)
167
+ bindir = gemspec.respond_to?(:bindir) ? gemspec.bindir.to_s : ""
168
+
169
+ selected = executables.empty? ? detected : executables
170
+ inferred_bindir = infer_bindir(gem_dir, selected, bindir)
171
+ needs_execs = executables.empty?
172
+ needs_bindir = !inferred_bindir.nil? && inferred_bindir != bindir
173
+ return gemspec unless needs_execs || needs_bindir
174
+
175
+ patched = gemspec.dup
176
+ patched.executables = selected if needs_execs
177
+ patched.bindir = inferred_bindir if needs_bindir
178
+ patched
179
+ end
180
+
181
+ def infer_bindir(gem_dir, executables, current_bindir)
182
+ %w[exe bin].each do |dir|
183
+ next unless executables.all? { |exe| File.file?(File.join(gem_dir, dir, exe)) }
184
+ return dir
185
+ end
186
+
187
+ current_bindir unless current_bindir.empty?
188
+ end
189
+
190
+ def minimal_gemspec(spec, full_name)
191
+ <<~RUBY
192
+ # frozen_string_literal: true
193
+ Gem::Specification.new do |s|
194
+ s.name = #{spec.name.inspect}
195
+ s.version = #{spec.version.to_s.inspect}
196
+ s.platform = #{platform_str(spec).inspect}
197
+ s.authors = ["scint"]
198
+ s.summary = "Installed by scint"
199
+ end
200
+ RUBY
201
+ end
202
+
203
+ def spec_full_name(spec)
204
+ name = spec.name
205
+ version = spec.version
206
+ plat = platform_str(spec)
207
+ base = "#{name}-#{version}"
208
+ (plat == "ruby" || plat.empty?) ? base : "#{base}-#{plat}"
209
+ end
210
+
211
+ def platform_str(spec)
212
+ p = spec.respond_to?(:platform) ? spec.platform : nil
213
+ p.nil? ? "ruby" : p.to_s
214
+ end
215
+
216
+ def ruby_install_dir(bundle_path)
217
+ File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
218
+ end
219
+
220
+ private_class_method :write_gemspec, :write_binstubs_impl, :write_ruby_bin_stub,
221
+ :write_bundle_bin_wrapper, :extract_executables,
222
+ :detect_executables_from_files, :augment_executable_metadata, :infer_bindir,
223
+ :minimal_gemspec, :spec_full_name, :platform_str, :ruby_install_dir
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "extension_builder"
4
+ require_relative "../platform"
5
+
6
+ module Scint
7
+ module Installer
8
+ module Planner
9
+ module_function
10
+
11
+ # Compare resolved specs against what's already installed.
12
+ # Returns an Array of PlanEntry with action set to one of:
13
+ # :skip — already installed in bundle_path
14
+ # :link — extracted in global cache, just needs linking
15
+ # :download — needs downloading from remote
16
+ # :build_ext — has native extensions that need compiling
17
+ #
18
+ # Download entries are sorted largest-first so big gems start early,
19
+ # keeping the pipeline saturated while small gems fill in gaps.
20
+ def plan(resolved_specs, bundle_path, cache_layout)
21
+ ruby_dir = ruby_install_dir(bundle_path)
22
+ entries = resolved_specs.map do |spec|
23
+ plan_one(spec, ruby_dir, cache_layout)
24
+ end
25
+
26
+ # Stable partition: downloads first (big→small), then everything else
27
+ downloads, rest = entries.partition { |e| e.action == :download }
28
+ downloads.sort_by! { |e| -(estimated_size(e.spec)) }
29
+
30
+ downloads + rest
31
+ end
32
+
33
+ def plan_one(spec, ruby_dir, cache_layout)
34
+ full = cache_layout.full_name(spec)
35
+ gem_path = File.join(ruby_dir, "gems", full)
36
+ spec_path = File.join(ruby_dir, "specifications", "#{full}.gemspec")
37
+
38
+ # Built-in gems (scint itself): copy from our own lib tree.
39
+ if spec.name == "scint" && spec.source.to_s.include?("built-in")
40
+ if Dir.exist?(gem_path) && File.exist?(spec_path)
41
+ return PlanEntry.new(spec: spec, action: :skip, cached_path: nil, gem_path: gem_path)
42
+ end
43
+
44
+ return PlanEntry.new(spec: spec, action: :builtin, cached_path: nil, gem_path: gem_path)
45
+ end
46
+
47
+ # Already installed? Require both gem files and specification.
48
+ if Dir.exist?(gem_path) && File.exist?(spec_path)
49
+ if extension_link_missing?(spec, ruby_dir, cache_layout)
50
+ extracted = cache_layout.extracted_path(spec)
51
+ action = ExtensionBuilder.cached_build_available?(spec, cache_layout) ? :link : :build_ext
52
+ return PlanEntry.new(spec: spec, action: action, cached_path: extracted, gem_path: gem_path)
53
+ end
54
+
55
+ return PlanEntry.new(spec: spec, action: :skip, cached_path: nil, gem_path: gem_path)
56
+ end
57
+
58
+ # Local path sources are linked directly from their source tree.
59
+ local_source = local_source_path(spec)
60
+ if local_source
61
+ action = ExtensionBuilder.buildable_source_dir?(local_source) ? :build_ext : :link
62
+ return PlanEntry.new(spec: spec, action: action, cached_path: local_source, gem_path: gem_path)
63
+ end
64
+
65
+ # Extracted in global cache?
66
+ extracted = cache_layout.extracted_path(spec)
67
+ if Dir.exist?(extracted)
68
+ action = needs_ext_build?(spec, cache_layout) ? :build_ext : :link
69
+ return PlanEntry.new(spec: spec, action: action, cached_path: extracted, gem_path: gem_path)
70
+ end
71
+
72
+ # Needs downloading
73
+ PlanEntry.new(spec: spec, action: :download, cached_path: nil, gem_path: gem_path)
74
+ end
75
+
76
+ def needs_ext_build?(spec, cache_layout)
77
+ extracted = cache_layout.extracted_path(spec)
78
+ return false unless ExtensionBuilder.buildable_source_dir?(extracted)
79
+
80
+ !ExtensionBuilder.cached_build_available?(spec, cache_layout)
81
+ end
82
+
83
+ def extension_link_missing?(spec, ruby_dir, cache_layout)
84
+ extracted = cache_layout.extracted_path(spec)
85
+ return false unless Dir.exist?(extracted)
86
+ return false unless ExtensionBuilder.buildable_source_dir?(extracted)
87
+
88
+ full = cache_layout.full_name(spec)
89
+ ext_install_dir = File.join(
90
+ ruby_dir,
91
+ "extensions",
92
+ Platform.gem_arch,
93
+ Platform.extension_api_version,
94
+ full,
95
+ )
96
+
97
+ !Dir.exist?(ext_install_dir)
98
+ end
99
+
100
+ def ruby_install_dir(bundle_path)
101
+ File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
102
+ end
103
+
104
+ # Rough size estimate for download ordering.
105
+ # If we don't know, use 0 so unknowns sort after large known gems.
106
+ def estimated_size(spec)
107
+ return spec.size if spec.respond_to?(:size) && spec.size
108
+ 0
109
+ end
110
+
111
+ def local_source_path(spec)
112
+ source =
113
+ if spec.respond_to?(:source)
114
+ spec.source
115
+ else
116
+ spec[:source]
117
+ end
118
+ return nil unless source
119
+
120
+ source_str =
121
+ if source.respond_to?(:path)
122
+ source.path.to_s
123
+ elsif source.respond_to?(:uri) && source.class.name.end_with?("::Path")
124
+ source.uri.to_s
125
+ else
126
+ source.to_s
127
+ end
128
+ return nil if source_str.empty?
129
+ return nil if source_str.start_with?("http://", "https://")
130
+ return nil if source_str.end_with?(".git") || source_str.include?(".git/")
131
+
132
+ absolute = File.expand_path(source_str, Dir.pwd)
133
+ Dir.exist?(absolute) ? absolute : nil
134
+ end
135
+
136
+ private_class_method :plan_one, :needs_ext_build?, :extension_link_missing?,
137
+ :ruby_install_dir, :estimated_size, :local_source_path
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../downloader/pool"
4
+ require_relative "../gem/package"
5
+ require_relative "../gem/extractor"
6
+ require_relative "../cache/layout"
7
+ require_relative "../fs"
8
+ require_relative "../errors"
9
+
10
+ module Scint
11
+ module Installer
12
+ # Data structures used by the preparer.
13
+ PlanEntry = Struct.new(:spec, :action, :cached_path, :gem_path, keyword_init: true)
14
+ PreparedGem = Struct.new(:spec, :extracted_path, :gemspec, :from_cache, keyword_init: true)
15
+
16
+ class Preparer
17
+ attr_reader :results
18
+
19
+ # scheduler: optional Scint::Scheduler for enqueuing jobs
20
+ # layout: Cache::Layout instance
21
+ # on_progress: optional callback proc
22
+ def initialize(layout:, scheduler: nil, on_progress: nil)
23
+ @layout = layout
24
+ @scheduler = scheduler
25
+ @on_progress = on_progress
26
+ @download_pool = Downloader::Pool.new(on_progress: on_progress)
27
+ @package = GemPkg::Package.new
28
+ @results = []
29
+ @mutex = Thread::Mutex.new
30
+ end
31
+
32
+ # Prepare all plan entries: download + extract as needed.
33
+ # Returns array of PreparedGem.
34
+ def prepare(entries)
35
+ # Sort by estimated size descending (larger gems first for better parallelism).
36
+ # Use version as rough proxy for size if no other info available.
37
+ sorted = entries.sort_by do |e|
38
+ s = e.spec
39
+ name = s.respond_to?(:name) ? s.name : s[:name]
40
+ -(name.length) # Longer names tend to be larger packages
41
+ end
42
+
43
+ to_download = []
44
+ already_cached = []
45
+
46
+ sorted.each do |entry|
47
+ inbound = @layout.inbound_path(entry.spec)
48
+ extracted = @layout.extracted_path(entry.spec)
49
+
50
+ if File.directory?(extracted)
51
+ # Already extracted -- load gemspec from cache or re-read
52
+ gemspec = load_cached_spec(entry.spec) || read_gemspec_from_extracted(extracted, entry.spec)
53
+ already_cached << PreparedGem.new(
54
+ spec: entry.spec,
55
+ extracted_path: extracted,
56
+ gemspec: gemspec,
57
+ from_cache: true,
58
+ )
59
+ elsif File.exist?(inbound)
60
+ # Downloaded but not extracted
61
+ already_cached << extract_gem(entry.spec, inbound)
62
+ else
63
+ # Need to download
64
+ to_download << entry
65
+ end
66
+ end
67
+
68
+ begin
69
+ # Batch download everything that's missing
70
+ if to_download.any?
71
+ download_items = to_download.map do |entry|
72
+ spec = entry.spec
73
+ source_uri = gem_download_uri(entry)
74
+
75
+ {
76
+ uri: source_uri,
77
+ dest: @layout.inbound_path(spec),
78
+ spec: spec,
79
+ checksum: nil,
80
+ }
81
+ end
82
+
83
+ download_results = @download_pool.download_batch(download_items)
84
+
85
+ download_results.each do |dr|
86
+ if dr[:error]
87
+ name = dr[:spec].respond_to?(:name) ? dr[:spec].name : dr[:spec][:name]
88
+ raise InstallError, "Failed to download #{name}: #{dr[:error].message}"
89
+ end
90
+
91
+ already_cached << extract_gem(dr[:spec], dr[:path])
92
+ end
93
+ end
94
+ ensure
95
+ @download_pool.close
96
+ end
97
+
98
+ @mutex.synchronize do
99
+ @results = already_cached
100
+ end
101
+
102
+ already_cached
103
+ end
104
+
105
+ # Prepare a single entry (for use with scheduler).
106
+ def prepare_one(entry)
107
+ inbound = @layout.inbound_path(entry.spec)
108
+ extracted = @layout.extracted_path(entry.spec)
109
+
110
+ if File.directory?(extracted)
111
+ gemspec = load_cached_spec(entry.spec) || read_gemspec_from_extracted(extracted, entry.spec)
112
+ return PreparedGem.new(
113
+ spec: entry.spec,
114
+ extracted_path: extracted,
115
+ gemspec: gemspec,
116
+ from_cache: true,
117
+ )
118
+ end
119
+
120
+ unless File.exist?(inbound)
121
+ uri = gem_download_uri(entry)
122
+ @download_pool.download(uri, inbound)
123
+ end
124
+
125
+ extract_gem(entry.spec, inbound)
126
+ end
127
+
128
+ private
129
+
130
+ def extract_gem(spec, gem_path)
131
+ dest = @layout.extracted_path(spec)
132
+
133
+ if File.directory?(dest)
134
+ gemspec = load_cached_spec(spec) || read_gemspec_from_extracted(dest, spec)
135
+ return PreparedGem.new(spec: spec, extracted_path: dest, gemspec: gemspec, from_cache: true)
136
+ end
137
+
138
+ # Extract to temp dir, then atomic move
139
+ tmp_dest = "#{dest}.#{Process.pid}.tmp"
140
+ FileUtils.rm_rf(tmp_dest) if File.exist?(tmp_dest)
141
+
142
+ result = @package.extract(gem_path, tmp_dest)
143
+ FS.atomic_move(tmp_dest, dest)
144
+
145
+ # Cache the gemspec as Marshal for fast future loads
146
+ cache_spec(spec, result[:gemspec])
147
+
148
+ PreparedGem.new(
149
+ spec: spec,
150
+ extracted_path: dest,
151
+ gemspec: result[:gemspec],
152
+ from_cache: false,
153
+ )
154
+ end
155
+
156
+ def load_cached_spec(spec)
157
+ path = @layout.spec_cache_path(spec)
158
+ return nil unless File.exist?(path)
159
+ Marshal.load(File.binread(path))
160
+ rescue ArgumentError, TypeError, EOFError
161
+ nil
162
+ end
163
+
164
+ def cache_spec(spec, gemspec)
165
+ path = @layout.spec_cache_path(spec)
166
+ FS.atomic_write(path, Marshal.dump(gemspec))
167
+ rescue StandardError
168
+ # Non-fatal: cache miss on next load
169
+ end
170
+
171
+ def read_gemspec_from_extracted(extracted_dir, spec)
172
+ pattern = File.join(extracted_dir, "*.gemspec")
173
+ candidates = Dir.glob(pattern)
174
+ if candidates.any?
175
+ begin
176
+ ::Gem::Specification.load(candidates.first)
177
+ rescue StandardError
178
+ nil
179
+ end
180
+ end
181
+ end
182
+
183
+ def gem_download_uri(entry)
184
+ spec = entry.spec
185
+ name = spec.respond_to?(:name) ? spec.name : spec[:name]
186
+ version = spec.respond_to?(:version) ? spec.version : spec[:version]
187
+ platform = spec.respond_to?(:platform) ? spec.platform : spec[:platform]
188
+
189
+ filename = if platform && platform.to_s != "ruby" && platform.to_s != ""
190
+ "#{name}-#{version}-#{platform}.gem"
191
+ else
192
+ "#{name}-#{version}.gem"
193
+ end
194
+
195
+ # Use cached_path if provided, otherwise construct from source
196
+ if entry.cached_path
197
+ entry.cached_path
198
+ elsif entry.gem_path
199
+ entry.gem_path
200
+ else
201
+ # Default to rubygems.org
202
+ "https://rubygems.org/gems/#{filename}"
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end