ajhahn.de
← eeco
Go 252 lines
package config

import (
	"bytes"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// workspaceSubdirs are the directories scaffolded inside the
// workspace by Init. The order is deterministic for predictable
// reports.
var workspaceSubdirs = []string{"engine", "memory", "workflows", "state", "docs"}

// InitReport summarises the result of an Init call. It is safe to use
// for both the first run (everything created) and idempotent re-runs
// (nothing changed).
type InitReport struct {
	RepoRoot      string
	Username      string
	Workspace     string
	WorkspaceName string
	Profile       Profile
	Gate          [][]string
	CreatedDirs   []string
	// CreatedKnowledgeDirs lists the project-type knowledge dirs created
	// this run inside UserDir (siblings of the engine workspace). Empty
	// when cfg carried no KnowledgeDirs or all already existed.
	CreatedKnowledgeDirs []string
	WroteReadme          bool
	GitignoreChanged     bool
	GitignorePath        string
	AlreadyInit          bool
}

// Init scaffolds the workspace described by cfg. It is idempotent: a
// second call against an initialised tree is a no-op except for
// re-reporting state. Init does not touch any file outside RepoRoot
// and creates only files inside the workspace plus a possible
// modification to <repo>/.gitignore (the documented opt-in
// modification).
func Init(cfg *Config) (InitReport, error) {
	if cfg == nil {
		return InitReport{}, errors.New("config is nil")
	}
	rep := InitReport{
		RepoRoot:      cfg.RepoRoot,
		Username:      cfg.Username,
		Workspace:     cfg.Workspace,
		WorkspaceName: cfg.WorkspaceName,
		Profile:       cfg.Profile,
		Gate:          append([][]string(nil), cfg.Gate...),
	}
	rep.AlreadyInit = IsInitialized(cfg)

	if err := ensureDir(cfg.Workspace); err != nil {
		return rep, err
	}
	for _, sub := range workspaceSubdirs {
		p := filepath.Join(cfg.Workspace, sub)
		created, err := ensureDirCreated(p)
		if err != nil {
			return rep, err
		}
		if created {
			rep.CreatedDirs = append(rep.CreatedDirs, sub)
		}
	}

	// Scaffold the project-type knowledge dirs as siblings of the engine
	// workspace, inside UserDir. They are resolved by `eeco init` from
	// the project-type detector and carried on cfg.KnowledgeDirs; a
	// Config built by Load alone has none, so this loop is a no-op there.
	// UserDir already exists (the workspace was created under it above).
	if cfg.UserDir != "" {
		for _, dir := range cfg.KnowledgeDirs {
			if !safeDirComponent(dir) {
				continue
			}
			created, err := ensureDirCreated(filepath.Join(cfg.UserDir, dir))
			if err != nil {
				return rep, err
			}
			if created {
				rep.CreatedKnowledgeDirs = append(rep.CreatedKnowledgeDirs, dir)
			}
		}
	}

	rep.GitignorePath = filepath.Join(cfg.RepoRoot, ".gitignore")
	// The whole per-user directory is gitignored — it holds the engine
	// workspace plus the knowledge dirs. A Config not produced by Load
	// (no Username) falls back to ignoring just the workspace name.
	ignoreName := cfg.Username
	if ignoreName == "" {
		ignoreName = cfg.WorkspaceName
	}
	changed, err := ensureIgnored(rep.GitignorePath, ignoreName)
	if err != nil {
		return rep, fmt.Errorf("update .gitignore: %w", err)
	}
	rep.GitignoreChanged = changed

	wrote, err := writeReadme(cfg)
	if err != nil {
		return rep, fmt.Errorf("write workspace README: %w", err)
	}
	rep.WroteReadme = wrote

	return rep, nil
}

// IsInitialized reports whether the workspace described by cfg looks
// scaffolded. The check is structural: all canonical subdirectories
// must exist as directories.
func IsInitialized(cfg *Config) bool {
	if cfg == nil {
		return false
	}
	for _, sub := range workspaceSubdirs {
		info, err := os.Stat(filepath.Join(cfg.Workspace, sub))
		if err != nil || !info.IsDir() {
			return false
		}
	}
	return true
}

// safeDirComponent reports whether name is usable as a single,
// non-escaping path component for a scaffolded knowledge dir. It rejects
// empty names, absolute paths, multi-segment paths, and the "." / ".."
// specials so a malformed catalog entry can never write outside UserDir.
func safeDirComponent(name string) bool {
	if name == "" || name == "." || name == ".." {
		return false
	}
	if name != filepath.Clean(name) {
		return false
	}
	if filepath.IsAbs(name) || strings.ContainsAny(name, `/\`) {
		return false
	}
	return true
}

func ensureDir(path string) error {
	info, err := os.Stat(path)
	if err == nil {
		if !info.IsDir() {
			return fmt.Errorf("%s exists and is not a directory", path)
		}
		return nil
	}
	if !errors.Is(err, os.ErrNotExist) {
		return err
	}
	return os.MkdirAll(path, 0o755)
}

func ensureDirCreated(path string) (bool, error) {
	info, err := os.Stat(path)
	if err == nil {
		if !info.IsDir() {
			return false, fmt.Errorf("%s exists and is not a directory", path)
		}
		return false, nil
	}
	if !errors.Is(err, os.ErrNotExist) {
		return false, err
	}
	if err := os.MkdirAll(path, 0o755); err != nil {
		return false, err
	}
	return true, nil
}

// ensureIgnored adds `/<name>/` to the .gitignore file at gitignorePath
// if no equivalent existing entry is present. It returns true if the
// file was created or modified. Equivalent entries are exact-line
// matches against `<name>`, `<name>/`, `/<name>`, or `/<name>/`. The
// check ignores comment lines and surrounding whitespace.
func ensureIgnored(gitignorePath, name string) (bool, error) {
	target := "/" + name + "/"
	existing, err := os.ReadFile(gitignorePath)
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return false, err
	}
	equiv := map[string]struct{}{
		name:             {},
		name + "/":       {},
		"/" + name:       {},
		"/" + name + "/": {},
	}
	for _, raw := range strings.Split(string(existing), "\n") {
		line := strings.TrimSpace(raw)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		if _, ok := equiv[line]; ok {
			return false, nil
		}
	}
	var buf bytes.Buffer
	if len(existing) > 0 && !bytes.HasSuffix(existing, []byte("\n")) {
		buf.WriteByte('\n')
	}
	buf.WriteString(target)
	buf.WriteByte('\n')

	f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return false, err
	}
	defer f.Close()
	if _, err := f.Write(buf.Bytes()); err != nil {
		return false, err
	}
	return true, nil
}

// writeReadme drops a short, neutral README at the workspace root the
// first time Init runs. Subsequent runs leave any existing README
// untouched.
func writeReadme(cfg *Config) (bool, error) {
	p := filepath.Join(cfg.Workspace, "README.md")
	if _, err := os.Stat(p); err == nil {
		return false, nil
	} else if !errors.Is(err, os.ErrNotExist) {
		return false, err
	}
	content := fmt.Sprintf(`eeco workspace

This directory is the private workspace for the repository at
%s. It is gitignored and must not be committed.

Detected profile: %s

Subdirectories:
  engine/     engine-side state and templates
  memory/     fact store, one file per fact
  workflows/  user-scaffolded workflows (builtins are embedded)
  state/      queue and other mutable runtime state
  docs/       per-repo documentation and handover notes

Edit config.local in this directory to override the detected profile
or the parse/build gate command.
`, cfg.RepoRoot, cfg.Profile)
	return true, os.WriteFile(p, []byte(content), 0o644)
}