1use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use schemars::JsonSchema;
8use serde::Deserialize;
9
10use zeph_common::ToolName;
11
12use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params};
13use crate::file::expand_tilde;
14use crate::registry::{InvocationHint, ToolDef};
15
16const DEFAULT_TIMEOUT_SECS: u64 = 30;
20
21#[derive(Debug, Default, Deserialize, JsonSchema, PartialEq, Eq)]
23#[serde(rename_all = "snake_case")]
24#[non_exhaustive]
25pub enum DiagnosticsLevel {
26 #[default]
28 Check,
29 Clippy,
31}
32
33#[derive(Debug, Deserialize, JsonSchema)]
34struct DiagnosticsParams {
35 path: Option<String>,
37 #[serde(default)]
39 level: DiagnosticsLevel,
40}
41
42#[derive(Debug)]
44pub struct DiagnosticsExecutor {
45 allowed_paths: Vec<PathBuf>,
46 max_diagnostics: usize,
48 timeout: Duration,
50}
51
52impl DiagnosticsExecutor {
53 #[must_use]
54 pub fn new(allowed_paths: Vec<PathBuf>) -> Self {
55 let paths = if allowed_paths.is_empty() {
56 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
57 } else {
58 allowed_paths.into_iter().map(expand_tilde).collect()
59 };
60 Self {
61 allowed_paths: paths
62 .into_iter()
63 .map(|p| p.canonicalize().unwrap_or(p))
64 .collect(),
65 max_diagnostics: 50,
66 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
67 }
68 }
69
70 #[must_use]
71 pub fn with_max_diagnostics(mut self, max: usize) -> Self {
72 self.max_diagnostics = max;
73 self
74 }
75
76 #[must_use]
82 pub fn with_timeout(mut self, timeout: Duration) -> Self {
83 self.timeout = timeout;
84 self
85 }
86
87 fn validate_path(&self, path: &Path) -> Result<PathBuf, ToolError> {
88 let path = expand_tilde(path.to_path_buf());
89 let resolved = if path.is_absolute() {
90 path
91 } else {
92 std::env::current_dir()
93 .unwrap_or_else(|_| PathBuf::from("."))
94 .join(path)
95 };
96 zeph_common::security::validate_path_within(&resolved, &self.allowed_paths).map_err(|e| {
100 match e.kind() {
101 std::io::ErrorKind::NotFound => ToolError::Execution(std::io::Error::new(
102 std::io::ErrorKind::NotFound,
103 format!("path not found: {}: {e}", resolved.display()),
104 )),
105 _ => ToolError::SandboxViolation {
109 path: resolved
110 .canonicalize()
111 .unwrap_or(resolved)
112 .display()
113 .to_string(),
114 },
115 }
116 })
117 }
118}
119
120impl ToolExecutor for DiagnosticsExecutor {
121 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
122 Ok(None)
123 }
124
125 #[cfg_attr(
126 feature = "profiling",
127 tracing::instrument(name = "tools.diagnostics.execute", skip_all)
128 )]
129 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
130 if call.tool_id != "diagnostics" {
131 return Ok(None);
132 }
133 let p: DiagnosticsParams = deserialize_params(&call.params)?;
134 let work_dir = if let Some(path) = &p.path {
135 self.validate_path(Path::new(path))?
136 } else {
137 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
138 self.validate_path(&cwd)?
139 };
140
141 let subcmd = match p.level {
142 DiagnosticsLevel::Check => "check",
143 DiagnosticsLevel::Clippy => "clippy",
144 };
145
146 let cargo = which_cargo()?;
147
148 let output = tokio::time::timeout(
151 self.timeout,
152 tokio::process::Command::new(&cargo)
153 .arg(subcmd)
154 .arg("--message-format=json")
155 .current_dir(&work_dir)
156 .kill_on_drop(true)
157 .output(),
158 )
159 .await
160 .map_err(|_| {
161 tracing::warn!(
162 cmd = subcmd,
163 timeout_secs = self.timeout.as_secs(),
164 "cargo diagnostics subprocess timed out, killing child"
165 );
166 ToolError::Timeout {
167 timeout_secs: self.timeout.as_secs(),
168 }
169 })?
170 .map_err(|e| {
171 ToolError::Execution(std::io::Error::new(
172 std::io::ErrorKind::NotFound,
173 format!("failed to run cargo: {e}"),
174 ))
175 })?;
176
177 let stdout = String::from_utf8_lossy(&output.stdout);
178 let diagnostics = parse_cargo_json(&stdout, self.max_diagnostics);
179
180 let summary = if diagnostics.is_empty() {
181 "No diagnostics".to_owned()
182 } else {
183 diagnostics.join("\n")
184 };
185
186 Ok(Some(ToolOutput {
187 tool_name: ToolName::new("diagnostics"),
188 summary,
189 blocks_executed: 1,
190 filter_stats: None,
191 diff: None,
192 streamed: false,
193 terminal_id: None,
194 locations: None,
195 raw_response: None,
196 claim_source: Some(crate::executor::ClaimSource::Diagnostics),
197 ..Default::default()
198 }))
199 }
200
201 fn tool_definitions(&self) -> Vec<ToolDef> {
202 vec![ToolDef {
203 id: "diagnostics".into(),
204 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(),
205 schema: schemars::schema_for!(DiagnosticsParams),
206 invocation: InvocationHint::ToolCall,
207 output_schema: None,
208 server_id: None,
209 }]
210 }
211
212 crate::tool_executor_no_inner_defaults!();
213}
214
215fn which_cargo() -> Result<PathBuf, ToolError> {
222 if let Ok(cargo) = std::env::var("CARGO") {
224 let p = PathBuf::from(&cargo);
225 if p.is_file() {
226 return Ok(p.canonicalize().unwrap_or(p));
227 }
228 }
229 for dir in std::env::var("PATH").unwrap_or_default().split(':') {
231 let candidate = PathBuf::from(dir).join("cargo");
232 if candidate.is_file() {
233 return Ok(candidate.canonicalize().unwrap_or(candidate));
234 }
235 }
236 Err(ToolError::Execution(std::io::Error::new(
237 std::io::ErrorKind::NotFound,
238 "cargo not found in PATH",
239 )))
240}
241
242pub(crate) fn parse_cargo_json(output: &str, max: usize) -> Vec<String> {
247 let mut results = Vec::new();
248 for line in output.lines() {
249 if results.len() >= max {
250 break;
251 }
252 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
253 continue;
254 };
255 if val.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
256 continue;
257 }
258 let Some(msg) = val.get("message") else {
259 continue;
260 };
261 let level = msg
262 .get("level")
263 .and_then(|l| l.as_str())
264 .unwrap_or("unknown");
265 let text = msg
266 .get("message")
267 .and_then(|m| m.as_str())
268 .unwrap_or("")
269 .trim();
270 if text.is_empty() {
271 continue;
272 }
273
274 let spans = msg
276 .get("spans")
277 .and_then(serde_json::Value::as_array)
278 .map_or(&[] as &[_], Vec::as_slice);
279
280 let primary = spans.iter().find(|s| {
281 s.get("is_primary")
282 .and_then(serde_json::Value::as_bool)
283 .unwrap_or(false)
284 });
285
286 if let Some(span) = primary {
287 let file = span
288 .get("file_name")
289 .and_then(|f| f.as_str())
290 .unwrap_or("?");
291 let line = span
292 .get("line_start")
293 .and_then(serde_json::Value::as_u64)
294 .unwrap_or(0);
295 let col = span
296 .get("column_start")
297 .and_then(serde_json::Value::as_u64)
298 .unwrap_or(0);
299 results.push(format!("{file}:{line}:{col}: {level}: {text}"));
300 } else {
301 results.push(format!("{level}: {text}"));
302 }
303 }
304 results
305}
306
307#[cfg(test)]
308mod tests {
309 use std::assert_matches;
310
311 use super::*;
312
313 fn make_params(
314 pairs: &[(&str, serde_json::Value)],
315 ) -> serde_json::Map<String, serde_json::Value> {
316 pairs
317 .iter()
318 .map(|(k, v)| ((*k).to_owned(), v.clone()))
319 .collect()
320 }
321
322 #[test]
325 fn parse_cargo_json_empty_input() {
326 let result = parse_cargo_json("", 50);
327 assert!(result.is_empty());
328 }
329
330 #[test]
331 fn parse_cargo_json_non_compiler_message_ignored() {
332 let line = r#"{"reason":"build-script-executed","package_id":"foo"}"#;
333 let result = parse_cargo_json(line, 50);
334 assert!(result.is_empty());
335 }
336
337 #[test]
338 fn parse_cargo_json_compiler_message_with_span() {
339 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}]}}"#;
340 let result = parse_cargo_json(line, 50);
341 assert_eq!(result.len(), 1);
342 assert!(result[0].contains("src/main.rs"));
343 assert!(result[0].contains("10"));
344 assert!(result[0].contains("error"));
345 assert!(result[0].contains("cannot find value"));
346 }
347
348 #[test]
349 fn parse_cargo_json_warning_with_span() {
350 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}]}}"#;
351 let result = parse_cargo_json(line, 50);
352 assert_eq!(result.len(), 1);
353 assert!(result[0].starts_with("src/lib.rs:3:9: warning:"));
354 }
355
356 #[test]
357 fn parse_cargo_json_no_primary_span_uses_message_only() {
358 let line = r#"{"reason":"compiler-message","message":{"level":"error","message":"aborting due to previous error","spans":[]}}"#;
359 let result = parse_cargo_json(line, 50);
360 assert_eq!(result.len(), 1);
361 assert_eq!(result[0], "error: aborting due to previous error");
362 }
363
364 #[test]
365 fn parse_cargo_json_max_cap_respected() {
366 let single = r#"{"reason":"compiler-message","message":{"level":"warning","message":"unused","spans":[]}}"#;
367 let input: String = (0..20).map(|_| single).collect::<Vec<_>>().join("\n");
368 let result = parse_cargo_json(&input, 5);
369 assert_eq!(result.len(), 5);
370 }
371
372 #[test]
373 fn parse_cargo_json_empty_message_skipped() {
374 let line = r#"{"reason":"compiler-message","message":{"level":"note","message":" ","spans":[]}}"#;
375 let result = parse_cargo_json(line, 50);
376 assert!(result.is_empty());
377 }
378
379 #[test]
380 fn parse_cargo_json_non_primary_span_skipped_for_location() {
381 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}]}}"#;
382 let result = parse_cargo_json(line, 50);
384 assert_eq!(result.len(), 1);
385 assert_eq!(result[0], "warning: some warning");
386 }
387
388 #[test]
389 fn parse_cargo_json_invalid_json_line_skipped() {
390 let input = "not json\n{\"reason\":\"build-script-executed\"}";
391 let result = parse_cargo_json(input, 50);
392 assert!(result.is_empty());
393 }
394
395 #[tokio::test]
403 async fn diagnostics_timeout_returns_timeout_error() {
404 let dir = tempfile::tempdir().unwrap();
405 let exec = DiagnosticsExecutor::new(vec![dir.path().to_path_buf()])
406 .with_timeout(std::time::Duration::from_nanos(1));
407
408 let call = ToolCall {
409 tool_id: ToolName::new("diagnostics"),
410 params: make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]),
411 caller_id: None,
412 context: None,
413
414 tool_call_id: String::new(),
415 skill_name: None,
416 };
417 let result = exec.execute_tool_call(&call).await;
418 assert_matches!(result, Err(ToolError::Timeout { .. }));
419 }
420
421 #[tokio::test]
424 async fn diagnostics_sandbox_violation() {
425 let dir = tempfile::tempdir().unwrap();
426 let exec = DiagnosticsExecutor::new(vec![dir.path().to_path_buf()]);
427
428 let call = ToolCall {
429 tool_id: ToolName::new("diagnostics"),
430 params: make_params(&[("path", serde_json::json!("/etc"))]),
431 caller_id: None,
432 context: None,
433
434 tool_call_id: String::new(),
435 skill_name: None,
436 };
437 let result = exec.execute_tool_call(&call).await;
438 assert!(result.is_err());
439 }
440
441 #[tokio::test]
442 async fn diagnostics_unknown_tool_returns_none() {
443 let exec = DiagnosticsExecutor::new(vec![]);
444 let call = ToolCall {
445 tool_id: ToolName::new("other"),
446 params: serde_json::Map::new(),
447 caller_id: None,
448 context: None,
449
450 tool_call_id: String::new(),
451 skill_name: None,
452 };
453 let result = exec.execute_tool_call(&call).await.unwrap();
454 assert!(result.is_none());
455 }
456
457 #[test]
458 fn diagnostics_tool_definition() {
459 let exec = DiagnosticsExecutor::new(vec![]);
460 let defs = exec.tool_definitions();
461 assert_eq!(defs.len(), 1);
462 assert_eq!(defs[0].id, "diagnostics");
463 assert_eq!(defs[0].invocation, InvocationHint::ToolCall);
464 }
465
466 #[test]
467 fn diagnostics_level_default_is_check() {
468 assert_eq!(DiagnosticsLevel::default(), DiagnosticsLevel::Check);
469 }
470
471 #[test]
472 fn diagnostics_level_deserialize_check() {
473 let p: DiagnosticsParams = serde_json::from_str(r#"{"level":"check"}"#).unwrap();
474 assert_eq!(p.level, DiagnosticsLevel::Check);
475 }
476
477 #[test]
478 fn diagnostics_level_deserialize_clippy() {
479 let p: DiagnosticsParams = serde_json::from_str(r#"{"level":"clippy"}"#).unwrap();
480 assert_eq!(p.level, DiagnosticsLevel::Clippy);
481 }
482
483 #[test]
484 fn diagnostics_params_path_optional() {
485 let p: DiagnosticsParams = serde_json::from_str(r"{}").unwrap();
486 assert!(p.path.is_none());
487 assert_eq!(p.level, DiagnosticsLevel::Check);
488 }
489
490 #[tokio::test]
493 async fn validate_path_expands_tilde_in_runtime_argument() {
494 let home = dirs::home_dir().expect("home dir must be resolvable in test env");
498 let subdir = tempfile::Builder::new()
499 .prefix("zeph_test_diagnostics_tilde_")
500 .tempdir_in(&home)
501 .expect("failed to create temp dir under home");
502 let dir_name = subdir.path().file_name().unwrap().to_str().unwrap();
503
504 let exec = DiagnosticsExecutor::new(vec![home.clone()]);
505 let canonical = exec
506 .validate_path(Path::new(&format!("~/{dir_name}")))
507 .unwrap();
508
509 assert_eq!(canonical, subdir.path().canonicalize().unwrap());
510 assert!(
511 !canonical.to_string_lossy().contains('~'),
512 "tilde must not appear in normalized runtime path: {canonical:?}"
513 );
514 }
515
516 #[tokio::test]
517 async fn validate_path_absolute_path_unchanged() {
518 let dir = tempfile::tempdir().unwrap();
521 let exec = DiagnosticsExecutor::new(vec![dir.path().to_path_buf()]);
522
523 let canonical = exec.validate_path(dir.path()).unwrap();
524
525 assert_eq!(canonical, dir.path().canonicalize().unwrap());
526 }
527
528 #[test]
530 fn diagnostics_clippy_subcmd_string() {
531 let subcmd = match DiagnosticsLevel::Clippy {
532 DiagnosticsLevel::Check => "check",
533 DiagnosticsLevel::Clippy => "clippy",
534 };
535 assert_eq!(subcmd, "clippy");
536 }
537
538 #[test]
539 fn diagnostics_check_subcmd_string() {
540 let subcmd = match DiagnosticsLevel::Check {
541 DiagnosticsLevel::Check => "check",
542 DiagnosticsLevel::Clippy => "clippy",
543 };
544 assert_eq!(subcmd, "check");
545 }
546}