1use 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
19fn 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
52fn format_location(loc: &Location) -> String {
54 format!(
55 "{}:{}:{}",
56 loc.uri, loc.range.start.line, loc.range.start.character
57 )
58}
59
60fn 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
75fn 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
83pub 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
150pub 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
220pub 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
291pub 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
360pub 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
452pub 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
552pub 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#[cfg(test)]
641mod tests {
642 use super::super::types::{Position, Range, TextEdit, WorkspaceEdit};
643 use super::*;
644 use std::collections::HashMap;
645
646 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 fn mock_backend() -> Arc<MockLspBackend> {
736 Arc::new(MockLspBackend::new())
737 }
738
739 #[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 #[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 #[tokio::test]
793 async fn test_hover_tool_execute_no_result() {
794 let backend = mock_backend();
795 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 #[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 #[tokio::test]
850 async fn test_definition_tool_no_results() {
851 let backend = mock_backend();
852 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
1265 fn test_extract_position_args_missing_field() {
1266 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 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 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}