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