ajhahn.de
← eeco
Go 140 lines
package cockpit

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"

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

// ledgerName is the reversibility record for emitted cockpit artifacts,
// inside <workspace>/state. It mirrors internal/hooks/state/hooks.json: a
// record per artifact, sha-stamped, with a backup pointer, so every emit is
// cleanly undoable.
const ledgerName = "cockpit.json"

// record is one emitted artifact's reversibility state. Keyed by
// (Target, Playbook). Created is true when generate created the leaf skill
// directory (so off can prune it); Backup points at a pre-existing file's
// saved copy under <workspace>/state/backups.
type record struct {
	Installed bool   `json:"installed"`
	Target    string `json:"target,omitempty"`
	Playbook  string `json:"playbook,omitempty"`
	Path      string `json:"path,omitempty"`
	SHA256    string `json:"sha256,omitempty"`
	Backup    string `json:"backup,omitempty"`
	Created   bool   `json:"created,omitempty"`
	At        string `json:"at,omitempty"`
}

// ledger is the persisted state. A slice so it grows per playbook/target in
// C2 without a shape change.
type ledger struct {
	Records []record `json:"records"`
}

// find returns the index of the record for (target, playbook), or -1.
func (l *ledger) find(target, playbook string) int {
	for i := range l.Records {
		if l.Records[i].Target == target && l.Records[i].Playbook == playbook {
			return i
		}
	}
	return -1
}

// upsert stores rec, replacing any existing (target, playbook) record.
func (l *ledger) upsert(rec record) {
	if i := l.find(rec.Target, rec.Playbook); i >= 0 {
		l.Records[i] = rec
		return
	}
	l.Records = append(l.Records, rec)
}

// hasInstalled reports whether any record is still installed — the
// "cockpit in use here" gate for Sync. init writes a default selection but
// never the ledger, so an empty (or all-removed) ledger means generate
// never produced an artifact and a drift scan has nothing to check.
func (l *ledger) hasInstalled() bool {
	for i := range l.Records {
		if l.Records[i].Installed {
			return true
		}
	}
	return false
}

// clear drops the record for (target, playbook) if present.
func (l *ledger) clear(target, playbook string) {
	i := l.find(target, playbook)
	if i < 0 {
		return
	}
	l.Records = append(l.Records[:i], l.Records[i+1:]...)
}

// findAgg / upsertAgg / clearAgg are the aggregate-target views of the ledger:
// an aggregate artifact (AGENTS.md, GEMINI.md) is one shared file for the
// whole set, so its record is keyed on the target alone (Playbook==""). Keying
// on target alone is what stops an `off` of one playbook from deleting a file
// shared by the rest (the orphan bug). They reuse the (target, playbook)
// primitives with an empty playbook, so per-playbook and aggregate records
// coexist under distinct keys.
func (l *ledger) findAgg(target string) int { return l.find(target, "") }
func (l *ledger) upsertAgg(rec record)      { l.upsert(rec) }
func (l *ledger) clearAgg(target string)    { l.clear(target, "") }

func ledgerPath(cfg *config.Config) string {
	return filepath.Join(cfg.Workspace, "state", ledgerName)
}

// loadLedger reads the cockpit ledger. A missing or empty file is empty
// state; a corrupt file degrades to empty state rather than wedging the
// tool (on-disk sha verification still guards every removal).
func loadLedger(cfg *config.Config) (ledger, error) {
	var l ledger
	b, err := os.ReadFile(ledgerPath(cfg))
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return l, nil
		}
		return l, fmt.Errorf("read cockpit ledger: %w", err)
	}
	if len(b) == 0 {
		return l, nil
	}
	if err := json.Unmarshal(b, &l); err != nil {
		return ledger{}, nil
	}
	return l, nil
}

// saveLedger writes the cockpit ledger with the indent + trailing-newline
// discipline of the hooks ledger.
func saveLedger(cfg *config.Config, l ledger) error {
	dir := filepath.Join(cfg.Workspace, "state")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("cockpit ledger dir: %w", err)
	}
	b, err := json.MarshalIndent(l, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(ledgerPath(cfg), append(b, '\n'), 0o644)
}

// sha256hex is the local 3-line dup of internal/hooks.sha256hex (the two
// packages share no exported helper; duplicating it keeps cockpit free of a
// hooks import for a one-liner).
func sha256hex(b []byte) string {
	sum := sha256.Sum256(b)
	return hex.EncodeToString(sum[:])
}