Go 128 lines
// Package projecttype classifies a repository into one of eeco's known
// project-type categories and resolves the knowledge-directory set that
// `eeco init` scaffolds for it.
//
// Classification is a four-layer pipeline (see detect.go): a marker-file
// scan, a conventional-directory scan, an interactive operator prompt,
// and a gated AI fallback. The first three need no AI spend; the fourth
// runs only when the caller injects an AIFunc and the operator opts in.
//
// The category set and per-category scaffold data live in the embedded
// catalog (catalog/*.json). The detection heuristics live in this
// package as a tuned, testable table, deliberately kept out of the
// catalog so the catalog stays an operator-reviewable description of
// what each category scaffolds rather than a tuning surface.
package projecttype
import (
"embed"
"encoding/json"
"fmt"
"slices"
)
//go:embed catalog/*.json
var catalogFS embed.FS
// Category is a project-type identifier. The canonical set is exactly
// the filenames under catalog/ (without the .json suffix).
type Category string
const (
CLI Category = "cli"
Library Category = "library"
WebApp Category = "webapp"
WebAPI Category = "webapi"
Fullstack Category = "fullstack"
Mobile Category = "mobile"
Embedded Category = "embedded"
GameDev Category = "gamedev"
ML Category = "ml"
Infra Category = "infra"
Generic Category = "generic"
)
// Entry is one catalog record: the scaffold and human/AI-facing data for
// a category.
type Entry struct {
Category Category `json:"category"`
Description string `json:"description"`
PickWhen string `json:"pick_when"`
Dirs []string `json:"dirs"`
}
// Catalog is the loaded set of category entries, keyed by Category.
type Catalog struct {
entries map[Category]Entry
}
// LoadCatalog parses every embedded catalog/*.json file. It errors if a
// file is malformed, a category appears twice, an entry has no dirs, or
// the generic fallback is absent.
func LoadCatalog() (*Catalog, error) {
ents, err := catalogFS.ReadDir("catalog")
if err != nil {
return nil, fmt.Errorf("read catalog dir: %w", err)
}
c := &Catalog{entries: make(map[Category]Entry, len(ents))}
for _, de := range ents {
if de.IsDir() {
continue
}
b, err := catalogFS.ReadFile("catalog/" + de.Name())
if err != nil {
return nil, fmt.Errorf("read %s: %w", de.Name(), err)
}
var e Entry
if err := json.Unmarshal(b, &e); err != nil {
return nil, fmt.Errorf("parse %s: %w", de.Name(), err)
}
if e.Category == "" {
return nil, fmt.Errorf("%s: empty category", de.Name())
}
if _, dup := c.entries[e.Category]; dup {
return nil, fmt.Errorf("duplicate category %q", e.Category)
}
if len(e.Dirs) == 0 {
return nil, fmt.Errorf("category %q has no dirs", e.Category)
}
c.entries[e.Category] = e
}
if _, ok := c.entries[Generic]; !ok {
return nil, fmt.Errorf("catalog missing the %q fallback entry", Generic)
}
return c, nil
}
// Get returns the entry for cat and whether it is known.
func (c *Catalog) Get(cat Category) (Entry, bool) {
e, ok := c.entries[cat]
return e, ok
}
// Has reports whether cat is a known category.
func (c *Catalog) Has(cat Category) bool {
_, ok := c.entries[cat]
return ok
}
// Categories returns every known category in sorted order.
func (c *Catalog) Categories() []Category {
out := make([]Category, 0, len(c.entries))
for cat := range c.entries {
out = append(out, cat)
}
slices.Sort(out)
return out
}
// DirsFor returns a copy of the scaffold dir-set for cat, or nil if cat
// is unknown.
func (c *Catalog) DirsFor(cat Category) []string {
e, ok := c.entries[cat]
if !ok {
return nil
}
return append([]string(nil), e.Dirs...)
}