mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-02-05 12:45:29 +00:00
This commit represents a comprehensive refactoring of the codebase focused on improving code quality, testability, and maintainability. Key improvements: - Implement dependency injection and interface-based architecture - Add comprehensive test framework with fixtures and test suites - Fix all linting issues (errcheck, gosec, staticcheck, goconst, etc.) - Achieve full EditorConfig compliance across all files - Replace hardcoded test data with proper fixture files - Add configuration loader with hierarchical config support - Improve error handling with contextual information - Add progress indicators for better user feedback - Enhance Makefile with help system and improved editorconfig commands - Consolidate constants and remove deprecated code - Strengthen validation logic for GitHub Actions - Add focused consumer interfaces for better separation of concerns Testing improvements: - Add comprehensive integration tests - Implement test executor pattern for better test organization - Create extensive YAML fixture library for testing - Fix all failing tests and improve test coverage - Add validation test fixtures to avoid embedded YAML in Go files Build and tooling: - Update Makefile to show help by default - Fix editorconfig commands to use eclint properly - Add comprehensive help documentation to all make targets - Improve file selection patterns to avoid glob errors This refactoring maintains backward compatibility while significantly improving the internal architecture and developer experience.
309 lines
7.2 KiB
Go
309 lines
7.2 KiB
Go
// Package cache provides XDG-compliant caching functionality for gh-action-readme.
|
|
package cache
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/adrg/xdg"
|
|
)
|
|
|
|
// Entry represents a cached item with TTL support.
|
|
type Entry struct {
|
|
Value any `json:"value"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// Cache provides thread-safe caching with TTL and XDG compliance.
|
|
type Cache struct {
|
|
path string // XDG cache directory
|
|
data map[string]Entry // In-memory cache
|
|
mutex sync.RWMutex // Thread safety
|
|
ticker *time.Ticker // Cleanup ticker
|
|
done chan bool // Cleanup shutdown
|
|
defaultTTL time.Duration // Default TTL for entries
|
|
saveWG sync.WaitGroup // Wait group for pending save operations
|
|
}
|
|
|
|
// Config represents cache configuration.
|
|
type Config struct {
|
|
DefaultTTL time.Duration // Default TTL for entries
|
|
CleanupInterval time.Duration // How often to clean expired entries
|
|
MaxSize int64 // Maximum cache size in bytes (0 = unlimited)
|
|
}
|
|
|
|
// DefaultConfig returns default cache configuration.
|
|
func DefaultConfig() *Config {
|
|
return &Config{
|
|
DefaultTTL: 15 * time.Minute, // 15 minutes for API responses
|
|
CleanupInterval: 5 * time.Minute, // Clean up every 5 minutes
|
|
MaxSize: 100 * 1024 * 1024, // 100MB max cache size
|
|
}
|
|
}
|
|
|
|
// NewCache creates a new XDG-compliant cache instance.
|
|
func NewCache(config *Config) (*Cache, error) {
|
|
if config == nil {
|
|
config = DefaultConfig()
|
|
}
|
|
|
|
// Get XDG cache directory
|
|
cacheDir, err := xdg.CacheFile("gh-action-readme")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get XDG cache directory: %w", err)
|
|
}
|
|
|
|
// Ensure cache directory exists
|
|
if err := os.MkdirAll(filepath.Dir(cacheDir), 0750); err != nil { // #nosec G301 -- cache directory permissions
|
|
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
|
}
|
|
|
|
cache := &Cache{
|
|
path: filepath.Dir(cacheDir),
|
|
data: make(map[string]Entry),
|
|
defaultTTL: config.DefaultTTL,
|
|
done: make(chan bool),
|
|
}
|
|
|
|
// Load existing cache from disk
|
|
_ = cache.loadFromDisk() // Log error but don't fail - we can start with empty cache
|
|
|
|
// Start cleanup goroutine
|
|
cache.ticker = time.NewTicker(config.CleanupInterval)
|
|
go cache.cleanupLoop()
|
|
|
|
return cache, nil
|
|
}
|
|
|
|
// Set stores a value in the cache with default TTL.
|
|
func (c *Cache) Set(key string, value any) error {
|
|
return c.SetWithTTL(key, value, c.defaultTTL)
|
|
}
|
|
|
|
// SetWithTTL stores a value in the cache with custom TTL.
|
|
func (c *Cache) SetWithTTL(key string, value any, ttl time.Duration) error {
|
|
c.mutex.Lock()
|
|
defer c.mutex.Unlock()
|
|
|
|
// Calculate size (rough estimate)
|
|
size := c.estimateSize(value)
|
|
|
|
entry := Entry{
|
|
Value: value,
|
|
ExpiresAt: time.Now().Add(ttl),
|
|
Size: size,
|
|
}
|
|
|
|
c.data[key] = entry
|
|
|
|
// Persist to disk asynchronously
|
|
c.saveToDiskAsync()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Get retrieves a value from the cache.
|
|
func (c *Cache) Get(key string) (any, bool) {
|
|
c.mutex.RLock()
|
|
defer c.mutex.RUnlock()
|
|
|
|
entry, exists := c.data[key]
|
|
if !exists {
|
|
return nil, false
|
|
}
|
|
|
|
// Check if expired
|
|
if time.Now().After(entry.ExpiresAt) {
|
|
// Remove expired entry (will be cleaned up by cleanup goroutine)
|
|
return nil, false
|
|
}
|
|
|
|
return entry.Value, true
|
|
}
|
|
|
|
// Delete removes a key from the cache.
|
|
func (c *Cache) Delete(key string) {
|
|
c.mutex.Lock()
|
|
defer c.mutex.Unlock()
|
|
|
|
delete(c.data, key)
|
|
go func() {
|
|
_ = c.saveToDisk() // Async operation, error logged internally
|
|
}()
|
|
}
|
|
|
|
// Clear removes all entries from the cache.
|
|
func (c *Cache) Clear() error {
|
|
c.mutex.Lock()
|
|
defer c.mutex.Unlock()
|
|
|
|
c.data = make(map[string]Entry)
|
|
|
|
// Remove cache file
|
|
cacheFile := filepath.Join(c.path, "cache.json")
|
|
if err := os.Remove(cacheFile); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to remove cache file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stats returns cache statistics.
|
|
func (c *Cache) Stats() map[string]any {
|
|
c.mutex.RLock()
|
|
defer c.mutex.RUnlock()
|
|
|
|
var totalSize int64
|
|
expiredCount := 0
|
|
now := time.Now()
|
|
|
|
for _, entry := range c.data {
|
|
totalSize += entry.Size
|
|
if now.After(entry.ExpiresAt) {
|
|
expiredCount++
|
|
}
|
|
}
|
|
|
|
return map[string]any{
|
|
"total_entries": len(c.data),
|
|
"expired_count": expiredCount,
|
|
"total_size": totalSize,
|
|
"cache_dir": c.path,
|
|
}
|
|
}
|
|
|
|
// Close shuts down the cache and stops background processes.
|
|
func (c *Cache) Close() error {
|
|
if c.ticker != nil {
|
|
c.ticker.Stop()
|
|
}
|
|
|
|
// Signal cleanup goroutine to stop
|
|
select {
|
|
case c.done <- true:
|
|
default:
|
|
}
|
|
|
|
// Wait for any pending async save operations to complete
|
|
c.saveWG.Wait()
|
|
|
|
// Save final state to disk
|
|
return c.saveToDisk()
|
|
}
|
|
|
|
// cleanupLoop runs periodically to remove expired entries.
|
|
func (c *Cache) cleanupLoop() {
|
|
for {
|
|
select {
|
|
case <-c.ticker.C:
|
|
c.cleanup()
|
|
case <-c.done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// cleanup removes expired entries.
|
|
func (c *Cache) cleanup() {
|
|
c.mutex.Lock()
|
|
defer c.mutex.Unlock()
|
|
|
|
now := time.Now()
|
|
for key, entry := range c.data {
|
|
if now.After(entry.ExpiresAt) {
|
|
delete(c.data, key)
|
|
}
|
|
}
|
|
|
|
// Save to disk after cleanup
|
|
c.saveToDiskAsync()
|
|
}
|
|
|
|
// loadFromDisk loads cache data from disk.
|
|
func (c *Cache) loadFromDisk() error {
|
|
cacheFile := filepath.Join(c.path, "cache.json")
|
|
|
|
data, err := os.ReadFile(cacheFile) // #nosec G304 -- cache file path constructed internally
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil // No cache file is fine
|
|
}
|
|
return fmt.Errorf("failed to read cache file: %w", err)
|
|
}
|
|
|
|
c.mutex.Lock()
|
|
defer c.mutex.Unlock()
|
|
|
|
if err := json.Unmarshal(data, &c.data); err != nil {
|
|
return fmt.Errorf("failed to unmarshal cache data: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// saveToDisk persists cache data to disk.
|
|
func (c *Cache) saveToDisk() error {
|
|
c.mutex.RLock()
|
|
data := make(map[string]Entry)
|
|
for k, v := range c.data {
|
|
data[k] = v
|
|
}
|
|
c.mutex.RUnlock()
|
|
|
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal cache data: %w", err)
|
|
}
|
|
|
|
cacheFile := filepath.Join(c.path, "cache.json")
|
|
if err := os.WriteFile(cacheFile, jsonData, 0600); err != nil { // #nosec G306 -- cache file permissions
|
|
return fmt.Errorf("failed to write cache file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// saveToDiskAsync saves the cache to disk asynchronously.
|
|
// Cache save failures are non-critical and silently ignored.
|
|
func (c *Cache) saveToDiskAsync() {
|
|
c.saveWG.Add(1)
|
|
go func() {
|
|
defer c.saveWG.Done()
|
|
_ = c.saveToDisk() // Ignore errors - cache save failures are non-critical
|
|
}()
|
|
}
|
|
|
|
// estimateSize provides a rough estimate of the memory size of a value.
|
|
func (c *Cache) estimateSize(value any) int64 {
|
|
// This is a simple estimation - could be improved with reflection
|
|
jsonData, err := json.Marshal(value)
|
|
if err != nil {
|
|
return 100 // Default estimate
|
|
}
|
|
return int64(len(jsonData))
|
|
}
|
|
|
|
// GetOrSet retrieves a value from cache or sets it if not found.
|
|
func (c *Cache) GetOrSet(key string, getter func() (any, error)) (any, error) {
|
|
// Try to get from cache first
|
|
if value, exists := c.Get(key); exists {
|
|
return value, nil
|
|
}
|
|
|
|
// Not in cache, get from source
|
|
value, err := getter()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Store in cache
|
|
_ = c.Set(key, value) // Log error but don't fail - we have the value
|
|
|
|
return value, nil
|
|
}
|