mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
The ⌘K palette searches file names, projects and actions; nothing in the product searched file contents. Three personas independently typed a phrase that lives inside a synced file and got "No matches". `bdrive grep <pattern> [folder]` searches the working folder — RE2 or -F literal, -i, -l, -n (default 200, 0 = all), output `path:line: text`, exit 0 on match and 1 on none. It searches exactly what the project syncs, via a new syncer.SyncedFiles that wraps the existing walkFolder: the one copy of the sync predicate, so an ignore rule or a narrowed scope excludes a file from search the same way it excludes it from sync, and .bdrive/ state can never surface. Not Explain, which countFiles every pruned dir — a grep in a repo with node_modules/ would walk it in full for a count it discards. A read stays a read: LoadProject, not ResolveMount (no registry self-heal, no device enrollment), no session, no flock, and the volume store is opened for IgnoreAccepted only when it already exists, so a search creates nothing. Both the path and the matched line go through safeField — a matched line is a teammate's file content, the widest version of the surface that function exists for. The hub-side content index stays deliberately unbuilt; the issue records its cost. ROADMAP's "Search across the hub" line is reworded rather than removed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
339 lines
10 KiB
Go
339 lines
10 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/runbear-io/beardrive/internal/config"
|
||
"github.com/runbear-io/beardrive/internal/store"
|
||
)
|
||
|
||
// grepMount builds an isolated BDRIVE_HOME with one enrolled project folder.
|
||
// Nothing here contacts a hub: grep is a local read.
|
||
func grepMount(t *testing.T) string {
|
||
t.Helper()
|
||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||
folder := t.TempDir()
|
||
folder, _ = filepath.EvalSymlinks(folder)
|
||
if _, err := config.SaveProject(folder, config.Project{
|
||
Volume: "wiki",
|
||
Remote: "https://hub.example.com/p/p-12345678",
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, _, err := config.EnrollMount(folder); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return folder
|
||
}
|
||
|
||
func grepWrite(t *testing.T, folder, rel, body string) {
|
||
t.Helper()
|
||
abs := filepath.Join(folder, filepath.FromSlash(rel))
|
||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(abs, []byte(body), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// grepRun drives the real cobra command and returns its combined output plus
|
||
// the error it exited with (errNoMatch is grep's status-1 no-match).
|
||
func grepRun(t *testing.T, args ...string) (string, error) {
|
||
t.Helper()
|
||
c := grepCmd()
|
||
var out bytes.Buffer
|
||
c.SetOut(&out)
|
||
c.SetErr(&out)
|
||
c.SetArgs(args)
|
||
err := c.Execute()
|
||
return out.String(), err
|
||
}
|
||
|
||
func TestGrepPrintsMatchingLines(t *testing.T) {
|
||
folder := grepMount(t)
|
||
grepWrite(t, folder, "wiki/runbook.md", "intro\nthe retention fold collapses day buckets\ntail\n")
|
||
grepWrite(t, folder, "specs/reads.md", "retention folding happens at boot\n")
|
||
grepWrite(t, folder, "unrelated.md", "nothing to see\n")
|
||
|
||
out, err := grepRun(t, "retention.*fold", folder)
|
||
if err != nil {
|
||
t.Fatalf("grep: %v\n%s", err, out)
|
||
}
|
||
for _, want := range []string{
|
||
"wiki/runbook.md:2: the retention fold collapses day buckets",
|
||
"specs/reads.md:1: retention folding happens at boot",
|
||
"2 files, 2 matching lines",
|
||
} {
|
||
if !strings.Contains(out, want) {
|
||
t.Errorf("missing %q in:\n%s", want, out)
|
||
}
|
||
}
|
||
if strings.Contains(out, "unrelated.md") {
|
||
t.Errorf("non-matching file printed:\n%s", out)
|
||
}
|
||
|
||
// -l prints bare paths, one per line, and no summary.
|
||
out, err = grepRun(t, "-l", "retention", folder)
|
||
if err != nil {
|
||
t.Fatalf("grep -l: %v\n%s", err, out)
|
||
}
|
||
got := strings.Fields(out)
|
||
if len(got) != 2 || got[0] != "specs/reads.md" || got[1] != "wiki/runbook.md" {
|
||
t.Errorf("-l should print two bare paths, got %q", out)
|
||
}
|
||
}
|
||
|
||
func TestGrepFlags(t *testing.T) {
|
||
folder := grepMount(t)
|
||
grepWrite(t, folder, "notes.md", "TODO later\ntodo now\nliteral a[b]c here\n")
|
||
|
||
// Default is case-sensitive; -i widens it.
|
||
out, _ := grepRun(t, "TODO", folder)
|
||
if strings.Contains(out, "todo now") {
|
||
t.Errorf("case-sensitive match leaked:\n%s", out)
|
||
}
|
||
out, err := grepRun(t, "-i", "TODO", folder)
|
||
if err != nil || !strings.Contains(out, "todo now") {
|
||
t.Errorf("-i should match both cases: %v\n%s", err, out)
|
||
}
|
||
|
||
// -F takes the pattern literally: as a regexp a[b]c matches "abc", which
|
||
// is not in the file — only the literal text is.
|
||
if _, err := grepRun(t, "a[b]c", folder); !errors.Is(err, errNoMatch) {
|
||
t.Errorf("regexp a[b]c should not match, got %v", err)
|
||
}
|
||
out, err = grepRun(t, "-F", "a[b]c", folder)
|
||
if err != nil || !strings.Contains(out, "literal a[b]c here") {
|
||
t.Errorf("-F should match literally: %v\n%s", err, out)
|
||
}
|
||
|
||
// A bad pattern is a real error, printed, not a silent no-match.
|
||
out, err = grepRun(t, "a(", folder)
|
||
if err == nil || errors.Is(err, errNoMatch) {
|
||
t.Errorf("bad pattern should error, got %v", err)
|
||
}
|
||
if !strings.Contains(out, "bad pattern") {
|
||
t.Errorf("bad pattern should say so:\n%s", out)
|
||
}
|
||
}
|
||
|
||
func TestGrepLimit(t *testing.T) {
|
||
folder := grepMount(t)
|
||
var body strings.Builder
|
||
for i := 0; i < 500; i++ {
|
||
body.WriteString("hit\n")
|
||
}
|
||
grepWrite(t, folder, "many.md", body.String())
|
||
|
||
// Default caps at 200 and says so.
|
||
out, err := grepRun(t, "hit", folder)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n := strings.Count(out, "many.md:"); n != 200 {
|
||
t.Errorf("default limit should print 200 lines, got %d", n)
|
||
}
|
||
if !strings.Contains(out, "output limited to 200 lines") {
|
||
t.Errorf("truncation should be announced:\n%s", out[len(out)-200:])
|
||
}
|
||
|
||
// -n 0 means all.
|
||
out, err = grepRun(t, "-n", "0", "hit", folder)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n := strings.Count(out, "many.md:"); n != 500 {
|
||
t.Errorf("-n 0 should print every line, got %d", n)
|
||
}
|
||
if strings.Contains(out, "output limited") {
|
||
t.Errorf("-n 0 must not announce truncation:\n%s", out)
|
||
}
|
||
}
|
||
|
||
// Results match what the project actually syncs: the same .bdriveignore rule
|
||
// that keeps a file off the hub keeps it out of search.
|
||
func TestGrepRespectsTheSyncFilter(t *testing.T) {
|
||
folder := grepMount(t)
|
||
grepWrite(t, folder, "keep.md", "needle\n")
|
||
grepWrite(t, folder, "drafts/skip.md", "needle\n")
|
||
|
||
out, err := grepRun(t, "needle", folder)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(out, "drafts/skip.md") {
|
||
t.Fatalf("drafts/skip.md should match before the rule:\n%s", out)
|
||
}
|
||
|
||
grepWrite(t, folder, ".bdriveignore", "drafts/\n")
|
||
out, err = grepRun(t, "needle", folder)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.Contains(out, "drafts/skip.md") {
|
||
t.Errorf("an ignored path must not be searchable:\n%s", out)
|
||
}
|
||
if !strings.Contains(out, "keep.md") {
|
||
t.Errorf("keep.md should still match:\n%s", out)
|
||
}
|
||
}
|
||
|
||
// .bdrive/ holds the project's own settings and .bdrive-tmp-* are half-written
|
||
// state files. Neither is project content and neither may ever surface.
|
||
func TestGrepNeverSearchesBdriveState(t *testing.T) {
|
||
folder := grepMount(t)
|
||
grepWrite(t, folder, "real.md", "p-12345678\n")
|
||
grepWrite(t, folder, ".bdrive-tmp-half", "p-12345678\n")
|
||
|
||
out, err := grepRun(t, "p-12345678", folder)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// .bdrive/config.json contains the remote URL, so it would match.
|
||
if strings.Contains(out, ".bdrive/") || strings.Contains(out, ".bdrive-tmp-") {
|
||
t.Errorf("bdrive state leaked into results:\n%s", out)
|
||
}
|
||
if !strings.Contains(out, "real.md") {
|
||
t.Errorf("real.md should match:\n%s", out)
|
||
}
|
||
}
|
||
|
||
func TestGrepSkipsBinaryFiles(t *testing.T) {
|
||
folder := grepMount(t)
|
||
grepWrite(t, folder, "text.md", "needle here\n")
|
||
// A NUL inside the first 8 KB is the binary rule.
|
||
grepWrite(t, folder, "blob.bin", "needle here\x00 and more\n")
|
||
// A NUL past the sniff window is not: the file reads as text.
|
||
grepWrite(t, folder, "late.bin", strings.Repeat("x\n", 6000)+"needle here\n\x00")
|
||
|
||
out, err := grepRun(t, "needle", folder)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.Contains(out, "blob.bin") {
|
||
t.Errorf("binary file searched:\n%s", out)
|
||
}
|
||
if !strings.Contains(out, "text.md") || !strings.Contains(out, "late.bin") {
|
||
t.Errorf("text files should match:\n%s", out)
|
||
}
|
||
}
|
||
|
||
// Every matched line is content a teammate wrote and synced — a strictly wider
|
||
// version of the surface safeField exists for. A planted file must not be able
|
||
// to repaint or reverse the operator's terminal.
|
||
func TestGrepOutputCannotRewriteTheTerminal(t *testing.T) {
|
||
folder := grepMount(t)
|
||
hostile := "needle \x1b[2J\x1b[3J\x1b[H\rgnitset\x9b31m\x7f end"
|
||
grepWrite(t, folder, "planted.md", hostile+"\n")
|
||
// The path is attacker-controlled too: a teammate chooses the file name.
|
||
grepWrite(t, folder, "name\x1b[31m.md", "needle\n")
|
||
|
||
for _, args := range [][]string{{"needle", folder}, {"-l", "needle", folder}} {
|
||
out, err := grepRun(t, args...)
|
||
if err != nil {
|
||
t.Fatalf("grep %v: %v", args, err)
|
||
}
|
||
for _, bad := range []string{"\x1b", "\r", "", "", "\x9b", "\x7f"} {
|
||
if strings.Contains(out, bad) {
|
||
t.Errorf("grep %v leaked %q into the terminal:\n%q", args, bad, out)
|
||
}
|
||
}
|
||
// One match is one line: no forged rows.
|
||
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
|
||
for _, r := range line {
|
||
if r < 0x20 && r != '\t' || r == 0x7f {
|
||
t.Errorf("control rune %q survived in %q", r, line)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// A read-only query must not enroll this device: LoadProject, never
|
||
// ResolveMount. Outside a project it says so, exits non-zero, and the registry
|
||
// is untouched.
|
||
func TestGrepOutsideAProjectWritesNothing(t *testing.T) {
|
||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||
folder := t.TempDir()
|
||
grepWrite(t, folder, "loose.md", "needle\n")
|
||
|
||
mounts := filepath.Join(os.Getenv("BDRIVE_HOME"), "mounts.json")
|
||
before, beforeErr := os.ReadFile(mounts)
|
||
|
||
out, err := grepRun(t, "needle", folder)
|
||
if err == nil || errors.Is(err, errNoMatch) {
|
||
t.Fatalf("should fail outside a project, got %v", err)
|
||
}
|
||
if !strings.Contains(out, "not a beardrive project") {
|
||
t.Errorf("message should name the problem:\n%s", out)
|
||
}
|
||
after, afterErr := os.ReadFile(mounts)
|
||
if (beforeErr == nil) != (afterErr == nil) || !bytes.Equal(before, after) {
|
||
t.Errorf("the registry was written by a read-only query")
|
||
}
|
||
}
|
||
|
||
// Exit codes are grep's, so `bdrive grep x || …` composes: 0 on match, 1 on
|
||
// no match with nothing printed at all.
|
||
func TestGrepExitCodes(t *testing.T) {
|
||
folder := grepMount(t)
|
||
grepWrite(t, folder, "a.md", "hay\n")
|
||
|
||
if out, err := grepRun(t, "hay", folder); err != nil {
|
||
t.Errorf("match should exit 0: %v\n%s", err, out)
|
||
}
|
||
out, err := grepRun(t, "needle", folder)
|
||
if !errors.Is(err, errNoMatch) {
|
||
t.Errorf("no match should return errNoMatch, got %v", err)
|
||
}
|
||
if out != "" {
|
||
t.Errorf("no match must print nothing, got %q", out)
|
||
}
|
||
}
|
||
|
||
// grep opens the volume store read-only for one field and never takes the
|
||
// volume flock, so a daemon mid-cycle cannot make a search hang.
|
||
func TestGrepDoesNotBlockOnTheVolumeLock(t *testing.T) {
|
||
folder := grepMount(t)
|
||
grepWrite(t, folder, "a.md", "needle\n")
|
||
|
||
proj, found, err := config.LoadProject(folder)
|
||
if err != nil || !found {
|
||
t.Fatal(err)
|
||
}
|
||
vdir, err := config.VolumeDir(proj.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
st, err := store.Open(vdir)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
unlock, err := st.Lock() // stand in for a cycle in progress
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer unlock()
|
||
|
||
done := make(chan string, 1)
|
||
go func() {
|
||
out, _ := grepRun(t, "needle", folder)
|
||
done <- out
|
||
}()
|
||
select {
|
||
case out := <-done:
|
||
if !strings.Contains(out, "a.md:1: needle") {
|
||
t.Errorf("grep under the lock should still find it:\n%s", out)
|
||
}
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("grep blocked on the volume lock")
|
||
}
|
||
}
|