atomcode_core/tool/
diagnostics.rs1use anyhow::Result;
5use async_trait::async_trait;
6use serde::Deserialize;
7use serde_json::json;
8
9use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
10use crate::lsp::types::DiagnosticSeverity;
11
12pub struct DiagnosticsTool;
13
14#[derive(Deserialize)]
15struct DiagnosticsArgs {
16 #[serde(default)]
17 file_path: Option<String>,
18 #[serde(default)]
19 severity: Option<String>,
20}
21
22#[async_trait]
23impl Tool for DiagnosticsTool {
24 fn definition(&self) -> ToolDef {
25 ToolDef {
26 name: "diagnostics",
27 description: "Get real-time compiler/linter diagnostics from the Language Server. Returns type errors, missing imports, and other issues without running a full build. Optionally filter by file path and severity.".into(),
28 parameters: json!({
29 "type": "object",
30 "properties": {
31 "file_path": {
32 "type": "string",
33 "description": "Absolute path to check. Omit for all project diagnostics."
34 },
35 "severity": {
36 "type": "string",
37 "enum": ["error", "warning", "all"],
38 "description": "Filter level. Default: error."
39 }
40 },
41 "required": []
42 }),
43 }
44 }
45
46 fn approval(&self, _args: &str) -> ApprovalRequirement {
47 ApprovalRequirement::AutoApprove
48 }
49
50 fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
51 let parsed = match serde_json::from_str::<DiagnosticsArgs>(args) {
52 Ok(parsed) => parsed,
53 Err(_) => return self.approval(args),
54 };
55 let Some(file_path) = parsed.file_path.as_deref() else {
56 return self.approval(args);
57 };
58 let working_dir = match ctx.working_dir.try_read() {
59 Ok(wd) => wd.clone(),
60 Err(_) => return self.approval(args),
61 };
62 match super::approval_for_path(file_path, &working_dir, super::ExternalPathAction::Read) {
63 Ok(approval) => approval,
64 Err(_) => self.approval(args),
65 }
66 }
67
68 async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
69 let parsed: DiagnosticsArgs = serde_json::from_str(args).unwrap_or(DiagnosticsArgs {
70 file_path: None,
71 severity: None,
72 });
73
74 let lsp = match &ctx.lsp {
75 Some(lsp) => lsp,
76 None => {
77 return Ok(ToolResult {
78 call_id: String::new(),
79 output: "LSP not available. No language servers are configured or enabled."
80 .into(),
81 success: true,
82 });
83 }
84 };
85
86 let severity_filter = parsed.severity.as_deref().unwrap_or("error");
87
88 if let Some(ref fp) = parsed.file_path {
91 let path = std::path::Path::new(fp);
92 if let Ok(content) = tokio::fs::read_to_string(path).await {
93 if lsp.notify_file_changed(path, &content).await? {
94 let delay = lsp.diagnostics_settle_delay_ms();
95 tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
96 }
97 } else {
98 let _ = lsp.ensure_server(path).await;
99 }
100 }
101
102 let mut diagnostics = if let Some(ref fp) = parsed.file_path {
104 let path = std::path::Path::new(fp);
105 lsp.diagnostics(path).await
106 } else {
107 lsp.all_diagnostics().await
108 };
109
110 match severity_filter {
112 "error" => {
113 diagnostics.retain(|d| d.severity == DiagnosticSeverity::Error);
114 }
115 "warning" => {
116 diagnostics.retain(|d| {
117 d.severity == DiagnosticSeverity::Error
118 || d.severity == DiagnosticSeverity::Warning
119 });
120 }
121 _ => {}
123 }
124
125 diagnostics.sort_by(|a, b| {
127 a.severity
128 .cmp(&b.severity)
129 .then_with(|| a.file.cmp(&b.file))
130 .then_with(|| a.line.cmp(&b.line))
131 });
132
133 if diagnostics.is_empty() {
134 let scope = if let Some(ref fp) = parsed.file_path {
135 format!(" in {}", fp)
136 } else {
137 String::new()
138 };
139 return Ok(ToolResult {
140 call_id: String::new(),
141 output: format!(
142 "No diagnostics found{} (filter: {}).",
143 scope, severity_filter
144 ),
145 success: true,
146 });
147 }
148
149 let count = diagnostics.len();
150 let lines: Vec<String> = diagnostics.iter().map(|d| d.display_line()).collect();
151 let mut output = format!(
152 "Found {} diagnostic{}:\n\n",
153 count,
154 if count == 1 { "" } else { "s" }
155 );
156 output.push_str(&lines.join("\n"));
157
158 Ok(ToolResult {
159 call_id: String::new(),
160 output,
161 success: true,
162 })
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::lsp::types::Diagnostic;
170
171 #[test]
172 fn definition_has_correct_name() {
173 let tool = DiagnosticsTool;
174 assert_eq!(tool.definition().name, "diagnostics");
175 }
176
177 #[test]
178 fn approval_is_auto() {
179 let tool = DiagnosticsTool;
180 assert!(matches!(
181 tool.approval("{}"),
182 ApprovalRequirement::AutoApprove
183 ));
184 }
185
186 #[tokio::test]
187 async fn approval_auto_without_file_path() {
188 let ctx = ToolContext::new(std::path::PathBuf::from("/tmp"));
189
190 assert!(matches!(
191 DiagnosticsTool.approval_with_context("{}", &ctx),
192 ApprovalRequirement::AutoApprove
193 ));
194 }
195
196 #[tokio::test]
197 async fn approval_requires_read_confirmation_for_external_file() {
198 let workspace = tempfile::tempdir().unwrap();
199 let outside = tempfile::tempdir().unwrap();
200 let file = outside.path().join("main.rs");
201 std::fs::write(&file, "fn main() {}\n").unwrap();
202 let ctx = ToolContext::new(workspace.path().to_path_buf());
203 let args = serde_json::json!({ "file_path": file }).to_string();
204
205 assert!(matches!(
206 DiagnosticsTool.approval_with_context(&args, &ctx),
207 ApprovalRequirement::RequireApproval(_)
208 ));
209 }
210
211 #[tokio::test]
212 async fn returns_lsp_not_available_when_no_lsp() {
213 let tool = DiagnosticsTool;
214 let ctx = ToolContext::new(std::path::PathBuf::from("/tmp"));
215 let result = tool.execute("{}", &ctx).await.unwrap();
216 assert!(result.output.contains("LSP not available"));
217 assert!(result.success);
218 }
219
220 #[test]
221 fn diagnostic_display_includes_line_info() {
222 let d = Diagnostic {
223 file: "src/main.rs".into(),
224 line: 10,
225 column: 5,
226 end_line: None,
227 end_column: None,
228 severity: DiagnosticSeverity::Error,
229 message: "type mismatch".into(),
230 source: Some("rustc".into()),
231 code: Some("E0308".into()),
232 };
233 let line = d.display_line();
234 assert!(line.contains("src/main.rs:10:5"));
235 assert!(line.contains("[ERROR]"));
236 assert!(line.contains("E0308"));
237 assert!(line.contains("type mismatch"));
238 }
239}