Skip to main content

synwire_agent/tools/
code.rs

1//! `code.*` tool provider for semantic code navigation and search.
2//!
3//! The tools in this module provide multi-backend dispatch for code
4//! intelligence operations. When no backend is configured, each tool returns a
5//! descriptive message explaining which dependencies are required.
6//!
7//! The actual backend dispatch (daemon proxy, LSP client) is injected by the
8//! consumer (e.g. the MCP server) at startup.
9
10use synwire_core::error::SynwireError;
11use synwire_core::tools::{
12    StaticToolProvider, StructuredTool, Tool, ToolOutput, ToolProvider, ToolSchema,
13};
14
15/// Configuration controlling which `code.*` backends are available.
16#[derive(Debug, Clone, Default)]
17#[non_exhaustive]
18pub struct CodeToolConfig {
19    /// If set, daemon-backed tools are available.
20    pub daemon_available: bool,
21    /// If set, LSP-backed tools are available.
22    pub lsp_available: bool,
23}
24
25/// Build a tool provider containing all `code.*` tools.
26///
27/// The returned provider includes:
28/// - `code.search` (semantic/graph/community modes)
29/// - `code.search_hybrid` (combined semantic + keyword search)
30/// - `code.definition` (LSP-first, graph fallback)
31/// - `code.references` (LSP -> xref -> graph fallback)
32/// - `code.symbols` (LSP with skeleton fallback)
33/// - `code.type_info` (LSP hover)
34/// - `code.dependencies` (package/module dependency graph)
35/// - `code.community_members` (community detection clusters)
36/// - `code.trace_dataflow` (data flow analysis)
37/// - `code.trace_callers` (call graph traversal)
38/// - `code.fault_localize` (SBFL-based fault localization)
39///
40/// # Errors
41///
42/// Returns [`SynwireError`] if any tool fails validation.
43pub fn code_tool_provider() -> Result<Box<dyn ToolProvider>, SynwireError> {
44    let tools: Vec<Box<dyn Tool>> = vec![
45        Box::new(build_code_search()?),
46        Box::new(build_code_search_hybrid()?),
47        Box::new(build_code_definition()?),
48        Box::new(build_code_references()?),
49        Box::new(build_code_symbols()?),
50        Box::new(build_code_type_info()?),
51        Box::new(build_code_dependencies()?),
52        Box::new(build_code_community_members()?),
53        Box::new(build_code_trace_dataflow()?),
54        Box::new(build_code_trace_callers()?),
55        Box::new(build_code_fault_localize()?),
56    ];
57    Ok(Box::new(StaticToolProvider::new(tools)))
58}
59
60/// Create a stub tool that returns a "not configured" message.
61///
62/// This is the default behaviour until the consumer injects a real backend.
63fn stub_response(tool_name: &str) -> ToolOutput {
64    ToolOutput {
65        content: format!(
66            "{tool_name}: not configured. This tool requires a daemon or LSP backend. \
67             Configure the appropriate backend to enable this tool."
68        ),
69        ..Default::default()
70    }
71}
72
73fn build_code_search() -> Result<StructuredTool, SynwireError> {
74    StructuredTool::builder()
75        .name("code.search")
76        .description(
77            "Search code semantically using embeddings, call graphs, or community clusters. \
78             Supports modes: semantic, graph, community.",
79        )
80        .schema(ToolSchema {
81            name: "code.search".into(),
82            description: "Search code semantically".into(),
83            parameters: serde_json::json!({
84                "type": "object",
85                "properties": {
86                    "query": {
87                        "type": "string",
88                        "description": "Natural language search query"
89                    },
90                    "mode": {
91                        "type": "string",
92                        "enum": ["semantic", "graph", "community"],
93                        "description": "Search mode (default: semantic)"
94                    },
95                    "limit": {
96                        "type": "integer",
97                        "description": "Maximum number of results (default: 10)"
98                    }
99                },
100                "required": ["query"],
101                "additionalProperties": false,
102            }),
103        })
104        .func(|_input| Box::pin(async { Ok(stub_response("code.search")) }))
105        .build()
106}
107
108fn build_code_search_hybrid() -> Result<StructuredTool, SynwireError> {
109    StructuredTool::builder()
110        .name("code.search_hybrid")
111        .description(
112            "Combined semantic and keyword search across the codebase. \
113             Merges embedding similarity with BM25 text matching.",
114        )
115        .schema(ToolSchema {
116            name: "code.search_hybrid".into(),
117            description: "Hybrid semantic + keyword code search".into(),
118            parameters: serde_json::json!({
119                "type": "object",
120                "properties": {
121                    "query": {
122                        "type": "string",
123                        "description": "Natural language search query"
124                    },
125                    "limit": {
126                        "type": "integer",
127                        "description": "Maximum number of results (default: 10)"
128                    }
129                },
130                "required": ["query"],
131                "additionalProperties": false,
132            }),
133        })
134        .func(|_input| Box::pin(async { Ok(stub_response("code.search_hybrid")) }))
135        .build()
136}
137
138fn build_code_definition() -> Result<StructuredTool, SynwireError> {
139    StructuredTool::builder()
140        .name("code.definition")
141        .description(
142            "Go to definition of a symbol. Uses LSP when available, \
143             falls back to call graph data.",
144        )
145        .schema(ToolSchema {
146            name: "code.definition".into(),
147            description: "Find the definition of a symbol".into(),
148            parameters: serde_json::json!({
149                "type": "object",
150                "properties": {
151                    "file": {
152                        "type": "string",
153                        "description": "File path containing the symbol"
154                    },
155                    "line": {
156                        "type": "integer",
157                        "description": "1-based line number"
158                    },
159                    "column": {
160                        "type": "integer",
161                        "description": "1-based column number"
162                    },
163                    "symbol": {
164                        "type": "string",
165                        "description": "Symbol name (used for graph fallback)"
166                    }
167                },
168                "required": ["file", "line", "column"],
169                "additionalProperties": false,
170            }),
171        })
172        .func(|_input| Box::pin(async { Ok(stub_response("code.definition")) }))
173        .build()
174}
175
176fn build_code_references() -> Result<StructuredTool, SynwireError> {
177    StructuredTool::builder()
178        .name("code.references")
179        .description(
180            "Find all references to a symbol. Tries LSP, cross-reference index, \
181             then call graph in order of availability.",
182        )
183        .schema(ToolSchema {
184            name: "code.references".into(),
185            description: "Find all references to a symbol".into(),
186            parameters: serde_json::json!({
187                "type": "object",
188                "properties": {
189                    "file": {
190                        "type": "string",
191                        "description": "File path containing the symbol"
192                    },
193                    "line": {
194                        "type": "integer",
195                        "description": "1-based line number"
196                    },
197                    "column": {
198                        "type": "integer",
199                        "description": "1-based column number"
200                    },
201                    "symbol": {
202                        "type": "string",
203                        "description": "Symbol name (used for index/graph fallback)"
204                    }
205                },
206                "required": ["file", "line", "column"],
207                "additionalProperties": false,
208            }),
209        })
210        .func(|_input| Box::pin(async { Ok(stub_response("code.references")) }))
211        .build()
212}
213
214fn build_code_symbols() -> Result<StructuredTool, SynwireError> {
215    StructuredTool::builder()
216        .name("code.symbols")
217        .description(
218            "List symbols in a file or workspace. Uses LSP document/workspace symbols \
219             when available, falls back to tree-sitter skeleton extraction.",
220        )
221        .schema(ToolSchema {
222            name: "code.symbols".into(),
223            description: "List symbols in a file or workspace".into(),
224            parameters: serde_json::json!({
225                "type": "object",
226                "properties": {
227                    "file": {
228                        "type": "string",
229                        "description": "File path (omit for workspace-wide search)"
230                    },
231                    "query": {
232                        "type": "string",
233                        "description": "Filter symbols by name pattern"
234                    }
235                },
236                "additionalProperties": false,
237            }),
238        })
239        .func(|_input| Box::pin(async { Ok(stub_response("code.symbols")) }))
240        .build()
241}
242
243fn build_code_type_info() -> Result<StructuredTool, SynwireError> {
244    StructuredTool::builder()
245        .name("code.type_info")
246        .description(
247            "Get type information and documentation for a symbol at a given position. \
248             Backed by LSP hover.",
249        )
250        .schema(ToolSchema {
251            name: "code.type_info".into(),
252            description: "Get type info for a symbol via LSP hover".into(),
253            parameters: serde_json::json!({
254                "type": "object",
255                "properties": {
256                    "file": {
257                        "type": "string",
258                        "description": "File path"
259                    },
260                    "line": {
261                        "type": "integer",
262                        "description": "1-based line number"
263                    },
264                    "column": {
265                        "type": "integer",
266                        "description": "1-based column number"
267                    }
268                },
269                "required": ["file", "line", "column"],
270                "additionalProperties": false,
271            }),
272        })
273        .func(|_input| Box::pin(async { Ok(stub_response("code.type_info")) }))
274        .build()
275}
276
277fn build_code_dependencies() -> Result<StructuredTool, SynwireError> {
278    StructuredTool::builder()
279        .name("code.dependencies")
280        .description("List package or module dependencies for a file or the project root.")
281        .schema(ToolSchema {
282            name: "code.dependencies".into(),
283            description: "List package/module dependencies".into(),
284            parameters: serde_json::json!({
285                "type": "object",
286                "properties": {
287                    "file": {
288                        "type": "string",
289                        "description": "File path (omit for project-level dependencies)"
290                    },
291                    "depth": {
292                        "type": "integer",
293                        "description": "Maximum dependency depth (default: 1)"
294                    }
295                },
296                "additionalProperties": false,
297            }),
298        })
299        .func(|_input| Box::pin(async { Ok(stub_response("code.dependencies")) }))
300        .build()
301}
302
303fn build_code_community_members() -> Result<StructuredTool, SynwireError> {
304    StructuredTool::builder()
305        .name("code.community_members")
306        .description(
307            "List symbols belonging to the same community cluster as the given symbol. \
308             Requires community detection index (hit-leiden).",
309        )
310        .schema(ToolSchema {
311            name: "code.community_members".into(),
312            description: "List symbols in the same community cluster".into(),
313            parameters: serde_json::json!({
314                "type": "object",
315                "properties": {
316                    "symbol": {
317                        "type": "string",
318                        "description": "Fully qualified symbol name"
319                    },
320                    "limit": {
321                        "type": "integer",
322                        "description": "Maximum number of members (default: 20)"
323                    }
324                },
325                "required": ["symbol"],
326                "additionalProperties": false,
327            }),
328        })
329        .func(|_input| Box::pin(async { Ok(stub_response("code.community_members")) }))
330        .build()
331}
332
333fn build_code_trace_dataflow() -> Result<StructuredTool, SynwireError> {
334    StructuredTool::builder()
335        .name("code.trace_dataflow")
336        .description("Trace data flow forwards or backwards from a variable or expression.")
337        .schema(ToolSchema {
338            name: "code.trace_dataflow".into(),
339            description: "Trace data flow from a variable".into(),
340            parameters: serde_json::json!({
341                "type": "object",
342                "properties": {
343                    "file": {
344                        "type": "string",
345                        "description": "File path"
346                    },
347                    "line": {
348                        "type": "integer",
349                        "description": "1-based line number"
350                    },
351                    "column": {
352                        "type": "integer",
353                        "description": "1-based column number"
354                    },
355                    "direction": {
356                        "type": "string",
357                        "enum": ["forward", "backward"],
358                        "description": "Trace direction (default: forward)"
359                    },
360                    "depth": {
361                        "type": "integer",
362                        "description": "Maximum trace depth (default: 5)"
363                    }
364                },
365                "required": ["file", "line", "column"],
366                "additionalProperties": false,
367            }),
368        })
369        .func(|_input| Box::pin(async { Ok(stub_response("code.trace_dataflow")) }))
370        .build()
371}
372
373fn build_code_trace_callers() -> Result<StructuredTool, SynwireError> {
374    StructuredTool::builder()
375        .name("code.trace_callers")
376        .description(
377            "Trace the call graph upward from a function to find all callers, \
378             transitively up to a configurable depth.",
379        )
380        .schema(ToolSchema {
381            name: "code.trace_callers".into(),
382            description: "Trace callers of a function".into(),
383            parameters: serde_json::json!({
384                "type": "object",
385                "properties": {
386                    "symbol": {
387                        "type": "string",
388                        "description": "Fully qualified function name"
389                    },
390                    "depth": {
391                        "type": "integer",
392                        "description": "Maximum caller depth (default: 3)"
393                    }
394                },
395                "required": ["symbol"],
396                "additionalProperties": false,
397            }),
398        })
399        .func(|_input| Box::pin(async { Ok(stub_response("code.trace_callers")) }))
400        .build()
401}
402
403fn build_code_fault_localize() -> Result<StructuredTool, SynwireError> {
404    StructuredTool::builder()
405        .name("code.fault_localize")
406        .description(
407            "Rank files and functions by suspiciousness using spectrum-based fault \
408             localization (SBFL). Requires test coverage data.",
409        )
410        .schema(ToolSchema {
411            name: "code.fault_localize".into(),
412            description: "SBFL fault localization".into(),
413            parameters: serde_json::json!({
414                "type": "object",
415                "properties": {
416                    "failing_tests": {
417                        "type": "array",
418                        "items": { "type": "string" },
419                        "description": "List of failing test identifiers"
420                    },
421                    "formula": {
422                        "type": "string",
423                        "enum": ["ochiai", "tarantula", "dstar"],
424                        "description": "SBFL formula (default: ochiai)"
425                    },
426                    "limit": {
427                        "type": "integer",
428                        "description": "Maximum number of results (default: 20)"
429                    }
430                },
431                "required": ["failing_tests"],
432                "additionalProperties": false,
433            }),
434        })
435        .func(|_input| Box::pin(async { Ok(stub_response("code.fault_localize")) }))
436        .build()
437}
438
439#[cfg(test)]
440#[allow(clippy::unwrap_used)]
441mod tests {
442    use super::*;
443
444    #[tokio::test]
445    async fn code_provider_discovers_all_tools() {
446        let provider = code_tool_provider().unwrap();
447        let tools = provider.discover_tools().await.unwrap();
448        assert_eq!(tools.len(), 11);
449    }
450
451    #[tokio::test]
452    async fn code_provider_get_by_name() {
453        let provider = code_tool_provider().unwrap();
454        let tool = provider.get_tool("code.search").await.unwrap();
455        assert!(tool.is_some());
456        let missing = provider.get_tool("code.nonexistent").await.unwrap();
457        assert!(missing.is_none());
458    }
459
460    #[tokio::test]
461    async fn stub_tools_return_not_configured() {
462        let provider = code_tool_provider().unwrap();
463        let tool = provider.get_tool("code.definition").await.unwrap().unwrap();
464        let output = tool
465            .invoke(serde_json::json!({"file": "main.rs", "line": 1, "column": 1}))
466            .await
467            .unwrap();
468        assert!(output.content.contains("not configured"));
469    }
470}