nub/internal/tool/builtin/builtin_test.go
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

177 lines
4.5 KiB
Go

package builtin
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"nub/internal/tool"
)
func testEnv(t *testing.T) tool.Env {
t.Helper()
dir := t.TempDir()
return tool.Env{Cwd: dir, RepoRoot: dir}
}
func writeFile(t *testing.T, env tool.Env, name, content string) string {
t.Helper()
path := filepath.Join(env.RepoRoot, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestEdit_AmbiguousMatchErrorsWithCount(t *testing.T) {
env := testEnv(t)
writeFile(t, env, "f.go", "foo\nfoo\nfoo\n")
in, _ := json.Marshal(map[string]any{"path": "f.go", "old_string": "foo", "new_string": "bar"})
res, err := EditTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected IsError for ambiguous match")
}
if !contains(res.ForModel, "3 matches") {
t.Errorf("error message should mention match count, got: %q", res.ForModel)
}
}
func TestEdit_ReplaceAllReplacesEveryMatch(t *testing.T) {
env := testEnv(t)
path := writeFile(t, env, "f.go", "foo\nfoo\nfoo\n")
in, _ := json.Marshal(map[string]any{"path": "f.go", "old_string": "foo", "new_string": "bar", "replace_all": true})
res, err := EditTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForModel)
}
data, _ := os.ReadFile(path)
if string(data) != "bar\nbar\nbar\n" {
t.Errorf("content = %q", data)
}
}
func TestEdit_UniqueMatchSucceeds(t *testing.T) {
env := testEnv(t)
writeFile(t, env, "f.go", "unique_marker\nother\n")
in, _ := json.Marshal(map[string]any{"path": "f.go", "old_string": "unique_marker", "new_string": "replaced"})
res, err := EditTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForModel)
}
}
func TestPath_EscapeViaDotDotIsRejected(t *testing.T) {
env := testEnv(t)
in, _ := json.Marshal(map[string]any{"path": "../outside.txt", "old_string": "a", "new_string": "b"})
res, err := EditTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected path escape to be rejected")
}
}
func TestPath_EscapeViaSymlinkIsRejected(t *testing.T) {
env := testEnv(t)
outsideDir := t.TempDir()
if err := os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(env.RepoRoot, "link")
if err := os.Symlink(outsideDir, link); err != nil {
t.Skipf("symlinks not supported: %v", err)
}
in, _ := json.Marshal(map[string]any{"path": "link/secret.txt"})
res, err := ReadTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected symlink escape to be rejected")
}
}
func TestBash_TimeoutKillsProcessGroup(t *testing.T) {
env := testEnv(t)
in, _ := json.Marshal(map[string]any{"command": "sleep 30", "timeout_seconds": 1})
start := time.Now()
res, err := BashTool{}.Run(context.Background(), in, env)
elapsed := time.Since(start)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected timeout to be reported as error")
}
if elapsed > 5*time.Second {
t.Errorf("timeout took too long: %s", elapsed)
}
}
func TestBash_CapturesStdoutAndExitCode(t *testing.T) {
env := testEnv(t)
in, _ := json.Marshal(map[string]any{"command": "echo hi && exit 3"})
res, err := BashTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected non-zero exit to be reported as error")
}
if !contains(res.ForModel, "hi") {
t.Errorf("expected stdout captured, got: %q", res.ForModel)
}
}
func TestGlob_RespectsGitignore(t *testing.T) {
env := testEnv(t)
writeFile(t, env, ".gitignore", "*.log\n")
writeFile(t, env, "keep.txt", "x")
writeFile(t, env, "skip.log", "x")
in, _ := json.Marshal(map[string]any{"pattern": "**/*"})
res, err := GlobTool{}.Run(context.Background(), in, env)
if err != nil {
t.Fatal(err)
}
if contains(res.ForModel, "skip.log") {
t.Errorf("gitignored file leaked into glob result: %q", res.ForModel)
}
if !contains(res.ForModel, "keep.txt") {
t.Errorf("expected keep.txt in result: %q", res.ForModel)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}