ajhahn.de
← eeco
Go 140 lines
// Package docs renders eeco's tracked-tree documentation scaffolds.
//
// It backs `eeco docs new <target>` — a one-shot, deterministic write
// of a tracked-tree doc (today: VISION.md) at the repository root,
// invoked explicitly by the operator. Reuses the precedent set by
// `eeco init` for the workspace `.gitignore` line: eeco may write into
// the tracked tree on explicit invocation, the operator stages and
// commits (Constraint 6).
//
// The render is template-driven and project-shape-aware: placeholders
// fill in the project basename, the eeco version, and which of the
// usual companion docs already exist so the scaffold's "See also"
// reflects the project rather than a generic stub.
package docs

import (
	"bytes"
	"embed"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"text/template"
)

//go:embed templates/*.tmpl
var templatesFS embed.FS

// Target enumerates the tracked-tree docs the scaffolder can produce.
// Each target maps to one filename at the repository root and one
// embedded template file under templates/.
type Target string

const (
	// TargetVision scaffolds VISION.md.
	TargetVision Target = "vision"
	// TargetReadme scaffolds README.md.
	TargetReadme Target = "readme"
)

// AllTargets returns every supported target in the order presented in
// usage messages.
func AllTargets() []Target {
	return []Target{TargetVision, TargetReadme}
}

// Filename returns the tracked-tree filename this target scaffolds to.
// An empty string means the target is not recognised.
func (t Target) Filename() string {
	switch t {
	case TargetVision:
		return "VISION.md"
	case TargetReadme:
		return "README.md"
	}
	return ""
}

// Params carries the substitutions a template can reference. Fields
// are kept small and deterministic; nothing here depends on wall-clock
// time so a re-render of the same project yields byte-identical bytes.
type Params struct {
	// Project is the repository basename (e.g. "eeco").
	Project string
	// Version is the eeco version string the binary was built with.
	Version string
	// HasReadme reports whether README.md exists at the repository root.
	HasReadme bool
	// HasUsage reports whether docs/USAGE.md exists.
	HasUsage bool
	// HasArch reports whether docs/ARCHITECTURE.md exists.
	HasArch bool
}

// Scaffold renders target's template into repoRoot/<target-filename>
// and returns the repo-relative path written. If the file already
// exists and overwrite is false, Scaffold refuses and returns an error
// whose message names the file and the override flag.
func Scaffold(target Target, repoRoot string, overwrite bool, p Params) (string, error) {
	name := target.Filename()
	if name == "" {
		return "", fmt.Errorf("unknown target %q", string(target))
	}
	// Filenames are hardcoded per target; this is belt-and-braces against
	// a future target accidentally introducing an escape.
	if filepath.IsAbs(name) || filepath.Clean(name) != name {
		return "", fmt.Errorf("target filename %q is not a safe relative path", name)
	}
	full := filepath.Join(repoRoot, name)
	if !overwrite {
		if _, err := os.Stat(full); err == nil {
			return "", fmt.Errorf("%s already exists at the repo root; pass --overwrite to replace", name)
		} else if !errors.Is(err, os.ErrNotExist) {
			return "", err
		}
	}

	tmplPath := "templates/" + string(target) + ".md.tmpl"
	raw, err := templatesFS.ReadFile(tmplPath)
	if err != nil {
		return "", fmt.Errorf("read template: %w", err)
	}
	tmpl, err := template.New(string(target)).Parse(string(raw))
	if err != nil {
		return "", fmt.Errorf("parse template: %w", err)
	}
	var buf bytes.Buffer
	if err := tmpl.Execute(&buf, p); err != nil {
		return "", fmt.Errorf("render template: %w", err)
	}
	if err := os.WriteFile(full, buf.Bytes(), 0o644); err != nil {
		return "", err
	}
	return name, nil
}

// Render returns the rendered template bytes for target without
// touching the filesystem. Useful for tests and any future caller that
// wants to inspect the scaffold without writing it.
func Render(target Target, p Params) (string, error) {
	name := target.Filename()
	if name == "" {
		return "", fmt.Errorf("unknown target %q", string(target))
	}
	tmplPath := "templates/" + string(target) + ".md.tmpl"
	raw, err := templatesFS.ReadFile(tmplPath)
	if err != nil {
		return "", fmt.Errorf("read template: %w", err)
	}
	tmpl, err := template.New(string(target)).Parse(string(raw))
	if err != nil {
		return "", fmt.Errorf("parse template: %w", err)
	}
	var buf bytes.Buffer
	if err := tmpl.Execute(&buf, p); err != nil {
		return "", fmt.Errorf("render template: %w", err)
	}
	return buf.String(), nil
}