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 /memory/. const IndexFilename = "MEMORY.md" // AtticDir is the name of the archive subdirectory under // /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 (/memory). MemoryDir string // AtticDir is the archive directory (/memory/attic). AtticDir string // StateDir is /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 }