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};
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/// Cargo diagnostics level.
15#[derive(Debug, Default, Deserialize, JsonSchema, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17#[non_exhaustive]
18pub enum DiagnosticsLevel {
19    /// Run `cargo check`
20    #[default]
21    Check,
22    /// Run `cargo clippy`
23    Clippy,
24}
25
26#[derive(Debug, Deserialize, JsonSchema)]
27struct DiagnosticsParams {
28    /// Workspace path (defaults to current directory)
29    path: Option<String>,
30    /// Diagnostics level: check or clippy
31    #[serde(default)]
32    level: DiagnosticsLevel,
33}
34
35/// Runs `cargo check` or `cargo clippy` and returns structured diagnostics.
36#[derive(Debug)]
37pub struct DiagnosticsExecutor {
38    allowed_paths: Vec<PathBuf>,
39    /// Maximum number of diagnostics to return (default: 50)
40    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
164/// Returns the path to the `cargo` binary, failing gracefully if not found.
165///
166/// Reads the `CARGO` environment variable (set by rustup/cargo during builds) or
167/// falls back to a PATH search. The process environment is assumed trusted — this
168/// function runs in the same process as the agent, not in an untrusted context.
169/// Canonicalization is applied as defence-in-depth to resolve any symlinks in the path.
170fn which_cargo() -> Result<PathBuf, ToolError> {
171    // Check CARGO env var first (set by rustup/cargo itself)
172    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    // Fall back to PATH lookup
179    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
191/// Parses cargo JSON output lines and extracts human-readable diagnostics.
192///
193/// Each JSON line from `--message-format=json` that represents a `compiler-message`
194/// with a span is formatted as `file:line:col: level: message`.
195pub(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        // Use the primary span if available for location info
224        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    // --- parse_cargo_json unit tests ---
270
271    #[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        // No primary span → fall back to message-only format
330        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    // --- sandbox tests ---
343
344    #[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    // CR-14: verify that level=clippy maps to "clippy" subcommand string
412    #[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}