Something went wrong. Try again.
Monorepo for PackTools - tooling for Minecraft Modpacks!
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231--!pw caps=read:pack,read:lock-- @pw/explain: what `explain <mod> [pack]` prints, everything Packwand knows about one installed-- mod from its sidecar and the lock.---- A port of Packwand's Zig command (C# `Inspection.Explain`), which Packwand's tests pinned byte-- for byte before this replaced it. AI-authored (Claude).
local util = require("@pw/util")local json = require("@pw/json")
local explain = {}
local EXTENSION = ".bun.json"
--- `name` without the sidecar extension, stripped once, or `name` when it has none.local function slug_or_name(name: string): string if util.ends_with(name, EXTENSION) then return string.sub(name, 1, #name - #EXTENSION) end return nameend
--- The slug of a sidecar path's file name.local function slug_of(path: string): string local parts = string.split(path, "/") return slug_or_name(parts[#parts])end
--- The one sidecar `name` names, by path or by its file name's slug, and its text.local function find(name: string): (string, string) local normalized = slug_or_name(name) local found, text, count = nil, nil, 0 local sidecars = pw.pack.sidecars() for _, sidecar in ipairs(sidecars) do if sidecar.path == name or slug_of(sidecar.path) == normalized then found, text = sidecar.path, sidecar.text count += 1 end end if count == 1 then return found :: string, text :: string elseif count > 1 then pw.fail("invalid_request", `metadata name "{name}" is ambiguous`) end local slugs = {} for _, sidecar in ipairs(sidecars) do table.insert(slugs, slug_of(sidecar.path)) end local near = pw.text.did_you_mean(normalized, slugs) local suggestion = if #near == 0 then "" else `; did you mean {table.concat(near, ", ")}?` pw.fail("not_found", `metadata "{name}" was not found{suggestion}`) error("unreachable")end
--- A member's text when it is a string, else "".local function text(object: any, name: string): string local value = if type(object) == "table" then object[name] else nil return if type(value) == "string" then value else ""end
--- A table member, or an empty one.local function table_of(object: any, name: string): { [string]: any } local value = if type(object) == "table" then object[name] else nil return if type(value) == "table" then value else {}end
--- `value` on one line as JSON. A table's members come in key order.local function compact(value: any): string if type(value) == "number" and (value ~= math.floor(value) or math.abs(value) >= 2 ^ 53) then return tostring(value) elseif type(value) ~= "table" then return json.compact(value) end local keys = util.keys(value) local is_array = #keys == #value and #value > 0 local parts = {} for _, key in ipairs(keys) do local item = compact(value[key]) table.insert(parts, if is_array then item else json.compact(key) .. ":" .. item) end local inside = table.concat(parts, ",") return if is_array then "[" .. inside .. "]" else "{" .. inside .. "}"end
--- An id under `update.<provider>`: a string as it is, anything else as its JSON, nil when absent.local function id(provider: { [string]: any }?, key: string): string? if provider == nil then return nil end local value = provider[key] if value == nil then return nil elseif type(value) == "string" then return value end return compact(value)end
--- The side the mod takes effect on: its own, else the pack's default, else both.local function effective_side(own: string): string local side = if own ~= "" then own else pw.pack.default_side if side == "" then return "both" elseif side ~= "client" and side ~= "server" and side ~= "both" then pw.fail("invalid_request", `side must be client, server or both, not "{side}"`) end return sideend
export type Explanation = { slug: string, name: string, path: string, filename: string, side: string, pinned: boolean, hash_format: string, hash: string, download_url: string, download_mode: string, providers: { string }, modrinth_project_id: string?, modrinth_version_id: string?, curseforge_project_id: string?, curseforge_file_id: string?,}
--- Everything known about the mod `name` names.function explain.explain(name: string): Explanation local path, source = find(name) local sidecar, refusal = pw.sidecar.parse(source, path) if sidecar == nil then pw.fail(refusal.kind, refusal.message) end local update = table_of(sidecar, "update") local download = table_of(sidecar, "download") local modrinth = if type(update.modrinth) == "table" then update.modrinth else nil local curseforge = if type(update.curseforge) == "table" then update.curseforge else nil local mod_name = text(sidecar, "name") local filename = text(sidecar, "filename") local side = effective_side(text(sidecar, "side")) local row = pw.lock:get(path) return { slug = name, name = mod_name, path = path, filename = filename, side = side, pinned = row ~= nil and row.pinned, hash_format = text(download, "hash-format"), hash = text(download, "hash"), download_url = text(download, "url"), download_mode = text(download, "mode"), -- The sidecar's schema sorts its providers. providers = util.keys(update), modrinth_project_id = id(modrinth, "mod-id"), modrinth_version_id = id(modrinth, "version"), curseforge_project_id = id(curseforge, "project-id"), curseforge_file_id = id(curseforge, "file-id"), }end
local function to_json(e: Explanation): string local document = json.object() json.set(document, "slug", e.slug) json.set(document, "name", e.name) json.set(document, "path", e.path) json.set(document, "filename", e.filename) json.set(document, "side", e.side) json.set(document, "pinned", e.pinned) json.set(document, "hash_format", e.hash_format) json.set(document, "hash", e.hash) -- Optional members are left out when absent or empty. local function optional(key: string, value: string?) if value ~= nil and value ~= "" then json.set(document, key, value) end end optional("download_url", e.download_url) optional("download_mode", e.download_mode) json.set(document, "providers", json.array(e.providers)) optional("modrinth_project_id", e.modrinth_project_id) optional("modrinth_version_id", e.modrinth_version_id) optional("curseforge_project_id", e.curseforge_project_id) optional("curseforge_file_id", e.curseforge_file_id) return json.pretty(document)end
--- Labelled rows, each label padded to the longest one plus two spaces.local function fields(rows: { { string } }): string local width = 0 for _, row in ipairs(rows) do width = math.max(width, #row[1]) end local lines = {} for _, row in ipairs(rows) do table.insert(lines, " " .. row[1] .. string.rep(" ", width + 2 - #row[1]) .. row[2] .. "\n") end return table.concat(lines)end
local function to_text(e: Explanation): string local rows = { { "path", e.path }, { "file", e.filename }, { "side", e.side }, { "pinned", if e.pinned then "true" else "false" }, { "hash", `{e.hash_format} {e.hash}` }, } if e.download_url ~= "" then table.insert(rows, { "url", e.download_url }) end table.insert(rows, { "provider", if #e.providers == 0 then "none (manually managed)" else table.concat(e.providers, ", ") }) if e.modrinth_project_id ~= nil then table.insert(rows, { "modrinth", `project {e.modrinth_project_id}, version {e.modrinth_version_id or "?"}` }) end if e.curseforge_project_id ~= nil then table.insert(rows, { "curseforge", `project {e.curseforge_project_id}, file {e.curseforge_file_id or "?"}` }) end return `{e.name} ({e.slug})\n` .. fields(rows)end
--- `options_json`: `{"name","json"?}`. Reports what the command prints as one text.function explain.main(options_json: string) local options = pw.json.decode(options_json) local explanation = explain.explain(options.name) pw.report.text(if options.json then to_json(explanation) else to_text(explanation))end
return explain