a73x

internal/rewriter/rewriter.go

Ref:   Size: 3.2 KiB

package rewriter

import (
	"bufio"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"
)

var ErrConcurrentModification = errors.New("file modified concurrently")

// stampRE matches "@claude" optionally followed by an existing "[fw-XXXXXXXX]" tag.
var stampRE = regexp.MustCompile(`@claude(?:\[fw-[0-9a-f]{8}\])?`)

// StampUUIDs rewrites the file at path, splicing "[fw-XXXXXXXX]" immediately
// after the first untagged "@claude" on each given line. stamps maps line
// number (1-indexed) → UUID (e.g. "fw-a1b2c3d4"). The write is atomic via
// tmpfile + rename. File mode is preserved.
//
// Aborts with ErrConcurrentModification if the file's mtime changed between
// read and rename (someone — likely the user's editor — wrote concurrently).
// In that case, the rewrite is dropped and the marker stays untagged for a
// retry on the next save event.
func StampUUIDs(path string, stamps map[int]string) error {
	info, err := os.Stat(path)
	if err != nil {
		return err
	}
	mtimeBefore := info.ModTime()

	in, err := os.Open(path)
	if err != nil {
		return err
	}
	defer in.Close()

	dir := filepath.Dir(path)
	tmp, err := os.CreateTemp(dir, ".filewatch-*.tmp")
	if err != nil {
		return err
	}
	tmpPath := tmp.Name()
	cleanup := func() {
		tmp.Close()
		os.Remove(tmpPath)
	}

	sc := bufio.NewScanner(in)
	sc.Buffer(make([]byte, 1024*1024), 1024*1024)
	bw := bufio.NewWriter(tmp)
	line := 0
	for sc.Scan() {
		line++
		text := sc.Text()
		if uuid, ok := stamps[line]; ok {
			text = stampLine(text, uuid)
		}
		if _, err := bw.WriteString(text); err != nil {
			cleanup()
			return err
		}
		if _, err := bw.WriteString("\n"); err != nil {
			cleanup()
			return err
		}
	}
	if err := sc.Err(); err != nil {
		cleanup()
		return err
	}
	if err := bw.Flush(); err != nil {
		cleanup()
		return err
	}
	if err := tmp.Chmod(info.Mode().Perm()); err != nil {
		cleanup()
		return err
	}
	if err := tmp.Close(); err != nil {
		os.Remove(tmpPath)
		return err
	}

	return atomicReplace(path, tmpPath, info.Mode().Perm(), mtimeBefore)
}

// atomicReplace stat-checks path against mtimeBefore. If the file was modified
// concurrently, returns ErrConcurrentModification and removes tmpPath. Otherwise
// renames tmpPath over path. The mode argument is currently unused (the caller
// already chmods tmpPath before close), but is included to keep this helper's
// signature self-contained for direct testing.
func atomicReplace(path, tmpPath string, mode os.FileMode, mtimeBefore time.Time) error {
	info, err := os.Stat(path)
	if err != nil {
		os.Remove(tmpPath)
		return err
	}
	if !info.ModTime().Equal(mtimeBefore) {
		os.Remove(tmpPath)
		return ErrConcurrentModification
	}
	if err := os.Rename(tmpPath, path); err != nil {
		os.Remove(tmpPath)
		return fmt.Errorf("rename: %w", err)
	}
	_ = mode // currently unused; reserved for future modes that need post-rename Chmod
	return nil
}

// stampLine splices "[uuid]" after the first untagged "@claude" on the line.
// Already-tagged occurrences are left untouched.
func stampLine(text, uuid string) string {
	done := false
	return stampRE.ReplaceAllStringFunc(text, func(m string) string {
		if done || strings.Contains(m, "[fw-") {
			return m
		}
		done = true
		return "@claude[" + uuid + "]"
	})
}