Skip to main content

atomcode_core/tool/
diagnostics.rs

1//! Diagnostics tool — surfaces real-time compiler/linter diagnostics from
2//! the Language Server without running a full build.
3
4use 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 a file path is given, sync the current file contents before reading
89        // the diagnostics cache. LSP diagnostics are notification-driven.
90        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        // Collect diagnostics.
103        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        // Apply severity filter.
111        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            // "all" — keep everything.
122            _ => {}
123        }
124
125        // Sort by severity (errors first), then by file, then by line.
126        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}