2026-07-11 14:49:02 -07:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
2026-07-13 15:05:05 -07:00
|
|
|
"os"
|
2026-07-11 14:49:02 -07:00
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
|
|
|
"github.com/runbear-io/beardrive/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Real hook payload shapes from the supported platforms — read-log must find
|
2026-07-13 15:05:05 -07:00
|
|
|
// the file paths wherever each platform puts them. Shell and search events
|
|
|
|
|
// are mined heuristically, so the folder holds real files to stat against.
|
2026-07-11 14:49:02 -07:00
|
|
|
func TestExtractEventPaths(t *testing.T) {
|
2026-07-13 15:05:05 -07:00
|
|
|
folder := t.TempDir()
|
|
|
|
|
for _, f := range []string{"wiki/a.md", "wiki/b.md", "notes.md", "out.md"} {
|
|
|
|
|
p := filepath.Join(folder, f)
|
|
|
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
abs := func(rel string) string { return filepath.Join(folder, rel) }
|
|
|
|
|
|
2026-07-11 14:49:02 -07:00
|
|
|
cases := map[string]struct {
|
|
|
|
|
payload string
|
|
|
|
|
want []string
|
|
|
|
|
}{
|
|
|
|
|
"claude read": {
|
|
|
|
|
`{"session_id":"abc","hook_event_name":"PostToolUse","tool_name":"Read",
|
|
|
|
|
"tool_input":{"file_path":"/proj/wiki/a.md"},"tool_response":{"type":"text"}}`,
|
|
|
|
|
[]string{"/proj/wiki/a.md"},
|
|
|
|
|
},
|
|
|
|
|
"gemini read_many_files": {
|
|
|
|
|
`{"session_id":"g1","tool":{"name":"read_many_files","args":{"paths":["wiki/a.md","wiki/b.md"]}}}`,
|
|
|
|
|
[]string{"wiki/a.md", "wiki/b.md"},
|
|
|
|
|
},
|
|
|
|
|
"gemini read_file absolute": {
|
|
|
|
|
`{"session_id":"g2","tool":{"name":"read_file","args":{"absolute_path":"/proj/notes.md"}}}`,
|
|
|
|
|
[]string{"/proj/notes.md"},
|
|
|
|
|
},
|
|
|
|
|
"hermes read_file": {
|
|
|
|
|
`{"hook_event_name":"post_tool_call","tool_name":"read_file","tool_args":{"path":"docs/x.md"}}`,
|
|
|
|
|
[]string{"docs/x.md"},
|
|
|
|
|
},
|
|
|
|
|
"duplicates collapse": {
|
|
|
|
|
`{"tool_input":{"file_path":"a.md"},"extra":{"file_path":"a.md"}}`,
|
|
|
|
|
[]string{"a.md"},
|
|
|
|
|
},
|
|
|
|
|
"no paths": {
|
|
|
|
|
`{"session_id":"abc","prompt":"hello"}`,
|
|
|
|
|
nil,
|
|
|
|
|
},
|
|
|
|
|
"not json": {
|
|
|
|
|
`plain text`,
|
|
|
|
|
nil,
|
|
|
|
|
},
|
2026-07-13 15:05:05 -07:00
|
|
|
// Shell commands: existing files named as arguments count as reads;
|
|
|
|
|
// flags, missing files, and redirect targets don't.
|
|
|
|
|
"bash cat and grep": {
|
|
|
|
|
`{"tool_name":"Bash","tool_input":{"command":"cat wiki/a.md && grep -n foo notes.md missing.md > out.md"}}`,
|
|
|
|
|
[]string{"wiki/a.md", "notes.md"},
|
|
|
|
|
},
|
|
|
|
|
"bash pipeline with quotes": {
|
|
|
|
|
`{"tool_name":"Bash","tool_input":{"command":"tail -20 'wiki/b.md' | head -5"}}`,
|
|
|
|
|
[]string{"wiki/b.md"},
|
|
|
|
|
},
|
|
|
|
|
// Search tools: reads are the files the MATCHES came from, mined
|
|
|
|
|
// from the response; the scope dir in tool_input.path must not leak.
|
|
|
|
|
"grep content lines": {
|
|
|
|
|
`{"tool_name":"Grep","tool_input":{"pattern":"foo","path":"` + folder + `"},
|
|
|
|
|
"tool_response":{"content":"wiki/a.md:3:foo bar\nwiki/b.md:9:foo"}}`,
|
|
|
|
|
[]string{"wiki/a.md", "wiki/b.md"},
|
|
|
|
|
},
|
|
|
|
|
"grep filenames list": {
|
|
|
|
|
`{"tool_name":"Grep","tool_input":{"pattern":"foo","path":"` + folder + `"},
|
|
|
|
|
"tool_response":{"filenames":["` + abs("notes.md") + `"]}}`,
|
|
|
|
|
[]string{abs("notes.md")},
|
|
|
|
|
},
|
|
|
|
|
// Listing tools: seeing a file's name is not reading it.
|
|
|
|
|
"glob ignored": {
|
|
|
|
|
`{"tool_name":"Glob","tool_input":{"pattern":"**/*.md"},
|
|
|
|
|
"tool_response":{"filenames":["` + abs("wiki/a.md") + `"]}}`,
|
|
|
|
|
nil,
|
|
|
|
|
},
|
2026-07-11 14:49:02 -07:00
|
|
|
}
|
|
|
|
|
for name, c := range cases {
|
2026-07-13 15:05:05 -07:00
|
|
|
got := extractEventPaths([]byte(c.payload), folder)
|
2026-07-11 14:49:02 -07:00
|
|
|
if strings.Join(got, ",") != strings.Join(c.want, ",") {
|
|
|
|
|
t.Errorf("%s: paths = %v, want %v", name, got, c.want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// End to end through the cobra command: a claude-style event lands in the
|
|
|
|
|
// mount's read spool, filtered to project-relative synced paths.
|
|
|
|
|
func TestReadLogCommand(t *testing.T) {
|
|
|
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
|
|
|
folder := t.TempDir()
|
|
|
|
|
folder, _ = filepath.EvalSymlinks(folder)
|
|
|
|
|
proj, err := config.SaveProject(folder, config.Project{Volume: "wiki"})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-08-03 16:20:51 +09:00
|
|
|
if _, _, err := config.EnrollMount(folder); err != nil { // enroll, as `bdrive init` would
|
2026-07-24 15:53:23 +09:00
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-07-11 14:49:02 -07:00
|
|
|
|
|
|
|
|
c := readLogCmd()
|
|
|
|
|
c.SetIn(bytes.NewReader([]byte(`{"session_id":"abc","tool_name":"Read",
|
|
|
|
|
"tool_input":{"file_path":"` + folder + `/wiki/a.md"},
|
|
|
|
|
"other":{"file_path":"/somewhere/else/entirely.md"}}`)))
|
|
|
|
|
c.SetArgs([]string{folder})
|
|
|
|
|
if err := c.Execute(); err != nil {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
evs, err := st.PendingReads()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(evs) != 1 || evs[0].Path != "wiki/a.md" {
|
|
|
|
|
t.Fatalf("spool = %+v, want just the in-project read, mount-relative", evs)
|
|
|
|
|
}
|
2026-08-11 04:18:53 +09:00
|
|
|
// The same session id `bdrive sync --hook` stamps onto the writes, off
|
|
|
|
|
// the same stdin payload — it is what lets the hub join this read to the
|
|
|
|
|
// run card that turn produced.
|
|
|
|
|
if evs[0].Session != "abc" {
|
|
|
|
|
t.Fatalf("spooled read Session = %q, want the event's session_id", evs[0].Session)
|
|
|
|
|
}
|
2026-07-11 14:49:02 -07:00
|
|
|
}
|
2026-07-24 15:53:23 +09:00
|
|
|
|
|
|
|
|
// read-log fires on every agent tool call in every folder, so it must be
|
|
|
|
|
// inert where this device never opted in: an unenrolled config.json (one
|
|
|
|
|
// that arrived with a git clone / copied folder) spools nothing, enrolls
|
|
|
|
|
// nothing, and creates no volume store; a paused project spools nothing.
|
|
|
|
|
func TestReadLogGated(t *testing.T) {
|
|
|
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
|
|
|
folder := t.TempDir()
|
|
|
|
|
folder, _ = filepath.EvalSymlinks(folder)
|
|
|
|
|
proj, err := config.SaveProject(folder, config.Project{Volume: "wiki"})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
vdir, err := config.VolumeDir(proj.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
event := `{"tool_name":"Read","tool_input":{"file_path":"` + folder + `/wiki/a.md"}}`
|
|
|
|
|
run := func() {
|
|
|
|
|
c := readLogCmd()
|
|
|
|
|
c.SetIn(bytes.NewReader([]byte(event)))
|
|
|
|
|
c.SetArgs([]string{folder})
|
|
|
|
|
if err := c.Execute(); err != nil {
|
|
|
|
|
t.Fatalf("read-log must never fail: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unenrolled: no spool, no store, no registry entry.
|
|
|
|
|
run()
|
|
|
|
|
if _, err := os.Stat(vdir); !os.IsNotExist(err) {
|
|
|
|
|
t.Fatal("unenrolled read-log created the volume store")
|
|
|
|
|
}
|
|
|
|
|
mounts, err := config.LoadMounts()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if _, enrolled := mounts[proj.ID]; enrolled {
|
|
|
|
|
t.Fatal("read-log enrolled the mount; only `bdrive init` may do that")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Enrolled but paused by `bdrive stop`: still nothing spooled.
|
2026-08-03 16:20:51 +09:00
|
|
|
if _, _, err := config.EnrollMount(folder); err != nil {
|
2026-07-24 15:53:23 +09:00
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := store.SetPaused(vdir, true); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
run()
|
|
|
|
|
st, err := store.Open(vdir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
evs, err := st.PendingReads()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(evs) != 0 {
|
|
|
|
|
t.Fatalf("paused read-log spooled %+v, want nothing", evs)
|
|
|
|
|
}
|
|
|
|
|
}
|