Skip to main content

zeph_tools/
diagnostics.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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
16/// Default bound on the `cargo check`/`cargo clippy` subprocess, mirroring
17/// `ShellConfig`'s default `timeout` (`zeph_config::tools::default_timeout`). A hostile
18/// or looping `build.rs`/proc-macro must not be able to hang the tool call indefinitely.
19const DEFAULT_TIMEOUT_SECS: u64 = 30;
20
21/// Cargo diagnostics level.
22#[derive(Debug, Default, Deserialize, JsonSchema, PartialEq, Eq)]
23#[serde(rename_all = "snake_case")]
24#[non_exhaustive]
25pub enum DiagnosticsLevel {
26    /// Run `cargo check`
27    #[default]
28    Check,
29    /// Run `cargo clippy`
30    Clippy,
31}
32
33#[derive(Debug, Deserialize, JsonSchema)]
34struct DiagnosticsParams {
35    /// Workspace path (defaults to current directory)
36    path: Option<String>,
37    /// Diagnostics level: check or clippy
38    #[serde(default)]
39    level: DiagnosticsLevel,
40}
41
42/// Runs `cargo check` or `cargo clippy` and returns structured diagnostics.
43#[derive(Debug)]
44pub struct DiagnosticsExecutor {
45    allowed_paths: Vec<PathBuf>,
46    /// Maximum number of diagnostics to return (default: 50)
47    max_diagnostics: usize,
48    /// Bound on the `cargo check`/`cargo clippy` subprocess (default: 30s).
49    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    /// Overrides the default 30s bound on the `cargo check`/`cargo clippy` subprocess.
77    /// Callers wiring this into the live agent should pass the same value as
78    /// `tools.shell.timeout`, since it governs the same class of decision (how long a
79    /// subprocess is allowed to run) and cargo operations can legitimately exceed the
80    /// 30s default on larger workspaces.
81    #[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        // #6032 SEC-2: shared canonicalize-then-containment-check, used identically by
97        // `FileExecutor` (via `is_path_within` on its own ancestor-resolved path) and
98        // `resolve_and_set_cwd`.
99        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                // The path exists (NotFound already excluded above) but escapes
106                // `allowed_paths` — re-canonicalize to report the symlink-resolved form,
107                // matching the pre-#6032 SEC-2 behavior of this error message.
108                _ => 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        // kill_on_drop ensures a hostile/looping build.rs or proc-macro is killed rather
149        // than leaked when the timeout below fires and drops this future.
150        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
215/// Returns the path to the `cargo` binary, failing gracefully if not found.
216///
217/// Reads the `CARGO` environment variable (set by rustup/cargo during builds) or
218/// falls back to a PATH search. The process environment is assumed trusted — this
219/// function runs in the same process as the agent, not in an untrusted context.
220/// Canonicalization is applied as defence-in-depth to resolve any symlinks in the path.
221fn which_cargo() -> Result<PathBuf, ToolError> {
222    // Check CARGO env var first (set by rustup/cargo itself)
223    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    // Fall back to PATH lookup
230    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
242/// Parses cargo JSON output lines and extracts human-readable diagnostics.
243///
244/// Each JSON line from `--message-format=json` that represents a `compiler-message`
245/// with a span is formatted as `file:line:col: level: message`.
246pub(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        // Use the primary span if available for location info
275        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    // --- parse_cargo_json unit tests ---
323
324    #[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        // No primary span → fall back to message-only format
383        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    // --- timeout tests ---
396
397    /// Regression test for #5433 critic finding S1: the cargo subprocess had no
398    /// timeout, violating the project's mandatory Await Discipline rule (every external
399    /// `.await` must be bounded) and letting a hostile/looping `build.rs` or proc-macro
400    /// hang the tool call indefinitely. An unrealistically small timeout (1ns) makes the
401    /// timeout branch deterministic regardless of how fast the real `cargo` spawn is.
402    #[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    // --- sandbox tests ---
422
423    #[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    // --- tilde expansion regression (#5415) ---
491
492    #[tokio::test]
493    async fn validate_path_expands_tilde_in_runtime_argument() {
494        // Regression for #5415: a `~`-prefixed workspace path coming from an LLM
495        // tool call must resolve to the real home directory, mirroring the fix
496        // for `FileExecutor::validate_path` in #5410.
497        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        // Non-regression: absolute paths without a leading `~` must still
519        // resolve exactly as before the tilde-expansion fix.
520        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    // CR-14: verify that level=clippy maps to "clippy" subcommand string
529    #[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}