ajhahn.de
← eeco
Go 284 lines
package hooks

import (
	"bytes"
	"os"
	"path/filepath"
	"strings"
	"testing"

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

// sessionCfg builds a session-files-only cfg for delivery tests.
func sessionCfg(t *testing.T, files ...string) *config.Config {
	t.Helper()
	root := t.TempDir()
	if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
		t.Fatal(err)
	}
	ws := filepath.Join(root, ".eeco")
	if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
		t.Fatal(err)
	}
	return &config.Config{
		RepoRoot:           root,
		WorkspaceName:      ".eeco",
		Workspace:          ws,
		SessionFiles:       files,
		PreCommitWorkflows: config.DefaultPreCommitWorkflows(),
		PostMergeWorkflows: config.DefaultPostMergeWorkflows(),
	}
}

func TestEnableSessionFiles_CreatesMissingFile(t *testing.T) {
	cfg := sessionCfg(t, "CLAUDE.md")
	records, errs := enableSessionFiles(cfg)
	if len(errs) != 0 {
		t.Fatalf("errs: %v", errs)
	}
	if len(records) != 1 {
		t.Fatalf("records = %d, want 1", len(records))
	}
	if !records[0].Created {
		t.Errorf("Created = false, want true (file did not exist)")
	}
	b, err := os.ReadFile(filepath.Join(cfg.RepoRoot, "CLAUDE.md"))
	if err != nil {
		t.Fatal(err)
	}
	if !strings.HasPrefix(string(b), sessionStartMarker) {
		t.Errorf("file does not start with start marker:\n%s", b)
	}
	if !strings.Contains(string(b), sessionEndMarker) {
		t.Errorf("file missing end marker:\n%s", b)
	}
	if !strings.Contains(string(b), sessionBlockHeader) {
		t.Errorf("file missing managed header:\n%s", b)
	}
}

func TestEnableSessionFiles_AppendsToExisting(t *testing.T) {
	cfg := sessionCfg(t, "CLAUDE.md")
	path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
	original := "# Project notes\n\nSome content here.\n"
	if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
		t.Fatal(err)
	}
	records, errs := enableSessionFiles(cfg)
	if len(errs) != 0 {
		t.Fatalf("errs: %v", errs)
	}
	if records[0].Created {
		t.Errorf("Created = true, want false")
	}
	b, _ := os.ReadFile(path)
	got := string(b)
	if !strings.HasPrefix(got, original) {
		t.Errorf("original content not preserved at file start:\n%s", got)
	}
	idx := strings.Index(got, sessionStartMarker)
	if idx <= 0 {
		t.Errorf("start marker missing or at file start:\n%s", got)
	}
}

func TestEnableSessionFiles_ReplacesExistingBlock(t *testing.T) {
	cfg := sessionCfg(t, "CLAUDE.md")
	path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
	original := "# Top\n\n" + sessionStartMarker + "\nOLD\n" + sessionEndMarker + "\n\n# Bottom\n"
	if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
		t.Fatal(err)
	}
	if _, errs := enableSessionFiles(cfg); len(errs) != 0 {
		t.Fatalf("errs: %v", errs)
	}
	b, _ := os.ReadFile(path)
	got := string(b)
	if strings.Contains(got, "OLD") {
		t.Errorf("old block content not replaced:\n%s", got)
	}
	if !strings.Contains(got, "# Top") || !strings.Contains(got, "# Bottom") {
		t.Errorf("surrounding content lost:\n%s", got)
	}
	if strings.Count(got, sessionStartMarker) != 1 || strings.Count(got, sessionEndMarker) != 1 {
		t.Errorf("marker count wrong: starts=%d ends=%d\n%s",
			strings.Count(got, sessionStartMarker),
			strings.Count(got, sessionEndMarker), got)
	}
}

func TestEnableSessionFiles_IgnoresFencedMarkers(t *testing.T) {
	cfg := sessionCfg(t, "DOC.md")
	path := filepath.Join(cfg.RepoRoot, "DOC.md")
	// Documentation that mentions the marker syntax inside a fenced
	// code block must not be interpreted as a real eeco block.
	original := "# Doc\n\n```\n" + sessionStartMarker + "\n```\n"
	if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
		t.Fatal(err)
	}
	records, errs := enableSessionFiles(cfg)
	if len(errs) != 0 {
		t.Fatalf("errs: %v", errs)
	}
	if records[0].Created {
		t.Errorf("Created = true, want false")
	}
	b, _ := os.ReadFile(path)
	got := string(b)
	if !strings.Contains(got, "```\n"+sessionStartMarker+"\n```") {
		t.Errorf("fenced marker mention was modified:\n%s", got)
	}
	if strings.Count(got, sessionEndMarker) != 1 {
		t.Errorf("end marker should appear exactly once at EOF, got:\n%s", got)
	}
}

func TestEnableSessionFiles_Idempotent(t *testing.T) {
	cfg := sessionCfg(t, "CLAUDE.md")
	path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
	if _, errs := enableSessionFiles(cfg); len(errs) != 0 {
		t.Fatal(errs)
	}
	b1, _ := os.ReadFile(path)
	if _, errs := enableSessionFiles(cfg); len(errs) != 0 {
		t.Fatal(errs)
	}
	b2, _ := os.ReadFile(path)
	if !bytes.Equal(b1, b2) {
		t.Errorf("second enable produced different bytes:\nfirst=%q\nsecond=%q", b1, b2)
	}
}

func TestEnableSessionFiles_AcceptsAbsolutePath(t *testing.T) {
	abs := filepath.Join(t.TempDir(), "rules.md")
	cfg := sessionCfg(t, abs)
	records, errs := enableSessionFiles(cfg)
	if len(errs) != 0 {
		t.Fatalf("errs: %v", errs)
	}
	if records[0].Path != abs {
		t.Errorf("path = %q, want %q", records[0].Path, abs)
	}
	if _, err := os.Stat(abs); err != nil {
		t.Errorf("absolute target not written: %v", err)
	}
}

func TestEnableSessionFiles_RejectsNestedMarkers(t *testing.T) {
	cfg := sessionCfg(t, "CLAUDE.md")
	path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
	bad := sessionStartMarker + "\n" + sessionStartMarker + "\n" + sessionEndMarker + "\n"
	if err := os.WriteFile(path, []byte(bad), 0o644); err != nil {
		t.Fatal(err)
	}
	_, errs := enableSessionFiles(cfg)
	if len(errs) != 1 {
		t.Fatalf("errs = %v, want exactly one", errs)
	}
	b, _ := os.ReadFile(path)
	if string(b) != bad {
		t.Errorf("file was modified despite malformed markers:\n%s", b)
	}
}

func TestDisableSessionFiles_RemovesEecoCreatedFile(t *testing.T) {
	cfg := sessionCfg(t, "CLAUDE.md")
	path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
	records, errs := enableSessionFiles(cfg)
	if len(errs) != 0 {
		t.Fatal(errs)
	}
	notes, derrs := disableSessionFiles(records)
	if len(derrs) != 0 || len(notes) != 0 {
		t.Fatalf("disable errs=%v notes=%v", derrs, notes)
	}
	if _, err := os.Stat(path); !os.IsNotExist(err) {
		t.Errorf("file still exists after disable: %v", err)
	}
}

func TestDisableSessionFiles_RestoresPreEnableContent(t *testing.T) {
	cfg := sessionCfg(t, "CLAUDE.md")
	path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
	original := "# Project\n\nKept content.\n"
	if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
		t.Fatal(err)
	}
	records, _ := enableSessionFiles(cfg)
	notes, derrs := disableSessionFiles(records)
	if len(derrs) != 0 || len(notes) != 0 {
		t.Fatalf("disable errs=%v notes=%v", derrs, notes)
	}
	b, _ := os.ReadFile(path)
	if string(b) != original {
		t.Errorf("post-disable content differs:\nwant=%q\ngot =%q", original, b)
	}
}

func TestDisableSessionFiles_LeavesForeignEditedBlockUntouched(t *testing.T) {
	cfg := sessionCfg(t, "CLAUDE.md")
	path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
	records, _ := enableSessionFiles(cfg)
	// Operator hand-edits the block content between markers.
	b, _ := os.ReadFile(path)
	tampered := bytes.Replace(b, []byte(sessionBlockHeader), []byte("<!-- hand-edited -->"), 1)
	if err := os.WriteFile(path, tampered, 0o644); err != nil {
		t.Fatal(err)
	}
	notes, derrs := disableSessionFiles(records)
	if len(derrs) != 0 {
		t.Fatalf("derrs: %v", derrs)
	}
	if len(notes) != 1 || !strings.Contains(notes[0], "edited since install") {
		t.Errorf("expected one foreign-edit note, got: %v", notes)
	}
	// The file is left exactly as the operator edited it.
	post, _ := os.ReadFile(path)
	if !bytes.Equal(post, tampered) {
		t.Errorf("file modified despite foreign edit detection")
	}
}

func TestRefreshSessionFiles_UpdatesBlock(t *testing.T) {
	cfg := sessionCfg(t, "CLAUDE.md")
	path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
	if _, errs := enableSessionFiles(cfg); len(errs) != 0 {
		t.Fatal(errs)
	}
	// Simulate state drift: write a docs file the auto-detect picks up.
	if err := os.WriteFile(filepath.Join(cfg.RepoRoot, "README.md"), []byte("# x\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	records, errs := refreshSessionFiles(cfg)
	if len(errs) != 0 {
		t.Fatal(errs)
	}
	b, _ := os.ReadFile(path)
	if !strings.Contains(string(b), "README.md") {
		t.Errorf("refresh did not pick up the new README.md mention:\n%s", b)
	}
	if records[0].SHA256 == "" {
		t.Errorf("refresh did not record a sha")
	}
}

func TestFindSessionBlock_NoMarkers(t *testing.T) {
	src := []byte("# plain doc\n\nno markers here\n")
	_, _, found, err := findSessionBlock(src)
	if err != nil || found {
		t.Errorf("found=%v err=%v, want found=false err=nil", found, err)
	}
}

func TestRenderSessionBlock_DeterministicNewlines(t *testing.T) {
	lf := renderSessionBlock("hello\n", "\n")
	crlf := renderSessionBlock("hello\n", "\r\n")
	if strings.Contains(lf, "\r") {
		t.Errorf("LF render contained CR:\n%q", lf)
	}
	if !strings.Contains(crlf, "\r\n") {
		t.Errorf("CRLF render missing CRLF:\n%q", crlf)
	}
}