Skip to main content

codesynapse_mcp/
mcp.rs

1use codesynapse_core::analyze::Analyzer;
2use codesynapse_core::embedding::StaticEmbedder;
3use codesynapse_core::error::Result;
4use codesynapse_core::global_graph::global_list;
5use codesynapse_core::graph::{GraphStore, SledGraphStore, StoreBackend};
6use codesynapse_core::types::{Edge, Node};
7use codesynapse_serve::context::context_query;
8use codesynapse_serve::graph_query::{
9    load_graph, query_graph_text_hybrid, query_top_nodes, ServeEdge, ServeGraph, ServeNode,
10};
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14use std::collections::HashMap;
15use std::io::{self, BufRead, Write};
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18use std::time::SystemTime;
19use walkdir::WalkDir;
20
21// ---------------------------------------------------------------------------
22// JSON-RPC 2.0 types
23// ---------------------------------------------------------------------------
24
25#[derive(Serialize)]
26struct JsonRpcResponse {
27    jsonrpc: String,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    id: Option<Value>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    result: Option<Value>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    error: Option<JsonRpcError>,
34}
35
36#[derive(Serialize)]
37struct JsonRpcError {
38    code: i32,
39    message: String,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    data: Option<Value>,
42}
43
44#[derive(Deserialize)]
45struct JsonRpcRequest {
46    #[serde(default)]
47    id: Option<Value>,
48    method: String,
49    #[serde(default)]
50    params: Option<Value>,
51}
52
53// ---------------------------------------------------------------------------
54// Server instructions — injected into agent system prompt via MCP initialize
55// ---------------------------------------------------------------------------
56
57pub const CODESYNAPSE_INSTRUCTIONS: &str = "\
58Use Codesynapse MCP tools before reading files or answering architecture questions.\n\
59NEVER use grep, find, Bash, or subagents to answer 'how does X work' questions.\n\
60Subagents do not inherit MCP — they fall back to grep. Always use tools directly.\n\
61\n\
62Tool selection — follow this hierarchy exactly:\n\
63\n\
64STEP 1 — ALWAYS call codesynapse_context FIRST for ANY of these:\n\
65  'How does X work?', 'What handles Y?', 'Where is Z?', 'Explain the mechanism for...'\n\
66  Natural language OR symbol name both work.\n\
67  If result does NOT start with '[No exact match' → review before answering:\n\
68    For 'what handles / what builds / what processes / what manages' questions:\n\
69      Check that results contain a HANDLER or BUILDER (a function/class that catches,\n\
70      dispatches, or constructs the thing) — not just code that raises or invokes it.\n\
71      If results only show WHERE something is raised/called (e.g. views raising NotFound)\n\
72      but not WHO handles it:\n\
73        1. Identify the exception/concept class name from the results (e.g. NotFound, Http404)\n\
74        2. Call codesynapse_resolve(\"<ClassName> handler\") — this surfaces registered\n\
75           callbacks and entry points that string-based config (Django settings, Spring\n\
76           @Bean, Express app.use) wires up invisibly to the graph.\n\
77        Do NOT fall back to grep — codesynapse_resolve finds entry points grep cannot.\n\
78    Otherwise → answer directly from it.\n\
79\n\
80STEP 2 — If codesynapse_context starts with '[No exact match — showing semantic results]':\n\
81  Identify specific type/function names in the results (CamelCase classes, snake_case functions).\n\
82  Call codesynapse_context(\"ExactName\") with EACH name — exact match returns callers+callees.\n\
83  For request flow questions this surfaces ALL callers of a service in one call.\n\
84  If no names found → call codesynapse_resolve(\"question\") for deeper hybrid search.\n\
85\n\
86STEP 2b — If exploring a multi-component mechanism (scheduler, I/O pipeline, runtime):\n\
87  Check results for TWO warning signs — either one means you have ONE layer, not architecture:\n\
88    WARNING A: All results are from the SAME directory or file.\n\
89    WARNING B: All results are methods/functions — no struct, enum, impl, or module entries.\n\
90  If either warning fires after 2-3 calls, DO NOT answer yet. Zoom out first:\n\
91    1. codesynapse_list_graphs()                   — identify the right module name\n\
92    2. codesynapse_module_summary(\"module\")         — get top-level architectural map\n\
93    3. Find the main struct/enum in the summary (e.g. Runtime, Scheduler, Worker)\n\
94    4. codesynapse_context(\"StructName\")            — exact match returns fields + callers\n\
95    5. codesynapse_read_with_callees(\"Struct\", \"method\") — gets method body + everything it calls in one shot\n\
96       This surfaces adjacent methods (LIFO slot, batching, dynamic tuning) without knowing their names upfront.\n\
97       Fall back to codesynapse_read_method only for small leaf methods.\n\
98  Use codesynapse_read_method(\"TypeName\", \"methodName\") to get COMPLETE method bodies\n\
99  when context returns a partial snippet and you need full accuracy.\n\
100\n\
101STEP 3 — codesynapse_query_vector: ONLY for structural ownership questions:\n\
102  'Which module owns X?', 'What class manages Y?' — NOT for mechanism questions.\n\
103  Do NOT grep/find to verify graph results — trust the graph.\n\
104\n\
105Other tools (use only when specifically needed):\n\
106  About to change a class               → codesynapse_blast_radius(\"ClassName\")\n\
107  Multiple classes changing (PR review) → codesynapse_blast_radius_multi([\"A\",\"B\",\"C\"])\n\
108  Blast radius + risk scores            → codesynapse_blast_radius_scored(\"ClassName\")\n\
109  Class inheritance tree                → codesynapse_hierarchy(\"ClassName\")\n\
110  Overview of a module                  → codesynapse_list_graphs() then codesynapse_module_summary(\"name\")\n\
111  Read/edit a class                     → codesynapse_outline(\"ClassName\") then codesynapse_read(...)\n\
112  Know exact method to read             → codesynapse_read_method(\"ClassName\", \"methodName\")\n\
113  Need method + what it calls           → codesynapse_read_with_callees(\"ClassName\", \"methodName\")\n\
114  Who calls X.method?                   → codesynapse_find_callers(\"ClassName\", \"methodName\")\n\
115  Request flow trace (controller→svc→DB) → context(\"ExactServiceClass\") — shows callers+callees\n\
116  All files referencing a class         → codesynapse_find_usages(\"ClassName\")\n\
117  Graph stale after module refresh      → codesynapse_build()\n\
118  Session usage + token savings         → codesynapse_stats()\n\
119\n\
120Graph selection:\n\
121  Default graph is \"merged\" (all modules combined). Use a module-specific graph\n\
122  when merged returns too many results or unrelated modules bleed in (>500 nodes):\n\
123    codesynapse_list_graphs()                          — see all modules with node counts\n\
124    codesynapse_query_vector(\"X\", graph=\"mymodule\")   — scope to one module\n\
125    codesynapse_module_summary(\"mymodule\")             — overview before diving in\n\
126  If results keep returning the wrong module → remove it:\n\
127    codesynapse module remove <name>\n\
128\n\
129Add a new module:\n\
130  1. In terminal: codesynapse module add <name> /absolute/path\n\
131  2. Back here:   codesynapse_build()";
132
133// ---------------------------------------------------------------------------
134// Tool definition
135// ---------------------------------------------------------------------------
136
137#[derive(Serialize)]
138struct ToolDef {
139    name: String,
140    description: String,
141    input_schema: Value,
142}
143
144fn tool_defs() -> Vec<ToolDef> {
145    vec![
146        ToolDef {
147            name: "codesynapse_query_graph".into(),
148            description: "Query the knowledge graph using natural language".into(),
149            input_schema: serde_json::json!({
150                "type": "object",
151                "properties": {
152                    "question": {"type": "string"},
153                    "budget": {"type": "integer", "default": 5}
154                },
155                "required": ["question"]
156            }),
157        },
158        ToolDef {
159            name: "codesynapse_get_node".into(),
160            description: "Get a node by its ID".into(),
161            input_schema: serde_json::json!({
162                "type": "object",
163                "properties": {"node_id": {"type": "string"}},
164                "required": ["node_id"]
165            }),
166        },
167        ToolDef {
168            name: "codesynapse_get_neighbors".into(),
169            description: "Get neighbors of a node up to a given depth".into(),
170            input_schema: serde_json::json!({
171                "type": "object",
172                "properties": {
173                    "node_id": {"type": "string"},
174                    "depth": {"type": "integer", "default": 1},
175                    "limit": {"type": "integer", "default": 50},
176                    "offset": {"type": "integer", "default": 0}
177                },
178                "required": ["node_id"]
179            }),
180        },
181        ToolDef {
182            name: "codesynapse_get_community".into(),
183            description: "Get nodes in a community by ID".into(),
184            input_schema: serde_json::json!({
185                "type": "object",
186                "properties": {
187                    "community_id": {"type": "integer"},
188                    "limit": {"type": "integer", "default": 50},
189                    "offset": {"type": "integer", "default": 0}
190                },
191                "required": ["community_id"]
192            }),
193        },
194        ToolDef {
195            name: "codesynapse_god_nodes".into(),
196            description: "Find the most connected (god) nodes".into(),
197            input_schema: serde_json::json!({
198                "type": "object",
199                "properties": {"top_n": {"type": "integer", "default": 10}},
200                "required": []
201            }),
202        },
203        ToolDef {
204            name: "codesynapse_graph_stats".into(),
205            description: "Get overall graph statistics".into(),
206            input_schema: serde_json::json!({
207                "type": "object",
208                "properties": {},
209                "required": []
210            }),
211        },
212        ToolDef {
213            name: "codesynapse_shortest_path".into(),
214            description: "Find the shortest path between two nodes (BFS)".into(),
215            input_schema: serde_json::json!({
216                "type": "object",
217                "properties": {
218                    "source": {"type": "string"},
219                    "target": {"type": "string"}
220                },
221                "required": ["source", "target"]
222            }),
223        },
224        ToolDef {
225            name: "codesynapse_find_all_paths".into(),
226            description: "Find all paths between two nodes up to a max length".into(),
227            input_schema: serde_json::json!({
228                "type": "object",
229                "properties": {
230                    "source": {"type": "string"},
231                    "target": {"type": "string"},
232                    "max_length": {"type": "integer", "default": 5}
233                },
234                "required": ["source", "target"]
235            }),
236        },
237        ToolDef {
238            name: "codesynapse_weighted_path".into(),
239            description: "Find the weighted shortest path using Dijkstra".into(),
240            input_schema: serde_json::json!({
241                "type": "object",
242                "properties": {
243                    "source": {"type": "string"},
244                    "target": {"type": "string"},
245                    "min_confidence": {"type": "number", "default": 0.0}
246                },
247                "required": ["source", "target"]
248            }),
249        },
250        ToolDef {
251            name: "codesynapse_community_bridges".into(),
252            description: "Find bridge edges that connect communities".into(),
253            input_schema: serde_json::json!({
254                "type": "object",
255                "properties": {"top_n": {"type": "integer", "default": 10}},
256                "required": []
257            }),
258        },
259        ToolDef {
260            name: "codesynapse_diff".into(),
261            description: "Compare two graphs and find added/removed edges".into(),
262            input_schema: serde_json::json!({
263                "type": "object",
264                "properties": {"other_graph": {"type": "string"}},
265                "required": ["other_graph"]
266            }),
267        },
268        ToolDef {
269            name: "codesynapse_pagerank".into(),
270            description: "Compute PageRank scores for all nodes".into(),
271            input_schema: serde_json::json!({
272                "type": "object",
273                "properties": {
274                    "top_n": {"type": "integer", "default": 10},
275                    "damping": {"type": "number", "default": 0.85},
276                    "max_iter": {"type": "integer", "default": 100}
277                },
278                "required": []
279            }),
280        },
281        ToolDef {
282            name: "codesynapse_detect_cycles".into(),
283            description: "Detect cycles (strongly connected components) in the graph".into(),
284            input_schema: serde_json::json!({
285                "type": "object",
286                "properties": {"max_cycles": {"type": "integer", "default": 10}},
287                "required": []
288            }),
289        },
290        ToolDef {
291            name: "codesynapse_smart_summary".into(),
292            description: "Generate a multi-level summary of the graph".into(),
293            input_schema: serde_json::json!({
294                "type": "object",
295                "properties": {
296                    "level": {"type": "string", "enum": ["detailed", "community", "architecture"], "default": "architecture"},
297                    "budget": {"type": "integer", "default": 100}
298                },
299                "required": []
300            }),
301        },
302        ToolDef {
303            name: "codesynapse_find_similar".into(),
304            description: "Find structurally similar nodes (requires Node2Vec)".into(),
305            input_schema: serde_json::json!({
306                "type": "object",
307                "properties": {
308                    "top_n": {"type": "integer", "default": 10},
309                    "node_id": {"type": "string"}
310                },
311                "required": ["node_id"]
312            }),
313        },
314        ToolDef {
315            name: "codesynapse_query_vector".into(),
316            description: "Structural ownership search — use ONLY when the question is 'what class/module handles X?' or 'what manages Y?' where you need to find which component owns a concept. NOT for mechanism questions ('how does X work') — use codesynapse_context for those. Returns a ranked node list, no source bodies.".into(),
317            input_schema: serde_json::json!({
318                "type": "object",
319                "properties": {
320                    "query": {"type": "string"},
321                    "graph": {"type": "string", "default": "merged"},
322                    "top_k": {"type": "integer", "default": 8}
323                },
324                "required": ["query"]
325            }),
326        },
327        ToolDef {
328            name: "codesynapse_blast_radius".into(),
329            description: "Find all nodes reachable from a class within N hops".into(),
330            input_schema: serde_json::json!({
331                "type": "object",
332                "properties": {
333                    "class_name": {"type": "string"},
334                    "graph": {"type": "string", "default": "merged"},
335                    "depth": {"type": "integer", "default": 3}
336                },
337                "required": ["class_name"]
338            }),
339        },
340        ToolDef {
341            name: "codesynapse_blast_radius_scored".into(),
342            description: "Blast radius of a class with risk scores (0.0–1.0) per affected node. Risk factors: security/payment keywords, high in-degree, no test coverage. Sorted HIGH→MEDIUM→LOW.".into(),
343            input_schema: serde_json::json!({
344                "type": "object",
345                "properties": {
346                    "class_name": {"type": "string"},
347                    "graph": {"type": "string", "default": "merged"},
348                    "depth": {"type": "integer", "default": 3}
349                },
350                "required": ["class_name"]
351            }),
352        },
353        ToolDef {
354            name: "codesynapse_blast_radius_multi".into(),
355            description: "Combined blast radius for multiple classes in one call. BFS from all seeds simultaneously, union of affected nodes grouped by hop distance.".into(),
356            input_schema: serde_json::json!({
357                "type": "object",
358                "properties": {
359                    "class_names": {"type": "array", "items": {"type": "string"}},
360                    "graph": {"type": "string", "default": "merged"},
361                    "depth": {"type": "integer", "default": 3}
362                },
363                "required": ["class_names"]
364            }),
365        },
366        ToolDef {
367            name: "codesynapse_query_semantic".into(),
368            description: "Traverse semantically_similar_to edges from seed nodes. Finds functionally related nodes by meaning. Requires graph built with --llm flag; returns graceful fallback if no semantic edges exist.".into(),
369            input_schema: serde_json::json!({
370                "type": "object",
371                "properties": {
372                    "query": {"type": "string"},
373                    "graph": {"type": "string", "default": "merged"},
374                    "depth": {"type": "integer", "default": 2},
375                    "min_confidence": {"type": "number", "default": 0.7}
376                },
377                "required": ["query"]
378            }),
379        },
380        ToolDef {
381            name: "codesynapse_hierarchy".into(),
382            description: "Show class inheritance tree (supertypes and implementors)".into(),
383            input_schema: serde_json::json!({
384                "type": "object",
385                "properties": {
386                    "class_name": {"type": "string"},
387                    "graph": {"type": "string", "default": "merged"}
388                },
389                "required": ["class_name"]
390            }),
391        },
392        ToolDef {
393            name: "codesynapse_list_graphs".into(),
394            description: "List all registered graph modules with node/edge counts".into(),
395            input_schema: serde_json::json!({
396                "type": "object",
397                "properties": {},
398                "required": []
399            }),
400        },
401        ToolDef {
402            name: "codesynapse_module_summary".into(),
403            description: "Node count, edge count, top god-nodes and language breakdown for a module".into(),
404            input_schema: serde_json::json!({
405                "type": "object",
406                "properties": {
407                    "module": {"type": "string"}
408                },
409                "required": ["module"]
410            }),
411        },
412        ToolDef {
413            name: "codesynapse_outline".into(),
414            description: "Get compact structural outline of a class (methods, fields, line numbers) via the knowledge graph".into(),
415            input_schema: serde_json::json!({
416                "type": "object",
417                "properties": {
418                    "class_name": {"type": "string"},
419                    "graph": {"type": "string", "default": "merged"}
420                },
421                "required": ["class_name"]
422            }),
423        },
424        ToolDef {
425            name: "codesynapse_read".into(),
426            description: "Read specific lines from a class's source file resolved via the knowledge graph".into(),
427            input_schema: serde_json::json!({
428                "type": "object",
429                "properties": {
430                    "class_name": {"type": "string"},
431                    "from_line": {"type": "integer", "default": 1},
432                    "to_line": {"type": "integer", "default": 0},
433                    "graph": {"type": "string", "default": "merged"}
434                },
435                "required": ["class_name"]
436            }),
437        },
438        ToolDef {
439            name: "codesynapse_read_method".into(),
440            description: "Read a specific method body — resolves class → file → finds method via brace tracking".into(),
441            input_schema: serde_json::json!({
442                "type": "object",
443                "properties": {
444                    "class_name": {"type": "string"},
445                    "method_name": {"type": "string"},
446                    "graph": {"type": "string", "default": "merged"}
447                },
448                "required": ["class_name", "method_name"]
449            }),
450        },
451        ToolDef {
452            name: "codesynapse_read_with_callees".into(),
453            description: "Read a method AND inline bodies of same-class methods it calls".into(),
454            input_schema: serde_json::json!({
455                "type": "object",
456                "properties": {
457                    "class_name": {"type": "string"},
458                    "method_name": {"type": "string"},
459                    "depth": {"type": "integer", "default": 1},
460                    "graph": {"type": "string", "default": "merged"}
461                },
462                "required": ["class_name", "method_name"]
463            }),
464        },
465        ToolDef {
466            name: "codesynapse_find_callers".into(),
467            description: "Find all callers of a class or method via graph edges, then source text search fallback".into(),
468            input_schema: serde_json::json!({
469                "type": "object",
470                "properties": {
471                    "class_name": {"type": "string"},
472                    "method_name": {"type": "string", "default": ""},
473                    "graph": {"type": "string", "default": "merged"}
474                },
475                "required": ["class_name"]
476            }),
477        },
478        ToolDef {
479            name: "codesynapse_find_usages".into(),
480            description: "Find all source files that reference a class (imports, fields, parameters, annotations)".into(),
481            input_schema: serde_json::json!({
482                "type": "object",
483                "properties": {
484                    "class_name": {"type": "string"},
485                    "graph": {"type": "string", "default": "merged"}
486                },
487                "required": ["class_name"]
488            }),
489        },
490        ToolDef {
491            name: "codesynapse_context".into(),
492            description: concat!(
493                "PRIMARY tool — call this FIRST for ALL mechanism, architecture, and 'how does X work' questions. ",
494                "Accepts natural language or a symbol name. ",
495                "Finds entry points via symbol matching, expands one hop via call graph edges, ",
496                "and returns full source bodies with line numbers. ",
497                "Falls back to semantic search when no exact symbol match is found. ",
498                "Answer directly from this output — do NOT call resolve or query_vector afterward. ",
499                "For request flow questions: if result is semantic fallback, call context(\"ExactClassName\") ",
500                "with specific class names found in results — exact name triggers call-graph expansion, ",
501                "showing all callers (e.g. every controller that uses a service) and callees in one shot."
502            ).into(),
503            input_schema: serde_json::json!({
504                "type": "object",
505                "properties": {
506                    "query":     {"type": "string", "description": "Natural-language question or symbol name — accepts both"},
507                    "graph":     {"type": "string", "default": "merged"},
508                    "max_chars": {"type": "integer", "default": 16000, "description": "Response size cap"}
509                },
510                "required": ["query"]
511            }),
512        },
513        ToolDef {
514            name: "codesynapse_resolve".into(),
515            description: concat!(
516                "FALLBACK ONLY — use only when codesynapse_context returns empty or no results. ",
517                "Runs hybrid BM25+dense search and returns outlines + top method bodies. ",
518                "Do NOT use as the first tool for 'how does X work' questions — use codesynapse_context first."
519            ).into(),
520            input_schema: serde_json::json!({
521                "type": "object",
522                "properties": {
523                    "query":     {"type": "string", "description": "Natural-language question or concept"},
524                    "graph":     {"type": "string", "default": "merged"},
525                    "top_k":     {"type": "integer", "default": 5, "description": "Max classes to include"},
526                    "max_chars": {"type": "integer", "default": 24000, "description": "Response size cap"}
527                },
528                "required": ["query"]
529            }),
530        },
531        ToolDef {
532            name: "codesynapse_build".into(),
533            description: "Reload the knowledge graph from disk — call after bootstrap to pick up changes without MCP restart".into(),
534            input_schema: serde_json::json!({
535                "type": "object",
536                "properties": {
537                    "module": {"type": "string", "default": ""}
538                },
539                "required": []
540            }),
541        },
542        ToolDef {
543            name: "codesynapse_stats".into(),
544            description: "Show codesynapse tool usage and estimated token savings across all sessions".into(),
545            input_schema: serde_json::json!({
546                "type": "object",
547                "properties": {},
548                "required": []
549            }),
550        },
551    ]
552}
553
554// ---------------------------------------------------------------------------
555// Outline helpers (Phase 3a)
556// ---------------------------------------------------------------------------
557
558#[derive(Clone)]
559pub(crate) struct OutlineItem {
560    kind: String,
561    name: String,
562    line: usize, // 1-indexed
563}
564
565fn outline_items(path: &Path, content: &str) -> Vec<OutlineItem> {
566    let ext = path
567        .extension()
568        .and_then(|e| e.to_str())
569        .unwrap_or("")
570        .to_lowercase();
571    match ext.as_str() {
572        "py" => outline_python(content),
573        "js" | "ts" | "tsx" | "jsx" => outline_js(content),
574        "rs" => outline_rust(content),
575        "go" => outline_go(content),
576        "rb" => outline_ruby(content),
577        "ex" | "exs" => outline_elixir(content),
578        _ => outline_java(content),
579    }
580}
581
582fn outline_java(content: &str) -> Vec<OutlineItem> {
583    let class_re = Regex::new(
584        r"(?i)^\s*(?:(?:public|protected|private|abstract|final|static)\s+)*(class|interface|enum|record)\s+(\w+)"
585    ).unwrap();
586    let method_re = Regex::new(
587        r"^\s*(?:(?:public|protected|private|static|final|abstract|synchronized|native|default|override)\s+)+(?:[\w<>\[\],\s]+\s+)(\w+)\s*\("
588    ).unwrap();
589    let field_re = Regex::new(
590        r"^\s*(?:(?:public|protected|private|static|final|volatile|transient)\s+)+([\w<>\[\]]+)\s+(\w+)\s*[=;]"
591    ).unwrap();
592    let mut items = Vec::new();
593    for (i, line) in content.lines().enumerate() {
594        let lineno = i + 1;
595        if let Some(cap) = class_re.captures(line) {
596            items.push(OutlineItem {
597                kind: cap[1].to_lowercase(),
598                name: cap[2].to_string(),
599                line: lineno,
600            });
601        } else if let Some(cap) = method_re.captures(line) {
602            let name = cap[1].to_string();
603            if !line.split('(').next().is_some_and(|s| s.contains('=')) {
604                items.push(OutlineItem {
605                    kind: "method".into(),
606                    name,
607                    line: lineno,
608                });
609            }
610        } else if let Some(cap) = field_re.captures(line) {
611            items.push(OutlineItem {
612                kind: "field".into(),
613                name: cap[2].to_string(),
614                line: lineno,
615            });
616        }
617    }
618    items
619}
620
621fn outline_python(content: &str) -> Vec<OutlineItem> {
622    let class_re = Regex::new(r"^class\s+(\w+)").unwrap();
623    let method_re = Regex::new(r"^(\s+)def\s+(\w+)\s*\(").unwrap();
624    let func_re = Regex::new(r"^def\s+(\w+)\s*\(").unwrap();
625    let mut items = Vec::new();
626    for (i, line) in content.lines().enumerate() {
627        let lineno = i + 1;
628        if let Some(cap) = class_re.captures(line) {
629            items.push(OutlineItem {
630                kind: "class".into(),
631                name: cap[1].into(),
632                line: lineno,
633            });
634        } else if let Some(cap) = method_re.captures(line) {
635            items.push(OutlineItem {
636                kind: "method".into(),
637                name: cap[2].into(),
638                line: lineno,
639            });
640        } else if let Some(cap) = func_re.captures(line) {
641            items.push(OutlineItem {
642                kind: "method".into(),
643                name: cap[1].into(),
644                line: lineno,
645            });
646        }
647    }
648    items
649}
650
651fn outline_js(content: &str) -> Vec<OutlineItem> {
652    const KEYWORDS: &[&str] = &[
653        "if",
654        "for",
655        "while",
656        "switch",
657        "catch",
658        "return",
659        "const",
660        "let",
661        "var",
662        "import",
663        "export",
664        "new",
665        "throw",
666        "case",
667        "default",
668        "else",
669        "try",
670        "do",
671        "typeof",
672        "instanceof",
673        "function",
674    ];
675    let class_re = Regex::new(
676        r"^\s*(?:export\s+(?:default\s+)?)?(?:abstract\s+)?(class|interface|type|enum)\s+(\w+)",
677    )
678    .unwrap();
679    let method_re = Regex::new(r"^\s*(?:(?:async|static|get|set)\s+)*(\w+)\s*\(").unwrap();
680    let field_re =
681        Regex::new(r"^\s*(?:(?:public|private|protected|readonly|static)\s+)+(\w+)\s*[?!:=]")
682            .unwrap();
683    let mut items = Vec::new();
684    for (i, line) in content.lines().enumerate() {
685        let lineno = i + 1;
686        let stripped = line.trim();
687        if stripped.starts_with("//") || stripped.starts_with('*') {
688            continue;
689        }
690        if let Some(cap) = class_re.captures(line) {
691            items.push(OutlineItem {
692                kind: cap[1].to_lowercase(),
693                name: cap[2].into(),
694                line: lineno,
695            });
696        } else if line.contains('(') {
697            if let Some(cap) = method_re.captures(line) {
698                let name = cap[1].to_string();
699                if !KEYWORDS.contains(&name.as_str()) {
700                    items.push(OutlineItem {
701                        kind: "method".into(),
702                        name,
703                        line: lineno,
704                    });
705                }
706            }
707        } else if let Some(cap) = field_re.captures(line) {
708            let name = cap[1].to_string();
709            if !KEYWORDS.contains(&name.as_str()) {
710                items.push(OutlineItem {
711                    kind: "field".into(),
712                    name,
713                    line: lineno,
714                });
715            }
716        }
717    }
718    items
719}
720
721fn outline_rust(content: &str) -> Vec<OutlineItem> {
722    let type_re =
723        Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|type)\s+(\w+)").unwrap();
724    // impl Trait for Type  →  capture Type; impl Type  →  capture Type
725    let impl_for_re = Regex::new(r"^\s*impl[^{]*\bfor\s+(\w+)").unwrap();
726    let impl_re = Regex::new(r"^\s*impl(?:\s*<[^>]*>)?\s+(\w+)").unwrap();
727    let fn_re = Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+(\w+)").unwrap();
728
729    let mut items = Vec::new();
730    for (i, line) in content.lines().enumerate() {
731        let lineno = i + 1;
732        let stripped = line.trim();
733        if stripped.starts_with("//") || stripped.starts_with('*') || stripped.starts_with('#') {
734            continue;
735        }
736        if let Some(cap) = type_re.captures(line) {
737            let kind = if line.contains("struct") {
738                "class"
739            } else if line.contains("trait") {
740                "interface"
741            } else {
742                "enum"
743            };
744            items.push(OutlineItem {
745                kind: kind.into(),
746                name: cap[1].into(),
747                line: lineno,
748            });
749        } else if let Some(cap) = impl_for_re.captures(line) {
750            items.push(OutlineItem {
751                kind: "class".into(),
752                name: cap[1].into(),
753                line: lineno,
754            });
755        } else if let Some(cap) = impl_re.captures(line) {
756            items.push(OutlineItem {
757                kind: "class".into(),
758                name: cap[1].into(),
759                line: lineno,
760            });
761        } else if let Some(cap) = fn_re.captures(line) {
762            items.push(OutlineItem {
763                kind: "method".into(),
764                name: cap[1].into(),
765                line: lineno,
766            });
767        }
768    }
769    items
770}
771
772fn outline_go(content: &str) -> Vec<OutlineItem> {
773    let type_re = Regex::new(r"^\s*type\s+(\w+)\s+(?:struct|interface)").unwrap();
774    let fn_re = Regex::new(r"^\s*func\s+(?:\([^)]*\)\s+)?(\w+)\s*\(").unwrap();
775
776    let mut items = Vec::new();
777    for (i, line) in content.lines().enumerate() {
778        let lineno = i + 1;
779        let stripped = line.trim();
780        if stripped.starts_with("//") {
781            continue;
782        }
783        if let Some(cap) = type_re.captures(line) {
784            let kind = if line.contains("interface") {
785                "interface"
786            } else {
787                "class"
788            };
789            items.push(OutlineItem {
790                kind: kind.into(),
791                name: cap[1].into(),
792                line: lineno,
793            });
794        } else if let Some(cap) = fn_re.captures(line) {
795            items.push(OutlineItem {
796                kind: "method".into(),
797                name: cap[1].into(),
798                line: lineno,
799            });
800        }
801    }
802    items
803}
804
805fn outline_ruby(content: &str) -> Vec<OutlineItem> {
806    let class_re = Regex::new(r"^\s*(?:class|module)\s+(\w+)").unwrap();
807    let method_re = Regex::new(r"^\s*def\s+(self\.)?(\w+)").unwrap();
808
809    let mut items = Vec::new();
810    for (i, line) in content.lines().enumerate() {
811        let lineno = i + 1;
812        let stripped = line.trim();
813        if stripped.starts_with('#') {
814            continue;
815        }
816        if let Some(cap) = class_re.captures(line) {
817            items.push(OutlineItem {
818                kind: "class".into(),
819                name: cap[1].into(),
820                line: lineno,
821            });
822        } else if let Some(cap) = method_re.captures(line) {
823            items.push(OutlineItem {
824                kind: "method".into(),
825                name: cap[2].into(),
826                line: lineno,
827            });
828        }
829    }
830    items
831}
832
833fn outline_elixir(content: &str) -> Vec<OutlineItem> {
834    let module_re = Regex::new(r"^\s*defmodule\s+([\w.]+)").unwrap();
835    let fn_re = Regex::new(r"^\s*(?:def|defp|defmacro|defmacrop)\s+(\w+)").unwrap();
836
837    let mut items = Vec::new();
838    for (i, line) in content.lines().enumerate() {
839        let lineno = i + 1;
840        let stripped = line.trim();
841        if stripped.starts_with('#') {
842            continue;
843        }
844        if let Some(cap) = module_re.captures(line) {
845            items.push(OutlineItem {
846                kind: "class".into(),
847                name: cap[1].into(),
848                line: lineno,
849            });
850        } else if let Some(cap) = fn_re.captures(line) {
851            items.push(OutlineItem {
852                kind: "method".into(),
853                name: cap[1].into(),
854                line: lineno,
855            });
856        }
857    }
858    items
859}
860
861fn detect_method_end(lines: &[&str], start: usize) -> usize {
862    let mut depth = 0i32;
863    let mut opened = false;
864    for (i, line) in lines[start..].iter().enumerate() {
865        depth += line.chars().filter(|&c| c == '{').count() as i32;
866        depth -= line.chars().filter(|&c| c == '}').count() as i32;
867        if depth > 0 {
868            opened = true;
869        }
870        if opened && depth <= 0 {
871            return start + i;
872        }
873    }
874    lines.len().saturating_sub(1)
875}
876
877fn extract_method_range(
878    lines: &[&str],
879    outline: &[OutlineItem],
880    method_name: &str,
881) -> Option<(usize, usize)> {
882    let lower = method_name.to_lowercase();
883    for item in outline {
884        if matches!(item.kind.as_str(), "method" | "function")
885            && item.name.to_lowercase().starts_with(&lower)
886        {
887            let start_0 = item.line - 1;
888            let end_0 = detect_method_end(lines, start_0);
889            return Some((item.line, end_0 + 1));
890        }
891    }
892    None
893}
894
895fn source_roots_from_graph(g: &ServeGraph) -> Vec<PathBuf> {
896    let paths: Vec<PathBuf> = g
897        .nodes_iter()
898        .map(|(_, n)| PathBuf::from(&n.source_file))
899        .filter(|p| p.is_absolute())
900        .collect();
901    if paths.is_empty() {
902        return Vec::new();
903    }
904    let mut common = paths[0].clone();
905    for p in &paths[1..] {
906        while !p.starts_with(&common) {
907            common = match common.parent() {
908                Some(par) => par.to_path_buf(),
909                None => return vec![PathBuf::from("/")],
910            };
911        }
912    }
913    if common.as_os_str().is_empty() {
914        return Vec::new();
915    }
916    vec![common]
917}
918
919fn find_nodes_by_label<'a>(g: &'a ServeGraph, name: &str) -> Vec<(&'a str, &'a ServeNode)> {
920    let lower = name.to_lowercase();
921    let mut exact: Vec<(&str, &ServeNode)> = Vec::new();
922    let mut partial: Vec<(&str, &ServeNode)> = Vec::new();
923    for (id, node) in g.nodes_iter() {
924        let nl = node.label.to_lowercase();
925        if nl == lower {
926            exact.push((id, node));
927        } else if nl.contains(&lower) {
928            partial.push((id, node));
929        }
930    }
931    partial.sort_by_key(|(_, n)| n.label.len());
932    exact.extend(partial);
933    exact
934}
935
936fn search_source_files(
937    roots: &[PathBuf],
938    exts: &[&str],
939    pattern: &Regex,
940    max_hits: usize,
941) -> Vec<(String, usize, String)> {
942    let mut hits = Vec::new();
943    'outer: for root in roots {
944        for entry in WalkDir::new(root)
945            .follow_links(false)
946            .into_iter()
947            .filter_map(|e| e.ok())
948        {
949            if !entry.file_type().is_file() {
950                continue;
951            }
952            let ext = entry
953                .path()
954                .extension()
955                .and_then(|e| e.to_str())
956                .unwrap_or("");
957            if !exts.contains(&ext) {
958                continue;
959            }
960            if let Ok(content) = std::fs::read_to_string(entry.path()) {
961                let rel = entry
962                    .path()
963                    .strip_prefix(root)
964                    .unwrap_or(entry.path())
965                    .to_string_lossy()
966                    .to_string();
967                for (lineno, line) in content.lines().enumerate() {
968                    if pattern.is_match(line) {
969                        let snippet: String = line.trim().chars().take(120).collect();
970                        hits.push((rel.clone(), lineno + 1, snippet));
971                        if hits.len() >= max_hits {
972                            break 'outer;
973                        }
974                    }
975                }
976            }
977        }
978    }
979    hits
980}
981
982// ---------------------------------------------------------------------------
983// MCP Server
984// ---------------------------------------------------------------------------
985
986type SourceCacheMap = HashMap<PathBuf, (SystemTime, String, Vec<OutlineItem>)>;
987
988pub struct McpServer {
989    backend: Mutex<StoreBackend>,
990    graph_path: Option<PathBuf>,
991    last_mtime: Mutex<Option<SystemTime>>,
992    global_dir: PathBuf,
993    embedder: Option<StaticEmbedder>,
994    node_embeddings: HashMap<String, Vec<f32>>,
995    graph_cache: Mutex<HashMap<String, (SystemTime, Arc<ServeGraph>)>>,
996    pub(crate) source_cache: Mutex<SourceCacheMap>,
997    stale_check: Mutex<Option<bool>>,
998    telemetry: Arc<codesynapse_core::telemetry::Telemetry>,
999}
1000
1001impl McpServer {
1002    pub fn new(graph_path: &Path) -> Result<Self> {
1003        let global_dir = dirs::home_dir()
1004            .unwrap_or_else(|| PathBuf::from("."))
1005            .join(".codesynapse");
1006        Self::new_with_global(graph_path, global_dir)
1007    }
1008
1009    pub fn new_with_global(graph_path: &Path, global_dir: PathBuf) -> Result<Self> {
1010        let store = SledGraphStore::open(graph_path)?;
1011        let backend = StoreBackend::Sled(store);
1012
1013        let model_path = global_dir.join("models").join("potion-code-16M");
1014        let (embedder, node_embeddings) = if model_path.exists() {
1015            let emb_path = global_dir.join("embeddings.json");
1016            if emb_path.exists() {
1017                let embedder = StaticEmbedder::from_path(&model_path).ok();
1018                let embs: HashMap<String, Vec<f32>> = std::fs::read_to_string(&emb_path)
1019                    .ok()
1020                    .and_then(|t| serde_json::from_str(&t).ok())
1021                    .unwrap_or_default();
1022                (embedder, embs)
1023            } else {
1024                (None, HashMap::new())
1025            }
1026        } else {
1027            (None, HashMap::new())
1028        };
1029
1030        let telemetry = Arc::new(codesynapse_core::telemetry::Telemetry::new(
1031            global_dir.clone(),
1032        ));
1033        Ok(McpServer {
1034            backend: Mutex::new(backend),
1035            graph_path: Some(graph_path.to_path_buf()),
1036            last_mtime: Mutex::new(None),
1037            global_dir,
1038            embedder,
1039            node_embeddings,
1040            graph_cache: Mutex::new(HashMap::new()),
1041            source_cache: Mutex::new(HashMap::new()),
1042            stale_check: Mutex::new(None),
1043            telemetry,
1044        })
1045    }
1046
1047    fn maybe_reload(&self) {
1048        let path = match &self.graph_path {
1049            Some(p) => p,
1050            None => return,
1051        };
1052        let mtime = match std::fs::metadata(path).and_then(|m| m.modified()) {
1053            Ok(t) => t,
1054            Err(_) => return,
1055        };
1056        let mut last = self.last_mtime.lock().unwrap();
1057        if last.is_none_or(|prev| prev != mtime) {
1058            if let Ok(store) = SledGraphStore::open(path) {
1059                *self.backend.lock().unwrap() = StoreBackend::Sled(store);
1060            }
1061            *last = Some(mtime);
1062        }
1063    }
1064
1065    pub fn run(&self) -> Result<()> {
1066        let stdin = io::stdin();
1067        let stdout = io::stdout();
1068        self.run_on(stdin.lock(), stdout.lock())
1069    }
1070
1071    pub fn run_on<R: BufRead, W: Write>(&self, reader: R, mut writer: W) -> Result<()> {
1072        self.telemetry.flush_bg();
1073        for line in reader.lines() {
1074            let line =
1075                line.map_err(|e| codesynapse_core::error::CodeSynapseError::msg(e.to_string()))?;
1076            if line.trim().is_empty() {
1077                continue;
1078            }
1079            let request: JsonRpcRequest = match serde_json::from_str(&line) {
1080                Ok(r) => r,
1081                Err(e) => {
1082                    let resp = JsonRpcResponse {
1083                        jsonrpc: "2.0".into(),
1084                        id: None,
1085                        result: None,
1086                        error: Some(JsonRpcError {
1087                            code: -32700,
1088                            message: format!("Parse error: {}", e),
1089                            data: None,
1090                        }),
1091                    };
1092                    let mut out = serde_json::to_string(&resp)?;
1093                    out.push('\n');
1094                    writer.write_all(out.as_bytes())?;
1095                    writer.flush()?;
1096                    continue;
1097                }
1098            };
1099
1100            self.maybe_reload();
1101            let response = self.handle_request(&request);
1102            let mut out = serde_json::to_string(&response)?;
1103            out.push('\n');
1104            writer.write_all(out.as_bytes())?;
1105            writer.flush()?;
1106        }
1107
1108        self.telemetry.persist_sync();
1109        Ok(())
1110    }
1111
1112    fn handle_request(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
1113        if request.method == "notifications/initialized" {
1114            return JsonRpcResponse {
1115                jsonrpc: "2.0".into(),
1116                id: None,
1117                result: None,
1118                error: None,
1119            };
1120        }
1121
1122        let result = match request.method.as_str() {
1123            "initialize" => Ok(serde_json::json!({
1124                "protocolVersion": "2024-11-05",
1125                "capabilities": { "tools": {} },
1126                "serverInfo": { "name": "codesynapse", "version": env!("CARGO_PKG_VERSION") },
1127                "instructions": CODESYNAPSE_INSTRUCTIONS
1128            })),
1129            "tools/list" => self.handle_tools_list(),
1130            "tools/call" => self.handle_tools_call(request),
1131            _ => Err(format!("Unknown method: {}", request.method)),
1132        };
1133
1134        match result {
1135            Ok(value) => JsonRpcResponse {
1136                jsonrpc: "2.0".into(),
1137                id: request.id.clone(),
1138                result: Some(value),
1139                error: None,
1140            },
1141            Err(msg) => JsonRpcResponse {
1142                jsonrpc: "2.0".into(),
1143                id: request.id.clone(),
1144                result: None,
1145                error: Some(JsonRpcError {
1146                    code: -32603,
1147                    message: msg,
1148                    data: None,
1149                }),
1150            },
1151        }
1152    }
1153
1154    fn handle_tools_list(&self) -> std::result::Result<Value, String> {
1155        let tools: Vec<Value> = tool_defs()
1156            .into_iter()
1157            .map(|t| {
1158                serde_json::json!({
1159                    "name": t.name,
1160                    "description": t.description,
1161                    "inputSchema": t.input_schema,
1162                })
1163            })
1164            .collect();
1165        Ok(serde_json::json!({ "tools": tools }))
1166    }
1167
1168    fn handle_tools_call(&self, request: &JsonRpcRequest) -> std::result::Result<Value, String> {
1169        let params = request
1170            .params
1171            .as_ref()
1172            .and_then(|p| p.as_object())
1173            .ok_or_else(|| "Missing params object".to_string())?;
1174
1175        let name = params
1176            .get("name")
1177            .and_then(|v| v.as_str())
1178            .ok_or_else(|| "Missing tool name".to_string())?;
1179
1180        let arguments = params
1181            .get("arguments")
1182            .and_then(|v| v.as_object())
1183            .cloned()
1184            .unwrap_or_default();
1185
1186        let text = match name {
1187            "codesynapse_query_graph" => self.tool_query_graph(&arguments),
1188            "codesynapse_get_node" => self.tool_get_node(&arguments),
1189            "codesynapse_get_neighbors" => self.tool_get_neighbors(&arguments),
1190            "codesynapse_get_community" => self.tool_get_community(&arguments),
1191            "codesynapse_god_nodes" => self.tool_god_nodes(&arguments),
1192            "codesynapse_graph_stats" => self.tool_graph_stats(&arguments),
1193            "codesynapse_shortest_path" => self.tool_shortest_path(&arguments),
1194            "codesynapse_find_all_paths" => self.tool_find_all_paths(&arguments),
1195            "codesynapse_weighted_path" => self.tool_weighted_path(&arguments),
1196            "codesynapse_community_bridges" => self.tool_community_bridges(&arguments),
1197            "codesynapse_diff" => self.tool_graph_diff(&arguments),
1198            "codesynapse_pagerank" => self.tool_pagerank(&arguments),
1199            "codesynapse_detect_cycles" => self.tool_detect_cycles(&arguments),
1200            "codesynapse_smart_summary" => self.tool_smart_summary(&arguments),
1201            "codesynapse_find_similar" => self.tool_find_similar(&arguments),
1202            "codesynapse_query_vector" => self.tool_graph_query_vector(&arguments),
1203            "codesynapse_blast_radius" => self.tool_graph_blast_radius(&arguments),
1204            "codesynapse_blast_radius_scored" => self.tool_graph_blast_radius_scored(&arguments),
1205            "codesynapse_blast_radius_multi" => self.tool_graph_blast_radius_multi(&arguments),
1206            "codesynapse_query_semantic" => self.tool_graph_query_semantic(&arguments),
1207            "codesynapse_hierarchy" => self.tool_graph_hierarchy(&arguments),
1208            "codesynapse_list_graphs" => self.tool_graph_list_graphs(&arguments),
1209            "codesynapse_module_summary" => self.tool_graph_module_summary(&arguments),
1210            "codesynapse_outline" => self.tool_codesynapse_outline(&arguments),
1211            "codesynapse_read" => self.tool_codesynapse_read(&arguments),
1212            "codesynapse_read_method" => self.tool_codesynapse_read_method(&arguments),
1213            "codesynapse_read_with_callees" => self.tool_codesynapse_read_with_callees(&arguments),
1214            "codesynapse_find_callers" => self.tool_codesynapse_find_callers(&arguments),
1215            "codesynapse_find_usages" => self.tool_codesynapse_find_usages(&arguments),
1216            "codesynapse_context" => self.tool_codesynapse_context(&arguments),
1217            "codesynapse_resolve" => self.tool_codesynapse_resolve(&arguments),
1218            "codesynapse_build" => self.tool_graph_build(&arguments),
1219            "codesynapse_stats" => self.tool_codesynapse_stats(&arguments),
1220            other => return Err(format!("Unknown tool: {}", other)),
1221        }?;
1222
1223        let text = if self.check_stale_once() {
1224            format!(
1225                "> ⚠ Graph may be stale — run `codesynapse build` or `codesynapse watch` to refresh.\n\n{text}"
1226            )
1227        } else {
1228            text
1229        };
1230
1231        Ok(serde_json::json!({
1232            "content": [{"type": "text", "text": text}]
1233        }))
1234    }
1235
1236    fn is_graph_stale(&self) -> bool {
1237        let global_graph = self.global_dir.join("global-graph.json");
1238        let Ok(gg_meta) = std::fs::metadata(&global_graph) else {
1239            return false;
1240        };
1241        let Ok(gg_mtime) = gg_meta.modified() else {
1242            return false;
1243        };
1244        let modules_dir = self.global_dir.join("modules");
1245        let Ok(entries) = std::fs::read_dir(&modules_dir) else {
1246            return false;
1247        };
1248        for entry in entries.flatten() {
1249            let graph_json = entry.path().join("graph.json");
1250            if let Ok(meta) = std::fs::metadata(&graph_json) {
1251                if let Ok(mtime) = meta.modified() {
1252                    if mtime > gg_mtime {
1253                        return true;
1254                    }
1255                }
1256            }
1257        }
1258        false
1259    }
1260
1261    fn check_stale_once(&self) -> bool {
1262        let mut guard = self.stale_check.lock().unwrap();
1263        if let Some(v) = *guard {
1264            return v;
1265        }
1266        let stale = self.is_graph_stale();
1267        *guard = Some(stale);
1268        stale
1269    }
1270
1271    fn load_graph(&self) -> std::result::Result<(Vec<Node>, Vec<Edge>), String> {
1272        let backend = self.backend.lock().unwrap();
1273        let nodes = backend.get_all_nodes().map_err(|e| e.to_string())?;
1274        let edges = backend.get_all_edges().map_err(|e| e.to_string())?;
1275        Ok((nodes, edges))
1276    }
1277
1278    fn serve_graph_to_nodes_edges(g: &ServeGraph) -> (Vec<Node>, Vec<Edge>) {
1279        let nodes: Vec<Node> = g
1280            .nodes_iter()
1281            .map(|(id, n)| Node {
1282                id: id.to_string(),
1283                label: n.label.clone(),
1284                file_type: "code".to_string(),
1285                source_file: n.source_file.clone(),
1286                source_location: if n.source_location.is_empty() {
1287                    None
1288                } else {
1289                    Some(n.source_location.clone())
1290                },
1291                community: n.community.map(|c| c as usize),
1292                rationale: None,
1293                docstring: n.docstring.clone(),
1294                metadata: HashMap::new(),
1295            })
1296            .collect();
1297        let edges: Vec<Edge> = g
1298            .edges_iter()
1299            .map(|e: &ServeEdge| Edge {
1300                source: e.source.clone(),
1301                target: e.target.clone(),
1302                relation: e.relation.clone(),
1303                confidence: e.confidence.clone(),
1304                source_file: None,
1305                weight: 1.0,
1306                context: e.context.clone(),
1307            })
1308            .collect();
1309        (nodes, edges)
1310    }
1311
1312    fn load_graph_with_fallback(&self) -> std::result::Result<(Vec<Node>, Vec<Edge>), String> {
1313        let (nodes, edges) = self.load_graph()?;
1314        if nodes.is_empty() {
1315            let g = self.load_module_serve_graph("merged")?;
1316            return Ok(Self::serve_graph_to_nodes_edges(&g));
1317        }
1318        Ok((nodes, edges))
1319    }
1320
1321    fn str_arg<'a>(
1322        args: &'a Map<String, Value>,
1323        key: &str,
1324    ) -> std::result::Result<&'a str, String> {
1325        args.get(key)
1326            .and_then(|v| v.as_str())
1327            .ok_or_else(|| format!("Missing or invalid string argument: {}", key))
1328    }
1329
1330    fn int_arg(args: &Map<String, Value>, key: &str, default: i64) -> i64 {
1331        args.get(key).and_then(|v| v.as_i64()).unwrap_or(default)
1332    }
1333
1334    fn float_arg(args: &Map<String, Value>, key: &str, default: f64) -> f64 {
1335        args.get(key).and_then(|v| v.as_f64()).unwrap_or(default)
1336    }
1337
1338    fn tool_query_graph(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1339        let question = Self::str_arg(args, "question")?;
1340        let budget = Self::int_arg(args, "budget", 5) as usize;
1341        let (_nodes, edges) = self.load_graph_with_fallback()?;
1342        // Simple keyword-based query
1343        let keywords: Vec<&str> = question.split_whitespace().collect();
1344        let matched: Vec<String> = edges
1345            .iter()
1346            .filter(|e| {
1347                keywords
1348                    .iter()
1349                    .any(|k| e.source.contains(k) || e.target.contains(k) || e.relation.contains(k))
1350            })
1351            .take(budget)
1352            .map(|e| format!("{} --[{}]--> {}", e.source, e.relation, e.target))
1353            .collect();
1354        if matched.is_empty() {
1355            Ok("No matching results found.".to_string())
1356        } else {
1357            Ok(matched.join("\n"))
1358        }
1359    }
1360
1361    fn tool_get_node(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1362        let node_id = Self::str_arg(args, "node_id")?;
1363        // Try sled first, fall back to merged graph
1364        let sled_result = self
1365            .backend
1366            .lock()
1367            .unwrap()
1368            .get_node(node_id)
1369            .ok()
1370            .flatten();
1371        if let Some(n) = sled_result {
1372            return serde_json::to_string_pretty(&n).map_err(|e| e.to_string());
1373        }
1374        match self.load_module_serve_graph("merged") {
1375            Ok(g) => match g.get_node(node_id) {
1376                Some(n) => Ok(format!(
1377                    "id: {}\nlabel: {}\nsource_file: {}\nsource_location: {}",
1378                    n.id, n.label, n.source_file, n.source_location
1379                )),
1380                None => Ok(format!("Node '{}' not found", node_id)),
1381            },
1382            Err(_) => Ok(format!("Node '{}' not found", node_id)),
1383        }
1384    }
1385
1386    fn tool_get_neighbors(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1387        let node_id = Self::str_arg(args, "node_id")?;
1388        let depth = Self::int_arg(args, "depth", 1) as usize;
1389        let limit = Self::int_arg(args, "limit", 50) as usize;
1390        let offset = Self::int_arg(args, "offset", 0) as usize;
1391
1392        // Try sled first
1393        let sled_empty = self.backend.lock().unwrap().node_count().unwrap_or(0) == 0;
1394        if !sled_empty {
1395            let mut collected = Vec::new();
1396            let mut current = vec![node_id.to_string()];
1397            let mut visited = std::collections::HashSet::new();
1398            visited.insert(node_id.to_string());
1399            for _ in 0..depth {
1400                let mut next = Vec::new();
1401                for id in &current {
1402                    if let Ok(neighbors) = self.backend.lock().unwrap().neighbors(id, None) {
1403                        for (neighbor, edge) in &neighbors {
1404                            if visited.insert(neighbor.id.clone()) {
1405                                collected.push(format!(
1406                                    "{} --[{}]--> {}",
1407                                    edge.source, edge.relation, edge.target
1408                                ));
1409                                next.push(neighbor.id.clone());
1410                            }
1411                        }
1412                    }
1413                }
1414                current = next;
1415            }
1416            let total = collected.len();
1417            let slice: Vec<String> = collected.into_iter().skip(offset).take(limit).collect();
1418            return Ok(format!(
1419                "Found {} neighbors (showing {}):\n{}",
1420                total,
1421                slice.len(),
1422                slice.join("\n")
1423            ));
1424        }
1425
1426        // Fallback: filter edges in merged graph
1427        let (_nodes, edges) = self.load_graph_with_fallback()?;
1428        let mut collected = Vec::new();
1429        let mut current = vec![node_id.to_string()];
1430        let mut visited = std::collections::HashSet::new();
1431        visited.insert(node_id.to_string());
1432        for _ in 0..depth {
1433            let mut next = Vec::new();
1434            for id in &current {
1435                for e in edges.iter().filter(|e| e.source == *id || e.target == *id) {
1436                    let neighbor = if e.source == *id {
1437                        &e.target
1438                    } else {
1439                        &e.source
1440                    };
1441                    if visited.insert(neighbor.clone()) {
1442                        collected.push(format!("{} --[{}]--> {}", e.source, e.relation, e.target));
1443                        next.push(neighbor.clone());
1444                    }
1445                }
1446            }
1447            current = next;
1448        }
1449        let total = collected.len();
1450        let slice: Vec<String> = collected.into_iter().skip(offset).take(limit).collect();
1451        Ok(format!(
1452            "Found {} neighbors (showing {}):\n{}",
1453            total,
1454            slice.len(),
1455            slice.join("\n")
1456        ))
1457    }
1458
1459    fn tool_get_community(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1460        let community_id = Self::int_arg(args, "community_id", 0) as usize;
1461        let limit = Self::int_arg(args, "limit", 50) as usize;
1462        let offset = Self::int_arg(args, "offset", 0) as usize;
1463
1464        let (nodes, _edges) = self.load_graph_with_fallback()?;
1465        let community_nodes: Vec<&Node> = nodes
1466            .iter()
1467            .filter(|n| n.community == Some(community_id))
1468            .skip(offset)
1469            .take(limit)
1470            .collect();
1471
1472        if community_nodes.is_empty() {
1473            return Ok(format!("No nodes found in community {}", community_id));
1474        }
1475
1476        let lines: Vec<String> = community_nodes
1477            .iter()
1478            .map(|n| format!("{} ({})", n.label, n.id))
1479            .collect();
1480        Ok(format!(
1481            "Community {} ({} nodes):\n{}",
1482            community_id,
1483            community_nodes.len(),
1484            lines.join("\n")
1485        ))
1486    }
1487
1488    fn tool_god_nodes(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1489        let top_n = Self::int_arg(args, "top_n", 10) as usize;
1490        let (nodes, edges) = self.load_graph_with_fallback()?;
1491        let analyzer = Analyzer;
1492        let gods = analyzer.god_nodes(&nodes, &edges, top_n);
1493        let lines: Vec<String> = gods
1494            .iter()
1495            .map(|n| format!("{} ({})", n.label, n.id))
1496            .collect();
1497        Ok(format!("Top {} god nodes:\n{}", top_n, lines.join("\n")))
1498    }
1499
1500    fn tool_graph_stats(&self, _args: &Map<String, Value>) -> std::result::Result<String, String> {
1501        let (nodes, edges) = self.load_graph_with_fallback()?;
1502        Ok(format!("Nodes: {}\nEdges: {}", nodes.len(), edges.len()))
1503    }
1504
1505    fn tool_shortest_path(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1506        let source = Self::str_arg(args, "source")?;
1507        let target = Self::str_arg(args, "target")?;
1508        let path = self
1509            .backend
1510            .lock()
1511            .unwrap()
1512            .shortest_path(source, target)
1513            .map_err(|e| e.to_string())?;
1514        match path {
1515            Some(nodes) => {
1516                let labels: Vec<String> = nodes.iter().map(|n| n.label.clone()).collect();
1517                Ok(format!(
1518                    "Shortest path ({} hops): {}",
1519                    labels.len() - 1,
1520                    labels.join(" -> ")
1521                ))
1522            }
1523            None => Ok(format!(
1524                "No path found between '{}' and '{}'",
1525                source, target
1526            )),
1527        }
1528    }
1529
1530    fn tool_find_all_paths(
1531        &self,
1532        args: &Map<String, Value>,
1533    ) -> std::result::Result<String, String> {
1534        let source = Self::str_arg(args, "source")?.to_string();
1535        let target = Self::str_arg(args, "target")?.to_string();
1536        let max_length = Self::int_arg(args, "max_length", 5) as usize;
1537
1538        let (_nodes, edges) = self.load_graph_with_fallback()?;
1539        let adj: HashMap<&str, Vec<&str>> = {
1540            let mut m: HashMap<&str, Vec<&str>> = HashMap::new();
1541            for e in &edges {
1542                m.entry(e.source.as_str())
1543                    .or_default()
1544                    .push(e.target.as_str());
1545            }
1546            m
1547        };
1548
1549        let mut all_paths: Vec<Vec<String>> = Vec::new();
1550        // Stack entries: (current_path, visited_set)
1551        let mut stack: Vec<(Vec<&str>, std::collections::HashSet<&str>)> = Vec::new();
1552        let mut init_visited = std::collections::HashSet::new();
1553        init_visited.insert(source.as_str());
1554        stack.push((vec![source.as_str()], init_visited));
1555
1556        while let Some((current, visited)) = stack.pop() {
1557            if current.len() > max_length {
1558                continue;
1559            }
1560            let node = *current.last().unwrap();
1561            if node == target.as_str() {
1562                all_paths.push(current.iter().map(|s| s.to_string()).collect());
1563                continue;
1564            }
1565            if let Some(neighbors) = adj.get(node) {
1566                for &next in neighbors {
1567                    if !visited.contains(next) {
1568                        let mut new_path = current.clone();
1569                        new_path.push(next);
1570                        let mut new_visited = visited.clone();
1571                        new_visited.insert(next);
1572                        stack.push((new_path, new_visited));
1573                    }
1574                }
1575            }
1576        }
1577
1578        if all_paths.is_empty() {
1579            return Ok(format!(
1580                "No paths found between '{}' and '{}'",
1581                source, target
1582            ));
1583        }
1584
1585        let lines: Vec<String> = all_paths
1586            .iter()
1587            .enumerate()
1588            .map(|(i, p)| format!("  {}. {}", i + 1, p.join(" -> ")))
1589            .collect();
1590        Ok(format!(
1591            "Found {} paths:\n{}",
1592            all_paths.len(),
1593            lines.join("\n")
1594        ))
1595    }
1596
1597    fn tool_weighted_path(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1598        let source = Self::str_arg(args, "source")?;
1599        let target = Self::str_arg(args, "target")?;
1600        let _min_confidence = Self::float_arg(args, "min_confidence", 0.0);
1601        let path = self
1602            .backend
1603            .lock()
1604            .unwrap()
1605            .dijkstra_shortest_path(source, target)
1606            .map_err(|e| e.to_string())?;
1607        match path {
1608            Some(nodes) => {
1609                let labels: Vec<String> = nodes.iter().map(|n| n.label.clone()).collect();
1610                Ok(format!(
1611                    "Weighted path ({} hops): {}",
1612                    labels.len() - 1,
1613                    labels.join(" -> ")
1614                ))
1615            }
1616            None => Ok(format!(
1617                "No path found between '{}' and '{}'",
1618                source, target
1619            )),
1620        }
1621    }
1622
1623    fn tool_community_bridges(
1624        &self,
1625        args: &Map<String, Value>,
1626    ) -> std::result::Result<String, String> {
1627        let top_n = Self::int_arg(args, "top_n", 10) as usize;
1628        let (nodes, edges) = self.load_graph_with_fallback()?;
1629        let analyzer = Analyzer;
1630        let bridges = analyzer.bridge_edges(&nodes, &edges);
1631        let top: Vec<&Edge> = bridges.iter().take(top_n).collect();
1632        if top.is_empty() {
1633            return Ok("No bridge edges found.".to_string());
1634        }
1635        let lines: Vec<String> = top
1636            .iter()
1637            .map(|e| format!("{} --[{}]--> {}", e.source, e.relation, e.target))
1638            .collect();
1639        Ok(format!(
1640            "Top {} bridge edges:\n{}",
1641            top.len(),
1642            lines.join("\n")
1643        ))
1644    }
1645
1646    fn tool_graph_diff(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1647        let other_path = Self::str_arg(args, "other_graph")?;
1648        let other_store = SledGraphStore::open(Path::new(other_path)).map_err(|e| e.to_string())?;
1649        let other_backend = StoreBackend::Sled(other_store);
1650        let other_edges = other_backend.get_all_edges().map_err(|e| e.to_string())?;
1651        let (_nodes, edges) = self.load_graph_with_fallback()?;
1652        let analyzer = Analyzer;
1653        let (added, removed) = analyzer.graph_diff(&other_edges, &edges);
1654        Ok(format!(
1655            "Added edges: {}\nRemoved edges: {}",
1656            added.len(),
1657            removed.len()
1658        ))
1659    }
1660
1661    fn tool_pagerank(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1662        let top_n = Self::int_arg(args, "top_n", 10) as usize;
1663        let damping = Self::float_arg(args, "damping", 0.85);
1664        let max_iter = Self::int_arg(args, "max_iter", 100) as usize;
1665        let (_nodes, edges) = self.load_graph_with_fallback()?;
1666        let analyzer = Analyzer;
1667        let mut ranks = analyzer.pagerank(&edges, damping, max_iter);
1668        let mut sorted: Vec<(String, f64)> = ranks.drain().collect();
1669        sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1670        let top: Vec<String> = sorted
1671            .iter()
1672            .take(top_n)
1673            .map(|(id, score)| format!("{}: {:.6}", id, score))
1674            .collect();
1675        Ok(format!("Top {} PageRank:\n{}", top_n, top.join("\n")))
1676    }
1677
1678    fn tool_detect_cycles(
1679        &self,
1680        _args: &Map<String, Value>,
1681    ) -> std::result::Result<String, String> {
1682        let (_nodes, edges) = self.load_graph_with_fallback()?;
1683        let analyzer = Analyzer;
1684        let sccs = analyzer.tarjan_scc(&edges);
1685        if sccs.is_empty() {
1686            return Ok("No cycles detected.".to_string());
1687        }
1688        let lines: Vec<String> = sccs
1689            .iter()
1690            .filter(|scc| scc.len() > 1)
1691            .enumerate()
1692            .map(|(i, scc)| format!("  Cycle {}: {}", i + 1, scc.join(" -> ")))
1693            .collect();
1694        if lines.is_empty() {
1695            return Ok("No cycles detected (all SCCs are singletons).".to_string());
1696        }
1697        Ok(format!(
1698            "Detected {} cycles:\n{}",
1699            lines.len(),
1700            lines.join("\n")
1701        ))
1702    }
1703
1704    fn tool_smart_summary(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1705        let level = args
1706            .get("level")
1707            .and_then(|v| v.as_str())
1708            .unwrap_or("architecture");
1709        let budget = Self::int_arg(args, "budget", 100) as usize;
1710        let (nodes, edges) = self.load_graph_with_fallback()?;
1711
1712        match level {
1713            "detailed" => {
1714                let mut lines = vec![format!(
1715                    "Detailed summary ({} nodes, {} edges, max {} items):",
1716                    nodes.len(),
1717                    edges.len(),
1718                    budget
1719                )];
1720                for node in nodes.iter().take(budget / 2) {
1721                    lines.push(format!("  Node: {} ({})", node.label, node.id));
1722                }
1723                for edge in edges.iter().take(budget / 2) {
1724                    lines.push(format!(
1725                        "  Edge: {} --[{}]--> {}",
1726                        edge.source, edge.relation, edge.target
1727                    ));
1728                }
1729                Ok(lines.join("\n"))
1730            }
1731            "community" => {
1732                let mut community_map: HashMap<Option<usize>, Vec<&Node>> = HashMap::new();
1733                for node in &nodes {
1734                    community_map.entry(node.community).or_default().push(node);
1735                }
1736                let mut lines = vec![format!(
1737                    "Community summary ({} communities):",
1738                    community_map.len()
1739                )];
1740                for (id, members) in community_map.iter().take(budget) {
1741                    lines.push(format!("  Community {:?}: {} members", id, members.len()));
1742                }
1743                Ok(lines.join("\n"))
1744            }
1745            _ => {
1746                // architecture level
1747                let node_count = nodes.len();
1748                let edge_count = edges.len();
1749                let analyzer = Analyzer;
1750                let gods = analyzer.god_nodes(&nodes, &edges, 5);
1751                let sccs = analyzer.tarjan_scc(&edges);
1752                let has_cycles = sccs.iter().any(|scc| scc.len() > 1);
1753                let god_lines: Vec<String> = gods.iter().map(|n| n.label.clone()).collect();
1754                Ok(format!(
1755                    "Architecture Summary:\n\
1756                     - Nodes: {}\n\
1757                     - Edges: {}\n\
1758                     - Cycles: {}\n\
1759                     - Top nodes: {}\n\
1760                     - Average degree: {:.2}",
1761                    node_count,
1762                    edge_count,
1763                    if has_cycles { "yes" } else { "no" },
1764                    god_lines.join(", "),
1765                    if node_count > 0 {
1766                        edge_count as f64 / node_count as f64
1767                    } else {
1768                        0.0
1769                    }
1770                ))
1771            }
1772        }
1773    }
1774
1775    fn tool_find_similar(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1776        let node_id = Self::str_arg(args, "node_id")?;
1777        let top_n = Self::int_arg(args, "top_n", 10) as usize;
1778
1779        let (_nodes, edges) = self.load_graph_with_fallback()?;
1780        let analyzer = Analyzer;
1781        let similar = analyzer.find_similar(&edges, node_id, top_n);
1782
1783        if similar.is_empty() {
1784            Ok(format!("No similar nodes found for '{}'", node_id))
1785        } else {
1786            let lines: Vec<String> = similar
1787                .iter()
1788                .map(|(id, score)| format!("  {} (similarity: {:.4})", id, score))
1789                .collect();
1790            Ok(format!(
1791                "Top {} nodes similar to '{}':\n{}",
1792                similar.len(),
1793                node_id,
1794                lines.join("\n")
1795            ))
1796        }
1797    }
1798
1799    fn load_module_serve_graph(
1800        &self,
1801        module: &str,
1802    ) -> std::result::Result<Arc<ServeGraph>, String> {
1803        let path = if module == "merged" || module.is_empty() {
1804            let p = self.global_dir.join("global-graph.json");
1805            if !p.exists() {
1806                return Err("No merged graph found. Run `codesynapse global add <path>` to register graphs.".into());
1807            }
1808            p
1809        } else {
1810            let repos = global_list(&self.global_dir);
1811            let entry = repos.get(module).ok_or_else(|| {
1812                format!(
1813                    "Module '{}' not found. Use graph_list_graphs to see available modules.",
1814                    module
1815                )
1816            })?;
1817            PathBuf::from(&entry.source_path)
1818        };
1819
1820        let mtime = std::fs::metadata(&path)
1821            .and_then(|m| m.modified())
1822            .unwrap_or(SystemTime::UNIX_EPOCH);
1823
1824        {
1825            let cache = self.graph_cache.lock().unwrap();
1826            if let Some((cached_mtime, arc)) = cache.get(module) {
1827                if *cached_mtime == mtime {
1828                    return Ok(Arc::clone(arc));
1829                }
1830            }
1831        }
1832
1833        let g = load_graph(&path).map_err(|e| e.to_string())?;
1834        let arc = Arc::new(g);
1835        self.graph_cache
1836            .lock()
1837            .unwrap()
1838            .insert(module.to_string(), (mtime, Arc::clone(&arc)));
1839        Ok(arc)
1840    }
1841
1842    fn tool_graph_list_graphs(
1843        &self,
1844        _args: &Map<String, Value>,
1845    ) -> std::result::Result<String, String> {
1846        let repos = global_list(&self.global_dir);
1847        if repos.is_empty() {
1848            return Ok("No graphs registered. Use `codesynapse global add <path> --as <tag>` to register one.".into());
1849        }
1850        let mut lines = vec![
1851            format!(
1852                "{:<30} {:>7}  {:>7}  {}",
1853                "Module", "Nodes", "Edges", "Source"
1854            ),
1855            format!("{}", "─".repeat(80)),
1856        ];
1857        let mut sorted: Vec<_> = repos.iter().collect();
1858        sorted.sort_by_key(|(k, _)| k.as_str());
1859        for (tag, entry) in sorted {
1860            lines.push(format!(
1861                "{:<30} {:>7}  {:>7}  {}",
1862                tag, entry.node_count, entry.edge_count, entry.source_path
1863            ));
1864        }
1865        Ok(lines.join("\n"))
1866    }
1867
1868    fn tool_graph_module_summary(
1869        &self,
1870        args: &Map<String, Value>,
1871    ) -> std::result::Result<String, String> {
1872        let module = Self::str_arg(args, "module")?;
1873        let repos = global_list(&self.global_dir);
1874        let entry = repos
1875            .get(module)
1876            .ok_or_else(|| format!("Module '{}' not found.", module))?;
1877
1878        let text = std::fs::read_to_string(&entry.source_path)
1879            .map_err(|e| format!("Could not read graph: {}", e))?;
1880        let v: serde_json::Value =
1881            serde_json::from_str(&text).map_err(|e| format!("Bad graph JSON: {}", e))?;
1882
1883        let nodes: Vec<Node> = v
1884            .get("nodes")
1885            .and_then(|n| n.as_array())
1886            .map(|arr| {
1887                arr.iter()
1888                    .filter_map(|i| serde_json::from_value(i.clone()).ok())
1889                    .collect()
1890            })
1891            .unwrap_or_default();
1892        let edges: Vec<Edge> = v
1893            .get("edges")
1894            .or_else(|| v.get("links"))
1895            .and_then(|e| e.as_array())
1896            .map(|arr| {
1897                arr.iter()
1898                    .filter_map(|i| serde_json::from_value(i.clone()).ok())
1899                    .collect()
1900            })
1901            .unwrap_or_default();
1902
1903        let analyzer = Analyzer;
1904        let gods = analyzer.god_nodes(&nodes, &edges, 5);
1905        let god_labels: Vec<String> = gods.iter().map(|n| n.label.clone()).collect();
1906
1907        let mut lang_counts: HashMap<&str, usize> = HashMap::new();
1908        for n in &nodes {
1909            *lang_counts.entry(n.file_type.as_str()).or_default() += 1;
1910        }
1911        let mut lang_sorted: Vec<_> = lang_counts.iter().collect();
1912        lang_sorted.sort_by(|a, b| b.1.cmp(a.1));
1913        let lang_str: Vec<String> = lang_sorted
1914            .iter()
1915            .take(5)
1916            .map(|(k, v)| format!("{}: {}", k, v))
1917            .collect();
1918
1919        Ok(format!(
1920            "Module:     {}\nNodes:      {}\nEdges:      {}\nTop nodes:  {}\nLanguages:  {}\nSource:     {}",
1921            module,
1922            entry.node_count,
1923            entry.edge_count,
1924            if god_labels.is_empty() { "(none)".to_string() } else { god_labels.join(", ") },
1925            if lang_str.is_empty() { "(none)".to_string() } else { lang_str.join(", ") },
1926            entry.source_path,
1927        ))
1928    }
1929
1930    fn tool_graph_blast_radius(
1931        &self,
1932        args: &Map<String, Value>,
1933    ) -> std::result::Result<String, String> {
1934        let class_name = Self::str_arg(args, "class_name")?;
1935        let graph_name = args
1936            .get("graph")
1937            .and_then(|v| v.as_str())
1938            .unwrap_or("merged");
1939        let depth = Self::int_arg(args, "depth", 3) as usize;
1940
1941        let g = self.load_module_serve_graph(graph_name)?;
1942
1943        let seed_id = g
1944            .nodes_iter()
1945            .find(|(id, n)| n.label.eq_ignore_ascii_case(class_name) || id.contains(class_name))
1946            .map(|(id, _)| id.to_string())
1947            .ok_or_else(|| format!("Node '{}' not found in graph '{}'.", class_name, graph_name))?;
1948
1949        let mut by_depth: Vec<Vec<String>> = vec![vec![seed_id.clone()]];
1950        let mut visited = std::collections::HashSet::new();
1951        visited.insert(seed_id.clone());
1952
1953        for d in 1..=depth {
1954            let prev = by_depth[d - 1].clone();
1955            let mut next = Vec::new();
1956            for node_id in &prev {
1957                for neighbor_id in g.neighbors(node_id) {
1958                    if visited.insert(neighbor_id.clone()) {
1959                        next.push(neighbor_id.clone());
1960                    }
1961                }
1962            }
1963            if next.is_empty() {
1964                break;
1965            }
1966            by_depth.push(next);
1967        }
1968
1969        let mut lines = vec![format!(
1970            "Blast radius of '{}' in '{}' (max depth {})",
1971            class_name, graph_name, depth
1972        )];
1973        for (d, nodes) in by_depth.iter().enumerate() {
1974            let labels: Vec<String> = nodes
1975                .iter()
1976                .map(|id| {
1977                    g.get_node(id)
1978                        .map(|n| n.label.clone())
1979                        .unwrap_or_else(|| id.clone())
1980                })
1981                .collect();
1982            if d == 0 {
1983                lines.push(format!("  Origin:  {}", labels.join(", ")));
1984            } else {
1985                lines.push(format!("  Depth {}: {}", d, labels.join(", ")));
1986            }
1987        }
1988        lines.push(format!("Total affected: {}", visited.len() - 1));
1989        Ok(lines.join("\n"))
1990    }
1991
1992    fn risk_score_node(g: &ServeGraph, node_id: &str) -> (f32, Vec<String>) {
1993        const KEYWORDS: &[&str] = &[
1994            "auth",
1995            "pay",
1996            "password",
1997            "token",
1998            "secret",
1999            "credit",
2000            "billing",
2001            "admin",
2002            "security",
2003            "encrypt",
2004            "hash",
2005            "key",
2006            "credential",
2007        ];
2008        let node = g.get_node(node_id);
2009        let label_lower = node.map(|n| n.label.to_lowercase()).unwrap_or_default();
2010        let file_lower = node
2011            .map(|n| n.source_file.to_lowercase())
2012            .unwrap_or_default();
2013        let combined = format!("{} {}", label_lower, file_lower);
2014
2015        let mut score = 0.0f32;
2016        let mut reasons = Vec::new();
2017
2018        let kw_hits: Vec<&str> = KEYWORDS
2019            .iter()
2020            .copied()
2021            .filter(|k| combined.contains(k))
2022            .collect();
2023        if !kw_hits.is_empty() {
2024            let kw_score = (kw_hits.len() as f32 * 0.15).min(0.4);
2025            score += kw_score;
2026            reasons.push(format!("keywords: {}", kw_hits.join(",")));
2027        }
2028
2029        let in_degree = g.edges_iter().filter(|e| e.target == node_id).count();
2030        if in_degree >= 10 {
2031            score += 0.3;
2032            reasons.push(format!("in_degree={}", in_degree));
2033        } else if in_degree >= 5 {
2034            score += 0.15;
2035            reasons.push(format!("in_degree={}", in_degree));
2036        }
2037
2038        let has_test_neighbor = g.edges_iter().any(|e| {
2039            if e.source == node_id || e.target == node_id {
2040                let neighbor_id = if e.source == node_id {
2041                    &e.target
2042                } else {
2043                    &e.source
2044                };
2045                g.get_node(neighbor_id)
2046                    .map(|n| {
2047                        let f = &n.source_file;
2048                        f.contains("test")
2049                            || f.contains("spec")
2050                            || f.contains("Test")
2051                            || f.contains("_test")
2052                    })
2053                    .unwrap_or(false)
2054            } else {
2055                false
2056            }
2057        });
2058        if !has_test_neighbor {
2059            score += 0.2;
2060            reasons.push("no_test_coverage".into());
2061        }
2062
2063        (score.min(1.0), reasons)
2064    }
2065
2066    fn tool_graph_blast_radius_scored(
2067        &self,
2068        args: &Map<String, Value>,
2069    ) -> std::result::Result<String, String> {
2070        let class_name = Self::str_arg(args, "class_name")?;
2071        let graph_name = args
2072            .get("graph")
2073            .and_then(|v| v.as_str())
2074            .unwrap_or("merged");
2075        let depth = Self::int_arg(args, "depth", 3) as usize;
2076
2077        let g = self.load_module_serve_graph(graph_name)?;
2078
2079        let seed_id = g
2080            .nodes_iter()
2081            .find(|(id, n)| n.label.eq_ignore_ascii_case(class_name) || id.contains(class_name))
2082            .map(|(id, _)| id.to_string())
2083            .ok_or_else(|| format!("Node '{}' not found in graph '{}'.", class_name, graph_name))?;
2084
2085        let mut by_depth: Vec<Vec<String>> = vec![vec![seed_id.clone()]];
2086        let mut visited = std::collections::HashSet::new();
2087        visited.insert(seed_id.clone());
2088
2089        for d in 1..=depth {
2090            let prev = by_depth[d - 1].clone();
2091            let mut next = Vec::new();
2092            for node_id in &prev {
2093                for neighbor_id in g.neighbors(node_id) {
2094                    if visited.insert(neighbor_id.clone()) {
2095                        next.push(neighbor_id.clone());
2096                    }
2097                }
2098            }
2099            if next.is_empty() {
2100                break;
2101            }
2102            by_depth.push(next);
2103        }
2104
2105        let affected: Vec<String> = visited
2106            .iter()
2107            .filter(|id| *id != &seed_id)
2108            .cloned()
2109            .collect();
2110
2111        let mut scored: Vec<(String, f32, Vec<String>)> = affected
2112            .iter()
2113            .map(|id| {
2114                let label = g
2115                    .get_node(id)
2116                    .map(|n| n.label.clone())
2117                    .unwrap_or_else(|| id.clone());
2118                let (score, reasons) = Self::risk_score_node(&g, id);
2119                (label, score, reasons)
2120            })
2121            .collect();
2122        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2123
2124        let mut lines = vec![format!(
2125            "Blast radius of '{}' in '{}' (max depth {}) — risk scored",
2126            class_name, graph_name, depth
2127        )];
2128        let tier = |s: f32| {
2129            if s >= 0.6 {
2130                "HIGH"
2131            } else if s >= 0.3 {
2132                "MEDIUM"
2133            } else {
2134                "LOW"
2135            }
2136        };
2137        for (label, score, reasons) in &scored {
2138            let reason_str = if reasons.is_empty() {
2139                String::new()
2140            } else {
2141                format!(" [{}]", reasons.join(", "))
2142            };
2143            lines.push(format!(
2144                "  [{:.2} {}] {}{}",
2145                score,
2146                tier(*score),
2147                label,
2148                reason_str
2149            ));
2150        }
2151        lines.push(format!("Total affected: {}", affected.len()));
2152        Ok(lines.join("\n"))
2153    }
2154
2155    fn tool_graph_blast_radius_multi(
2156        &self,
2157        args: &Map<String, Value>,
2158    ) -> std::result::Result<String, String> {
2159        let graph_name = args
2160            .get("graph")
2161            .and_then(|v| v.as_str())
2162            .unwrap_or("merged");
2163        let depth = Self::int_arg(args, "depth", 3) as usize;
2164
2165        let names_val = args
2166            .get("class_names")
2167            .and_then(|v| v.as_array())
2168            .ok_or_else(|| "Missing required argument: class_names".to_string())?;
2169        let class_names: Vec<String> = names_val
2170            .iter()
2171            .filter_map(|v| v.as_str().map(str::to_string))
2172            .collect();
2173
2174        if class_names.is_empty() {
2175            return Err("class_names must be a non-empty array".to_string());
2176        }
2177
2178        let g = self.load_module_serve_graph(graph_name)?;
2179
2180        let mut seed_ids: Vec<String> = Vec::new();
2181        let mut not_found: Vec<String> = Vec::new();
2182        for name in &class_names {
2183            match g
2184                .nodes_iter()
2185                .find(|(id, n)| n.label.eq_ignore_ascii_case(name) || id.contains(name.as_str()))
2186            {
2187                Some((id, _)) => seed_ids.push(id.to_string()),
2188                None => not_found.push(name.clone()),
2189            }
2190        }
2191
2192        let mut by_depth: Vec<Vec<String>> = vec![seed_ids.clone()];
2193        let mut visited: std::collections::HashSet<String> = seed_ids.iter().cloned().collect();
2194
2195        for d in 1..=depth {
2196            let prev = by_depth[d - 1].clone();
2197            let mut next = Vec::new();
2198            for node_id in &prev {
2199                for neighbor_id in g.neighbors(node_id) {
2200                    if visited.insert(neighbor_id.clone()) {
2201                        next.push(neighbor_id.clone());
2202                    }
2203                }
2204            }
2205            if next.is_empty() {
2206                break;
2207            }
2208            by_depth.push(next);
2209        }
2210
2211        let seed_set: std::collections::HashSet<&String> = seed_ids.iter().collect();
2212        let total_affected = visited.iter().filter(|id| !seed_set.contains(*id)).count();
2213
2214        let mut lines = vec![format!(
2215            "Multi blast radius in '{}' (seeds: {}, max depth {})",
2216            graph_name,
2217            class_names.join(", "),
2218            depth
2219        )];
2220        if !not_found.is_empty() {
2221            lines.push(format!("Not found: {}", not_found.join(", ")));
2222        }
2223        for (d, nodes) in by_depth.iter().enumerate() {
2224            let labels: Vec<String> = nodes
2225                .iter()
2226                .map(|id| {
2227                    g.get_node(id)
2228                        .map(|n| n.label.clone())
2229                        .unwrap_or_else(|| id.clone())
2230                })
2231                .collect();
2232            if d == 0 {
2233                lines.push(format!("  Seeds:   {}", labels.join(", ")));
2234            } else {
2235                lines.push(format!("  Depth {}: {}", d, labels.join(", ")));
2236            }
2237        }
2238        lines.push(format!("Total affected: {}", total_affected));
2239        Ok(lines.join("\n"))
2240    }
2241
2242    fn tool_graph_query_semantic(
2243        &self,
2244        args: &Map<String, Value>,
2245    ) -> std::result::Result<String, String> {
2246        let query = Self::str_arg(args, "query")?;
2247        let graph_name = args
2248            .get("graph")
2249            .and_then(|v| v.as_str())
2250            .unwrap_or("merged");
2251        let depth = Self::int_arg(args, "depth", 2) as usize;
2252        let min_confidence = args
2253            .get("min_confidence")
2254            .and_then(|v| v.as_f64())
2255            .unwrap_or(0.7) as f32;
2256
2257        let g = self.load_module_serve_graph(graph_name)?;
2258
2259        let query_lower = query.to_lowercase();
2260        let seed_ids: Vec<String> = {
2261            let mut seeds: Vec<String> = g
2262                .nodes_iter()
2263                .filter(|(_, n)| {
2264                    let l = n.label.to_lowercase();
2265                    query_lower.split_whitespace().any(|w| l.contains(w))
2266                })
2267                .take(5)
2268                .map(|(id, _)| id.to_string())
2269                .collect();
2270            if seeds.is_empty() {
2271                seeds = find_nodes_by_label(&g, query)
2272                    .into_iter()
2273                    .take(5)
2274                    .map(|(id, _)| id.to_string())
2275                    .collect();
2276            }
2277            seeds
2278        };
2279
2280        if seed_ids.is_empty() {
2281            return Ok(format!("No seed nodes found for query '{}'.", query));
2282        }
2283
2284        let has_any_semantic = g
2285            .edges_iter()
2286            .any(|e| e.relation == "semantically_similar_to");
2287        if !has_any_semantic {
2288            return Ok(format!(
2289                "No semantic neighbors found for '{}'. Graph may not have been built with --llm.",
2290                query
2291            ));
2292        }
2293
2294        let mut visited: std::collections::HashSet<String> = seed_ids.iter().cloned().collect();
2295        let mut frontier: Vec<String> = seed_ids.clone();
2296        let mut results: Vec<(String, usize)> = Vec::new();
2297
2298        for d in 1..=depth {
2299            let mut next_frontier = Vec::new();
2300            for node_id in &frontier {
2301                for edge in g.edges_iter() {
2302                    if edge.relation != "semantically_similar_to" {
2303                        continue;
2304                    }
2305                    let conf: f32 = edge.confidence.parse().unwrap_or(1.0);
2306                    if conf < min_confidence {
2307                        continue;
2308                    }
2309                    let neighbor = if edge.source == *node_id {
2310                        Some(edge.target.clone())
2311                    } else if edge.target == *node_id {
2312                        Some(edge.source.clone())
2313                    } else {
2314                        None
2315                    };
2316                    if let Some(nb) = neighbor {
2317                        if visited.insert(nb.clone()) {
2318                            next_frontier.push(nb.clone());
2319                            results.push((nb, d));
2320                        }
2321                    }
2322                }
2323            }
2324            frontier = next_frontier;
2325            if frontier.is_empty() {
2326                break;
2327            }
2328        }
2329
2330        if results.is_empty() {
2331            return Ok(format!(
2332                "No semantic neighbors found for '{}'. Graph may not have been built with --llm.",
2333                query
2334            ));
2335        }
2336
2337        let seed_labels: Vec<String> = seed_ids
2338            .iter()
2339            .map(|id| {
2340                g.get_node(id)
2341                    .map(|n| n.label.clone())
2342                    .unwrap_or_else(|| id.clone())
2343            })
2344            .collect();
2345
2346        let mut lines = vec![format!(
2347            "Semantic neighbors of '{}' (seeds: {}, depth {}, min_confidence {:.2})",
2348            query,
2349            seed_labels.join(", "),
2350            depth,
2351            min_confidence
2352        )];
2353        for (id, d) in &results {
2354            let label = g
2355                .get_node(id)
2356                .map(|n| n.label.clone())
2357                .unwrap_or_else(|| id.clone());
2358            lines.push(format!("  [depth {}] {}", d, label));
2359        }
2360        Ok(lines.join("\n"))
2361    }
2362
2363    fn tool_graph_hierarchy(
2364        &self,
2365        args: &Map<String, Value>,
2366    ) -> std::result::Result<String, String> {
2367        let class_name = Self::str_arg(args, "class_name")?;
2368        let graph_name = args
2369            .get("graph")
2370            .and_then(|v| v.as_str())
2371            .unwrap_or("merged");
2372
2373        let g = self.load_module_serve_graph(graph_name)?;
2374
2375        let seed_id = g
2376            .nodes_iter()
2377            .find(|(id, n)| n.label.eq_ignore_ascii_case(class_name) || id.contains(class_name))
2378            .map(|(id, _)| id.to_string())
2379            .ok_or_else(|| format!("Node '{}' not found in graph '{}'.", class_name, graph_name))?;
2380
2381        let seed_label = g
2382            .get_node(&seed_id)
2383            .map(|n| n.label.clone())
2384            .unwrap_or_else(|| seed_id.clone());
2385
2386        let hierarchy_rels = ["extends", "implements", "inherits"];
2387
2388        let mut supertypes: Vec<String> = Vec::new();
2389        let mut subtypes: Vec<String> = Vec::new();
2390
2391        for edge in g.edges_iter() {
2392            let rel = edge.relation.to_lowercase();
2393            if !hierarchy_rels.contains(&rel.as_str()) {
2394                continue;
2395            }
2396            if edge.source == seed_id {
2397                let label = g
2398                    .get_node(&edge.target)
2399                    .map(|n| n.label.clone())
2400                    .unwrap_or_else(|| edge.target.clone());
2401                supertypes.push(label);
2402            }
2403            if edge.target == seed_id {
2404                let label = g
2405                    .get_node(&edge.source)
2406                    .map(|n| n.label.clone())
2407                    .unwrap_or_else(|| edge.source.clone());
2408                subtypes.push(label);
2409            }
2410        }
2411
2412        let mut lines = vec![format!("Hierarchy for '{}':", seed_label)];
2413        if supertypes.is_empty() {
2414            lines.push("  Supertypes: (none)".into());
2415        } else {
2416            lines.push(format!("  Supertypes: {}", supertypes.join(", ")));
2417        }
2418        lines.push(format!("  ↳ {}", seed_label));
2419        if subtypes.is_empty() {
2420            lines.push("  Subtypes: (none)".into());
2421        } else {
2422            for sub in &subtypes {
2423                lines.push(format!("    ↳ {}", sub));
2424            }
2425        }
2426        Ok(lines.join("\n"))
2427    }
2428
2429    pub(crate) fn get_source_cached(&self, path: &PathBuf) -> Option<(String, Vec<OutlineItem>)> {
2430        let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
2431
2432        {
2433            let cache = self.source_cache.lock().unwrap();
2434            if let Some((cached_mtime, content, items)) = cache.get(path) {
2435                if *cached_mtime == mtime {
2436                    return Some((content.clone(), items.clone()));
2437                }
2438            }
2439        }
2440
2441        let content = std::fs::read_to_string(path).ok()?;
2442        let items = outline_items(path, &content);
2443
2444        let mut cache = self.source_cache.lock().unwrap();
2445        if cache.len() >= 1000 {
2446            cache.clear();
2447        }
2448        cache.insert(path.clone(), (mtime, content.clone(), items.clone()));
2449        Some((content, items))
2450    }
2451
2452    fn tool_graph_query_vector(
2453        &self,
2454        args: &Map<String, Value>,
2455    ) -> std::result::Result<String, String> {
2456        let query = Self::str_arg(args, "query")?;
2457        let graph_name = args
2458            .get("graph")
2459            .and_then(|v| v.as_str())
2460            .unwrap_or("merged");
2461        let top_k = Self::int_arg(args, "top_k", 8) as usize;
2462
2463        let g = self.load_module_serve_graph(graph_name)?;
2464        let dense = self
2465            .embedder
2466            .as_ref()
2467            .filter(|_| !self.node_embeddings.is_empty())
2468            .map(|e| (e, &self.node_embeddings));
2469        let result = query_graph_text_hybrid(&g, query, "bfs", 2, top_k * 200, None, dense);
2470        Ok(result)
2471    }
2472
2473    // ---------------------------------------------------------------------------
2474    // Phase 3 & 4 — File tools + utility tools
2475    // ---------------------------------------------------------------------------
2476
2477    fn log_tool_call(&self, tool: &str, result_chars: usize, saved_chars: usize) {
2478        use std::io::Write as IoWrite;
2479        let path = self.global_dir.join("tool_stats.jsonl");
2480        let ts = SystemTime::now()
2481            .duration_since(SystemTime::UNIX_EPOCH)
2482            .unwrap_or_default()
2483            .as_secs_f64();
2484        let entry = serde_json::json!({
2485            "ts": ts,
2486            "tool": tool,
2487            "result_chars": result_chars,
2488            "saved_chars": saved_chars,
2489        });
2490        if let Ok(mut f) = std::fs::OpenOptions::new()
2491            .create(true)
2492            .append(true)
2493            .open(&path)
2494        {
2495            let _ = writeln!(f, "{}", entry);
2496        }
2497        self.telemetry.record_usage(tool, saved_chars, true);
2498    }
2499
2500    fn resolve_class_source(
2501        &self,
2502        g: &ServeGraph,
2503        class_name: &str,
2504        method_hint: Option<&str>,
2505    ) -> std::result::Result<PathBuf, String> {
2506        let candidates = find_nodes_by_label(g, class_name);
2507        if candidates.is_empty() {
2508            return Err(format!("No node matching '{}' found in graph.", class_name));
2509        }
2510        let mut paths: Vec<PathBuf> = Vec::new();
2511        for (id, node) in candidates.iter().take(10) {
2512            if !node.source_file.is_empty() {
2513                let p = PathBuf::from(&node.source_file);
2514                if p.exists() {
2515                    paths.push(p);
2516                    continue;
2517                }
2518            }
2519            for neighbor_id in g.neighbors(id) {
2520                if let Some(neighbor) = g.get_node(neighbor_id) {
2521                    if !neighbor.source_file.is_empty() {
2522                        let p = PathBuf::from(&neighbor.source_file);
2523                        if p.exists() {
2524                            paths.push(p);
2525                            break;
2526                        }
2527                    }
2528                }
2529            }
2530        }
2531        if paths.is_empty() {
2532            return Err(format!(
2533                "Found '{}' in graph but source file not found on disk.",
2534                class_name
2535            ));
2536        }
2537        if let Some(mhint) = method_hint {
2538            if paths.len() > 1 {
2539                let mhint_lower = mhint.to_lowercase();
2540                for p in &paths {
2541                    if let Ok(content) = std::fs::read_to_string(p) {
2542                        let items = outline_items(p, &content);
2543                        if items.iter().any(|i| {
2544                            i.kind == "method" && i.name.to_lowercase().contains(&mhint_lower)
2545                        }) {
2546                            return Ok(p.clone());
2547                        }
2548                    }
2549                }
2550            }
2551        }
2552        Ok(paths[0].clone())
2553    }
2554
2555    fn tool_codesynapse_outline(
2556        &self,
2557        args: &Map<String, Value>,
2558    ) -> std::result::Result<String, String> {
2559        let class_name = Self::str_arg(args, "class_name")?;
2560        let graph_name = args
2561            .get("graph")
2562            .and_then(|v| v.as_str())
2563            .unwrap_or("merged");
2564        let g = self.load_module_serve_graph(graph_name)?;
2565        let path = self.resolve_class_source(&g, class_name, None)?;
2566        let content = std::fs::read_to_string(&path)
2567            .map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
2568        let items = outline_items(&path, &content);
2569        if items.is_empty() {
2570            return Ok(format!(
2571                "Could not extract outline from {} (unsupported format or empty).",
2572                path.display()
2573            ));
2574        }
2575        let total_lines = content.lines().count();
2576        let mut lines = vec![
2577            format!("File: {}", path.display()),
2578            format!("Size: {} lines", total_lines),
2579            String::new(),
2580        ];
2581        for item in &items {
2582            let prefix = match item.kind.as_str() {
2583                "class" => "CLASS ",
2584                "interface" => "IFACE ",
2585                "enum" => "ENUM  ",
2586                "record" => "RECORD",
2587                "method" => "  def ",
2588                "field" => "  var ",
2589                _ => "  ??? ",
2590            };
2591            lines.push(format!("  L{:<6} {} {}", item.line, prefix, item.name));
2592        }
2593        let result = lines.join("\n");
2594        self.log_tool_call(
2595            "codesynapse_outline",
2596            result.len(),
2597            content.len().saturating_sub(result.len()),
2598        );
2599        Ok(result)
2600    }
2601
2602    fn tool_codesynapse_read(
2603        &self,
2604        args: &Map<String, Value>,
2605    ) -> std::result::Result<String, String> {
2606        let class_name = Self::str_arg(args, "class_name")?;
2607        let graph_name = args
2608            .get("graph")
2609            .and_then(|v| v.as_str())
2610            .unwrap_or("merged");
2611        let from_line = Self::int_arg(args, "from_line", 1) as usize;
2612        let mut to_line = Self::int_arg(args, "to_line", 0) as usize;
2613
2614        let g = self.load_module_serve_graph(graph_name)?;
2615        let path = self.resolve_class_source(&g, class_name, None)?;
2616        let content = std::fs::read_to_string(&path)
2617            .map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
2618        let all_lines: Vec<&str> = content.lines().collect();
2619        let total = all_lines.len();
2620        if to_line == 0 {
2621            to_line = total;
2622        }
2623        if from_line > to_line {
2624            return Err(format!(
2625                "Invalid range: from_line ({}) > to_line ({})",
2626                from_line, to_line
2627            ));
2628        }
2629        let s = from_line.saturating_sub(1);
2630        let e = to_line.min(total);
2631        let numbered: Vec<String> = all_lines[s..e]
2632            .iter()
2633            .enumerate()
2634            .map(|(i, line)| format!("{:<6} {}", from_line + i, line))
2635            .collect();
2636        let result = format!(
2637            "File: {}  ({} lines total)\n\n── lines {}-{} ──\n{}",
2638            path.display(),
2639            total,
2640            from_line,
2641            to_line,
2642            numbered.join("\n")
2643        );
2644        self.log_tool_call(
2645            "codesynapse_read",
2646            result.len(),
2647            content.len().saturating_sub(result.len()),
2648        );
2649        Ok(result)
2650    }
2651
2652    fn tool_codesynapse_read_method(
2653        &self,
2654        args: &Map<String, Value>,
2655    ) -> std::result::Result<String, String> {
2656        let class_name = Self::str_arg(args, "class_name")?;
2657        let method_name = Self::str_arg(args, "method_name")?;
2658        let graph_name = args
2659            .get("graph")
2660            .and_then(|v| v.as_str())
2661            .unwrap_or("merged");
2662
2663        let g = self.load_module_serve_graph(graph_name)?;
2664        let path = self.resolve_class_source(&g, class_name, Some(method_name))?;
2665        let content = std::fs::read_to_string(&path)
2666            .map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
2667        let source_lines: Vec<&str> = content.lines().collect();
2668        let items = outline_items(&path, &content);
2669
2670        match extract_method_range(&source_lines, &items, method_name) {
2671            None => {
2672                let methods: Vec<&str> = items
2673                    .iter()
2674                    .filter(|i| i.kind == "method")
2675                    .map(|i| i.name.as_str())
2676                    .take(20)
2677                    .collect();
2678                Err(format!(
2679                    "No method matching '{}' in {}.\nAvailable methods: {}",
2680                    method_name,
2681                    class_name,
2682                    methods.join(", ")
2683                ))
2684            }
2685            Some((start_1, end_1)) => {
2686                let numbered: Vec<String> = source_lines[start_1 - 1..end_1]
2687                    .iter()
2688                    .enumerate()
2689                    .map(|(i, line)| format!("{:<6} {}", start_1 + i, line))
2690                    .collect();
2691                let result = format!(
2692                    "File: {}  (lines {}-{} of {})\n\n{}",
2693                    path.display(),
2694                    start_1,
2695                    end_1,
2696                    source_lines.len(),
2697                    numbered.join("\n")
2698                );
2699                self.log_tool_call(
2700                    "codesynapse_read_method",
2701                    result.len(),
2702                    content.len().saturating_sub(result.len()),
2703                );
2704                Ok(result)
2705            }
2706        }
2707    }
2708
2709    fn tool_codesynapse_read_with_callees(
2710        &self,
2711        args: &Map<String, Value>,
2712    ) -> std::result::Result<String, String> {
2713        let class_name = Self::str_arg(args, "class_name")?;
2714        let method_name = Self::str_arg(args, "method_name")?;
2715        let graph_name = args
2716            .get("graph")
2717            .and_then(|v| v.as_str())
2718            .unwrap_or("merged");
2719
2720        let g = self.load_module_serve_graph(graph_name)?;
2721        let path = self.resolve_class_source(&g, class_name, Some(method_name))?;
2722        let content = std::fs::read_to_string(&path)
2723            .map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
2724        let source_lines: Vec<&str> = content.lines().collect();
2725        let items = outline_items(&path, &content);
2726
2727        let mut all_methods: HashMap<String, (usize, usize)> = HashMap::new();
2728        for item in &items {
2729            if item.kind == "method" {
2730                if let Some(range) =
2731                    extract_method_range(&source_lines, std::slice::from_ref(item), &item.name)
2732                {
2733                    all_methods.insert(item.name.clone(), range);
2734                }
2735            }
2736        }
2737
2738        let (start_1, end_1) = match extract_method_range(&source_lines, &items, method_name) {
2739            None => {
2740                let methods: Vec<&str> = all_methods.keys().map(|s| s.as_str()).take(20).collect();
2741                return Err(format!(
2742                    "No method matching '{}' in {}.\nAvailable methods: {}",
2743                    method_name,
2744                    class_name,
2745                    methods.join(", ")
2746                ));
2747            }
2748            Some(r) => r,
2749        };
2750
2751        let render_method = |name: &str, s1: usize, e1: usize, indent: &str| -> String {
2752            let lines: Vec<String> = source_lines[s1 - 1..e1]
2753                .iter()
2754                .enumerate()
2755                .map(|(i, line)| format!("{}{:<6} {}", indent, s1 + i, line))
2756                .collect();
2757            format!(
2758                "{}--- {} (L{}-{}) ---\n{}",
2759                indent,
2760                name,
2761                s1,
2762                e1,
2763                lines.join("\n")
2764            )
2765        };
2766
2767        let mut sections = vec![render_method(method_name, start_1, end_1, "")];
2768
2769        let body = source_lines[start_1 - 1..end_1].join("\n");
2770        let mut callee_keys: Vec<String> = all_methods
2771            .keys()
2772            .filter(|&name| name != method_name && body.contains(&format!("{}(", name)))
2773            .cloned()
2774            .collect();
2775        callee_keys.sort();
2776        let mut seen = std::collections::HashSet::new();
2777        for callee_name in callee_keys {
2778            if seen.insert(callee_name.clone()) {
2779                if let Some(&(cs1, ce1)) = all_methods.get(&callee_name) {
2780                    sections.push(render_method(&callee_name, cs1, ce1, "  "));
2781                }
2782            }
2783        }
2784
2785        Ok(format!(
2786            "File: {}\n\n{}",
2787            path.display(),
2788            sections.join("\n\n")
2789        ))
2790    }
2791
2792    fn tool_codesynapse_find_callers(
2793        &self,
2794        args: &Map<String, Value>,
2795    ) -> std::result::Result<String, String> {
2796        let class_name = Self::str_arg(args, "class_name")?;
2797        let method_name = args
2798            .get("method_name")
2799            .and_then(|v| v.as_str())
2800            .unwrap_or("");
2801        let graph_name = args
2802            .get("graph")
2803            .and_then(|v| v.as_str())
2804            .unwrap_or("merged");
2805
2806        let g = self.load_module_serve_graph(graph_name)?;
2807        let candidates = find_nodes_by_label(&g, class_name);
2808        if candidates.is_empty() {
2809            return Ok(format!(
2810                "No node matching '{}' found in graph '{}'.",
2811                class_name, graph_name
2812            ));
2813        }
2814        let target_ids: std::collections::HashSet<String> = candidates
2815            .iter()
2816            .take(5)
2817            .map(|(id, _)| id.to_string())
2818            .collect();
2819
2820        let call_rels = ["calls", "uses", "invokes"];
2821        let mut callers: Vec<(String, String)> = Vec::new();
2822        for edge in g.edges_iter() {
2823            if !target_ids.contains(&edge.target) {
2824                continue;
2825            }
2826            if !call_rels.contains(&edge.relation.as_str()) {
2827                continue;
2828            }
2829            if let Some(caller_node) = g.get_node(&edge.source) {
2830                callers.push((caller_node.label.clone(), caller_node.source_file.clone()));
2831            }
2832        }
2833
2834        let subject = if method_name.is_empty() {
2835            class_name.to_string()
2836        } else {
2837            format!("{}.{}", class_name, method_name)
2838        };
2839
2840        if !callers.is_empty() {
2841            let mut lines = vec![format!(
2842                "Callers of {} ({} found via graph call edges):\n",
2843                subject,
2844                callers.len()
2845            )];
2846            for (label, src) in &callers {
2847                lines.push(format!("  {}", label));
2848                if !src.is_empty() {
2849                    lines.push(format!("    [{}]", src));
2850                }
2851            }
2852            return Ok(lines.join("\n"));
2853        }
2854
2855        let roots = source_roots_from_graph(&g);
2856        if roots.is_empty() {
2857            return Ok(format!(
2858                "No graph call edges found for {}. No source roots detected.",
2859                subject
2860            ));
2861        }
2862        let search_term = if method_name.is_empty() {
2863            class_name
2864        } else {
2865            method_name
2866        };
2867        let pattern = Regex::new(&format!(r"\b{}\b", regex::escape(search_term)))
2868            .map_err(|e| e.to_string())?;
2869        let exts = ["java", "js", "ts", "tsx", "py", "kt", "rs", "go", "rb"];
2870        let hits = search_source_files(&roots, &exts, &pattern, 50);
2871
2872        if hits.is_empty() {
2873            return Ok(format!(
2874                "No callers found for {} (graph edges + source text search).",
2875                subject
2876            ));
2877        }
2878        let mut lines = vec![format!(
2879            "Callers of {} ({} source matches — no graph call edges):\n",
2880            subject,
2881            hits.len()
2882        )];
2883        for (rel, lineno, content) in &hits {
2884            lines.push(format!("  {}:{}", rel, lineno));
2885            lines.push(format!("    {}", content));
2886        }
2887        Ok(lines.join("\n"))
2888    }
2889
2890    fn tool_codesynapse_find_usages(
2891        &self,
2892        args: &Map<String, Value>,
2893    ) -> std::result::Result<String, String> {
2894        let class_name = Self::str_arg(args, "class_name")?;
2895        let graph_name = args
2896            .get("graph")
2897            .and_then(|v| v.as_str())
2898            .unwrap_or("merged");
2899
2900        let g = self.load_module_serve_graph(graph_name)?;
2901        let roots = source_roots_from_graph(&g);
2902        if roots.is_empty() {
2903            return Ok(
2904                "No source roots detected in graph. Ensure graph was built with absolute paths."
2905                    .into(),
2906            );
2907        }
2908        let pattern = Regex::new(&format!(r"\b{}\b", regex::escape(class_name)))
2909            .map_err(|e| e.to_string())?;
2910        let exts = [
2911            "java", "js", "ts", "tsx", "py", "kt", "rs", "go", "rb", "cls",
2912        ];
2913        let hits = search_source_files(&roots, &exts, &pattern, 60);
2914
2915        if hits.is_empty() {
2916            return Ok(format!(
2917                "No usages of '{}' found across source roots.",
2918                class_name
2919            ));
2920        }
2921        let truncated = hits.len() >= 60;
2922        let mut lines = vec![format!(
2923            "Usages of {} ({}{}  matches):\n",
2924            class_name,
2925            hits.len(),
2926            if truncated { "+" } else { "" }
2927        )];
2928        let mut prev_file = String::new();
2929        for (rel, lineno, content) in &hits {
2930            if *rel != prev_file {
2931                lines.push(format!("  {}:", rel));
2932                prev_file = rel.clone();
2933            }
2934            lines.push(format!("    L{}: {}", lineno, content));
2935        }
2936        if truncated {
2937            lines.push("\n  (truncated at 60 matches — use a more specific name)".into());
2938        }
2939        Ok(lines.join("\n"))
2940    }
2941
2942    fn tool_codesynapse_context(
2943        &self,
2944        args: &Map<String, Value>,
2945    ) -> std::result::Result<String, String> {
2946        let query = Self::str_arg(args, "query")?;
2947        let graph_name = args
2948            .get("graph")
2949            .and_then(|v| v.as_str())
2950            .unwrap_or("merged");
2951        let max_chars = Self::int_arg(args, "max_chars", 16000) as usize;
2952
2953        let g = self.load_module_serve_graph(graph_name)?;
2954        let dense = self
2955            .embedder
2956            .as_ref()
2957            .filter(|_| !self.node_embeddings.is_empty())
2958            .map(|e| (e, &self.node_embeddings));
2959
2960        let result = context_query(&g, query, dense);
2961
2962        let query_lower = query.to_lowercase();
2963        let query_tokens: Vec<&str> = query_lower.split_whitespace().collect();
2964
2965        let mut out = String::new();
2966        let mut total_chars = 0usize;
2967
2968        if result.fallback {
2969            out.push_str("[No exact match — showing semantic results]\n\n");
2970        }
2971
2972        let render_group = |header: &str,
2973                            nodes: &[(String, String, String)],
2974                            out: &mut String,
2975                            total_chars: &mut usize,
2976                            max_chars: usize,
2977                            query_tokens: &[&str],
2978                            require_source: bool|
2979         -> (bool, usize) {
2980            if nodes.is_empty() {
2981                return (true, 0);
2982            }
2983            let mut rendered = 0usize;
2984            let mut header_written = false;
2985            for (_, label, source_file) in nodes {
2986                if *total_chars >= max_chars {
2987                    return (false, rendered);
2988                }
2989                let path = std::path::PathBuf::from(source_file);
2990                let source = self.get_source_cached(&path);
2991                if require_source && source.is_none() {
2992                    continue; // skip nodes whose source file is unreadable
2993                }
2994                if !header_written {
2995                    out.push_str(&format!("## {}\n", header));
2996                    header_written = true;
2997                }
2998                let header_line = format!(
2999                    "### {} ({})\n",
3000                    label,
3001                    path.file_name()
3002                        .and_then(|f| f.to_str())
3003                        .unwrap_or(source_file)
3004                );
3005                out.push_str(&header_line);
3006                rendered += 1;
3007
3008                if let Some((content, items)) = source {
3009                    let (content, items) = (content, items);
3010                    let source_lines: Vec<&str> = content.lines().collect();
3011
3012                    // Find the best-matching method body
3013                    let mut scored: Vec<(usize, &OutlineItem)> = items
3014                        .iter()
3015                        .filter(|i| matches!(i.kind.as_str(), "method" | "function"))
3016                        .map(|item| {
3017                            let name_lower = item.name.to_lowercase();
3018                            let label_lower = label.to_lowercase();
3019                            let score = if name_lower == label_lower {
3020                                100
3021                            } else {
3022                                query_tokens
3023                                    .iter()
3024                                    .filter(|t| name_lower.contains(*t))
3025                                    .count()
3026                            };
3027                            (score, item)
3028                        })
3029                        .collect();
3030                    scored.sort_by_key(|k| std::cmp::Reverse(k.0));
3031
3032                    let mut wrote_body = false;
3033                    for &(_, item) in &scored {
3034                        if let Some((start, end)) =
3035                            extract_method_range(&source_lines, &items, &item.name)
3036                        {
3037                            let body: Vec<String> = source_lines[start - 1..end]
3038                                .iter()
3039                                .enumerate()
3040                                .map(|(i, l)| format!("{:<6} {}", start + i, l))
3041                                .collect();
3042                            out.push_str(&body.join("\n"));
3043                            out.push('\n');
3044                            wrote_body = true;
3045                            break;
3046                        }
3047                    }
3048                    if !wrote_body {
3049                        let numbered: Vec<String> = content
3050                            .lines()
3051                            .enumerate()
3052                            .map(|(i, l)| format!("{:<6} {}", i + 1, l))
3053                            .collect();
3054                        let joined = numbered.join("\n");
3055                        let snippet: String = joined.chars().take(2048).collect();
3056                        out.push_str(&snippet);
3057                        out.push('\n');
3058                    } else if let Some(method_item) = scored.first().map(|(_, i)| *i) {
3059                        let method_line = method_item.line;
3060                        let parent_class = items
3061                            .iter()
3062                            .filter(|i| i.kind == "class" && i.line <= method_line)
3063                            .max_by_key(|i| i.line);
3064                        if let Some(cls) = parent_class {
3065                            let next_class_line = items
3066                                .iter()
3067                                .filter(|i| i.kind == "class" && i.line > cls.line)
3068                                .map(|i| i.line)
3069                                .min()
3070                                .unwrap_or(usize::MAX);
3071                            let sibling_count = items
3072                                .iter()
3073                                .filter(|i| {
3074                                    matches!(i.kind.as_str(), "method" | "function")
3075                                        && i.line > cls.line
3076                                        && i.line < next_class_line
3077                                        && i.name != method_item.name
3078                                })
3079                                .count();
3080                            if sibling_count > 0 {
3081                                out.push_str(&format!(
3082                                    "> {} has {} other method(s) not shown — \
3083                                    call codesynapse_read(\"{}\") to see the full class\n",
3084                                    cls.name, sibling_count, cls.name
3085                                ));
3086                            }
3087                        }
3088                    }
3089                } // end if let Some(source)
3090                out.push('\n');
3091                *total_chars = out.len();
3092            }
3093            (true, rendered)
3094        };
3095
3096        type NodeGroup<'a> = (&'a str, &'a [(String, String, String)]);
3097        let groups: &[NodeGroup<'_>] = &[
3098            ("Entry Points", &result.entry_points),
3099            ("Callers", &result.callers),
3100            ("Callees", &result.callees),
3101        ];
3102
3103        let mut entry_rendered = 0usize;
3104        let mut fell_back = result.fallback;
3105        for (header, nodes) in groups {
3106            let (completed, rendered) = render_group(
3107                header,
3108                nodes,
3109                &mut out,
3110                &mut total_chars,
3111                max_chars,
3112                &query_tokens,
3113                true, // require_source: skip nodes with unreadable paths
3114            );
3115            if *header == "Entry Points" {
3116                entry_rendered = rendered;
3117            }
3118            if !completed {
3119                out.push_str("\n[truncated — raise max_chars to see more]\n");
3120                break;
3121            }
3122        }
3123
3124        // Exact match found but every matched node had an unreadable source path —
3125        // fall back to semantic so the response is not empty.
3126        if !result.fallback && entry_rendered == 0 && !result.entry_points.is_empty() {
3127            out.clear();
3128            out.push_str("[No exact match — showing semantic results]\n\n");
3129            let fallback_nodes = query_top_nodes(&g, query, 5, dense);
3130            render_group(
3131                "Entry Points",
3132                &fallback_nodes,
3133                &mut out,
3134                &mut total_chars,
3135                max_chars,
3136                &query_tokens,
3137                false, // don't require source for semantic fallback
3138            );
3139            fell_back = true;
3140        }
3141
3142        if entry_rendered > 0 && result.callers.is_empty() && !fell_back {
3143            out.push_str(
3144                "\n> No callers found — symbol is defined but not called within this graph.\n",
3145            );
3146        }
3147
3148        if out.trim().is_empty() || out.trim() == "[No exact match — showing semantic results]" {
3149            return Ok("No results found.".into());
3150        }
3151
3152        if !fell_back {
3153            let node_count = g.num_nodes();
3154            out.push_str(&format!(
3155                "\n---\n> Exact match found (graph has {} indexed nodes). \
3156                The entry points and source above cover the relevant mechanism. \
3157                Answer from this context.\n",
3158                node_count
3159            ));
3160        }
3161
3162        self.log_tool_call("codesynapse_context", out.len(), total_chars);
3163        Ok(out)
3164    }
3165
3166    fn tool_codesynapse_resolve(
3167        &self,
3168        args: &Map<String, Value>,
3169    ) -> std::result::Result<String, String> {
3170        let query = Self::str_arg(args, "query")?;
3171        let graph_name = args
3172            .get("graph")
3173            .and_then(|v| v.as_str())
3174            .unwrap_or("merged");
3175        let top_k = Self::int_arg(args, "top_k", 3) as usize;
3176        let max_chars = Self::int_arg(args, "max_chars", 24000) as usize;
3177
3178        let g = self.load_module_serve_graph(graph_name)?;
3179        let dense = self
3180            .embedder
3181            .as_ref()
3182            .filter(|_| !self.node_embeddings.is_empty())
3183            .map(|e| (e, &self.node_embeddings));
3184
3185        let top_nodes = query_top_nodes(&g, query, top_k, dense);
3186        if top_nodes.is_empty() {
3187            return Ok("No matching nodes found.".into());
3188        }
3189
3190        let low_confidence = top_nodes.len() > 1 && {
3191            let first_file = &top_nodes[0].2;
3192            top_nodes.iter().all(|(_, _, f)| f == first_file)
3193        };
3194
3195        let query_lower = query.to_lowercase();
3196        let query_tokens: Vec<&str> = query_lower.split_whitespace().collect();
3197
3198        let mut sections: Vec<String> = Vec::new();
3199        let mut total_chars = 0usize;
3200        let mut raw_chars = 0usize;
3201
3202        for (node_id, label, source_file) in &top_nodes {
3203            if total_chars >= max_chars {
3204                break;
3205            }
3206            let path = PathBuf::from(source_file);
3207            let (content, items) = match self.get_source_cached(&path) {
3208                Some(v) => v,
3209                None => continue,
3210            };
3211            raw_chars += content.len();
3212            let source_lines: Vec<&str> = content.lines().collect();
3213
3214            let mut scored_methods: Vec<(usize, &OutlineItem)> = items
3215                .iter()
3216                .filter(|i| i.kind == "method")
3217                .map(|item| {
3218                    let name_lower = item.name.to_lowercase();
3219                    let score = query_tokens
3220                        .iter()
3221                        .filter(|t| name_lower.contains(*t))
3222                        .count();
3223                    (score, item)
3224                })
3225                .collect();
3226            scored_methods.sort_by_key(|k| std::cmp::Reverse(k.0));
3227
3228            let caller_count = g
3229                .edges_iter()
3230                .filter(|e| e.target == *node_id && e.relation.contains("call"))
3231                .count();
3232            let caller_note = if caller_count == 0 {
3233                " [0 explicit callers — may be entry point, registered callback, or unused]"
3234                    .to_string()
3235            } else {
3236                format!(" [{} caller(s)]", caller_count)
3237            };
3238            let mut sec = format!("═══ {} ({}){}\n", label, path.display(), caller_note);
3239
3240            for item in &items {
3241                let prefix = match item.kind.as_str() {
3242                    "class" | "interface" | "enum" | "record" => "CLASS",
3243                    "method" => "  def",
3244                    "field" => "  var",
3245                    _ => "     ",
3246                };
3247                sec.push_str(&format!("  L{:<6} {} {}\n", item.line, prefix, item.name));
3248            }
3249            sec.push('\n');
3250
3251            for &(score, item) in &scored_methods {
3252                if total_chars + sec.len() >= max_chars {
3253                    break;
3254                }
3255                if let Some((start, end)) = extract_method_range(&source_lines, &items, &item.name)
3256                {
3257                    let body: Vec<String> = source_lines[start - 1..end]
3258                        .iter()
3259                        .enumerate()
3260                        .map(|(i, l)| format!("{:<6} {}", start + i, l))
3261                        .collect();
3262                    let method_block = format!(
3263                        "── {}() [L{}-{}] {}\n{}\n",
3264                        item.name,
3265                        start,
3266                        end,
3267                        if score > 0 { "★" } else { "" },
3268                        body.join("\n")
3269                    );
3270                    if method_block.len() > 4000 && score == 0 {
3271                        continue;
3272                    }
3273                    sec.push_str(&method_block);
3274                }
3275            }
3276
3277            total_chars += sec.len();
3278            sections.push(sec);
3279        }
3280
3281        let mut result = sections.join("\n");
3282        if low_confidence {
3283            result = format!(
3284                "[low confidence — results are from one file; try rephrasing or use Bash to grep]\n\n{}",
3285                result
3286            );
3287        }
3288        self.log_tool_call(
3289            "codesynapse_resolve",
3290            result.len(),
3291            raw_chars.saturating_sub(result.len()),
3292        );
3293        Ok(result)
3294    }
3295
3296    fn tool_graph_build(&self, _args: &Map<String, Value>) -> std::result::Result<String, String> {
3297        *self.last_mtime.lock().unwrap() = None;
3298        self.graph_cache.lock().unwrap().clear();
3299        let merged = self.global_dir.join("global-graph.json");
3300        if !merged.exists() {
3301            return Ok(
3302                "global-graph.json not found. Run `codesynapse global add <path>` to register graphs.".into()
3303            );
3304        }
3305        let size_kb = std::fs::metadata(&merged)
3306            .map(|m| m.len() / 1024)
3307            .unwrap_or(0);
3308        let manifest_path = self.global_dir.join("global-manifest.json");
3309        let n_modules = if manifest_path.exists() {
3310            std::fs::read_to_string(&manifest_path)
3311                .ok()
3312                .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
3313                .and_then(|v| v.get("repos").and_then(|r| r.as_object()).map(|m| m.len()))
3314                .unwrap_or(0)
3315        } else {
3316            0
3317        };
3318        Ok(format!(
3319            "Graph cache cleared. Next query will reload from disk.\n  global-graph.json: {}KB\n  manifest: {} module(s)",
3320            size_kb, n_modules
3321        ))
3322    }
3323
3324    fn tool_codesynapse_stats(
3325        &self,
3326        _args: &Map<String, Value>,
3327    ) -> std::result::Result<String, String> {
3328        let path = self.global_dir.join("tool_stats.jsonl");
3329        if !path.exists() {
3330            return Ok(
3331                "No usage data yet — stats are recorded as you use codesynapse tools.".into(),
3332            );
3333        }
3334        let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
3335        let entries: Vec<serde_json::Value> = text
3336            .lines()
3337            .filter(|l| !l.trim().is_empty())
3338            .filter_map(|l| serde_json::from_str(l).ok())
3339            .collect();
3340        if entries.is_empty() {
3341            return Ok("No usage data yet.".into());
3342        }
3343
3344        let now = SystemTime::now()
3345            .duration_since(SystemTime::UNIX_EPOCH)
3346            .unwrap_or_default()
3347            .as_secs_f64();
3348
3349        let sum_saved = |subset: &[&serde_json::Value]| -> f64 {
3350            subset
3351                .iter()
3352                .map(|e| e.get("saved_chars").and_then(|v| v.as_f64()).unwrap_or(0.0))
3353                .sum::<f64>()
3354                / 4.0
3355        };
3356        let fmt_tok = |n: f64| -> String {
3357            if n >= 1_000_000.0 {
3358                format!("{:.1}M", n / 1_000_000.0)
3359            } else if n >= 1_000.0 {
3360                format!("{:.1}k", n / 1_000.0)
3361            } else {
3362                format!("{:.0}", n)
3363            }
3364        };
3365        // bar width = 22; row total = 1+1+8+1+1+1+4+7+1+1+8+8+1+1+22+1+1 = 68 ✓
3366        let bar = |value: f64, max: f64| -> String {
3367            if max <= 0.0 {
3368                return "░".repeat(22);
3369            }
3370            let filled = ((value / max) * 22.0) as usize;
3371            let filled = filled.min(22);
3372            format!("{}{}", "█".repeat(filled), "░".repeat(22 - filled))
3373        };
3374
3375        let today: Vec<&serde_json::Value> = entries
3376            .iter()
3377            .filter(|e| now - e.get("ts").and_then(|v| v.as_f64()).unwrap_or(0.0) < 86400.0)
3378            .collect();
3379        let week: Vec<&serde_json::Value> = entries
3380            .iter()
3381            .filter(|e| now - e.get("ts").and_then(|v| v.as_f64()).unwrap_or(0.0) < 7.0 * 86400.0)
3382            .collect();
3383        let all: Vec<&serde_json::Value> = entries.iter().collect();
3384
3385        let today_saved = sum_saved(&today);
3386        let week_saved = sum_saved(&week);
3387        let total_saved = sum_saved(&all);
3388        let max_saved = today_saved.max(week_saved).max(total_saved).max(1.0);
3389
3390        // Box: 68 chars total, 66 inner.
3391        // Row: ║ {:<8} │ {:>4} calls │ {:>8} tokens │ {bar_22} ║
3392        //       1 1  8  1 1 1  4    7 1 1  8      8  1 1  22  1 1 = 68 ✓
3393        // │ at inner positions 10, 23, 41 → ╪ at same positions in col_div
3394        let row = |label: &str, count: usize, saved: f64| -> String {
3395            format!(
3396                "║ {:<8} │ {:>4} calls │ {:>8} tokens │ {} ║",
3397                label,
3398                count,
3399                fmt_tok(saved),
3400                bar(saved, max_saved)
3401            )
3402        };
3403
3404        // Top 5 tools by call count
3405        let mut tool_counts: std::collections::HashMap<String, usize> =
3406            std::collections::HashMap::new();
3407        for e in &entries {
3408            if let Some(t) = e.get("tool").and_then(|v| v.as_str()) {
3409                *tool_counts.entry(t.to_string()).or_insert(0) += 1;
3410            }
3411        }
3412        let mut ranked: Vec<(String, usize)> = tool_counts.into_iter().collect();
3413        ranked.sort_by_key(|b| std::cmp::Reverse(b.1));
3414
3415        let top = "╔══════════════════════════════════════════════════════════════════╗";
3416        // col_div: ╪ at inner positions 10, 23, 41 — aligns with │ in data rows
3417        let col_div = "╠══════════╪════════════╪═════════════════╪════════════════════════╣";
3418        let full_div = "╠══════════════════════════════════════════════════════════════════╣";
3419        let bot = "╚══════════════════════════════════════════════════════════════════╝";
3420
3421        let mut lines = vec![
3422            String::new(),
3423            top.into(),
3424            format!("║{:^66}║", " codesynapse -- usage stats "),
3425            col_div.into(),
3426            row("Today", today.len(), today_saved),
3427            row("Last 7d", week.len(), week_saved),
3428            row("All time", entries.len(), total_saved),
3429        ];
3430
3431        if !ranked.is_empty() {
3432            lines.push(full_div.into());
3433            lines.push(format!("║{:^66}║", " top tools "));
3434            lines.push(full_div.into());
3435            for (i, (tool, count)) in ranked.iter().take(5).enumerate() {
3436                let short = tool.strip_prefix("codesynapse_").unwrap_or(tool);
3437                let short = if short.len() > 55 {
3438                    &short[..55]
3439                } else {
3440                    short
3441                };
3442                let label = format!("  {}. {}", i + 1, short);
3443                // right-align count in last 6 chars; total inner = 60+6 = 66 ✓
3444                let content = format!("{:<60}{:>6}", label, count);
3445                lines.push(format!("║{}║", content));
3446            }
3447        }
3448
3449        lines.push(bot.into());
3450        lines.push(String::new());
3451        Ok(lines.join("\n"))
3452    }
3453}
3454
3455#[cfg(test)]
3456mod tests {
3457    use super::*;
3458    use codesynapse_core::graph::MemoryGraphStore;
3459    use codesynapse_core::types::{Edge, Node};
3460    use serde_json::Map;
3461
3462    fn setup_store() -> StoreBackend {
3463        let store = MemoryGraphStore::new();
3464        let backend = StoreBackend::Memory(store);
3465        let nodes = vec![
3466            node("a", "NodeA"),
3467            node("b", "NodeB"),
3468            node("c", "NodeC"),
3469            node("d", "NodeD"),
3470        ];
3471        for n in &nodes {
3472            backend.add_node(n.clone()).unwrap();
3473        }
3474        for e in &[
3475            edge("a", "b", "calls"),
3476            edge("b", "c", "calls"),
3477            edge("c", "d", "calls"),
3478        ] {
3479            backend.add_edge(e.clone()).unwrap();
3480        }
3481        backend
3482    }
3483
3484    fn make_server(backend: StoreBackend) -> McpServer {
3485        let global_dir = PathBuf::from("/nonexistent-test-global");
3486        McpServer {
3487            backend: Mutex::new(backend),
3488            graph_path: None,
3489            last_mtime: Mutex::new(None),
3490            telemetry: Arc::new(codesynapse_core::telemetry::Telemetry::new(
3491                global_dir.clone(),
3492            )),
3493            global_dir,
3494            embedder: None,
3495            node_embeddings: HashMap::new(),
3496            graph_cache: Mutex::new(HashMap::new()),
3497            source_cache: Mutex::new(HashMap::new()),
3498            stale_check: Mutex::new(None),
3499        }
3500    }
3501
3502    fn make_server_with_global(backend: StoreBackend, global_dir: PathBuf) -> McpServer {
3503        McpServer {
3504            backend: Mutex::new(backend),
3505            graph_path: None,
3506            last_mtime: Mutex::new(None),
3507            telemetry: Arc::new(codesynapse_core::telemetry::Telemetry::new(
3508                global_dir.clone(),
3509            )),
3510            global_dir,
3511            embedder: None,
3512            node_embeddings: HashMap::new(),
3513            graph_cache: Mutex::new(HashMap::new()),
3514            source_cache: Mutex::new(HashMap::new()),
3515            stale_check: Mutex::new(None),
3516        }
3517    }
3518
3519    fn node(id: &str, label: &str) -> Node {
3520        Node {
3521            id: id.to_string(),
3522            label: label.to_string(),
3523            file_type: "code".to_string(),
3524            source_file: "test.rs".to_string(),
3525            source_location: None,
3526            community: None,
3527            rationale: None,
3528            docstring: None,
3529            metadata: HashMap::new(),
3530        }
3531    }
3532
3533    fn edge(src: &str, tgt: &str, rel: &str) -> Edge {
3534        Edge {
3535            source: src.to_string(),
3536            target: tgt.to_string(),
3537            relation: rel.to_string(),
3538            confidence: "EXTRACTED".to_string(),
3539            source_file: Some("test.rs".to_string()),
3540            weight: 1.0,
3541            context: None,
3542        }
3543    }
3544
3545    #[test]
3546    fn test_tools_list() {
3547        let tools = tool_defs();
3548        assert_eq!(tools.len(), 33);
3549        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
3550        assert!(names.contains(&"codesynapse_context"));
3551        assert!(names.contains(&"codesynapse_query_graph"));
3552        assert!(names.contains(&"codesynapse_get_node"));
3553        assert!(names.contains(&"codesynapse_get_neighbors"));
3554        assert!(names.contains(&"codesynapse_get_community"));
3555        assert!(names.contains(&"codesynapse_god_nodes"));
3556        assert!(names.contains(&"codesynapse_graph_stats"));
3557        assert!(names.contains(&"codesynapse_shortest_path"));
3558        assert!(names.contains(&"codesynapse_find_all_paths"));
3559        assert!(names.contains(&"codesynapse_weighted_path"));
3560        assert!(names.contains(&"codesynapse_community_bridges"));
3561        assert!(names.contains(&"codesynapse_diff"));
3562        assert!(names.contains(&"codesynapse_pagerank"));
3563        assert!(names.contains(&"codesynapse_detect_cycles"));
3564        assert!(names.contains(&"codesynapse_smart_summary"));
3565        assert!(names.contains(&"codesynapse_find_similar"));
3566        assert!(names.contains(&"codesynapse_query_vector"));
3567        assert!(names.contains(&"codesynapse_blast_radius"));
3568        assert!(names.contains(&"codesynapse_blast_radius_scored"));
3569        assert!(names.contains(&"codesynapse_blast_radius_multi"));
3570        assert!(names.contains(&"codesynapse_query_semantic"));
3571        assert!(names.contains(&"codesynapse_hierarchy"));
3572        assert!(names.contains(&"codesynapse_list_graphs"));
3573        assert!(names.contains(&"codesynapse_module_summary"));
3574        assert!(names.contains(&"codesynapse_outline"));
3575        assert!(names.contains(&"codesynapse_read"));
3576        assert!(names.contains(&"codesynapse_read_method"));
3577        assert!(names.contains(&"codesynapse_read_with_callees"));
3578        assert!(names.contains(&"codesynapse_find_callers"));
3579        assert!(names.contains(&"codesynapse_find_usages"));
3580        assert!(names.contains(&"codesynapse_build"));
3581        assert!(names.contains(&"codesynapse_stats"));
3582    }
3583
3584    #[test]
3585    fn test_tools_list_json() {
3586        let server = make_server(setup_store());
3587        let request = JsonRpcRequest {
3588            id: Some(serde_json::json!(1)),
3589            method: "tools/list".into(),
3590            params: None,
3591        };
3592        let response = server.handle_request(&request);
3593        assert_eq!(response.id, Some(serde_json::json!(1)));
3594        assert!(response.error.is_none());
3595        let result = response.result.unwrap();
3596        let tools = result["tools"].as_array().unwrap();
3597        assert_eq!(tools.len(), 33);
3598    }
3599
3600    #[test]
3601    fn test_tool_graph_stats() {
3602        let server = make_server(setup_store());
3603        let result = server.tool_graph_stats(&Map::new()).unwrap();
3604        assert!(result.contains("Nodes: 4"));
3605        assert!(result.contains("Edges: 3"));
3606    }
3607
3608    #[test]
3609    fn test_tool_get_node_found() {
3610        let server = make_server(setup_store());
3611        let mut args = Map::new();
3612        args.insert("node_id".to_string(), serde_json::json!("a"));
3613        let result = server.tool_get_node(&args).unwrap();
3614        assert!(result.contains("NodeA"));
3615    }
3616
3617    #[test]
3618    fn test_tool_get_node_not_found() {
3619        let server = make_server(setup_store());
3620        let mut args = Map::new();
3621        args.insert("node_id".to_string(), serde_json::json!("z"));
3622        let result = server.tool_get_node(&args).unwrap();
3623        assert!(result.contains("not found"));
3624    }
3625
3626    #[test]
3627    fn test_tool_god_nodes() {
3628        let server = make_server(setup_store());
3629        let mut args = Map::new();
3630        args.insert("top_n".to_string(), serde_json::json!(3));
3631        let result = server.tool_god_nodes(&args).unwrap();
3632        assert!(result.contains("god node"));
3633    }
3634
3635    #[test]
3636    fn test_tool_shortest_path() {
3637        let server = make_server(setup_store());
3638        let mut args = Map::new();
3639        args.insert("source".to_string(), serde_json::json!("a"));
3640        args.insert("target".to_string(), serde_json::json!("d"));
3641        let result = server.tool_shortest_path(&args).unwrap();
3642        assert!(result.contains("NodeA"));
3643        assert!(result.contains("NodeD"));
3644    }
3645
3646    #[test]
3647    fn test_tool_find_all_paths() {
3648        let server = make_server(setup_store());
3649        let mut args = Map::new();
3650        args.insert("source".to_string(), serde_json::json!("a"));
3651        args.insert("target".to_string(), serde_json::json!("d"));
3652        args.insert("max_length".to_string(), serde_json::json!(5));
3653        let result = server.tool_find_all_paths(&args).unwrap();
3654        assert!(result.contains("a -> b"));
3655    }
3656
3657    #[test]
3658    fn test_tool_smart_summary_architecture() {
3659        let server = make_server(setup_store());
3660        let mut args = Map::new();
3661        args.insert("level".to_string(), serde_json::json!("architecture"));
3662        let result = server.tool_smart_summary(&args).unwrap();
3663        assert!(result.contains("Nodes: 4"));
3664        assert!(result.contains("Edges: 3"));
3665    }
3666
3667    #[test]
3668    fn test_tool_unknown_method() {
3669        let server = make_server(setup_store());
3670        let request = JsonRpcRequest {
3671            id: Some(serde_json::json!(1)),
3672            method: "unknown".into(),
3673            params: None,
3674        };
3675        let response = server.handle_request(&request);
3676        assert!(response.error.is_some());
3677    }
3678
3679    #[test]
3680    fn test_tool_unknown_tool() {
3681        let server = make_server(setup_store());
3682        let mut args = Map::new();
3683        args.insert("name".to_string(), serde_json::json!("nonexistent"));
3684        args.insert("arguments".to_string(), serde_json::json!({}));
3685        let params = serde_json::json!({"name": "nonexistent", "arguments": {}});
3686        let request = JsonRpcRequest {
3687            id: Some(serde_json::json!(1)),
3688            method: "tools/call".into(),
3689            params: Some(params),
3690        };
3691        let response = server.handle_request(&request);
3692        assert!(response.error.is_some());
3693    }
3694
3695    #[test]
3696    fn test_server_hotreload_detects_mtime_change() {
3697        use std::io::Cursor;
3698        use std::time::Duration;
3699
3700        let graph_file = std::env::temp_dir().join("codesynapse_hotreload_test.tmp");
3701        std::fs::write(&graph_file, b"placeholder").unwrap();
3702
3703        let global_dir = PathBuf::from("/nonexistent-test-global");
3704        let server = McpServer {
3705            backend: Mutex::new(setup_store()),
3706            graph_path: Some(graph_file.clone()),
3707            last_mtime: Mutex::new(None),
3708            telemetry: Arc::new(codesynapse_core::telemetry::Telemetry::new(
3709                global_dir.clone(),
3710            )),
3711            global_dir,
3712            embedder: None,
3713            node_embeddings: HashMap::new(),
3714            graph_cache: Mutex::new(HashMap::new()),
3715            source_cache: Mutex::new(HashMap::new()),
3716            stale_check: Mutex::new(None),
3717        };
3718
3719        let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n";
3720        server
3721            .run_on(Cursor::new(input.as_bytes()), Vec::new())
3722            .unwrap();
3723        let mtime_after_first = *server.last_mtime.lock().unwrap();
3724        assert!(
3725            mtime_after_first.is_some(),
3726            "mtime should be recorded after first call"
3727        );
3728
3729        // Simulate mtime change: reset recorded mtime to an old value
3730        let old_mtime = mtime_after_first.unwrap() - Duration::from_secs(10);
3731        *server.last_mtime.lock().unwrap() = Some(old_mtime);
3732
3733        // Touch the file so its real mtime differs from what we injected
3734        std::thread::sleep(Duration::from_millis(20));
3735        std::fs::write(&graph_file, b"updated").unwrap();
3736
3737        server
3738            .run_on(Cursor::new(input.as_bytes()), Vec::new())
3739            .unwrap();
3740        let mtime_after_second = *server.last_mtime.lock().unwrap();
3741        assert_ne!(
3742            Some(old_mtime),
3743            mtime_after_second,
3744            "mtime should be refreshed after file change"
3745        );
3746
3747        let _ = std::fs::remove_file(&graph_file);
3748    }
3749
3750    #[test]
3751    fn test_blank_stdin_lines_do_not_crash() {
3752        use std::io::Cursor;
3753        let server = make_server(setup_store());
3754        // Mix of blank, whitespace-only, and a valid request
3755        let input = "\n   \n\t\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n\n";
3756        let reader = Cursor::new(input.as_bytes());
3757        let mut output = Vec::new();
3758        server
3759            .run_on(reader, &mut output)
3760            .expect("should not crash on blank lines");
3761        let out_str = String::from_utf8(output).unwrap();
3762        assert!(
3763            !out_str.is_empty(),
3764            "should produce response for valid line"
3765        );
3766        assert!(
3767            out_str.contains("tools"),
3768            "response should include tool listing"
3769        );
3770        // Only one JSON object should appear (blank lines produce no output)
3771        assert_eq!(out_str.lines().count(), 1);
3772    }
3773
3774    // ---------------------------------------------------------------------------
3775    // Phase 2 — Graph MCP tool tests
3776
3777    fn write_test_graph(
3778        dir: &std::path::Path,
3779        name: &str,
3780        nodes: &[(&str, &str, &str)],
3781        edges: &[(&str, &str, &str)],
3782    ) -> PathBuf {
3783        let nodes_json: Vec<serde_json::Value> = nodes
3784            .iter()
3785            .map(|(id, label, ft)| serde_json::json!({"id": id, "label": label, "file_type": ft, "source_file": "src/x.rs"}))
3786            .collect();
3787        let edges_json: Vec<serde_json::Value> = edges
3788            .iter()
3789            .map(|(src, tgt, rel)| serde_json::json!({"source": src, "target": tgt, "relation": rel, "confidence": "EXTRACTED"}))
3790            .collect();
3791        let path = dir.join(name);
3792        std::fs::write(
3793            &path,
3794            serde_json::to_string(&serde_json::json!({"nodes": nodes_json, "edges": edges_json}))
3795                .unwrap(),
3796        )
3797        .unwrap();
3798        path
3799    }
3800
3801    fn write_manifest(global_dir: &std::path::Path, entries: &[(&str, &str, usize, usize)]) {
3802        use codesynapse_core::global_graph::{GlobalManifest, RepoManifestEntry};
3803        let mut manifest = GlobalManifest::default();
3804        for (tag, path, node_count, edge_count) in entries {
3805            manifest.repos.insert(
3806                tag.to_string(),
3807                RepoManifestEntry {
3808                    added_at: "2026-01-01T00:00:00Z".into(),
3809                    source_path: path.to_string(),
3810                    node_count: *node_count,
3811                    edge_count: *edge_count,
3812                    source_hash: "abc123".into(),
3813                },
3814            );
3815        }
3816        std::fs::create_dir_all(global_dir).unwrap();
3817        std::fs::write(
3818            global_dir.join("global-manifest.json"),
3819            serde_json::to_string_pretty(&manifest).unwrap(),
3820        )
3821        .unwrap();
3822    }
3823
3824    #[test]
3825    fn test_graph_list_graphs_empty() {
3826        let tmp = tempfile::tempdir().unwrap();
3827        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3828        let result = server.tool_graph_list_graphs(&Map::new()).unwrap();
3829        assert!(result.contains("No graphs registered"));
3830    }
3831
3832    #[test]
3833    fn test_graph_list_graphs_with_entries() {
3834        let tmp = tempfile::tempdir().unwrap();
3835        let graph_path = write_test_graph(tmp.path(), "g.json", &[("a", "MyClass", "code")], &[]);
3836        write_manifest(
3837            tmp.path(),
3838            &[("mymodule", graph_path.to_str().unwrap(), 1, 0)],
3839        );
3840        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3841        let result = server.tool_graph_list_graphs(&Map::new()).unwrap();
3842        assert!(result.contains("mymodule"));
3843        assert!(result.contains("1"));
3844    }
3845
3846    #[test]
3847    fn test_graph_module_summary_not_found() {
3848        let tmp = tempfile::tempdir().unwrap();
3849        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3850        let mut args = Map::new();
3851        args.insert("module".into(), serde_json::json!("nonexistent"));
3852        let result = server.tool_graph_module_summary(&args);
3853        assert!(result.is_err());
3854        assert!(result.unwrap_err().contains("not found"));
3855    }
3856
3857    #[test]
3858    fn test_graph_module_summary_found() {
3859        let tmp = tempfile::tempdir().unwrap();
3860        let graph_path = write_test_graph(
3861            tmp.path(),
3862            "g.json",
3863            &[("a", "Alpha", "code"), ("b", "Beta", "code")],
3864            &[("a", "b", "calls")],
3865        );
3866        write_manifest(tmp.path(), &[("mymod", graph_path.to_str().unwrap(), 2, 1)]);
3867        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3868        let mut args = Map::new();
3869        args.insert("module".into(), serde_json::json!("mymod"));
3870        let result = server.tool_graph_module_summary(&args).unwrap();
3871        assert!(result.contains("mymod"));
3872        assert!(result.contains("Nodes:"));
3873    }
3874
3875    #[test]
3876    fn test_graph_blast_radius() {
3877        let tmp = tempfile::tempdir().unwrap();
3878        // A→B→C, D is disconnected
3879        let graph_path = write_test_graph(
3880            tmp.path(),
3881            "g.json",
3882            &[
3883                ("a", "Alpha", "code"),
3884                ("b", "Beta", "code"),
3885                ("c", "Gamma", "code"),
3886                ("d", "Delta", "code"),
3887            ],
3888            &[("a", "b", "calls"), ("b", "c", "calls")],
3889        );
3890        // Use as "merged" (global-graph.json)
3891        std::fs::copy(&graph_path, tmp.path().join("global-graph.json")).unwrap();
3892        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3893        let mut args = Map::new();
3894        args.insert("class_name".into(), serde_json::json!("Alpha"));
3895        args.insert("depth".into(), serde_json::json!(3));
3896        let result = server.tool_graph_blast_radius(&args).unwrap();
3897        assert!(result.contains("Alpha"));
3898        assert!(result.contains("Beta") || result.contains("b"));
3899    }
3900
3901    #[test]
3902    fn test_graph_blast_radius_not_found() {
3903        let tmp = tempfile::tempdir().unwrap();
3904        write_test_graph(
3905            tmp.path(),
3906            "global-graph.json",
3907            &[("a", "Alpha", "code")],
3908            &[],
3909        );
3910        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3911        let mut args = Map::new();
3912        args.insert("class_name".into(), serde_json::json!("ZZZNotHere"));
3913        let result = server.tool_graph_blast_radius(&args);
3914        assert!(result.is_err());
3915    }
3916
3917    #[test]
3918    fn test_graph_hierarchy_no_edges() {
3919        let tmp = tempfile::tempdir().unwrap();
3920        write_test_graph(
3921            tmp.path(),
3922            "global-graph.json",
3923            &[("a", "Animal", "code"), ("b", "Dog", "code")],
3924            &[("b", "a", "extends")],
3925        );
3926        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3927        let mut args = Map::new();
3928        args.insert("class_name".into(), serde_json::json!("Animal"));
3929        let result = server.tool_graph_hierarchy(&args).unwrap();
3930        assert!(result.contains("Animal"));
3931        assert!(result.contains("Dog") || result.contains("b"));
3932    }
3933
3934    #[test]
3935    fn test_graph_query_vector_returns_result() {
3936        let tmp = tempfile::tempdir().unwrap();
3937        write_test_graph(
3938            tmp.path(),
3939            "global-graph.json",
3940            &[("a", "UserService", "code"), ("b", "AuthService", "code")],
3941            &[("a", "b", "calls")],
3942        );
3943        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3944        let mut args = Map::new();
3945        args.insert("query".into(), serde_json::json!("user"));
3946        let result = server.tool_graph_query_vector(&args).unwrap();
3947        assert!(!result.is_empty());
3948    }
3949
3950    // ---------------------------------------------------------------------------
3951    // Phase 3 & 4 tests
3952
3953    fn write_source_file(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
3954        let path = dir.join(name);
3955        std::fs::write(&path, content).unwrap();
3956        path
3957    }
3958
3959    fn write_graph_with_abs_source(
3960        tmp: &std::path::Path,
3961        graph_name: &str,
3962        src_abs: &str,
3963    ) -> PathBuf {
3964        let json = serde_json::json!({
3965            "nodes": [{"id": "cls1", "label": "MyClass", "file_type": "code", "source_file": src_abs}],
3966            "edges": []
3967        });
3968        let path = tmp.join(graph_name);
3969        std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
3970        path
3971    }
3972
3973    #[test]
3974    fn test_outline_java_basic() {
3975        let path = PathBuf::from("Foo.java");
3976        let content = "public class Foo {\n  public void bar() {\n  }\n}";
3977        let items = outline_items(&path, content);
3978        let kinds: Vec<&str> = items.iter().map(|i| i.kind.as_str()).collect();
3979        assert!(kinds.contains(&"class"), "should find class");
3980        assert!(kinds.contains(&"method"), "should find method");
3981    }
3982
3983    #[test]
3984    fn test_outline_python_basic() {
3985        let path = PathBuf::from("foo.py");
3986        let content = "class Foo:\n    def bar(self):\n        pass\n\ndef top_func():\n    pass";
3987        let items = outline_items(&path, content);
3988        assert!(items.iter().any(|i| i.kind == "class" && i.name == "Foo"));
3989        assert!(items.iter().any(|i| i.kind == "method" && i.name == "bar"));
3990        assert!(items
3991            .iter()
3992            .any(|i| i.kind == "method" && i.name == "top_func"));
3993    }
3994
3995    #[test]
3996    fn test_outline_js_basic() {
3997        let path = PathBuf::from("foo.ts");
3998        let content = "export class Foo {\n  doThing() {\n  }\n}";
3999        let items = outline_items(&path, content);
4000        assert!(items.iter().any(|i| i.kind == "class" && i.name == "Foo"));
4001        assert!(items
4002            .iter()
4003            .any(|i| i.kind == "method" && i.name == "doThing"));
4004    }
4005
4006    #[test]
4007    fn test_detect_method_end_brace_tracking() {
4008        let lines: Vec<&str> = vec!["void foo() {", "  if (x) { bar(); }", "}", "void next() {"];
4009        let end = detect_method_end(&lines, 0);
4010        assert_eq!(end, 2);
4011    }
4012
4013    #[test]
4014    fn test_extract_method_range_found() {
4015        let lines: Vec<&str> = vec!["public void doIt() {", "  x++;", "}"];
4016        let items = vec![OutlineItem {
4017            kind: "method".into(),
4018            name: "doIt".into(),
4019            line: 1,
4020        }];
4021        let result = extract_method_range(&lines, &items, "doIt");
4022        assert!(result.is_some());
4023        let (s, e) = result.unwrap();
4024        assert_eq!(s, 1);
4025        assert_eq!(e, 3);
4026    }
4027
4028    #[test]
4029    fn test_source_roots_from_graph_empty() {
4030        let tmp = tempfile::tempdir().unwrap();
4031        write_test_graph(
4032            tmp.path(),
4033            "global-graph.json",
4034            &[("a", "Foo", "code")],
4035            &[],
4036        );
4037        let g = load_graph(&tmp.path().join("global-graph.json")).unwrap();
4038        let roots = source_roots_from_graph(&g);
4039        assert!(
4040            roots.is_empty(),
4041            "relative source_file paths should yield no roots"
4042        );
4043    }
4044
4045    #[test]
4046    fn test_synapse_outline_resolves_file() {
4047        let tmp = tempfile::tempdir().unwrap();
4048        let src = write_source_file(
4049            tmp.path(),
4050            "Foo.java",
4051            "public class Foo {\n  public void bar() {}\n}",
4052        );
4053        write_graph_with_abs_source(tmp.path(), "global-graph.json", src.to_str().unwrap());
4054        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4055        let mut args = Map::new();
4056        args.insert("class_name".into(), serde_json::json!("MyClass"));
4057        let result = server.tool_codesynapse_outline(&args).unwrap();
4058        assert!(
4059            result.contains("Foo.java") || result.contains("File:"),
4060            "should include file info"
4061        );
4062    }
4063
4064    #[test]
4065    fn test_synapse_outline_not_found() {
4066        let tmp = tempfile::tempdir().unwrap();
4067        write_test_graph(
4068            tmp.path(),
4069            "global-graph.json",
4070            &[("a", "Foo", "code")],
4071            &[],
4072        );
4073        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4074        let mut args = Map::new();
4075        args.insert("class_name".into(), serde_json::json!("ZZZNotHere"));
4076        let result = server.tool_codesynapse_outline(&args);
4077        assert!(result.is_err() || result.unwrap().contains("not found"));
4078    }
4079
4080    #[test]
4081    fn test_synapse_read_line_range() {
4082        let tmp = tempfile::tempdir().unwrap();
4083        let src = write_source_file(tmp.path(), "Bar.java", "line1\nline2\nline3\nline4\nline5");
4084        write_graph_with_abs_source(tmp.path(), "global-graph.json", src.to_str().unwrap());
4085        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4086        let mut args = Map::new();
4087        args.insert("class_name".into(), serde_json::json!("MyClass"));
4088        args.insert("from_line".into(), serde_json::json!(2));
4089        args.insert("to_line".into(), serde_json::json!(3));
4090        let result = server.tool_codesynapse_read(&args).unwrap();
4091        assert!(result.contains("line2"));
4092        assert!(result.contains("line3"));
4093        assert!(!result.contains("line4"));
4094    }
4095
4096    #[test]
4097    fn test_synapse_read_method_found() {
4098        let tmp = tempfile::tempdir().unwrap();
4099        let src = write_source_file(
4100            tmp.path(),
4101            "Svc.java",
4102            "public class Svc {\n  public void doWork() {\n    int x = 1;\n  }\n}",
4103        );
4104        write_graph_with_abs_source(tmp.path(), "global-graph.json", src.to_str().unwrap());
4105        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4106        let mut args = Map::new();
4107        args.insert("class_name".into(), serde_json::json!("MyClass"));
4108        args.insert("method_name".into(), serde_json::json!("doWork"));
4109        let result = server.tool_codesynapse_read_method(&args).unwrap();
4110        assert!(result.contains("doWork"));
4111        assert!(result.contains("int x = 1"));
4112    }
4113
4114    #[test]
4115    fn test_synapse_read_method_not_found() {
4116        let tmp = tempfile::tempdir().unwrap();
4117        let src = write_source_file(
4118            tmp.path(),
4119            "Svc.java",
4120            "public class Svc {\n  public void doWork() {}\n}",
4121        );
4122        write_graph_with_abs_source(tmp.path(), "global-graph.json", src.to_str().unwrap());
4123        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4124        let mut args = Map::new();
4125        args.insert("class_name".into(), serde_json::json!("MyClass"));
4126        args.insert("method_name".into(), serde_json::json!("nonexistentMethod"));
4127        let result = server.tool_codesynapse_read_method(&args);
4128        assert!(result.is_err());
4129        assert!(result.unwrap_err().contains("No method matching"));
4130    }
4131
4132    #[test]
4133    fn test_synapse_read_with_callees() {
4134        let tmp = tempfile::tempdir().unwrap();
4135        let src = write_source_file(tmp.path(), "Svc.java",
4136            "public class Svc {\n  public void main() {\n    helper();\n  }\n  public void helper() {\n    int x = 0;\n  }\n}");
4137        write_graph_with_abs_source(tmp.path(), "global-graph.json", src.to_str().unwrap());
4138        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4139        let mut args = Map::new();
4140        args.insert("class_name".into(), serde_json::json!("MyClass"));
4141        args.insert("method_name".into(), serde_json::json!("main"));
4142        let result = server.tool_codesynapse_read_with_callees(&args).unwrap();
4143        assert!(result.contains("main"));
4144        assert!(result.contains("helper"));
4145    }
4146
4147    #[test]
4148    fn test_synapse_find_callers_no_graph_edges() {
4149        let tmp = tempfile::tempdir().unwrap();
4150        write_test_graph(
4151            tmp.path(),
4152            "global-graph.json",
4153            &[("a", "Foo", "code")],
4154            &[],
4155        );
4156        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4157        let mut args = Map::new();
4158        args.insert("class_name".into(), serde_json::json!("Foo"));
4159        let result = server.tool_codesynapse_find_callers(&args).unwrap();
4160        assert!(!result.is_empty());
4161    }
4162
4163    #[test]
4164    fn test_synapse_find_callers_via_graph_edges() {
4165        let tmp = tempfile::tempdir().unwrap();
4166        let json = serde_json::json!({
4167            "nodes": [
4168                {"id": "a", "label": "FooService", "file_type": "code", "source_file": "Foo.java"},
4169                {"id": "b", "label": "BarController", "file_type": "code", "source_file": "Bar.java"}
4170            ],
4171            "edges": [
4172                {"source": "b", "target": "a", "relation": "calls", "confidence": "EXTRACTED"}
4173            ]
4174        });
4175        std::fs::write(
4176            tmp.path().join("global-graph.json"),
4177            serde_json::to_string(&json).unwrap(),
4178        )
4179        .unwrap();
4180        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4181        let mut args = Map::new();
4182        args.insert("class_name".into(), serde_json::json!("FooService"));
4183        let result = server.tool_codesynapse_find_callers(&args).unwrap();
4184        assert!(result.contains("BarController") || result.contains("graph call edges"));
4185    }
4186
4187    #[test]
4188    fn test_synapse_find_usages_no_roots() {
4189        let tmp = tempfile::tempdir().unwrap();
4190        write_test_graph(
4191            tmp.path(),
4192            "global-graph.json",
4193            &[("a", "Foo", "code")],
4194            &[],
4195        );
4196        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4197        let mut args = Map::new();
4198        args.insert("class_name".into(), serde_json::json!("Foo"));
4199        let result = server.tool_codesynapse_find_usages(&args).unwrap();
4200        assert!(!result.is_empty());
4201    }
4202
4203    #[test]
4204    fn test_graph_build_no_graph() {
4205        let tmp = tempfile::tempdir().unwrap();
4206        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4207        let result = server.tool_graph_build(&Map::new()).unwrap();
4208        assert!(result.contains("not found") || result.contains("global-graph"));
4209    }
4210
4211    #[test]
4212    fn test_graph_build_with_graph() {
4213        let tmp = tempfile::tempdir().unwrap();
4214        write_test_graph(
4215            tmp.path(),
4216            "global-graph.json",
4217            &[("a", "Foo", "code")],
4218            &[],
4219        );
4220        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4221        let result = server.tool_graph_build(&Map::new()).unwrap();
4222        assert!(result.contains("cleared") || result.contains("reload"));
4223    }
4224
4225    #[test]
4226    fn test_synapse_stats_no_data() {
4227        let tmp = tempfile::tempdir().unwrap();
4228        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4229        let result = server.tool_codesynapse_stats(&Map::new()).unwrap();
4230        assert!(result.contains("No usage data"));
4231    }
4232
4233    #[test]
4234    fn test_synapse_stats_with_data() {
4235        let tmp = tempfile::tempdir().unwrap();
4236        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4237        // Log some fake stats
4238        server.log_tool_call("codesynapse_outline", 100, 5000);
4239        server.log_tool_call("codesynapse_read_method", 200, 8000);
4240        let result = server.tool_codesynapse_stats(&Map::new()).unwrap();
4241        assert!(result.contains("codesynapse"));
4242        assert!(result.contains("calls"));
4243    }
4244
4245    #[test]
4246    fn test_tool_blast_radius_scored_ranks_high_in_degree_first() {
4247        let tmp = tempfile::tempdir().unwrap();
4248        // Alpha→Beta, plus 10 other nodes also pointing to Beta → high in-degree for Beta
4249        write_test_graph(
4250            tmp.path(),
4251            "global-graph.json",
4252            &[
4253                ("a", "Alpha", "code"),
4254                ("b", "Beta", "code"),
4255                ("c", "C1", "code"),
4256                ("d", "C2", "code"),
4257                ("e", "C3", "code"),
4258                ("f", "C4", "code"),
4259                ("g", "C5", "code"),
4260                ("h", "C6", "code"),
4261                ("i", "C7", "code"),
4262                ("j", "C8", "code"),
4263                ("k", "C9", "code"),
4264                ("l", "C10", "code"),
4265            ],
4266            &[
4267                ("a", "b", "calls"),
4268                ("c", "b", "calls"),
4269                ("d", "b", "calls"),
4270                ("e", "b", "calls"),
4271                ("f", "b", "calls"),
4272                ("g", "b", "calls"),
4273                ("h", "b", "calls"),
4274                ("i", "b", "calls"),
4275                ("j", "b", "calls"),
4276                ("k", "b", "calls"),
4277                ("l", "b", "calls"),
4278            ],
4279        );
4280        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4281        let mut args = Map::new();
4282        args.insert("class_name".into(), serde_json::json!("Alpha"));
4283        args.insert("depth".into(), serde_json::json!(2));
4284        let result = server.tool_graph_blast_radius_scored(&args).unwrap();
4285        assert!(result.contains("Beta"));
4286        assert!(result.contains("HIGH") || result.contains("MEDIUM"));
4287    }
4288
4289    #[test]
4290    fn test_tool_blast_radius_scored_no_node() {
4291        let tmp = tempfile::tempdir().unwrap();
4292        write_test_graph(
4293            tmp.path(),
4294            "global-graph.json",
4295            &[("a", "Alpha", "code")],
4296            &[],
4297        );
4298        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4299        let mut args = Map::new();
4300        args.insert("class_name".into(), serde_json::json!("ZZZGhost"));
4301        let result = server.tool_graph_blast_radius_scored(&args);
4302        assert!(result.is_err());
4303    }
4304
4305    #[test]
4306    fn test_tool_blast_radius_multi_union() {
4307        let tmp = tempfile::tempdir().unwrap();
4308        write_test_graph(
4309            tmp.path(),
4310            "global-graph.json",
4311            &[
4312                ("a", "Alpha", "code"),
4313                ("b", "Beta", "code"),
4314                ("c", "Gamma", "code"),
4315                ("d", "Delta", "code"),
4316            ],
4317            &[("a", "c", "calls"), ("b", "d", "calls")],
4318        );
4319        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4320        let mut args = Map::new();
4321        args.insert("class_names".into(), serde_json::json!(["Alpha", "Beta"]));
4322        let result = server.tool_graph_blast_radius_multi(&args).unwrap();
4323        assert!(result.contains("Alpha") || result.contains("a"));
4324        assert!(result.contains("Beta") || result.contains("b"));
4325        assert!(result.contains("Gamma") || result.contains("Delta") || result.contains("Total"));
4326    }
4327
4328    #[test]
4329    fn test_tool_blast_radius_multi_not_found() {
4330        let tmp = tempfile::tempdir().unwrap();
4331        write_test_graph(
4332            tmp.path(),
4333            "global-graph.json",
4334            &[("a", "Alpha", "code")],
4335            &[],
4336        );
4337        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4338        let mut args = Map::new();
4339        args.insert(
4340            "class_names".into(),
4341            serde_json::json!(["Alpha", "ZZZGhost"]),
4342        );
4343        let result = server.tool_graph_blast_radius_multi(&args).unwrap();
4344        assert!(result.contains("ZZZGhost"));
4345        assert!(result.contains("Not found"));
4346    }
4347
4348    #[test]
4349    fn test_tool_graph_query_semantic_no_semantic_edges() {
4350        let tmp = tempfile::tempdir().unwrap();
4351        write_test_graph(
4352            tmp.path(),
4353            "global-graph.json",
4354            &[("a", "UserService", "code"), ("b", "AuthService", "code")],
4355            &[("a", "b", "calls")],
4356        );
4357        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4358        let mut args = Map::new();
4359        args.insert("query".into(), serde_json::json!("user"));
4360        let result = server.tool_graph_query_semantic(&args).unwrap();
4361        assert!(result.contains("--llm") || result.contains("semantic"));
4362    }
4363
4364    #[test]
4365    fn test_find_nodes_by_label_exact_first() {
4366        let tmp = tempfile::tempdir().unwrap();
4367        let json = serde_json::json!({
4368            "nodes": [
4369                {"id": "a", "label": "FooService", "file_type": "code", "source_file": ""},
4370                {"id": "b", "label": "Foo", "file_type": "code", "source_file": ""}
4371            ],
4372            "edges": []
4373        });
4374        std::fs::write(
4375            tmp.path().join("global-graph.json"),
4376            serde_json::to_string(&json).unwrap(),
4377        )
4378        .unwrap();
4379        let g = load_graph(&tmp.path().join("global-graph.json")).unwrap();
4380        let results = find_nodes_by_label(&g, "Foo");
4381        assert!(!results.is_empty());
4382        assert_eq!(results[0].1.label, "Foo", "exact match should be first");
4383    }
4384
4385    #[test]
4386    fn test_outline_rust() {
4387        let src = r#"
4388pub struct Runtime { handle: Handle }
4389pub trait Scheduler: Send { fn schedule(&self); }
4390impl Runtime {
4391    pub fn new() -> Self { todo!() }
4392    pub async fn block_on<F>(&self, f: F) {}
4393}
4394impl Scheduler for Runtime {
4395    fn schedule(&self) {}
4396}
4397async fn spawn_local<F>(f: F) {}
4398fn park() {}
4399"#;
4400        let items = outline_rust(src);
4401        let kinds: Vec<(&str, &str)> = items
4402            .iter()
4403            .map(|i| (i.kind.as_str(), i.name.as_str()))
4404            .collect();
4405        assert!(kinds.contains(&("class", "Runtime")), "struct→class");
4406        assert!(
4407            kinds.contains(&("interface", "Scheduler")),
4408            "trait→interface"
4409        );
4410        assert!(
4411            kinds.iter().any(|(k, n)| *k == "method" && *n == "new"),
4412            "fn new"
4413        );
4414        assert!(
4415            kinds
4416                .iter()
4417                .any(|(k, n)| *k == "method" && *n == "block_on"),
4418            "async fn"
4419        );
4420        assert!(
4421            kinds
4422                .iter()
4423                .any(|(k, n)| *k == "method" && *n == "spawn_local"),
4424            "top-level async fn"
4425        );
4426        assert!(
4427            kinds.iter().any(|(k, n)| *k == "method" && *n == "park"),
4428            "top-level fn"
4429        );
4430        // impl Scheduler for Runtime → name=Runtime
4431        assert!(
4432            kinds
4433                .iter()
4434                .filter(|(k, n)| *k == "class" && *n == "Runtime")
4435                .count()
4436                >= 2,
4437            "impl+struct both captured"
4438        );
4439    }
4440
4441    #[test]
4442    fn test_outline_go() {
4443        let src = r#"
4444type Server struct { port int }
4445type Handler interface { ServeHTTP() }
4446func NewServer() *Server { return nil }
4447func (s *Server) Start() {}
4448"#;
4449        let items = outline_go(src);
4450        let kinds: Vec<(&str, &str)> = items
4451            .iter()
4452            .map(|i| (i.kind.as_str(), i.name.as_str()))
4453            .collect();
4454        assert!(kinds.contains(&("class", "Server")));
4455        assert!(kinds.contains(&("interface", "Handler")));
4456        assert!(kinds
4457            .iter()
4458            .any(|(k, n)| *k == "method" && *n == "NewServer"));
4459        assert!(kinds.iter().any(|(k, n)| *k == "method" && *n == "Start"));
4460    }
4461
4462    #[test]
4463    fn test_outline_ruby() {
4464        let src = r#"
4465class User
4466  def initialize(name); end
4467  def self.find(id); end
4468end
4469module Auth
4470  def authenticate; end
4471end
4472"#;
4473        let items = outline_ruby(src);
4474        let kinds: Vec<(&str, &str)> = items
4475            .iter()
4476            .map(|i| (i.kind.as_str(), i.name.as_str()))
4477            .collect();
4478        assert!(kinds.contains(&("class", "User")));
4479        assert!(kinds.contains(&("class", "Auth")));
4480        assert!(kinds
4481            .iter()
4482            .any(|(k, n)| *k == "method" && *n == "initialize"));
4483        assert!(kinds.iter().any(|(k, n)| *k == "method" && *n == "find"));
4484    }
4485
4486    #[test]
4487    fn test_outline_elixir() {
4488        let src = r#"
4489defmodule MyApp.Router do
4490  def call(conn, opts), do: conn
4491  defp handle(conn), do: conn
4492end
4493"#;
4494        let items = outline_elixir(src);
4495        let kinds: Vec<(&str, &str)> = items
4496            .iter()
4497            .map(|i| (i.kind.as_str(), i.name.as_str()))
4498            .collect();
4499        assert!(kinds
4500            .iter()
4501            .any(|(k, n)| *k == "class" && n.contains("Router")));
4502        assert!(kinds.iter().any(|(k, n)| *k == "method" && *n == "call"));
4503        assert!(kinds.iter().any(|(k, n)| *k == "method" && *n == "handle"));
4504    }
4505
4506    // --- System prompt tool hierarchy ---
4507
4508    #[test]
4509    fn test_system_prompt_context_is_primary() {
4510        assert!(
4511            CODESYNAPSE_INSTRUCTIONS.contains("ALWAYS call codesynapse_context FIRST"),
4512            "system prompt must designate codesynapse_context as primary tool"
4513        );
4514    }
4515
4516    #[test]
4517    fn test_system_prompt_resolve_is_step2() {
4518        assert!(
4519            CODESYNAPSE_INSTRUCTIONS.contains("codesynapse_resolve"),
4520            "system prompt must reference resolve as step 2"
4521        );
4522    }
4523
4524    #[test]
4525    fn test_system_prompt_resolve_trigger() {
4526        assert!(
4527            CODESYNAPSE_INSTRUCTIONS.contains("No exact match"),
4528            "system prompt must tell LLM to use resolve when context returns no exact match"
4529        );
4530    }
4531
4532    #[test]
4533    fn test_system_prompt_trust_graph_line_kept() {
4534        assert!(
4535            CODESYNAPSE_INSTRUCTIONS.contains("Do NOT grep/find to verify graph results"),
4536            "trust-the-graph line must be kept"
4537        );
4538    }
4539
4540    // --- Sibling hint: context tool detects other methods in same class ---
4541
4542    #[test]
4543    fn test_sibling_hint_detects_methods_in_same_class() {
4544        let source = "public class ProfileDatafetcher {\n\
4545            public User getUserProfile(DataFetchingEnvironment env) {\n\
4546                return env.getLocalContext();\n\
4547            }\n\
4548            private User queryProfile(DataFetchingEnvironment env) {\n\
4549                return SecurityUtil.getCurrentUser();\n\
4550            }\n\
4551            public void otherMethod() {}\n\
4552        }\n";
4553        let items = outline_java(source);
4554        let class_items: Vec<_> = items.iter().filter(|i| i.kind == "class").collect();
4555        let method_items: Vec<_> = items.iter().filter(|i| i.kind == "method").collect();
4556        assert!(!class_items.is_empty(), "expected class item");
4557        assert!(method_items.len() >= 2, "expected at least 2 method items");
4558
4559        // Simulate sibling detection for getUserProfile (first method)
4560        let method = method_items
4561            .iter()
4562            .find(|i| i.name == "getUserProfile")
4563            .unwrap();
4564        let parent = items
4565            .iter()
4566            .filter(|i| i.kind == "class" && i.line <= method.line)
4567            .max_by_key(|i| i.line)
4568            .unwrap();
4569        assert_eq!(parent.name, "ProfileDatafetcher");
4570        let siblings = items
4571            .iter()
4572            .filter(|i| {
4573                matches!(i.kind.as_str(), "method" | "function")
4574                    && i.line > parent.line
4575                    && i.name != method.name
4576            })
4577            .count();
4578        assert!(
4579            siblings >= 2,
4580            "expected at least 2 siblings, got {}",
4581            siblings
4582        );
4583    }
4584
4585    // --- Issue 7: top_k default ---
4586
4587    #[test]
4588    fn test_resolve_top_k_default_is_3() {
4589        let default = McpServer::int_arg(&Map::new(), "top_k", 3);
4590        assert_eq!(default, 3, "top_k default must be 3");
4591    }
4592
4593    // --- Issue 3: Graph cache ---
4594
4595    #[test]
4596    fn test_graph_cache_returns_same_arc() {
4597        use std::sync::Arc;
4598        let tmp = tempfile::tempdir().unwrap();
4599        write_test_graph(
4600            tmp.path(),
4601            "global-graph.json",
4602            &[("a", "Alpha", "code")],
4603            &[],
4604        );
4605        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4606        let g1 = server.load_module_serve_graph("merged").unwrap();
4607        let g2 = server.load_module_serve_graph("merged").unwrap();
4608        assert!(
4609            Arc::ptr_eq(&g1, &g2),
4610            "second load must return the cached Arc"
4611        );
4612    }
4613
4614    #[test]
4615    fn test_graph_cache_cleared_by_build() {
4616        use std::sync::Arc;
4617        let tmp = tempfile::tempdir().unwrap();
4618        write_test_graph(
4619            tmp.path(),
4620            "global-graph.json",
4621            &[("a", "Alpha", "code")],
4622            &[],
4623        );
4624        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4625        let g1 = server.load_module_serve_graph("merged").unwrap();
4626        server.tool_graph_build(&Map::new()).unwrap();
4627        let g2 = server.load_module_serve_graph("merged").unwrap();
4628        assert!(
4629            !Arc::ptr_eq(&g1, &g2),
4630            "build must clear cache, forcing a fresh load"
4631        );
4632    }
4633
4634    // --- Issue 4: Source cache ---
4635
4636    #[test]
4637    fn test_source_cache_populated_after_resolve() {
4638        let tmp = tempfile::tempdir().unwrap();
4639        let src = write_source_file(
4640            tmp.path(),
4641            "Foo.java",
4642            "public class Foo {\n  public void run() {}\n}",
4643        );
4644        write_graph_with_abs_source(tmp.path(), "global-graph.json", src.to_str().unwrap());
4645        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4646        let mut args = Map::new();
4647        args.insert("query".into(), serde_json::json!("Foo"));
4648        server.tool_codesynapse_resolve(&args).unwrap();
4649        assert!(
4650            !server.source_cache.lock().unwrap().is_empty(),
4651            "source cache must be populated after resolve reads a file"
4652        );
4653    }
4654
4655    #[test]
4656    fn test_source_cache_capped_at_1000() {
4657        let tmp = tempfile::tempdir().unwrap();
4658        let real = write_source_file(tmp.path(), "Real.java", "public class Real {}");
4659        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4660        {
4661            let mut cache = server.source_cache.lock().unwrap();
4662            for i in 0..1000usize {
4663                cache.insert(
4664                    PathBuf::from(format!("/fake/{}.java", i)),
4665                    (SystemTime::UNIX_EPOCH, String::new(), Vec::new()),
4666                );
4667            }
4668        }
4669        assert_eq!(server.source_cache.lock().unwrap().len(), 1000);
4670        server.get_source_cached(&real);
4671        let len = server.source_cache.lock().unwrap().len();
4672        assert!(len < 1000, "cache must be evicted when full (got {})", len);
4673    }
4674
4675    #[test]
4676    fn test_stale_check_returns_false_when_no_modules_dir() {
4677        let tmp = tempfile::tempdir().unwrap();
4678        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4679        assert!(!server.is_graph_stale(), "no modules dir → not stale");
4680    }
4681
4682    #[test]
4683    fn test_stale_check_returns_false_when_modules_up_to_date() {
4684        let tmp = tempfile::tempdir().unwrap();
4685        let global_dir = tmp.path().to_path_buf();
4686        let modules_dir = global_dir.join("modules").join("mymod");
4687        std::fs::create_dir_all(&modules_dir).unwrap();
4688        // Write module graph first, then global graph (global is newer → not stale)
4689        std::fs::write(modules_dir.join("graph.json"), b"{}").unwrap();
4690        std::thread::sleep(std::time::Duration::from_millis(10));
4691        std::fs::write(global_dir.join("global-graph.json"), b"{}").unwrap();
4692        let server = make_server_with_global(setup_store(), global_dir);
4693        assert!(!server.is_graph_stale());
4694    }
4695
4696    #[test]
4697    fn test_stale_check_returns_true_when_module_newer_than_global() {
4698        let tmp = tempfile::tempdir().unwrap();
4699        let global_dir = tmp.path().to_path_buf();
4700        let modules_dir = global_dir.join("modules").join("mymod");
4701        std::fs::create_dir_all(&modules_dir).unwrap();
4702        // Write global graph first, then module graph (module is newer → stale)
4703        std::fs::write(global_dir.join("global-graph.json"), b"{}").unwrap();
4704        std::thread::sleep(std::time::Duration::from_millis(10));
4705        std::fs::write(modules_dir.join("graph.json"), b"{}").unwrap();
4706        let server = make_server_with_global(setup_store(), global_dir);
4707        assert!(server.is_graph_stale());
4708    }
4709
4710    #[test]
4711    fn test_check_stale_once_caches_result() {
4712        let tmp = tempfile::tempdir().unwrap();
4713        let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4714        let first = server.check_stale_once();
4715        // Manually set stale_check to Some(true) to verify it's not re-computed
4716        *server.stale_check.lock().unwrap() = Some(true);
4717        assert!(
4718            server.check_stale_once(),
4719            "should return cached true, not re-check"
4720        );
4721        let _ = first;
4722    }
4723
4724    // --- Fix 1: skip nodes with unreadable source, fallback when all missing ---
4725
4726    fn make_server_with_module(
4727        module_name: &str,
4728        graph_json: serde_json::Value,
4729    ) -> (McpServer, tempfile::TempDir) {
4730        let tmpdir = tempfile::TempDir::new().unwrap();
4731        let graph_path = tmpdir.path().join(format!("{}.json", module_name));
4732        std::fs::write(&graph_path, graph_json.to_string()).unwrap();
4733        let manifest = serde_json::json!({
4734            "version": 1,
4735            "repos": {
4736                module_name: {
4737                    "added_at": "2024-01-01",
4738                    "source_path": graph_path.to_str().unwrap(),
4739                    "node_count": 1,
4740                    "edge_count": 0,
4741                    "source_hash": ""
4742                }
4743            }
4744        });
4745        std::fs::write(
4746            tmpdir.path().join("global-manifest.json"),
4747            manifest.to_string(),
4748        )
4749        .unwrap();
4750        let global_dir = tmpdir.path().to_path_buf();
4751        let server = McpServer {
4752            backend: Mutex::new(StoreBackend::Memory(MemoryGraphStore::new())),
4753            graph_path: None,
4754            last_mtime: Mutex::new(None),
4755            telemetry: Arc::new(codesynapse_core::telemetry::Telemetry::new(
4756                global_dir.clone(),
4757            )),
4758            global_dir,
4759            embedder: None,
4760            node_embeddings: HashMap::new(),
4761            graph_cache: Mutex::new(HashMap::new()),
4762            source_cache: Mutex::new(HashMap::new()),
4763            stale_check: Mutex::new(None),
4764        };
4765        (server, tmpdir)
4766    }
4767
4768    #[test]
4769    fn test_context_skips_node_with_missing_source() {
4770        // Two nodes with same label — one readable, one dead. Only the readable one should render.
4771        let src_dir = tempfile::TempDir::new().unwrap();
4772        let src_file = src_dir.path().join("real.py");
4773        std::fs::write(&src_file, "def authenticate():\n    pass\n").unwrap();
4774
4775        let graph = serde_json::json!({
4776            "nodes": [
4777                {
4778                    "id": "real_node",
4779                    "label": "authenticate",
4780                    "source_file": src_file.to_str().unwrap(),
4781                    "file_type": "code"
4782                },
4783                {
4784                    "id": "dead_node",
4785                    "label": "authenticate",
4786                    "source_file": "/nonexistent/dead.py",
4787                    "file_type": "code"
4788                }
4789            ],
4790            "edges": []
4791        });
4792
4793        let (server, _dir) = make_server_with_module("fix1-skip", graph);
4794        let mut args = Map::new();
4795        args.insert("query".into(), "authenticate".into());
4796        args.insert("graph".into(), "fix1-skip".into());
4797        let result = server.tool_codesynapse_context(&args).unwrap();
4798
4799        assert!(
4800            !result.contains("dead.py"),
4801            "node with missing source should not appear; output: {}",
4802            &result[..result.len().min(300)]
4803        );
4804        assert!(
4805            result.contains("Exact match found"),
4806            "readable node should still produce exact match; output: {}",
4807            &result[..result.len().min(300)]
4808        );
4809    }
4810
4811    #[test]
4812    fn test_context_fallback_when_all_sources_missing() {
4813        let graph = serde_json::json!({
4814            "nodes": [
4815                {
4816                    "id": "dead_node",
4817                    "label": "authenticate",
4818                    "source_file": "/nonexistent/dead.py",
4819                    "file_type": "code"
4820                }
4821            ],
4822            "edges": []
4823        });
4824
4825        let (server, _dir) = make_server_with_module("fix1-fallback", graph);
4826        let mut args = Map::new();
4827        args.insert("query".into(), "authenticate".into());
4828        args.insert("graph".into(), "fix1-fallback".into());
4829        let result = server.tool_codesynapse_context(&args).unwrap();
4830
4831        assert!(
4832            result.starts_with("[No exact match"),
4833            "when all exact-match sources are missing, should show semantic fallback; got: {}",
4834            &result[..result.len().min(300)]
4835        );
4836    }
4837}