package main import ( "bytes" "encoding/json" "os" "reflect" "strings" "testing" "github.com/spf13/cobra" "github.com/stretchr/testify/require" ) // newCallTestCmd builds a fresh call command bound to an out/err buffer and // resets the package-level flag state to defaults so tests don't leak. func newCallTestCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) { t.Helper() callIndex = "." callJSON = "" callJSONFile = "" callArgs = nil callFormat = "json" callDry = false callQuiet = false buf := &bytes.Buffer{} cmd := &cobra.Command{Use: "call", RunE: runCall} cmd.SetOut(buf) cmd.SetErr(buf) return cmd, buf } // TestCoerceScalar exhaustively covers the deterministic --arg value coercion. func TestCoerceScalar(t *testing.T) { cases := []struct { in string want any }{ {"true", true}, {"false", false}, {"null", nil}, {"42", int64(42)}, {"-7", int64(-7)}, {"3.14", 3.14}, {"", ""}, {"hello", "hello"}, {"1.0.0", "1.0.0"}, // not a number — stays a string {`[1,2,3]`, []any{float64(1), float64(2), float64(3)}}, {`{"k":"v"}`, map[string]any{"k": "v"}}, {`[broken`, `[broken`}, // invalid JSON array — falls back to literal string } for _, c := range cases { got := coerceScalar(c.in) if !reflect.DeepEqual(got, c.want) { t.Errorf("coerceScalar(%q) = %#v (%T), want %#v (%T)", c.in, got, got, c.want, c.want) } } } // TestCoerceArg covers the key/value split, the := walrus raw-JSON form, and // error cases. func TestCoerceArg(t *testing.T) { t.Run("plain string", func(t *testing.T) { k, v, err := coerceArg("name=foo") require.NoError(t, err) require.Equal(t, "name", k) require.Equal(t, "foo", v) }) t.Run("bool", func(t *testing.T) { k, v, err := coerceArg("flag=true") require.NoError(t, err) require.Equal(t, "flag", k) require.Equal(t, true, v) }) t.Run("int", func(t *testing.T) { _, v, err := coerceArg("limit=50") require.NoError(t, err) require.Equal(t, int64(50), v) }) t.Run("empty value", func(t *testing.T) { k, v, err := coerceArg("note=") require.NoError(t, err) require.Equal(t, "note", k) require.Equal(t, "", v) }) t.Run("walrus forces raw string", func(t *testing.T) { // version:="1.0" must stay the string "1.0", not the number 1.0. k, v, err := coerceArg(`version:="1.0"`) require.NoError(t, err) require.Equal(t, "version", k) require.Equal(t, "1.0", v) }) t.Run("walrus parses raw JSON object", func(t *testing.T) { k, v, err := coerceArg(`opts:={"a":1}`) require.NoError(t, err) require.Equal(t, "opts", k) require.Equal(t, map[string]any{"a": float64(1)}, v) }) t.Run("walrus invalid JSON errors", func(t *testing.T) { _, _, err := coerceArg(`x:=not-json`) require.Error(t, err) }) t.Run("missing equals errors", func(t *testing.T) { _, _, err := coerceArg("noequals") require.Error(t, err) }) t.Run("empty key errors", func(t *testing.T) { _, _, err := coerceArg("=value") require.Error(t, err) }) } // TestLowerCallArgs_Precedence asserts the three precedence layers: base JSON, // inline JSON merged over it, and --arg merged on top (last wins per key). func TestLowerCallArgs_Precedence(t *testing.T) { cmd, _ := newCallTestCmd(t) t.Run("inline json plus arg override", func(t *testing.T) { obj, err := lowerCallArgs(cmd, `{"a":1,"b":2}`, "", []string{"b=3"}) require.NoError(t, err) require.Equal(t, map[string]any{"a": float64(1), "b": int64(3)}, obj) }) t.Run("later arg replaces earlier same key", func(t *testing.T) { obj, err := lowerCallArgs(cmd, "", "", []string{"k=first", "k=second"}) require.NoError(t, err) require.Equal(t, "second", obj["k"]) }) t.Run("arg over inline json over file base", func(t *testing.T) { // Write a base file, then merge inline JSON and --arg over it. base := writeTempJSON(t, `{"a":"file","b":"file","c":"file"}`) obj, err := lowerCallArgs(cmd, `{"b":"inline","c":"inline"}`, base, []string{"c=arg"}) require.NoError(t, err) require.Equal(t, "file", obj["a"]) // only in the file base require.Equal(t, "inline", obj["b"]) // inline beats file require.Equal(t, "arg", obj["c"]) // arg beats inline + file }) } // TestLowerCallArgs_StdinBase asserts --json - reads the base object from stdin. func TestLowerCallArgs_StdinBase(t *testing.T) { cmd, _ := newCallTestCmd(t) cmd.SetIn(strings.NewReader(`{"from":"stdin","n":1}`)) obj, err := lowerCallArgs(cmd, "-", "", []string{"n=2"}) require.NoError(t, err) require.Equal(t, "stdin", obj["from"]) require.Equal(t, int64(2), obj["n"]) } // TestLowerCallArgs_NonObjectRejected asserts a non-object base JSON is refused. func TestLowerCallArgs_NonObjectRejected(t *testing.T) { cmd, _ := newCallTestCmd(t) _, err := lowerCallArgs(cmd, `[1,2,3]`, "", nil) require.Error(t, err) require.Contains(t, err.Error(), "JSON object") } // TestCallDry_NoDaemon asserts --dry prints the lowered object and the target // tool WITHOUT invoking the daemon (the stub records any call). func TestCallDry_NoDaemon(t *testing.T) { orig := callDaemonTool t.Cleanup(func() { callDaemonTool = orig }) called := false callDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) { called = true return nil, nil } cmd, buf := newCallTestCmd(t) callDry = true callJSON = `{"id":"pkg/foo.go::Bar"}` callArgs = []string{"depth=2", "verbose=true"} require.NoError(t, runCall(cmd, []string{"get_callers"})) require.False(t, called, "--dry must NOT invoke the daemon (not even tool_profile)") out := buf.String() require.Contains(t, out, "tool: get_callers") // The lowered object is printed as indented JSON. require.Contains(t, out, `"id": "pkg/foo.go::Bar"`) require.Contains(t, out, `"depth": 2`) require.Contains(t, out, `"verbose": true`) } // cannedToolProfileJSON is a minimal tool_profile response with a descriptors // array covering a read tool and a mutating tool. const cannedToolProfileJSON = `{ "live": ["search_symbols", "get_callers"], "deferred": ["edit_file", "rename_symbol"], "descriptors": [ {"name":"search_symbols","category":"nav","mutating":false,"presets":["core","nav","readonly"],"summary":"BM25 symbol search."}, {"name":"get_callers","category":"nav","mutating":false,"presets":["core","nav","readonly"],"summary":"Who calls a function."}, {"name":"edit_file","category":"edit","mutating":true,"presets":["core","edit"],"summary":"Edit a file."}, {"name":"rename_symbol","category":"edit","mutating":true,"presets":["edit"],"summary":"Rename across files."} ] }` // TestCall_UnknownToolSuggestsNearest asserts that, when a daemon is reachable // and returns a catalog, an unknown tool name yields an error listing the // nearest matches plus the `gortex tools search` hint. func TestCall_UnknownToolSuggestsNearest(t *testing.T) { orig := callDaemonTool t.Cleanup(func() { callDaemonTool = orig }) var calledTools []string callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) { calledTools = append(calledTools, tool) if tool == "tool_profile" { return json.RawMessage(cannedToolProfileJSON), nil } t.Fatalf("must not call %q after a failed name validation", tool) return nil, nil } cmd, _ := newCallTestCmd(t) err := runCall(cmd, []string{"get_caller"}) // typo of get_callers require.Error(t, err) require.Contains(t, err.Error(), "unknown tool") require.Contains(t, err.Error(), "get_callers", "should suggest the nearest match") require.Contains(t, err.Error(), "gortex tools search") require.Equal(t, []string{"tool_profile"}, calledTools, "only the catalog fetch should happen") } // TestCall_KnownMutatingToolWarns asserts a known mutating tool both forwards // the call and prints the stderr write note (unless --quiet). func TestCall_KnownMutatingToolWarns(t *testing.T) { orig := callDaemonTool t.Cleanup(func() { callDaemonTool = orig }) callDaemonTool = func(_ string, tool string, args map[string]any) (json.RawMessage, error) { if tool == "tool_profile" { return json.RawMessage(cannedToolProfileJSON), nil } require.Equal(t, "edit_file", tool) require.Equal(t, "json", args["format"]) return json.RawMessage(`{"status":"ok"}`), nil } cmd, buf := newCallTestCmd(t) require.NoError(t, runCall(cmd, []string{"edit_file"})) out := buf.String() require.Contains(t, out, "note: edit_file writes") require.Contains(t, out, `"status": "ok"`) } // TestCall_QuietSuppressesMutatingNote asserts --quiet hides the write note. func TestCall_QuietSuppressesMutatingNote(t *testing.T) { orig := callDaemonTool t.Cleanup(func() { callDaemonTool = orig }) callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) { if tool == "tool_profile" { return json.RawMessage(cannedToolProfileJSON), nil } return json.RawMessage(`{}`), nil } cmd, buf := newCallTestCmd(t) callQuiet = true require.NoError(t, runCall(cmd, []string{"edit_file"})) require.NotContains(t, buf.String(), "note:") } // TestCall_NoDaemonSkipsValidation asserts that when the catalog fetch fails // (no daemon), validation is skipped and the normal call path runs — surfacing // whatever the call returns rather than an "unknown tool" error. func TestCall_NoDaemonSkipsValidation(t *testing.T) { orig := callDaemonTool t.Cleanup(func() { callDaemonTool = orig }) callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) { if tool == "tool_profile" { return nil, daemonRequiredErr(".") // no catalog available } require.Equal(t, "some_tool", tool, "the call must proceed despite the catalog miss") return json.RawMessage(`{"ok":true}`), nil } cmd, buf := newCallTestCmd(t) require.NoError(t, runCall(cmd, []string{"some_tool"})) require.Contains(t, buf.String(), `"ok": true`) } // TestCall_FormatGCXVerbatim asserts gcx/toon output is printed verbatim, not // re-indented through emitDaemonJSON. func TestCall_FormatGCXVerbatim(t *testing.T) { orig := callDaemonTool t.Cleanup(func() { callDaemonTool = orig }) callDaemonTool = func(_ string, tool string, args map[string]any) (json.RawMessage, error) { if tool == "tool_profile" { return json.RawMessage(cannedToolProfileJSON), nil } require.Equal(t, "gcx", args["format"], "the chosen format must be forwarded") return json.RawMessage("GCX1|results|...\n"), nil } cmd, buf := newCallTestCmd(t) callFormat = "gcx" require.NoError(t, runCall(cmd, []string{"search_symbols"})) require.Equal(t, "GCX1|results|...\n", buf.String()) } // TestCall_DaemonRequired asserts that, with no daemon running and no stub, the // real call path returns the actionable daemon-required error (mirrors // query_daemon_required_test.go). The catalog fetch fails first (no daemon), so // validation is skipped and the call itself surfaces the error. func TestCall_DaemonRequired(t *testing.T) { // Point at a temp dir no daemon could possibly track so resolveExecutor // returns ErrNoExecutor -> daemonRequiredErr. cmd, _ := newCallTestCmd(t) callIndex = t.TempDir() err := runCall(cmd, []string{"search_symbols"}) require.Error(t, err) require.Contains(t, err.Error(), "gortex track") } // writeTempJSON writes content to a temp file and returns its path. func writeTempJSON(t *testing.T, content string) string { t.Helper() dir := t.TempDir() path := dir + "/base.json" require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) return path }