Go 378 lines
package hooks
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/ajhahnde/eeco/internal/config"
)
// Marker spellings for the file-based session-start delivery channel.
// Mirrors the `<!-- eeco:archive:... -->` spellings from
// `eeco docs compact`. HTML-comment so a Markdown reader (and
// most assistants reading a CLAUDE.md / AGENTS.md / .cursorrules) hides
// them from the rendered prose while still treating the body between
// them as content.
const (
sessionStartMarker = "<!-- eeco:session:start -->"
sessionEndMarker = "<!-- eeco:session:end -->"
)
// sessionBlockHeader is a single HTML-comment line placed inside the
// block (right after the start marker) so an operator reading the file
// sees what the block is and how to remove it. Kept terse.
const sessionBlockHeader = "<!-- Managed by eeco. Removed by `eeco hooks session-start off`; refreshed by `eeco hooks session-start refresh`. -->"
// fileRecord is one managed file's reversibility state for the
// session-start file delivery channel.
type fileRecord struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
Created bool `json:"created,omitempty"`
}
// resolveSessionFile expands a session_files entry against the repo
// root. Repo-relative entries are joined with cfg.RepoRoot; absolute
// entries are accepted verbatim. The config parser already rejected
// `..` traversal and whitespace for repo-relative entries.
func resolveSessionFile(cfg *config.Config, entry string) string {
if filepath.IsAbs(entry) {
return entry
}
return filepath.Join(cfg.RepoRoot, entry)
}
// renderSessionBlock builds the marker-delimited block from emitted
// content. The exact bytes are deterministic for a given input so a
// repeated enable/refresh with the same project state is a byte-for-byte
// no-op (idempotency).
func renderSessionBlock(emitted string, newline string) string {
var b strings.Builder
b.WriteString(sessionStartMarker)
b.WriteString(newline)
b.WriteString(sessionBlockHeader)
b.WriteString(newline)
if emitted != "" {
// Emit may end with a trailing newline; strip then re-add a
// single newline so the block ends consistently.
body := strings.TrimRight(emitted, "\r\n")
b.WriteString(body)
b.WriteString(newline)
}
b.WriteString(sessionEndMarker)
b.WriteString(newline)
return b.String()
}
// emitSessionContent renders what the file-delivery block should
// contain: the same text the JSON-channel command (`eeco hooks
// session-emit`) prints. When every Emit block is empty (no docs, no
// mailbox content, no queue items) the returned string is empty and the
// caller still writes the block — a minimal block with no content body
// is a valid signal to the operator that delivery is wired.
func emitSessionContent(cfg *config.Config) string {
var buf bytes.Buffer
Emit(cfg, &buf)
return buf.String()
}
// findSessionBlock walks src once and returns the byte offsets of the
// start-marker line and the end-marker line for the single eeco
// session-start block. Markers inside fenced code blocks are ignored.
// found=false when no markers (or only one) are present at top level.
// An unmatched/nested marker pair is reported as an error so the
// caller can refuse to write rather than guess.
func findSessionBlock(src []byte) (startByte, endByte int, found bool, err error) {
lines := splitLinesKeepEOL(src)
inFence := false
startIdx, endIdx := -1, -1
for i, line := range lines {
trimmed := strings.TrimRight(line, "\r\n")
stripped := strings.TrimLeft(trimmed, " \t")
if strings.HasPrefix(stripped, "```") || strings.HasPrefix(stripped, "~~~") {
inFence = !inFence
continue
}
if inFence {
continue
}
marker := strings.TrimSpace(trimmed)
switch marker {
case sessionStartMarker:
if startIdx != -1 {
return 0, 0, false, fmt.Errorf("session-start file: nested start marker at line %d (previous still open at line %d)", i+1, startIdx+1)
}
startIdx = i
case sessionEndMarker:
if startIdx == -1 {
return 0, 0, false, fmt.Errorf("session-start file: end marker at line %d with no matching start", i+1)
}
endIdx = i
// Compute byte offsets by summing line lengths.
startByte = 0
for j := 0; j < startIdx; j++ {
startByte += len(lines[j])
}
endByte = startByte
for j := startIdx; j <= endIdx; j++ {
endByte += len(lines[j])
}
return startByte, endByte, true, nil
}
}
if startIdx != -1 {
return 0, 0, false, fmt.Errorf("session-start file: start marker at line %d with no matching end", startIdx+1)
}
return 0, 0, false, nil
}
// writeFileAtomic mirrors writeJSONAtomic's discipline — same-directory
// temp + rename — but takes raw bytes so it serves the file-delivery
// channel that does not produce JSON.
func writeFileAtomic(path string, content []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("ensure dir %s: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, ".eeco-session-*")
if err != nil {
return fmt.Errorf("temp file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, werr := tmp.Write(content); werr != nil {
tmp.Close()
return fmt.Errorf("write temp file: %w", werr)
}
if cerr := tmp.Close(); cerr != nil {
return fmt.Errorf("close temp file: %w", cerr)
}
if perm == 0 {
perm = 0o644
}
if cherr := os.Chmod(tmpName, perm); cherr != nil {
return fmt.Errorf("chmod temp file: %w", cherr)
}
if rerr := os.Rename(tmpName, path); rerr != nil {
return fmt.Errorf("replace file: %w", rerr)
}
return nil
}
// dominantNewlineRaw picks the newline style used most often in src,
// with a "\n" fallback for empty or newline-less input.
func dominantNewlineRaw(src []byte) string {
crlf := bytes.Count(src, []byte("\r\n"))
lf := bytes.Count(src, []byte("\n")) - crlf
if crlf > lf {
return "\r\n"
}
return "\n"
}
// applySessionBlock returns the bytes of path after enabling/refreshing
// the marker block, plus per-file metadata: the file existed before this
// write, and the sha256 of the new block content alone. When the file
// did not exist, a new file is created containing only the block.
type applyResult struct {
NewBytes []byte
Block string
Perm os.FileMode
Existed bool
Newline string
}
func applySessionBlock(path string, emitted string) (applyResult, error) {
var res applyResult
info, statErr := os.Stat(path)
if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
return res, fmt.Errorf("stat %s: %w", path, statErr)
}
if statErr == nil && info.IsDir() {
return res, fmt.Errorf("%s is a directory", path)
}
var existing []byte
if statErr == nil {
res.Existed = true
res.Perm = info.Mode().Perm()
b, rerr := os.ReadFile(path)
if rerr != nil {
return res, fmt.Errorf("read %s: %w", path, rerr)
}
existing = b
} else {
res.Perm = 0o644
}
res.Newline = dominantNewlineRaw(existing)
res.Block = renderSessionBlock(emitted, res.Newline)
if !res.Existed {
res.NewBytes = []byte(res.Block)
return res, nil
}
startByte, endByte, found, ferr := findSessionBlock(existing)
if ferr != nil {
return res, ferr
}
if found {
var buf bytes.Buffer
buf.Write(existing[:startByte])
buf.WriteString(res.Block)
buf.Write(existing[endByte:])
res.NewBytes = buf.Bytes()
return res, nil
}
// No block present yet. Append at EOF, guaranteeing a blank line
// between any prior content and the new block.
var buf bytes.Buffer
buf.Write(existing)
if len(existing) > 0 {
if !bytes.HasSuffix(existing, []byte("\n")) {
buf.WriteString(res.Newline)
}
buf.WriteString(res.Newline)
}
buf.WriteString(res.Block)
res.NewBytes = buf.Bytes()
return res, nil
}
// enableSessionFiles materialises the marker block in every configured
// session_files entry. Each entry's outcome is independent: a per-file
// failure is returned in errs but does not abort the others. A path that
// existed before this write is preserved (block replaced in place when
// present, appended at EOF otherwise); a fresh file is created with
// only the block content.
func enableSessionFiles(cfg *config.Config) (records []fileRecord, errs []error) {
emitted := emitSessionContent(cfg)
for _, entry := range cfg.SessionFiles {
path := resolveSessionFile(cfg, entry)
res, err := applySessionBlock(path, emitted)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", entry, err))
continue
}
if err := writeFileAtomic(path, res.NewBytes, res.Perm); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", entry, err))
continue
}
records = append(records, fileRecord{
Path: path,
SHA256: sha256hex([]byte(res.Block)),
Created: !res.Existed,
})
}
return records, errs
}
// refreshSessionFiles re-renders the marker block in every entry. The
// outcome shape mirrors enableSessionFiles; a path that has lost its
// block (operator deleted the markers, or the file is gone) is treated
// the same as on enable — block re-inserted, or file re-created when
// missing.
func refreshSessionFiles(cfg *config.Config) (records []fileRecord, errs []error) {
return enableSessionFiles(cfg)
}
// disableSessionFiles removes the marker block from every recorded path
// that still carries an eeco-written block. A file whose block has been
// hand-edited (sha mismatch) is left untouched and reported in notes;
// a file whose only content was the block is deleted, restoring the
// pre-enable state.
func disableSessionFiles(records []fileRecord) (notes []string, errs []error) {
for _, rec := range records {
existing, rerr := os.ReadFile(rec.Path)
if errors.Is(rerr, os.ErrNotExist) {
continue
}
if rerr != nil {
errs = append(errs, fmt.Errorf("%s: %w", rec.Path, rerr))
continue
}
startByte, endByte, found, ferr := findSessionBlock(existing)
if ferr != nil {
notes = append(notes, fmt.Sprintf("%s: %v — left untouched", rec.Path, ferr))
continue
}
if !found {
continue
}
blockBytes := existing[startByte:endByte]
if rec.SHA256 != "" && sha256hex(blockBytes) != rec.SHA256 {
notes = append(notes, fmt.Sprintf("%s: session block edited since install — left untouched", rec.Path))
continue
}
head := append([]byte{}, existing[:startByte]...)
tail := existing[endByte:]
// When the block was at EOF, the bytes we kept ended with the
// blank-line separator we inserted on enable. Normalise to a
// single trailing newline (or nothing, when head is empty); the
// in-middle case keeps head+tail byte-identical to the
// pre-enable bytes.
var remaining []byte
if len(tail) == 0 {
head = bytes.TrimRight(head, "\r\n")
if len(head) > 0 {
head = append(head, '\n')
}
remaining = head
} else {
remaining = append(head, tail...)
}
if rec.Created && isOnlyWhitespace(remaining) {
if err := os.Remove(rec.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("%s: %w", rec.Path, err))
}
continue
}
perm := os.FileMode(0o644)
if info, serr := os.Stat(rec.Path); serr == nil {
perm = info.Mode().Perm()
}
if err := writeFileAtomic(rec.Path, remaining, perm); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", rec.Path, err))
}
}
return notes, errs
}
// isOnlyWhitespace reports whether b consists solely of whitespace
// characters.
func isOnlyWhitespace(b []byte) bool {
for _, c := range b {
switch c {
case ' ', '\t', '\r', '\n':
continue
default:
return false
}
}
return true
}
// splitLinesKeepEOL returns the lines of src with the trailing newline
// (LF or CRLF) preserved on each. An unterminated final line is
// returned as-is. Mirrors internal/docs/compact.go's helper.
func splitLinesKeepEOL(src []byte) []string {
var lines []string
for len(src) > 0 {
i := bytes.IndexByte(src, '\n')
if i < 0 {
lines = append(lines, string(src))
break
}
lines = append(lines, string(src[:i+1]))
src = src[i+1:]
}
return lines
}