a73x

acf62ebc

feat(rewriter): atomic UUID stamping with mode preservation

a73x   2026-04-29 05:49

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff --git a/internal/rewriter/rewriter.go b/internal/rewriter/rewriter.go
new file mode 100644
index 0000000..2fab15b
--- /dev/null
+++ b/internal/rewriter/rewriter.go
@@ -0,0 +1,116 @@
package rewriter

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

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
	}

	// mtime check before atomic rename (deterministic test for this comes in Task 7)
	info2, err := os.Stat(path)
	if err != nil {
		os.Remove(tmpPath)
		return err
	}
	if !info2.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)
	}
	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 + "]"
	})
}
diff --git a/internal/rewriter/rewriter_test.go b/internal/rewriter/rewriter_test.go
new file mode 100644
index 0000000..d60835d
--- /dev/null
+++ b/internal/rewriter/rewriter_test.go
@@ -0,0 +1,65 @@
package rewriter

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func TestStampUUIDsInsertsTagAfterClaude(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "foo.go")
	original := "package foo\n\n// @claude write a test\nfunc Foo() {}\n"
	if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
		t.Fatal(err)
	}

	err := StampUUIDs(path, map[int]string{3: "fw-a1b2c3d4"})
	if err != nil {
		t.Fatalf("StampUUIDs: %v", err)
	}

	got, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	want := "package foo\n\n// @claude[fw-a1b2c3d4] write a test\nfunc Foo() {}\n"
	if string(got) != want {
		t.Errorf("got:\n%s\nwant:\n%s", got, want)
	}
}

func TestStampUUIDsPreservesIndentation(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "foo.go")
	original := "func F() {\n    // @claude refactor\n}\n"
	if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
		t.Fatal(err)
	}

	if err := StampUUIDs(path, map[int]string{2: "fw-deadbeef"}); err != nil {
		t.Fatal(err)
	}
	got, _ := os.ReadFile(path)
	if !strings.Contains(string(got), "    // @claude[fw-deadbeef] refactor") {
		t.Errorf("indentation not preserved:\n%s", got)
	}
}

func TestStampUUIDsBlockComment(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "x.html")
	original := "<!-- @claude add aria -->\n<div></div>\n"
	if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := StampUUIDs(path, map[int]string{1: "fw-12345678"}); err != nil {
		t.Fatal(err)
	}
	got, _ := os.ReadFile(path)
	want := "<!-- @claude[fw-12345678] add aria -->\n<div></div>\n"
	if string(got) != want {
		t.Errorf("got:\n%s\nwant:\n%s", got, want)
	}
}