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