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        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        // kill_on_drop ensures a hostile/looping build.rs or proc-macro is killed rather
140        // than leaked when the timeout below fires and drops this future.
141        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
203/// Returns the path to the `cargo` binary, failing gracefully if not found.
204///
205/// Reads the `CARGO` environment variable (set by rustup/cargo during builds) or
206/// falls back to a PATH search. The process environment is assumed trusted — this
207/// function runs in the same process as the agent, not in an untrusted context.
208/// Canonicalization is applied as defence-in-depth to resolve any symlinks in the path.
209fn which_cargo() -> Result<PathBuf, ToolError> {
210    // Check CARGO env var first (set by rustup/cargo itself)
211    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    // Fall back to PATH lookup
218    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
230/// Parses cargo JSON output lines and extracts human-readable diagnostics.
231///
232/// Each JSON line from `--message-format=json` that represents a `compiler-message`
233/// with a span is formatted as `file:line:col: level: message`.
234pub(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        // Use the primary span if available for location info
263        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    // --- parse_cargo_json unit tests ---
311
312    #[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        // No primary span → fall back to message-only format
371        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    // --- timeout tests ---
384
385    /// Regression test for #5433 critic finding S1: the cargo subprocess had no
386    /// timeout, violating the project's mandatory Await Discipline rule (every external
387    /// `.await` must be bounded) and letting a hostile/looping `build.rs` or proc-macro
388    /// hang the tool call indefinitely. An unrealistically small timeout (1ns) makes the
389    /// timeout branch deterministic regardless of how fast the real `cargo` spawn is.
390    #[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    // --- sandbox tests ---
410
411    #[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    // --- tilde expansion regression (#5415) ---
479
480    #[tokio::test]
481    async fn validate_path_expands_tilde_in_runtime_argument() {
482        // Regression for #5415: a `~`-prefixed workspace path coming from an LLM
483        // tool call must resolve to the real home directory, mirroring the fix
484        // for `FileExecutor::validate_path` in #5410.
485        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        // Non-regression: absolute paths without a leading `~` must still
507        // resolve exactly as before the tilde-expansion fix.
508        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    // CR-14: verify that level=clippy maps to "clippy" subcommand string
517    #[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}