ajhahn.de
← eeco
Go 159 lines
package memory

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

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

// IndexFilename is the name of the regenerated index living alongside
// the fact files under <workspace>/memory/.
const IndexFilename = "MEMORY.md"

// AtticDir is the name of the archive subdirectory under
// <workspace>/memory/.
const AtticDir = "attic"

// Store owns the on-disk memory store. It is constructed by Open and is
// safe to reuse across operations within a single process.
type Store struct {
	// RepoRoot is the repository root; used to resolve fact `ref` paths.
	RepoRoot string
	// MemoryDir is the directory holding fact files (<workspace>/memory).
	MemoryDir string
	// AtticDir is the archive directory (<workspace>/memory/attic).
	AtticDir string
	// StateDir is <workspace>/state, used for queue.md and gc.log.
	StateDir string
	// StaleDays is the threshold for reference-fact ageing.
	StaleDays int
	// Now is the clock source; defaulted to time.Now and injectable for
	// tests.
	Now func() time.Time
}

// Open returns a Store rooted at cfg.Workspace. The memory directory is
// created if missing so that callers may operate on a freshly
// initialised repository without a separate ensure step.
func Open(cfg *config.Config) (*Store, error) {
	if cfg == nil {
		return nil, errors.New("memory.Open: nil config")
	}
	memDir := filepath.Join(cfg.Workspace, "memory")
	if err := os.MkdirAll(memDir, 0o755); err != nil {
		return nil, fmt.Errorf("memory: create memory dir: %w", err)
	}
	stateDir := filepath.Join(cfg.Workspace, "state")
	return &Store{
		RepoRoot:  cfg.RepoRoot,
		MemoryDir: memDir,
		AtticDir:  filepath.Join(memDir, AtticDir),
		StateDir:  stateDir,
		StaleDays: cfg.StaleDays,
		Now:       time.Now,
	}, nil
}

// pathFor returns the on-disk path for a fact with the given name.
func (s *Store) pathFor(name string) string {
	return filepath.Join(s.MemoryDir, name+".md")
}

// Save serialises f and writes it atomically to disk. The filename
// derives from f.Name. Save validates f and refuses to overwrite a
// file that exists with a different name (which would indicate a
// rename via Save rather than an explicit move).
func (s *Store) Save(f *Fact) error {
	if err := f.Validate(); err != nil {
		return fmt.Errorf("memory.Save: %w", err)
	}
	data, err := Serialize(f)
	if err != nil {
		return fmt.Errorf("memory.Save: %w", err)
	}
	path := s.pathFor(f.Name)
	tmp, err := os.CreateTemp(s.MemoryDir, "."+f.Name+".*.tmp")
	if err != nil {
		return fmt.Errorf("memory.Save: %w", err)
	}
	tmpPath := tmp.Name()
	cleanup := func() { _ = os.Remove(tmpPath) }
	if _, err := tmp.Write(data); err != nil {
		_ = tmp.Close()
		cleanup()
		return fmt.Errorf("memory.Save: %w", err)
	}
	if err := tmp.Close(); err != nil {
		cleanup()
		return fmt.Errorf("memory.Save: %w", err)
	}
	if err := os.Rename(tmpPath, path); err != nil {
		cleanup()
		return fmt.Errorf("memory.Save: %w", err)
	}
	f.Path = path
	return nil
}

// LoadAll reads every fact file in MemoryDir (skipping AtticDir, the
// index, any dot-prefixed entry, and any non-`.md` entry) and returns
// the parsed facts. Dot-prefixed names can never be valid facts
// because Fact.Name is restricted to `^[a-z0-9][a-z0-9-]*$`, so a
// dot-prefixed file under MemoryDir was placed by hand and is ignored
// rather than parsed. A parse error on any other single file aborts
// the load: the store is a small curated set and silent skips would
// hide bugs.
func (s *Store) LoadAll() ([]*Fact, error) {
	entries, err := os.ReadDir(s.MemoryDir)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, nil
		}
		return nil, fmt.Errorf("memory.LoadAll: %w", err)
	}
	var out []*Fact
	seen := map[string]string{}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		if name == IndexFilename {
			continue
		}
		if strings.HasPrefix(name, ".") {
			continue
		}
		if !strings.HasSuffix(name, ".md") {
			continue
		}
		path := filepath.Join(s.MemoryDir, name)
		data, err := os.ReadFile(path)
		if err != nil {
			return nil, fmt.Errorf("memory.LoadAll: read %s: %w", name, err)
		}
		f, err := ParseFact(data)
		if err != nil {
			return nil, fmt.Errorf("memory.LoadAll: parse %s: %w", name, err)
		}
		expectedName := strings.TrimSuffix(name, ".md")
		if f.Name != expectedName {
			return nil, fmt.Errorf("memory.LoadAll: %s: frontmatter name %q does not match filename", name, f.Name)
		}
		if dup, ok := seen[f.Name]; ok {
			return nil, fmt.Errorf("memory.LoadAll: duplicate fact %q (%s and %s)", f.Name, dup, name)
		}
		seen[f.Name] = name
		f.Path = path
		out = append(out, f)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out, nil
}