From f8c37c0c161765a95023d90fb685ea72cb323456 Mon Sep 17 00:00:00 2001 From: Jo Garnier Date: Thu, 4 Jun 2026 09:47:16 +0000 Subject: [PATCH] add lua testing --- README.md | 112 ++++++ cmd.go | 4 + cmd_test.go | 2 +- doc/yay.8 | 72 +++- go.mod | 1 + go.sum | 2 + init.lua | 171 +++++++++ init.lua.example | 171 +++++++++ main.go | 7 + pkg/menus/clean_menu.go | 2 +- pkg/menus/diff_menu.go | 2 +- pkg/menus/edit_menu.go | 2 +- pkg/query/query_builder.go | 30 ++ pkg/query/query_builder_test.go | 91 ++++- pkg/query/types.go | 31 ++ pkg/runtime/runtime.go | 1 + pkg/settings/config.go | 187 ++++++++- pkg/settings/config_test.go | 297 +++++++++++++++ pkg/settings/dirs.go | 48 +++ pkg/settings/dirs_test.go | 61 +++ pkg/settings/lua/load.go | 207 ++++++++++ pkg/settings/lua/load_test.go | 145 +++++++ pkg/settings/lua/lua.go | 645 ++++++++++++++++++++++++++++++++ pkg/settings/lua/lua_test.go | 106 ++++++ pkg/upgrade/service.go | 159 +++++++- pkg/upgrade/service_test.go | 147 ++++++++ pkg/upgrade/sources.go | 52 ++- pkg/upgrade/sources_test.go | 24 +- query_test.go | 2 +- sync.go | 72 +++- sync_test.go | 87 +++++ 31 files changed, 2894 insertions(+), 46 deletions(-) create mode 100644 init.lua create mode 100644 init.lua.example create mode 100644 pkg/settings/lua/load.go create mode 100644 pkg/settings/lua/load_test.go create mode 100644 pkg/settings/lua/lua.go create mode 100644 pkg/settings/lua/lua_test.go diff --git a/README.md b/README.md index cb7fab68..f1140131 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,118 @@ pacman -S --needed git base-devel yay - Use `yay -Y --devel --save` to make development package updates permanently enabled (`yay` and `yay -Syu` will then always check dev packages) +## Lua Configuration (Experimental) + +If `init.lua` exists in the current working directory, yay uses it for local +testing. Otherwise it falls back to the yay config directory. In either case, +Lua config replaces the user `yay.conf` and legacy `config.json` files. + +- Working directory path: `./init.lua` +- Config path: `$XDG_CONFIG_HOME/yay/init.lua` +- Fallback path: `$HOME/.config/yay/init.lua` +- Shipped example: [`init.lua.example`](./init.lua.example) + +### `yay.opt` + +Use `yay.opt.` for normal configuration values. Lua keys accept the same +settings as the INI config and can be written in snake_case or their existing +config name. + +```lua +yay.opt.editor = "nvim" +yay.opt.build_dir = yay.api.expand("~/.cache/yay") +yay.opt.clean_menu = true +yay.opt.request_split_n = 150 +``` + +### `yay.api` + +- `yay.api.getenv(name)` returns the environment variable value or `nil` +- `yay.api.expand(path)` expands environment variables and `~/...` +- `yay.api.info(message)` writes an info log line +- `yay.api.warn(message)` writes a warning log line +- `yay.api.error(message)` writes an error log line +- `yay.api.capture(cmd, ...)` runs a command and returns `stdout`, `stderr`, and `exit_code` +- `yay.api.run(cmd, ...)` runs a command with inherited stdio and returns `exit_code` +- `yay.api.json_decode(json)` decodes JSON into Lua tables + +### Hooks + +- `yay.hook.on_prompt(name, default)` can override menu answers for `clean`, + `diff`, `edit`, and `upgrade` +- `yay.hook.should_include_aur_update(pkg)` can override the built-in + version-based AUR upgrade decision + +The `pkg` table passed to `should_include_aur_update` contains: + +- `name` +- `base` +- `repository` +- `local_version` +- `remote_version` +- `local_build_date` +- `remote_last_modified` +- `default_include` + +This is enough to recreate the old behavior of selecting AUR updates by update +time instead of version: + +```lua +yay.hook.should_include_aur_update = function(pkg) + if pkg.default_include then + return true + end + + if pkg.repository ~= "aur" then + return false + end + + if pkg.local_version ~= pkg.remote_version then + return false + end + + if pkg.local_build_date == 0 then + return false + end + + return pkg.remote_last_modified > pkg.local_build_date +end +``` + +### External Providers + +Lua can also register fully custom upgrade providers under `yay.provider`. +Each provider owns both discovery and execution logic, so yay only handles the +menu plumbing. + +- `yay.provider..search(terms)` returns a Lua array of search results +- `yay.provider..install(items)` executes the selected search/yogurt items for that provider +- `yay.provider..list()` returns a Lua array of upgrade items +- `yay.provider..upgrade(items)` executes the selected items for that provider + +Each search result can contain: + +- `name` +- `repository` +- `base` +- `version` +- `installed_version` +- `description` +- `extra` + +Each upgrade item can contain: + +- `name` +- `repository` +- `base` +- `local_version` +- `remote_version` +- `extra` + +The shipped [`init.lua.example`](./init.lua.example) includes a Homebrew +provider that uses `brew search` and `brew install` for search/yogurt mode, +plus `brew outdated --json=v2` and `brew upgrade` for upgrades. + ## Examples of Custom Operations | Command | Description | diff --git a/cmd.go b/cmd.go index 1f5db4b1..08fa0ff1 100644 --- a/cmd.go +++ b/cmd.go @@ -88,6 +88,10 @@ Permanent configuration options: --noanswerdiff Unset the answer for the edit diff menu --noansweredit Unset the answer for the edit pkgbuild menu --noanswerupgrade Unset the answer for the upgrade menu + Experimental: if init.lua exists in the yay config + directory, it replaces yay.conf/config.json and can + set yay.opt.*, yay.hook.on_prompt(name, default), + and yay.api.{getenv,expand,info,warn,error} --cleanmenu Give the option to clean build PKGBUILDS --diffmenu Give the option to show diffs for build files --editmenu Give the option to edit/view PKGBUILDS diff --git a/cmd_test.go b/cmd_test.go index 65f62d49..d268e0b1 100644 --- a/cmd_test.go +++ b/cmd_test.go @@ -112,7 +112,7 @@ func TestYogurtMenuAURDB(t *testing.T) { Logger: logger, CmdBuilder: cmdBuilder, VCSStore: &vcs.Mock{}, - QueryBuilder: query.NewSourceQueryBuilder(aurCache, logger, "votes", parser.ModeAny, "name", + QueryBuilder: query.NewSourceQueryBuilder(aurCache, nil, logger, "votes", parser.ModeAny, "name", true, false, true), AURClient: aurCache, } diff --git a/doc/yay.8 b/doc/yay.8 index d2a19912..ac30becf 100644 --- a/doc/yay.8 +++ b/doc/yay.8 @@ -547,9 +547,75 @@ The config directory is \fI$XDG_CONFIG_HOME/yay/\fR. If \fB$XDG_CONFIG_HOME\fR is unset, the config directory will fall back to \fI$HOME/.config/yay\fR. -\fIconfig.json\fR Is used to store all of Yay's config options. Editing -this file should be done through Yay, using the options -mentioned in \fBPERMANENT CONFIGURATION SETTINGS\fR. +\fIyay.conf\fR is the primary user configuration file. + +\fIconfig.json\fR is a legacy compatibility file and is only read when no +Lua config is present. + +\fIinit.lua\fR is an experimental Lua config. When present, it replaces +\fIyay.conf\fR and \fIconfig.json\fR for user configuration loading. +Use \fByay.opt.\fR to set normal options. See the repository's +\fIinit.lua.example\fR for a complete sample. + +.SS LUA CONFIGURATION +When present, \fIinit.lua\fR is loaded from the current working directory +before normal operation begins. If no working-directory Lua config exists, +yay falls back to the config directory. + +.TP +.B yay.opt. +Sets regular yay configuration values. Keys may be written in snake_case or +with the existing config names. + +.TP +.B yay.api.getenv(name) +Returns the environment variable value or \fBnil\fR. + +.TP +.B yay.api.expand(path) +Expands environment variables and \fI~/...\fR. + +.TP +.B yay.api.info(message), yay.api.warn(message), yay.api.error(message) +Write log messages during config loading or hook execution. + +.TP +.B yay.api.capture(cmd, ...), yay.api.run(cmd, ...) +Run external commands from Lua. \fBcapture\fR returns stdout, stderr, and an +exit code. \fBrun\fR inherits stdio and returns an exit code. + +.TP +.B yay.api.json_decode(json) +Decodes JSON text into Lua tables. + +.TP +.B yay.hook.on_prompt(name, default) +Overrides menu answers for the \fBclean\fR, \fBdiff\fR, \fBedit\fR, and +\fBupgrade\fR prompts. Return a string to replace the default answer, or +return \fBdefault\fR/\fBnil\fR to keep the built-in behavior. + +.TP +.B yay.hook.should_include_aur_update(pkg) +Overrides the built-in version-based AUR upgrade decision. Return \fBtrue\fR +to include the package in the upgrade list, \fBfalse\fR to exclude it, or +\fBnil\fR to keep yay's default decision. + +The \fBpkg\fR table contains \fBname\fR, \fBbase\fR, \fBrepository\fR, +\fBlocal_version\fR, \fBremote_version\fR, \fBlocal_build_date\fR, +\fBremote_last_modified\fR, and \fBdefault_include\fR. + +This can be used to recreate the old behavior of selecting AUR updates by +update time instead of only by version, by comparing +\fBremote_last_modified\fR against \fBlocal_build_date\fR when the versions +are unchanged. + +.TP +.B yay.provider..list(), yay.provider..upgrade(items) +Registers a fully Lua-managed external upgrade provider. \fBlist()\fR returns +an array of upgrade items, and \fBupgrade(items)\fR executes the selected +items. Each item may contain \fBname\fR, \fBrepository\fR, \fBbase\fR, +\fBlocal_version\fR, \fBremote_version\fR, and \fBextra\fR. See +\fIinit.lua.example\fR for a Homebrew-based example. .TP .B CACHE DIRECTORY diff --git a/go.mod b/go.mod index 683dcce1..99486426 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/leonelquinteros/gotext v1.7.2 github.com/stretchr/testify v1.11.1 + github.com/yuin/gopher-lua v1.1.2 golang.org/x/net v0.54.0 golang.org/x/sys v0.44.0 golang.org/x/term v0.43.0 diff --git a/go.sum b/go.sum index c7f1dee3..815ab269 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= diff --git a/init.lua b/init.lua new file mode 100644 index 00000000..bad3c1d3 --- /dev/null +++ b/init.lua @@ -0,0 +1,171 @@ +-- Example yay init.lua +-- +-- Install as: +-- $XDG_CONFIG_HOME/yay/init.lua +-- or: +-- $HOME/.config/yay/init.lua + +local function env(name, fallback) + local value = yay.api.getenv(name) + if value == nil or value == "" then + return fallback + end + + return value +end + +yay.api.info("loading yay init.lua") + +-- Normal configuration uses yay.opt.. +yay.opt.editor = env("EDITOR", "nvim") +yay.opt.editor_flags = "-f" +yay.opt.build_dir = yay.api.expand("~/.cache/yay") +yay.opt.clean_menu = true +yay.opt.diff_menu = true +yay.opt.sort_by = "votes" +yay.opt.search_by = "name-desc" +yay.opt.request_split_n = 150 + +-- Prompt hooks can override the default static answer values. +yay.hook.on_prompt = function(name, default) + if name == "clean" then + return "None" + end + + return default +end + +-- Re-enable the old behavior of selecting AUR updates by update time instead +-- of only by version. This keeps the built-in version check, but also treats +-- unchanged-version AUR packages as upgrades when the AUR package metadata was +-- modified after the locally installed package was built. +yay.hook.should_include_aur_update = function(pkg) + if pkg.default_include then + return true + end + + if pkg.repository ~= "aur" then + return false + end + + if pkg.local_version ~= pkg.remote_version then + return false + end + + if pkg.local_build_date == 0 then + return false + end + + return pkg.remote_last_modified > pkg.local_build_date +end + +-- External providers live entirely in Lua. A provider can expose: +-- yay.provider..search(terms) -> { { name=..., repository=..., ... }, ... } +-- yay.provider..install(items) +-- yay.provider..list() -> { { name=..., repository=..., ... }, ... } +-- yay.provider..upgrade(items) +-- +-- This Homebrew example adds search/install support for the yogurt menu and +-- uses `brew outdated --json=v2` for upgrade discovery. +yay.provider.homebrew = { + search = function(terms) + local args = { "brew", "search", "--formula", "--cask" } + for _, term in ipairs(terms) do + table.insert(args, term) + end + + local stdout, stderr, code = yay.api.capture(args) + if code ~= 0 then + yay.api.warn("homebrew search skipped: " .. stderr) + return {} + end + + local results = {} + for name in stdout:gmatch("[^\r\n]+") do + table.insert(results, { + name = name, + repository = "homebrew", + description = "Homebrew package", + }) + end + + return results + end, + + install = function(items) + local args = { "brew", "install" } + for _, item in ipairs(items) do + table.insert(args, item.name) + end + if yay.api.run(args) ~= 0 then + error("homebrew install failed") + end + end, + + list = function() + local stdout, stderr, code = yay.api.capture({ "brew", "outdated", "--json=v2" }) + if code ~= 0 then + yay.api.warn("homebrew provider skipped: " .. stderr) + return {} + end + + local decoded = yay.api.json_decode(stdout) + local upgrades = {} + + for _, item in ipairs(decoded.formulae or {}) do + table.insert(upgrades, { + name = item.name, + repository = "homebrew", + base = "formula", + local_version = (item.installed_versions and item.installed_versions[1]) or "", + remote_version = item.current_version or "", + }) + end + + for _, item in ipairs(decoded.casks or {}) do + table.insert(upgrades, { + name = item.name, + repository = "homebrew", + base = "cask", + local_version = (item.installed_versions and item.installed_versions[1]) or "", + remote_version = item.current_version or "", + extra = "cask", + }) + end + + return upgrades + end, + + upgrade = function(items) + local formulae = {} + local casks = {} + + for _, item in ipairs(items) do + if item.base == "cask" then + table.insert(casks, item.name) + else + table.insert(formulae, item.name) + end + end + + if #formulae > 0 then + local args = { "brew", "upgrade" } + for _, name in ipairs(formulae) do + table.insert(args, name) + end + if yay.api.run(args) ~= 0 then + error("homebrew formula upgrade failed") + end + end + + if #casks > 0 then + local args = { "brew", "upgrade", "--cask" } + for _, name in ipairs(casks) do + table.insert(args, name) + end + if yay.api.run(args) ~= 0 then + error("homebrew cask upgrade failed") + end + end + end, +} \ No newline at end of file diff --git a/init.lua.example b/init.lua.example new file mode 100644 index 00000000..bad3c1d3 --- /dev/null +++ b/init.lua.example @@ -0,0 +1,171 @@ +-- Example yay init.lua +-- +-- Install as: +-- $XDG_CONFIG_HOME/yay/init.lua +-- or: +-- $HOME/.config/yay/init.lua + +local function env(name, fallback) + local value = yay.api.getenv(name) + if value == nil or value == "" then + return fallback + end + + return value +end + +yay.api.info("loading yay init.lua") + +-- Normal configuration uses yay.opt.. +yay.opt.editor = env("EDITOR", "nvim") +yay.opt.editor_flags = "-f" +yay.opt.build_dir = yay.api.expand("~/.cache/yay") +yay.opt.clean_menu = true +yay.opt.diff_menu = true +yay.opt.sort_by = "votes" +yay.opt.search_by = "name-desc" +yay.opt.request_split_n = 150 + +-- Prompt hooks can override the default static answer values. +yay.hook.on_prompt = function(name, default) + if name == "clean" then + return "None" + end + + return default +end + +-- Re-enable the old behavior of selecting AUR updates by update time instead +-- of only by version. This keeps the built-in version check, but also treats +-- unchanged-version AUR packages as upgrades when the AUR package metadata was +-- modified after the locally installed package was built. +yay.hook.should_include_aur_update = function(pkg) + if pkg.default_include then + return true + end + + if pkg.repository ~= "aur" then + return false + end + + if pkg.local_version ~= pkg.remote_version then + return false + end + + if pkg.local_build_date == 0 then + return false + end + + return pkg.remote_last_modified > pkg.local_build_date +end + +-- External providers live entirely in Lua. A provider can expose: +-- yay.provider..search(terms) -> { { name=..., repository=..., ... }, ... } +-- yay.provider..install(items) +-- yay.provider..list() -> { { name=..., repository=..., ... }, ... } +-- yay.provider..upgrade(items) +-- +-- This Homebrew example adds search/install support for the yogurt menu and +-- uses `brew outdated --json=v2` for upgrade discovery. +yay.provider.homebrew = { + search = function(terms) + local args = { "brew", "search", "--formula", "--cask" } + for _, term in ipairs(terms) do + table.insert(args, term) + end + + local stdout, stderr, code = yay.api.capture(args) + if code ~= 0 then + yay.api.warn("homebrew search skipped: " .. stderr) + return {} + end + + local results = {} + for name in stdout:gmatch("[^\r\n]+") do + table.insert(results, { + name = name, + repository = "homebrew", + description = "Homebrew package", + }) + end + + return results + end, + + install = function(items) + local args = { "brew", "install" } + for _, item in ipairs(items) do + table.insert(args, item.name) + end + if yay.api.run(args) ~= 0 then + error("homebrew install failed") + end + end, + + list = function() + local stdout, stderr, code = yay.api.capture({ "brew", "outdated", "--json=v2" }) + if code ~= 0 then + yay.api.warn("homebrew provider skipped: " .. stderr) + return {} + end + + local decoded = yay.api.json_decode(stdout) + local upgrades = {} + + for _, item in ipairs(decoded.formulae or {}) do + table.insert(upgrades, { + name = item.name, + repository = "homebrew", + base = "formula", + local_version = (item.installed_versions and item.installed_versions[1]) or "", + remote_version = item.current_version or "", + }) + end + + for _, item in ipairs(decoded.casks or {}) do + table.insert(upgrades, { + name = item.name, + repository = "homebrew", + base = "cask", + local_version = (item.installed_versions and item.installed_versions[1]) or "", + remote_version = item.current_version or "", + extra = "cask", + }) + end + + return upgrades + end, + + upgrade = function(items) + local formulae = {} + local casks = {} + + for _, item in ipairs(items) do + if item.base == "cask" then + table.insert(casks, item.name) + else + table.insert(formulae, item.name) + end + end + + if #formulae > 0 then + local args = { "brew", "upgrade" } + for _, name in ipairs(formulae) do + table.insert(args, name) + end + if yay.api.run(args) ~= 0 then + error("homebrew formula upgrade failed") + end + end + + if #casks > 0 then + local args = { "brew", "upgrade", "--cask" } + for _, name in ipairs(casks) do + table.insert(args, name) + end + if yay.api.run(args) ~= 0 then + error("homebrew cask upgrade failed") + end + end + end, +} \ No newline at end of file diff --git a/main.go b/main.go index d0708f4f..d6c9c5a1 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "github.com/Jguer/yay/v12/pkg/db/ialpm" "github.com/Jguer/yay/v12/pkg/runtime" "github.com/Jguer/yay/v12/pkg/settings" + lualoader "github.com/Jguer/yay/v12/pkg/settings/lua" "github.com/Jguer/yay/v12/pkg/settings/parser" "github.com/Jguer/yay/v12/pkg/text" ) @@ -80,6 +81,12 @@ func main() { return } + // Experimental: load init.lua (if present) on top of the parsed config. + if errLua := lualoader.LoadInto(fallbackLog, cfg); errLua != nil { + fallbackLog.Errorln(errLua) + } + defer cfg.CloseLua() + cmdArgs := parser.MakeArguments() // Parse command line diff --git a/pkg/menus/clean_menu.go b/pkg/menus/clean_menu.go index 4e10df37..378d33d0 100644 --- a/pkg/menus/clean_menu.go +++ b/pkg/menus/clean_menu.go @@ -52,7 +52,7 @@ func CleanFn(ctx context.Context, run *runtime.Runtime, w io.Writer, toClean, errClean := selectionMenu(run.Logger, pkgbuildDirsByBase, bases, installed, gotext.Get("Packages to cleanBuild?"), - settings.NoConfirm, run.Cfg.AnswerClean, skipFunc) + settings.NoConfirm, run.Cfg.OnPrompt("clean", run.Cfg.AnswerClean), skipFunc) if errClean != nil { return errClean } diff --git a/pkg/menus/diff_menu.go b/pkg/menus/diff_menu.go index 29a656b9..ddaa922b 100644 --- a/pkg/menus/diff_menu.go +++ b/pkg/menus/diff_menu.go @@ -158,7 +158,7 @@ func DiffFn(ctx context.Context, run *runtime.Runtime, w io.Writer, } toDiff, errMenu := selectionMenu(run.Logger, pkgbuildDirsByBase, bases, installed, gotext.Get("Diffs to show?"), - settings.NoConfirm, run.Cfg.AnswerDiff, nil) + settings.NoConfirm, run.Cfg.OnPrompt("diff", run.Cfg.AnswerDiff), nil) if errMenu != nil || len(toDiff) == 0 { return errMenu } diff --git a/pkg/menus/edit_menu.go b/pkg/menus/edit_menu.go index 74981f60..c04fb67c 100644 --- a/pkg/menus/edit_menu.go +++ b/pkg/menus/edit_menu.go @@ -127,7 +127,7 @@ func EditFn(ctx context.Context, run *runtime.Runtime, w io.Writer, } toEdit, errMenu := selectionMenu(run.Logger, pkgbuildDirsByBase, bases, installed, - gotext.Get("PKGBUILDs to edit?"), settings.NoConfirm, run.Cfg.AnswerEdit, nil) + gotext.Get("PKGBUILDs to edit?"), settings.NoConfirm, run.Cfg.OnPrompt("edit", run.Cfg.AnswerEdit), nil) if errMenu != nil || len(toEdit) == 0 { return errMenu } diff --git a/pkg/query/query_builder.go b/pkg/query/query_builder.go index 0fb6d0db..7d0be47a 100644 --- a/pkg/query/query_builder.go +++ b/pkg/query/query_builder.go @@ -17,6 +17,7 @@ import ( "github.com/Jguer/yay/v12/pkg/db" "github.com/Jguer/yay/v12/pkg/intrange" + "github.com/Jguer/yay/v12/pkg/settings" "github.com/Jguer/yay/v12/pkg/settings/parser" "github.com/Jguer/yay/v12/pkg/text" ) @@ -51,10 +52,12 @@ type SourceQueryBuilder struct { aurClient aur.QueryClient logger *text.Logger + cfg *settings.Configuration } func NewSourceQueryBuilder( aurClient aur.QueryClient, + cfg *settings.Configuration, logger *text.Logger, sortBy string, targetMode parser.TargetMode, @@ -65,6 +68,7 @@ func NewSourceQueryBuilder( ) *SourceQueryBuilder { return &SourceQueryBuilder{ aurClient: aurClient, + cfg: cfg, logger: logger, bottomUp: bottomUp, sortBy: sortBy, @@ -261,6 +265,30 @@ func (s *SourceQueryBuilder) Execute(ctx context.Context, dbExecutor db.Executor } } + if s.targetMode == parser.ModeAny && s.cfg != nil { + externalResults, err := s.cfg.SearchExternalPackages(ctx, pkgS) + if err != nil { + s.logger.Errorln(err) + } else { + for i := range externalResults { + result := externalResults[i] + if s.queryMap[result.Repository] == nil { + s.queryMap[result.Repository] = map[string]any{} + } + + s.queryMap[result.Repository][result.Name] = result + sortableResults.results = append(sortableResults.results, abstractResult{ + source: result.Repository, + name: result.Name, + description: result.Description, + packageBase: result.Base, + votes: -1, + popularity: -1, + }) + } + } + } + sort.Sort(sortableResults) s.results = sortableResults.results @@ -297,6 +325,8 @@ func (s *SourceQueryBuilder) Results(dbExecutor db.Executor, verboseSearch Searc toPrint += aurPkgSearchString(&pPkg, dbExecutor, s.singleLineResults) case alpm.Package: toPrint += syncPkgSearchString(pPkg, dbExecutor, s.singleLineResults) + case settings.ExternalSearchResult: + toPrint += externalPkgSearchString(&pPkg, s.singleLineResults) } s.logger.Println(toPrint) diff --git a/pkg/query/query_builder_test.go b/pkg/query/query_builder_test.go index d7b72d61..02a87103 100644 --- a/pkg/query/query_builder_test.go +++ b/pkg/query/query_builder_test.go @@ -13,12 +13,51 @@ import ( "github.com/Jguer/yay/v12/pkg/db/mock" mockaur "github.com/Jguer/yay/v12/pkg/dep/mock" + "github.com/Jguer/yay/v12/pkg/intrange" + "github.com/Jguer/yay/v12/pkg/settings" "github.com/Jguer/yay/v12/pkg/settings/parser" "github.com/Jguer/yay/v12/pkg/text" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +type mockQueryLuaRunner struct { + search func(ctx context.Context, terms []string) ([]settings.ExternalSearchResult, error) +} + +func (m *mockQueryLuaRunner) CallOnPrompt(name, defaultAns string) (string, bool, error) { + return "", false, nil +} + +func (m *mockQueryLuaRunner) CallShouldIncludeAURUpdate(candidate settings.AURUpdateContext) (bool, bool, error) { + return false, false, nil +} + +func (m *mockQueryLuaRunner) CallListExternalUpgrades(ctx context.Context) ([]settings.ExternalUpgrade, error) { + return nil, nil +} + +func (m *mockQueryLuaRunner) CallRunExternalUpgrades(ctx context.Context, repository string, upgrades []settings.ExternalUpgrade) (bool, error) { + return false, nil +} + +func (m *mockQueryLuaRunner) CallSearchExternalPackages(ctx context.Context, terms []string) ([]settings.ExternalSearchResult, error) { + if m.search == nil { + return nil, nil + } + + return m.search(ctx, terms) +} + +func (m *mockQueryLuaRunner) CallInstallExternalPackages(ctx context.Context, repository string, targets []settings.ExternalInstallTarget) (bool, error) { + return false, nil +} + +func (m *mockQueryLuaRunner) HasProvider(name string) bool { return true } + +func (m *mockQueryLuaRunner) Close() {} + func TestSourceQueryBuilder(t *testing.T) { t.Parallel() type testCase struct { @@ -293,7 +332,7 @@ func TestSourceQueryBuilder(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { w := &strings.Builder{} - queryBuilder := NewSourceQueryBuilder(mockAUR, + queryBuilder := NewSourceQueryBuilder(mockAUR, nil, text.NewLogger(w, io.Discard, strings.NewReader(""), false, "test"), tc.sortBy, tc.targetMode, tc.searchBy, tc.bottomUp, tc.singleLineResults, tc.separateSources) @@ -313,6 +352,50 @@ func TestSourceQueryBuilder(t *testing.T) { } } +func TestSourceQueryBuilderExternalSearchResults(t *testing.T) { + t.Parallel() + + loggerOut := &strings.Builder{} + logger := text.NewLogger(loggerOut, io.Discard, strings.NewReader(""), false, "test") + cfg := &settings.Configuration{} + cfg.SetLuaEngine(&mockQueryLuaRunner{search: func(_ context.Context, terms []string) ([]settings.ExternalSearchResult, error) { + assert.Equal(t, []string{"ripgrep"}, terms) + return []settings.ExternalSearchResult{{ + Name: "ripgrep", + Repository: "homebrew", + Version: "14.1.0", + InstalledVersion: "14.0.0", + Description: "Search tool", + Extra: "formula", + }}, nil + }}) + + queryBuilder := NewSourceQueryBuilder(&mockaur.MockAUR{GetFn: func(context.Context, *aur.Query) ([]aur.Pkg, error) { + return []aur.Pkg{}, nil + }}, cfg, logger, "name", parser.ModeAny, "name", false, false, true) + + mockDB := &mock.DBExecutor{ + ReposFn: func() []string { return []string{"core"} }, + SyncPackagesFn: func(...string) []mock.IPackage { return nil }, + LocalPackageFn: func(string) mock.IPackage { return nil }, + } + + queryBuilder.Execute(context.Background(), mockDB, []string{"ripgrep"}) + require.Len(t, queryBuilder.results, 1) + assert.Equal(t, "homebrew", queryBuilder.results[0].source) + assert.Equal(t, "ripgrep", queryBuilder.results[0].name) + + require.NoError(t, queryBuilder.Results(mockDB, Detailed)) + assert.Contains(t, loggerOut.String(), "homebrew") + assert.Contains(t, loggerOut.String(), "ripgrep") + assert.Contains(t, loggerOut.String(), "Search tool") + + include, exclude, _, otherExclude := intrange.ParseNumberMenu("1") + targets, err := queryBuilder.GetTargets(include, exclude, otherExclude) + require.NoError(t, err) + assert.Equal(t, []string{"homebrew/ripgrep"}, targets) +} + func TestSourceQueryBuilderTieSortsByRepoOrder(t *testing.T) { t.Parallel() @@ -419,7 +502,7 @@ func TestSourceQueryBuilderTieSortsByRepoOrder(t *testing.T) { mockDB.ReposFn = func() []string { return tc.repoOrder } - queryBuilder := NewSourceQueryBuilder(mockAUR, + queryBuilder := NewSourceQueryBuilder(mockAUR, nil, text.NewLogger(w, io.Discard, strings.NewReader(""), false, "test"), "", parser.ModeAny, "", tc.bottomUp, false, true) @@ -463,7 +546,7 @@ func TestSourceQueryBuilderTieDoesNotSeparateSources(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { w := &strings.Builder{} - queryBuilder := NewSourceQueryBuilder(mockAUR, + queryBuilder := NewSourceQueryBuilder(mockAUR, nil, text.NewLogger(w, io.Discard, strings.NewReader(""), false, "test"), "", parser.ModeAny, "", tc.bottomUp, false, true) @@ -582,7 +665,7 @@ func TestSourceQueryBuilderSortByFields(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { w := &strings.Builder{} - queryBuilder := NewSourceQueryBuilder(mockAUR, + queryBuilder := NewSourceQueryBuilder(mockAUR, nil, text.NewLogger(w, io.Discard, strings.NewReader(""), false, "test"), tc.sortBy, parser.ModeAny, "", tc.bottomUp, false, false) diff --git a/pkg/query/types.go b/pkg/query/types.go index 3fd5829f..f960f2df 100644 --- a/pkg/query/types.go +++ b/pkg/query/types.go @@ -9,6 +9,7 @@ import ( "github.com/leonelquinteros/gotext" "github.com/Jguer/yay/v12/pkg/db" + "github.com/Jguer/yay/v12/pkg/settings" "github.com/Jguer/yay/v12/pkg/text" ) @@ -116,3 +117,33 @@ func syncPkgSearchString(pkg alpm.Package, dbExecutor db.Executor, singleLineRes return toPrint } + +func externalPkgSearchString(pkg *settings.ExternalSearchResult, singleLineResults bool) string { + toPrint := text.Bold(text.ColorHash(pkg.Repository)) + "/" + text.Bold(pkg.Name) + + if pkg.Version != "" { + toPrint += " " + text.Cyan(pkg.Version) + } + + if pkg.Extra != "" { + toPrint += text.Bold(" (" + pkg.Extra + ")") + } + + if pkg.InstalledVersion != "" { + if pkg.Version != "" && pkg.InstalledVersion != pkg.Version { + toPrint += text.Bold(text.Green(gotext.Get("(Installed: %s)", pkg.InstalledVersion))) + } else { + toPrint += text.Bold(text.Green(gotext.Get("(Installed)"))) + } + } + + if singleLineResults { + toPrint += "\t" + } else { + toPrint += "\n " + } + + toPrint += pkg.Description + + return toPrint +} diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 82c36049..a6478851 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -128,6 +128,7 @@ func NewRuntime(cfg *settings.Configuration, cmdArgs *parser.Arguments, version queryBuilder := query.NewSourceQueryBuilder( aurClient, + cfg, logger.Child("mixed.querybuilder"), cfg.SortBy, cfg.Mode, cfg.SearchBy, cfg.BottomUp, cfg.SingleLineResults, cfg.SeparateSources) diff --git a/pkg/settings/config.go b/pkg/settings/config.go index 4d8bb6cf..d8e5f1f6 100644 --- a/pkg/settings/config.go +++ b/pkg/settings/config.go @@ -1,6 +1,7 @@ package settings import ( + "context" "bytes" _ "embed" "encoding/json" @@ -80,6 +81,175 @@ type Configuration struct { SaveConfig bool `json:"-" ini:"-"` Mode parser.TargetMode `json:"-" ini:"-"` ReBuild parser.RebuildMode `json:"rebuild" ini:"ReBuild"` + + // luaEngine holds an optional Lua scripting engine when the user has an + // init.lua config. It is kept behind an interface to avoid a pkg/settings + // -> pkg/settings/lua import cycle. nil when no init.lua is present. + luaEngine LuaHookRunner `json:"-" ini:"-"` +} + +// LuaHookRunner is the subset of the Lua engine surface that pkg/settings +// needs at runtime. The concrete implementation lives in pkg/settings/lua. +type LuaHookRunner interface { + CallOnPrompt(name, defaultAns string) (string, bool, error) + CallShouldIncludeAURUpdate(candidate AURUpdateContext) (bool, bool, error) + CallListExternalUpgrades(ctx context.Context) ([]ExternalUpgrade, error) + CallRunExternalUpgrades(ctx context.Context, repository string, upgrades []ExternalUpgrade) (bool, error) + CallSearchExternalPackages(ctx context.Context, terms []string) ([]ExternalSearchResult, error) + CallInstallExternalPackages(ctx context.Context, repository string, targets []ExternalInstallTarget) (bool, error) + HasProvider(name string) bool + Close() +} + +// ExternalUpgrade describes an upgrade candidate returned by a Lua-defined +// provider such as Homebrew or Flatpak. +type ExternalUpgrade struct { + Name string + Base string + Repository string + LocalVersion string + RemoteVersion string + Extra string +} + +// ExternalSearchResult describes a search result returned by a Lua-defined +// provider that can participate in mixed-source search results. +type ExternalSearchResult struct { + Name string + Base string + Repository string + Version string + InstalledVersion string + Description string + Extra string +} + +// ExternalInstallTarget identifies a package selection routed to a Lua-defined +// provider install handler. +type ExternalInstallTarget struct { + Name string + Repository string +} + +// AURUpdateContext describes a potential AUR upgrade candidate for Lua hooks. +// DefaultInclude is the built-in version-based decision before Lua overrides. +type AURUpdateContext struct { + Name string + Base string + Repository string + LocalVersion string + RemoteVersion string + LocalBuildDate int64 + RemoteLastModified int64 + DefaultInclude bool +} + +// SetLuaEngine attaches a Lua hook runner to the configuration. Passing nil +// detaches any existing engine without closing it. +func (c *Configuration) SetLuaEngine(e LuaHookRunner) { c.luaEngine = e } + +// CloseLua releases the embedded Lua engine, if any. Safe to call multiple +// times. +func (c *Configuration) CloseLua() { + if c.luaEngine == nil { + return + } + c.luaEngine.Close() + c.luaEngine = nil +} + +// OnPrompt asks the Lua `on_prompt` hook (if any) for an answer to the named +// menu prompt. When no engine or no hook is configured, defaultAns is +// returned unchanged. Errors from Lua are written to stderr and the default +// is used as a safe fallback. +func (c *Configuration) OnPrompt(name, defaultAns string) string { + if c == nil || c.luaEngine == nil { + return defaultAns + } + got, ok, err := c.luaEngine.CallOnPrompt(name, defaultAns) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return defaultAns + } + if !ok { + return defaultAns + } + return got +} + +// ShouldIncludeAURUpdate asks the Lua `should_include_aur_update` hook (if +// any) whether an AUR package should be treated as upgradable. When no engine, +// no hook, or a hook error is present, the default version-based decision is +// returned unchanged. +func (c *Configuration) ShouldIncludeAURUpdate(candidate AURUpdateContext) bool { + if c == nil || c.luaEngine == nil { + return candidate.DefaultInclude + } + got, ok, err := c.luaEngine.CallShouldIncludeAURUpdate(candidate) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return candidate.DefaultInclude + } + if !ok { + return candidate.DefaultInclude + } + return got +} + +// ExternalUpgrades asks Lua-defined providers (if any) for additional upgrade +// candidates that should be shown alongside repo/AUR updates. +func (c *Configuration) ExternalUpgrades(ctx context.Context) ([]ExternalUpgrade, error) { + if c == nil || c.luaEngine == nil { + return nil, nil + } + + upgrades, err := c.luaEngine.CallListExternalUpgrades(ctx) + if err != nil { + return nil, err + } + + return upgrades, nil +} + +// RunExternalUpgradeProvider asks Lua to execute the selected upgrades for a +// named provider. It is a no-op when no Lua engine or provider is configured. +func (c *Configuration) RunExternalUpgradeProvider(ctx context.Context, repository string, upgrades []ExternalUpgrade) error { + if c == nil || c.luaEngine == nil || repository == "" || len(upgrades) == 0 { + return nil + } + + _, err := c.luaEngine.CallRunExternalUpgrades(ctx, repository, upgrades) + return err +} + +// SearchExternalPackages asks Lua-defined providers for additional search +// results to include in mixed-source search output. +func (c *Configuration) SearchExternalPackages(ctx context.Context, terms []string) ([]ExternalSearchResult, error) { + if c == nil || c.luaEngine == nil { + return nil, nil + } + + return c.luaEngine.CallSearchExternalPackages(ctx, terms) +} + +// InstallExternalPackages asks a Lua-defined provider to install the selected +// package targets. The boolean return is false when the provider is absent. +func (c *Configuration) InstallExternalPackages(ctx context.Context, repository string, targets []ExternalInstallTarget) (bool, error) { + if c == nil || c.luaEngine == nil || repository == "" || len(targets) == 0 { + return false, nil + } + + return c.luaEngine.CallInstallExternalPackages(ctx, repository, targets) +} + +// HasExternalProvider reports whether Lua defines a provider table for the +// given source/repository name. +func (c *Configuration) HasExternalProvider(name string) bool { + if c == nil || c.luaEngine == nil || name == "" { + return false + } + + return c.luaEngine.HasProvider(name) } // Save writes yay config to INI file. @@ -215,6 +385,18 @@ func NewConfig(logger *text.Logger, configPath, version string) (*Configuration, newConfig.CompletionPath = filepath.Join(cacheHome, completionFileName) newConfig.VCSFilePath = filepath.Join(cacheHome, vcsFileName) + // Experimental: if the user has an init.lua, it fully replaces INI/JSON + // config loading. The script itself is executed later (from main, via the + // pkg/settings/lua package); here we only branch on its presence. + if luaPath, err := ResolveLuaConfigPath(); err == nil && luaPath != "" { + if logger != nil { + logger.Debugln("lua config detected, skipping INI/JSON loaders:", luaPath) + } + goto skipINI + } else if err != nil && logger != nil { + logger.Errorln(err) + } + // Load system-wide INI config first (silently ignored if not present) if err := newConfig.loadINI(SystemConfigPath); err != nil && logger != nil { logger.Errorln(err) @@ -224,13 +406,14 @@ func NewConfig(logger *text.Logger, configPath, version string) (*Configuration, newConfig.load(configPath) // Load user INI config (takes priority over JSON when both exist) - userINIPath := GetINIConfigPath() - if userINIPath != "" { + if userINIPath := GetINIConfigPath(); userINIPath != "" { if err := newConfig.loadINI(userINIPath); err != nil && logger != nil { logger.Errorln(err) } } +skipINI: + if aurdest := os.Getenv("AURDEST"); aurdest != "" { newConfig.BuildDir = aurdest } diff --git a/pkg/settings/config_test.go b/pkg/settings/config_test.go index 9610ba92..e0ed0087 100644 --- a/pkg/settings/config_test.go +++ b/pkg/settings/config_test.go @@ -4,7 +4,9 @@ package settings import ( + "context" "encoding/json" + "errors" "os" "path/filepath" "testing" @@ -13,6 +15,77 @@ import ( "github.com/stretchr/testify/require" ) +type mockLuaHookRunner struct { + callPrompt func(name, defaultAns string) (string, bool, error) + callAURUpdate func(candidate AURUpdateContext) (bool, bool, error) + callExternalUpdates func(ctx context.Context) ([]ExternalUpgrade, error) + callRunExternal func(ctx context.Context, repository string, upgrades []ExternalUpgrade) (bool, error) + callSearchExternal func(ctx context.Context, terms []string) ([]ExternalSearchResult, error) + callInstallExternal func(ctx context.Context, repository string, targets []ExternalInstallTarget) (bool, error) + hasProvider func(name string) bool + closed bool +} + +func (m *mockLuaHookRunner) CallOnPrompt(name, defaultAns string) (string, bool, error) { + if m.callPrompt == nil { + return "", false, nil + } + + return m.callPrompt(name, defaultAns) +} + +func (m *mockLuaHookRunner) CallShouldIncludeAURUpdate(candidate AURUpdateContext) (bool, bool, error) { + if m.callAURUpdate == nil { + return false, false, nil + } + + return m.callAURUpdate(candidate) +} + +func (m *mockLuaHookRunner) CallListExternalUpgrades(ctx context.Context) ([]ExternalUpgrade, error) { + if m.callExternalUpdates == nil { + return nil, nil + } + + return m.callExternalUpdates(ctx) +} + +func (m *mockLuaHookRunner) CallRunExternalUpgrades(ctx context.Context, repository string, upgrades []ExternalUpgrade) (bool, error) { + if m.callRunExternal == nil { + return false, nil + } + + return m.callRunExternal(ctx, repository, upgrades) +} + +func (m *mockLuaHookRunner) CallSearchExternalPackages(ctx context.Context, terms []string) ([]ExternalSearchResult, error) { + if m.callSearchExternal == nil { + return nil, nil + } + + return m.callSearchExternal(ctx, terms) +} + +func (m *mockLuaHookRunner) CallInstallExternalPackages(ctx context.Context, repository string, targets []ExternalInstallTarget) (bool, error) { + if m.callInstallExternal == nil { + return false, nil + } + + return m.callInstallExternal(ctx, repository, targets) +} + +func (m *mockLuaHookRunner) HasProvider(name string) bool { + if m.hasProvider == nil { + return false + } + + return m.hasProvider(name) +} + +func (m *mockLuaHookRunner) Close() { + m.closed = true +} + // GIVEN a non existing build dir in the config // WHEN the config is loaded // THEN the directory should be created @@ -45,6 +118,57 @@ func TestNewConfig(t *testing.T) { assert.NoError(t, err) } +func TestNewConfigSkipsLegacyConfigWhenLuaConfigExists(t *testing.T) { + configDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(configDir, "yay"), 0o755)) + t.Setenv("XDG_CONFIG_HOME", configDir) + + cacheHome := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheHome) + + legacyBuildDir := filepath.Join(t.TempDir(), "legacy-build-dir") + config := map[string]string{"BuildDir": legacyBuildDir} + configJSON, err := json.Marshal(config) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "yay", "config.json"), configJSON, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "yay", "init.lua"), []byte("return true\n"), 0o644)) + + newConfig, err := NewConfig(nil, GetConfigPath(), "v1.0.0") + require.NoError(t, err) + assert.Equal(t, filepath.Join(cacheHome, "yay"), newConfig.BuildDir) + assert.NotEqual(t, legacyBuildDir, newConfig.BuildDir) +} + +func TestNewConfigSkipsLegacyConfigWhenLuaConfigExistsInWorkingDir(t *testing.T) { + configDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(configDir, "yay"), 0o755)) + t.Setenv("XDG_CONFIG_HOME", configDir) + + cacheHome := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheHome) + + legacyBuildDir := filepath.Join(t.TempDir(), "legacy-build-dir") + config := map[string]string{"BuildDir": legacyBuildDir} + configJSON, err := json.Marshal(config) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "yay", "config.json"), configJSON, 0o644)) + + cwd := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(cwd, "init.lua"), []byte("return true\n"), 0o644)) + + oldWD, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(cwd)) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWD)) + }) + + newConfig, err := NewConfig(nil, GetConfigPath(), "v1.0.0") + require.NoError(t, err) + assert.Equal(t, filepath.Join(cacheHome, "yay"), newConfig.BuildDir) + assert.NotEqual(t, legacyBuildDir, newConfig.BuildDir) +} + // GIVEN a non existing build dir in the config and AURDEST set to a non-existing folder // WHEN the config is loaded // THEN the directory of AURDEST should be created and selected @@ -135,6 +259,179 @@ func TestConfiguration_setPrivilegeElevator(t *testing.T) { assert.True(t, config.SudoLoop) } +func TestConfigurationOnPrompt(t *testing.T) { + t.Run("returns default when no engine is attached", func(t *testing.T) { + cfg := &Configuration{} + assert.Equal(t, "default", cfg.OnPrompt("clean", "default")) + }) + + t.Run("returns override when hook provides one", func(t *testing.T) { + cfg := &Configuration{} + cfg.SetLuaEngine(&mockLuaHookRunner{ + callPrompt: func(name, defaultAns string) (string, bool, error) { + assert.Equal(t, "clean", name) + assert.Equal(t, "default", defaultAns) + return "1 2 3", true, nil + }, + }) + + assert.Equal(t, "1 2 3", cfg.OnPrompt("clean", "default")) + }) + + t.Run("falls back to default on hook error", func(t *testing.T) { + cfg := &Configuration{} + cfg.SetLuaEngine(&mockLuaHookRunner{ + callPrompt: func(name, defaultAns string) (string, bool, error) { + return "", false, errors.New("boom") + }, + }) + + assert.Equal(t, "default", cfg.OnPrompt("clean", "default")) + }) +} + +func TestConfigurationCloseLua(t *testing.T) { + runner := &mockLuaHookRunner{} + cfg := &Configuration{} + cfg.SetLuaEngine(runner) + + cfg.CloseLua() + assert.True(t, runner.closed) + assert.Equal(t, "default", cfg.OnPrompt("clean", "default")) +} + +func TestConfigurationShouldIncludeAURUpdate(t *testing.T) { + candidate := AURUpdateContext{DefaultInclude: false, RemoteLastModified: 200, LocalBuildDate: 100} + + t.Run("returns default when no engine is attached", func(t *testing.T) { + cfg := &Configuration{} + assert.False(t, cfg.ShouldIncludeAURUpdate(candidate)) + }) + + t.Run("returns override when hook provides one", func(t *testing.T) { + cfg := &Configuration{} + cfg.SetLuaEngine(&mockLuaHookRunner{ + callAURUpdate: func(got AURUpdateContext) (bool, bool, error) { + assert.Equal(t, candidate.RemoteLastModified, got.RemoteLastModified) + return true, true, nil + }, + }) + + assert.True(t, cfg.ShouldIncludeAURUpdate(candidate)) + }) + + t.Run("falls back to default on hook error", func(t *testing.T) { + cfg := &Configuration{} + cfg.SetLuaEngine(&mockLuaHookRunner{ + callAURUpdate: func(AURUpdateContext) (bool, bool, error) { + return false, false, errors.New("boom") + }, + }) + + assert.False(t, cfg.ShouldIncludeAURUpdate(candidate)) + }) +} + +func TestConfigurationExternalUpgrades(t *testing.T) { + t.Run("returns nil when no engine is attached", func(t *testing.T) { + cfg := &Configuration{} + upgrades, err := cfg.ExternalUpgrades(context.Background()) + assert.NoError(t, err) + assert.Nil(t, upgrades) + }) + + t.Run("returns upgrades from lua engine", func(t *testing.T) { + cfg := &Configuration{} + cfg.SetLuaEngine(&mockLuaHookRunner{ + callExternalUpdates: func(context.Context) ([]ExternalUpgrade, error) { + return []ExternalUpgrade{{Name: "wget", Repository: "homebrew", LocalVersion: "1.0", RemoteVersion: "2.0"}}, nil + }, + }) + + upgrades, err := cfg.ExternalUpgrades(context.Background()) + assert.NoError(t, err) + assert.Equal(t, []ExternalUpgrade{{Name: "wget", Repository: "homebrew", LocalVersion: "1.0", RemoteVersion: "2.0"}}, upgrades) + }) +} + +func TestConfigurationRunExternalUpgradeProvider(t *testing.T) { + t.Run("noops when no engine is attached", func(t *testing.T) { + cfg := &Configuration{} + assert.NoError(t, cfg.RunExternalUpgradeProvider(context.Background(), "homebrew", []ExternalUpgrade{{Name: "wget"}})) + }) + + t.Run("forwards execution to lua engine", func(t *testing.T) { + cfg := &Configuration{} + cfg.SetLuaEngine(&mockLuaHookRunner{ + callRunExternal: func(_ context.Context, repository string, upgrades []ExternalUpgrade) (bool, error) { + assert.Equal(t, "homebrew", repository) + assert.Equal(t, []ExternalUpgrade{{Name: "wget", Repository: "homebrew"}}, upgrades) + return true, nil + }, + }) + + assert.NoError(t, cfg.RunExternalUpgradeProvider(context.Background(), "homebrew", []ExternalUpgrade{{Name: "wget", Repository: "homebrew"}})) + }) +} + +func TestConfigurationSearchExternalPackages(t *testing.T) { + t.Run("returns nil when no engine is attached", func(t *testing.T) { + cfg := &Configuration{} + results, err := cfg.SearchExternalPackages(context.Background(), []string{"ripgrep"}) + assert.NoError(t, err) + assert.Nil(t, results) + }) + + t.Run("returns results from lua engine", func(t *testing.T) { + cfg := &Configuration{} + cfg.SetLuaEngine(&mockLuaHookRunner{ + callSearchExternal: func(_ context.Context, terms []string) ([]ExternalSearchResult, error) { + assert.Equal(t, []string{"ripgrep"}, terms) + return []ExternalSearchResult{{Name: "ripgrep", Repository: "homebrew", Description: "search tool"}}, nil + }, + }) + + results, err := cfg.SearchExternalPackages(context.Background(), []string{"ripgrep"}) + assert.NoError(t, err) + assert.Equal(t, []ExternalSearchResult{{Name: "ripgrep", Repository: "homebrew", Description: "search tool"}}, results) + }) +} + +func TestConfigurationInstallExternalPackages(t *testing.T) { + t.Run("noops when no engine is attached", func(t *testing.T) { + cfg := &Configuration{} + handled, err := cfg.InstallExternalPackages(context.Background(), "homebrew", []ExternalInstallTarget{{Name: "ripgrep", Repository: "homebrew"}}) + assert.NoError(t, err) + assert.False(t, handled) + }) + + t.Run("forwards install targets to lua engine", func(t *testing.T) { + cfg := &Configuration{} + cfg.SetLuaEngine(&mockLuaHookRunner{ + callInstallExternal: func(_ context.Context, repository string, targets []ExternalInstallTarget) (bool, error) { + assert.Equal(t, "homebrew", repository) + assert.Equal(t, []ExternalInstallTarget{{Name: "ripgrep", Repository: "homebrew"}}, targets) + return true, nil + }, + }) + + handled, err := cfg.InstallExternalPackages(context.Background(), "homebrew", []ExternalInstallTarget{{Name: "ripgrep", Repository: "homebrew"}}) + assert.NoError(t, err) + assert.True(t, handled) + }) +} + +func TestConfigurationHasExternalProvider(t *testing.T) { + cfg := &Configuration{} + assert.False(t, cfg.HasExternalProvider("homebrew")) + + cfg.SetLuaEngine(&mockLuaHookRunner{hasProvider: func(name string) bool { + return name == "homebrew" + }}) + assert.True(t, cfg.HasExternalProvider("homebrew")) + assert.False(t, cfg.HasExternalProvider("flatpak")) +} + // GIVEN default config and sudo loop enabled // GIVEN only su in path // WHEN setPrivilegeElevator gets called diff --git a/pkg/settings/dirs.go b/pkg/settings/dirs.go index ac590b55..618d4034 100644 --- a/pkg/settings/dirs.go +++ b/pkg/settings/dirs.go @@ -8,6 +8,7 @@ import ( const ( configFileName string = "config.json" // configFileName holds the name of the config file. iniConfigFileName string = "yay.conf" // iniConfigFileName holds the name of the INI config file. + luaConfigFileName string = "init.lua" // luaConfigFileName holds the name of the experimental Lua config file. vcsFileName string = "vcs.json" // vcsFileName holds the name of the vcs file. completionFileName string = "completion.cache" systemdCache string = "/var/cache/yay" // systemd should handle cache creation @@ -51,6 +52,53 @@ func GetINIConfigPath() string { return "" } +// GetLuaConfigPath returns the path to the user's experimental Lua config +// file (init.lua). The directory is NOT created on lookup so that callers can +// cheaply check for existence; use os.Stat to test before opening. +func GetLuaConfigPath() string { + if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" { + return filepath.Join(configHome, "yay", luaConfigFileName) + } + + if configHome := os.Getenv("HOME"); configHome != "" { + return filepath.Join(configHome, ".config", "yay", luaConfigFileName) + } + + return "" +} + +// ResolveLuaConfigPath returns the first existing Lua config path. +// +// Resolution order: +// 1. ./init.lua in the current working directory +// 2. $XDG_CONFIG_HOME/yay/init.lua +// 3. $HOME/.config/yay/init.lua +func ResolveLuaConfigPath() (string, error) { + if wd, err := os.Getwd(); err == nil { + cwdPath := filepath.Join(wd, luaConfigFileName) + if _, err := os.Stat(cwdPath); err == nil { + return cwdPath, nil + } else if !os.IsNotExist(err) { + return "", err + } + } else { + return "", err + } + + configPath := GetLuaConfigPath() + if configPath == "" { + return "", nil + } + + if _, err := os.Stat(configPath); err == nil { + return configPath, nil + } else if os.IsNotExist(err) { + return "", nil + } else { + return "", err + } +} + func getCacheHome() (string, error) { uid := os.Geteuid() diff --git a/pkg/settings/dirs_test.go b/pkg/settings/dirs_test.go index 582b9459..7fd8103d 100644 --- a/pkg/settings/dirs_test.go +++ b/pkg/settings/dirs_test.go @@ -29,3 +29,64 @@ func Test_getCacheHome(t *testing.T) { require.NoError(t, os.Unsetenv("TMPDIR")) require.NoError(t, os.Unsetenv("SUDO_USER")) } + +func TestGetLuaConfigPath(t *testing.T) { + t.Run("uses XDG_CONFIG_HOME when set", func(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("HOME", "/unused-home") + + assert.Equal(t, filepath.Join(configHome, "yay", "init.lua"), GetLuaConfigPath()) + }) + + t.Run("falls back to HOME when XDG_CONFIG_HOME is unset", func(t *testing.T) { + require.NoError(t, os.Unsetenv("XDG_CONFIG_HOME")) + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + assert.Equal(t, filepath.Join(homeDir, ".config", "yay", "init.lua"), GetLuaConfigPath()) + }) +} + +func TestResolveLuaConfigPath(t *testing.T) { + t.Run("prefers init.lua in current working directory", func(t *testing.T) { + cwd := t.TempDir() + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + + require.NoError(t, os.MkdirAll(filepath.Join(configHome, "yay"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(configHome, "yay", "init.lua"), []byte("return true\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(cwd, "init.lua"), []byte("return true\n"), 0o644)) + + oldWD, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(cwd)) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWD)) + }) + + resolved, err := ResolveLuaConfigPath() + require.NoError(t, err) + assert.Equal(t, filepath.Join(cwd, "init.lua"), resolved) + }) + + t.Run("falls back to config directory", func(t *testing.T) { + cwd := t.TempDir() + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + + require.NoError(t, os.MkdirAll(filepath.Join(configHome, "yay"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(configHome, "yay", "init.lua"), []byte("return true\n"), 0o644)) + + oldWD, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(cwd)) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWD)) + }) + + resolved, err := ResolveLuaConfigPath() + require.NoError(t, err) + assert.Equal(t, filepath.Join(configHome, "yay", "init.lua"), resolved) + }) +} diff --git a/pkg/settings/lua/load.go b/pkg/settings/lua/load.go new file mode 100644 index 00000000..563562d9 --- /dev/null +++ b/pkg/settings/lua/load.go @@ -0,0 +1,207 @@ +package lua + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/Jguer/yay/v12/pkg/settings" + "github.com/Jguer/yay/v12/pkg/text" + lua "github.com/yuin/gopher-lua" +) + +func registerBuiltins(e *Engine, logger *text.Logger) error { + bindings := map[string]lua.LGFunction{ + "getenv": func(L *lua.LState) int { + key := L.CheckString(1) + value, ok := os.LookupEnv(key) + if !ok { + L.Push(lua.LNil) + return 1 + } + L.Push(lua.LString(value)) + return 1 + }, + "expand": func(L *lua.LState) int { + value := os.ExpandEnv(L.CheckString(1)) + if len(value) >= 2 && value[:2] == "~/" { + value = filepath.Join(os.Getenv("HOME"), value[2:]) + } + L.Push(lua.LString(value)) + return 1 + }, + "info": func(L *lua.LState) int { + if logger != nil { + logger.Infoln(L.CheckString(1)) + } + return 0 + }, + "warn": func(L *lua.LState) int { + if logger != nil { + logger.Warnln(L.CheckString(1)) + } + return 0 + }, + "error": func(L *lua.LState) int { + if logger != nil { + logger.Errorln(L.CheckString(1)) + } + return 0 + }, + "capture": func(L *lua.LState) int { + args := luaCommandArgs(L) + cmd := exec.CommandContext(e.contextOrBackground(), args[0], args[1:]...) + stdout, stderr, exitCode := runCapture(cmd) + L.Push(lua.LString(stdout)) + L.Push(lua.LString(stderr)) + L.Push(lua.LNumber(exitCode)) + return 3 + }, + "run": func(L *lua.LState) int { + args := luaCommandArgs(L) + cmd := exec.CommandContext(e.contextOrBackground(), args[0], args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + L.Push(lua.LNumber(commandExitCode(err))) + return 1 + }, + "json_decode": func(L *lua.LState) int { + input := L.CheckString(1) + var decoded any + if err := json.Unmarshal([]byte(input), &decoded); err != nil { + L.RaiseError("json_decode: %v", err) + return 0 + } + L.Push(toLuaValue(L, decoded)) + return 1 + }, + } + + for name, fn := range bindings { + if err := e.SetAPI(name, fn); err != nil { + return err + } + } + + return nil +} + +func luaCommandArgs(L *lua.LState) []string { + args := make([]string, 0, max(L.GetTop(), 1)) + if tbl, ok := L.Get(1).(*lua.LTable); ok { + tbl.ForEach(func(_ lua.LValue, value lua.LValue) { + args = append(args, value.String()) + }) + } else { + for i := 1; i <= L.GetTop(); i++ { + args = append(args, L.CheckString(i)) + } + } + + if len(args) == 0 { + L.ArgError(1, "expected command") + } + + return args +} + +func runCapture(cmd *exec.Cmd) (stdout, stderr string, code int) { + output, err := cmd.Output() + stdout = string(output) + if err == nil { + return stdout, "", 0 + } + + code = commandExitCode(err) + exitErr := &exec.ExitError{} + if errors.As(err, &exitErr) { + stderr = string(exitErr.Stderr) + return stdout, stderr, code + } + + return stdout, err.Error(), code +} + +func commandExitCode(err error) int { + if err == nil { + return 0 + } + + exitErr := &exec.ExitError{} + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + + return -1 +} + +func toLuaValue(L *lua.LState, value any) lua.LValue { + switch v := value.(type) { + case nil: + return lua.LNil + case string: + return lua.LString(v) + case bool: + return lua.LBool(v) + case float64: + return lua.LNumber(v) + case []any: + tbl := L.NewTable() + for _, item := range v { + tbl.Append(toLuaValue(L, item)) + } + return tbl + case map[string]any: + tbl := L.NewTable() + for key, item := range v { + L.SetField(tbl, key, toLuaValue(L, item)) + } + return tbl + default: + return lua.LString(fmt.Sprint(v)) + } +} + +// LoadInto runs the user's init.lua (if present) and applies its `yay.opt` +// table onto cfg, then attaches the engine to cfg so hooks can be invoked at +// runtime. If no init.lua is present, LoadInto is a no-op and returns nil. +// +// Errors from Apply (type mismatches) and unknown keys are reported via +// logger but do not fail the load. +func LoadInto(logger *text.Logger, cfg *settings.Configuration) error { + path, err := settings.ResolveLuaConfigPath() + if err != nil { + return fmt.Errorf("lua: resolve init.lua: %w", err) + } + if path == "" { + return nil + } + + e := New() + if err := registerBuiltins(e, logger); err != nil { + e.Close() + return err + } + if err := e.Run(path); err != nil { + e.Close() + return err + } + + unknown, errs := e.Apply(cfg) + if logger != nil { + for _, k := range unknown { + logger.Warnln("lua: unknown yay.opt key:", k) + } + for _, err := range errs { + logger.Errorln(err) + } + } + + cfg.SetLuaEngine(e) + return nil +} diff --git a/pkg/settings/lua/load_test.go b/pkg/settings/lua/load_test.go new file mode 100644 index 00000000..5af5e35d --- /dev/null +++ b/pkg/settings/lua/load_test.go @@ -0,0 +1,145 @@ +package lua + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/Jguer/yay/v12/pkg/settings" + "github.com/Jguer/yay/v12/pkg/text" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadIntoRegistersBuiltins(t *testing.T) { + configHome := t.TempDir() + homeDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("HOME", homeDir) + t.Setenv("YAY_LUA_EDITOR", "nvim") + + configDir := filepath.Join(configHome, "yay") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "init.lua"), []byte(` + yay.api.info("lua init starting") + yay.api.warn("lua init warning") + yay.api.error("lua init error") + + yay.opt.editor = yay.api.getenv("YAY_LUA_EDITOR") + yay.opt.build_dir = yay.api.expand("~/lua-build") + `), 0o644)) + + cfg := settings.DefaultConfig("v1.0.0") + logger := text.NewLogger(os.Stdout, os.Stderr, os.Stdin, false, "lua-test") + + require.NoError(t, LoadInto(logger, cfg)) + defer cfg.CloseLua() + + assert.Equal(t, "nvim", cfg.Editor) + assert.Equal(t, filepath.Join(homeDir, "lua-build"), cfg.BuildDir) + assert.NotNil(t, cfg) +} + +func TestLoadIntoExternalProvider(t *testing.T) { + configHome := t.TempDir() + homeDir := t.TempDir() + runLog := filepath.Join(t.TempDir(), "provider-run.log") + + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("HOME", homeDir) + t.Setenv("YAY_LUA_RUN_LOG", runLog) + + configDir := filepath.Join(configHome, "yay") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "init.lua"), []byte(` + yay.provider.homebrew = { + list = function() + local stdout, stderr, code = yay.api.capture("sh", "-c", "printf '%s' '[{\"name\":\"wget\",\"installed_versions\":[\"1.0\"],\"current_version\":\"2.0\"}]'") + if code ~= 0 then + error(stderr) + end + + local decoded = yay.api.json_decode(stdout) + local upgrades = {} + for _, item in ipairs(decoded) do + table.insert(upgrades, { + name = item.name, + repository = "homebrew", + local_version = item.installed_versions[1], + remote_version = item.current_version, + extra = "formula", + }) + end + + return upgrades + end, + upgrade = function(items) + local names = {} + for _, item in ipairs(items) do + table.insert(names, item.name) + end + + local script = string.format("printf '%s' '%s' > \"%s\"", "%s", table.concat(names, ","), yay.api.expand("$YAY_LUA_RUN_LOG")) + local code = yay.api.run("sh", "-c", script) + if code ~= 0 then + error("provider execution failed") + end + end, + } + `), 0o644)) + + cfg := settings.DefaultConfig("v1.0.0") + logger := text.NewLogger(os.Stdout, os.Stderr, os.Stdin, false, "lua-test") + + require.NoError(t, LoadInto(logger, cfg)) + defer cfg.CloseLua() + + upgrades, err := cfg.ExternalUpgrades(context.Background()) + require.NoError(t, err) + assert.Equal(t, []settings.ExternalUpgrade{{ + Name: "wget", + Repository: "homebrew", + LocalVersion: "1.0", + RemoteVersion: "2.0", + Extra: "formula", + }}, upgrades) + + require.NoError(t, cfg.RunExternalUpgradeProvider(context.Background(), "homebrew", upgrades)) + + runOutput, err := os.ReadFile(runLog) + require.NoError(t, err) + assert.Equal(t, "wget", string(runOutput)) +} + +func TestLoadIntoPrefersWorkingDirectoryInitLua(t *testing.T) { + configHome := t.TempDir() + homeDir := t.TempDir() + cwd := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("HOME", homeDir) + t.Setenv("YAY_LUA_EDITOR", "vim") + + require.NoError(t, os.MkdirAll(filepath.Join(configHome, "yay"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(configHome, "yay", "init.lua"), []byte(` + yay.opt.editor = "config-dir" + `), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(cwd, "init.lua"), []byte(` + yay.opt.editor = yay.api.getenv("YAY_LUA_EDITOR") + `), 0o644)) + + oldWD, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(cwd)) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWD)) + }) + + cfg := settings.DefaultConfig("v1.0.0") + logger := text.NewLogger(os.Stdout, os.Stderr, os.Stdin, false, "lua-test") + + require.NoError(t, LoadInto(logger, cfg)) + defer cfg.CloseLua() + + assert.Equal(t, "vim", cfg.Editor) +} diff --git a/pkg/settings/lua/lua.go b/pkg/settings/lua/lua.go new file mode 100644 index 00000000..3035740a --- /dev/null +++ b/pkg/settings/lua/lua.go @@ -0,0 +1,645 @@ +// Package lua provides an experimental Lua-based configuration surface for +// yay, mirroring neovim's `init.lua` pattern. +// +// The user writes a script at $XDG_CONFIG_HOME/yay/init.lua. The script is run +// inside a fresh gopher-lua state where a global `yay` table is pre-installed: +// +// yay.opt. -- setter proxy; values are collected and applied +// to a *settings.Configuration via reflection over +// its `ini:"..."` struct tags. Both snake_case and +// PascalCase keys are accepted (case-insensitive). +// yay.hook.on_prompt -- function(name, default) -> string. When set, it +// is invoked by menu code in lieu of the default +// answer. Returning nil/false falls back to the +// default. +// +// Apply takes a generic struct pointer and uses reflection so normal config +// fields do not need bespoke Lua bindings. +package lua + +import ( + "context" + "fmt" + "reflect" + "sort" + "strings" + "sync" + + "github.com/Jguer/yay/v12/pkg/settings" + lua "github.com/yuin/gopher-lua" +) + +const ( + globalName = "yay" + optTableName = "opt" + apiTableName = "api" + hookTableName = "hook" + providerTableName = "provider" + hookOnPrompt = "on_prompt" + hookShouldIncludeAURUpdate = "should_include_aur_update" + providerListFunctionName = "list" + providerSearchFunctionName = "search" + providerInstallFunctionName = "install" + providerRunFunctionName = "upgrade" +) + +// Engine wraps a gopher-lua state and serializes access to it. +type Engine struct { + mu sync.Mutex + L *lua.LState + callContext context.Context +} + +// New creates a new Lua engine with the `yay` global installed. +func New() *Engine { + L := lua.NewState() + + yayTbl := L.NewTable() + L.SetGlobal(globalName, yayTbl) + + // yay.opt: plain table; values are read after the script runs. + L.SetField(yayTbl, optTableName, L.NewTable()) + // yay.api: helper functions exposed to init.lua and runtime hooks. + L.SetField(yayTbl, apiTableName, L.NewTable()) + // yay.hook: plain table; functions stored here are invoked from Go. + L.SetField(yayTbl, hookTableName, L.NewTable()) + // yay.provider: registry of custom upgrade providers managed by Lua. + L.SetField(yayTbl, providerTableName, L.NewTable()) + + return &Engine{L: L} +} + +func (e *Engine) contextOrBackground() context.Context { + if e == nil || e.callContext == nil { + return context.Background() + } + + return e.callContext +} + +// SetAPI registers a helper function under yay.api.. +func (e *Engine) SetAPI(name string, fn lua.LGFunction) error { + e.mu.Lock() + defer e.mu.Unlock() + + yayTbl, ok := e.L.GetGlobal(globalName).(*lua.LTable) + if !ok { + return fmt.Errorf("lua: missing %s table", globalName) + } + apiTbl, ok := e.L.GetField(yayTbl, apiTableName).(*lua.LTable) + if !ok { + return fmt.Errorf("lua: missing %s.%s table", globalName, apiTableName) + } + + e.L.SetField(apiTbl, name, e.L.NewFunction(fn)) + return nil +} + +// Close releases the Lua state. +func (e *Engine) Close() { + if e == nil || e.L == nil { + return + } + e.mu.Lock() + defer e.mu.Unlock() + e.L.Close() + e.L = nil +} + +// Run executes the Lua file at path. +func (e *Engine) Run(path string) error { + e.mu.Lock() + defer e.mu.Unlock() + if err := e.L.DoFile(path); err != nil { + return fmt.Errorf("lua: error running %s: %w", path, err) + } + return nil +} + +// RunString executes an inline Lua snippet. Primarily for tests. +func (e *Engine) RunString(src string) error { + e.mu.Lock() + defer e.mu.Unlock() + if err := e.L.DoString(src); err != nil { + return fmt.Errorf("lua: error running snippet: %w", err) + } + return nil +} + +// Apply walks the `yay.opt` table and writes recognized keys into cfg via +// reflection. cfg must be a pointer to a struct whose fields are tagged with +// `ini:""`. Unknown keys are returned via the unknown slice (caller can +// log them); type-mismatches are reported in errs. +func (e *Engine) Apply(cfg any) (unknown []string, errs []error) { + e.mu.Lock() + defer e.mu.Unlock() + + v := reflect.ValueOf(cfg) + if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct { + return nil, []error{fmt.Errorf("lua: Apply: expected pointer to struct, got %T", cfg)} + } + sv := v.Elem() + st := sv.Type() + + // Build a case-insensitive index of ini tag -> field index. + index := make(map[string]int, st.NumField()) + for i := 0; i < st.NumField(); i++ { + tag := st.Field(i).Tag.Get("ini") + if tag == "" || tag == "-" { + continue + } + index[strings.ToLower(tag)] = i + } + + yayTbl, ok := e.L.GetGlobal(globalName).(*lua.LTable) + if !ok { + return nil, nil + } + optTbl, ok := e.L.GetField(yayTbl, optTableName).(*lua.LTable) + if !ok { + return nil, nil + } + + optTbl.ForEach(func(k, val lua.LValue) { + key, ok := k.(lua.LString) + if !ok { + return + } + canonical := strings.ToLower(strings.ReplaceAll(string(key), "_", "")) + fieldIdx, found := index[canonical] + if !found { + unknown = append(unknown, string(key)) + return + } + if err := assign(sv.Field(fieldIdx), val); err != nil { + errs = append(errs, fmt.Errorf("yay.opt.%s: %w", string(key), err)) + } + }) + + return unknown, errs +} + +// assign writes a Lua value into a reflect.Value, performing basic coercion. +func assign(field reflect.Value, val lua.LValue) error { + if !field.CanSet() { + return fmt.Errorf("field is not settable") + } + switch field.Kind() { + case reflect.String: + s, ok := val.(lua.LString) + if !ok { + return fmt.Errorf("expected string, got %s", val.Type()) + } + field.SetString(string(s)) + case reflect.Bool: + b, ok := val.(lua.LBool) + if !ok { + return fmt.Errorf("expected boolean, got %s", val.Type()) + } + field.SetBool(bool(b)) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n, ok := val.(lua.LNumber) + if !ok { + return fmt.Errorf("expected number, got %s", val.Type()) + } + field.SetInt(int64(n)) + default: + return fmt.Errorf("unsupported field kind %s", field.Kind()) + } + return nil +} + +// CallOnPrompt invokes yay.hook.on_prompt(name, defaultAns) if it is a +// function. The boolean return is false when the hook is not set, when it +// returns nil/false, or when it returns a non-string value. Errors from the +// Lua call are returned untouched. +func (e *Engine) CallOnPrompt(name, defaultAns string) (string, bool, error) { + e.mu.Lock() + defer e.mu.Unlock() + + yayTbl, ok := e.L.GetGlobal(globalName).(*lua.LTable) + if !ok { + return "", false, nil + } + hookTbl, ok := e.L.GetField(yayTbl, hookTableName).(*lua.LTable) + if !ok { + return "", false, nil + } + fn, ok := e.L.GetField(hookTbl, hookOnPrompt).(*lua.LFunction) + if !ok { + return "", false, nil + } + + if err := e.L.CallByParam(lua.P{ + Fn: fn, + NRet: 1, + Protect: true, + }, lua.LString(name), lua.LString(defaultAns)); err != nil { + return "", false, fmt.Errorf("lua: on_prompt: %w", err) + } + ret := e.L.Get(-1) + e.L.Pop(1) + + switch v := ret.(type) { + case lua.LString: + return string(v), true, nil + case *lua.LNilType: + return "", false, nil + case lua.LBool: + if !bool(v) { + return "", false, nil + } + } + return "", false, nil +} + +// CallShouldIncludeAURUpdate invokes yay.hook.should_include_aur_update(candidate) +// if it is a function. The second return value is false when the hook is not +// set or returns nil. +func (e *Engine) CallShouldIncludeAURUpdate(candidate settings.AURUpdateContext) (bool, bool, error) { + e.mu.Lock() + defer e.mu.Unlock() + + yayTbl, ok := e.L.GetGlobal(globalName).(*lua.LTable) + if !ok { + return false, false, nil + } + hookTbl, ok := e.L.GetField(yayTbl, hookTableName).(*lua.LTable) + if !ok { + return false, false, nil + } + fn, ok := e.L.GetField(hookTbl, hookShouldIncludeAURUpdate).(*lua.LFunction) + if !ok { + return false, false, nil + } + + arg := e.L.NewTable() + e.L.SetField(arg, "name", lua.LString(candidate.Name)) + e.L.SetField(arg, "base", lua.LString(candidate.Base)) + e.L.SetField(arg, "repository", lua.LString(candidate.Repository)) + e.L.SetField(arg, "local_version", lua.LString(candidate.LocalVersion)) + e.L.SetField(arg, "remote_version", lua.LString(candidate.RemoteVersion)) + e.L.SetField(arg, "local_build_date", lua.LNumber(candidate.LocalBuildDate)) + e.L.SetField(arg, "remote_last_modified", lua.LNumber(candidate.RemoteLastModified)) + e.L.SetField(arg, "default_include", lua.LBool(candidate.DefaultInclude)) + + if err := e.L.CallByParam(lua.P{ + Fn: fn, + NRet: 1, + Protect: true, + }, arg); err != nil { + return false, false, fmt.Errorf("lua: should_include_aur_update: %w", err) + } + ret := e.L.Get(-1) + e.L.Pop(1) + + switch v := ret.(type) { + case lua.LBool: + return bool(v), true, nil + case *lua.LNilType: + return false, false, nil + default: + return false, false, fmt.Errorf("lua: should_include_aur_update: expected boolean or nil, got %s", ret.Type()) + } +} + +// CallListExternalUpgrades invokes all registered yay.provider..list +// functions and merges their returned upgrades into a single slice. +func (e *Engine) CallListExternalUpgrades(ctx context.Context) ([]settings.ExternalUpgrade, error) { + e.mu.Lock() + defer e.mu.Unlock() + + previousCtx := e.callContext + e.callContext = ctx + defer func() { e.callContext = previousCtx }() + + providers, err := e.providersTable() + if err != nil || providers == nil { + return nil, err + } + providerNames, providerTables, err := e.collectProviders(providers) + if err != nil { + return nil, err + } + + upgrades := make([]settings.ExternalUpgrade, 0) + for _, providerName := range providerNames { + provider := providerTables[providerName] + fn, ok := e.L.GetField(provider, providerListFunctionName).(*lua.LFunction) + if !ok { + continue + } + + if err := e.L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true}); err != nil { + return nil, fmt.Errorf("lua: provider %s list: %w", providerName, err) + } + + ret := e.L.Get(-1) + e.L.Pop(1) + + if ret == lua.LNil { + continue + } + + items, ok := ret.(*lua.LTable) + if !ok { + return nil, fmt.Errorf("lua: provider %s list: expected table or nil, got %s", providerName, ret.Type()) + } + + providerUpgrades, err := e.externalUpgradesFromLua(providerName, items) + if err != nil { + return nil, err + } + + upgrades = append(upgrades, providerUpgrades...) + } + + return upgrades, nil +} + +// CallRunExternalUpgrades invokes yay.provider..upgrade for the selected +// upgrades. The boolean return is false when the provider or hook is absent. +func (e *Engine) CallRunExternalUpgrades(ctx context.Context, repository string, upgrades []settings.ExternalUpgrade) (bool, error) { + e.mu.Lock() + defer e.mu.Unlock() + + previousCtx := e.callContext + e.callContext = ctx + defer func() { e.callContext = previousCtx }() + + providers, err := e.providersTable() + if err != nil || providers == nil { + return false, err + } + + provider, ok := e.L.GetField(providers, repository).(*lua.LTable) + if !ok { + return false, nil + } + + fn, ok := e.L.GetField(provider, providerRunFunctionName).(*lua.LFunction) + if !ok { + return false, nil + } + + if err := e.L.CallByParam(lua.P{Fn: fn, NRet: 0, Protect: true}, e.externalUpgradesToLua(upgrades)); err != nil { + return true, fmt.Errorf("lua: provider %s upgrade: %w", repository, err) + } + + return true, nil +} + +// CallSearchExternalPackages invokes all registered yay.provider..search +// functions and merges their returned results into a single slice. +func (e *Engine) CallSearchExternalPackages(ctx context.Context, terms []string) ([]settings.ExternalSearchResult, error) { + e.mu.Lock() + defer e.mu.Unlock() + + previousCtx := e.callContext + e.callContext = ctx + defer func() { e.callContext = previousCtx }() + + providers, err := e.providersTable() + if err != nil || providers == nil { + return nil, err + } + providerNames, providerTables, err := e.collectProviders(providers) + if err != nil { + return nil, err + } + + termsTable := e.L.NewTable() + for _, term := range terms { + termsTable.Append(lua.LString(term)) + } + + results := make([]settings.ExternalSearchResult, 0) + for _, providerName := range providerNames { + provider := providerTables[providerName] + fn, ok := e.L.GetField(provider, providerSearchFunctionName).(*lua.LFunction) + if !ok { + continue + } + + if err := e.L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true}, termsTable); err != nil { + return nil, fmt.Errorf("lua: provider %s search: %w", providerName, err) + } + + ret := e.L.Get(-1) + e.L.Pop(1) + if ret == lua.LNil { + continue + } + + items, ok := ret.(*lua.LTable) + if !ok { + return nil, fmt.Errorf("lua: provider %s search: expected table or nil, got %s", providerName, ret.Type()) + } + + providerResults, err := e.externalSearchResultsFromLua(providerName, items) + if err != nil { + return nil, err + } + results = append(results, providerResults...) + } + + return results, nil +} + +// CallInstallExternalPackages invokes yay.provider..install for the +// selected targets. The boolean return is false when the provider or hook is +// absent. +func (e *Engine) CallInstallExternalPackages(ctx context.Context, repository string, targets []settings.ExternalInstallTarget) (bool, error) { + e.mu.Lock() + defer e.mu.Unlock() + + previousCtx := e.callContext + e.callContext = ctx + defer func() { e.callContext = previousCtx }() + + providers, err := e.providersTable() + if err != nil || providers == nil { + return false, err + } + + provider, ok := e.L.GetField(providers, repository).(*lua.LTable) + if !ok { + return false, nil + } + + fn, ok := e.L.GetField(provider, providerInstallFunctionName).(*lua.LFunction) + if !ok { + return false, nil + } + + if err := e.L.CallByParam(lua.P{Fn: fn, NRet: 0, Protect: true}, e.externalInstallTargetsToLua(targets)); err != nil { + return true, fmt.Errorf("lua: provider %s install: %w", repository, err) + } + + return true, nil +} + +func (e *Engine) HasProvider(name string) bool { + e.mu.Lock() + defer e.mu.Unlock() + + providers, err := e.providersTable() + if err != nil || providers == nil { + return false + } + + _, ok := e.L.GetField(providers, name).(*lua.LTable) + return ok +} + +func (e *Engine) providersTable() (*lua.LTable, error) { + yayTbl, ok := e.L.GetGlobal(globalName).(*lua.LTable) + if !ok { + return nil, fmt.Errorf("lua: missing %s table", globalName) + } + + providers, ok := e.L.GetField(yayTbl, providerTableName).(*lua.LTable) + if !ok { + return nil, nil + } + + return providers, nil +} + +func (e *Engine) collectProviders(providers *lua.LTable) ([]string, map[string]*lua.LTable, error) { + providerNames := make([]string, 0) + providerTables := make(map[string]*lua.LTable) + providers.ForEach(func(k, v lua.LValue) { + name, ok := k.(lua.LString) + if !ok { + return + } + provider, ok := v.(*lua.LTable) + if !ok { + return + } + providerName := string(name) + providerNames = append(providerNames, providerName) + providerTables[providerName] = provider + }) + sort.Strings(providerNames) + + return providerNames, providerTables, nil +} + +func (e *Engine) externalUpgradesFromLua(providerName string, items *lua.LTable) ([]settings.ExternalUpgrade, error) { + upgrades := make([]settings.ExternalUpgrade, 0) + items.ForEach(func(_ lua.LValue, value lua.LValue) { + if value == lua.LNil { + return + } + item, ok := value.(*lua.LTable) + if !ok { + upgrades = append(upgrades, settings.ExternalUpgrade{Repository: providerName, Extra: fmt.Sprintf("__error__:%s", value.Type())}) + return + } + + upgrade := settings.ExternalUpgrade{ + Name: luaTableString(item, "name"), + Base: luaTableString(item, "base"), + Repository: luaTableString(item, "repository"), + LocalVersion: luaTableString(item, "local_version"), + RemoteVersion: luaTableString(item, "remote_version"), + Extra: luaTableString(item, "extra"), + } + if upgrade.Repository == "" { + upgrade.Repository = providerName + } + upgrades = append(upgrades, upgrade) + }) + + for _, upgrade := range upgrades { + if strings.HasPrefix(upgrade.Extra, "__error__:") { + return nil, fmt.Errorf("lua: provider %s list: expected table items, got %s", providerName, strings.TrimPrefix(upgrade.Extra, "__error__:")) + } + if upgrade.Name == "" { + return nil, fmt.Errorf("lua: provider %s list: upgrade entry missing name", providerName) + } + } + + return upgrades, nil +} + +func (e *Engine) externalUpgradesToLua(upgrades []settings.ExternalUpgrade) *lua.LTable { + tbl := e.L.NewTable() + for _, upgrade := range upgrades { + item := e.L.NewTable() + e.L.SetField(item, "name", lua.LString(upgrade.Name)) + e.L.SetField(item, "base", lua.LString(upgrade.Base)) + e.L.SetField(item, "repository", lua.LString(upgrade.Repository)) + e.L.SetField(item, "local_version", lua.LString(upgrade.LocalVersion)) + e.L.SetField(item, "remote_version", lua.LString(upgrade.RemoteVersion)) + e.L.SetField(item, "extra", lua.LString(upgrade.Extra)) + tbl.Append(item) + } + + return tbl +} + +func (e *Engine) externalSearchResultsFromLua(providerName string, items *lua.LTable) ([]settings.ExternalSearchResult, error) { + results := make([]settings.ExternalSearchResult, 0) + items.ForEach(func(_ lua.LValue, value lua.LValue) { + if value == lua.LNil { + return + } + item, ok := value.(*lua.LTable) + if !ok { + results = append(results, settings.ExternalSearchResult{Repository: providerName, Extra: fmt.Sprintf("__error__:%s", value.Type())}) + return + } + + result := settings.ExternalSearchResult{ + Name: luaTableString(item, "name"), + Base: luaTableString(item, "base"), + Repository: luaTableString(item, "repository"), + Version: luaTableString(item, "version"), + InstalledVersion: luaTableString(item, "installed_version"), + Description: luaTableString(item, "description"), + Extra: luaTableString(item, "extra"), + } + if result.Repository == "" { + result.Repository = providerName + } + results = append(results, result) + }) + + for _, result := range results { + if strings.HasPrefix(result.Extra, "__error__:") { + return nil, fmt.Errorf("lua: provider %s search: expected table items, got %s", providerName, strings.TrimPrefix(result.Extra, "__error__:")) + } + if result.Name == "" { + return nil, fmt.Errorf("lua: provider %s search: result entry missing name", providerName) + } + } + + return results, nil +} + +func (e *Engine) externalInstallTargetsToLua(targets []settings.ExternalInstallTarget) *lua.LTable { + tbl := e.L.NewTable() + for _, target := range targets { + item := e.L.NewTable() + e.L.SetField(item, "name", lua.LString(target.Name)) + e.L.SetField(item, "repository", lua.LString(target.Repository)) + tbl.Append(item) + } + + return tbl +} + +func luaTableString(tbl *lua.LTable, field string) string { + value := tbl.RawGetString(field) + if value == lua.LNil { + return "" + } + if str, ok := value.(lua.LString); ok { + return string(str) + } + + return fmt.Sprint(value) +} diff --git a/pkg/settings/lua/lua_test.go b/pkg/settings/lua/lua_test.go new file mode 100644 index 00000000..f18e93f8 --- /dev/null +++ b/pkg/settings/lua/lua_test.go @@ -0,0 +1,106 @@ +package lua + +import ( + "testing" + + "github.com/Jguer/yay/v12/pkg/settings" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// minimal struct mirroring the relevant Configuration fields/tags, kept local +// to avoid an import cycle with pkg/settings. +type fakeCfg struct { + Editor string `ini:"Editor"` + BottomUp bool `ini:"BottomUp"` + SplitN int `ini:"RequestSplitN"` + Skipped string `ini:"-"` + Untagged string +} + +func TestEngine_ApplyAndOnPrompt(t *testing.T) { + t.Parallel() + e := New() + defer e.Close() + + src := ` + yay.opt.editor = "nvim" + yay.opt.bottom_up = true + yay.opt.RequestSplitN = 42 + yay.opt.does_not_exist = "ignored" + yay.hook.on_prompt = function(name, default) + if name == "clean" then return "1 2 3" end + return default + end + yay.hook.should_include_aur_update = function(pkg) + return pkg.remote_last_modified > pkg.local_build_date + end + ` + require.NoError(t, e.RunString(src)) + + var cfg fakeCfg + unknown, errs := e.Apply(&cfg) + require.Empty(t, errs, "unexpected apply errors: %v", errs) + assert.ElementsMatch(t, []string{"does_not_exist"}, unknown) + assert.Equal(t, "nvim", cfg.Editor) + assert.True(t, cfg.BottomUp) + assert.Equal(t, 42, cfg.SplitN) + + got, ok, err := e.CallOnPrompt("clean", "") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "1 2 3", got) + + got, ok, err = e.CallOnPrompt("diff", "default-ans") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "default-ans", got) + + include, ok, err := e.CallShouldIncludeAURUpdate(settings.AURUpdateContext{ + Name: "hello", + Repository: "aur", + LocalVersion: "2.0.0", + RemoteVersion: "2.0.0", + LocalBuildDate: 100, + RemoteLastModified: 200, + DefaultInclude: false, + }) + require.NoError(t, err) + assert.True(t, ok) + assert.True(t, include) +} + +func TestEngine_OnPromptUnsetReturnsFalse(t *testing.T) { + t.Parallel() + e := New() + defer e.Close() + + got, ok, err := e.CallOnPrompt("clean", "x") + require.NoError(t, err) + assert.False(t, ok) + assert.Equal(t, "", got) +} + +func TestEngine_ShouldIncludeAURUpdateUnsetReturnsFalse(t *testing.T) { + t.Parallel() + e := New() + defer e.Close() + + include, ok, err := e.CallShouldIncludeAURUpdate(settings.AURUpdateContext{}) + require.NoError(t, err) + assert.False(t, ok) + assert.False(t, include) +} + +func TestEngine_ApplyTypeMismatchReportsError(t *testing.T) { + t.Parallel() + e := New() + defer e.Close() + + require.NoError(t, e.RunString(`yay.opt.editor = 123`)) + + var cfg fakeCfg + _, errs := e.Apply(&cfg) + require.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "yay.opt.editor") +} diff --git a/pkg/upgrade/service.go b/pkg/upgrade/service.go index 3b73e78b..39e8d8b8 100644 --- a/pkg/upgrade/service.go +++ b/pkg/upgrade/service.go @@ -34,6 +34,10 @@ type UpgradeService struct { noConfirm bool AURWarnings *query.AURWarnings + + externalUpgrades UpSlice + externalIndex map[string]settings.ExternalUpgrade + selectedExternal map[string][]settings.ExternalUpgrade } func NewUpgradeService(grapher *dep.Grapher, aurCache aur.QueryClient, @@ -57,6 +61,10 @@ func (u *UpgradeService) upGraph(ctx context.Context, graph *topo.Graph[string, enableDowngrade bool, filter Filter, ) (err error) { + u.externalUpgrades = UpSlice{} + u.externalIndex = make(map[string]settings.ExternalUpgrade) + u.selectedExternal = nil + var ( develUp UpSlice errs multierror.MultiError @@ -83,7 +91,23 @@ func (u *UpgradeService) upGraph(ctx context.Context, graph *topo.Graph[string, u.AURWarnings.CalculateMissing(remoteNames, remote, aurdata) - aurUp = UpAUR(u.log, remote, aurdata, enableDowngrade) + aurUp = UpAUR(u.log, remote, aurdata, enableDowngrade, func(local db.IPackage, remotePkg *query.Pkg, defaultInclude bool) bool { + localBuildDate := int64(0) + if buildDate := local.BuildDate(); !buildDate.IsZero() { + localBuildDate = buildDate.Unix() + } + + return u.cfg.ShouldIncludeAURUpdate(settings.AURUpdateContext{ + Name: remotePkg.Name, + Base: remotePkg.PackageBase, + Repository: "aur", + LocalVersion: local.Version(), + RemoteVersion: remotePkg.Version, + LocalBuildDate: localBuildDate, + RemoteLastModified: int64(remotePkg.LastModified), + DefaultInclude: defaultInclude, + }) + }) if u.cfg.Devel { u.log.OperationInfoln(gotext.Get("Checking development packages...")) @@ -178,6 +202,12 @@ func (u *UpgradeService) upGraph(ctx context.Context, graph *topo.Graph[string, errs.Add(err) } + externalUpgrades, externalErr := u.cfg.ExternalUpgrades(ctx) + if externalErr == nil { + u.setExternalUpgrades(externalUpgrades, filter) + } + errs.Add(externalErr) + return errs.Return() } @@ -252,15 +282,16 @@ func (u *UpgradeService) GraphUpgrades(ctx context.Context, // userExcludeUpgrades asks the user which packages to exclude from the upgrade and // removes them from the graph func (u *UpgradeService) UserExcludeUpgrades(graph *topo.Graph[string, *dep.InstallInfo]) ([]string, error) { - if graph.Len() == 0 { + if graph.Len() == 0 && len(u.externalUpgrades.Up) == 0 { return []string{}, nil } aurUp, repoUp := u.graphToUpSlice(graph) sort.Sort(repoUp) sort.Sort(aurUp) + sort.Sort(u.externalUpgrades) - allUp := UpSlice{Repos: append(repoUp.Repos, aurUp.Repos...)} + allUp := UpSlice{Repos: append(append(repoUp.Repos, aurUp.Repos...), u.externalUpgrades.Repos...)} for _, up := range repoUp.Up { if up.LocalVersion == "" && up.Reason != alpm.PkgReasonExplicit { allUp.PulledDeps = append(allUp.PulledDeps, up) @@ -269,6 +300,10 @@ func (u *UpgradeService) UserExcludeUpgrades(graph *topo.Graph[string, *dep.Inst } } + for _, up := range u.externalUpgrades.Up { + allUp.Up = append(allUp.Up, up) + } + for _, up := range aurUp.Up { if up.LocalVersion == "" && up.Reason != alpm.PkgReasonExplicit { allUp.PulledDeps = append(allUp.PulledDeps, up) @@ -291,7 +326,7 @@ func (u *UpgradeService) UserExcludeUpgrades(graph *topo.Graph[string, *dep.Inst u.log.Infoln(gotext.Get("Packages to exclude: (eg: \"1 2 3\", \"1-3\", \"^4\" or repo name)")) u.log.Warnln(gotext.Get("Excluding packages may cause partial upgrades and break systems")) - numbers, err := u.log.GetInput(u.cfg.AnswerUpgrade, settings.NoConfirm) + numbers, err := u.log.GetInput(u.cfg.OnPrompt("upgrade", u.cfg.AnswerUpgrade), settings.NoConfirm) if err != nil { return nil, err } @@ -302,9 +337,11 @@ func (u *UpgradeService) UserExcludeUpgrades(graph *topo.Graph[string, *dep.Inst // true if user doesn't want to include specific repositories/packages noIncludes := len(include) == 0 && otherInclude.Cardinality() == 0 + u.selectedExternal = make(map[string][]settings.ExternalUpgrade) // No exclusions or inclusions specified, return early if noIncludes && len(exclude) == 0 && otherExclude.Cardinality() == 0 { + u.selectAllExternalUpgrades() return []string{}, nil } @@ -312,22 +349,27 @@ func (u *UpgradeService) UserExcludeUpgrades(graph *topo.Graph[string, *dep.Inst for i := range allUp.Up { up := &allUp.Up[i] upgradeID := len(allUp.Up) - i + externalUpgrade, isExternal := u.externalUpgradeFor(*up) + + selected := false // check if user wants to exclude specific things (true) or include specific things if noIncludes { - // exclude repositories mentioned by the user - if otherExclude.Contains(up.Repository) { - u.log.Debugln("pruning", up.Name) - excluded = append(excluded, graph.Prune(up.Name)...) - } - // exclude packages mentioned by the user - if exclude.Get(upgradeID) { - u.log.Debugln("pruning", up.Name) - excluded = append(excluded, graph.Prune(up.Name)...) - } + selected = !otherExclude.Contains(up.Repository) && !exclude.Get(upgradeID) // If the user explicitly wants to include a package/repository, exclude everything else - } else if !include.Get(upgradeID) && !otherInclude.Contains(up.Repository) { + } else { + selected = include.Get(upgradeID) || otherInclude.Contains(up.Repository) + } + + if isExternal { + if selected { + u.selectedExternal[externalUpgrade.Repository] = append(u.selectedExternal[externalUpgrade.Repository], externalUpgrade) + } + continue + } + + if !selected { u.log.Debugln("pruning", up.Name) excluded = append(excluded, graph.Prune(up.Name)...) } @@ -335,3 +377,90 @@ func (u *UpgradeService) UserExcludeUpgrades(graph *topo.Graph[string, *dep.Inst return excluded, nil } + +func (u *UpgradeService) HasSelectedExternalUpgrades() bool { + for _, upgrades := range u.selectedExternal { + if len(upgrades) > 0 { + return true + } + } + + return false +} + +func (u *UpgradeService) RunExternalUpgrades(ctx context.Context) error { + if len(u.externalUpgrades.Up) == 0 { + return nil + } + if u.selectedExternal == nil { + u.selectAllExternalUpgrades() + } + + runRepos := make(map[string]struct{}, len(u.selectedExternal)) + for _, repo := range u.externalUpgrades.Repos { + upgrades := u.selectedExternal[repo] + if len(upgrades) == 0 { + continue + } + if _, seen := runRepos[repo]; seen { + continue + } + runRepos[repo] = struct{}{} + + if err := u.cfg.RunExternalUpgradeProvider(ctx, repo, upgrades); err != nil { + return err + } + } + + return nil +} + +func (u *UpgradeService) setExternalUpgrades(upgrades []settings.ExternalUpgrade, filter Filter) { + u.externalUpgrades = UpSlice{Up: make([]Upgrade, 0, len(upgrades))} + u.externalIndex = make(map[string]settings.ExternalUpgrade, len(upgrades)) + seenRepos := make(map[string]struct{}, len(upgrades)) + + for _, external := range upgrades { + upgrade := Upgrade{ + Name: external.Name, + Base: external.Base, + Repository: external.Repository, + LocalVersion: external.LocalVersion, + RemoteVersion: external.RemoteVersion, + Reason: alpm.PkgReasonExplicit, + Extra: external.Extra, + } + + if filter != nil && !filter(&upgrade) { + continue + } + + u.externalUpgrades.Up = append(u.externalUpgrades.Up, upgrade) + u.externalIndex[externalUpgradeKey(upgrade)] = external + + if _, ok := seenRepos[upgrade.Repository]; !ok { + seenRepos[upgrade.Repository] = struct{}{} + u.externalUpgrades.Repos = append(u.externalUpgrades.Repos, upgrade.Repository) + } + } +} + +func (u *UpgradeService) selectAllExternalUpgrades() { + u.selectedExternal = make(map[string][]settings.ExternalUpgrade) + for _, upgrade := range u.externalUpgrades.Up { + externalUpgrade, ok := u.externalUpgradeFor(upgrade) + if !ok { + continue + } + u.selectedExternal[externalUpgrade.Repository] = append(u.selectedExternal[externalUpgrade.Repository], externalUpgrade) + } +} + +func (u *UpgradeService) externalUpgradeFor(upgrade Upgrade) (settings.ExternalUpgrade, bool) { + external, ok := u.externalIndex[externalUpgradeKey(upgrade)] + return external, ok +} + +func externalUpgradeKey(upgrade Upgrade) string { + return fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%s\x00%s", upgrade.Repository, upgrade.Name, upgrade.Base, upgrade.LocalVersion, upgrade.RemoteVersion, upgrade.Extra) +} diff --git a/pkg/upgrade/service_test.go b/pkg/upgrade/service_test.go index 67c1be63..a530fa6b 100644 --- a/pkg/upgrade/service_test.go +++ b/pkg/upgrade/service_test.go @@ -32,6 +32,49 @@ func ptrString(s string) *string { return &s } +type mockLuaUpgradeRunner struct { + listExternal func(context.Context) ([]settings.ExternalUpgrade, error) + runExternal func(context.Context, string, []settings.ExternalUpgrade) (bool, error) +} + +func (m *mockLuaUpgradeRunner) CallOnPrompt(name, defaultAns string) (string, bool, error) { + return "", false, nil +} + +func (m *mockLuaUpgradeRunner) CallShouldIncludeAURUpdate(candidate settings.AURUpdateContext) (bool, bool, error) { + return false, false, nil +} + +func (m *mockLuaUpgradeRunner) CallListExternalUpgrades(ctx context.Context) ([]settings.ExternalUpgrade, error) { + if m.listExternal == nil { + return nil, nil + } + + return m.listExternal(ctx) +} + +func (m *mockLuaUpgradeRunner) CallRunExternalUpgrades(ctx context.Context, repository string, upgrades []settings.ExternalUpgrade) (bool, error) { + if m.runExternal == nil { + return false, nil + } + + return m.runExternal(ctx, repository, upgrades) +} + +func (m *mockLuaUpgradeRunner) CallSearchExternalPackages(ctx context.Context, terms []string) ([]settings.ExternalSearchResult, error) { + return nil, nil +} + +func (m *mockLuaUpgradeRunner) CallInstallExternalPackages(ctx context.Context, repository string, targets []settings.ExternalInstallTarget) (bool, error) { + return false, nil +} + +func (m *mockLuaUpgradeRunner) HasProvider(name string) bool { + return false +} + +func (m *mockLuaUpgradeRunner) Close() {} + func TestUpgradeService_GraphUpgrades(t *testing.T) { t.Parallel() linuxDepInfo := &dep.InstallInfo{ @@ -686,6 +729,110 @@ func TestUpgradeService_GraphUpgradesNoUpdates(t *testing.T) { } } +func TestUpgradeService_ExternalUpgrades(t *testing.T) { + t.Parallel() + + t.Run("runs selected external upgrades when no graph packages exist", func(t *testing.T) { + t.Parallel() + + dbExe := &mock.DBExecutor{ + InstalledRemotePackageNamesFn: func() []string { return []string{} }, + InstalledRemotePackagesFn: func() map[string]mock.IPackage { return map[string]mock.IPackage{} }, + SyncUpgradesFn: func(bool) (map[string]db.SyncUpgrade, error) { return map[string]db.SyncUpgrade{}, nil }, + ReposFn: func() []string { return []string{"core"} }, + } + + logger := text.NewLogger(io.Discard, os.Stderr, strings.NewReader("\n"), true, "test") + grapher := dep.NewGrapher(dbExe, &mockaur.MockAUR{GetFn: func(context.Context, *aur.Query) ([]aur.Pkg, error) { + return []aur.Pkg{}, nil + }}, false, true, false, false, false, logger) + + executed := make([]settings.ExternalUpgrade, 0) + cfg := &settings.Configuration{Mode: parser.ModeAny} + cfg.SetLuaEngine(&mockLuaUpgradeRunner{ + listExternal: func(context.Context) ([]settings.ExternalUpgrade, error) { + return []settings.ExternalUpgrade{{Name: "wget", Repository: "homebrew", LocalVersion: "1.0", RemoteVersion: "2.0"}}, nil + }, + runExternal: func(_ context.Context, repository string, upgrades []settings.ExternalUpgrade) (bool, error) { + assert.Equal(t, "homebrew", repository) + executed = append(executed, upgrades...) + return true, nil + }, + }) + + u := &UpgradeService{ + log: logger, + grapher: grapher, + aurCache: &mockaur.MockAUR{GetFn: func(context.Context, *aur.Query) ([]aur.Pkg, error) { return []aur.Pkg{}, nil }}, + dbExecutor: dbExe, + vcsStore: &vcs.Mock{}, + cfg: cfg, + AURWarnings: query.NewWarnings(logger), + } + + graph, err := u.GraphUpgrades(context.Background(), nil, false, func(*Upgrade) bool { return true }) + require.NoError(t, err) + assert.Equal(t, 0, graph.Len()) + + excluded, err := u.UserExcludeUpgrades(graph) + require.NoError(t, err) + assert.Empty(t, excluded) + assert.True(t, u.HasSelectedExternalUpgrades()) + + require.NoError(t, u.RunExternalUpgrades(context.Background())) + assert.Equal(t, []settings.ExternalUpgrade{{Name: "wget", Repository: "homebrew", LocalVersion: "1.0", RemoteVersion: "2.0"}}, executed) + }) + + t.Run("excluded external upgrades are not executed", func(t *testing.T) { + t.Parallel() + + dbExe := &mock.DBExecutor{ + InstalledRemotePackageNamesFn: func() []string { return []string{} }, + InstalledRemotePackagesFn: func() map[string]mock.IPackage { return map[string]mock.IPackage{} }, + SyncUpgradesFn: func(bool) (map[string]db.SyncUpgrade, error) { return map[string]db.SyncUpgrade{}, nil }, + ReposFn: func() []string { return []string{"core"} }, + } + + logger := text.NewLogger(io.Discard, os.Stderr, strings.NewReader("1\n"), true, "test") + grapher := dep.NewGrapher(dbExe, &mockaur.MockAUR{GetFn: func(context.Context, *aur.Query) ([]aur.Pkg, error) { + return []aur.Pkg{}, nil + }}, false, true, false, false, false, logger) + + runs := 0 + cfg := &settings.Configuration{Mode: parser.ModeAny} + cfg.SetLuaEngine(&mockLuaUpgradeRunner{ + listExternal: func(context.Context) ([]settings.ExternalUpgrade, error) { + return []settings.ExternalUpgrade{{Name: "wget", Repository: "homebrew", LocalVersion: "1.0", RemoteVersion: "2.0"}}, nil + }, + runExternal: func(context.Context, string, []settings.ExternalUpgrade) (bool, error) { + runs++ + return true, nil + }, + }) + + u := &UpgradeService{ + log: logger, + grapher: grapher, + aurCache: &mockaur.MockAUR{GetFn: func(context.Context, *aur.Query) ([]aur.Pkg, error) { return []aur.Pkg{}, nil }}, + dbExecutor: dbExe, + vcsStore: &vcs.Mock{}, + cfg: cfg, + AURWarnings: query.NewWarnings(logger), + } + + graph, err := u.GraphUpgrades(context.Background(), nil, false, func(*Upgrade) bool { return true }) + require.NoError(t, err) + + excluded, err := u.UserExcludeUpgrades(graph) + require.NoError(t, err) + assert.Empty(t, excluded) + assert.False(t, u.HasSelectedExternalUpgrades()) + + require.NoError(t, u.RunExternalUpgrades(context.Background())) + assert.Equal(t, 0, runs) + }) +} + func TestUpgradeService_Warnings(t *testing.T) { t.Parallel() dbExe := &mock.DBExecutor{ diff --git a/pkg/upgrade/sources.go b/pkg/upgrade/sources.go index d8c668d5..51184b79 100644 --- a/pkg/upgrade/sources.go +++ b/pkg/upgrade/sources.go @@ -2,6 +2,7 @@ package upgrade import ( "context" + "time" "github.com/leonelquinteros/gotext" @@ -11,6 +12,10 @@ import ( "github.com/Jguer/yay/v12/pkg/vcs" ) +// IncludeAURUpdateFunc can override the default version-based inclusion +// decision for an AUR package candidate. +type IncludeAURUpdateFunc func(local db.IPackage, remote *query.Pkg, defaultInclude bool) bool + func UpDevel( ctx context.Context, log *text.Logger, @@ -63,7 +68,7 @@ func printIgnoringPackage(log *text.Logger, pkg db.IPackage, newPkgVersion strin // UpAUR gathers foreign packages and checks if they have new versions. // Output: Upgrade type package list. func UpAUR(log *text.Logger, remote map[string]db.IPackage, aurdata map[string]*query.Pkg, - enableDowngrade bool, + enableDowngrade bool, include IncludeAURUpdateFunc, ) UpSlice { toUpgrade := UpSlice{Up: make([]Upgrade, 0), Repos: []string{"aur"}} @@ -73,22 +78,41 @@ func UpAUR(log *text.Logger, remote map[string]db.IPackage, aurdata map[string]* continue } - if (db.VerCmp(pkg.Version(), aurPkg.Version) < 0) || - (enableDowngrade && (db.VerCmp(pkg.Version(), aurPkg.Version) > 0)) { - if pkg.ShouldIgnore() { - printIgnoringPackage(log, pkg, aurPkg.Version) + defaultInclude := (db.VerCmp(pkg.Version(), aurPkg.Version) < 0) || + (enableDowngrade && (db.VerCmp(pkg.Version(), aurPkg.Version) > 0)) + shouldInclude := defaultInclude + if include != nil { + shouldInclude = include(pkg, aurPkg, defaultInclude) + } + if !shouldInclude { + continue + } + + if pkg.ShouldIgnore() { + printIgnoringPackage(log, pkg, aurPkg.Version) + continue + } + + upgrade := Upgrade{ + Name: aurPkg.Name, + Base: aurPkg.PackageBase, + Repository: "aur", + LocalVersion: pkg.Version(), + RemoteVersion: aurPkg.Version, + Reason: pkg.Reason(), + } + if !defaultInclude && pkg.Version() == aurPkg.Version { + if localBuildDate := pkg.BuildDate(); !localBuildDate.IsZero() { + upgrade.Extra = gotext.Get("Lua hook selected package: AUR modified %s after local build %s", + time.Unix(int64(aurPkg.LastModified), 0).Format(time.RFC3339), + localBuildDate.Format(time.RFC3339), + ) } else { - toUpgrade.Up = append(toUpgrade.Up, - Upgrade{ - Name: aurPkg.Name, - Base: aurPkg.PackageBase, - Repository: "aur", - LocalVersion: pkg.Version(), - RemoteVersion: aurPkg.Version, - Reason: pkg.Reason(), - }) + upgrade.Extra = gotext.Get("Lua hook selected package with unchanged version") } } + + toUpgrade.Up = append(toUpgrade.Up, upgrade) } return toUpgrade diff --git a/pkg/upgrade/sources_test.go b/pkg/upgrade/sources_test.go index 184c795e..2f6c83a0 100644 --- a/pkg/upgrade/sources_test.go +++ b/pkg/upgrade/sources_test.go @@ -9,6 +9,7 @@ import ( "os" "strings" "testing" + "time" aur "github.com/Jguer/aur" "github.com/stretchr/testify/assert" @@ -27,6 +28,7 @@ func Test_upAUR(t *testing.T) { remote map[string]alpm.Package aurdata map[string]*aur.Pkg enableDowngrade bool + include IncludeAURUpdateFunc } tests := []struct { name string @@ -112,13 +114,33 @@ func Test_upAUR(t *testing.T) { }, want: UpSlice{Repos: []string{"aur"}, Up: []Upgrade{}}, }, + { + name: "Lua Include Unchanged Version By LastModified", + args: args{ + remote: map[string]alpm.Package{ + "hello": &mock.Package{PName: "hello", PVersion: "2.0.0", PBuildDate: time.Unix(100, 0)}, + }, + aurdata: map[string]*aur.Pkg{"hello": {Version: "2.0.0", Name: "hello", LastModified: 200, PackageBase: "hello"}}, + include: func(local alpm.Package, remote *aur.Pkg, defaultInclude bool) bool { + return defaultInclude || int64(remote.LastModified) > local.BuildDate().Unix() + }, + }, + want: UpSlice{Repos: []string{"aur"}, Up: []Upgrade{{ + Name: "hello", + Base: "hello", + Repository: "aur", + LocalVersion: "2.0.0", + RemoteVersion: "2.0.0", + Extra: "Lua hook selected package: AUR modified 1970-01-01T00:03:20Z after local build 1970-01-01T00:01:40Z", + }}}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := UpAUR(text.NewLogger(io.Discard, os.Stderr, strings.NewReader(""), false, "test"), - tt.args.remote, tt.args.aurdata, tt.args.enableDowngrade) + tt.args.remote, tt.args.aurdata, tt.args.enableDowngrade, tt.args.include) assert.ElementsMatch(t, tt.want.Repos, got.Repos) assert.ElementsMatch(t, tt.want.Up, got.Up) assert.Equal(t, tt.want.Len(), got.Len()) diff --git a/query_test.go b/query_test.go index 215c580a..4832cd00 100644 --- a/query_test.go +++ b/query_test.go @@ -288,7 +288,7 @@ func TestSyncSearchAURDB(t *testing.T) { run := &runtime.Runtime{ CmdBuilder: cmdBuilder, AURClient: mockAUR, - QueryBuilder: query.NewSourceQueryBuilder(mockAUR, newTestLogger(), "votes", parser.ModeAny, "name", + QueryBuilder: query.NewSourceQueryBuilder(mockAUR, nil, newTestLogger(), "votes", parser.ModeAny, "name", tc.bottomUp, tc.singleLine, tc.mixed), Logger: newTestLogger(), Cfg: &settings.Configuration{}, diff --git a/sync.go b/sync.go index b9d95845..b7720c77 100644 --- a/sync.go +++ b/sync.go @@ -15,6 +15,7 @@ import ( "github.com/Jguer/yay/v12/pkg/settings/exe" "github.com/Jguer/yay/v12/pkg/settings/parser" "github.com/Jguer/yay/v12/pkg/sync" + "github.com/Jguer/yay/v12/pkg/text" "github.com/Jguer/yay/v12/pkg/upgrade" ) @@ -46,16 +47,20 @@ func syncInstall(ctx context.Context, grapher := dep.NewGrapher(dbExecutor, aurCache, false, settings.NoConfirm, noDeps, noCheck, cmdArgs.ExistsArg("needed"), run.Logger.Child("grapher")) + normalTargets, externalInstallTargets := splitExternalTargets(run.Cfg, dbExecutor, cmdArgs.Targets) + cmdArgs.Targets = normalTargets + graph, err := grapher.GraphFromTargets(ctx, nil, cmdArgs.Targets) if err != nil { return err } excluded := []string{} + var upService *upgrade.UpgradeService if cmdArgs.ExistsArg("u", "sysupgrade") { var errSysUp error - upService := upgrade.NewUpgradeService( + upService = upgrade.NewUpgradeService( grapher, aurCache, dbExecutor, run.VCSStore, run.Cfg, settings.NoConfirm, run.Logger.Child("upgrade")) @@ -87,7 +92,24 @@ func syncInstall(ctx context.Context, return err } - return opService.Run(ctx, run, cmdArgs, targets, excluded) + shouldRunMainOps := len(targets) > 0 || (len(externalInstallTargets) == 0 && (upService == nil || !upService.HasSelectedExternalUpgrades())) + if shouldRunMainOps { + if err := opService.Run(ctx, run, cmdArgs, targets, excluded); err != nil { + return err + } + } + + if err := runExternalInstallTargets(ctx, run.Cfg, externalInstallTargets); err != nil { + return err + } + + if upService != nil { + if err := upService.RunExternalUpgrades(ctx); err != nil { + return err + } + } + + return nil } func earlyRefresh(ctx context.Context, cfg *settings.Configuration, cmdBuilder exe.ICmdBuilder, cmdArgs *parser.Arguments) error { @@ -103,3 +125,49 @@ func earlyRefresh(ctx context.Context, cfg *settings.Configuration, cmdBuilder e return cmdBuilder.Show(cmdBuilder.BuildPacmanCmd(ctx, arguments, cfg.Mode, settings.NoConfirm)) } + +func splitExternalTargets(cfg *settings.Configuration, dbExecutor db.Executor, targets []string) ([]string, map[string][]settings.ExternalInstallTarget) { + repoSet := make(map[string]struct{}, len(dbExecutor.Repos())) + for _, repo := range dbExecutor.Repos() { + repoSet[repo] = struct{}{} + } + + normalTargets := make([]string, 0, len(targets)) + externalTargets := make(map[string][]settings.ExternalInstallTarget) + for _, target := range targets { + repository, name := text.SplitDBFromName(target) + if repository == "" || repository == "aur" { + normalTargets = append(normalTargets, target) + continue + } + if _, ok := repoSet[repository]; ok { + normalTargets = append(normalTargets, target) + continue + } + if cfg == nil || !cfg.HasExternalProvider(repository) { + normalTargets = append(normalTargets, target) + continue + } + + externalTargets[repository] = append(externalTargets[repository], settings.ExternalInstallTarget{ + Name: name, + Repository: repository, + }) + } + + return normalTargets, externalTargets +} + +func runExternalInstallTargets(ctx context.Context, cfg *settings.Configuration, externalTargets map[string][]settings.ExternalInstallTarget) error { + for repository, targets := range externalTargets { + handled, err := cfg.InstallExternalPackages(ctx, repository, targets) + if err != nil { + return err + } + if !handled { + return fmt.Errorf("no external install provider configured for %s", repository) + } + } + + return nil +} diff --git a/sync_test.go b/sync_test.go index 0e2dd3ca..90e44a11 100644 --- a/sync_test.go +++ b/sync_test.go @@ -31,6 +31,49 @@ import ( "github.com/Jguer/yay/v12/pkg/vcs" ) +type mockExternalInstallLuaRunner struct { + install func(ctx context.Context, repository string, targets []settings.ExternalInstallTarget) (bool, error) + has func(name string) bool +} + +func (m *mockExternalInstallLuaRunner) CallOnPrompt(name, defaultAns string) (string, bool, error) { + return "", false, nil +} + +func (m *mockExternalInstallLuaRunner) CallShouldIncludeAURUpdate(candidate settings.AURUpdateContext) (bool, bool, error) { + return false, false, nil +} + +func (m *mockExternalInstallLuaRunner) CallListExternalUpgrades(ctx context.Context) ([]settings.ExternalUpgrade, error) { + return nil, nil +} + +func (m *mockExternalInstallLuaRunner) CallRunExternalUpgrades(ctx context.Context, repository string, upgrades []settings.ExternalUpgrade) (bool, error) { + return false, nil +} + +func (m *mockExternalInstallLuaRunner) CallSearchExternalPackages(ctx context.Context, terms []string) ([]settings.ExternalSearchResult, error) { + return nil, nil +} + +func (m *mockExternalInstallLuaRunner) CallInstallExternalPackages(ctx context.Context, repository string, targets []settings.ExternalInstallTarget) (bool, error) { + if m.install == nil { + return false, nil + } + + return m.install(ctx, repository, targets) +} + +func (m *mockExternalInstallLuaRunner) HasProvider(name string) bool { + if m.has == nil { + return false + } + + return m.has(name) +} + +func (m *mockExternalInstallLuaRunner) Close() {} + func TestSyncUpgrade(t *testing.T) { t.Parallel() makepkgBin := t.TempDir() + "/makepkg" @@ -143,6 +186,50 @@ func TestSyncUpgrade(t *testing.T) { } } +func TestSyncInstallExternalTargets(t *testing.T) { + t.Parallel() + + called := false + cfg := &settings.Configuration{RemoveMake: "no"} + cfg.SetLuaEngine(&mockExternalInstallLuaRunner{ + has: func(name string) bool { return name == "homebrew" }, + install: func(_ context.Context, repository string, targets []settings.ExternalInstallTarget) (bool, error) { + called = true + assert.Equal(t, "homebrew", repository) + assert.Equal(t, []settings.ExternalInstallTarget{{Name: "ripgrep", Repository: "homebrew"}}, targets) + return true, nil + }, + }) + + mockRunner := &exe.MockRunner{ + CaptureFn: func(cmd *exec.Cmd) (stdout string, stderr string, err error) { return "", "", nil }, + ShowFn: func(cmd *exec.Cmd) error { return nil }, + } + + run := &runtime.Runtime{ + Cfg: cfg, + Logger: text.NewLogger(io.Discard, os.Stderr, strings.NewReader(""), true, "test"), + CmdBuilder: exe.NewCmdBuilder(cfg, mockRunner, text.NewLogger(io.Discard, os.Stderr, strings.NewReader(""), true, "test"), "/var/lib/pacman"), + VCSStore: &vcs.Mock{}, + AURClient: &mockaur.MockAUR{GetFn: func(ctx context.Context, query *aur.Query) ([]aur.Pkg, error) { + return []aur.Pkg{}, nil + }}, + } + + cmdArgs := parser.MakeArguments() + cmdArgs.AddArg("S") + cmdArgs.AddTarget("homebrew/ripgrep") + + dbExc := &mock.DBExecutor{ + ReposFn: func() []string { return []string{"core"} }, + } + + require.NoError(t, syncInstall(context.Background(), run, cmdArgs, dbExc)) + assert.True(t, called) + assert.Empty(t, mockRunner.ShowCalls) + assert.Empty(t, mockRunner.CaptureCalls) +} + func TestSyncUpgrade_IgnoreAll(t *testing.T) { t.Parallel() makepkgBin := t.TempDir() + "/makepkg"