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
}