package notes import ( "os" "path/filepath" "strings" "sync" "testing" ) func seed(t *testing.T, dir, rel, content string) string { t.Helper() abs := filepath.Join(dir, rel) if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { t.Fatal(err) } return abs } func read(t *testing.T, abs string) string { t.Helper() b, err := os.ReadFile(abs) if err != nil { t.Fatal(err) } return string(b) } func TestRead_ReturnsVerbatimBytes(t *testing.T) { dir := newBrain(t) const body = "---\ntitle: 'x'\n---\n\nbody with double spaces\r\nand a CRLF line\nno trailing newline" seed(t, dir, "knowledge/verbatim.md", body) got, err := Read(dir, "knowledge/verbatim.md") if err != nil { t.Fatalf("Read: %v", err) } if string(got) != body { t.Errorf("Read returned %q, want the file byte-for-byte", got) } } func TestRead_RefusesNonUTF8RatherThanCorruptingIt(t *testing.T) { // The response is marshalled into a JSON string, and encoding/json // replaces invalid UTF-8 with U+FFFD. Returning it would mean every // old_str the caller derives from that region can never match, and // pasting it back would write the corruption to disk permanently. dir := newBrain(t) seed(t, dir, "knowledge/bad.md", "ok \xff\xfe broken\n") if _, err := Read(dir, "knowledge/bad.md"); err == nil { t.Fatal("Read accepted invalid UTF-8; it must refuse rather than silently substitute") } } func TestEdit_ReplacesExactlyOnceAndLeavesEveryOtherByteAlone(t *testing.T) { dir := newBrain(t) const body = "---\ntitle: 'x'\n---\n\nalpha\nbeta\ngamma\n" abs := seed(t, dir, "knowledge/n.md", body) res, err := Edit(dir, "knowledge/n.md", "beta", "BETA", ScopeNotes) if err != nil { t.Fatalf("Edit: %v", err) } want := strings.Replace(body, "beta", "BETA", 1) if got := read(t, abs); got != want { t.Errorf("file = %q, want %q", got, want) } if res.ContentHash == "" { t.Error("result should carry a content hash for read-after-write confirmation") } } func TestEdit_RefusesAmbiguousAndAbsentMatchesWithoutWriting(t *testing.T) { dir := newBrain(t) const body = "alpha\nalpha\n" abs := seed(t, dir, "knowledge/n.md", body) cases := []struct{ name, old, want string }{ {"two matches", "alpha", "2"}, {"no match", "delta", "0"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := Edit(dir, "knowledge/n.md", tc.old, "x", ScopeNotes) if err == nil { t.Fatal("want a loud refusal") } if !strings.Contains(err.Error(), tc.want) { t.Errorf("error must state the match count, got %v", err) } if got := read(t, abs); got != body { t.Errorf("file changed on a refused edit: %q", got) } }) } } func TestEdit_CountsOverlappingMatches(t *testing.T) { // bytes.Count counts NON-overlapping occurrences: "aa" in "aaa" is 1, so // a Count-based uniqueness check calls an ambiguous edit unique. dir := newBrain(t) seed(t, dir, "knowledge/n.md", "aaa\n") if _, err := Edit(dir, "knowledge/n.md", "aa", "b", ScopeNotes); err == nil { t.Fatal("overlapping matches must be reported as ambiguous") } } func TestEdit_RejectsDegenerateArguments(t *testing.T) { dir := newBrain(t) seed(t, dir, "knowledge/n.md", "alpha\n") if _, err := Edit(dir, "knowledge/n.md", "", "x", ScopeNotes); err == nil { t.Error("empty old_str must be rejected") } if _, err := Edit(dir, "knowledge/n.md", "alpha", "alpha", ScopeNotes); err == nil { t.Error("a no-op edit must be rejected rather than burning a sync cycle") } } func TestEdit_DeletionIsLegal(t *testing.T) { dir := newBrain(t) abs := seed(t, dir, "knowledge/n.md", "keep\ndrop\n") if _, err := Edit(dir, "knowledge/n.md", "drop\n", "", ScopeNotes); err != nil { t.Fatalf("Edit: %v", err) } if got := read(t, abs); got != "keep\n" { t.Errorf("file = %q, want %q", got, "keep\n") } } func TestEdit_ReportsWhenOnlyLineEndingsDiffer(t *testing.T) { // The likeliest real failure: the caller's old_str lost its \r. Never // normalise silently — that breaks byte-identity — but say so, because an // opaque "no match" is unfixable from the caller's side. dir := newBrain(t) seed(t, dir, "knowledge/n.md", "alpha\r\nbeta\r\n") _, err := Edit(dir, "knowledge/n.md", "alpha\nbeta", "x", ScopeNotes) if err == nil { t.Fatal("want a refusal") } if !strings.Contains(err.Error(), "line ending") { t.Errorf("error should point at CRLF, got %v", err) } } func TestEdit_RefusesWhileGitIsMidOperation(t *testing.T) { // brain-sync runs `git merge -X ours origin/main`, and aborts on conflict // with a hard reset. An edit landing inside that window is either erased // or committed as a wrongly-resolved conflict, and the caller was told it // succeeded. dir := newBrain(t) seed(t, dir, "knowledge/n.md", "alpha\n") for _, marker := range []string{"MERGE_HEAD", "index.lock", "REBASE_HEAD"} { t.Run(marker, func(t *testing.T) { p := filepath.Join(dir, ".git", marker) if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } defer func() { _ = os.Remove(p) }() if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err == nil { t.Errorf("edit accepted while %s exists", marker) } }) } } func TestEdit_RefusesAFileHoldingConflictMarkers(t *testing.T) { dir := newBrain(t) seed(t, dir, "knowledge/n.md", "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> origin/main\n") if _, err := Edit(dir, "knowledge/n.md", "ours", "x", ScopeNotes); err == nil { t.Fatal("editing one side of an unresolved conflict must be refused") } } func TestEdit_ConcurrentEditsBothApply(t *testing.T) { // Read-modify-write with no lock loses one update AND reports success for // both. old_str uniqueness does not protect against it: uniqueness is // evaluated against the stale read. dir := newBrain(t) abs := seed(t, dir, "knowledge/n.md", "alpha\nbeta\n") var wg sync.WaitGroup errs := make([]error, 2) for i, pair := range [][2]string{{"alpha", "ALPHA"}, {"beta", "BETA"}} { wg.Add(1) go func(i int, old, new string) { defer wg.Done() _, errs[i] = Edit(dir, "knowledge/n.md", old, new, ScopeNotes) }(i, pair[0], pair[1]) } wg.Wait() for i, err := range errs { if err != nil { t.Fatalf("edit %d failed: %v", i, err) } } if got := read(t, abs); got != "ALPHA\nBETA\n" { t.Errorf("file = %q, want both edits applied", got) } } func TestEdit_LeavesNoTemporaryFileBehind(t *testing.T) { dir := newBrain(t) seed(t, dir, "knowledge/n.md", "alpha\n") if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err != nil { t.Fatal(err) } entries, err := os.ReadDir(filepath.Join(dir, "knowledge")) if err != nil { t.Fatal(err) } for _, e := range entries { if e.Name() != "note.md" && e.Name() != "n.md" { t.Errorf("stray file left in the tree: %q (git add -A would commit it)", e.Name()) } } } func TestEdit_PreservesReadableMode(t *testing.T) { // Other uids write this tree (brain-fix, brain-enrich). os.CreateTemp // yields 0600 and rename preserves it, which would break them silently. dir := newBrain(t) abs := seed(t, dir, "knowledge/n.md", "alpha\n") if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err != nil { t.Fatal(err) } fi, err := os.Stat(abs) if err != nil { t.Fatal(err) } if fi.Mode().Perm() != 0o644 { t.Errorf("mode = %v, want 0644", fi.Mode().Perm()) } } func TestEdit_RefusesNonUTF8Replacement(t *testing.T) { dir := newBrain(t) seed(t, dir, "knowledge/n.md", "alpha\n") if _, err := Edit(dir, "knowledge/n.md", "alpha", "bad \xff", ScopeNotes); err == nil { t.Fatal("new_str with invalid UTF-8 must be refused, not written") } }