From cd5f3c0175a122f971f4c29daec8ff28f5e6d260 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 21:37:39 +0200 Subject: [PATCH] feat(hyperguild): brain query subcommand Adds 'hyperguild brain query ' against the brain HTTP REST /query endpoint. Default human output prints path + score + title; --json passes through the response envelope. --limit overrides the default 5-result cap. runBrainWrite remains a stub for Task 5. --- cmd/hyperguild/brain.go | 59 ++++++++++++++++++++++++++ cmd/hyperguild/brain_test.go | 81 ++++++++++++++++++++++++++++++++++++ cmd/hyperguild/main.go | 2 +- cmd/hyperguild/main_test.go | 3 +- 4 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 cmd/hyperguild/brain.go create mode 100644 cmd/hyperguild/brain_test.go diff --git a/cmd/hyperguild/brain.go b/cmd/hyperguild/brain.go new file mode 100644 index 0000000..f224dc5 --- /dev/null +++ b/cmd/hyperguild/brain.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" +) + +func runBrain(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + if len(args) == 0 { + return errors.New("brain: subcommand required (query|write)") + } + switch args[0] { + case "query": + return runBrainQuery(ctx, args[1:], stdin, stdout, stderr) + case "write": + return runBrainWrite(ctx, args[1:], stdin, stdout, stderr) + default: + return fmt.Errorf("brain: unknown subcommand: %s (expected query|write)", args[0]) + } +} + +func runBrainQuery(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("brain query", flag.ContinueOnError) + fs.SetOutput(stderr) + asJSON := fs.Bool("json", false, "output JSON instead of human-readable") + limit := fs.Int("limit", 5, "maximum number of results") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("parse flags: %w", err) + } + if fs.NArg() < 1 { + return errors.New("brain query: topic required") + } + topic := fs.Arg(0) + + res, err := newBrainClient().Query(ctx, topic, *limit) + if err != nil { + return err + } + + if *asJSON { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + return enc.Encode(res) + } + for _, hit := range res.Results { + fmt.Fprintf(stdout, "%s score=%d %s\n", hit.Path, hit.Score, hit.Title) //nolint:errcheck + } + return nil +} + +// runBrainWrite is implemented in Task 5; stub now returns an explicit error +// so the router compiles and tests for runBrainQuery can run. +func runBrainWrite(_ context.Context, _ []string, _ io.Reader, _, _ io.Writer) error { + return errors.New("brain write: not implemented (Task 5)") +} diff --git a/cmd/hyperguild/brain_test.go b/cmd/hyperguild/brain_test.go new file mode 100644 index 0000000..4c7f063 --- /dev/null +++ b/cmd/hyperguild/brain_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func brainQueryServer(t *testing.T, body string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +func TestRunBrainQuery_Human(t *testing.T) { + srv := brainQueryServer(t, `{"results":[{"path":"knowledge/a.md","title":"A","excerpt":"...","score":9},{"path":"knowledge/b.md","title":"B","excerpt":"...","score":3}]}`) + defer srv.Close() + t.Setenv("BRAIN_URL", srv.URL) + + var out, errBuf bytes.Buffer + err := runBrain(context.Background(), []string{"query", "topic"}, strings.NewReader(""), &out, &errBuf) + require.NoError(t, err) + got := out.String() + assert.Contains(t, got, "knowledge/a.md") + assert.Contains(t, got, "score=9") + assert.Contains(t, got, "knowledge/b.md") +} + +func TestRunBrainQuery_JSON(t *testing.T) { + srv := brainQueryServer(t, `{"results":[{"path":"x.md","title":"X","excerpt":"e","score":5}]}`) + defer srv.Close() + t.Setenv("BRAIN_URL", srv.URL) + + var out, errBuf bytes.Buffer + err := runBrain(context.Background(), []string{"query", "--json", "topic"}, strings.NewReader(""), &out, &errBuf) + require.NoError(t, err) + assert.Contains(t, out.String(), `"path": "x.md"`) + assert.Contains(t, out.String(), `"score": 5`) +} + +func TestRunBrainQuery_Limit(t *testing.T) { + gotLimit := "" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotLimit = r.URL.Query().Get("limit") + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer srv.Close() + t.Setenv("BRAIN_URL", srv.URL) + + var out, errBuf bytes.Buffer + err := runBrain(context.Background(), []string{"query", "--limit", "12", "topic"}, strings.NewReader(""), &out, &errBuf) + require.NoError(t, err) + assert.Equal(t, "12", gotLimit) +} + +func TestRunBrainQuery_MissingTopic(t *testing.T) { + var out, errBuf bytes.Buffer + err := runBrain(context.Background(), []string{"query"}, strings.NewReader(""), &out, &errBuf) + assert.Error(t, err) +} + +func TestRunBrain_NoSubsubcommand(t *testing.T) { + var out, errBuf bytes.Buffer + err := runBrain(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf) + assert.Error(t, err) + assert.Contains(t, err.Error(), "subcommand required") +} + +func TestRunBrain_UnknownSubsubcommand(t *testing.T) { + var out, errBuf bytes.Buffer + err := runBrain(context.Background(), []string{"bogus"}, strings.NewReader(""), &out, &errBuf) + assert.Error(t, err) +} diff --git a/cmd/hyperguild/main.go b/cmd/hyperguild/main.go index cca51f4..d4caae8 100644 --- a/cmd/hyperguild/main.go +++ b/cmd/hyperguild/main.go @@ -26,7 +26,7 @@ func notYet(ctx context.Context, args []string, stdin io.Reader, stdout, stderr func subcommands() map[string]subcommand { return map[string]subcommand{ "tier": runTier, - "brain": notYet, + "brain": runBrain, "mode": notYet, } } diff --git a/cmd/hyperguild/main_test.go b/cmd/hyperguild/main_test.go index 6959f20..594a832 100644 --- a/cmd/hyperguild/main_test.go +++ b/cmd/hyperguild/main_test.go @@ -35,8 +35,7 @@ func TestDispatch_UnknownSubcommand_ReturnsTwo(t *testing.T) { func TestDispatch_KnownSubcommand_RoutesAndReturnsExitCode(t *testing.T) { var out, errBuf bytes.Buffer - code := dispatch(context.Background(), []string{"brain"}, strings.NewReader(""), &out, &errBuf) - // At this point, brain still returns the not-implemented error → exit 1. + code := dispatch(context.Background(), []string{"mode"}, strings.NewReader(""), &out, &errBuf) assert.Equal(t, 1, code) assert.Contains(t, errBuf.String(), "not implemented") }