ajhahn.de
← eeco
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...)
}