ajhahn.de
← eeco
Go 210 lines
package cockpit

import (
	"errors"
	"fmt"
	"os"
	"strings"
	"time"

	"github.com/ajhahnde/eeco/internal/config"
)

// GenerateAll emits an aggregate target's single shared artifact for the whole
// playbook set, reversibly. The uniform safety gate runs on EVERY playbook in
// the set first: a forbidden write-git verb in any one refuses the whole emit
// (nothing written, no ledger churn), naming the offending playbook — advisory
// targets do not relax the invariant. The artifact dir is cfg.UserDir (never
// pruned), so off can never remove the private tree. Re-emitting unchanged
// bytes is a byte-idempotent no-op.
func GenerateAll(cfg *config.Config, set []Playbook, target string) (GenerateResult, error) {
	r, ok := rendererFor(target)
	if !ok {
		return GenerateResult{}, unknownTargetErr(target)
	}
	agg, ok := isAggregate(r)
	if !ok {
		return GenerateResult{}, fmt.Errorf("target %q is per-playbook; use Generate", target)
	}
	for _, pb := range set {
		if hits := ScanAllowlistForWriteGitVerbs(composeAllowedTools(pb), pb.Intent.forbiddenVerbs()); len(hits) > 0 {
			return GenerateResult{}, fmt.Errorf(
				"refusing to emit %s: playbook %q has forbidden write-git verb(s) in allowlist: %s",
				target, pb.Name, strings.Join(hits, ", "))
		}
	}

	content, err := agg.RenderAll(set)
	if err != nil {
		return GenerateResult{}, err
	}
	dst, err := userArtifactPath(cfg, agg.AggRelPath())
	if err != nil {
		return GenerateResult{}, err
	}
	newSHA := sha256hex(content)

	l, err := loadLedger(cfg)
	if err != nil {
		return GenerateResult{}, err
	}
	priorIdx := l.findAgg(target)
	hasPrior := priorIdx >= 0 && l.Records[priorIdx].Installed
	var prior record
	if priorIdx >= 0 {
		prior = l.Records[priorIdx]
	}

	if hasPrior && prior.SHA256 == newSHA {
		if onDisk, rerr := os.ReadFile(dst); rerr == nil && sha256hex(onDisk) == newSHA {
			return GenerateResult{Path: dst, Action: "already current", Fidelity: fidelityOf(r)}, nil
		}
	}

	backup := prior.Backup
	if !hasPrior {
		// The aggregate dir is cfg.UserDir, which always exists and is never
		// pruned, so only a pre-existing foreign file needs backing up.
		if existing, rerr := os.ReadFile(dst); rerr == nil {
			bp, berr := backupExisting(cfg, target, "_all", existing)
			if berr != nil {
				return GenerateResult{}, berr
			}
			backup = bp
		} else if !errors.Is(rerr, os.ErrNotExist) {
			return GenerateResult{}, fmt.Errorf("inspect %s: %w", dst, rerr)
		}
	}

	if err := writeFileAtomic(dst, content, 0o644); err != nil {
		return GenerateResult{}, err
	}

	l.upsertAgg(record{
		Installed: true,
		Target:    target,
		Playbook:  "", // aggregate: keyed on target alone
		Path:      dst,
		SHA256:    newSHA,
		Backup:    backup,
		Created:   false, // dir is cfg.UserDir — never created, never pruned
		At:        time.Now().UTC().Format(time.RFC3339),
	})
	if err := saveLedger(cfg, l); err != nil {
		return GenerateResult{}, err
	}

	action := "generated"
	switch {
	case hasPrior:
		action = "regenerated"
	case backup != "":
		action = "updated"
	}
	return GenerateResult{Path: dst, Action: action, Backup: backup, Fidelity: fidelityOf(r)}, nil
}

// VerifyAll checks an aggregate target's on-disk artifact against the bytes
// freshly rendered for set, then runs the self-consistency safety check on the
// on-disk bytes (S4) — the advisory analog of the enforced allowlist scan,
// since these targets have no answer key. It never mutates anything.
func VerifyAll(cfg *config.Config, set []Playbook, target string) (VerifyResult, error) {
	r, ok := rendererFor(target)
	if !ok {
		return VerifyResult{}, unknownTargetErr(target)
	}
	agg, ok := isAggregate(r)
	if !ok {
		return VerifyResult{}, fmt.Errorf("target %q is per-playbook; use Verify", target)
	}
	desired, err := agg.RenderAll(set)
	if err != nil {
		return VerifyResult{}, err
	}
	dst, err := userArtifactPath(cfg, agg.AggRelPath())
	if err != nil {
		return VerifyResult{}, err
	}
	onDisk, rerr := os.ReadFile(dst)
	if errors.Is(rerr, os.ErrNotExist) {
		return VerifyResult{Clean: false, Detail: fmt.Sprintf("%s: not emitted (run `eeco cockpit generate`)", target)}, nil
	}
	if rerr != nil {
		return VerifyResult{}, fmt.Errorf("read %s: %w", dst, rerr)
	}
	if sha256hex(onDisk) != sha256hex(desired) {
		return VerifyResult{Clean: false, Detail: fmt.Sprintf(
			"%s: drifted (hand-edited; run `eeco cockpit generate` to restore)", target)}, nil
	}
	sc := checkSelfConsistencyBytes(onDisk, set)
	if !sc.OK {
		return VerifyResult{Clean: false, Detail: fmt.Sprintf(
			"%s: self-consistency FAILED: %s", target, strings.Join(sc.Notes, "; "))}, nil
	}
	return VerifyResult{Clean: true, Detail: fmt.Sprintf("%s: clean (advisory — not harness-enforced)", target)}, nil
}

// OffAll removes an aggregate target's shared artifact, sha-gated and
// reversible. A hand-edited file (on-disk sha != recorded sha) is left
// untouched. It restores a backed-up foreign file or removes eeco's artifact,
// then clears the target-only record. It NEVER prunes a directory, so it can
// never remove cfg.UserDir (the private tree).
func OffAll(cfg *config.Config, target string) (OffResult, error) {
	r, ok := rendererFor(target)
	if !ok {
		return OffResult{}, unknownTargetErr(target)
	}
	agg, ok := isAggregate(r)
	if !ok {
		return OffResult{}, fmt.Errorf("target %q is per-playbook; use Off", target)
	}
	dst, err := userArtifactPath(cfg, agg.AggRelPath())
	if err != nil {
		return OffResult{}, err
	}
	l, err := loadLedger(cfg)
	if err != nil {
		return OffResult{}, err
	}
	i := l.findAgg(target)
	if i < 0 || !l.Records[i].Installed {
		return OffResult{Changed: false, Message: fmt.Sprintf("%s: not emitted", target)}, nil
	}
	rec := l.Records[i]

	onDisk, rerr := os.ReadFile(dst)
	if errors.Is(rerr, os.ErrNotExist) {
		l.clearAgg(target)
		if err := saveLedger(cfg, l); err != nil {
			return OffResult{}, err
		}
		return OffResult{Changed: true, Message: fmt.Sprintf("%s: already removed; ledger cleared", target)}, nil
	}
	if rerr != nil {
		return OffResult{}, fmt.Errorf("read %s: %w", dst, rerr)
	}
	if sha256hex(onDisk) != rec.SHA256 {
		return OffResult{Changed: false, Message: fmt.Sprintf("%s: edited since generate — left untouched", target)}, nil
	}

	if rec.Backup != "" {
		// Restore the pre-eeco file by atomically replacing eeco's artifact, so
		// the path is never absent mid-restore; an unreadable backup falls back
		// to removing the artifact.
		if bb, berr := os.ReadFile(rec.Backup); berr == nil {
			if werr := writeFileAtomic(dst, bb, 0o644); werr != nil {
				return OffResult{}, werr
			}
		} else if remErr := os.Remove(dst); remErr != nil {
			return OffResult{}, fmt.Errorf("remove %s: %w", dst, remErr)
		}
	} else if err := os.Remove(dst); err != nil {
		return OffResult{}, fmt.Errorf("remove %s: %w", dst, err)
	}
	l.clearAgg(target)
	if err := saveLedger(cfg, l); err != nil {
		return OffResult{}, err
	}
	return OffResult{Changed: true, Message: fmt.Sprintf("%s: removed (reversed)", target)}, nil
}