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(""), 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) } }