1
0
mirror of https://github.com/Jguer/yay.git synced 2026-02-17 13:52:14 +01:00

feat(ini): support ini config (#2774)

* support ini config

* support user ini config

* Update pkg/settings/ini.go

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jo 2026-01-30 16:07:24 +01:00 committed by GitHub
parent 3d7701fa35
commit 3e3d5aa111
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 644 additions and 500 deletions

View File

@ -66,6 +66,7 @@ linters:
- gochecknoinits
- gocritic
- godot
- gosec
- govet
- lll
- revive

3
go.mod
View File

@ -16,6 +16,7 @@ require (
golang.org/x/sys v0.40.0
golang.org/x/term v0.39.0
gopkg.in/h2non/gock.v1 v1.1.2
gopkg.in/ini.v1 v1.67.1
)
require (
@ -26,7 +27,7 @@ require (
github.com/itchyny/gojq v0.12.18 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/ohler55/ojg v1.27.0 // indirect
github.com/ohler55/ojg v1.28.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

7
go.sum
View File

@ -1,7 +1,5 @@
github.com/Jguer/aur v1.3.0 h1:skdjp/P9kB75TBaJmn9PKK/kCeA9QsgjdUrORZ3gldU=
github.com/Jguer/aur v1.3.0/go.mod h1:F8Awo+WKzTxlXtNOO4pDQjMkePLZ+oMSbu+1fKLTTLo=
github.com/Jguer/dyalpm v0.1.0 h1:cGajPBZvjZmCG1B1hJmFdNwLoezrIqfiOaAOURM+Kc4=
github.com/Jguer/dyalpm v0.1.0/go.mod h1:eUPJQ/zSclJKTxOPihpspulI+S8WQNsxHJoIBiBgogw=
github.com/Jguer/dyalpm v0.1.1 h1:38JkmJuHIGXVZedXIDGz/nhVcn8DtMk4zM+GRGipC7w=
github.com/Jguer/dyalpm v0.1.1/go.mod h1:eUPJQ/zSclJKTxOPihpspulI+S8WQNsxHJoIBiBgogw=
github.com/Jguer/votar v1.0.0 h1:drPYpV5Py5BeAQS8xezmT6uCEfLzotNjLf5yfmlHKTg=
@ -42,11 +40,14 @@ github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/ohler55/ojg v1.27.0 h1:1JzdkMpDc/X9bzRaN1+8AFLnrSiFy96yDSaeACCGD5U=
github.com/ohler55/ojg v1.27.0/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o=
github.com/ohler55/ojg v1.28.0 h1:8xClBgMIRRJGDUC9xNe7NprP4kD2C3mQMeon3wY4KXA=
github.com/ohler55/ojg v1.28.0/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
@ -67,6 +68,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -80,11 +80,6 @@ func main() {
return
}
if errS := cfg.RunMigrations(fallbackLog,
settings.DefaultMigrations(), configPath, yayVersion); errS != nil {
fallbackLog.Errorln(errS)
}
cmdArgs := parser.MakeArguments()
// Parse command line

View File

@ -2,6 +2,7 @@ package settings
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"os"
@ -13,8 +14,12 @@ import (
"github.com/Jguer/yay/v12/pkg/text"
"github.com/leonelquinteros/gotext"
"gopkg.in/ini.v1"
)
//go:embed yay.conf
var defaultsINI []byte
// HideMenus indicates if pacman's provider menus must be hidden.
var HideMenus = false
@ -23,91 +28,70 @@ var NoConfirm = false
// Configuration stores yay's config.
type Configuration struct {
AURURL string `json:"aururl"`
AURRPCURL string `json:"aurrpcurl"`
BuildDir string `json:"buildDir"`
Editor string `json:"editor"`
EditorFlags string `json:"editorflags"`
MakepkgBin string `json:"makepkgbin"`
MakepkgConf string `json:"makepkgconf"`
PacmanBin string `json:"pacmanbin"`
PacmanConf string `json:"pacmanconf"`
ReDownload string `json:"redownload"`
AnswerClean string `json:"answerclean"`
AnswerDiff string `json:"answerdiff"`
AnswerEdit string `json:"answeredit"`
AnswerUpgrade string `json:"answerupgrade"`
GitBin string `json:"gitbin"`
GpgBin string `json:"gpgbin"`
GpgFlags string `json:"gpgflags"`
MFlags string `json:"mflags"`
SortBy string `json:"sortby"`
SearchBy string `json:"searchby"`
GitFlags string `json:"gitflags"`
RemoveMake string `json:"removemake"`
SudoBin string `json:"sudobin"`
SudoFlags string `json:"sudoflags"`
Version string `json:"version"`
RequestSplitN int `json:"requestsplitn"`
CompletionInterval int `json:"completionrefreshtime"`
MaxConcurrentDownloads int `json:"maxconcurrentdownloads"`
BottomUp bool `json:"bottomup"`
SudoLoop bool `json:"sudoloop"`
TimeUpdate bool `json:"timeupdate"`
Devel bool `json:"devel"`
CleanAfter bool `json:"cleanAfter"`
KeepSrc bool `json:"keepSrc"`
Provides bool `json:"provides"`
PGPFetch bool `json:"pgpfetch"`
CleanMenu bool `json:"cleanmenu"`
DiffMenu bool `json:"diffmenu"`
EditMenu bool `json:"editmenu"`
CombinedUpgrade bool `json:"combinedupgrade"`
UseAsk bool `json:"useask"`
BatchInstall bool `json:"batchinstall"`
SingleLineResults bool `json:"singlelineresults"`
SeparateSources bool `json:"separatesources"`
Debug bool `json:"debug"`
UseRPC bool `json:"rpc"`
DoubleConfirm bool `json:"doubleconfirm"` // confirm install before and after build
AURURL string `json:"aururl" ini:"AurUrl"`
AURRPCURL string `json:"aurrpcurl" ini:"AurRpcUrl"`
BuildDir string `json:"buildDir" ini:"BuildDir"`
Editor string `json:"editor" ini:"Editor"`
EditorFlags string `json:"editorflags" ini:"EditorFlags"`
MakepkgBin string `json:"makepkgbin" ini:"MakepkgBin"`
MakepkgConf string `json:"makepkgconf" ini:"MakepkgConf"`
PacmanBin string `json:"pacmanbin" ini:"PacmanBin"`
PacmanConf string `json:"pacmanconf" ini:"PacmanConf"`
ReDownload string `json:"redownload" ini:"ReDownload"`
AnswerClean string `json:"answerclean" ini:"AnswerClean"`
AnswerDiff string `json:"answerdiff" ini:"AnswerDiff"`
AnswerEdit string `json:"answeredit" ini:"AnswerEdit"`
AnswerUpgrade string `json:"answerupgrade" ini:"AnswerUpgrade"`
GitBin string `json:"gitbin" ini:"GitBin"`
GpgBin string `json:"gpgbin" ini:"GpgBin"`
GpgFlags string `json:"gpgflags" ini:"GpgFlags"`
MFlags string `json:"mflags" ini:"MFlags"`
SortBy string `json:"sortby" ini:"SortBy"`
SearchBy string `json:"searchby" ini:"SearchBy"`
GitFlags string `json:"gitflags" ini:"GitFlags"`
RemoveMake string `json:"removemake" ini:"RemoveMake"`
SudoBin string `json:"sudobin" ini:"SudoBin"`
SudoFlags string `json:"sudoflags" ini:"SudoFlags"`
Version string `json:"version" ini:"-"`
RequestSplitN int `json:"requestsplitn" ini:"RequestSplitN"`
CompletionInterval int `json:"completionrefreshtime" ini:"CompletionInterval"`
MaxConcurrentDownloads int `json:"maxconcurrentdownloads" ini:"MaxConcurrentDownloads"`
BottomUp bool `json:"bottomup" ini:"BottomUp"`
SudoLoop bool `json:"sudoloop" ini:"SudoLoop"`
TimeUpdate bool `json:"timeupdate" ini:"TimeUpdate"`
Devel bool `json:"devel" ini:"Devel"`
CleanAfter bool `json:"cleanAfter" ini:"CleanAfter"`
KeepSrc bool `json:"keepSrc" ini:"KeepSrc"`
Provides bool `json:"provides" ini:"Provides"`
PGPFetch bool `json:"pgpfetch" ini:"PgpFetch"`
CleanMenu bool `json:"cleanmenu" ini:"CleanMenu"`
DiffMenu bool `json:"diffmenu" ini:"DiffMenu"`
EditMenu bool `json:"editmenu" ini:"EditMenu"`
CombinedUpgrade bool `json:"combinedupgrade" ini:"CombinedUpgrade"`
UseAsk bool `json:"useask" ini:"UseAsk"`
BatchInstall bool `json:"batchinstall" ini:"BatchInstall"`
SingleLineResults bool `json:"singlelineresults" ini:"SingleLineResults"`
SeparateSources bool `json:"separatesources" ini:"SeparateSources"`
Debug bool `json:"debug" ini:"Debug"`
UseRPC bool `json:"rpc" ini:"Rpc"`
DoubleConfirm bool `json:"doubleconfirm" ini:"DoubleConfirm"` // confirm install before and after build
CompletionPath string `json:"-"`
VCSFilePath string `json:"-"`
// ConfigPath string `json:"-"`
SaveConfig bool `json:"-"`
Mode parser.TargetMode `json:"-"`
ReBuild parser.RebuildMode `json:"rebuild"`
CompletionPath string `json:"-" ini:"-"`
VCSFilePath string `json:"-" ini:"-"`
SaveConfig bool `json:"-" ini:"-"`
Mode parser.TargetMode `json:"-" ini:"-"`
ReBuild parser.RebuildMode `json:"rebuild" ini:"ReBuild"`
}
// SaveConfig writes yay config to file.
// Save writes yay config to INI file.
func (c *Configuration) Save(configPath, version string) error {
c.Version = version
marshalledinfo, err := json.MarshalIndent(c, "", "\t")
if err != nil {
return err
// Use INI config path instead of JSON
iniPath := GetINIConfigPath()
if iniPath == "" {
return fmt.Errorf("unable to determine config path")
}
// https://github.com/Jguer/yay/issues/1325
marshalledinfo = append(marshalledinfo, '\n')
// https://github.com/Jguer/yay/issues/1399
if _, err = os.Stat(filepath.Dir(configPath)); os.IsNotExist(err) && err != nil {
if mkErr := os.MkdirAll(filepath.Dir(configPath), 0o755); mkErr != nil {
return mkErr
}
}
in, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer in.Close()
if _, err = in.Write(marshalledinfo); err != nil {
return err
}
return in.Sync()
return c.SaveINI(iniPath)
}
func (c *Configuration) expandEnv() {
@ -190,55 +174,34 @@ func (c *Configuration) setPrivilegeElevator() error {
}
func DefaultConfig(version string) *Configuration {
return &Configuration{
AURURL: "https://aur.archlinux.org",
BuildDir: os.ExpandEnv("$HOME/.cache/yay"),
CleanAfter: false,
KeepSrc: false,
Editor: "",
EditorFlags: "",
Devel: false,
MakepkgBin: "makepkg",
MakepkgConf: "",
PacmanBin: "pacman",
PGPFetch: true,
PacmanConf: "/etc/pacman.conf",
GpgFlags: "",
MFlags: "",
GitFlags: "",
BottomUp: true,
CompletionInterval: 7,
MaxConcurrentDownloads: 1,
SortBy: "",
SearchBy: "name-desc",
SudoLoop: false,
GitBin: "git",
GpgBin: "gpg",
SudoBin: "sudo",
SudoFlags: "",
TimeUpdate: false,
RequestSplitN: 150,
ReDownload: "no",
ReBuild: "no",
BatchInstall: false,
AnswerClean: "",
AnswerDiff: "",
AnswerEdit: "",
AnswerUpgrade: "",
RemoveMake: "ask",
Provides: true,
CleanMenu: true,
DiffMenu: true,
EditMenu: false,
UseAsk: false,
CombinedUpgrade: true,
SeparateSources: true,
Version: version,
Debug: false,
UseRPC: true,
DoubleConfirm: true,
Mode: parser.ModeAny,
cfg := &Configuration{
Version: version,
Mode: parser.ModeAny,
}
// Load defaults from embedded INI
iniCfg, err := ini.LoadSources(ini.LoadOptions{
AllowBooleanKeys: true,
Insensitive: true,
InsensitiveSections: true,
IgnoreInlineComment: true,
}, defaultsINI)
if err != nil {
// Fallback to minimal defaults if embedded config fails
cfg.AURURL = "https://aur.archlinux.org"
cfg.BuildDir = os.ExpandEnv("$HOME/.cache/yay")
return cfg
}
// Map the default section
_ = iniCfg.Section("").MapTo(cfg)
// Also map [options] section if present
if iniCfg.HasSection("options") {
_ = iniCfg.Section("options").MapTo(cfg)
}
return cfg
}
func NewConfig(logger *text.Logger, configPath, version string) (*Configuration, error) {
@ -252,8 +215,23 @@ func NewConfig(logger *text.Logger, configPath, version string) (*Configuration,
newConfig.BuildDir = cacheHome
newConfig.CompletionPath = filepath.Join(cacheHome, completionFileName)
newConfig.VCSFilePath = filepath.Join(cacheHome, vcsFileName)
// Load system-wide INI config first (silently ignored if not present)
if err := newConfig.loadINI(SystemConfigPath); err != nil && logger != nil {
logger.Errorln(err)
}
// Load user JSON config (legacy, overrides system config)
newConfig.load(configPath)
// Load user INI config (takes priority over JSON when both exist)
userINIPath := GetINIConfigPath()
if userINIPath != "" {
if err := newConfig.loadINI(userINIPath); err != nil && logger != nil {
logger.Errorln(err)
}
}
if aurdest := os.Getenv("AURDEST"); aurdest != "" {
newConfig.BuildDir = aurdest
}

View File

@ -7,6 +7,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.
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
@ -30,6 +31,26 @@ func GetConfigPath() string {
return ""
}
// GetINIConfigPath returns the path to the user's INI config file (yay.conf).
// This is used for both loading (with priority over JSON) and saving.
func GetINIConfigPath() string {
if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
configDir := filepath.Join(configHome, "yay")
if err := initDir(configDir); err == nil {
return filepath.Join(configDir, iniConfigFileName)
}
}
if configHome := os.Getenv("HOME"); configHome != "" {
configDir := filepath.Join(configHome, ".config", "yay")
if err := initDir(configDir); err == nil {
return filepath.Join(configDir, iniConfigFileName)
}
}
return ""
}
func getCacheHome() (string, error) {
uid := os.Geteuid()

73
pkg/settings/ini.go Normal file
View File

@ -0,0 +1,73 @@
package settings
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/ini.v1"
)
// SystemConfigPath is the path to the system-wide INI configuration file.
const SystemConfigPath = "/etc/yay.conf"
// loadINI parses an INI configuration file and applies values to the Configuration.
// It silently returns nil if the file doesn't exist.
// Uses struct tags for mapping (e.g., `ini:"AurUrl"`).
func (c *Configuration) loadINI(path string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil
}
cfg, err := ini.LoadSources(ini.LoadOptions{
AllowBooleanKeys: true,
Insensitive: true,
InsensitiveSections: true,
IgnoreInlineComment: true,
}, path)
if err != nil {
return fmt.Errorf("failed to load INI config file '%s': %w", path, err)
}
// Map the default section to the config struct
if err := cfg.Section("").MapTo(c); err != nil {
return fmt.Errorf("failed to map INI config '%s': %w", path, err)
}
// Also map [options] section if present (for compatibility)
if cfg.HasSection("options") {
if err := cfg.Section("options").MapTo(c); err != nil {
return fmt.Errorf("failed to map INI [options] section '%s': %w", path, err)
}
}
return nil
}
// SaveINI writes the configuration to an INI file at the specified path.
func (c *Configuration) SaveINI(path string) error {
cfg := ini.Empty(ini.LoadOptions{
AllowBooleanKeys: true,
})
// Use [options] section for compatibility with system config
section, err := cfg.NewSection("options")
if err != nil {
return fmt.Errorf("failed to create INI section: %w", err)
}
if err := section.ReflectFrom(c); err != nil {
return fmt.Errorf("failed to reflect config to INI: %w", err)
}
// Ensure parent directory exists
if dir := filepath.Dir(path); dir != "" {
if _, err := os.Stat(dir); os.IsNotExist(err) {
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
return fmt.Errorf("failed to create config directory: %w", mkErr)
}
}
}
return cfg.SaveTo(path)
}

362
pkg/settings/ini_test.go Normal file
View File

@ -0,0 +1,362 @@
package settings
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfigurationLoadINI(t *testing.T) {
t.Parallel()
t.Run("load nonexistent file returns nil", func(t *testing.T) {
t.Parallel()
cfg := DefaultConfig("test")
err := cfg.loadINI("/nonexistent/path/yay.conf")
assert.NoError(t, err)
})
t.Run("load valid INI file", func(t *testing.T) {
t.Parallel()
content := `# System-wide yay configuration
[options]
AurUrl = https://custom.aur.org
BuildDir = /var/cache/yay
Editor = vim
Devel = true
SudoLoop = yes
RequestSplitN = 200
BottomUp = false
; This is also a comment
CleanAfter = 1
`
tmpDir := t.TempDir()
iniPath := filepath.Join(tmpDir, "yay.conf")
require.NoError(t, os.WriteFile(iniPath, []byte(content), 0o644))
cfg := DefaultConfig("test")
err := cfg.loadINI(iniPath)
require.NoError(t, err)
assert.Equal(t, "https://custom.aur.org", cfg.AURURL)
assert.Equal(t, "/var/cache/yay", cfg.BuildDir)
assert.Equal(t, "vim", cfg.Editor)
assert.True(t, cfg.Devel)
assert.True(t, cfg.SudoLoop)
assert.Equal(t, 200, cfg.RequestSplitN)
assert.False(t, cfg.BottomUp)
assert.True(t, cfg.CleanAfter)
})
t.Run("load INI file with all option types", func(t *testing.T) {
t.Parallel()
content := `
# String options
AurUrl = https://aur.example.com
AurRpcUrl = https://aur.example.com/rpc
BuildDir = /tmp/build
Editor = nvim
EditorFlags = -p
MakepkgBin = /usr/bin/makepkg
MakepkgConf = /etc/makepkg.conf
PacmanBin = /usr/bin/pacman
PacmanConf = /etc/pacman.conf
GitBin = /usr/bin/git
GpgBin = /usr/bin/gpg
GpgFlags = --keyserver-options
MFlags = -s
SortBy = votes
SearchBy = name
GitFlags = --depth=1
RemoveMake = yes
SudoBin = doas
SudoFlags = -n
ReDownload = all
AnswerClean = All
AnswerDiff = None
AnswerEdit = None
AnswerUpgrade = None
ReBuild = all
# Integer options
RequestSplitN = 100
CompletionInterval = 3
MaxConcurrentDownloads = 4
# Boolean options
BottomUp = true
SudoLoop = false
TimeUpdate = yes
Devel = no
CleanAfter = true
KeepSrc = false
Provides = true
PgpFetch = false
CleanMenu = yes
DiffMenu = no
EditMenu = true
CombinedUpgrade = false
UseAsk = true
BatchInstall = false
SingleLineResults = true
SeparateSources = false
Debug = no
Rpc = yes
DoubleConfirm = false
`
tmpDir := t.TempDir()
iniPath := filepath.Join(tmpDir, "yay.conf")
require.NoError(t, os.WriteFile(iniPath, []byte(content), 0o644))
cfg := DefaultConfig("test")
err := cfg.loadINI(iniPath)
require.NoError(t, err)
// String options
assert.Equal(t, "https://aur.example.com", cfg.AURURL)
assert.Equal(t, "https://aur.example.com/rpc", cfg.AURRPCURL)
assert.Equal(t, "/tmp/build", cfg.BuildDir)
assert.Equal(t, "nvim", cfg.Editor)
assert.Equal(t, "-p", cfg.EditorFlags)
assert.Equal(t, "/usr/bin/makepkg", cfg.MakepkgBin)
assert.Equal(t, "/etc/makepkg.conf", cfg.MakepkgConf)
assert.Equal(t, "/usr/bin/pacman", cfg.PacmanBin)
assert.Equal(t, "/etc/pacman.conf", cfg.PacmanConf)
assert.Equal(t, "/usr/bin/git", cfg.GitBin)
assert.Equal(t, "/usr/bin/gpg", cfg.GpgBin)
assert.Equal(t, "--keyserver-options", cfg.GpgFlags)
assert.Equal(t, "-s", cfg.MFlags)
assert.Equal(t, "votes", cfg.SortBy)
assert.Equal(t, "name", cfg.SearchBy)
assert.Equal(t, "--depth=1", cfg.GitFlags)
assert.Equal(t, "yes", cfg.RemoveMake)
assert.Equal(t, "doas", cfg.SudoBin)
assert.Equal(t, "-n", cfg.SudoFlags)
assert.Equal(t, "all", cfg.ReDownload)
assert.Equal(t, "All", cfg.AnswerClean)
assert.Equal(t, "None", cfg.AnswerDiff)
assert.Equal(t, "None", cfg.AnswerEdit)
assert.Equal(t, "None", cfg.AnswerUpgrade)
assert.Equal(t, "all", string(cfg.ReBuild))
// Integer options
assert.Equal(t, 100, cfg.RequestSplitN)
assert.Equal(t, 3, cfg.CompletionInterval)
assert.Equal(t, 4, cfg.MaxConcurrentDownloads)
// Boolean options
assert.True(t, cfg.BottomUp)
assert.False(t, cfg.SudoLoop)
assert.True(t, cfg.TimeUpdate)
assert.False(t, cfg.Devel)
assert.True(t, cfg.CleanAfter)
assert.False(t, cfg.KeepSrc)
assert.True(t, cfg.Provides)
assert.False(t, cfg.PGPFetch)
assert.True(t, cfg.CleanMenu)
assert.False(t, cfg.DiffMenu)
assert.True(t, cfg.EditMenu)
assert.False(t, cfg.CombinedUpgrade)
assert.True(t, cfg.UseAsk)
assert.False(t, cfg.BatchInstall)
assert.True(t, cfg.SingleLineResults)
assert.False(t, cfg.SeparateSources)
assert.False(t, cfg.Debug)
assert.True(t, cfg.UseRPC)
assert.False(t, cfg.DoubleConfirm)
})
t.Run("load INI without section header", func(t *testing.T) {
t.Parallel()
content := `# Config without section header
AurUrl = https://custom.aur.org
Devel = true
RequestSplitN = 250
`
tmpDir := t.TempDir()
iniPath := filepath.Join(tmpDir, "yay.conf")
require.NoError(t, os.WriteFile(iniPath, []byte(content), 0o644))
cfg := DefaultConfig("test")
err := cfg.loadINI(iniPath)
require.NoError(t, err)
assert.Equal(t, "https://custom.aur.org", cfg.AURURL)
assert.True(t, cfg.Devel)
assert.Equal(t, 250, cfg.RequestSplitN)
})
t.Run("load INI with boolean keys (no value)", func(t *testing.T) {
t.Parallel()
content := `# Boolean keys without values are treated as true
Devel
SudoLoop
CleanAfter
`
tmpDir := t.TempDir()
iniPath := filepath.Join(tmpDir, "yay.conf")
require.NoError(t, os.WriteFile(iniPath, []byte(content), 0o644))
cfg := DefaultConfig("test")
cfg.Devel = false
cfg.SudoLoop = false
cfg.CleanAfter = false
err := cfg.loadINI(iniPath)
require.NoError(t, err)
assert.True(t, cfg.Devel)
assert.True(t, cfg.SudoLoop)
assert.True(t, cfg.CleanAfter)
})
t.Run("unknown options are ignored", func(t *testing.T) {
t.Parallel()
content := `AurUrl = https://custom.aur.org
unknownoption = somevalue
anotherunknown = 123
`
tmpDir := t.TempDir()
iniPath := filepath.Join(tmpDir, "yay.conf")
require.NoError(t, os.WriteFile(iniPath, []byte(content), 0o644))
cfg := DefaultConfig("test")
err := cfg.loadINI(iniPath)
require.NoError(t, err)
assert.Equal(t, "https://custom.aur.org", cfg.AURURL)
})
}
func TestSystemConfigPath(t *testing.T) {
t.Parallel()
assert.Equal(t, "/etc/yay.conf", SystemConfigPath)
}
func TestConfigurationSaveINI(t *testing.T) {
t.Parallel()
t.Run("save and reload config", func(t *testing.T) {
t.Parallel()
cfg := DefaultConfig("test")
cfg.AURURL = "https://custom.aur.org"
cfg.BuildDir = "/custom/build"
cfg.Editor = "nvim"
cfg.Devel = true
cfg.SudoLoop = true
cfg.RequestSplitN = 150
cfg.BottomUp = true
cfg.CleanAfter = false
tmpDir := t.TempDir()
iniPath := filepath.Join(tmpDir, "yay.conf")
err := cfg.SaveINI(iniPath)
require.NoError(t, err)
// Verify file was created
_, err = os.Stat(iniPath)
require.NoError(t, err)
// Load into new config and verify values
cfg2 := DefaultConfig("test")
err = cfg2.loadINI(iniPath)
require.NoError(t, err)
assert.Equal(t, "https://custom.aur.org", cfg2.AURURL)
assert.Equal(t, "/custom/build", cfg2.BuildDir)
assert.Equal(t, "nvim", cfg2.Editor)
assert.True(t, cfg2.Devel)
assert.True(t, cfg2.SudoLoop)
assert.Equal(t, 150, cfg2.RequestSplitN)
assert.True(t, cfg2.BottomUp)
assert.False(t, cfg2.CleanAfter)
})
t.Run("save creates directory if not exists", func(t *testing.T) {
t.Parallel()
cfg := DefaultConfig("test")
cfg.AURURL = "https://test.aur.org"
tmpDir := t.TempDir()
nestedPath := filepath.Join(tmpDir, "nested", "dir", "yay.conf")
err := cfg.SaveINI(nestedPath)
require.NoError(t, err)
// Verify file was created
_, err = os.Stat(nestedPath)
require.NoError(t, err)
})
t.Run("roundtrip preserves all field types", func(t *testing.T) {
t.Parallel()
cfg := DefaultConfig("test")
// String fields
cfg.AURURL = "https://roundtrip.aur.org"
cfg.AURRPCURL = "https://roundtrip.aur.org/rpc"
cfg.BuildDir = "/roundtrip/build"
cfg.Editor = "emacs"
cfg.EditorFlags = "-nw"
cfg.SudoBin = "doas"
cfg.SudoFlags = "-n"
cfg.ReDownload = "all"
cfg.ReBuild = "tree"
// Integer fields
cfg.RequestSplitN = 75
cfg.CompletionInterval = 5
cfg.MaxConcurrentDownloads = 8
// Boolean fields
cfg.BottomUp = true
cfg.SudoLoop = false
cfg.Devel = true
cfg.CleanAfter = false
cfg.UseRPC = true
cfg.BatchInstall = true
tmpDir := t.TempDir()
iniPath := filepath.Join(tmpDir, "yay.conf")
err := cfg.SaveINI(iniPath)
require.NoError(t, err)
cfg2 := DefaultConfig("test")
err = cfg2.loadINI(iniPath)
require.NoError(t, err)
// Verify all fields
assert.Equal(t, cfg.AURURL, cfg2.AURURL)
assert.Equal(t, cfg.AURRPCURL, cfg2.AURRPCURL)
assert.Equal(t, cfg.BuildDir, cfg2.BuildDir)
assert.Equal(t, cfg.Editor, cfg2.Editor)
assert.Equal(t, cfg.EditorFlags, cfg2.EditorFlags)
assert.Equal(t, cfg.SudoBin, cfg2.SudoBin)
assert.Equal(t, cfg.SudoFlags, cfg2.SudoFlags)
assert.Equal(t, cfg.ReDownload, cfg2.ReDownload)
assert.Equal(t, cfg.ReBuild, cfg2.ReBuild)
assert.Equal(t, cfg.RequestSplitN, cfg2.RequestSplitN)
assert.Equal(t, cfg.CompletionInterval, cfg2.CompletionInterval)
assert.Equal(t, cfg.MaxConcurrentDownloads, cfg2.MaxConcurrentDownloads)
assert.Equal(t, cfg.BottomUp, cfg2.BottomUp)
assert.Equal(t, cfg.SudoLoop, cfg2.SudoLoop)
assert.Equal(t, cfg.Devel, cfg2.Devel)
assert.Equal(t, cfg.CleanAfter, cfg2.CleanAfter)
assert.Equal(t, cfg.UseRPC, cfg2.UseRPC)
assert.Equal(t, cfg.BatchInstall, cfg2.BatchInstall)
})
}

View File

@ -1,90 +0,0 @@
package settings
import (
"fmt"
"github.com/Jguer/yay/v12/pkg/db"
"github.com/Jguer/yay/v12/pkg/text"
"github.com/leonelquinteros/gotext"
)
type configMigration interface {
// Description of what the migration does
fmt.Stringer
// return true if migration was done
Do(config *Configuration) bool
// Target version of the migration (e.g. "11.2.1")
// Should match the version of yay releasing this migration
TargetVersion() string
}
type configProviderMigration struct{}
func (migration *configProviderMigration) String() string {
return gotext.Get("Disable 'provides' setting by default")
}
func (migration *configProviderMigration) Do(config *Configuration) bool {
if config.Provides {
config.Provides = false
return true
}
return false
}
func (migration *configProviderMigration) TargetVersion() string {
return "11.2.1"
}
type configSortByMigration struct{}
func (migration *configSortByMigration) String() string {
return gotext.Get("Reset 'sortby' setting to default")
}
func (migration *configSortByMigration) Do(config *Configuration) bool {
if config.SortBy != "" {
config.SortBy = ""
return true
}
return false
}
func (migration *configSortByMigration) TargetVersion() string {
return "13.0.0"
}
func DefaultMigrations() []configMigration {
return []configMigration{
&configProviderMigration{},
&configSortByMigration{},
}
}
func (c *Configuration) RunMigrations(logger *text.Logger, migrations []configMigration,
configPath, newVersion string,
) error {
saveConfig := false
for _, migration := range migrations {
if db.VerCmp(migration.TargetVersion(), c.Version) > 0 {
if migration.Do(c) {
logger.Infoln("Config migration executed (",
migration.TargetVersion(), "):", migration)
saveConfig = true
}
}
}
if saveConfig {
return c.Save(configPath, newVersion)
}
return nil
}

View File

@ -1,275 +0,0 @@
//go:build !integration
// +build !integration
package settings
import (
"encoding/json"
"io"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Jguer/yay/v12/pkg/text"
)
func newTestLogger() *text.Logger {
return text.NewLogger(io.Discard, io.Discard, strings.NewReader(""), true, "test")
}
func TestMigrationNothingToDo(t *testing.T) {
t.Parallel()
// Create temporary file for config
configFile, err := os.CreateTemp("/tmp", "yay-*-config.json")
require.NoError(t, err)
testFilePath := configFile.Name()
defer os.Remove(testFilePath)
// Create config with configVersion
config := Configuration{
Version: "99.0.0",
// Create runtime with runtimeVersion
}
// Run Migration
err = config.RunMigrations(newTestLogger(), DefaultMigrations(), testFilePath, "20.0.0")
require.NoError(t, err)
// Check file contents if wantSave otherwise check file empty
cfile, err := os.Open(testFilePath)
require.NoError(t, err)
defer cfile.Close()
decoder := json.NewDecoder(cfile)
newConfig := Configuration{}
err = decoder.Decode(&newConfig)
require.Error(t, err)
assert.Empty(t, newConfig.Version)
}
func TestProvidesMigrationDo(t *testing.T) {
migration := &configProviderMigration{}
config := Configuration{
Provides: true,
}
assert.True(t, migration.Do(&config))
falseConfig := Configuration{Provides: false}
assert.False(t, migration.Do(&falseConfig))
}
func TestProvidesMigration(t *testing.T) {
t.Parallel()
type testCase struct {
desc string
testConfig *Configuration
newVersion string
wantSave bool
}
testCases := []testCase{
{
desc: "to upgrade",
testConfig: &Configuration{
Version: "11.0.1",
Provides: true,
},
newVersion: "11.2.1",
wantSave: true,
},
{
desc: "to upgrade-git",
testConfig: &Configuration{
Version: "11.2.0.r7.g6f60892",
Provides: true,
},
newVersion: "11.2.1",
wantSave: true,
},
{
desc: "to not upgrade",
testConfig: &Configuration{
Version: "11.2.0",
Provides: false,
},
newVersion: "11.2.1",
wantSave: false,
},
{
desc: "to not upgrade - target version",
testConfig: &Configuration{
Version: "11.2.1",
Provides: true,
},
newVersion: "11.2.1",
wantSave: false,
},
{
desc: "to not upgrade - new version",
testConfig: &Configuration{
Version: "11.3.0",
Provides: true,
},
newVersion: "11.3.0",
wantSave: false,
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
// Create temporary file for config
configFile, err := os.CreateTemp("/tmp", "yay-*-config.json")
require.NoError(t, err)
testFilePath := configFile.Name()
defer os.Remove(testFilePath)
// Create config with configVersion and provides
tcConfig := Configuration{
Version: tc.testConfig.Version,
Provides: tc.testConfig.Provides,
// Create runtime with runtimeVersion
}
// Run Migration
err = tcConfig.RunMigrations(newTestLogger(),
[]configMigration{&configProviderMigration{}},
testFilePath, tc.newVersion)
require.NoError(t, err)
// Check file contents if wantSave otherwise check file empty
cfile, err := os.Open(testFilePath)
require.NoError(t, err)
defer cfile.Close()
decoder := json.NewDecoder(cfile)
newConfig := Configuration{}
err = decoder.Decode(&newConfig)
if tc.wantSave {
require.NoError(t, err)
assert.Equal(t, tc.newVersion, newConfig.Version)
assert.Equal(t, false, newConfig.Provides)
} else {
require.Error(t, err)
assert.Empty(t, newConfig.Version)
}
})
}
}
func TestSortByMigrationDo(t *testing.T) {
migration := &configSortByMigration{}
config := Configuration{
SortBy: "name",
}
assert.True(t, migration.Do(&config))
falseConfig := Configuration{SortBy: ""}
assert.False(t, migration.Do(&falseConfig))
}
func TestSortByMigration(t *testing.T) {
t.Parallel()
type testCase struct {
desc string
testConfig *Configuration
newVersion string
wantSave bool
}
testCases := []testCase{
{
desc: "to upgrade",
testConfig: &Configuration{
Version: "12.9.0",
SortBy: "name",
},
newVersion: "13.0.0",
wantSave: true,
},
{
desc: "to upgrade-git",
testConfig: &Configuration{
Version: "12.9.0.r7.g6f60892",
SortBy: "votes",
},
newVersion: "13.0.0",
wantSave: true,
},
{
desc: "to not upgrade",
testConfig: &Configuration{
Version: "12.9.0",
SortBy: "",
},
newVersion: "13.0.0",
wantSave: false,
},
{
desc: "to not upgrade - target version",
testConfig: &Configuration{
Version: "13.0.0",
SortBy: "name",
},
newVersion: "13.0.0",
wantSave: false,
},
{
desc: "to not upgrade - new version",
testConfig: &Configuration{
Version: "13.1.0",
SortBy: "name",
},
newVersion: "13.1.0",
wantSave: false,
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
// Create temporary file for config
configFile, err := os.CreateTemp("/tmp", "yay-*-config.json")
require.NoError(t, err)
testFilePath := configFile.Name()
defer os.Remove(testFilePath)
// Create config with configVersion and sortby
tcConfig := Configuration{
Version: tc.testConfig.Version,
SortBy: tc.testConfig.SortBy,
// Create runtime with runtimeVersion
}
// Run Migration
err = tcConfig.RunMigrations(newTestLogger(),
[]configMigration{&configSortByMigration{}},
testFilePath, tc.newVersion)
require.NoError(t, err)
// Check file contents if wantSave otherwise check file empty
cfile, err := os.Open(testFilePath)
require.NoError(t, err)
defer cfile.Close()
decoder := json.NewDecoder(cfile)
newConfig := Configuration{}
err = decoder.Decode(&newConfig)
if tc.wantSave {
require.NoError(t, err)
assert.Equal(t, tc.newVersion, newConfig.Version)
assert.Equal(t, "", newConfig.SortBy)
} else {
require.Error(t, err)
assert.Empty(t, newConfig.Version)
}
})
}
}

74
pkg/settings/yay.conf Normal file
View File

@ -0,0 +1,74 @@
# /etc/yay.conf - System-wide yay configuration
[options]
# AUR Settings
AurUrl = https://aur.archlinux.org
#AurRpcUrl = https://aur.archlinux.org/rpc
# Directories
BuildDir = ~/.cache/yay
# Binaries
#Editor = vim
#EditorFlags =
MakepkgBin = makepkg
#MakepkgConf =
PacmanBin = pacman
PacmanConf = /etc/pacman.conf
GitBin = git
GpgBin = gpg
SudoBin = sudo
# Flags
# GpgFlags =
# MFlags =
# GitFlags =
# SudoFlags =
# Search/Display
BottomUp
# SingleLineResults
SeparateSources
# Sorting
#SortBy =
SearchBy = name-desc
# Build options
#Devel
#CleanAfter
#KeepSrc
#BatchInstall
RemoveMake = ask
# Download options
ReDownload = no
ReBuild = no
PgpFetch
# Menus
CleanMenu
DiffMenu
# EditMenu
# Prompts
#AnswerClean =
#AnswerDiff =
#AnswerEdit =
#AnswerUpgrade =
# Behavior
CombinedUpgrade
#SudoLoop
#TimeUpdate
Provides
#UseAsk
DoubleConfirm
Rpc
#Debug
# Limits
RequestSplitN = 150
CompletionInterval = 7
MaxConcurrentDownloads = 1

1
yay.conf Symbolic link
View File

@ -0,0 +1 @@
pkg/settings/yay.conf