--!pw caps=read:pack,read:lock -- @pw/explain: what `explain [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 name end --- 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.`: 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 side end 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