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 let canonical = resolved.canonicalize().map_err(|e| {
97 ToolError::Execution(std::io::Error::new(
98 std::io::ErrorKind::NotFound,
99 format!("path not found: {}: {e}", resolved.display()),
100 ))
101 })?;
102 if !self.allowed_paths.iter().any(|a| canonical.starts_with(a)) {
103 return Err(ToolError::SandboxViolation {
104 path: canonical.display().to_string(),
105 });
106 }
107 Ok(canonical)
108 }
109}
110
111impl ToolExecutor for DiagnosticsExecutor {
112 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
113 Ok(None)
114 }
115
116 #[cfg_attr(
117 feature = "profiling",
118 tracing::instrument(name = "tools.diagnostics.execute", skip_all)
119 )]
120 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
121 if call.tool_id != "diagnostics" {
122 return Ok(None);
123 }
124 let p: DiagnosticsParams = deserialize_params(&call.params)?;
125 let work_dir = if let Some(path) = &p.path {
126 self.validate_path(Path::new(path))?
127 } else {
128 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
129 self.validate_path(&cwd)?
130 };
131
132 let subcmd = match p.level {
133 DiagnosticsLevel::Check => "check",
134 DiagnosticsLevel::Clippy => "clippy",
135 };
136
137 let cargo = which_cargo()?;
138
139 let output = tokio::time::timeout(
142 self.timeout,
143 tokio::process::Command::new(&cargo)
144 .arg(subcmd)
145 .arg("--message-format=json")
146 .current_dir(&work_dir)
147 .kill_on_drop(true)
148 .output(),
149 )
150 .await
151 .map_err(|_| {
152 tracing::warn!(
153 cmd = subcmd,
154 timeout_secs = self.timeout.as_secs(),
155 "cargo diagnostics subprocess timed out, killing child"
156 );
157 ToolError::Timeout {
158 timeout_secs: self.timeout.as_secs(),
159 }
160 })?
161 .map_err(|e| {
162 ToolError::Execution(std::io::Error::new(
163 std::io::ErrorKind::NotFound,
164 format!("failed to run cargo: {e}"),
165 ))
166 })?;
167
168 let stdout = String::from_utf8_lossy(&output.stdout);
169 let diagnostics = parse_cargo_json(&stdout, self.max_diagnostics);
170
171 let summary = if diagnostics.is_empty() {
172 "No diagnostics".to_owned()
173 } else {
174 diagnostics.join("\n")
175 };
176
177 Ok(Some(ToolOutput {
178 tool_name: ToolName::new("diagnostics"),
179 summary,
180 blocks_executed: 1,
181 filter_stats: None,
182 diff: None,
183 streamed: false,
184 terminal_id: None,
185 locations: None,
186 raw_response: None,
187 claim_source: Some(crate::executor::ClaimSource::Diagnostics),
188 }))
189 }
190
191 fn tool_definitions(&self) -> Vec<ToolDef> {
192 vec![ToolDef {
193 id: "diagnostics".into(),
194 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(),
195 schema: schemars::schema_for!(DiagnosticsParams),
196 invocation: InvocationHint::ToolCall,
197 output_schema: None,
198 server_id: None,
199 }]
200 }
201}
202
203fn which_cargo() -> Result<PathBuf, ToolError> {
210 if let Ok(cargo) = std::env::var("CARGO") {
212 let p = PathBuf::from(&cargo);
213 if p.is_file() {
214 return Ok(p.canonicalize().unwrap_or(p));
215 }
216 }
217 for dir in std::env::var("PATH").unwrap_or_default().split(':') {
219 let candidate = PathBuf::from(dir).join("cargo");
220 if candidate.is_file() {
221 return Ok(candidate.canonicalize().unwrap_or(candidate));
222 }
223 }
224 Err(ToolError::Execution(std::io::Error::new(
225 std::io::ErrorKind::NotFound,
226 "cargo not found in PATH",
227 )))
228}
229
230pub(crate) fn parse_cargo_json(output: &str, max: usize) -> Vec<String> {
235 let mut results = Vec::new();
236 for line in output.lines() {
237 if results.len() >= max {
238 break;
239 }
240 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
241 continue;
242 };
243 if val.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
244 continue;
245 }
246 let Some(msg) = val.get("message") else {
247 continue;
248 };
249 let level = msg
250 .get("level")
251 .and_then(|l| l.as_str())
252 .unwrap_or("unknown");
253 let text = msg
254 .get("message")
255 .and_then(|m| m.as_str())
256 .unwrap_or("")
257 .trim();
258 if text.is_empty() {
259 continue;
260 }
261
262 let spans = msg
264 .get("spans")
265 .and_then(serde_json::Value::as_array)
266 .map_or(&[] as &[_], Vec::as_slice);
267
268 let primary = spans.iter().find(|s| {
269 s.get("is_primary")
270 .and_then(serde_json::Value::as_bool)
271 .unwrap_or(false)
272 });
273
274 if let Some(span) = primary {
275 let file = span
276 .get("file_name")
277 .and_then(|f| f.as_str())
278 .unwrap_or("?");
279 let line = span
280 .get("line_start")
281 .and_then(serde_json::Value::as_u64)
282 .unwrap_or(0);
283 let col = span
284 .get("column_start")
285 .and_then(serde_json::Value::as_u64)
286 .unwrap_or(0);
287 results.push(format!("{file}:{line}:{col}: {level}: {text}"));
288 } else {
289 results.push(format!("{level}: {text}"));
290 }
291 }
292 results
293}
294
295#[cfg(test)]
296mod tests {
297 use std::assert_matches;
298
299 use super::*;
300
301 fn make_params(
302 pairs: &[(&str, serde_json::Value)],
303 ) -> serde_json::Map<String, serde_json::Value> {
304 pairs
305 .iter()
306 .map(|(k, v)| ((*k).to_owned(), v.clone()))
307 .collect()
308 }
309
310 #[test]
313 fn parse_cargo_json_empty_input() {
314 let result = parse_cargo_json("", 50);
315 assert!(result.is_empty());
316 }
317
318 #[test]
319 fn parse_cargo_json_non_compiler_message_ignored() {
320 let line = r#"{"reason":"build-script-executed","package_id":"foo"}"#;
321 let result = parse_cargo_json(line, 50);
322 assert!(result.is_empty());
323 }
324
325 #[test]
326 fn parse_cargo_json_compiler_message_with_span() {
327 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}]}}"#;
328 let result = parse_cargo_json(line, 50);
329 assert_eq!(result.len(), 1);
330 assert!(result[0].contains("src/main.rs"));
331 assert!(result[0].contains("10"));
332 assert!(result[0].contains("error"));
333 assert!(result[0].contains("cannot find value"));
334 }
335
336 #[test]
337 fn parse_cargo_json_warning_with_span() {
338 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}]}}"#;
339 let result = parse_cargo_json(line, 50);
340 assert_eq!(result.len(), 1);
341 assert!(result[0].starts_with("src/lib.rs:3:9: warning:"));
342 }
343
344 #[test]
345 fn parse_cargo_json_no_primary_span_uses_message_only() {
346 let line = r#"{"reason":"compiler-message","message":{"level":"error","message":"aborting due to previous error","spans":[]}}"#;
347 let result = parse_cargo_json(line, 50);
348 assert_eq!(result.len(), 1);
349 assert_eq!(result[0], "error: aborting due to previous error");
350 }
351
352 #[test]
353 fn parse_cargo_json_max_cap_respected() {
354 let single = r#"{"reason":"compiler-message","message":{"level":"warning","message":"unused","spans":[]}}"#;
355 let input: String = (0..20).map(|_| single).collect::<Vec<_>>().join("\n");
356 let result = parse_cargo_json(&input, 5);
357 assert_eq!(result.len(), 5);
358 }
359
360 #[test]
361 fn parse_cargo_json_empty_message_skipped() {
362 let line = r#"{"reason":"compiler-message","message":{"level":"note","message":" ","spans":[]}}"#;
363 let result = parse_cargo_json(line, 50);
364 assert!(result.is_empty());
365 }
366
367 #[test]
368 fn parse_cargo_json_non_primary_span_skipped_for_location() {
369 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}]}}"#;
370 let result = parse_cargo_json(line, 50);
372 assert_eq!(result.len(), 1);
373 assert_eq!(result[0], "warning: some warning");
374 }
375
376 #[test]
377 fn parse_cargo_json_invalid_json_line_skipped() {
378 let input = "not json\n{\"reason\":\"build-script-executed\"}";
379 let result = parse_cargo_json(input, 50);
380 assert!(result.is_empty());
381 }
382
383 #[tokio::test]
391 async fn diagnostics_timeout_returns_timeout_error() {
392 let dir = tempfile::tempdir().unwrap();
393 let exec = DiagnosticsExecutor::new(vec![dir.path().to_path_buf()])
394 .with_timeout(std::time::Duration::from_nanos(1));
395
396 let call = ToolCall {
397 tool_id: ToolName::new("diagnostics"),
398 params: make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]),
399 caller_id: None,
400 context: None,
401
402 tool_call_id: String::new(),
403 skill_name: None,
404 };
405 let result = exec.execute_tool_call(&call).await;
406 assert_matches!(result, Err(ToolError::Timeout { .. }));
407 }
408
409 #[tokio::test]
412 async fn diagnostics_sandbox_violation() {
413 let dir = tempfile::tempdir().unwrap();
414 let exec = DiagnosticsExecutor::new(vec![dir.path().to_path_buf()]);
415
416 let call = ToolCall {
417 tool_id: ToolName::new("diagnostics"),
418 params: make_params(&[("path", serde_json::json!("/etc"))]),
419 caller_id: None,
420 context: None,
421
422 tool_call_id: String::new(),
423 skill_name: None,
424 };
425 let result = exec.execute_tool_call(&call).await;
426 assert!(result.is_err());
427 }
428
429 #[tokio::test]
430 async fn diagnostics_unknown_tool_returns_none() {
431 let exec = DiagnosticsExecutor::new(vec![]);
432 let call = ToolCall {
433 tool_id: ToolName::new("other"),
434 params: serde_json::Map::new(),
435 caller_id: None,
436 context: None,
437
438 tool_call_id: String::new(),
439 skill_name: None,
440 };
441 let result = exec.execute_tool_call(&call).await.unwrap();
442 assert!(result.is_none());
443 }
444
445 #[test]
446 fn diagnostics_tool_definition() {
447 let exec = DiagnosticsExecutor::new(vec![]);
448 let defs = exec.tool_definitions();
449 assert_eq!(defs.len(), 1);
450 assert_eq!(defs[0].id, "diagnostics");
451 assert_eq!(defs[0].invocation, InvocationHint::ToolCall);
452 }
453
454 #[test]
455 fn diagnostics_level_default_is_check() {
456 assert_eq!(DiagnosticsLevel::default(), DiagnosticsLevel::Check);
457 }
458
459 #[test]
460 fn diagnostics_level_deserialize_check() {
461 let p: DiagnosticsParams = serde_json::from_str(r#"{"level":"check"}"#).unwrap();
462 assert_eq!(p.level, DiagnosticsLevel::Check);
463 }
464
465 #[test]
466 fn diagnostics_level_deserialize_clippy() {
467 let p: DiagnosticsParams = serde_json::from_str(r#"{"level":"clippy"}"#).unwrap();
468 assert_eq!(p.level, DiagnosticsLevel::Clippy);
469 }
470
471 #[test]
472 fn diagnostics_params_path_optional() {
473 let p: DiagnosticsParams = serde_json::from_str(r"{}").unwrap();
474 assert!(p.path.is_none());
475 assert_eq!(p.level, DiagnosticsLevel::Check);
476 }
477
478 #[tokio::test]
481 async fn validate_path_expands_tilde_in_runtime_argument() {
482 let home = dirs::home_dir().expect("home dir must be resolvable in test env");
486 let subdir = tempfile::Builder::new()
487 .prefix("zeph_test_diagnostics_tilde_")
488 .tempdir_in(&home)
489 .expect("failed to create temp dir under home");
490 let dir_name = subdir.path().file_name().unwrap().to_str().unwrap();
491
492 let exec = DiagnosticsExecutor::new(vec![home.clone()]);
493 let canonical = exec
494 .validate_path(Path::new(&format!("~/{dir_name}")))
495 .unwrap();
496
497 assert_eq!(canonical, subdir.path().canonicalize().unwrap());
498 assert!(
499 !canonical.to_string_lossy().contains('~'),
500 "tilde must not appear in normalized runtime path: {canonical:?}"
501 );
502 }
503
504 #[tokio::test]
505 async fn validate_path_absolute_path_unchanged() {
506 let dir = tempfile::tempdir().unwrap();
509 let exec = DiagnosticsExecutor::new(vec![dir.path().to_path_buf()]);
510
511 let canonical = exec.validate_path(dir.path()).unwrap();
512
513 assert_eq!(canonical, dir.path().canonicalize().unwrap());
514 }
515
516 #[test]
518 fn diagnostics_clippy_subcmd_string() {
519 let subcmd = match DiagnosticsLevel::Clippy {
520 DiagnosticsLevel::Check => "check",
521 DiagnosticsLevel::Clippy => "clippy",
522 };
523 assert_eq!(subcmd, "clippy");
524 }
525
526 #[test]
527 fn diagnostics_check_subcmd_string() {
528 let subcmd = match DiagnosticsLevel::Check {
529 DiagnosticsLevel::Check => "check",
530 DiagnosticsLevel::Clippy => "clippy",
531 };
532 assert_eq!(subcmd, "check");
533 }
534}