use anyhow::Result;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
use crate::lsp::types::DiagnosticSeverity;
pub struct DiagnosticsTool;
#[derive(Deserialize)]
struct DiagnosticsArgs {
#[serde(default)]
file_path: Option<String>,
#[serde(default)]
severity: Option<String>,
}
#[async_trait]
impl Tool for DiagnosticsTool {
fn definition(&self) -> ToolDef {
ToolDef {
name: "diagnostics",
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(),
parameters: json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to check. Omit for all project diagnostics."
},
"severity": {
"type": "string",
"enum": ["error", "warning", "all"],
"description": "Filter level. Default: error."
}
},
"required": []
}),
}
}
fn approval(&self, _args: &str) -> ApprovalRequirement {
ApprovalRequirement::AutoApprove
}
fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
let parsed = match serde_json::from_str::<DiagnosticsArgs>(args) {
Ok(parsed) => parsed,
Err(_) => return self.approval(args),
};
let Some(file_path) = parsed.file_path.as_deref() else {
return self.approval(args);
};
let working_dir = match ctx.working_dir.try_read() {
Ok(wd) => wd.clone(),
Err(_) => return self.approval(args),
};
match super::approval_for_path(file_path, &working_dir, super::ExternalPathAction::Read) {
Ok(approval) => approval,
Err(_) => self.approval(args),
}
}
async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
let parsed: DiagnosticsArgs = serde_json::from_str(args).unwrap_or(DiagnosticsArgs {
file_path: None,
severity: None,
});
let lsp = match &ctx.lsp {
Some(lsp) => lsp,
None => {
return Ok(ToolResult {
call_id: String::new(),
output: "LSP not available. No language servers are configured or enabled."
.into(),
success: true,
});
}
};
let severity_filter = parsed.severity.as_deref().unwrap_or("error");
if let Some(ref fp) = parsed.file_path {
let path = std::path::Path::new(fp);
if let Ok(content) = tokio::fs::read_to_string(path).await {
if lsp.notify_file_changed(path, &content).await? {
let delay = lsp.diagnostics_settle_delay_ms();
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
} else {
let _ = lsp.ensure_server(path).await;
}
}
let mut diagnostics = if let Some(ref fp) = parsed.file_path {
let path = std::path::Path::new(fp);
lsp.diagnostics(path).await
} else {
lsp.all_diagnostics().await
};
match severity_filter {
"error" => {
diagnostics.retain(|d| d.severity == DiagnosticSeverity::Error);
}
"warning" => {
diagnostics.retain(|d| {
d.severity == DiagnosticSeverity::Error
|| d.severity == DiagnosticSeverity::Warning
});
}
_ => {}
}
diagnostics.sort_by(|a, b| {
a.severity
.cmp(&b.severity)
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.line.cmp(&b.line))
});
if diagnostics.is_empty() {
let scope = if let Some(ref fp) = parsed.file_path {
format!(" in {}", fp)
} else {
String::new()
};
return Ok(ToolResult {
call_id: String::new(),
output: format!(
"No diagnostics found{} (filter: {}).",
scope, severity_filter
),
success: true,
});
}
let count = diagnostics.len();
let lines: Vec<String> = diagnostics.iter().map(|d| d.display_line()).collect();
let mut output = format!(
"Found {} diagnostic{}:\n\n",
count,
if count == 1 { "" } else { "s" }
);
output.push_str(&lines.join("\n"));
Ok(ToolResult {
call_id: String::new(),
output,
success: true,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lsp::types::Diagnostic;
#[test]
fn definition_has_correct_name() {
let tool = DiagnosticsTool;
assert_eq!(tool.definition().name, "diagnostics");
}
#[test]
fn approval_is_auto() {
let tool = DiagnosticsTool;
assert!(matches!(
tool.approval("{}"),
ApprovalRequirement::AutoApprove
));
}
#[tokio::test]
async fn approval_auto_without_file_path() {
let ctx = ToolContext::new(std::path::PathBuf::from("/tmp"));
assert!(matches!(
DiagnosticsTool.approval_with_context("{}", &ctx),
ApprovalRequirement::AutoApprove
));
}
#[tokio::test]
async fn approval_requires_read_confirmation_for_external_file() {
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let file = outside.path().join("main.rs");
std::fs::write(&file, "fn main() {}\n").unwrap();
let ctx = ToolContext::new(workspace.path().to_path_buf());
let args = serde_json::json!({ "file_path": file }).to_string();
assert!(matches!(
DiagnosticsTool.approval_with_context(&args, &ctx),
ApprovalRequirement::RequireApproval(_)
));
}
#[tokio::test]
async fn returns_lsp_not_available_when_no_lsp() {
let tool = DiagnosticsTool;
let ctx = ToolContext::new(std::path::PathBuf::from("/tmp"));
let result = tool.execute("{}", &ctx).await.unwrap();
assert!(result.output.contains("LSP not available"));
assert!(result.success);
}
#[test]
fn diagnostic_display_includes_line_info() {
let d = Diagnostic {
file: "src/main.rs".into(),
line: 10,
column: 5,
end_line: None,
end_column: None,
severity: DiagnosticSeverity::Error,
message: "type mismatch".into(),
source: Some("rustc".into()),
code: Some("E0308".into()),
};
let line = d.display_line();
assert!(line.contains("src/main.rs:10:5"));
assert!(line.contains("[ERROR]"));
assert!(line.contains("E0308"));
assert!(line.contains("type mismatch"));
}
}