package tools import ( "context" "os" "path/filepath" "strings" "testing" ) // editIn runs the edit tool against a temp file. It returns the resulting file // content and the tool error. func editIn(t *testing.T, content, input string) (string, error) { t.Helper() dir := t.TempDir() p := filepath.Join(dir, "f.txt") if err := os.WriteFile(p, []byte(content), 0o644); err != nil { t.Fatal(err) } reg := NewRegistry() for _, tl := range BuiltinIn(dir, NewFence(), nil) { if err := reg.Register(tl); err != nil { t.Fatal(err) } } e, _ := reg.Get("edit") input = strings.ReplaceAll(input, "PATH", strings.ReplaceAll(p, `\`, `\\`)) if _, err := e.Run(context.Background(), input); err != nil { return "", err } got, err := os.ReadFile(p) if err != nil { t.Fatal(err) } return string(got), nil } // TestEditRequiresUniqueMatch:多个匹配默认必须报错,不能像以前那样静默改第一个。 func TestEditRequiresUniqueMatch(t *testing.T) { _, err := editIn(t, "foo\nbar\nfoo\n", `{"path":"PATH","old_string":"foo","new_string":"x"}`) if err == nil { t.Fatal("multiple matches must fail without replace_all") } if !strings.Contains(err.Error(), "2 times") || !strings.Contains(err.Error(), "replace_all") { t.Fatalf("error should name the count and the escape hatch, got %q", err) } } func TestEditReplaceAll(t *testing.T) { got, err := editIn(t, "foo\nbar\nfoo\n", `{"path":"PATH","old_string":"foo","new_string":"x","replace_all":true}`) if err != nil { t.Fatal(err) } if got != "x\nbar\nx\n" { t.Fatalf("got %q", got) } } func TestEditUniqueReplacement(t *testing.T) { got, err := editIn(t, "alpha beta gamma\n", `{"path":"PATH","old_string":"beta","new_string":"B"}`) if err != nil { t.Fatal(err) } if got != "alpha B gamma\n" { t.Fatalf("got %q", got) } } // TestEditNormalizesTypographicCharacters:模型从网页复制的弯引号/破折号, // 用 ASCII 也能命中;写回后文件里是 new_string 的原始字节。 func TestEditNormalizesTypographicCharacters(t *testing.T) { got, err := editIn(t, "say “hello” — now\n", `{"path":"PATH","old_string":"say \"hello\" - now","new_string":"say \"bye\" - later"}`) if err != nil { t.Fatal(err) } if got != "say \"bye\" - later\n" { t.Fatalf("got %q", got) } } // TestEditIgnoresTrailingWhitespace:行尾多/少空格时退到逐行匹配, // 且替换后不把旧行的多余空白带回来。 func TestEditIgnoresTrailingWhitespace(t *testing.T) { got, err := editIn(t, "a \nb\t\nc\n", `{"path":"PATH","old_string":"a\nb\n","new_string":"x\ny\n"}`) if err != nil { t.Fatal(err) } if got != "x\ny\nc\n" { t.Fatalf("got %q", got) } } // TestEditPreservesCRLF:命中 CRLF 区域时,LF 风格的 new_string 要转成 CRLF。 func TestEditPreservesCRLF(t *testing.T) { got, err := editIn(t, "one\r\ntwo\r\nthree\r\n", `{"path":"PATH","old_string":"one\ntwo","new_string":"1\n2"}`) if err != nil { t.Fatal(err) } if got != "1\r\n2\r\nthree\r\n" { t.Fatalf("got %q", got) } } func TestEditNotFound(t *testing.T) { _, err := editIn(t, "abc\n", `{"path":"PATH","old_string":"zzz","new_string":"x"}`) if err == nil || !strings.Contains(err.Error(), "not found") { t.Fatalf("want not-found error, got %v", err) } } func TestEditRejectsEmptyOldString(t *testing.T) { _, err := editIn(t, "abc\n", `{"path":"PATH","old_string":"","new_string":"x"}`) if err == nil || !strings.Contains(err.Error(), "must not be empty") { t.Fatalf("want empty-old-string error, got %v", err) } } // TestEditAmbiguousDoesNotRecordSnapshot:失败的编辑不能留档。 func TestEditAmbiguousDoesNotRecordSnapshot(t *testing.T) { dir := t.TempDir() snap := &recordingSnap{} reg := NewRegistry() for _, tl := range BuiltinIn(dir, NewFence(), snap) { if err := reg.Register(tl); err != nil { t.Fatal(err) } } e, _ := reg.Get("edit") p := filepath.Join(dir, "s.txt") if err := os.WriteFile(p, []byte("x x"), 0o644); err != nil { t.Fatal(err) } if _, err := e.Run(context.Background(), `{"path":"`+p+`","old_string":"x","new_string":"y"}`); err == nil { t.Fatal("ambiguous edit must fail") } if len(snap.got) != 0 { t.Fatalf("failed edit recorded a snapshot: %+v", snap.got) } }