package mcp import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/graph" ) // newSafeDeleteTestServer builds a server with one indexed file // containing two symbols: Live (called by Caller) and DeadCode // (unused). resolveNodePath uses the indexer root, so the test // server fakes an indexer with the temp root. func newSafeDeleteTestServer(t *testing.T) (*Server, string) { t.Helper() dir := t.TempDir() src := `package foo // DeadCode is an unused helper. func DeadCode() int { return 42 } // Live is used by Caller. func Live() int { return 1 } func Caller() int { return Live() } ` require.NoError(t, os.WriteFile(filepath.Join(dir, "foo.go"), []byte(src), 0o644)) g := graph.New() // We use absolute paths in FilePath so resolveFilePath takes the // absolute branch and skips indexer dependency. abs := filepath.Join(dir, "foo.go") g.AddNode(&graph.Node{ ID: abs + "::DeadCode", Name: "DeadCode", Kind: graph.KindFunction, FilePath: abs, StartLine: 4, EndLine: 6, }) g.AddNode(&graph.Node{ ID: abs + "::Live", Name: "Live", Kind: graph.KindFunction, FilePath: abs, StartLine: 9, EndLine: 11, }) g.AddNode(&graph.Node{ ID: abs + "::Caller", Name: "Caller", Kind: graph.KindFunction, FilePath: abs, StartLine: 13, EndLine: 15, }) g.AddEdge(&graph.Edge{From: abs + "::Caller", To: abs + "::Live", Kind: graph.EdgeCalls}) s := &Server{ graph: g, session: newSessionState(), tokenStats: &tokenStats{}, symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)}, sessions: newSessionMap(), toolScopes: newScopeRegistry(), } return s, dir } func callSafeDelete(t *testing.T, s *Server, args map[string]any) map[string]any { t.Helper() req := mcp.CallToolRequest{} req.Params.Arguments = args res, err := s.handleSafeDeleteSymbol(context.Background(), req) require.NoError(t, err) require.NotNil(t, res) if res.IsError { return map[string]any{"is_error": true, "content": res.Content} } tc, ok := res.Content[0].(mcp.TextContent) require.True(t, ok) var m map[string]any require.NoError(t, json.Unmarshal([]byte(tc.Text), &m)) return m } func TestSafeDelete_DryRunReturnsPreview(t *testing.T) { s, dir := newSafeDeleteTestServer(t) abs := filepath.Join(dir, "foo.go") out := callSafeDelete(t, s, map[string]any{ "id": abs + "::DeadCode", "dry_run": true, }) assert.Equal(t, "preview", out["status"]) assert.EqualValues(t, 0, out["reference_count"].(float64)) preview, _ := out["preview"].(string) assert.Contains(t, preview, "DeadCode") // Disk untouched — DeadCode still in file. body, _ := os.ReadFile(abs) assert.Contains(t, string(body), "func DeadCode()") } func TestSafeDelete_CommitRemovesSymbolAndDocComment(t *testing.T) { s, dir := newSafeDeleteTestServer(t) abs := filepath.Join(dir, "foo.go") out := callSafeDelete(t, s, map[string]any{ "id": abs + "::DeadCode", "dry_run": false, }) assert.Equal(t, "deleted", out["status"]) body, _ := os.ReadFile(abs) assert.NotContains(t, string(body), "func DeadCode()", "function removed") assert.NotContains(t, string(body), "DeadCode is an unused helper", "leading doc comment removed") assert.Contains(t, string(body), "func Live()", "Live untouched") } func TestSafeDelete_RejectsWhenReferenced(t *testing.T) { s, dir := newSafeDeleteTestServer(t) abs := filepath.Join(dir, "foo.go") out := callSafeDelete(t, s, map[string]any{ "id": abs + "::Live", "dry_run": false, }) assert.Equal(t, "rejected_has_references", out["status"]) assert.EqualValues(t, 1, out["reference_count"].(float64)) refs, _ := out["references"].([]any) require.Len(t, refs, 1) row := refs[0].(map[string]any) assert.Equal(t, abs+"::Caller", row["from_id"]) assert.Equal(t, "calls", row["kind"]) // Disk untouched. body, _ := os.ReadFile(abs) assert.Contains(t, string(body), "func Live()") } func TestSafeDelete_ForceBypassesReferences(t *testing.T) { s, dir := newSafeDeleteTestServer(t) abs := filepath.Join(dir, "foo.go") out := callSafeDelete(t, s, map[string]any{ "id": abs + "::Live", "dry_run": false, "force": true, }) assert.Equal(t, "deleted", out["status"]) body, _ := os.ReadFile(abs) assert.NotContains(t, string(body), "func Live()") } func TestSafeDelete_RejectsUnknownSymbol(t *testing.T) { s, _ := newSafeDeleteTestServer(t) out := callSafeDelete(t, s, map[string]any{ "id": "nonexistent::Foo", }) assert.True(t, out["is_error"] == true) } func TestSafeDelete_RejectsMissingID(t *testing.T) { s, _ := newSafeDeleteTestServer(t) out := callSafeDelete(t, s, map[string]any{}) assert.True(t, out["is_error"] == true) } func TestSafeDelete_DryRunWithReferencesPreservesRefList(t *testing.T) { s, dir := newSafeDeleteTestServer(t) abs := filepath.Join(dir, "foo.go") out := callSafeDelete(t, s, map[string]any{ "id": abs + "::Live", "dry_run": true, }) // References cause an immediate reject, regardless of dry_run. assert.Equal(t, "rejected_has_references", out["status"]) } func TestSafeDelete_RecordsSessionEdit(t *testing.T) { s, dir := newSafeDeleteTestServer(t) abs := filepath.Join(dir, "foo.go") out := callSafeDelete(t, s, map[string]any{ "id": abs + "::DeadCode", "dry_run": false, }) require.Equal(t, "deleted", out["status"]) mods := s.symHistory.Get(abs + "::DeadCode") require.Len(t, mods, 1) assert.True(t, mods[0].SignatureChanged, "delete is a signature change") } func TestSafeDelete_LinesDeletedAccurate(t *testing.T) { s, dir := newSafeDeleteTestServer(t) abs := filepath.Join(dir, "foo.go") out := callSafeDelete(t, s, map[string]any{ "id": abs + "::DeadCode", "dry_run": true, }) // Lines 3-7 (doc comment line 3, func lines 4-6, blank line 7). assert.EqualValues(t, 3, out["start_line"].(float64)) assert.GreaterOrEqual(t, out["lines_deleted"].(float64), 4.0) preview, _ := out["preview"].(string) assert.Equal(t, strings.Count(preview, "\n")+1, int(out["lines_deleted"].(float64))) } func TestCollectReferencingEdges_FiltersStructuralEdges(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "a", Name: "a", Kind: graph.KindFunction}) g.AddNode(&graph.Node{ID: "b", Name: "b", Kind: graph.KindFunction}) g.AddNode(&graph.Node{ID: "file", Name: "file", Kind: graph.KindFile}) // One real reference (calls) and one structural (defines). g.AddEdge(&graph.Edge{From: "b", To: "a", Kind: graph.EdgeCalls}) g.AddEdge(&graph.Edge{From: "file", To: "a", Kind: graph.EdgeDefines}) refs := collectReferencingEdges(g, "a") require.Len(t, refs, 1) assert.Equal(t, "calls", refs[0].Kind) assert.Equal(t, "b", refs[0].FromID) }