Skip to main content

rustant_tools/lsp/
tools.rs

1//! LSP-based tools for code intelligence.
2//!
3//! Provides 7 tools that leverage language server capabilities:
4//! hover, go-to-definition, find-references, diagnostics,
5//! completions, rename, and format.
6
7use crate::registry::Tool;
8use async_trait::async_trait;
9use rustant_core::error::ToolError;
10use rustant_core::types::{RiskLevel, ToolOutput};
11use serde_json::Value;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15use super::LspBackend;
16use super::client::LspError;
17use super::types::{CompletionItem, Diagnostic, DiagnosticSeverity, Location};
18
19// ---------------------------------------------------------------------------
20// Helper functions
21// ---------------------------------------------------------------------------
22
23/// Extract `file`, `line`, and `character` from JSON arguments.
24fn extract_position_args(args: &Value) -> Result<(PathBuf, u32, u32), ToolError> {
25    let file =
26        args.get("file")
27            .and_then(|v| v.as_str())
28            .ok_or_else(|| ToolError::InvalidArguments {
29                name: "lsp".to_string(),
30                reason: "missing required parameter 'file'".to_string(),
31            })?;
32
33    let line =
34        args.get("line")
35            .and_then(|v| v.as_u64())
36            .ok_or_else(|| ToolError::InvalidArguments {
37                name: "lsp".to_string(),
38                reason: "missing required parameter 'line'".to_string(),
39            })? as u32;
40
41    let character = args
42        .get("character")
43        .and_then(|v| v.as_u64())
44        .ok_or_else(|| ToolError::InvalidArguments {
45            name: "lsp".to_string(),
46            reason: "missing required parameter 'character'".to_string(),
47        })? as u32;
48
49    Ok((PathBuf::from(file), line, character))
50}
51
52/// Format a single `Location` as `"uri:line:col"`.
53fn format_location(loc: &Location) -> String {
54    format!(
55        "{}:{}:{}",
56        loc.uri, loc.range.start.line, loc.range.start.character
57    )
58}
59
60/// Format a `Diagnostic` as a human-readable string.
61fn format_diagnostic(diag: &Diagnostic) -> String {
62    let severity = match diag.severity {
63        Some(DiagnosticSeverity::Error) => "error",
64        Some(DiagnosticSeverity::Warning) => "warning",
65        Some(DiagnosticSeverity::Information) => "info",
66        Some(DiagnosticSeverity::Hint) => "hint",
67        None => "unknown",
68    };
69    format!(
70        "[{}] line {}: {}",
71        severity, diag.range.start.line, diag.message
72    )
73}
74
75/// Convert an `LspError` into a `ToolError`.
76fn lsp_err(tool_name: &str, err: LspError) -> ToolError {
77    ToolError::ExecutionFailed {
78        name: tool_name.to_string(),
79        message: err.to_string(),
80    }
81}
82
83// ---------------------------------------------------------------------------
84// 1. LspHoverTool
85// ---------------------------------------------------------------------------
86
87/// Provides hover information (type info, documentation) via the language server.
88pub struct LspHoverTool {
89    backend: Arc<dyn LspBackend>,
90}
91
92impl LspHoverTool {
93    pub fn new(backend: Arc<dyn LspBackend>) -> Self {
94        Self { backend }
95    }
96}
97
98#[async_trait]
99impl Tool for LspHoverTool {
100    fn name(&self) -> &str {
101        "lsp_hover"
102    }
103
104    fn description(&self) -> &str {
105        "Get hover information (type info, documentation) for a symbol at a given position in a file"
106    }
107
108    fn parameters_schema(&self) -> Value {
109        serde_json::json!({
110            "type": "object",
111            "properties": {
112                "file": {
113                    "type": "string",
114                    "description": "Path to the file"
115                },
116                "line": {
117                    "type": "integer",
118                    "description": "Line number (0-indexed)"
119                },
120                "character": {
121                    "type": "integer",
122                    "description": "Character position (0-indexed)"
123                }
124            },
125            "required": ["file", "line", "character"]
126        })
127    }
128
129    fn risk_level(&self) -> RiskLevel {
130        RiskLevel::ReadOnly
131    }
132
133    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
134        let (file, line, character) = extract_position_args(&args)?;
135        let result = self
136            .backend
137            .hover(&file, line, character)
138            .await
139            .map_err(|e| lsp_err("lsp_hover", e))?;
140
141        match result {
142            Some(text) => Ok(ToolOutput::text(text)),
143            None => Ok(ToolOutput::text(
144                "No hover information available at this position.",
145            )),
146        }
147    }
148}
149
150// ---------------------------------------------------------------------------
151// 2. LspDefinitionTool
152// ---------------------------------------------------------------------------
153
154/// Navigates to the definition of a symbol via the language server.
155pub struct LspDefinitionTool {
156    backend: Arc<dyn LspBackend>,
157}
158
159impl LspDefinitionTool {
160    pub fn new(backend: Arc<dyn LspBackend>) -> Self {
161        Self { backend }
162    }
163}
164
165#[async_trait]
166impl Tool for LspDefinitionTool {
167    fn name(&self) -> &str {
168        "lsp_definition"
169    }
170
171    fn description(&self) -> &str {
172        "Go to the definition of a symbol at a given position in a file"
173    }
174
175    fn parameters_schema(&self) -> Value {
176        serde_json::json!({
177            "type": "object",
178            "properties": {
179                "file": {
180                    "type": "string",
181                    "description": "Path to the file"
182                },
183                "line": {
184                    "type": "integer",
185                    "description": "Line number (0-indexed)"
186                },
187                "character": {
188                    "type": "integer",
189                    "description": "Character position (0-indexed)"
190                }
191            },
192            "required": ["file", "line", "character"]
193        })
194    }
195
196    fn risk_level(&self) -> RiskLevel {
197        RiskLevel::ReadOnly
198    }
199
200    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
201        let (file, line, character) = extract_position_args(&args)?;
202        let locations = self
203            .backend
204            .definition(&file, line, character)
205            .await
206            .map_err(|e| lsp_err("lsp_definition", e))?;
207
208        if locations.is_empty() {
209            return Ok(ToolOutput::text("No definition found at this position."));
210        }
211
212        let formatted: Vec<String> = locations.iter().map(format_location).collect();
213        Ok(ToolOutput::text(format!(
214            "Definition location(s):\n{}",
215            formatted.join("\n")
216        )))
217    }
218}
219
220// ---------------------------------------------------------------------------
221// 3. LspReferencesTool
222// ---------------------------------------------------------------------------
223
224/// Finds all references to a symbol via the language server.
225pub struct LspReferencesTool {
226    backend: Arc<dyn LspBackend>,
227}
228
229impl LspReferencesTool {
230    pub fn new(backend: Arc<dyn LspBackend>) -> Self {
231        Self { backend }
232    }
233}
234
235#[async_trait]
236impl Tool for LspReferencesTool {
237    fn name(&self) -> &str {
238        "lsp_references"
239    }
240
241    fn description(&self) -> &str {
242        "Find all references to a symbol at a given position in a file"
243    }
244
245    fn parameters_schema(&self) -> Value {
246        serde_json::json!({
247            "type": "object",
248            "properties": {
249                "file": {
250                    "type": "string",
251                    "description": "Path to the file"
252                },
253                "line": {
254                    "type": "integer",
255                    "description": "Line number (0-indexed)"
256                },
257                "character": {
258                    "type": "integer",
259                    "description": "Character position (0-indexed)"
260                }
261            },
262            "required": ["file", "line", "character"]
263        })
264    }
265
266    fn risk_level(&self) -> RiskLevel {
267        RiskLevel::ReadOnly
268    }
269
270    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
271        let (file, line, character) = extract_position_args(&args)?;
272        let locations = self
273            .backend
274            .references(&file, line, character)
275            .await
276            .map_err(|e| lsp_err("lsp_references", e))?;
277
278        if locations.is_empty() {
279            return Ok(ToolOutput::text("No references found at this position."));
280        }
281
282        let formatted: Vec<String> = locations.iter().map(format_location).collect();
283        Ok(ToolOutput::text(format!(
284            "Found {} reference(s):\n{}",
285            locations.len(),
286            formatted.join("\n")
287        )))
288    }
289}
290
291// ---------------------------------------------------------------------------
292// 4. LspDiagnosticsTool
293// ---------------------------------------------------------------------------
294
295/// Retrieves diagnostics (errors, warnings) for a file from the language server.
296pub struct LspDiagnosticsTool {
297    backend: Arc<dyn LspBackend>,
298}
299
300impl LspDiagnosticsTool {
301    pub fn new(backend: Arc<dyn LspBackend>) -> Self {
302        Self { backend }
303    }
304}
305
306#[async_trait]
307impl Tool for LspDiagnosticsTool {
308    fn name(&self) -> &str {
309        "lsp_diagnostics"
310    }
311
312    fn description(&self) -> &str {
313        "Get diagnostic messages (errors, warnings) for a file from the language server"
314    }
315
316    fn parameters_schema(&self) -> Value {
317        serde_json::json!({
318            "type": "object",
319            "properties": {
320                "file": {
321                    "type": "string",
322                    "description": "Path to the file"
323                }
324            },
325            "required": ["file"]
326        })
327    }
328
329    fn risk_level(&self) -> RiskLevel {
330        RiskLevel::ReadOnly
331    }
332
333    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
334        let file = args.get("file").and_then(|v| v.as_str()).ok_or_else(|| {
335            ToolError::InvalidArguments {
336                name: "lsp_diagnostics".to_string(),
337                reason: "missing required parameter 'file'".to_string(),
338            }
339        })?;
340
341        let diagnostics = self
342            .backend
343            .diagnostics(std::path::Path::new(file))
344            .await
345            .map_err(|e| lsp_err("lsp_diagnostics", e))?;
346
347        if diagnostics.is_empty() {
348            return Ok(ToolOutput::text("No diagnostics found for this file."));
349        }
350
351        let formatted: Vec<String> = diagnostics.iter().map(format_diagnostic).collect();
352        Ok(ToolOutput::text(format!(
353            "Found {} diagnostic(s):\n{}",
354            diagnostics.len(),
355            formatted.join("\n")
356        )))
357    }
358}
359
360// ---------------------------------------------------------------------------
361// 5. LspCompletionsTool
362// ---------------------------------------------------------------------------
363
364/// Provides code completion suggestions from the language server.
365pub struct LspCompletionsTool {
366    backend: Arc<dyn LspBackend>,
367}
368
369impl LspCompletionsTool {
370    pub fn new(backend: Arc<dyn LspBackend>) -> Self {
371        Self { backend }
372    }
373}
374
375#[async_trait]
376impl Tool for LspCompletionsTool {
377    fn name(&self) -> &str {
378        "lsp_completions"
379    }
380
381    fn description(&self) -> &str {
382        "Get code completion suggestions at a given position in a file"
383    }
384
385    fn parameters_schema(&self) -> Value {
386        serde_json::json!({
387            "type": "object",
388            "properties": {
389                "file": {
390                    "type": "string",
391                    "description": "Path to the file"
392                },
393                "line": {
394                    "type": "integer",
395                    "description": "Line number (0-indexed)"
396                },
397                "character": {
398                    "type": "integer",
399                    "description": "Character position (0-indexed)"
400                },
401                "limit": {
402                    "type": "integer",
403                    "description": "Maximum completions to return (default 20)"
404                }
405            },
406            "required": ["file", "line", "character"]
407        })
408    }
409
410    fn risk_level(&self) -> RiskLevel {
411        RiskLevel::ReadOnly
412    }
413
414    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
415        let (file, line, character) = extract_position_args(&args)?;
416        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
417
418        let completions = self
419            .backend
420            .completions(&file, line, character)
421            .await
422            .map_err(|e| lsp_err("lsp_completions", e))?;
423
424        if completions.is_empty() {
425            return Ok(ToolOutput::text(
426                "No completions available at this position.",
427            ));
428        }
429
430        let limited: Vec<&CompletionItem> = completions.iter().take(limit).collect();
431        let formatted: Vec<String> = limited
432            .iter()
433            .map(|item| {
434                let detail = item
435                    .detail
436                    .as_deref()
437                    .map(|d| format!(" - {}", d))
438                    .unwrap_or_default();
439                format!("  {}{}", item.label, detail)
440            })
441            .collect();
442
443        Ok(ToolOutput::text(format!(
444            "Completions ({} of {}):\n{}",
445            limited.len(),
446            completions.len(),
447            formatted.join("\n")
448        )))
449    }
450}
451
452// ---------------------------------------------------------------------------
453// 6. LspRenameTool
454// ---------------------------------------------------------------------------
455
456/// Renames a symbol across the project using the language server.
457pub struct LspRenameTool {
458    backend: Arc<dyn LspBackend>,
459}
460
461impl LspRenameTool {
462    pub fn new(backend: Arc<dyn LspBackend>) -> Self {
463        Self { backend }
464    }
465}
466
467#[async_trait]
468impl Tool for LspRenameTool {
469    fn name(&self) -> &str {
470        "lsp_rename"
471    }
472
473    fn description(&self) -> &str {
474        "Rename a symbol across the project using the language server"
475    }
476
477    fn parameters_schema(&self) -> Value {
478        serde_json::json!({
479            "type": "object",
480            "properties": {
481                "file": {
482                    "type": "string",
483                    "description": "Path to the file"
484                },
485                "line": {
486                    "type": "integer",
487                    "description": "Line number (0-indexed)"
488                },
489                "character": {
490                    "type": "integer",
491                    "description": "Character position (0-indexed)"
492                },
493                "new_name": {
494                    "type": "string",
495                    "description": "The new name for the symbol"
496                }
497            },
498            "required": ["file", "line", "character", "new_name"]
499        })
500    }
501
502    fn risk_level(&self) -> RiskLevel {
503        RiskLevel::Write
504    }
505
506    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
507        let (file, line, character) = extract_position_args(&args)?;
508        let new_name = args
509            .get("new_name")
510            .and_then(|v| v.as_str())
511            .ok_or_else(|| ToolError::InvalidArguments {
512                name: "lsp_rename".to_string(),
513                reason: "missing required parameter 'new_name'".to_string(),
514            })?;
515
516        let edit = self
517            .backend
518            .rename(&file, line, character, new_name)
519            .await
520            .map_err(|e| lsp_err("lsp_rename", e))?;
521
522        let changes = match &edit.changes {
523            Some(c) if !c.is_empty() => c,
524            _ => {
525                return Ok(ToolOutput::text("No changes produced by rename operation."));
526            }
527        };
528
529        let mut lines = Vec::new();
530        for (uri, edits) in changes {
531            lines.push(format!("{}:", uri));
532            for te in edits {
533                lines.push(format!(
534                    "  line {}:{}-{}:{}: \"{}\"",
535                    te.range.start.line,
536                    te.range.start.character,
537                    te.range.end.line,
538                    te.range.end.character,
539                    te.new_text
540                ));
541            }
542        }
543
544        Ok(ToolOutput::text(format!(
545            "Rename applied across {} file(s):\n{}",
546            changes.len(),
547            lines.join("\n")
548        )))
549    }
550}
551
552// ---------------------------------------------------------------------------
553// 7. LspFormatTool
554// ---------------------------------------------------------------------------
555
556/// Formats a source file using the language server.
557pub struct LspFormatTool {
558    backend: Arc<dyn LspBackend>,
559}
560
561impl LspFormatTool {
562    pub fn new(backend: Arc<dyn LspBackend>) -> Self {
563        Self { backend }
564    }
565}
566
567#[async_trait]
568impl Tool for LspFormatTool {
569    fn name(&self) -> &str {
570        "lsp_format"
571    }
572
573    fn description(&self) -> &str {
574        "Format a source file using the language server's formatting capabilities"
575    }
576
577    fn parameters_schema(&self) -> Value {
578        serde_json::json!({
579            "type": "object",
580            "properties": {
581                "file": {
582                    "type": "string",
583                    "description": "Path to the file to format"
584                }
585            },
586            "required": ["file"]
587        })
588    }
589
590    fn risk_level(&self) -> RiskLevel {
591        RiskLevel::Write
592    }
593
594    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
595        let file = args.get("file").and_then(|v| v.as_str()).ok_or_else(|| {
596            ToolError::InvalidArguments {
597                name: "lsp_format".to_string(),
598                reason: "missing required parameter 'file'".to_string(),
599            }
600        })?;
601
602        let edits = self
603            .backend
604            .format(std::path::Path::new(file))
605            .await
606            .map_err(|e| lsp_err("lsp_format", e))?;
607
608        if edits.is_empty() {
609            return Ok(ToolOutput::text(
610                "File is already formatted. No changes needed.",
611            ));
612        }
613
614        let formatted: Vec<String> = edits
615            .iter()
616            .map(|te| {
617                format!(
618                    "  line {}:{}-{}:{}: \"{}\"",
619                    te.range.start.line,
620                    te.range.start.character,
621                    te.range.end.line,
622                    te.range.end.character,
623                    te.new_text
624                )
625            })
626            .collect();
627
628        Ok(ToolOutput::text(format!(
629            "Applied {} formatting edit(s):\n{}",
630            edits.len(),
631            formatted.join("\n")
632        )))
633    }
634}
635
636// ===========================================================================
637// Tests
638// ===========================================================================
639
640#[cfg(test)]
641mod tests {
642    use super::super::types::{Position, Range, TextEdit, WorkspaceEdit};
643    use super::*;
644    use std::collections::HashMap;
645
646    // -----------------------------------------------------------------------
647    // Mock backend
648    // -----------------------------------------------------------------------
649
650    struct MockLspBackend {
651        hover_result: tokio::sync::Mutex<Option<String>>,
652        definition_result: tokio::sync::Mutex<Vec<Location>>,
653        references_result: tokio::sync::Mutex<Vec<Location>>,
654        diagnostics_result: tokio::sync::Mutex<Vec<Diagnostic>>,
655        completions_result: tokio::sync::Mutex<Vec<CompletionItem>>,
656        rename_result: tokio::sync::Mutex<WorkspaceEdit>,
657        format_result: tokio::sync::Mutex<Vec<TextEdit>>,
658    }
659
660    impl MockLspBackend {
661        fn new() -> Self {
662            Self {
663                hover_result: tokio::sync::Mutex::new(None),
664                definition_result: tokio::sync::Mutex::new(Vec::new()),
665                references_result: tokio::sync::Mutex::new(Vec::new()),
666                diagnostics_result: tokio::sync::Mutex::new(Vec::new()),
667                completions_result: tokio::sync::Mutex::new(Vec::new()),
668                rename_result: tokio::sync::Mutex::new(WorkspaceEdit { changes: None }),
669                format_result: tokio::sync::Mutex::new(Vec::new()),
670            }
671        }
672    }
673
674    #[async_trait]
675    impl LspBackend for MockLspBackend {
676        async fn hover(
677            &self,
678            _file: &std::path::Path,
679            _line: u32,
680            _character: u32,
681        ) -> Result<Option<String>, LspError> {
682            Ok(self.hover_result.lock().await.clone())
683        }
684
685        async fn definition(
686            &self,
687            _file: &std::path::Path,
688            _line: u32,
689            _character: u32,
690        ) -> Result<Vec<Location>, LspError> {
691            Ok(self.definition_result.lock().await.clone())
692        }
693
694        async fn references(
695            &self,
696            _file: &std::path::Path,
697            _line: u32,
698            _character: u32,
699        ) -> Result<Vec<Location>, LspError> {
700            Ok(self.references_result.lock().await.clone())
701        }
702
703        async fn diagnostics(&self, _file: &std::path::Path) -> Result<Vec<Diagnostic>, LspError> {
704            Ok(self.diagnostics_result.lock().await.clone())
705        }
706
707        async fn completions(
708            &self,
709            _file: &std::path::Path,
710            _line: u32,
711            _character: u32,
712        ) -> Result<Vec<CompletionItem>, LspError> {
713            Ok(self.completions_result.lock().await.clone())
714        }
715
716        async fn rename(
717            &self,
718            _file: &std::path::Path,
719            _line: u32,
720            _character: u32,
721            _new_name: &str,
722        ) -> Result<WorkspaceEdit, LspError> {
723            Ok(self.rename_result.lock().await.clone())
724        }
725
726        async fn format(&self, _file: &std::path::Path) -> Result<Vec<TextEdit>, LspError> {
727            Ok(self.format_result.lock().await.clone())
728        }
729    }
730
731    // -----------------------------------------------------------------------
732    // Helper to build a mock-backed tool
733    // -----------------------------------------------------------------------
734
735    fn mock_backend() -> Arc<MockLspBackend> {
736        Arc::new(MockLspBackend::new())
737    }
738
739    // -----------------------------------------------------------------------
740    // 1. test_hover_tool_name_and_schema
741    // -----------------------------------------------------------------------
742
743    #[test]
744    fn test_hover_tool_name_and_schema() {
745        let backend = mock_backend();
746        let tool = LspHoverTool::new(backend);
747
748        assert_eq!(tool.name(), "lsp_hover");
749        assert_eq!(
750            tool.description(),
751            "Get hover information (type info, documentation) for a symbol at a given position in a file"
752        );
753        assert_eq!(tool.risk_level(), RiskLevel::ReadOnly);
754
755        let schema = tool.parameters_schema();
756        assert_eq!(schema["type"], "object");
757        assert!(schema["properties"]["file"].is_object());
758        assert!(schema["properties"]["line"].is_object());
759        assert!(schema["properties"]["character"].is_object());
760        let required = schema["required"].as_array().unwrap();
761        assert!(required.contains(&serde_json::json!("file")));
762        assert!(required.contains(&serde_json::json!("line")));
763        assert!(required.contains(&serde_json::json!("character")));
764    }
765
766    // -----------------------------------------------------------------------
767    // 2. test_hover_tool_execute_with_result
768    // -----------------------------------------------------------------------
769
770    #[tokio::test]
771    async fn test_hover_tool_execute_with_result() {
772        let backend = mock_backend();
773        *backend.hover_result.lock().await = Some("fn main() -> ()".to_string());
774
775        let tool = LspHoverTool::new(backend);
776        let result = tool
777            .execute(serde_json::json!({
778                "file": "/src/main.rs",
779                "line": 10,
780                "character": 5
781            }))
782            .await
783            .unwrap();
784
785        assert_eq!(result.content, "fn main() -> ()");
786    }
787
788    // -----------------------------------------------------------------------
789    // 3. test_hover_tool_execute_no_result
790    // -----------------------------------------------------------------------
791
792    #[tokio::test]
793    async fn test_hover_tool_execute_no_result() {
794        let backend = mock_backend();
795        // hover_result is None by default
796
797        let tool = LspHoverTool::new(backend);
798        let result = tool
799            .execute(serde_json::json!({
800                "file": "/src/main.rs",
801                "line": 0,
802                "character": 0
803            }))
804            .await
805            .unwrap();
806
807        assert!(result.content.contains("No hover information"));
808    }
809
810    // -----------------------------------------------------------------------
811    // 4. test_definition_tool_execute
812    // -----------------------------------------------------------------------
813
814    #[tokio::test]
815    async fn test_definition_tool_execute() {
816        let backend = mock_backend();
817        *backend.definition_result.lock().await = vec![Location {
818            uri: "/src/lib.rs".to_string(),
819            range: Range {
820                start: Position {
821                    line: 42,
822                    character: 4,
823                },
824                end: Position {
825                    line: 42,
826                    character: 10,
827                },
828            },
829        }];
830
831        let tool = LspDefinitionTool::new(backend);
832        let result = tool
833            .execute(serde_json::json!({
834                "file": "/src/main.rs",
835                "line": 10,
836                "character": 5
837            }))
838            .await
839            .unwrap();
840
841        assert!(result.content.contains("Definition location(s):"));
842        assert!(result.content.contains("/src/lib.rs:42:4"));
843    }
844
845    // -----------------------------------------------------------------------
846    // 5. test_definition_tool_no_results
847    // -----------------------------------------------------------------------
848
849    #[tokio::test]
850    async fn test_definition_tool_no_results() {
851        let backend = mock_backend();
852        // definition_result is empty by default
853
854        let tool = LspDefinitionTool::new(backend);
855        let result = tool
856            .execute(serde_json::json!({
857                "file": "/src/main.rs",
858                "line": 0,
859                "character": 0
860            }))
861            .await
862            .unwrap();
863
864        assert!(result.content.contains("No definition found"));
865    }
866
867    // -----------------------------------------------------------------------
868    // 6. test_references_tool_execute
869    // -----------------------------------------------------------------------
870
871    #[tokio::test]
872    async fn test_references_tool_execute() {
873        let backend = mock_backend();
874        *backend.references_result.lock().await = vec![
875            Location {
876                uri: "/src/main.rs".to_string(),
877                range: Range {
878                    start: Position {
879                        line: 10,
880                        character: 5,
881                    },
882                    end: Position {
883                        line: 10,
884                        character: 15,
885                    },
886                },
887            },
888            Location {
889                uri: "/src/lib.rs".to_string(),
890                range: Range {
891                    start: Position {
892                        line: 20,
893                        character: 8,
894                    },
895                    end: Position {
896                        line: 20,
897                        character: 18,
898                    },
899                },
900            },
901            Location {
902                uri: "/tests/integration.rs".to_string(),
903                range: Range {
904                    start: Position {
905                        line: 3,
906                        character: 12,
907                    },
908                    end: Position {
909                        line: 3,
910                        character: 22,
911                    },
912                },
913            },
914        ];
915
916        let tool = LspReferencesTool::new(backend);
917        let result = tool
918            .execute(serde_json::json!({
919                "file": "/src/main.rs",
920                "line": 10,
921                "character": 5
922            }))
923            .await
924            .unwrap();
925
926        assert!(result.content.contains("Found 3 reference(s):"));
927        assert!(result.content.contains("/src/main.rs:10:5"));
928        assert!(result.content.contains("/src/lib.rs:20:8"));
929        assert!(result.content.contains("/tests/integration.rs:3:12"));
930    }
931
932    // -----------------------------------------------------------------------
933    // 7. test_diagnostics_tool_execute
934    // -----------------------------------------------------------------------
935
936    #[tokio::test]
937    async fn test_diagnostics_tool_execute() {
938        let backend = mock_backend();
939        *backend.diagnostics_result.lock().await = vec![
940            Diagnostic {
941                range: Range {
942                    start: Position {
943                        line: 5,
944                        character: 0,
945                    },
946                    end: Position {
947                        line: 5,
948                        character: 1,
949                    },
950                },
951                severity: Some(DiagnosticSeverity::Error),
952                message: "expected `;`".to_string(),
953                source: Some("rustc".to_string()),
954                code: None,
955            },
956            Diagnostic {
957                range: Range {
958                    start: Position {
959                        line: 12,
960                        character: 4,
961                    },
962                    end: Position {
963                        line: 12,
964                        character: 5,
965                    },
966                },
967                severity: Some(DiagnosticSeverity::Warning),
968                message: "unused variable `x`".to_string(),
969                source: Some("rustc".to_string()),
970                code: None,
971            },
972            Diagnostic {
973                range: Range {
974                    start: Position {
975                        line: 20,
976                        character: 0,
977                    },
978                    end: Position {
979                        line: 20,
980                        character: 10,
981                    },
982                },
983                severity: Some(DiagnosticSeverity::Information),
984                message: "consider using `let` binding".to_string(),
985                source: Some("clippy".to_string()),
986                code: None,
987            },
988        ];
989
990        let tool = LspDiagnosticsTool::new(backend);
991        let result = tool
992            .execute(serde_json::json!({
993                "file": "/src/main.rs"
994            }))
995            .await
996            .unwrap();
997
998        assert!(result.content.contains("Found 3 diagnostic(s):"));
999        assert!(result.content.contains("[error] line 5: expected `;`"));
1000        assert!(
1001            result
1002                .content
1003                .contains("[warning] line 12: unused variable `x`")
1004        );
1005        assert!(
1006            result
1007                .content
1008                .contains("[info] line 20: consider using `let` binding")
1009        );
1010    }
1011
1012    // -----------------------------------------------------------------------
1013    // 8. test_completions_tool_execute
1014    // -----------------------------------------------------------------------
1015
1016    #[tokio::test]
1017    async fn test_completions_tool_execute() {
1018        let backend = mock_backend();
1019        *backend.completions_result.lock().await = vec![
1020            CompletionItem {
1021                label: "println!".to_string(),
1022                kind: Some(15),
1023                detail: Some("macro".to_string()),
1024                documentation: None,
1025                insert_text: None,
1026            },
1027            CompletionItem {
1028                label: "print!".to_string(),
1029                kind: Some(15),
1030                detail: Some("macro".to_string()),
1031                documentation: None,
1032                insert_text: None,
1033            },
1034        ];
1035
1036        let tool = LspCompletionsTool::new(backend);
1037        let result = tool
1038            .execute(serde_json::json!({
1039                "file": "/src/main.rs",
1040                "line": 10,
1041                "character": 4
1042            }))
1043            .await
1044            .unwrap();
1045
1046        assert!(result.content.contains("Completions (2 of 2):"));
1047        assert!(result.content.contains("println! - macro"));
1048        assert!(result.content.contains("print! - macro"));
1049    }
1050
1051    // -----------------------------------------------------------------------
1052    // 9. test_completions_tool_with_limit
1053    // -----------------------------------------------------------------------
1054
1055    #[tokio::test]
1056    async fn test_completions_tool_with_limit() {
1057        let backend = mock_backend();
1058        *backend.completions_result.lock().await = vec![
1059            CompletionItem {
1060                label: "aaa".to_string(),
1061                kind: None,
1062                detail: None,
1063                documentation: None,
1064                insert_text: None,
1065            },
1066            CompletionItem {
1067                label: "bbb".to_string(),
1068                kind: None,
1069                detail: None,
1070                documentation: None,
1071                insert_text: None,
1072            },
1073            CompletionItem {
1074                label: "ccc".to_string(),
1075                kind: None,
1076                detail: None,
1077                documentation: None,
1078                insert_text: None,
1079            },
1080            CompletionItem {
1081                label: "ddd".to_string(),
1082                kind: None,
1083                detail: None,
1084                documentation: None,
1085                insert_text: None,
1086            },
1087            CompletionItem {
1088                label: "eee".to_string(),
1089                kind: None,
1090                detail: None,
1091                documentation: None,
1092                insert_text: None,
1093            },
1094        ];
1095
1096        let tool = LspCompletionsTool::new(backend);
1097        let result = tool
1098            .execute(serde_json::json!({
1099                "file": "/src/main.rs",
1100                "line": 10,
1101                "character": 4,
1102                "limit": 2
1103            }))
1104            .await
1105            .unwrap();
1106
1107        assert!(result.content.contains("Completions (2 of 5):"));
1108        assert!(result.content.contains("aaa"));
1109        assert!(result.content.contains("bbb"));
1110        assert!(!result.content.contains("ccc"));
1111    }
1112
1113    // -----------------------------------------------------------------------
1114    // 10. test_rename_tool_execute
1115    // -----------------------------------------------------------------------
1116
1117    #[tokio::test]
1118    async fn test_rename_tool_execute() {
1119        let backend = mock_backend();
1120        let mut changes = HashMap::new();
1121        changes.insert(
1122            "/src/main.rs".to_string(),
1123            vec![TextEdit {
1124                range: Range {
1125                    start: Position {
1126                        line: 10,
1127                        character: 4,
1128                    },
1129                    end: Position {
1130                        line: 10,
1131                        character: 7,
1132                    },
1133                },
1134                new_text: "new_func".to_string(),
1135            }],
1136        );
1137        changes.insert(
1138            "/src/lib.rs".to_string(),
1139            vec![TextEdit {
1140                range: Range {
1141                    start: Position {
1142                        line: 5,
1143                        character: 8,
1144                    },
1145                    end: Position {
1146                        line: 5,
1147                        character: 11,
1148                    },
1149                },
1150                new_text: "new_func".to_string(),
1151            }],
1152        );
1153        *backend.rename_result.lock().await = WorkspaceEdit {
1154            changes: Some(changes),
1155        };
1156
1157        let tool = LspRenameTool::new(backend);
1158        let result = tool
1159            .execute(serde_json::json!({
1160                "file": "/src/main.rs",
1161                "line": 10,
1162                "character": 4,
1163                "new_name": "new_func"
1164            }))
1165            .await
1166            .unwrap();
1167
1168        assert!(result.content.contains("Rename applied across 2 file(s):"));
1169        assert!(result.content.contains("new_func"));
1170    }
1171
1172    // -----------------------------------------------------------------------
1173    // 11. test_rename_tool_risk_level
1174    // -----------------------------------------------------------------------
1175
1176    #[test]
1177    fn test_rename_tool_risk_level() {
1178        let backend = mock_backend();
1179        let tool = LspRenameTool::new(backend);
1180        assert_eq!(tool.risk_level(), RiskLevel::Write);
1181    }
1182
1183    // -----------------------------------------------------------------------
1184    // 12. test_format_tool_execute
1185    // -----------------------------------------------------------------------
1186
1187    #[tokio::test]
1188    async fn test_format_tool_execute() {
1189        let backend = mock_backend();
1190        *backend.format_result.lock().await = vec![
1191            TextEdit {
1192                range: Range {
1193                    start: Position {
1194                        line: 0,
1195                        character: 0,
1196                    },
1197                    end: Position {
1198                        line: 0,
1199                        character: 10,
1200                    },
1201                },
1202                new_text: "fn main() {".to_string(),
1203            },
1204            TextEdit {
1205                range: Range {
1206                    start: Position {
1207                        line: 1,
1208                        character: 0,
1209                    },
1210                    end: Position {
1211                        line: 1,
1212                        character: 5,
1213                    },
1214                },
1215                new_text: "    println!(\"hello\");".to_string(),
1216            },
1217        ];
1218
1219        let tool = LspFormatTool::new(backend);
1220        let result = tool
1221            .execute(serde_json::json!({
1222                "file": "/src/main.rs"
1223            }))
1224            .await
1225            .unwrap();
1226
1227        assert!(result.content.contains("Applied 2 formatting edit(s):"));
1228        assert!(result.content.contains("fn main()"));
1229    }
1230
1231    // -----------------------------------------------------------------------
1232    // 13. test_format_tool_risk_level
1233    // -----------------------------------------------------------------------
1234
1235    #[test]
1236    fn test_format_tool_risk_level() {
1237        let backend = mock_backend();
1238        let tool = LspFormatTool::new(backend);
1239        assert_eq!(tool.risk_level(), RiskLevel::Write);
1240    }
1241
1242    // -----------------------------------------------------------------------
1243    // 14. test_extract_position_args_valid
1244    // -----------------------------------------------------------------------
1245
1246    #[test]
1247    fn test_extract_position_args_valid() {
1248        let args = serde_json::json!({
1249            "file": "/src/main.rs",
1250            "line": 10,
1251            "character": 5
1252        });
1253
1254        let (file, line, character) = extract_position_args(&args).unwrap();
1255        assert_eq!(file, PathBuf::from("/src/main.rs"));
1256        assert_eq!(line, 10);
1257        assert_eq!(character, 5);
1258    }
1259
1260    // -----------------------------------------------------------------------
1261    // 15. test_extract_position_args_missing_field
1262    // -----------------------------------------------------------------------
1263
1264    #[test]
1265    fn test_extract_position_args_missing_field() {
1266        // Missing 'character'
1267        let args = serde_json::json!({
1268            "file": "/src/main.rs",
1269            "line": 10
1270        });
1271        let result = extract_position_args(&args);
1272        assert!(result.is_err());
1273        match result.unwrap_err() {
1274            ToolError::InvalidArguments { reason, .. } => {
1275                assert!(reason.contains("character"));
1276            }
1277            other => panic!("Expected InvalidArguments, got: {:?}", other),
1278        }
1279
1280        // Missing 'file'
1281        let args = serde_json::json!({
1282            "line": 10,
1283            "character": 5
1284        });
1285        let result = extract_position_args(&args);
1286        assert!(result.is_err());
1287        match result.unwrap_err() {
1288            ToolError::InvalidArguments { reason, .. } => {
1289                assert!(reason.contains("file"));
1290            }
1291            other => panic!("Expected InvalidArguments, got: {:?}", other),
1292        }
1293
1294        // Missing 'line'
1295        let args = serde_json::json!({
1296            "file": "/src/main.rs",
1297            "character": 5
1298        });
1299        let result = extract_position_args(&args);
1300        assert!(result.is_err());
1301        match result.unwrap_err() {
1302            ToolError::InvalidArguments { reason, .. } => {
1303                assert!(reason.contains("line"));
1304            }
1305            other => panic!("Expected InvalidArguments, got: {:?}", other),
1306        }
1307    }
1308}