ref(installerconf): use typed struct instead of string map

Conf is now a plain struct with typed fields (Source, Owner, Repo,
TagPrefix, VersionPrefix, Exclude, BaseURL) instead of a generic
map[string]string with accessor methods. Unrecognized keys go into
an Extra map for forward compatibility.

Config stays flat key=value — covers the common patterns (simple
github, version prefix stripping, monorepo tag prefix, filename
exclusions). Complex cases belong in Go code, not config.
This commit is contained in:
AJ ONeal
2026-03-10 10:42:37 -06:00
parent 090fb9e242
commit 8cdc00b2d8
2 changed files with 204 additions and 87 deletions

View File

@@ -3,9 +3,46 @@
// The format is simple key=value, one per line. Blank lines and lines
// starting with # are ignored. Keys and values are trimmed of whitespace.
//
// Minimal example (covers ~60% of packages):
//
// source = github
// owner = sharkdp
// repo = bat
//
// With version prefix stripping (jq tags are "jq-1.7.1"):
//
// source = github
// owner = jqlang
// repo = jq
// version_prefix = jq-
//
// With filename exclusions (hugo publishes _extended_ variants):
//
// source = github
// owner = gohugoio
// repo = hugo
// exclude = _extended_, Linux-64bit
//
// Monorepo with tag prefix:
//
// source = github
// owner = therootcompany
// repo = golib
// tag_prefix = tools/monorel/
//
// Non-GitHub sources:
//
// source = nodedist
// url = https://nodejs.org/download/release
//
// source = gitea
// base_url = https://gitea.com
// owner = xorm
// repo = xorm
//
// Complex packages that need custom logic beyond what the classifier
// auto-detects (e.g. ollama's universal binaries, ffmpeg's non-standard
// naming) should put that logic in Go code, not in the config.
package installerconf
import (
@@ -15,9 +52,38 @@ import (
"strings"
)
// Conf holds the parsed contents of a releases.conf file.
// Conf holds the parsed per-package release configuration.
type Conf struct {
m map[string]string
// Source is the fetch source type: "github", "gitea", "gitlab",
// "gittag", "nodedist", etc.
Source string
// Owner is the repository owner (org or user).
Owner string
// Repo is the repository name.
Repo string
// BaseURL is a custom base URL for non-GitHub sources
// (e.g. a Gitea instance or nodedist index URL).
BaseURL string
// TagPrefix filters releases in monorepos. Only tags starting with
// this prefix are included, and the prefix is stripped from the
// version string. Example: "tools/monorel/"
TagPrefix string
// VersionPrefix is stripped from version strings.
// Example: jq tags as "jq-1.7.1" → set to "jq-" to get "1.7.1".
VersionPrefix string
// Exclude lists filename substrings to filter out.
// Assets whose name contains any of these are skipped.
// Example: ["_extended_", "-gogit-", "-docs-"]
Exclude []string
// Extra holds any unrecognized keys for forward compatibility.
Extra map[string]string
}
// Read parses a releases.conf file.
@@ -28,7 +94,7 @@ func Read(path string) (*Conf, error) {
}
defer f.Close()
c := &Conf{m: make(map[string]string)}
raw := make(map[string]string)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
@@ -39,20 +105,49 @@ func Read(path string) (*Conf, error) {
if !ok {
continue
}
c.m[strings.TrimSpace(key)] = strings.TrimSpace(val)
raw[strings.TrimSpace(key)] = strings.TrimSpace(val)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("installerconf: read %s: %w", path, err)
}
c := &Conf{}
c.Source = raw["source"]
c.Owner = raw["owner"]
c.Repo = raw["repo"]
c.TagPrefix = raw["tag_prefix"]
c.VersionPrefix = raw["version_prefix"]
if v := raw["base_url"]; v != "" {
c.BaseURL = v
} else {
c.BaseURL = raw["url"]
}
if v := raw["exclude"]; v != "" {
for _, p := range strings.Split(v, ",") {
p = strings.TrimSpace(p)
if p != "" {
c.Exclude = append(c.Exclude, p)
}
}
}
// Collect unrecognized keys.
known := map[string]bool{
"source": true, "owner": true, "repo": true,
"base_url": true, "url": true,
"tag_prefix": true, "version_prefix": true,
"exclude": true,
}
for k, v := range raw {
if !known[k] {
if c.Extra == nil {
c.Extra = make(map[string]string)
}
c.Extra[k] = v
}
}
return c, nil
}
// Get returns the value for a key, or "" if not set.
func (c *Conf) Get(key string) string {
return c.m[key]
}
// Source returns the fetch source type (e.g. "github", "nodedist", "gitea").
func (c *Conf) Source() string {
return c.m["source"]
}

View File

@@ -8,106 +8,128 @@ import (
"github.com/webinstall/webi-installers/internal/installerconf"
)
func TestRead(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "releases.conf")
os.WriteFile(path, []byte(`
# Hugo release config
func TestSimpleGitHub(t *testing.T) {
c := confFromString(t, `
source = github
owner = sharkdp
repo = bat
`)
assertEqual(t, "Source", c.Source, "github")
assertEqual(t, "Owner", c.Owner, "sharkdp")
assertEqual(t, "Repo", c.Repo, "bat")
assertEqual(t, "TagPrefix", c.TagPrefix, "")
assertEqual(t, "VersionPrefix", c.VersionPrefix, "")
if len(c.Exclude) != 0 {
t.Errorf("Exclude = %v, want empty", c.Exclude)
}
}
func TestVersionPrefix(t *testing.T) {
c := confFromString(t, `
source = github
owner = jqlang
repo = jq
version_prefix = jq-
`)
assertEqual(t, "VersionPrefix", c.VersionPrefix, "jq-")
}
func TestExclude(t *testing.T) {
c := confFromString(t, `
source = github
owner = gohugoio
repo = hugo
`), 0o644)
c, err := installerconf.Read(path)
if err != nil {
t.Fatal(err)
}
if c.Source() != "github" {
t.Errorf("Source() = %q, want github", c.Source())
}
if c.Get("owner") != "gohugoio" {
t.Errorf("owner = %q, want gohugoio", c.Get("owner"))
}
if c.Get("repo") != "hugo" {
t.Errorf("repo = %q, want hugo", c.Get("repo"))
exclude = _extended_, Linux-64bit
`)
if len(c.Exclude) != 2 {
t.Fatalf("Exclude has %d items, want 2: %v", len(c.Exclude), c.Exclude)
}
assertEqual(t, "Exclude[0]", c.Exclude[0], "_extended_")
assertEqual(t, "Exclude[1]", c.Exclude[1], "Linux-64bit")
}
func TestReadMonorepo(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "releases.conf")
os.WriteFile(path, []byte(`source = github
func TestMonorepoTagPrefix(t *testing.T) {
c := confFromString(t, `
source = github
owner = therootcompany
repo = golib
tag_prefix = tools/monorel/
`), 0o644)
c, err := installerconf.Read(path)
if err != nil {
t.Fatal(err)
}
if c.Get("tag_prefix") != "tools/monorel/" {
t.Errorf("tag_prefix = %q", c.Get("tag_prefix"))
}
`)
assertEqual(t, "TagPrefix", c.TagPrefix, "tools/monorel/")
}
func TestReadNodeDist(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "releases.conf")
os.WriteFile(path, []byte(`source = nodedist
func TestNodeDist(t *testing.T) {
c := confFromString(t, `
source = nodedist
url = https://nodejs.org/download/release
`), 0o644)
c, err := installerconf.Read(path)
if err != nil {
t.Fatal(err)
}
if c.Source() != "nodedist" {
t.Errorf("Source() = %q, want nodedist", c.Source())
}
if c.Get("url") != "https://nodejs.org/download/release" {
t.Errorf("url = %q", c.Get("url"))
}
`)
assertEqual(t, "Source", c.Source, "nodedist")
assertEqual(t, "BaseURL", c.BaseURL, "https://nodejs.org/download/release")
}
func TestReadSkipsBlanksAndComments(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "releases.conf")
os.WriteFile(path, []byte(`
# comment
func TestGiteaBaseURL(t *testing.T) {
c := confFromString(t, `
source = gitea
base_url = https://gitea.com
owner = xorm
repo = xorm
`)
assertEqual(t, "Source", c.Source, "gitea")
assertEqual(t, "BaseURL", c.BaseURL, "https://gitea.com")
assertEqual(t, "Owner", c.Owner, "xorm")
}
func TestBlanksAndComments(t *testing.T) {
c := confFromString(t, `
# Hugo config
source = github
# another comment
# owner line
owner = foo
`), 0o644)
`)
assertEqual(t, "Source", c.Source, "github")
assertEqual(t, "Owner", c.Owner, "foo")
}
c, err := installerconf.Read(path)
if err != nil {
t.Fatal(err)
}
if c.Source() != "github" {
t.Errorf("Source() = %q", c.Source())
}
if c.Get("owner") != "foo" {
t.Errorf("owner = %q", c.Get("owner"))
func TestExtraKeys(t *testing.T) {
c := confFromString(t, `
source = github
owner = foo
repo = bar
custom_thing = hello
`)
if c.Extra == nil || c.Extra["custom_thing"] != "hello" {
t.Errorf("Extra[custom_thing] = %q, want hello", c.Extra["custom_thing"])
}
}
func TestGetMissing(t *testing.T) {
func TestEmptyExclude(t *testing.T) {
c := confFromString(t, "source = github\n")
if c.Exclude != nil {
t.Errorf("Exclude = %v, want nil", c.Exclude)
}
}
// helpers
func confFromString(t *testing.T, content string) *installerconf.Conf {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "releases.conf")
os.WriteFile(path, []byte("source = github\n"), 0o644)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
c, err := installerconf.Read(path)
if err != nil {
t.Fatal(err)
}
return c
}
if c.Get("nonexistent") != "" {
t.Errorf("Get(nonexistent) = %q, want empty", c.Get("nonexistent"))
func assertEqual(t *testing.T, name, got, want string) {
t.Helper()
if got != want {
t.Errorf("%s = %q, want %q", name, got, want)
}
}