feat: add cache package for exclude list and health cache YAML read/write
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
go.mod
1
go.mod
@@ -7,4 +7,5 @@ require github.com/spf13/cobra v1.10.2
|
|||||||
require (
|
require (
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
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=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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/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=
|
||||||
|
|||||||
95
internal/cache/cache.go
vendored
Normal file
95
internal/cache/cache.go
vendored
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
internal/cache/cache_test.go
vendored
Normal file
105
internal/cache/cache_test.go
vendored
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user