1use std::path::{Path, PathBuf};
5
6use schemars::JsonSchema;
7use serde::Deserialize;
8
9use zeph_common::ToolName;
10
11use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params};
12use crate::registry::{InvocationHint, ToolDef};
13
14#[derive(Debug, Default, Deserialize, JsonSchema, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17#[non_exhaustive]
18pub enum DiagnosticsLevel {
19 #[default]
21 Check,
22 Clippy,
24}
25
26#[derive(Debug, Deserialize, JsonSchema)]
27struct DiagnosticsParams {
28 path: Option<String>,
30 #[serde(default)]
32 level: DiagnosticsLevel,
33}
34
35#[derive(Debug)]
37pub struct DiagnosticsExecutor {
38 allowed_paths: Vec<PathBuf>,
39 max_diagnostics: usize,
41}
42
43impl DiagnosticsExecutor {
44 #[must_use]
45 pub fn new(allowed_paths: Vec<PathBuf>) -> Self {
46 let paths = if allowed_paths.is_empty() {
47 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
48 } else {
49 allowed_paths
50 };
51 Self {
52 allowed_paths: paths
53 .into_iter()
54 .map(|p| p.canonicalize().unwrap_or(p))
55 .collect(),
56 max_diagnostics: 50,
57 }
58 }
59
60 #[must_use]
61 pub fn with_max_diagnostics(mut self, max: usize) -> Self {
62 self.max_diagnostics = max;
63 self
64 }
65
66 fn validate_path(&self, path: &Path) -> Result<PathBuf, ToolError> {
67 let resolved = if path.is_absolute() {
68 path.to_path_buf()
69 } else {
70 std::env::current_dir()
71 .unwrap_or_else(|_| PathBuf::from("."))
72 .join(path)
73 };
74 let canonical = resolved.canonicalize().map_err(|e| {
75 ToolError::Execution(std::io::Error::new(
76 std::io::ErrorKind::NotFound,
77 format!("path not found: {}: {e}", resolved.display()),
78 ))
79 })?;
80 if !self.allowed_paths.iter().any(|a| canonical.starts_with(a)) {
81 return Err(ToolError::SandboxViolation {
82 path: canonical.display().to_string(),
83 });
84 }
85 Ok(canonical)
86 }
87}
88
89impl ToolExecutor for DiagnosticsExecutor {
90 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
91 Ok(None)
92 }
93
94 #[cfg_attr(
95 feature = "profiling",
96 tracing::instrument(name = "tools.diagnostics.execute", skip_all)
97 )]
98 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
99 if call.tool_id != "diagnostics" {
100 return Ok(None);
101 }
102 let p: DiagnosticsParams = deserialize_params(&call.params)?;
103 let work_dir = if let Some(path) = &p.path {
104 self.validate_path(Path::new(path))?
105 } else {
106 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
107 self.validate_path(&cwd)?
108 };
109
110 let subcmd = match p.level {
111 DiagnosticsLevel::Check => "check",
112 DiagnosticsLevel::Clippy => "clippy",
113 };
114
115 let cargo = which_cargo()?;
116
117 let output = tokio::process::Command::new(&cargo)
118 .arg(subcmd)
119 .arg("--message-format=json")
120 .current_dir(&work_dir)
121 .output()
122 .await
123 .map_err(|e| {
124 ToolError::Execution(std::io::Error::new(
125 std::io::ErrorKind::NotFound,
126 format!("failed to run cargo: {e}"),
127 ))
128 })?;
129
130 let stdout = String::from_utf8_lossy(&output.stdout);
131 let diagnostics = parse_cargo_json(&stdout, self.max_diagnostics);
132
133 let summary = if diagnostics.is_empty() {
134 "No diagnostics".to_owned()
135 } else {
136 diagnostics.join("\n")
137 };
138
139 Ok(Some(ToolOutput {
140 tool_name: ToolName::new("diagnostics"),
141 summary,
142 blocks_executed: 1,
143 filter_stats: None,
144 diff: None,
145 streamed: false,
146 terminal_id: None,
147 locations: None,
148 raw_response: None,
149 claim_source: Some(crate::executor::ClaimSource::Diagnostics),
150 }))
151 }
152
153 fn tool_definitions(&self) -> Vec<ToolDef> {
154 vec![ToolDef {
155 id: "diagnostics".into(),
156 description: "Run cargo check or cargo clippy on a Rust workspace and return compiler diagnostics.\n\nParameters: path (string, optional) - workspace directory (default: cwd); level (string, optional) - \"check\" or \"clippy\" (default: \"check\")\nReturns: structured diagnostics with file paths, line numbers, severity, and messages; capped at 50 results\nErrors: SandboxViolation if path outside allowed dirs; Execution if cargo is not found\nExample: {\"path\": \".\", \"level\": \"clippy\"}".into(),
157 schema: schemars::schema_for!(DiagnosticsParams),
158 invocation: InvocationHint::ToolCall,
159 output_schema: None,
160 }]
161 }
162}
163
164fn which_cargo() -> Result<PathBuf, ToolError> {
171 if let Ok(cargo) = std::env::var("CARGO") {
173 let p = PathBuf::from(&cargo);
174 if p.is_file() {
175 return Ok(p.canonicalize().unwrap_or(p));
176 }
177 }
178 for dir in std::env::var("PATH").unwrap_or_default().split(':') {
180 let candidate = PathBuf::from(dir).join("cargo");
181 if candidate.is_file() {
182 return Ok(candidate.canonicalize().unwrap_or(candidate));
183 }
184 }
185 Err(ToolError::Execution(std::io::Error::new(
186 std::io::ErrorKind::NotFound,
187 "cargo not found in PATH",
188 )))
189}
190
191pub(crate) fn parse_cargo_json(output: &str, max: usize) -> Vec<String> {
196 let mut results = Vec::new();
197 for line in output.lines() {
198 if results.len() >= max {
199 break;
200 }
201 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
202 continue;
203 };
204 if val.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
205 continue;
206 }
207 let Some(msg) = val.get("message") else {
208 continue;
209 };
210 let level = msg
211 .get("level")
212 .and_then(|l| l.as_str())
213 .unwrap_or("unknown");
214 let text = msg
215 .get("message")
216 .and_then(|m| m.as_str())
217 .unwrap_or("")
218 .trim();
219 if text.is_empty() {
220 continue;
221 }
222
223 let spans = msg
225 .get("spans")
226 .and_then(serde_json::Value::as_array)
227 .map_or(&[] as &[_], Vec::as_slice);
228
229 let primary = spans.iter().find(|s| {
230 s.get("is_primary")
231 .and_then(serde_json::Value::as_bool)
232 .unwrap_or(false)
233 });
234
235 if let Some(span) = primary {
236 let file = span
237 .get("file_name")
238 .and_then(|f| f.as_str())
239 .unwrap_or("?");
240 let line = span
241 .get("line_start")
242 .and_then(serde_json::Value::as_u64)
243 .unwrap_or(0);
244 let col = span
245 .get("column_start")
246 .and_then(serde_json::Value::as_u64)
247 .unwrap_or(0);
248 results.push(format!("{file}:{line}:{col}: {level}: {text}"));
249 } else {
250 results.push(format!("{level}: {text}"));
251 }
252 }
253 results
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 fn make_params(
261 pairs: &[(&str, serde_json::Value)],
262 ) -> serde_json::Map<String, serde_json::Value> {
263 pairs
264 .iter()
265 .map(|(k, v)| ((*k).to_owned(), v.clone()))
266 .collect()
267 }
268
269 #[test]
272 fn parse_cargo_json_empty_input() {
273 let result = parse_cargo_json("", 50);
274 assert!(result.is_empty());
275 }
276
277 #[test]
278 fn parse_cargo_json_non_compiler_message_ignored() {
279 let line = r#"{"reason":"build-script-executed","package_id":"foo"}"#;
280 let result = parse_cargo_json(line, 50);
281 assert!(result.is_empty());
282 }
283
284 #[test]
285 fn parse_cargo_json_compiler_message_with_span() {
286 let line = r#"{"reason":"compiler-message","message":{"level":"error","message":"cannot find value `foo` in this scope","spans":[{"file_name":"src/main.rs","line_start":10,"column_start":5,"is_primary":true}]}}"#;
287 let result = parse_cargo_json(line, 50);
288 assert_eq!(result.len(), 1);
289 assert!(result[0].contains("src/main.rs"));
290 assert!(result[0].contains("10"));
291 assert!(result[0].contains("error"));
292 assert!(result[0].contains("cannot find value"));
293 }
294
295 #[test]
296 fn parse_cargo_json_warning_with_span() {
297 let line = r#"{"reason":"compiler-message","message":{"level":"warning","message":"unused variable: `x`","spans":[{"file_name":"src/lib.rs","line_start":3,"column_start":9,"is_primary":true}]}}"#;
298 let result = parse_cargo_json(line, 50);
299 assert_eq!(result.len(), 1);
300 assert!(result[0].starts_with("src/lib.rs:3:9: warning:"));
301 }
302
303 #[test]
304 fn parse_cargo_json_no_primary_span_uses_message_only() {
305 let line = r#"{"reason":"compiler-message","message":{"level":"error","message":"aborting due to previous error","spans":[]}}"#;
306 let result = parse_cargo_json(line, 50);
307 assert_eq!(result.len(), 1);
308 assert_eq!(result[0], "error: aborting due to previous error");
309 }
310
311 #[test]
312 fn parse_cargo_json_max_cap_respected() {
313 let single = r#"{"reason":"compiler-message","message":{"level":"warning","message":"unused","spans":[]}}"#;
314 let input: String = (0..20).map(|_| single).collect::<Vec<_>>().join("\n");
315 let result = parse_cargo_json(&input, 5);
316 assert_eq!(result.len(), 5);
317 }
318
319 #[test]
320 fn parse_cargo_json_empty_message_skipped() {
321 let line = r#"{"reason":"compiler-message","message":{"level":"note","message":" ","spans":[]}}"#;
322 let result = parse_cargo_json(line, 50);
323 assert!(result.is_empty());
324 }
325
326 #[test]
327 fn parse_cargo_json_non_primary_span_skipped_for_location() {
328 let line = r#"{"reason":"compiler-message","message":{"level":"warning","message":"some warning","spans":[{"file_name":"src/foo.rs","line_start":1,"column_start":1,"is_primary":false}]}}"#;
329 let result = parse_cargo_json(line, 50);
331 assert_eq!(result.len(), 1);
332 assert_eq!(result[0], "warning: some warning");
333 }
334
335 #[test]
336 fn parse_cargo_json_invalid_json_line_skipped() {
337 let input = "not json\n{\"reason\":\"build-script-executed\"}";
338 let result = parse_cargo_json(input, 50);
339 assert!(result.is_empty());
340 }
341
342 #[tokio::test]
345 async fn diagnostics_sandbox_violation() {
346 let dir = tempfile::tempdir().unwrap();
347 let exec = DiagnosticsExecutor::new(vec![dir.path().to_path_buf()]);
348
349 let call = ToolCall {
350 tool_id: ToolName::new("diagnostics"),
351 params: make_params(&[("path", serde_json::json!("/etc"))]),
352 caller_id: None,
353 context: None,
354
355 tool_call_id: String::new(),
356 skill_name: None,
357 };
358 let result = exec.execute_tool_call(&call).await;
359 assert!(result.is_err());
360 }
361
362 #[tokio::test]
363 async fn diagnostics_unknown_tool_returns_none() {
364 let exec = DiagnosticsExecutor::new(vec![]);
365 let call = ToolCall {
366 tool_id: ToolName::new("other"),
367 params: serde_json::Map::new(),
368 caller_id: None,
369 context: None,
370
371 tool_call_id: String::new(),
372 skill_name: None,
373 };
374 let result = exec.execute_tool_call(&call).await.unwrap();
375 assert!(result.is_none());
376 }
377
378 #[test]
379 fn diagnostics_tool_definition() {
380 let exec = DiagnosticsExecutor::new(vec![]);
381 let defs = exec.tool_definitions();
382 assert_eq!(defs.len(), 1);
383 assert_eq!(defs[0].id, "diagnostics");
384 assert_eq!(defs[0].invocation, InvocationHint::ToolCall);
385 }
386
387 #[test]
388 fn diagnostics_level_default_is_check() {
389 assert_eq!(DiagnosticsLevel::default(), DiagnosticsLevel::Check);
390 }
391
392 #[test]
393 fn diagnostics_level_deserialize_check() {
394 let p: DiagnosticsParams = serde_json::from_str(r#"{"level":"check"}"#).unwrap();
395 assert_eq!(p.level, DiagnosticsLevel::Check);
396 }
397
398 #[test]
399 fn diagnostics_level_deserialize_clippy() {
400 let p: DiagnosticsParams = serde_json::from_str(r#"{"level":"clippy"}"#).unwrap();
401 assert_eq!(p.level, DiagnosticsLevel::Clippy);
402 }
403
404 #[test]
405 fn diagnostics_params_path_optional() {
406 let p: DiagnosticsParams = serde_json::from_str(r"{}").unwrap();
407 assert!(p.path.is_none());
408 assert_eq!(p.level, DiagnosticsLevel::Check);
409 }
410
411 #[test]
413 fn diagnostics_clippy_subcmd_string() {
414 let subcmd = match DiagnosticsLevel::Clippy {
415 DiagnosticsLevel::Check => "check",
416 DiagnosticsLevel::Clippy => "clippy",
417 };
418 assert_eq!(subcmd, "clippy");
419 }
420
421 #[test]
422 fn diagnostics_check_subcmd_string() {
423 let subcmd = match DiagnosticsLevel::Check {
424 DiagnosticsLevel::Check => "check",
425 DiagnosticsLevel::Clippy => "clippy",
426 };
427 assert_eq!(subcmd, "check");
428 }
429}