ajhahn.de
← eeco
Go 182 lines
package memory

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/ajhahnde/eeco/internal/queue"
)

// GCAction summarises what GC did with a single fact. Action is one of
// "archived", "queued", or "kept". Reason is the human-readable trigger
// description for archive/queue, or empty for kept facts.
type GCAction struct {
	Name   string
	Type   FactType
	Action string
	Reason string
}

// GCResult is the aggregate result of a GC pass.
type GCResult struct {
	Actions  []GCAction
	Archived int
	Queued   int
	Kept     int
}

const (
	gcLogFilename = "gc.log"
)

// GC walks every fact in the store, applies the PLAN.md GC table, and
// performs the prescribed action on each. Pinned facts are always
// kept. Reference and finding facts are archived to the attic.
// Project, feedback, and user facts are queued for review (never
// silently dropped). The MEMORY.md index is regenerated from whatever
// remains. Errors short-circuit; a failure mid-pass leaves the store
// in a consistent on-disk state up to that point.
func (s *Store) GC() (GCResult, error) {
	var res GCResult
	facts, err := s.LoadAll()
	if err != nil {
		return res, fmt.Errorf("gc: %w", err)
	}

	now := s.Now().UTC()
	today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
	project := filepath.Base(s.RepoRoot)
	if project == "." || project == "/" {
		project = "repo"
	}

	for _, f := range facts {
		if f.Pin {
			res.Kept++
			res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "kept", Reason: "pinned"})
			continue
		}
		if f.Disabled {
			res.Kept++
			res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "kept", Reason: "disabled"})
			continue
		}
		reason := s.triggerFor(f, today)
		if reason == "" {
			res.Kept++
			res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "kept"})
			continue
		}
		switch f.Type {
		case TypeReference, TypeFinding:
			if err := s.archive(f, reason, now); err != nil {
				return res, fmt.Errorf("gc archive %s: %w", f.Name, err)
			}
			res.Archived++
			res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "archived", Reason: reason})
		case TypeProject, TypeFeedback, TypeUser:
			if err := s.queueReview(f, reason, project, today, now); err != nil {
				return res, fmt.Errorf("gc queue %s: %w", f.Name, err)
			}
			res.Queued++
			res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "queued", Reason: reason})
		default:
			// Should be unreachable; ValidType is enforced at parse.
			res.Kept++
			res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "kept", Reason: "unknown type"})
		}
	}

	remaining, err := s.LoadAll()
	if err != nil {
		return res, fmt.Errorf("gc: reload after pass: %w", err)
	}
	if err := s.WriteIndex(remaining); err != nil {
		return res, fmt.Errorf("gc: write index: %w", err)
	}
	return res, nil
}

// triggerFor evaluates the GC table rows in spec order and returns the
// first matching reason, or "" if the fact is to be kept. Today is the
// UTC date to compare against.
func (s *Store) triggerFor(f *Fact, today time.Time) string {
	if f.Ref != "" {
		full := filepath.Join(s.RepoRoot, f.Ref)
		if _, err := os.Stat(full); err != nil {
			if errors.Is(err, os.ErrNotExist) {
				return "ref missing: " + f.Ref
			}
			// Other stat errors (perm, etc.) — surface as a trigger so
			// the user is alerted rather than silently dropped.
			return "ref unreadable: " + f.Ref
		}
	}
	if f.Expires != nil && f.Expires.Before(today) {
		return "expired " + f.Expires.UTC().Format(DateLayout)
	}
	if f.Type == TypeFinding && f.Status == "resolved" {
		return "finding resolved"
	}
	if f.Type == TypeReference {
		age := today.Sub(f.LastUsed.UTC())
		threshold := time.Duration(s.StaleDays) * 24 * time.Hour
		if age > threshold {
			return fmt.Sprintf("stale: last_used %s (> %d days)", f.LastUsed.UTC().Format(DateLayout), s.StaleDays)
		}
	}
	return ""
}

// archive moves the fact file from MemoryDir to AtticDir and appends a
// log entry. A name collision in the attic is renamed by suffix to
// avoid clobbering an earlier archive.
func (s *Store) archive(f *Fact, reason string, now time.Time) error {
	if err := os.MkdirAll(s.AtticDir, 0o755); err != nil {
		return err
	}
	dst := filepath.Join(s.AtticDir, f.Name+".md")
	if _, err := os.Stat(dst); err == nil {
		dst = filepath.Join(s.AtticDir, fmt.Sprintf("%s.%d.md", f.Name, now.Unix()))
	}
	if err := os.Rename(f.Path, dst); err != nil {
		return err
	}
	return s.logGC(now, "archived", f.Name, reason)
}

// queueReview appends a review item to the queue. The fact file is
// left in place: load-bearing user/project/feedback facts are never
// silently moved. today is the calendar date stamped on the queue row;
// now is the wall-clock timestamp recorded in gc.log.
func (s *Store) queueReview(f *Fact, reason, project string, today, now time.Time) error {
	item := queue.Item{
		Kind:    "gc-review",
		Title:   fmt.Sprintf("memory '%s' looks stale: %s", f.Name, reason),
		Project: project,
		Detail:  fmt.Sprintf("type=%s description=%q", f.Type, f.Description),
		Date:    today,
	}
	if _, err := queue.AppendUnique(s.StateDir, item); err != nil {
		return err
	}
	return s.logGC(now, "queued", f.Name, reason)
}

func (s *Store) logGC(now time.Time, action, name, reason string) error {
	if err := os.MkdirAll(s.StateDir, 0o755); err != nil {
		return err
	}
	logPath := filepath.Join(s.StateDir, gcLogFilename)
	f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return err
	}
	defer f.Close()
	_, err = fmt.Fprintf(f, "%s %s %s reason=%q\n", now.UTC().Format(time.RFC3339), action, name, reason)
	return err
}