Files
594a027c15 feat(cli): bdrive grep — search the text inside the files a project syncs (BEA-99) (#136)
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>
2026-08-11 04:27:20 +09:00

339 lines
10 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}