ajhahn.de
← eeco
Go 151 lines
package manifest

// Package manifest writes per-directory .ai.json manifests: a compact,
// deterministic enumeration of a knowledge directory's immediate entries for
// human audit and AI orientation. The deterministic walk fills paths and kinds
// only; descriptions are left empty and an opt-in AI pass (the Slice-D
// `eeco refresh-manifest` verb, using the ManifestSummary prompt) fills them.

import (
	"encoding/json"
	"errors"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
)

// FileName is the per-directory manifest file name.
const FileName = ".ai.json"

// vcsDir is the version-control directory the manifest walk never descends
// into or enumerates: the private workspace-history repo (and any nested VCS
// dir) is engine plumbing, not knowledge. Pairs with the engine-workspace
// exclusion so a refresh after `init` never writes into ajhahnde/.git.
const vcsDir = ".git"

// Item is one entry in a directory manifest. Desc and FindWhen are populated by
// the opt-in AI enrichment pass, never by the deterministic walk.
type Item struct {
	Path     string `json:"path"`
	Kind     string `json:"kind"` // "file" or "dir"
	Desc     string `json:"desc,omitempty"`
	FindWhen string `json:"find_when,omitempty"`
}

// Manifest is the .ai.json document for one directory.
type Manifest struct {
	Dir     string `json:"dir"`
	Purpose string `json:"purpose,omitempty"`
	Items   []Item `json:"items"`
}

// Build walks the immediate children of <root>/<dir> and returns a
// deterministic skeleton manifest (paths + kinds; descriptions empty), sorted
// by path. The manifest file itself is never listed, so re-running over a
// directory that already holds an .ai.json is idempotent.
func Build(root, dir string) (Manifest, error) {
	target := filepath.Join(root, dir)
	entries, err := os.ReadDir(target)
	if err != nil {
		return Manifest{}, err
	}

	items := make([]Item, 0, len(entries))
	for _, e := range entries {
		name := e.Name()
		if name == FileName || name == vcsDir {
			continue
		}
		if e.IsDir() {
			items = append(items, Item{Path: name + "/", Kind: "dir"})
			continue
		}
		items = append(items, Item{Path: name, Kind: "file"})
	}
	sort.Slice(items, func(i, j int) bool { return items[i].Path < items[j].Path })

	return Manifest{Dir: dir, Items: items}, nil
}

// KnowledgeDirs walks the whole tree under userDir and returns every directory
// in it — top-level knowledge dirs and their nested subdirectories alike — as
// paths relative to userDir, sorted. userDir itself is never returned, and the
// engine workspace (engineName, e.g. ".eeco") is excluded along with its entire
// subtree. Separators in the returned paths are OS-native. A userDir that does
// not exist yet yields an empty list and no error, so a refresh on an un-inited
// repo is a clean no-op.
func KnowledgeDirs(userDir, engineName string) ([]string, error) {
	var out []string
	err := filepath.WalkDir(userDir, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() {
			return nil
		}
		if path == userDir {
			return nil
		}
		if d.Name() == engineName || d.Name() == vcsDir {
			return fs.SkipDir
		}
		rel, err := filepath.Rel(userDir, path)
		if err != nil {
			return err
		}
		out = append(out, rel)
		return nil
	})
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, nil
		}
		return nil, err
	}
	sort.Strings(out)
	return out, nil
}

// Subtree walks <userDir>/<dir> and returns dir plus all of its nested
// subdirectories as paths relative to userDir, sorted — so each is directly
// usable with Build(userDir, x) and Write(userDir, x, m). No engine exclusion
// applies, as the engine workspace is never passed as dir.
func Subtree(userDir, dir string) ([]string, error) {
	root := filepath.Join(userDir, dir)
	var out []string
	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() {
			return nil
		}
		if d.Name() == vcsDir {
			return fs.SkipDir
		}
		rel, err := filepath.Rel(userDir, path)
		if err != nil {
			return err
		}
		out = append(out, rel)
		return nil
	})
	if err != nil {
		return nil, err
	}
	sort.Strings(out)
	return out, nil
}

// Write marshals m to <root>/<dir>/.ai.json with stable indentation and a
// trailing newline.
func Write(root, dir string, m Manifest) error {
	data, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return err
	}
	data = append(data, '\n')
	return os.WriteFile(filepath.Join(root, dir, FileName), data, 0o644)
}