From ddc32f45d0300c461423b489bc1bf2d8321df302 Mon Sep 17 00:00:00 2001 From: Julien Bisconti Date: Fri, 27 Feb 2026 23:19:31 +0100 Subject: [PATCH] feat: add cache package for exclude list and health cache YAML read/write Co-Authored-By: Claude Opus 4.6 --- go.mod | 1 + go.sum | 2 + internal/cache/cache.go | 95 +++++++++++++++++++++++++++++++ internal/cache/cache_test.go | 105 +++++++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/cache_test.go diff --git a/go.mod b/go.mod index 7bed71c..5e4eb4f 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,5 @@ require github.com/spf13/cobra v1.10.2 require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0..ff4d6ec 100644 --- a/go.sum +++ b/go.sum @@ -8,3 +8,5 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..a19089d --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,95 @@ +package cache + +import ( + "os" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// ExcludeList holds URL prefixes to skip during checking. +type ExcludeList struct { + Domains []string `yaml:"domains"` +} + +// IsExcluded returns true if the URL starts with any excluded prefix. +func (e *ExcludeList) IsExcluded(url string) bool { + for _, d := range e.Domains { + if strings.HasPrefix(url, d) { + return true + } + } + return false +} + +// LoadExcludeList reads an exclude.yaml file. +func LoadExcludeList(path string) (*ExcludeList, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var excl ExcludeList + if err := yaml.Unmarshal(data, &excl); err != nil { + return nil, err + } + return &excl, nil +} + +// HealthEntry stores metadata about a single entry. +type HealthEntry struct { + URL string `yaml:"url"` + Name string `yaml:"name"` + Status string `yaml:"status"` // healthy, inactive, stale, archived, dead + Stars int `yaml:"stars,omitempty"` + Forks int `yaml:"forks,omitempty"` + LastPush time.Time `yaml:"last_push,omitempty"` + HasLicense bool `yaml:"has_license,omitempty"` + HasReadme bool `yaml:"has_readme,omitempty"` + CheckedAt time.Time `yaml:"checked_at"` +} + +// HealthCache is the full YAML cache file. +type HealthCache struct { + Entries []HealthEntry `yaml:"entries"` +} + +// LoadHealthCache reads a health_cache.yaml file. Returns empty cache if file doesn't exist. +func LoadHealthCache(path string) (*HealthCache, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &HealthCache{}, nil + } + return nil, err + } + var hc HealthCache + if err := yaml.Unmarshal(data, &hc); err != nil { + return nil, err + } + return &hc, nil +} + +// SaveHealthCache writes the cache to a YAML file. +func SaveHealthCache(path string, hc *HealthCache) error { + data, err := yaml.Marshal(hc) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// Merge updates the cache with new entries, replacing existing ones by URL. +func (hc *HealthCache) Merge(entries []HealthEntry) { + index := make(map[string]int) + for i, e := range hc.Entries { + index[e.URL] = i + } + for _, e := range entries { + if i, exists := index[e.URL]; exists { + hc.Entries[i] = e + } else { + hc.Entries = append(hc.Entries, e) + } + } +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..03ec514 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,105 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadExcludeList(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "exclude.yaml") + content := `domains: + - https://example.com + - https://test.org +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + excl, err := LoadExcludeList(path) + if err != nil { + t.Fatal(err) + } + if len(excl.Domains) != 2 { + t.Errorf("domains count = %d, want 2", len(excl.Domains)) + } + if !excl.IsExcluded("https://example.com/foo") { + t.Error("expected https://example.com/foo to be excluded") + } + if excl.IsExcluded("https://other.com") { + t.Error("expected https://other.com to NOT be excluded") + } +} + +func TestHealthCacheRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "health.yaml") + + original := &HealthCache{ + Entries: []HealthEntry{ + { + URL: "https://github.com/example/repo", + Name: "Example", + Status: "healthy", + Stars: 42, + LastPush: time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC), + HasLicense: true, + HasReadme: true, + CheckedAt: time.Date(2026, 2, 27, 9, 0, 0, 0, time.UTC), + }, + }, + } + + if err := SaveHealthCache(path, original); err != nil { + t.Fatal(err) + } + + loaded, err := LoadHealthCache(path) + if err != nil { + t.Fatal(err) + } + if len(loaded.Entries) != 1 { + t.Fatalf("entries = %d, want 1", len(loaded.Entries)) + } + if loaded.Entries[0].Stars != 42 { + t.Errorf("stars = %d, want 42", loaded.Entries[0].Stars) + } +} + +func TestLoadHealthCacheMissing(t *testing.T) { + hc, err := LoadHealthCache("/nonexistent/path.yaml") + if err != nil { + t.Fatal(err) + } + if len(hc.Entries) != 0 { + t.Errorf("entries = %d, want 0 for missing file", len(hc.Entries)) + } +} + +func TestMerge(t *testing.T) { + hc := &HealthCache{ + Entries: []HealthEntry{ + {URL: "https://github.com/a/a", Name: "A", Stars: 10}, + {URL: "https://github.com/b/b", Name: "B", Stars: 20}, + }, + } + + hc.Merge([]HealthEntry{ + {URL: "https://github.com/b/b", Name: "B", Stars: 25}, // update + {URL: "https://github.com/c/c", Name: "C", Stars: 30}, // new + }) + + if len(hc.Entries) != 3 { + t.Fatalf("entries = %d, want 3", len(hc.Entries)) + } + // B should be updated + if hc.Entries[1].Stars != 25 { + t.Errorf("B stars = %d, want 25", hc.Entries[1].Stars) + } + // C should be appended + if hc.Entries[2].Name != "C" { + t.Errorf("last entry = %q, want C", hc.Entries[2].Name) + } +}