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 })() }