diff --git a/doc/examples/hide_first_submitted.lua b/doc/examples/hide_first_submitted.lua new file mode 100644 index 00000000..435ac16e --- /dev/null +++ b/doc/examples/hide_first_submitted.lua @@ -0,0 +1,16 @@ +yay.create_autocmd("SearchFilter", { + desc = "hide AUR packages submitted in the last 14 days", + callback = function(event) + yay.log.info("hiding AUR packages submitted in the last 14 days") + local out = {} + local cutoff = os.time() - (14 * 24 * 60 * 60) + for _, r in ipairs(event.data.results) do + if r.source == "aur" and r.first_submitted ~= -1 and r.first_submitted >= cutoff then + yay.log.debug("hiding newly submitted AUR package: ", r.name) + else + out[#out + 1] = { source = r.source, name = r.name } + end + end + return out + end, +}) diff --git a/doc/examples/install_log.lua b/doc/examples/install_log.lua new file mode 100644 index 00000000..50bc6186 --- /dev/null +++ b/doc/examples/install_log.lua @@ -0,0 +1,35 @@ +local log_path = (os.getenv("XDG_STATE_HOME") or (os.getenv("HOME") .. "/.local/state")) .. "/yay/install.log" +local log_dir = log_path:match("^(.+)/[^/]+$") + +yay.create_autocmd("PostInstall", { + desc = "append every installed/upgraded package to a persistent log", + callback = function(event) + yay.log.info("install_log: writing to ", log_path) + os.execute("mkdir -p " .. log_dir) + local f, err = io.open(log_path, "a") + if not f then + yay.log.warn("install_log: cannot open log file: ", err) + return + end + + local ts = os.date("%Y-%m-%dT%H:%M:%S") + + for _, pkg in ipairs(event.data.packages) do + if pkg.installed then + local action = pkg.upgrade and "upgrade" or "install" + local version_change + if pkg.upgrade then + version_change = pkg.local_version .. " -> " .. pkg.version + else + version_change = pkg.version + end + + local flags = pkg.devel and " devel" or "" + f:write(string.format("%s %-9s %-7s %-14s %-12s %s%s\n", + ts, action, pkg.source, pkg.reason, pkg.name, version_change, flags)) + end + end + + f:close() + end, +}) diff --git a/doc/init.lua b/doc/init.lua index e0a25680..f9b6391d 100644 --- a/doc/init.lua +++ b/doc/init.lua @@ -103,3 +103,33 @@ yay.opt.double_confirm = true -- Ask for confirmation before and after builds du -- end -- end, -- }) +-- +-- Run Lua once after a successful install/upgrade transaction (skipped on +-- --downloadonly). The callback is fire-and-forget; returning has no effect. +-- +-- yay.create_autocmd("PostInstall", { +-- desc = "log every package yay installed", +-- callback = function(event) +-- for _, pkg in ipairs(event.data.packages) do +-- if pkg.installed then +-- yay.log.info(pkg.name .. " " .. pkg.version .. " installed (" .. pkg.source .. ")") +-- end +-- end +-- end, +-- }) +-- +-- Run Lua during -Ss / -S number menu after ranking, before display. Return +-- an ordered array of {source=, name=} to filter/reorder; nil = unchanged. +-- +-- yay.create_autocmd("SearchFilter", { +-- desc = "show only AUR results", +-- callback = function(event) +-- local out = {} +-- for _, r in ipairs(event.data.results) do +-- if r.source == "aur" then +-- out[#out + 1] = { source = r.source, name = r.name } +-- end +-- end +-- return out +-- end, +-- }) diff --git a/doc/lua.md b/doc/lua.md index cc7c2cd5..c061936d 100644 --- a/doc/lua.md +++ b/doc/lua.md @@ -302,3 +302,117 @@ yay.create_autocmd("AURPostDownload", { end, }) ``` + +--- + +## Post-install hooks + +
Available from yay v13.0.0
+ +`PostInstall` fires once after a successful install/upgrade transaction, before +yay exits. It is skipped when `--downloadonly` (`-w`) is used. Because the +installation is already complete when the callback runs, calling `yay.abort` +logs the message but cannot roll back anything. + +### PostInstall event + +```lua +{ + event = "PostInstall", + data = { + packages = { + { + name = "pkgname", + version = "1.2.3-1", -- resolved version + local_version = "1.0.0-1", -- previously installed ("" if not installed) + source = "aur", -- "aur" | "sync" | "local" | "srcinfo" | "missing" + reason = "explicit", -- "explicit" | "dependency" | "make_dependency" | "check_dependency" | "unknown" + installed = true, -- false for AUR bases that failed but were tolerated + upgrade = false, -- true when replacing an older version + devel = false, -- true for VCS (-git/-svn/…) packages + }, + -- one entry per package yay resolved; sorted alphabetically + }, + }, +} +``` + +The `packages` array covers every node yay resolved into the transaction (all +sources, all topo layers). Transitive repo dependencies pulled in by pacman +but not explicitly tracked by yay are **not** included. The callback is +fire-and-forget: no return value is read. + +### Example + +```lua +yay.create_autocmd("PostInstall", { + desc = "log every package yay installed", + callback = function(event) + for _, pkg in ipairs(event.data.packages) do + if pkg.installed then + yay.log.info(pkg.name .. " " .. pkg.version .. " installed (" .. pkg.source .. ")") + end + end + end, +}) +``` + +--- + +## Search-filter hooks + +`SearchFilter` runs during `yay -Ss` and the `yay -S` number menu, after +results are ranked and sorted but before they are displayed. The callback +receives the full ordered result list and may return a filtered or reordered +subset. Returning `nil` (or nothing) leaves the list unchanged. + +Multiple `SearchFilter` hooks **chain**: each hook receives the output of the +previous hook. An unknown `(source, name)` pair in the return table is a hard +error; duplicate refs are deduplicated first-wins. Hook errors are logged and +the **unfiltered** results are shown rather than aborting the command. + +### SearchFilter event + +Available from yay v13.0.0
+ +```lua +{ + event = "SearchFilter", + data = { + results = { + { + source = "aur", -- "aur" or the pacman DB name (e.g. "core", "extra") + name = "pkgname", + description = "A useful package", + base = "pkgbase", + votes = 123, -- -1 for sync packages + popularity = 1.23, -- -1 for sync packages + first_submitted = 1700000000, -- -1 for sync packages + last_modified = 1700000001, -- -1 for sync packages + provides = { "virtual-pkg" }, + }, + -- … + }, + }, +} +``` + +The callback must return `nil` or an array of `{source=, name=}` tables. Every +`(source, name)` pair must exist in the input; unknown pairs are an error. + +### Example + +```lua +yay.create_autocmd("SearchFilter", { + desc = "show only AUR results", + callback = function(event) + local out = {} + for _, r in ipairs(event.data.results) do + if r.source == "aur" then + out[#out + 1] = { source = r.source, name = r.name } + end + end + return out + end, +}) +``` diff --git a/main.go b/main.go index 81c9c9d1..34e265a3 100644 --- a/main.go +++ b/main.go @@ -133,6 +133,7 @@ func main() { luaEngine.SetLogger(run.Logger.Child("lua")) } run.Lua = luaEngine + run.QueryBuilder.SetLua(luaEngine) dbExecutor, err := ialpm.NewExecutor(run.PacmanConf, run.Logger.Child("db")) if err != nil { diff --git a/pkg/query/query_builder.go b/pkg/query/query_builder.go index d7f049fc..f0041dca 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" + settingslua "github.com/Jguer/yay/v12/pkg/settings/lua" "github.com/Jguer/yay/v12/pkg/settings/parser" "github.com/Jguer/yay/v12/pkg/text" ) @@ -35,6 +36,7 @@ type Builder interface { Execute(ctx context.Context, dbExecutor db.Executor, pkgS []string) Results(dbExecutor db.Executor, verboseSearch SearchVerbosity) error GetTargets(include, exclude intrange.IntRanges, otherExclude mapset.Set[string]) ([]string, error) + SetLua(engine *settingslua.Engine) } type SortFunc func(pkgA, pkgB abstractResult) int @@ -51,6 +53,7 @@ type SourceQueryBuilder struct { aurClient aur.QueryClient logger *text.Logger + lua *settingslua.Engine } func NewSourceQueryBuilder( @@ -77,6 +80,10 @@ func NewSourceQueryBuilder( } } +func (s *SourceQueryBuilder) SetLua(engine *settingslua.Engine) { + s.lua = engine +} + type abstractResult struct { source string name string @@ -262,7 +269,7 @@ func (s *SourceQueryBuilder) Execute(ctx context.Context, dbExecutor db.Executor } sort.Sort(sortableResults) - s.results = sortableResults.results + s.results = s.applySearchFilter(sortableResults.results) if aurErr != nil { s.logger.Errorln(ErrAURSearch{inner: aurErr}) @@ -353,3 +360,46 @@ func matchesSearch(pkg *aur.Pkg, terms []string) bool { return true } + +func (s *SourceQueryBuilder) applySearchFilter(results []abstractResult) []abstractResult { + if s.lua == nil || !s.lua.HasAutocmd(settingslua.EventSearchFilter) { + return results + } + + pkgs := make([]settingslua.SearchResultPackage, len(results)) + for i := range results { + pkgs[i] = settingslua.SearchResultPackage{ + Source: results[i].source, + Name: results[i].name, + Description: results[i].description, + Base: results[i].packageBase, + Votes: results[i].votes, + Popularity: results[i].popularity, + FirstSubmitted: results[i].firstSubmitted, + LastModified: results[i].lastModified, + Provides: results[i].provides, + } + } + + refs, err := s.lua.RunSearchFilter(&settingslua.SearchFilterEvent{Results: pkgs}) + if err != nil { + s.logger.Errorln(err) + return results + } + + if refs == nil { + return results + } + + byRef := make(map[settingslua.SearchResultRef]abstractResult, len(results)) + for i := range results { + byRef[settingslua.SearchResultRef{Source: results[i].source, Name: results[i].name}] = results[i] + } + + filtered := make([]abstractResult, 0, len(refs)) + for _, ref := range refs { + filtered = append(filtered, byRef[ref]) + } + + return filtered +} diff --git a/pkg/query/query_builder_searchfilter_test.go b/pkg/query/query_builder_searchfilter_test.go new file mode 100644 index 00000000..b03a1c88 --- /dev/null +++ b/pkg/query/query_builder_searchfilter_test.go @@ -0,0 +1,80 @@ +//go:build !integration +// +build !integration + +package query + +import ( + "context" + "io" + "strings" + "testing" + + settingslua "github.com/Jguer/yay/v12/pkg/settings/lua" + "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" +) + +// TestSourceQueryBuilderSearchFilterHook verifies that a SearchFilter hook +// registered on the Lua engine is applied after ranking, filtering s.results +// to only the entries the callback returns. +func TestSourceQueryBuilderSearchFilterHook(t *testing.T) { + t.Parallel() + + mockDB, mockAUR := newYayQueryBuilderMocks() + + w := &strings.Builder{} + logger := text.NewLogger(w, io.Discard, strings.NewReader(""), false, "test") + + // Baseline: build without a Lua hook and record count. + baselineQB := NewSourceQueryBuilder(mockAUR, logger, "", parser.ModeAny, "", false, false, false) + baselineQB.Execute(context.Background(), mockDB, []string{"yay"}) + baselineCount := len(baselineQB.results) + require.Greater(t, baselineCount, 0, "baseline query must return at least one result") + + // Count how many AUR results exist in the baseline. + aurCount := 0 + for _, r := range baselineQB.results { + if r.source == "aur" { + aurCount++ + } + } + require.Greater(t, aurCount, 0, "baseline must have at least one AUR result") + + // The mock also returns a non-AUR (sync) result for "yay". + // Confirm there is at least one so the filter actually does something. + nonAURCount := baselineCount - aurCount + require.Greater(t, nonAURCount, 0, "baseline must have at least one non-AUR result for the filter to exercise") + + // Build with a SearchFilter that keeps only AUR results. + e := settingslua.New() + defer e.Close() + + require.NoError(t, e.L.DoString(` + yay.create_autocmd("SearchFilter", { + callback = function(event) + local out = {} + for _, r in ipairs(event.data.results) do + if r.source == "aur" then + out[#out + 1] = { source = r.source, name = r.name } + end + end + return out + end, + }) + `)) + + filteredQB := NewSourceQueryBuilder(mockAUR, logger, "", parser.ModeAny, "", false, false, false) + filteredQB.SetLua(e) + filteredQB.Execute(context.Background(), mockDB, []string{"yay"}) + + assert.Equal(t, aurCount, len(filteredQB.results), + "filtered results should equal the number of AUR packages in the baseline") + + for _, r := range filteredQB.results { + assert.Equal(t, "aur", r.source, + "every result after the SearchFilter hook must be from aur") + } +} diff --git a/pkg/settings/lua/autocmd.go b/pkg/settings/lua/autocmd.go index c56f5380..9f1b1461 100644 --- a/pkg/settings/lua/autocmd.go +++ b/pkg/settings/lua/autocmd.go @@ -11,6 +11,8 @@ const ( EventAURPreInstall = "AURPreInstall" EventAURPostDownload = "AURPostDownload" EventUpgradeSelect = "UpgradeSelect" + EventPostInstall = "PostInstall" + EventSearchFilter = "SearchFilter" ) type Autocmd struct { @@ -82,9 +84,46 @@ type UpgradeSelectResult struct { SkipMenu bool } +type PostInstallEvent struct { + Packages []PostInstallPackage +} + +type PostInstallPackage struct { + Name string + Version string + LocalVersion string + Source string + Reason string + Installed bool + Upgrade bool + Devel bool +} + +type SearchFilterEvent struct { + Results []SearchResultPackage +} + +type SearchResultPackage struct { + Source string + Name string + Description string + Base string + Votes int + Popularity float64 + FirstSubmitted int + LastModified int + Provides []string +} + +type SearchResultRef struct { + Source string + Name string +} + func (e *Engine) createAutocmd(state *glua.LState) int { event := state.CheckString(1) - if event != EventAURPreInstall && event != EventAURPostDownload && event != EventUpgradeSelect { + if event != EventAURPreInstall && event != EventAURPostDownload && + event != EventUpgradeSelect && event != EventPostInstall && event != EventSearchFilter { state.ArgError(1, fmt.Sprintf("unsupported event %q", event)) return 0 } @@ -121,35 +160,25 @@ func (e *Engine) HasAutocmd(event string) bool { } func (e *Engine) RunAURPreInstall(event *AURPreInstallEvent) error { - if !e.HasAutocmd(EventAURPreInstall) { - return nil - } - - for _, autocmd := range e.autocmds[EventAURPreInstall] { - if err := e.L.CallByParam(glua.P{ - Fn: autocmd.callback, - NRet: 0, - Protect: true, - }, e.aurEventTable(EventAURPreInstall, event)); err != nil { - return fmt.Errorf("%s %s: %w", EventAURPreInstall, event.Base, wrapLuaErr(err)) - } - } - - return nil + return e.runAUREvent(EventAURPreInstall, event) } func (e *Engine) RunAURPostDownload(event *AURPreInstallEvent) error { - if !e.HasAutocmd(EventAURPostDownload) { + return e.runAUREvent(EventAURPostDownload, event) +} + +func (e *Engine) runAUREvent(eventName string, event *AURPreInstallEvent) error { + if !e.HasAutocmd(eventName) { return nil } - for _, autocmd := range e.autocmds[EventAURPostDownload] { + for _, autocmd := range e.autocmds[eventName] { if err := e.L.CallByParam(glua.P{ Fn: autocmd.callback, NRet: 0, Protect: true, - }, e.aurEventTable(EventAURPostDownload, event)); err != nil { - return fmt.Errorf("%s %s: %w", EventAURPostDownload, event.Base, wrapLuaErr(err)) + }, e.aurEventTable(eventName, event)); err != nil { + return fmt.Errorf("%s %s: %w", eventName, event.Base, wrapLuaErr(err)) } } @@ -200,15 +229,19 @@ func (e *Engine) RunUpgradeSelect(event *UpgradeSelectEvent) (UpgradeSelectResul return result, nil } -func (e *Engine) aurEventTable(eventName string, event *AURPreInstallEvent) *glua.LTable { - state := e.L - eventTable := state.NewTable() - data := state.NewTable() - +func (e *Engine) newEventTable(eventName string) (eventTable, data *glua.LTable) { + eventTable = e.L.NewTable() + data = e.L.NewTable() eventTable.RawSetString("event", glua.LString(eventName)) - eventTable.RawSetString("match", glua.LString(event.Base)) eventTable.RawSetString("data", data) + return eventTable, data +} + +func (e *Engine) aurEventTable(eventName string, event *AURPreInstallEvent) *glua.LTable { + eventTable, data := e.newEventTable(eventName) + eventTable.RawSetString("match", glua.LString(event.Base)) + data.RawSetString("base", glua.LString(event.Base)) data.RawSetString("dir", glua.LString(event.Dir)) data.RawSetString("pkgbuild_path", glua.LString(event.PKGBUILDPath)) @@ -224,13 +257,7 @@ func (e *Engine) aurEventTable(eventName string, event *AURPreInstallEvent) *glu } func (e *Engine) upgradeSelectTable(event *UpgradeSelectEvent) *glua.LTable { - state := e.L - eventTable := state.NewTable() - data := state.NewTable() - - eventTable.RawSetString("event", glua.LString(EventUpgradeSelect)) - eventTable.RawSetString("data", data) - + eventTable, data := e.newEventTable(EventUpgradeSelect) data.RawSetString("upgrades", e.upgradeSelectPackagesTable(event.Upgrades)) data.RawSetString("pulled_dependencies", e.upgradeSelectPackagesTable(event.PulledDependencies)) @@ -363,3 +390,185 @@ func (e *Engine) stringArray(values []string) *glua.LTable { return tbl } + +func (e *Engine) RunPostInstall(event *PostInstallEvent) error { + if !e.HasAutocmd(EventPostInstall) { + return nil + } + + for _, autocmd := range e.autocmds[EventPostInstall] { + if err := e.L.CallByParam(glua.P{ + Fn: autocmd.callback, + NRet: 0, + Protect: true, + }, e.postInstallTable(event)); err != nil { + return fmt.Errorf("%s: %w", EventPostInstall, wrapLuaErr(err)) + } + } + + return nil +} + +func (e *Engine) RunSearchFilter(event *SearchFilterEvent) ([]SearchResultRef, error) { + if !e.HasAutocmd(EventSearchFilter) { + return nil, nil + } + + indexByRef := make(map[SearchResultRef]int, len(event.Results)) + for i, pkg := range event.Results { + indexByRef[SearchResultRef{Source: pkg.Source, Name: pkg.Name}] = i + } + + active := event.Results + + for _, autocmd := range e.autocmds[EventSearchFilter] { + if err := e.L.CallByParam(glua.P{ + Fn: autocmd.callback, + NRet: 1, + Protect: true, + }, e.searchFilterTable(active)); err != nil { + return nil, fmt.Errorf("%s: %w", EventSearchFilter, wrapLuaErr(err)) + } + + value := e.L.Get(-1) + e.L.Pop(1) + + refs, returned, err := parseSearchFilterResult(value, indexByRef) + if err != nil { + return nil, fmt.Errorf("%s: %w", EventSearchFilter, err) + } + + if !returned { + continue + } + + next := make([]SearchResultPackage, 0, len(refs)) + for _, ref := range refs { + next = append(next, event.Results[indexByRef[ref]]) + } + + active = next + } + + result := make([]SearchResultRef, len(active)) + for i, pkg := range active { + result[i] = SearchResultRef{Source: pkg.Source, Name: pkg.Name} + } + + return result, nil +} + +func (e *Engine) postInstallTable(event *PostInstallEvent) *glua.LTable { + eventTable, data := e.newEventTable(EventPostInstall) + data.RawSetString("packages", e.postInstallPackagesTable(event.Packages)) + + return eventTable +} + +func (e *Engine) postInstallPackagesTable(packages []PostInstallPackage) *glua.LTable { + tbl := e.L.NewTable() + + for i := range packages { + pkg := &packages[i] + pkgTbl := e.L.NewTable() + pkgTbl.RawSetString("name", glua.LString(pkg.Name)) + pkgTbl.RawSetString("version", glua.LString(pkg.Version)) + pkgTbl.RawSetString("local_version", glua.LString(pkg.LocalVersion)) + pkgTbl.RawSetString("source", glua.LString(pkg.Source)) + pkgTbl.RawSetString("reason", glua.LString(pkg.Reason)) + pkgTbl.RawSetString("installed", glua.LBool(pkg.Installed)) + pkgTbl.RawSetString("upgrade", glua.LBool(pkg.Upgrade)) + pkgTbl.RawSetString("devel", glua.LBool(pkg.Devel)) + tbl.Append(pkgTbl) + } + + return tbl +} + +func (e *Engine) searchFilterTable(packages []SearchResultPackage) *glua.LTable { + eventTable, data := e.newEventTable(EventSearchFilter) + data.RawSetString("results", e.searchResultPackagesTable(packages)) + + return eventTable +} + +func (e *Engine) searchResultPackagesTable(packages []SearchResultPackage) *glua.LTable { + tbl := e.L.NewTable() + + for i := range packages { + pkg := &packages[i] + pkgTbl := e.L.NewTable() + pkgTbl.RawSetString("source", glua.LString(pkg.Source)) + pkgTbl.RawSetString("name", glua.LString(pkg.Name)) + pkgTbl.RawSetString("description", glua.LString(pkg.Description)) + pkgTbl.RawSetString("base", glua.LString(pkg.Base)) + pkgTbl.RawSetString("votes", glua.LNumber(pkg.Votes)) + pkgTbl.RawSetString("popularity", glua.LNumber(pkg.Popularity)) + pkgTbl.RawSetString("first_submitted", glua.LNumber(pkg.FirstSubmitted)) + pkgTbl.RawSetString("last_modified", glua.LNumber(pkg.LastModified)) + pkgTbl.RawSetString("provides", e.stringArray(pkg.Provides)) + tbl.Append(pkgTbl) + } + + return tbl +} + +func parseSearchFilterResult(value glua.LValue, valid map[SearchResultRef]int) ([]SearchResultRef, bool, error) { + if value == glua.LNil { + return nil, false, nil + } + + tbl, ok := value.(*glua.LTable) + if !ok { + return nil, false, fmt.Errorf("callback must return nil or a table, got %s", value.Type()) + } + + var ( + refs []SearchResultRef + parseErr error + ) + + seen := mapset.NewThreadUnsafeSet[SearchResultRef]() + + tbl.ForEach(func(_ glua.LValue, val glua.LValue) { + if parseErr != nil { + return + } + + entry, ok := val.(*glua.LTable) + if !ok { + parseErr = fmt.Errorf("each result must be a table") + return + } + + source, ok := entry.RawGetString("source").(glua.LString) + if !ok { + parseErr = fmt.Errorf("result source must be a string") + return + } + + name, ok := entry.RawGetString("name").(glua.LString) + if !ok { + parseErr = fmt.Errorf("result name must be a string") + return + } + + ref := SearchResultRef{Source: string(source), Name: string(name)} + if _, exists := valid[ref]; !exists { + parseErr = fmt.Errorf("unknown search result %s/%s", ref.Source, ref.Name) + return + } + + if !seen.Add(ref) { + return + } + + refs = append(refs, ref) + }) + + if parseErr != nil { + return nil, false, parseErr + } + + return refs, true, nil +} diff --git a/pkg/settings/lua/autocmd_postinstall_searchfilter_test.go b/pkg/settings/lua/autocmd_postinstall_searchfilter_test.go new file mode 100644 index 00000000..2d891ff4 --- /dev/null +++ b/pkg/settings/lua/autocmd_postinstall_searchfilter_test.go @@ -0,0 +1,226 @@ +package lua + +import ( + "testing" + + "github.com/stretchr/testify/require" + glua "github.com/yuin/gopher-lua" +) + +func TestRunPostInstallEventTableShape(t *testing.T) { + t.Parallel() + + e := New() + defer e.Close() + + ran := false + e.L.SetGlobal("setRan", e.L.NewFunction(func(_ *glua.LState) int { + ran = true + return 0 + })) + + require.NoError(t, e.L.DoString(` + yay.create_autocmd("PostInstall", { + callback = function(event) + if event.event ~= "PostInstall" then error("bad event name") end + if event.data == nil then error("missing data") end + local pkg = event.data.packages[1] + if pkg == nil then error("missing package") end + if pkg.name ~= "mypkg" then error("bad name: " .. tostring(pkg.name)) end + if pkg.version ~= "1.2.3-1" then error("bad version") end + if pkg.local_version ~= "1.0.0-1" then error("bad local_version") end + if pkg.source ~= "aur" then error("bad source") end + if pkg.reason ~= "explicit" then error("bad reason") end + if pkg.installed ~= true then error("bad installed") end + if pkg.upgrade ~= false then error("bad upgrade") end + if pkg.devel ~= true then error("bad devel") end + setRan() + end, + }) + `)) + + err := e.RunPostInstall(&PostInstallEvent{ + Packages: []PostInstallPackage{ + { + Name: "mypkg", + Version: "1.2.3-1", + LocalVersion: "1.0.0-1", + Source: "aur", + Reason: "explicit", + Installed: true, + Upgrade: false, + Devel: true, + }, + }, + }) + require.NoError(t, err) + require.True(t, ran) +} + +func TestRunPostInstallReturnsAbortWithoutTraceback(t *testing.T) { + t.Parallel() + + e := New() + defer e.Close() + + require.NoError(t, e.L.DoString(` + yay.create_autocmd("PostInstall", { + callback = function() + yay.abort("blocked") + end, + }) + `)) + + err := e.RunPostInstall(&PostInstallEvent{ + Packages: []PostInstallPackage{{Name: "mypkg"}}, + }) + require.EqualError(t, err, "PostInstall: blocked") +} + +func TestRunSearchFilterEventTableShapeAndReturn(t *testing.T) { + t.Parallel() + + e := New() + defer e.Close() + + require.NoError(t, e.L.DoString(` + yay.create_autocmd("SearchFilter", { + callback = function(event) + if event.event ~= "SearchFilter" then error("bad event name") end + local r = event.data.results[1] + if r.source ~= "aur" then error("bad source") end + if r.name ~= "pkgA" then error("bad name") end + if r.description ~= "desc A" then error("bad description") end + if r.base ~= "pkgA" then error("bad base") end + if r.votes ~= 42 then error("bad votes") end + if math.abs(r.popularity - 3.14) > 0.001 then error("bad popularity") end + if r.first_submitted ~= 1000 then error("bad first_submitted") end + if r.last_modified ~= 2000 then error("bad last_modified") end + if r.provides[1] ~= "pkgA-compat" then error("bad provides") end + + -- Return reversed order, dropping pkgC + return { + { source = "sync", name = "pkgB" }, + { source = "aur", name = "pkgA" }, + } + end, + }) + `)) + + refs, err := e.RunSearchFilter(&SearchFilterEvent{ + Results: []SearchResultPackage{ + { + Source: "aur", + Name: "pkgA", + Description: "desc A", + Base: "pkgA", + Votes: 42, + Popularity: 3.14, + FirstSubmitted: 1000, + LastModified: 2000, + Provides: []string{"pkgA-compat"}, + }, + {Source: "sync", Name: "pkgB"}, + {Source: "aur", Name: "pkgC"}, + }, + }) + require.NoError(t, err) + require.Equal(t, []SearchResultRef{ + {Source: "sync", Name: "pkgB"}, + {Source: "aur", Name: "pkgA"}, + }, refs) +} + +func TestRunSearchFilterNilReturnMeansUnchanged(t *testing.T) { + t.Parallel() + + e := New() + defer e.Close() + + require.NoError(t, e.L.DoString(` + yay.create_autocmd("SearchFilter", { + callback = function(event) + -- return nothing + end, + }) + `)) + + refs, err := e.RunSearchFilter(&SearchFilterEvent{ + Results: []SearchResultPackage{ + {Source: "aur", Name: "pkgA"}, + {Source: "sync", Name: "pkgB"}, + }, + }) + require.NoError(t, err) + require.Equal(t, []SearchResultRef{ + {Source: "aur", Name: "pkgA"}, + {Source: "sync", Name: "pkgB"}, + }, refs) +} + +func TestRunSearchFilterRejectsUnknownResult(t *testing.T) { + t.Parallel() + + e := New() + defer e.Close() + + require.NoError(t, e.L.DoString(` + yay.create_autocmd("SearchFilter", { + callback = function() + return { { source = "x", name = "ghost" } } + end, + }) + `)) + + _, err := e.RunSearchFilter(&SearchFilterEvent{ + Results: []SearchResultPackage{ + {Source: "aur", Name: "pkgA"}, + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "unknown search result x/ghost") +} + +func TestRunSearchFilterChainsMultipleHooks(t *testing.T) { + t.Parallel() + + e := New() + defer e.Close() + + // First hook drops pkgC. + // Second hook sees only pkgA and pkgB, reorders them. + require.NoError(t, e.L.DoString(` + yay.create_autocmd("SearchFilter", { + callback = function(event) + -- drop pkgC + return { + { source = "aur", name = "pkgA" }, + { source = "sync", name = "pkgB" }, + } + end, + }) + yay.create_autocmd("SearchFilter", { + callback = function(event) + if #event.data.results ~= 2 then error("expected 2 results, got " .. #event.data.results) end + -- reorder: B then A + return { + { source = "sync", name = "pkgB" }, + { source = "aur", name = "pkgA" }, + } + end, + }) + `)) + + refs, err := e.RunSearchFilter(&SearchFilterEvent{ + Results: []SearchResultPackage{ + {Source: "aur", Name: "pkgA"}, + {Source: "sync", Name: "pkgB"}, + {Source: "aur", Name: "pkgC"}, + }, + }) + require.NoError(t, err) + require.Equal(t, []SearchResultRef{ + {Source: "sync", Name: "pkgB"}, + {Source: "aur", Name: "pkgA"}, + }, refs) +} diff --git a/pkg/sync/post_install.go b/pkg/sync/post_install.go new file mode 100644 index 00000000..a480dda6 --- /dev/null +++ b/pkg/sync/post_install.go @@ -0,0 +1,75 @@ +package sync + +import ( + "sort" + + "github.com/Jguer/yay/v12/pkg/dep" + settingslua "github.com/Jguer/yay/v12/pkg/settings/lua" +) + +// postInstallEvent flattens the resolved topo layers into the PostInstall +// payload. Packages recorded in failedAndIgnored (last-layer AUR build +// failures tolerated by the installer) are marked installed = false. +func postInstallEvent(targets []map[string]*dep.InstallInfo, failedAndIgnored map[string]error) *settingslua.PostInstallEvent { + merged := map[string]*dep.InstallInfo{} + for _, layer := range targets { + for name, info := range layer { + merged[name] = info + } + } + + names := make([]string, 0, len(merged)) + for name := range merged { + names = append(names, name) + } + + sort.Strings(names) + + packages := make([]settingslua.PostInstallPackage, 0, len(names)) + for _, name := range names { + info := merged[name] + _, failed := failedAndIgnored[name] + packages = append(packages, settingslua.PostInstallPackage{ + Name: name, + Version: info.Version, + LocalVersion: info.LocalVersion, + Source: luaSource(info.Source), + Reason: luaReason(info.Reason), + Installed: !failed, + Upgrade: info.Upgrade, + Devel: info.Devel, + }) + } + + return &settingslua.PostInstallEvent{Packages: packages} +} + +func luaSource(source dep.Source) string { + switch source { + case dep.AUR: + return "aur" + case dep.Sync: + return "sync" + case dep.Local: + return "local" + case dep.SrcInfo: + return "srcinfo" + default: + return "missing" + } +} + +func luaReason(reason dep.Reason) string { + switch reason { + case dep.Explicit: + return "explicit" + case dep.Dep: + return "dependency" + case dep.MakeDep: + return "make_dependency" + case dep.CheckDep: + return "check_dependency" + default: + return "unknown" + } +} diff --git a/pkg/sync/post_install_test.go b/pkg/sync/post_install_test.go new file mode 100644 index 00000000..24869896 --- /dev/null +++ b/pkg/sync/post_install_test.go @@ -0,0 +1,132 @@ +//go:build !integration +// +build !integration + +package sync + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Jguer/yay/v12/pkg/dep" + settingslua "github.com/Jguer/yay/v12/pkg/settings/lua" +) + +func TestPostInstallEvent(t *testing.T) { + t.Parallel() + + base := "aur-base" + targets := []map[string]*dep.InstallInfo{ + { + // Layer 0: two AUR packages, one of which will fail. + "pkgA": { + Source: dep.AUR, + Reason: dep.Explicit, + Version: "2.0-1", + LocalVersion: "1.0-1", + AURBase: &base, + Upgrade: true, + Devel: false, + }, + "pkgB": { + Source: dep.AUR, + Reason: dep.Dep, + Version: "1.1-1", + LocalVersion: "", + AURBase: &base, + Upgrade: false, + Devel: true, + }, + }, + { + // Layer 1: one Sync package and a duplicate of pkgA (rollup case). + "pkgC": { + Source: dep.Sync, + Reason: dep.MakeDep, + Version: "3.0-1", + Upgrade: false, + Devel: false, + }, + // pkgA appears in layer 1 too; layer merge last-wins → this version. + "pkgA": { + Source: dep.AUR, + Reason: dep.Explicit, + Version: "2.0-2", + LocalVersion: "1.0-1", + AURBase: &base, + Upgrade: true, + Devel: false, + }, + }, + } + + // pkgB failed to install. + failedAndIgnored := map[string]error{ + "pkgB": assert.AnError, + } + + event := postInstallEvent(targets, failedAndIgnored) + + // Must be sorted by name. + want := &settingslua.PostInstallEvent{ + Packages: []settingslua.PostInstallPackage{ + { + Name: "pkgA", + Version: "2.0-2", + LocalVersion: "1.0-1", + Source: "aur", + Reason: "explicit", + Installed: true, + Upgrade: true, + Devel: false, + }, + { + Name: "pkgB", + Version: "1.1-1", + LocalVersion: "", + Source: "aur", + Reason: "dependency", + Installed: false, // in failedAndIgnored + Upgrade: false, + Devel: true, + }, + { + Name: "pkgC", + Version: "3.0-1", + Source: "sync", + Reason: "make_dependency", + Installed: true, + Upgrade: false, + Devel: false, + }, + }, + } + + assert.Equal(t, want, event) +} + +func TestPostInstallEventSourceAndReasonMapping(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source dep.Source + reason dep.Reason + wantSource string + wantReason string + }{ + {"aur explicit", dep.AUR, dep.Explicit, "aur", "explicit"}, + {"sync dep", dep.Sync, dep.Dep, "sync", "dependency"}, + {"local makedep", dep.Local, dep.MakeDep, "local", "make_dependency"}, + {"srcinfo checkdep", dep.SrcInfo, dep.CheckDep, "srcinfo", "check_dependency"}, + {"missing unknown", dep.Missing, dep.Reason(99), "missing", "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.wantSource, luaSource(tt.source)) + assert.Equal(t, tt.wantReason, luaReason(tt.reason)) + }) + } +} diff --git a/pkg/sync/sync.go b/pkg/sync/sync.go index b03da10f..0af35817 100644 --- a/pkg/sync/sync.go +++ b/pkg/sync/sync.go @@ -10,6 +10,7 @@ import ( "github.com/Jguer/yay/v12/pkg/multierror" "github.com/Jguer/yay/v12/pkg/runtime" "github.com/Jguer/yay/v12/pkg/settings" + settingslua "github.com/Jguer/yay/v12/pkg/settings/lua" "github.com/Jguer/yay/v12/pkg/settings/parser" "github.com/Jguer/yay/v12/pkg/sync/build" "github.com/Jguer/yay/v12/pkg/sync/srcinfo" @@ -127,6 +128,13 @@ func (o *OperationService) Run(ctx context.Context, run *runtime.Runtime, multiErr.Add(err) } + if !cmdArgs.ExistsArg("w", "downloadonly") && run.Lua != nil && + run.Lua.HasAutocmd(settingslua.EventPostInstall) { + if err := run.Lua.RunPostInstall(postInstallEvent(targets, failedAndIgnored)); err != nil { + multiErr.Add(err) + } + } + return multiErr.Return() }