ajhahn.de
← eeco
Go 175 lines
package cockpit

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"

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

// Selection is the operator-managed set of active cockpit targets, persisted
// at <username>/.eeco/cockpit.json — the C0-envisioned selector, separate from
// the emission ledger at <workspace>/state/cockpit.json. Targets is the active
// harness set `eeco cockpit generate` renders by default. Playbooks is
// reserved for future narrowing: absent (or empty) means every registered
// playbook (C2 keeps it implicit-all; the field is present so a later slice
// can narrow without a schema change).
type Selection struct {
	Targets   []string `json:"targets"`
	Playbooks []string `json:"playbooks,omitempty"`
}

// selectionName is the selection store's filename inside the workspace dir
// (<username>/.eeco/). It is intentionally the same base name as the emission
// ledger but at a different path (the ledger is under state/), so the two
// never collide.
const selectionName = "cockpit.json"

func selectionPath(cfg *config.Config) string {
	return filepath.Join(cfg.Workspace, selectionName)
}

// SelectionPath returns the absolute path of the selection store
// (<username>/.eeco/cockpit.json). Exported so the contract-watch hook can
// recognize an edit to it without duplicating the path construction.
func SelectionPath(cfg *config.Config) string {
	return selectionPath(cfg)
}

// HasSelection reports whether a selection store already exists, so init can
// record the operator's harness choice once without clobbering it on re-run.
func HasSelection(cfg *config.Config) bool {
	_, err := os.Stat(selectionPath(cfg))
	return err == nil
}

// DefaultSelection is the fail-safe active set: Claude alone, the one enforced
// target. Used when no selection is configured or the stored one is unusable.
func DefaultSelection() Selection {
	return Selection{Targets: []string{"claude"}}
}

// globalSelectionPath is the user-global cockpit selection, parallel to the
// global config.local layer: a project with no workspace selection inherits
// these targets. Empty when no global config dir resolves.
func globalSelectionPath() string {
	dir := config.GlobalConfigDir()
	if dir == "" {
		return ""
	}
	return filepath.Join(dir, selectionName)
}

// GlobalSelectionPath is the exported user-global cockpit selection path
// (or "" when no global config dir resolves), for command messaging.
func GlobalSelectionPath() string {
	return globalSelectionPath()
}

// LoadGlobalSelection reads the user-global cockpit selection, degrading to
// DefaultSelection when it is absent, empty, corrupt, or all-unknown.
func LoadGlobalSelection() Selection {
	if s, ok := loadSelectionFile(globalSelectionPath()); ok {
		return s
	}
	return DefaultSelection()
}

// SaveGlobalSelection writes the user-global cockpit selection, creating the
// global config dir if absent. Same sanitize semantics as SaveSelection.
func SaveGlobalSelection(s Selection) error {
	path := globalSelectionPath()
	if path == "" {
		return fmt.Errorf("cannot resolve a global config directory (set EECO_CONFIG_HOME or HOME)")
	}
	s.Targets = sanitizeTargets(s.Targets)
	if len(s.Targets) == 0 {
		s.Targets = DefaultSelection().Targets
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return fmt.Errorf("selection dir: %w", err)
	}
	out, err := json.MarshalIndent(s, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, append(out, '\n'), 0o644)
}

// LoadSelection reads the active target set, resolving in layers: workspace
// cockpit.json → user-global cockpit.json → DefaultSelection. A missing,
// empty, corrupt, or all-unknown file at one layer falls through to the next
// rather than wedging the tool (mirrors loadLedger). Unknown target names are
// dropped so a stale entry from a newer binary can't break an older one.
func LoadSelection(cfg *config.Config) Selection {
	if s, ok := loadSelectionFile(selectionPath(cfg)); ok {
		return s
	}
	if s, ok := loadSelectionFile(globalSelectionPath()); ok {
		return s
	}
	return DefaultSelection()
}

// loadSelectionFile parses a cockpit.json at path. ok is false when the file
// is absent, empty, unparseable, or declares no known targets.
func loadSelectionFile(path string) (Selection, bool) {
	if path == "" {
		return Selection{}, false
	}
	b, err := os.ReadFile(path)
	if err != nil || len(b) == 0 {
		return Selection{}, false
	}
	var s Selection
	if err := json.Unmarshal(b, &s); err != nil {
		return Selection{}, false
	}
	s.Targets = sanitizeTargets(s.Targets)
	if len(s.Targets) == 0 {
		return Selection{}, false
	}
	return s, true
}

// SaveSelection writes the active target set under the workspace dir, creating
// it if absent. Targets are sanitized (deduped, known-only, order-preserving);
// an empty result falls back to the default so the store is never wedged.
func SaveSelection(cfg *config.Config, s Selection) error {
	s.Targets = sanitizeTargets(s.Targets)
	if len(s.Targets) == 0 {
		s.Targets = DefaultSelection().Targets
	}
	dir := filepath.Dir(selectionPath(cfg))
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("selection dir: %w", err)
	}
	out, err := json.MarshalIndent(s, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(selectionPath(cfg), append(out, '\n'), 0o644)
}

// sanitizeTargets returns the known targets in in, deduplicated and in their
// first-seen order. Unknown or blank names are dropped.
func sanitizeTargets(in []string) []string {
	seen := make(map[string]bool, len(in))
	var out []string
	for _, t := range in {
		t = strings.TrimSpace(t)
		if t == "" || seen[t] {
			continue
		}
		if _, ok := rendererFor(t); !ok {
			continue
		}
		seen[t] = true
		out = append(out, t)
	}
	return out
}