1use synwire_core::error::SynwireError;
11use synwire_core::tools::{
12 StaticToolProvider, StructuredTool, Tool, ToolOutput, ToolProvider, ToolSchema,
13};
14
15#[derive(Debug, Clone, Default)]
17#[non_exhaustive]
18pub struct CodeToolConfig {
19 pub daemon_available: bool,
21 pub lsp_available: bool,
23}
24
25pub 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
60fn 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}