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)
}
}