diff --git a/.golangci.yml b/.golangci.yml index 894dc8a8..4d736703 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -66,6 +66,7 @@ linters: - gochecknoinits - gocritic - godot + - gosec - govet - lll - revive diff --git a/go.mod b/go.mod index 09fef992..0ae2c516 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 0d31d055..29b51fae 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index 39b63d22..d0708f4f 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/pkg/settings/config.go b/pkg/settings/config.go index fbbba7fb..a3395983 100644 --- a/pkg/settings/config.go +++ b/pkg/settings/config.go @@ -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 } diff --git a/pkg/settings/dirs.go b/pkg/settings/dirs.go index ceb52fb0..ac590b55 100644 --- a/pkg/settings/dirs.go +++ b/pkg/settings/dirs.go @@ -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() diff --git a/pkg/settings/ini.go b/pkg/settings/ini.go new file mode 100644 index 00000000..6860ea91 --- /dev/null +++ b/pkg/settings/ini.go @@ -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) +} diff --git a/pkg/settings/ini_test.go b/pkg/settings/ini_test.go new file mode 100644 index 00000000..31a3a8df --- /dev/null +++ b/pkg/settings/ini_test.go @@ -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) + }) +} diff --git a/pkg/settings/migrations.go b/pkg/settings/migrations.go deleted file mode 100644 index bd6e8a99..00000000 --- a/pkg/settings/migrations.go +++ /dev/null @@ -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 -} diff --git a/pkg/settings/migrations_test.go b/pkg/settings/migrations_test.go deleted file mode 100644 index 4662baa6..00000000 --- a/pkg/settings/migrations_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/pkg/settings/yay.conf b/pkg/settings/yay.conf new file mode 100644 index 00000000..b70e17f1 --- /dev/null +++ b/pkg/settings/yay.conf @@ -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 diff --git a/yay.conf b/yay.conf new file mode 120000 index 00000000..4264894d --- /dev/null +++ b/yay.conf @@ -0,0 +1 @@ +pkg/settings/yay.conf \ No newline at end of file