ajhahn.de
← eeco
Go 216 lines
package hooks

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

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

// newMachineryCfg builds a config with a per-user dir (so the machinery has
// a <UserDir>/.claude/settings.json to write) and a workspace for the
// ledger. The .claude dir is intentionally NOT pre-created, exercising the
// MkdirAll-on-enable path.
func newMachineryCfg(t *testing.T) *config.Config {
	t.Helper()
	root := t.TempDir()
	userDir := filepath.Join(root, "tester")
	ws := filepath.Join(userDir, ".eeco")
	if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
		t.Fatal(err)
	}
	return &config.Config{
		RepoRoot:      root,
		UserDir:       userDir,
		WorkspaceName: ".eeco",
		Workspace:     ws,
	}
}

func TestCockpitMachinery_EnableInstallsGuardGroup(t *testing.T) {
	cfg := newMachineryCfg(t)
	if _, err := EnableCockpitMachinery(cfg); err != nil {
		t.Fatalf("EnableCockpitMachinery: %v", err)
	}
	path := cockpitSettingsPath(cfg)
	b, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("settings.json not written: %v", err)
	}
	root := map[string]any{}
	if err := json.Unmarshal(b, &root); err != nil {
		t.Fatalf("settings.json not valid JSON: %v", err)
	}
	if !machineryFullyInstalled(root) {
		t.Errorf("not all machinery groups present after enable:\n%s", b)
	}
	// All four event groups land (PreToolUse / SessionStart / Stop / PostToolUse).
	for _, ev := range []string{"PreToolUse", "SessionStart", "Stop", "PostToolUse"} {
		if len(eventGroups(root, ev)) == 0 {
			t.Errorf("event %s group missing after enable", ev)
		}
	}
	// Ledger records the install.
	l, _ := loadLedger(cfg)
	if !l.CockpitMachinery.Installed {
		t.Error("ledger CockpitMachinery.Installed = false after enable")
	}
}

func TestCockpitMachinery_EnableIdempotent(t *testing.T) {
	cfg := newMachineryCfg(t)
	if _, err := EnableCockpitMachinery(cfg); err != nil {
		t.Fatal(err)
	}
	path := cockpitSettingsPath(cfg)
	first, _ := os.ReadFile(path)
	msg, err := EnableCockpitMachinery(cfg)
	if err != nil {
		t.Fatal(err)
	}
	second, _ := os.ReadFile(path)
	if string(first) != string(second) {
		t.Error("settings.json changed on a no-op re-enable")
	}
	if msg == "" {
		t.Error("expected an already-enabled message")
	}
}

func TestCockpitMachinery_OffRestoresAndPreservesForeign(t *testing.T) {
	cfg := newMachineryCfg(t)
	path := cockpitSettingsPath(cfg)
	// A pre-existing settings file with a foreign PreToolUse group + an
	// unknown key; `off` must restore it byte-for-byte after on/off.
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	original := `{
  "model": "opus",
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "some-other-tool guard"
          }
        ]
      }
    ]
  }
}
`
	if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
		t.Fatal(err)
	}

	if _, err := EnableCockpitMachinery(cfg); err != nil {
		t.Fatalf("enable: %v", err)
	}
	if _, err := DisableCockpitMachinery(cfg); err != nil {
		t.Fatalf("disable: %v", err)
	}

	b, _ := os.ReadFile(path)
	root := map[string]any{}
	if err := json.Unmarshal(b, &root); err != nil {
		t.Fatalf("settings.json not valid JSON after off: %v", err)
	}
	if machineryInstalled(root) {
		t.Error("machinery group survived disable")
	}
	// The foreign group + unknown key must remain.
	groups := eventGroups(root, "PreToolUse")
	if len(groups) != 1 {
		t.Fatalf("foreign PreToolUse group count = %d, want 1", len(groups))
	}
	if root["model"] != "opus" {
		t.Errorf("unknown key not preserved: model=%v", root["model"])
	}
}

func TestCockpitMachinery_OffRemovesFileItCreated(t *testing.T) {
	cfg := newMachineryCfg(t)
	path := cockpitSettingsPath(cfg)
	if _, err := os.Stat(path); !os.IsNotExist(err) {
		t.Fatalf("settings file should be absent before enable, stat err=%v", err)
	}
	if _, err := EnableCockpitMachinery(cfg); err != nil {
		t.Fatalf("enable: %v", err)
	}
	if _, err := DisableCockpitMachinery(cfg); err != nil {
		t.Fatalf("disable: %v", err)
	}
	// eeco created the file and our group was its only content → absent
	// again (byte-for-byte restore), not a leftover {} shell.
	if _, err := os.Stat(path); !os.IsNotExist(err) {
		t.Errorf("settings file eeco created should be removed on off, stat err=%v", err)
	}
}

func TestCockpitMachinery_DisableNotEnabled(t *testing.T) {
	cfg := newMachineryCfg(t)
	msg, err := DisableCockpitMachinery(cfg)
	if err != nil {
		t.Fatal(err)
	}
	if msg != "cockpit machinery not enabled" {
		t.Errorf("disable-not-enabled msg = %q", msg)
	}
}

func TestCockpitMachinery_StatusReflectsDisk(t *testing.T) {
	cfg := newMachineryCfg(t)
	lines := CockpitMachineryStatus(cfg)
	if len(lines) == 0 || !strings.Contains(lines[0], "off") {
		t.Errorf("status before enable = %v, want off", lines)
	}
	if _, err := EnableCockpitMachinery(cfg); err != nil {
		t.Fatal(err)
	}
	lines = CockpitMachineryStatus(cfg)
	if !strings.Contains(lines[0], "on") {
		t.Errorf("status after enable = %v, want on", lines)
	}
	// One header line + one line per managed event, every event reading "on".
	if len(lines) != 1+len(machineryHookSet()) {
		t.Fatalf("status line count = %d, want %d", len(lines), 1+len(machineryHookSet()))
	}
	for _, ev := range []string{"PreToolUse", "SessionStart", "Stop", "PostToolUse"} {
		found := false
		for _, ln := range lines[1:] {
			if strings.Contains(ln, ev) && strings.Contains(ln, "on") {
				found = true
			}
		}
		if !found {
			t.Errorf("status missing an on-line for event %s:\n%v", ev, lines)
		}
	}
}

func TestCockpitMachinery_Refresh(t *testing.T) {
	cfg := newMachineryCfg(t)
	// Not enabled → a clean no-op message, nothing touched.
	if msg, err := RefreshCockpitMachinery(cfg); err != nil || !strings.Contains(msg, "not enabled") {
		t.Errorf("refresh before enable = (%q, %v), want a not-enabled no-op", msg, err)
	}
	if _, err := EnableCockpitMachinery(cfg); err != nil {
		t.Fatal(err)
	}
	// Freshly enabled → commands already embed selfPath(), so refresh is a
	// no-op "already current".
	msg, err := RefreshCockpitMachinery(cfg)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(msg, "already current") {
		t.Errorf("refresh of a freshly-enabled machinery = %q, want already-current", msg)
	}
}