diff --git a/internal/installerconf/installerconf.go b/internal/installerconf/installerconf.go index 30c6a3f..fc575f4 100644 --- a/internal/installerconf/installerconf.go +++ b/internal/installerconf/installerconf.go @@ -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"] -} diff --git a/internal/installerconf/installerconf_test.go b/internal/installerconf/installerconf_test.go index 13b8289..8e48fb8 100644 --- a/internal/installerconf/installerconf_test.go +++ b/internal/installerconf/installerconf_test.go @@ -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) } }