gemvault 0.1.5 → 0.2.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.
@@ -1,13 +1,18 @@
1
- require "fileutils"
1
+ require "pathname"
2
+ require_relative "vaulted_gem"
2
3
  require_relative "../../gemvault/vault"
4
+ require_relative "../../gemvault/gem_entry"
3
5
 
4
6
  module Bundler
5
7
  module Plugin
6
- # Bundler plugin source that installs gems from a .gemv vault file.
8
+ # Bundler plugin source that installs gems from a .gemv vault file. A thin
9
+ # doorway over the Bundler Source API: it resolves the vault path, hands
10
+ # Bundler the gemspecs it can install, and delegates each gem's placement in
11
+ # the bundle to a VaultedGem.
7
12
  class VaultSource
8
13
  def initialize(opts)
9
14
  super
10
- @vault_path = resolve_vault_path(@uri)
15
+ @vault_path = Pathname(@uri).expand_path(Bundler.root)
11
16
  @allow_remote = false
12
17
  end
13
18
 
@@ -15,7 +20,9 @@ module Bundler
15
20
  validate_vault_exists!
16
21
 
17
22
  Gemvault::Vault.open(@vault_path) do |vault|
18
- vault.gem_entries.filter_map { |entry| gemspec_file_for(vault, entry) }
23
+ vault.gem_entries
24
+ .map { |entry| vault.spec_from_blob(entry) }
25
+ .filter_map { |spec| gemspec_file_for(spec) }
19
26
  end
20
27
  end
21
28
 
@@ -30,22 +37,35 @@ module Bundler
30
37
  @allow_remote = true
31
38
  end
32
39
 
40
+ # The inverse of remote!. Bundler's local-only resolve (Definition#check!,
41
+ # reached by `bundle check`) calls local_only! on every source. The base
42
+ # Bundler::Plugin::API::Source defines no such hook, so without this the
43
+ # command dies with NoMethodError; here it simply stops advertising gems
44
+ # that live only inside the archive.
45
+ def local_only!
46
+ @allow_remote = false
47
+ end
48
+
49
+ # Also invoked on every source during resolution (Definition#prefer_local!,
50
+ # for `--prefer-local`). The vault reads from one local file already, so
51
+ # there is no remote to deprioritize.
52
+ def prefer_local!; end
53
+
33
54
  def install(spec, opts = {})
34
- gem_dir = gem_dir_for(spec.full_name)
35
- return use_installed_gem(spec, gem_dir) if File.directory?(gem_dir) && !opts[:force]
55
+ gem = VaultedGem.new(spec)
56
+ return gem.adopt if gem.installed? && !opts[:force]
36
57
 
37
- Bundler.ui.confirm "Installing #{version_message(spec)} from vault #{@uri}"
38
- install_into_bundle(spec, opts)
39
- spec.post_install_message
58
+ Bundler.ui.confirm "Installing #{gem.version_message} from vault #{@uri}"
59
+ extract(spec) { |gem_path| gem.install(gem_path:, build_args: opts[:build_args] || []) }
40
60
  end
41
61
 
42
62
  def options_to_lock
43
63
  {}
44
64
  end
45
65
 
46
- # No source-level install_path to copy: VaultSource#install installs
47
- # each gem into Bundler.bundle_path via RubyGemsGemInstaller, so the
48
- # default Source#cache would dereference a non-existent directory.
66
+ # No source-level install_path to copy: VaultSource#install installs each
67
+ # gem into Bundler.bundle_path via RubyGemsGemInstaller, so the default
68
+ # Source#cache would dereference a non-existent directory.
49
69
  def cache(spec, custom_path = nil); end
50
70
 
51
71
  def to_s
@@ -54,80 +74,35 @@ module Bundler
54
74
 
55
75
  private
56
76
 
57
- def gemspec_file_for(vault, entry)
58
- spec = vault.spec_from_blob(entry.name, entry.version, entry.platform)
59
- gem_dir = gem_dir_for(spec.full_name)
60
- return anchor_gemspec(gem_dir, spec.full_name, spec.to_ruby) if File.directory?(gem_dir)
77
+ def gemspec_file_for(spec)
78
+ gem = VaultedGem.new(spec)
79
+ return gem.anchor(spec.to_ruby) if gem.installed?
61
80
  return nil unless @allow_remote
62
81
 
63
- write_tmp_gemspec(spec.full_name, spec.to_ruby)
82
+ write_tmp_gemspec(spec)
64
83
  end
65
84
 
66
- def write_tmp_gemspec(full_name, spec_ruby)
67
- gemspec_dir = File.join(Bundler.tmp("vault_source"), "specifications")
68
- FileUtils.mkdir_p(gemspec_dir)
69
- gemspec_path = File.join(gemspec_dir, "#{full_name}.gemspec")
70
- File.write(gemspec_path, spec_ruby)
71
- gemspec_path
85
+ def write_tmp_gemspec(spec)
86
+ tmp_dir = Pathname(Bundler.tmp("vault_source"))
87
+ gemspec_dir = tmp_dir / "specifications"
88
+ gemspec_dir.mkpath
89
+ gemspec = gemspec_dir / "#{spec.full_name}.gemspec"
90
+ gemspec.write(spec.to_ruby)
91
+ gemspec.to_s
72
92
  end
73
93
 
74
- def use_installed_gem(spec, gem_dir)
75
- Bundler.ui.debug "Using #{version_message(spec)} from vault #{@uri}"
76
- spec.full_gem_path = gem_dir
77
- spec.loaded_from = File.join(gem_dir, "#{spec.full_name}.gemspec")
78
- nil
79
- end
80
-
81
- def install_into_bundle(spec, opts)
94
+ def extract(spec, &)
82
95
  Gemvault::Vault.open(@vault_path) do |vault|
83
- vault.with_gem_file(spec.name, spec.version.to_s, platform: spec.platform.to_s) do |gem_path|
84
- installed_spec = build_installer(gem_path, opts).install
85
- gem_dir = installed_spec.full_gem_path
86
- spec.full_gem_path = gem_dir
87
- spec.loaded_from = anchor_gemspec(gem_dir, spec.full_name, installed_spec.to_ruby)
88
- end
96
+ vault.with_gem_file(entry_for(spec), &)
89
97
  end
90
98
  end
91
99
 
92
- def build_installer(gem_path, opts)
93
- require "bundler/rubygems_gem_installer"
94
-
95
- Bundler::RubyGemsGemInstaller.at(
96
- gem_path,
97
- install_dir: Bundler.bundle_path.to_s,
98
- bin_dir: Bundler.system_bindir.to_s,
99
- ignore_dependencies: true,
100
- wrappers: true,
101
- env_shebang: true,
102
- build_args: opts[:build_args] || [],
103
- )
104
- end
105
-
106
- def version_message(spec)
107
- message = "#{spec.name} #{spec.version}"
108
- message += " (#{spec.platform})" if spec.platform != Gem::Platform::RUBY && !spec.platform.nil?
109
- message
110
- end
111
-
112
- def resolve_vault_path(uri)
113
- File.expand_path(uri, Bundler.root.to_s)
114
- end
115
-
116
- # Bundler computes full_gem_path as dirname(loaded_from) for plugin
117
- # sources, so the gemspec must live inside the gem directory -- not
118
- # in specifications/ -- for load paths to resolve correctly.
119
- def anchor_gemspec(gem_dir, full_name, spec_ruby)
120
- gemspec_path = File.join(gem_dir, "#{full_name}.gemspec")
121
- File.write(gemspec_path, spec_ruby) unless File.exist?(gemspec_path)
122
- gemspec_path
123
- end
124
-
125
- def gem_dir_for(full_name)
126
- File.join(Bundler.bundle_path, "gems", full_name)
100
+ def entry_for(spec)
101
+ Gemvault::GemEntry.from_spec(spec)
127
102
  end
128
103
 
129
104
  def validate_vault_exists!
130
- return if File.file?(@vault_path)
105
+ return if @vault_path.file?
131
106
 
132
107
  raise Bundler::PathError,
133
108
  "Could not find vault '#{@uri}' referenced in Gemfile " \
@@ -0,0 +1,66 @@
1
+ require "pathname"
2
+
3
+ module Bundler
4
+ module Plugin
5
+ # A gem sourced from a vault as it lives (or will live) inside the bundle.
6
+ # Owns where the gem unpacks, whether it is already installed, installing an
7
+ # extracted .gem file, and anchoring its gemspec inside the gem directory --
8
+ # Bundler derives full_gem_path from dirname(loaded_from), so the gemspec
9
+ # must sit beside the gem, not in specifications/.
10
+ class VaultedGem
11
+ def initialize(spec)
12
+ @spec = spec
13
+ @gem_dir = Bundler.bundle_path.join("gems", spec.full_name)
14
+ end
15
+
16
+ def installed?
17
+ @gem_dir.directory?
18
+ end
19
+
20
+ def version_message
21
+ message = "#{@spec.name} #{@spec.version}"
22
+ message += " (#{@spec.platform})" if @spec.platform != Gem::Platform::RUBY && !@spec.platform.nil?
23
+ message
24
+ end
25
+
26
+ # Point the Bundler spec at the already-unpacked gem.
27
+ def adopt
28
+ Bundler.ui.debug "Using #{version_message} from vault"
29
+ @spec.full_gem_path = @gem_dir.to_s
30
+ @spec.loaded_from = anchor(@spec.to_ruby)
31
+ nil
32
+ end
33
+
34
+ # Install the extracted .gem file into the bundle, point the spec at it,
35
+ # and return its post-install message.
36
+ def install(gem_path:, build_args:)
37
+ installed = installer(gem_path:, build_args:).install
38
+ @spec.full_gem_path = installed.full_gem_path
39
+ @spec.loaded_from = anchor(installed.to_ruby, dir: installed.full_gem_path)
40
+ @spec.post_install_message
41
+ end
42
+
43
+ def anchor(spec_ruby, dir: @gem_dir)
44
+ gemspec = Pathname(dir).join("#{@spec.full_name}.gemspec")
45
+ gemspec.write(spec_ruby) unless gemspec.exist?
46
+ gemspec.to_s
47
+ end
48
+
49
+ private
50
+
51
+ def installer(gem_path:, build_args:)
52
+ require "bundler/rubygems_gem_installer"
53
+
54
+ Bundler::RubyGemsGemInstaller.at(
55
+ gem_path,
56
+ install_dir: Bundler.bundle_path.to_s,
57
+ bin_dir: Bundler.system_bindir.to_s,
58
+ ignore_dependencies: true,
59
+ wrappers: true,
60
+ env_shebang: true,
61
+ build_args:,
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,6 @@
1
+ module Gemvault
2
+ # One member of a vault's tar archive: a named blob of bytes. A value object
3
+ # so archive code names what it moves around instead of juggling raw arrays.
4
+ class ArchiveEntry < Data.define(:name, :bytes)
5
+ end
6
+ end
@@ -1,5 +1,6 @@
1
1
  require "command_kit/command"
2
2
  require_relative "../vault"
3
+ require_relative "../vault_path"
3
4
 
4
5
  module Gemvault
5
6
  class CLI
@@ -7,11 +8,14 @@ module Gemvault
7
8
  class Command < CommandKit::Command
8
9
  private
9
10
 
10
- def with_vault(path, create: false, &block)
11
- Gemvault::Vault.open(path, create: create, &block)
12
- rescue Gemvault::Vault::Error => e
13
- print_error(e.message)
14
- exit(1)
11
+ def with_vault(locator, create: false, &block)
12
+ begin
13
+ path = VaultPath.resolve(locator)
14
+ Gemvault::Vault.open(path, create:, &block)
15
+ rescue Gemvault::Vault::Error => e
16
+ print_error(e.message)
17
+ exit(1)
18
+ end
15
19
  end
16
20
  end
17
21
  end
@@ -1,4 +1,4 @@
1
- require "fileutils"
1
+ require "pathname"
2
2
  require_relative "../command"
3
3
 
4
4
  module Gemvault
@@ -25,21 +25,21 @@ module Gemvault
25
25
  desc: "Output directory"
26
26
 
27
27
  def run(vault, name, version = nil)
28
- output_dir = options[:output]
28
+ output_dir = Pathname(options[:output])
29
29
 
30
30
  with_vault(vault) do |v|
31
- ::FileUtils.mkdir_p(output_dir)
32
- entries = matching_entries(v, name, version)
31
+ output_dir.mkpath
32
+ entries = matching_entries(vault: v, name:, version:)
33
33
  abort_missing_gem(name) if entries.empty?
34
- extract_entries(v, entries, output_dir)
34
+ extract_entries(vault: v, entries:, output_dir:)
35
35
  end
36
36
  end
37
37
 
38
38
  private
39
39
 
40
- def matching_entries(vault, name, version)
41
- entries = vault.gem_entries.select { |e| e.name == name }
42
- version ? entries.select { |e| e.version == version } : entries
40
+ def matching_entries(vault:, name:, version:)
41
+ entries = vault.gem_entries.select { |entry| entry.name == name }
42
+ version ? entries.select { |entry| entry.version == version } : entries
43
43
  end
44
44
 
45
45
  def abort_missing_gem(name)
@@ -47,10 +47,10 @@ module Gemvault
47
47
  exit(1)
48
48
  end
49
49
 
50
- def extract_entries(vault, entries, output_dir)
50
+ def extract_entries(vault:, entries:, output_dir:)
51
51
  entries.each do |entry|
52
- data = vault.gem_data(entry.name, entry.version, platform: entry.platform)
53
- File.binwrite(File.join(output_dir, entry.filename), data)
52
+ data = vault.gem_data(entry)
53
+ (output_dir / entry.filename).binwrite(data)
54
54
  puts "Extracted #{entry.filename}"
55
55
  end
56
56
  end
@@ -1,3 +1,4 @@
1
+ require "pathname"
1
2
  require_relative "../command"
2
3
 
3
4
  module Gemvault
@@ -12,9 +13,9 @@ module Gemvault
12
13
  desc: "Vault name (auto-appends .gemv)"
13
14
 
14
15
  def run(name)
15
- path = name.end_with?(".gemv") ? name : "#{name}.gemv"
16
+ path = Pathname(name.end_with?(".gemv") ? name : "#{name}.gemv")
16
17
 
17
- if File.exist?(path)
18
+ if path.exist?
18
19
  print_error("#{path} already exists")
19
20
  exit(1)
20
21
  end
@@ -26,15 +26,25 @@ module Gemvault
26
26
  desc: "Gem version (overrides positional and NAME-VERSION forms)"
27
27
 
28
28
  def run(vault, name, positional_version = nil)
29
- ref = Gemvault::GemReference.parse(name, version: options[:version] || positional_version)
30
- with_vault(vault) { |v| report_removal(v.remove(ref)) }
31
- rescue Gemvault::GemReference::NonExactVersionError => e
32
- print_error(e.message)
33
- exit(1)
29
+ begin
30
+ version = options[:version] || positional_version
31
+ reference = Gemvault::GemReference.parse(name, version:)
32
+ remove_matching(vault:, reference:)
33
+ rescue Gemvault::GemReference::NonExactVersionError => e
34
+ print_error(e.message)
35
+ exit(1)
36
+ end
34
37
  end
35
38
 
36
39
  private
37
40
 
41
+ def remove_matching(vault:, reference:)
42
+ with_vault(vault) do |v|
43
+ removed = v.remove(reference)
44
+ report_removal(removed)
45
+ end
46
+ end
47
+
38
48
  def report_removal(count)
39
49
  if count.zero?
40
50
  print_error("No matching gem found")
@@ -1,4 +1,5 @@
1
1
  require_relative "../command"
2
+ require_relative "../../vault_path"
2
3
  require_relative "../../vault_upgrade"
3
4
 
4
5
  module Gemvault
@@ -17,29 +18,52 @@ module Gemvault
17
18
  option :no_backup, desc: "Do not write a .bak copy before upgrading"
18
19
 
19
20
  def run(vault)
20
- upgrade = Gemvault::VaultUpgrade.new(vault, backup: !options[:no_backup])
21
- summary = upgrade.plan
22
- return report_no_op(vault, summary) if summary.no_op?
23
- return report_dry_run(vault, summary) if options[:dry_run]
24
-
25
- upgrade.call
26
- report_upgraded(vault, summary)
27
- rescue Gemvault::Vault::Error => e
28
- print_error(e.message)
29
- exit(1)
21
+ begin
22
+ upgrade = build_upgrade(vault)
23
+ dispatch(vault:, upgrade:)
24
+ rescue Gemvault::Vault::Error => e
25
+ print_error(e.message)
26
+ exit(1)
27
+ end
30
28
  end
31
29
 
32
30
  private
33
31
 
34
- def report_no_op(vault, summary)
32
+ def build_upgrade(vault)
33
+ path = VaultPath.resolve(vault)
34
+ Gemvault::VaultUpgrade.new(path, backup: backup?)
35
+ end
36
+
37
+ def backup?
38
+ !options[:no_backup]
39
+ end
40
+
41
+ def dispatch(vault:, upgrade:)
42
+ summary = upgrade.plan
43
+ case decision(summary)
44
+ in { no_op: true }
45
+ report_no_op(vault:, summary:)
46
+ in { dry_run: true }
47
+ report_dry_run(vault:, summary:)
48
+ else
49
+ upgrade.call
50
+ report_upgraded(vault:, summary:)
51
+ end
52
+ end
53
+
54
+ def decision(summary)
55
+ { no_op: summary.no_op?, dry_run: options[:dry_run] }
56
+ end
57
+
58
+ def report_no_op(vault:, summary:)
35
59
  puts "#{vault} is already current (format #{summary.to_version})"
36
60
  end
37
61
 
38
- def report_dry_run(vault, summary)
62
+ def report_dry_run(vault:, summary:)
39
63
  puts "Would upgrade #{vault}: #{summary} (#{summary.gem_count} gems)"
40
64
  end
41
65
 
42
- def report_upgraded(vault, summary)
66
+ def report_upgraded(vault:, summary:)
43
67
  puts "Upgraded #{vault}: #{summary} (#{summary.gem_count} gems)"
44
68
  end
45
69
  end
@@ -1,4 +1,5 @@
1
1
  require "sqlite3"
2
+ require "pathname"
2
3
  require_relative "vault"
3
4
  require_relative "vault_session"
4
5
  require_relative "gem_extraction"
@@ -18,7 +19,7 @@ module Gemvault
18
19
  attr_reader :path
19
20
 
20
21
  def initialize(path)
21
- @path = File.expand_path(path)
22
+ @path = Pathname(path).expand_path
22
23
  open_vault!
23
24
  end
24
25
 
@@ -30,12 +31,12 @@ module Gemvault
30
31
  raise Vault::ReadOnlyError, read_only_message
31
32
  end
32
33
 
33
- def gem_data(name, version, platform: "ruby")
34
+ def gem_data(entry)
34
35
  row = @db.execute(
35
36
  "SELECT data FROM gems WHERE name = ? AND version = ? AND platform = ?",
36
- [name, version, platform],
37
+ [entry.name, entry.version, entry.platform],
37
38
  ).first
38
- raise Vault::NotFoundError, "Gem not found: #{name}-#{version} (#{platform})" unless row
39
+ raise Vault::NotFoundError, "Gem not found: #{entry}" unless row
39
40
 
40
41
  row["data"]
41
42
  end
@@ -60,28 +61,30 @@ module Gemvault
60
61
 
61
62
  def format_version
62
63
  row = @db.execute("SELECT value FROM metadata WHERE key = 'vault_version'").first
63
- row ? row["value"].to_i : FORMAT_VERSION
64
+ return FORMAT_VERSION unless row
65
+
66
+ row["value"].to_i
64
67
  end
65
68
 
66
69
  private
67
70
 
68
71
  def open_vault!
69
- raise Vault::NotFoundError, "Vault not found: #{@path}" unless File.exist?(@path)
72
+ raise Vault::NotFoundError, "Vault not found: #{@path}" unless @path.exist?
70
73
 
71
74
  validate_sqlite!
72
75
  @db = new_database
73
- Vault.assert_readable!(format_version, @path)
76
+ Vault.assert_readable!(version: format_version, path: @path)
74
77
  Deprecation.warn_once(deprecation_message)
75
78
  end
76
79
 
77
80
  def new_database
78
- db = SQLite3::Database.new(@path)
81
+ db = SQLite3::Database.new(@path.to_s)
79
82
  db.results_as_hash = true
80
83
  db
81
84
  end
82
85
 
83
86
  def validate_sqlite!
84
- return if File.binread(@path, Vault::SQLITE_MAGIC.bytesize) == Vault::SQLITE_MAGIC
87
+ return if @path.binread(Vault::SQLITE_MAGIC.bytesize) == Vault::SQLITE_MAGIC
85
88
 
86
89
  raise Vault::Error, "Not a valid vault file (not SQLite): #{@path}"
87
90
  end
@@ -3,44 +3,46 @@ module Gemvault
3
3
  # (stderr in production). Silenceable per-block or via an environment opt-out
4
4
  # so automated pipelines that consume a legacy vault are not spammed.
5
5
  module Deprecation
6
+ extend self
7
+
6
8
  ENV_KEY = "GEMVAULT_SILENCE_DEPRECATIONS".freeze
7
9
 
8
- class << self
9
- attr_writer :output
10
+ attr_writer :output
10
11
 
11
- def output
12
- @output ||= $stderr
13
- end
12
+ def output
13
+ @output ||= $stderr
14
+ end
14
15
 
15
- def warn_once(message)
16
- return if silenced? || seen.include?(message)
16
+ def warn_once(message)
17
+ return if silenced?
18
+ return unless seen.add?(message)
17
19
 
18
- seen << message
19
- output.puts("gemvault: #{message}")
20
- end
20
+ output.puts("gemvault: #{message}")
21
+ end
21
22
 
22
- def silence
23
- previous = @silenced
24
- @silenced = true
23
+ def silence
24
+ previous = @silenced
25
+ @silenced = true
26
+ begin
25
27
  yield
26
28
  ensure
27
29
  @silenced = previous
28
30
  end
31
+ end
29
32
 
30
- def silenced?
31
- @silenced || ENV.key?(ENV_KEY)
32
- end
33
+ def silenced?
34
+ @silenced || ENV.key?(ENV_KEY)
35
+ end
33
36
 
34
- def reset!
35
- @seen = []
36
- @silenced = false
37
- end
37
+ def reset!
38
+ @seen = Set.new
39
+ @silenced = false
40
+ end
38
41
 
39
- private
42
+ private
40
43
 
41
- def seen
42
- @seen ||= []
43
- end
44
+ def seen
45
+ @seen ||= Set.new
44
46
  end
45
47
  end
46
48
  end
@@ -1,38 +1,34 @@
1
1
  module Gemvault
2
- # Value object for one gem row (name, version, platform, created_at) in a vault.
3
- class GemEntry
4
- attr_reader :name, :version, :platform, :created_at
2
+ # Value object for one gem's identity (name, version, platform) and the time
3
+ # it was stored in a vault. Equality, hashing, and to_h come from Data.
4
+ class GemEntry < Data.define(:name, :version, :platform, :created_at)
5
+ RUBY_PLATFORM_NAME = "ruby".freeze
5
6
 
6
- def initialize(name:, version:, platform: "ruby", created_at: nil)
7
- @name = name
8
- @version = version
9
- @platform = platform
10
- @created_at = created_at
7
+ ##
8
+ # Builds an entry from a <tt>Gem::Specification</tt> (or any object with
9
+ # +name+, +version+, and +platform+), stringifying the version and platform.
10
+ def self.from_spec(spec, created_at: nil)
11
+ new(name: spec.name, version: spec.version.to_s, platform: spec.platform.to_s, created_at:)
11
12
  end
12
13
 
13
- def full_name
14
- platform == "ruby" ? "#{name}-#{version}" : "#{name}-#{version}-#{platform}"
14
+ def initialize(name:, version:, platform: RUBY_PLATFORM_NAME, created_at: nil)
15
+ super
15
16
  end
16
17
 
17
- def filename
18
- "#{full_name}.gem"
19
- end
18
+ def ruby_platform? = platform == RUBY_PLATFORM_NAME
20
19
 
21
- def to_s
22
- platform == "ruby" ? "#{name}-#{version}" : "#{name}-#{version} (#{platform})"
20
+ def full_name
21
+ ruby_platform? ? "#{name}-#{version}" : "#{name}-#{version}-#{platform}"
23
22
  end
24
23
 
25
- def ==(other)
26
- other.is_a?(self.class) &&
27
- name == other.name &&
28
- version == other.version &&
29
- platform == other.platform
30
- end
24
+ def filename = "#{full_name}.gem"
31
25
 
32
- alias eql? ==
26
+ def same_identity_as?(other)
27
+ name == other.name && version == other.version && platform == other.platform
28
+ end
33
29
 
34
- def hash
35
- [name, version, platform].hash
30
+ def to_s
31
+ ruby_platform? ? "#{name}-#{version}" : "#{name}-#{version} (#{platform})"
36
32
  end
37
33
  end
38
34
  end