ajhahn.de
← eeco
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
}