vcdeps 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.
data/lib/vcdeps/cli.rb ADDED
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vcdeps"
4
+ require "vcvars"
5
+
6
+ module Vcdeps
7
+ # Command-line interface for the `vcdeps` executable. Mirrors the vcvars CLI:
8
+ # `run` returns an Integer exit status (never calls exit), dispatch is a plain
9
+ # case, flags are hand-parsed (no optparse), and one rescue turns every known
10
+ # error into a clean one-liner with no backtrace.
11
+ class CLI
12
+ def self.start(argv)
13
+ new.run(argv)
14
+ end
15
+
16
+ # Returns a process exit status (Integer).
17
+ def run(argv)
18
+ argv = argv.dup
19
+ command = argv.shift
20
+
21
+ case command
22
+ when "doctor" then cmd_doctor(argv)
23
+ when "where" then cmd_where(argv)
24
+ when "install" then cmd_install(argv)
25
+ when "vendor" then cmd_vendor(argv)
26
+ when "baseline" then cmd_baseline(argv)
27
+ when "bootstrap" then cmd_bootstrap(argv)
28
+ when "version", "-v", "--version" then cmd_version
29
+ when nil, "help", "-h", "--help" then print_help; 0
30
+ else
31
+ warn "vcdeps: unknown command #{command.inspect}\n\n"
32
+ print_help
33
+ 1
34
+ end
35
+ rescue Vcdeps::Error, Vcvars::Error, ArgumentError => e
36
+ # Library messages already carry the "vcdeps: " prefix (§2.1); don't double
37
+ # it when we re-emit them through the CLI.
38
+ msg = e.message.to_s.sub(/\Avcdeps:\s*/, "")
39
+ warn "vcdeps: #{msg}"
40
+ 1
41
+ rescue Interrupt
42
+ 130
43
+ end
44
+
45
+ # --- commands ------------------------------------------------------------
46
+
47
+ def cmd_version
48
+ puts "vcdeps #{Vcdeps::VERSION}"
49
+ 0
50
+ end
51
+
52
+ def cmd_doctor(argv)
53
+ require "vcdeps/doctor"
54
+ manifest = extract_opt(argv, "--manifest")
55
+ deep = !argv.include?("--quick")
56
+ checks = Doctor.run(manifest: manifest, deep: deep)
57
+
58
+ checks.each do |c|
59
+ puts "#{c.icon} #{c.label}"
60
+ next unless c.detail && !c.detail.to_s.empty?
61
+
62
+ c.detail.to_s.each_line { |line| puts " #{line.chomp}" }
63
+ end
64
+
65
+ fails = checks.count { |c| c.status == :fail }
66
+ warns = checks.count { |c| c.status == :warn }
67
+ puts
68
+ if fails.zero?
69
+ extra = warns.positive? ? " (#{warns} warning#{'s' if warns > 1})" : ""
70
+ puts "Summary: no blocking problems#{extra}."
71
+ 0
72
+ else
73
+ puts "Summary: #{fails} problem#{'s' if fails > 1} found — see remedies above."
74
+ 1
75
+ end
76
+ end
77
+
78
+ def cmd_where(_argv)
79
+ tool = Vcdeps.tool
80
+ if tool
81
+ puts tool.to_s
82
+ puts "exe: #{tool.exe}"
83
+ puts "version: #{tool.version}"
84
+ puts "source: #{tool.source}"
85
+ 0
86
+ else
87
+ Vcdeps.tool! # raises ToolNotFound with the three remedies -> rescued as a one-liner
88
+ 0
89
+ end
90
+ end
91
+
92
+ def cmd_install(argv)
93
+ manifest = extract_opt(argv, "--manifest")
94
+ triplet = extract_opt(argv, "--triplet")
95
+ force = argv.delete("--force") ? true : false
96
+ manifest_dir = resolve_manifest(manifest)
97
+
98
+ inst = Vcdeps.install!(manifest: manifest_dir, triplet: triplet, force: force,
99
+ out: $stderr)
100
+ puts inst.prefix
101
+ 0
102
+ end
103
+
104
+ def cmd_vendor(argv)
105
+ into = extract_opt(argv, "--into")
106
+ manifest = extract_opt(argv, "--manifest")
107
+ triplet = extract_opt(argv, "--triplet")
108
+ licenses = !argv.delete("--no-licenses")
109
+ shim = !argv.delete("--no-shim")
110
+
111
+ if into.nil? || into.empty?
112
+ warn "vcdeps vendor: --into DIR is required."
113
+ warn "Example: vcdeps vendor --into lib/foo/vendor"
114
+ return 1
115
+ end
116
+
117
+ inst = Vcdeps.install!(manifest: resolve_manifest(manifest), triplet: triplet,
118
+ out: $stderr)
119
+ written = Vcdeps.vendor!(inst, into: into, licenses: licenses, shim: shim,
120
+ out: $stderr)
121
+ if written.empty?
122
+ puts "(nothing to vendor — static-md or header-only build)"
123
+ else
124
+ written.each { |f| puts f }
125
+ end
126
+ 0
127
+ end
128
+
129
+ def cmd_baseline(argv)
130
+ manifest = extract_opt(argv, "--manifest")
131
+ sha = Vcdeps.baseline!(manifest: resolve_manifest(manifest), out: $stderr)
132
+ puts sha
133
+ 0
134
+ end
135
+
136
+ def cmd_bootstrap(_argv)
137
+ tool = Vcdeps.bootstrap!(out: $stderr)
138
+ puts "Bootstrapped: #{tool}"
139
+ 0
140
+ end
141
+
142
+ # --- helpers -------------------------------------------------------------
143
+
144
+ # Manifest default: cwd; if cwd has no vcpkg.json and exactly one
145
+ # ext/*/vcpkg.json exists, use that one (gem-layout DX).
146
+ def resolve_manifest(explicit)
147
+ return explicit if explicit && !explicit.empty?
148
+ return Dir.pwd if File.exist?(File.join(Dir.pwd, "vcpkg.json"))
149
+
150
+ ext_manifests = Dir[File.join(Dir.pwd, "ext", "*", "vcpkg.json")]
151
+ return File.dirname(ext_manifests.first) if ext_manifests.size == 1
152
+
153
+ Dir.pwd
154
+ end
155
+
156
+ # Pulls "--opt value" or "--opt=value" out of argv (mutating it). Returns the
157
+ # value or nil; raises on a dangling flag.
158
+ def extract_opt(argv, name)
159
+ if (i = argv.index(name))
160
+ value = argv[i + 1]
161
+ if value.nil?
162
+ argv.slice!(i, 1)
163
+ raise Error, "#{name} requires a value."
164
+ end
165
+ argv.slice!(i, 2)
166
+ return value
167
+ end
168
+ prefix = "#{name}="
169
+ if (i = argv.index { |a| a.to_s.start_with?(prefix) })
170
+ value = argv[i][prefix.length..]
171
+ argv.slice!(i, 1)
172
+ return value
173
+ end
174
+ nil
175
+ end
176
+
177
+ def print_help
178
+ puts <<~HELP
179
+ vcdeps #{Vcdeps::VERSION} — vcpkg-powered native dependencies for Ruby C extensions on Windows (MSVC).
180
+
181
+ Usage:
182
+ vcdeps doctor [--manifest DIR] [--quick]
183
+ Diagnose vcpkg acquisition + mkmf wiring
184
+ vcdeps where Show the resolved vcpkg (path, version, source)
185
+ vcdeps install [--manifest DIR] [--triplet T] [--force]
186
+ Pre-build the manifest's ports (CI / warm cache)
187
+ vcdeps vendor --into DIR [--manifest DIR] [--triplet T] [--no-licenses] [--no-shim]
188
+ install! then vendor DLLs + licenses + preload shim
189
+ vcdeps baseline [--manifest DIR]
190
+ Add/refresh "builtin-baseline" in vcpkg.json
191
+ vcdeps bootstrap Create a private vcpkg under %LOCALAPPDATA%\\vcdeps
192
+ vcdeps version
193
+ vcdeps help
194
+
195
+ Examples:
196
+ vcdeps doctor --manifest ext/foo
197
+ vcdeps install --manifest ext/foo --triplet x64-windows-static-md
198
+ vcdeps vendor --into lib/foo/vendor --manifest ext/foo
199
+ vcdeps baseline --manifest ext/foo
200
+
201
+ In extconf.rb, `require "vcdeps/mkmf"; Vcdeps.mkmf!(vendor: ...)` does the
202
+ install + wiring + vendoring automatically — no CLI step is needed to build.
203
+ HELP
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "vcdeps"
5
+ require "vcvars"
6
+ require "vcvars/doctor"
7
+
8
+ module Vcdeps
9
+ # Diagnoses vcpkg acquisition + mkmf wiring, mirroring Vcvars::Doctor's
10
+ # Check/icon/Summary pattern. Delegates the toolchain checks to vcvars (a hard
11
+ # dependency whose Doctor is public API) and adds the vcdeps-specific checks
12
+ # (§2.9): resolution, manifest baseline, opt-dir shadowing, Ruby-bin DLL
13
+ # collisions, triplet sanity, binary cache, offline readiness, home hygiene.
14
+ module Doctor
15
+ module_function
16
+
17
+ Check = Struct.new(:status, :label, :detail, keyword_init: true) do
18
+ def icon
19
+ { ok: "[ OK ]", warn: "[WARN]", fail: "[FAIL]", info: "[INFO]" }[status]
20
+ end
21
+ end
22
+
23
+ # Returns an Array<Check>. `manifest` (a dir) enables the manifest/triplet
24
+ # checks; `deep: true` adds the home-size INFO.
25
+ def run(manifest: nil, deep: true)
26
+ checks = []
27
+ checks.concat(toolchain_checks)
28
+ checks.concat(tool_checks)
29
+ checks.concat(actions_root_checks)
30
+ checks.concat(manifest_checks(manifest))
31
+ checks.concat(optdir_checks)
32
+ checks.concat(collision_checks(manifest))
33
+ checks.concat(triplet_checks)
34
+ checks.concat(cache_checks)
35
+ checks.concat(offline_checks)
36
+ checks.concat(home_checks(deep: deep))
37
+ checks
38
+ end
39
+
40
+ def healthy?(checks)
41
+ checks.none? { |c| c.status == :fail }
42
+ end
43
+
44
+ # 1. mswin Ruby + CRT linkage (delegates to vcvars). 2. vcvars viability.
45
+ def toolchain_checks
46
+ out = []
47
+ out << if Vcvars::Doctor.mswin?
48
+ Check.new(status: :ok, label: "Ruby is a native MSVC (mswin) build",
49
+ detail: "#{RUBY_VERSION} #{RbConfig::CONFIG['arch']}, CRT " \
50
+ "#{Vcvars::Doctor.crt_flag}")
51
+ else
52
+ Check.new(status: :fail, label: "Ruby is NOT an mswin build",
53
+ detail: "vcdeps targets MSVC (mswin) Ruby only. Your Ruby is " \
54
+ "#{RbConfig::CONFIG['arch']}. Not supported on MinGW/UCRT.")
55
+ end
56
+
57
+ inst = safe_locate
58
+ out << if inst
59
+ Check.new(status: :ok, label: "Visual Studio located", detail: inst.to_s)
60
+ else
61
+ Check.new(status: :warn, label: "No Visual Studio located via vcvars",
62
+ detail: "vcdeps needs cl.exe to build extensions and to find the " \
63
+ "VS-bundled vcpkg. Install \"Desktop development with " \
64
+ "C++\"; run `vcvars doctor`.")
65
+ end
66
+ out << Check.new(status: (Vcvars.active? ? :ok : :info),
67
+ label: "Developer environment #{Vcvars.active? ? 'ACTIVE' : 'not yet active'}",
68
+ detail: Vcvars.active? ? nil : "vcdeps activates it on demand " \
69
+ "(Vcvars.activate!); harmless if inactive now.")
70
+ out
71
+ end
72
+
73
+ # 3. vcpkg resolution: source/path/version, or FAIL + the three remedies.
74
+ def tool_checks
75
+ tool = safe_tool
76
+ if tool
77
+ [Check.new(status: :ok, label: "vcpkg resolved (#{tool.source})",
78
+ detail: "#{tool.exe}\n version #{tool.version}")]
79
+ else
80
+ [Check.new(status: :fail, label: "No usable vcpkg found",
81
+ detail: "Remedies: (a) install the VS component " \
82
+ "Microsoft.VisualStudio.Component.Vcpkg; (b) set " \
83
+ "VCPKG_ROOT to an existing instance; (c) run " \
84
+ "`vcdeps bootstrap` (or VCDEPS_BOOTSTRAP=1).")]
85
+ end
86
+ end
87
+
88
+ # 4. VCPKG_INSTALLATION_ROOT set but invalid (issue #9269).
89
+ def actions_root_checks
90
+ root = ENV["VCPKG_INSTALLATION_ROOT"]
91
+ return [] if root.nil? || root.empty?
92
+ return [] if ToolFinder.valid_root(root)
93
+
94
+ [Check.new(status: :warn, label: "VCPKG_INSTALLATION_ROOT is set but invalid",
95
+ detail: "#{root.tr('/', '\\')} lacks vcpkg.exe/.vcpkg-root (the " \
96
+ "runner-images #9269 bug). vcdeps SKIPS it; nothing to fix " \
97
+ "unless you rely on it.")]
98
+ end
99
+
100
+ # 5. Manifest (when given): parses; builtin-baseline present or
101
+ # vcpkg-configuration.json beside it — else FAIL with the baseline remedy.
102
+ def manifest_checks(manifest)
103
+ return [] if manifest.nil?
104
+
105
+ dir = File.expand_path(manifest)
106
+ path = File.join(dir, Manifest::MANIFEST_NAME)
107
+ return [Check.new(status: :fail, label: "No manifest at #{path.tr('/', '\\')}",
108
+ detail: "Create a vcpkg.json there.")] unless File.exist?(path)
109
+
110
+ begin
111
+ Manifest.load!(dir)
112
+ [Check.new(status: :ok, label: "Manifest is valid and baselined",
113
+ detail: path.tr("/", "\\"))]
114
+ rescue ManifestError => e
115
+ [Check.new(status: :fail, label: "Manifest problem", detail: e.message)]
116
+ end
117
+ end
118
+
119
+ # 6. opt-dir shadowing: parse configure_args for --with-opt-dir.
120
+ def optdir_checks
121
+ args = RbConfig::CONFIG["configure_args"].to_s
122
+ m = args.match(/--with-opt-dir=(\S+)/)
123
+ return [] unless m
124
+
125
+ [Check.new(status: :warn, label: "Ruby was built with --with-opt-dir",
126
+ detail: "#{m[1]} is folded into EVERY extension's paths AHEAD of " \
127
+ "appended dirs (opt-dir shadowing, R§8.4). vcdeps PREPENDS " \
128
+ "its -I / -libpath so its vcpkg tree wins; no action needed.")]
129
+ end
130
+
131
+ # 7. Ruby-bin DLL collisions: scan the install's/vendored DLL base names
132
+ # against Ruby's bindir.
133
+ def collision_checks(manifest)
134
+ return [] if manifest.nil?
135
+
136
+ bindir = RbConfig::CONFIG["bindir"].to_s
137
+ return [] if bindir.empty?
138
+
139
+ names = manifest_dll_basenames(manifest)
140
+ collisions = names.select { |n| File.exist?(File.join(bindir, n)) }
141
+ return [] if collisions.empty?
142
+
143
+ collisions.map do |n|
144
+ Check.new(status: :warn, label: "DLL name collides with Ruby's bin: #{n}",
145
+ detail: "#{File.join(bindir, n).tr('/', '\\')} exists; first-loaded " \
146
+ "wins process-wide (R§9.2). The preload shim runs first " \
147
+ "under normal require order.")
148
+ end
149
+ end
150
+
151
+ # 8. Triplet sanity: resolved triplet vs §2.6 rules.
152
+ def triplet_checks
153
+ triplet = ENV["VCDEPS_TRIPLET"]
154
+ triplet = (Triplet.default rescue nil) if triplet.nil? || triplet.empty?
155
+ return [Check.new(status: :warn, label: "Cannot derive a triplet for this arch",
156
+ detail: "Set VCDEPS_TRIPLET.")] if triplet.nil?
157
+
158
+ begin
159
+ Triplet.validate!(triplet)
160
+ [Check.new(status: :ok, label: "Triplet OK: #{triplet}", detail: nil)]
161
+ rescue TripletError => e
162
+ [Check.new(status: :fail, label: "Triplet rejected", detail: e.message)]
163
+ end
164
+ end
165
+
166
+ # 9. Binary cache: %LOCALAPPDATA%\vcpkg\archives (or override) + entry count.
167
+ def cache_checks
168
+ cache = ENV["VCPKG_DEFAULT_BINARY_CACHE"]
169
+ cache = File.join(ENV["LOCALAPPDATA"] || "", "vcpkg", "archives") if cache.nil? || cache.empty?
170
+ if File.directory?(cache)
171
+ count = Dir[File.join(cache.tr("\\", "/"), "**", "*.zip")].size
172
+ [Check.new(status: :info, label: "Binary cache present (#{count} archive(s))",
173
+ detail: cache.tr("/", "\\"))]
174
+ else
175
+ [Check.new(status: :info, label: "Binary cache not yet created",
176
+ detail: "#{cache.tr('/', '\\')} will hold built ports; first cold " \
177
+ "build populates it.")]
178
+ end
179
+ end
180
+
181
+ # 10. Registry/offline readiness: baseline tree under registries.
182
+ def offline_checks
183
+ reg = File.join(ENV["LOCALAPPDATA"] || "", "vcpkg", "registries")
184
+ if File.directory?(reg) && !Dir.empty?(reg.tr("\\", "/"))
185
+ [Check.new(status: :info, label: "Registry cache present (offline-capable)",
186
+ detail: reg.tr("/", "\\"))]
187
+ else
188
+ [Check.new(status: :info, label: "Registry cache empty",
189
+ detail: "First install of a baseline needs network for the registry " \
190
+ "git-tree fetch.")]
191
+ end
192
+ end
193
+
194
+ # 11. Vcdeps.home hygiene: spaces / non-ASCII WARN; size INFO on deep.
195
+ def home_checks(deep:)
196
+ out = []
197
+ h = Vcdeps.home
198
+ if h =~ /\s/
199
+ out << Check.new(status: :warn, label: "VCDEPS_HOME path contains spaces",
200
+ detail: "#{h} — naked -I flags and some port builds mishandle " \
201
+ "spaces. Set VCDEPS_HOME to a short ASCII path.")
202
+ elsif h =~ /[^\x00-\x7F]/
203
+ out << Check.new(status: :warn, label: "VCDEPS_HOME path has non-ASCII characters",
204
+ detail: "#{h} — some port build scripts mishandle it. Set " \
205
+ "VCDEPS_HOME to a short ASCII path.")
206
+ else
207
+ out << Check.new(status: :ok, label: "vcdeps home path is clean", detail: h)
208
+ end
209
+
210
+ if deep && File.directory?(h)
211
+ out << Check.new(status: :info, label: "vcdeps home size: #{home_size_mb(h)} MB",
212
+ detail: h)
213
+ end
214
+ out
215
+ end
216
+
217
+ # --- internals -----------------------------------------------------------
218
+
219
+ def safe_locate
220
+ Vcvars.locate
221
+ rescue StandardError
222
+ nil
223
+ end
224
+
225
+ def safe_tool
226
+ Vcdeps.tool
227
+ rescue StandardError
228
+ nil
229
+ end
230
+
231
+ # Best-effort: the DLL base names a manifest would vendor, read from an
232
+ # already-installed tree if present (so doctor stays offline and cheap).
233
+ def manifest_dll_basenames(manifest)
234
+ dir = File.expand_path(manifest)
235
+ tool = safe_tool
236
+ return [] unless tool
237
+
238
+ triplet = ENV["VCDEPS_TRIPLET"]
239
+ triplet = (Triplet.default rescue nil) if triplet.nil? || triplet.empty?
240
+ return [] unless triplet
241
+
242
+ key = Manifest.key(dir, triplet, tool.version)
243
+ root = File.join(Vcdeps.home, "installed", key)
244
+ return [] unless File.directory?(root)
245
+
246
+ inst = Installed.new(root: root, triplet: triplet, manifest_dir: dir,
247
+ key: key, tool: tool)
248
+ inst.dlls.map { |d| File.basename(d) }
249
+ rescue StandardError
250
+ []
251
+ end
252
+
253
+ def home_size_mb(dir)
254
+ total = 0
255
+ Dir.glob(File.join(dir.tr("\\", "/"), "**", "*"), File::FNM_DOTMATCH) do |f|
256
+ total += File.size(f) if File.file?(f)
257
+ end
258
+ (total / (1024.0 * 1024.0)).round(1)
259
+ rescue StandardError
260
+ 0
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vcdeps
4
+ # One installed port (name/version/triplet), parsed from an info-list filename.
5
+ Port = Struct.new(:name, :version, :triplet, keyword_init: true)
6
+
7
+ # An immutable snapshot of one completed out-of-tree install. All paths are
8
+ # absolute Strings with backslash separators (display-normalized like
9
+ # Vcvars::Locator#win). Runtime DLLs are enumerated from the authoritative
10
+ # vcpkg\info\*.list files — NEVER guessed from port names (R§8.2) — and the
11
+ # debug/ tree is never exposed (R§8.4).
12
+ class Installed
13
+ attr_reader :root, :triplet, :manifest_dir, :key, :tool
14
+
15
+ # `tool` is nil for a --with-vcdeps-dir bypass install. `prefix:` overrides
16
+ # the computed <root>\<triplet> (the bypass passes the prefix dir directly).
17
+ def initialize(root:, triplet:, manifest_dir:, key:, tool:, prefix: nil)
18
+ @root = win(root)
19
+ @triplet = triplet
20
+ @manifest_dir = win(manifest_dir)
21
+ @key = key
22
+ @tool = tool
23
+ @prefix_override = prefix ? win(prefix) : nil
24
+ end
25
+
26
+ # <root>\<triplet> (or the bypass prefix override).
27
+ def prefix
28
+ return @prefix_override if @prefix_override
29
+
30
+ File.join(@root, @triplet).tr("/", "\\")
31
+ end
32
+
33
+ def include_dir
34
+ File.join(prefix, "include").tr("/", "\\")
35
+ end
36
+
37
+ # The RELEASE lib dir — never debug\lib (R§8.4).
38
+ def lib_dir
39
+ File.join(prefix, "lib").tr("/", "\\")
40
+ end
41
+
42
+ def bin_dir
43
+ File.join(prefix, "bin").tr("/", "\\")
44
+ end
45
+
46
+ # Accessor only; vcdeps never runs pkg-config (§5.14).
47
+ def pkgconfig_dir
48
+ File.join(prefix, "lib", "pkgconfig").tr("/", "\\")
49
+ end
50
+
51
+ # The vcpkg metadata dir holding info\*.list (and status).
52
+ def info_dir
53
+ File.join(@root, "vcpkg", "info").tr("/", "\\")
54
+ end
55
+
56
+ # Array<Port> parsed from <root>\vcpkg\info\<name>_<ver>_<triplet>.list
57
+ # filenames. In bypass mode (no tool) there is no info tree, so this is [].
58
+ def ports
59
+ list_files.filter_map { |path| parse_port(File.basename(path, ".list")) }
60
+ end
61
+
62
+ # Array<String> absolute paths of RELEASE runtime DLLs.
63
+ # Normal mode: read every info-list file, take entries ending in .dll that
64
+ # are NOT under a debug/ directory, resolve to absolute backslash paths.
65
+ # Bypass mode (no tool): glob bin\*.dll.
66
+ def dlls
67
+ if bypass?
68
+ Dir[File.join(bin_dir.tr("\\", "/"), "*.dll")].sort.map { |p| win(p) }
69
+ else
70
+ out = []
71
+ list_files.each do |list|
72
+ read_list_entries(list).each do |entry|
73
+ next unless entry =~ /\.dll\z/i
74
+ next if entry =~ %r{(?:\A|/)debug/}i
75
+
76
+ out << win(File.join(@root, entry))
77
+ end
78
+ end
79
+ out.uniq.sort
80
+ end
81
+ end
82
+
83
+ # Hash{ "zlib" => "<prefix>\share\zlib\copyright", ... } for EXISTING files.
84
+ def copyright_files
85
+ result = {}
86
+ ports.each do |port|
87
+ cand = File.join(prefix, "share", port.name, "copyright")
88
+ result[port.name] = win(cand) if File.exist?(cand)
89
+ end
90
+ # Bypass mode has no ports; scan share\*\copyright directly.
91
+ if bypass?
92
+ share = File.join(prefix, "share").tr("\\", "/")
93
+ Dir[File.join(share, "*", "copyright")].each do |cp|
94
+ name = File.basename(File.dirname(cp))
95
+ result[name] ||= win(cp)
96
+ end
97
+ end
98
+ result
99
+ end
100
+
101
+ private
102
+
103
+ def bypass?
104
+ @tool.nil?
105
+ end
106
+
107
+ def win(path)
108
+ path.nil? ? path : path.tr("/", "\\")
109
+ end
110
+
111
+ # The info-list files for THIS triplet (filenames end in _<triplet>.list).
112
+ def list_files
113
+ dir = info_dir.tr("\\", "/")
114
+ return [] unless File.directory?(dir)
115
+
116
+ Dir[File.join(dir, "*_#{@triplet}.list")].sort
117
+ end
118
+
119
+ # info-list entries are "/"-separated relative paths under <root>.
120
+ def read_list_entries(path)
121
+ File.readlines(path, encoding: "UTF-8").map { |l| l.strip }.reject(&:empty?)
122
+ rescue StandardError
123
+ []
124
+ end
125
+
126
+ # "<name>_<version>_<triplet>" -> Port. Split name on the FIRST "_" and
127
+ # triplet on the LAST "_" so versions containing "_" (rare) survive.
128
+ def parse_port(base)
129
+ first = base.index("_")
130
+ last = base.rindex("_")
131
+ return nil if first.nil? || last.nil? || first == last
132
+
133
+ name = base[0...first]
134
+ version = base[(first + 1)...last]
135
+ triplet = base[(last + 1)..]
136
+ Port.new(name: name, version: version, triplet: triplet)
137
+ end
138
+ end
139
+ end